@zackbart/connecta 0.32.0 → 0.33.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 (137) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +15 -7
  3. package/bin/connecta.mjs +24 -6
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.d.ts +167 -23
  6. package/dist/access-tokens.js +676 -140
  7. package/dist/agent-identity.js +47 -0
  8. package/dist/auth/cloudflare-access.js +13 -5
  9. package/dist/call-admission-context.js +15 -0
  10. package/dist/call-admission.js +30 -43
  11. package/dist/call-recovery.js +6 -2
  12. package/dist/config-value-sources.js +11 -9
  13. package/dist/config.d.ts +37 -75
  14. package/dist/config.js +110 -36
  15. package/dist/connector-access.d.ts +4 -34
  16. package/dist/connector-access.js +15 -99
  17. package/dist/connector-caller.d.ts +7 -1
  18. package/dist/connector-caller.js +3 -2
  19. package/dist/connectors/api-connector.d.ts +27 -4
  20. package/dist/connectors/api-connector.js +2 -0
  21. package/dist/connectors/remote-mcp.js +2 -0
  22. package/dist/describe-config.d.ts +29 -7
  23. package/dist/describe-config.js +7 -17
  24. package/dist/index.d.ts +4 -3
  25. package/dist/index.js +29 -10
  26. package/dist/invocation.js +5 -3
  27. package/dist/membership.d.ts +51 -0
  28. package/dist/membership.js +121 -0
  29. package/dist/module-contracts.d.ts +23 -6
  30. package/dist/operator-record.d.ts +2 -2
  31. package/dist/operator-record.js +2 -4
  32. package/dist/operator-ui/agents.d.ts +74 -0
  33. package/dist/operator-ui/agents.js +104 -0
  34. package/dist/operator-ui/contract.d.ts +149 -28
  35. package/dist/operator-ui/endpoints.js +192 -0
  36. package/dist/operator-ui/fix-prompts.js +4 -4
  37. package/dist/operator-ui/generated.js +5 -4
  38. package/dist/operator-ui/member-page.js +112 -0
  39. package/dist/operator-ui/model.d.ts +14 -6
  40. package/dist/operator-ui/view.d.ts +84 -19
  41. package/dist/operator-ui/view.js +82 -21
  42. package/dist/page-styles.js +1 -1
  43. package/dist/provider.d.ts +61 -8
  44. package/dist/provider.js +46 -7
  45. package/dist/providers/_shared/google/workspace.d.ts +53 -16
  46. package/dist/providers/_shared/google/workspace.js +30 -8
  47. package/dist/providers/_shared/rest/tools.js +2 -2
  48. package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
  49. package/dist/providers/_shared/rest/value-safety.js +699 -7
  50. package/dist/providers/ccb/index.js +1 -1
  51. package/dist/providers/cloudflare/index.js +3 -3
  52. package/dist/providers/cloudflare/rest.js +25 -3
  53. package/dist/providers/cloudflare/skill.generated.js +1 -1
  54. package/dist/providers/cloudflare/value-safety.js +32 -0
  55. package/dist/providers/docs/index.js +3 -3
  56. package/dist/providers/docs/skill.generated.js +2 -2
  57. package/dist/providers/drive/index.js +3 -3
  58. package/dist/providers/drive/skill.generated.js +2 -2
  59. package/dist/providers/forms/index.js +3 -3
  60. package/dist/providers/forms/skill.generated.js +2 -2
  61. package/dist/providers/gmail/index.js +10 -8
  62. package/dist/providers/gmail/skill.generated.js +2 -2
  63. package/dist/providers/mixpanel/index.js +1 -1
  64. package/dist/providers/notion/index.d.ts +7 -0
  65. package/dist/providers/notion/index.js +100 -11
  66. package/dist/providers/notion/rest.js +45 -5
  67. package/dist/providers/notion/skill.generated.js +2 -2
  68. package/dist/providers/notion/value-safety.js +30 -5
  69. package/dist/providers/overflow/index.js +1 -1
  70. package/dist/providers/planning-center/index.js +1 -1
  71. package/dist/providers/sheets/index.d.ts +3 -0
  72. package/dist/providers/sheets/index.js +6 -3
  73. package/dist/providers/sheets/skill.generated.js +2 -2
  74. package/dist/providers/slides/index.js +3 -3
  75. package/dist/providers/slides/skill.generated.js +2 -2
  76. package/dist/providers/stripe/rest.js +9 -2
  77. package/dist/providers/stripe/skill.generated.js +1 -1
  78. package/dist/providers/stripe/value-safety.js +22 -0
  79. package/dist/providers/vercel/rest.js +20 -2
  80. package/dist/providers/vercel/skill.generated.js +1 -1
  81. package/dist/providers/vercel/value-safety.js +22 -0
  82. package/dist/registry.d.ts +2 -3
  83. package/dist/registry.js +8 -9
  84. package/dist/routes/access-tokens.js +82 -75
  85. package/dist/routes/activity-disclosure.js +17 -18
  86. package/dist/routes/activity.js +34 -54
  87. package/dist/routes/actor-labels.js +33 -4
  88. package/dist/routes/agent-path.js +43 -0
  89. package/dist/routes/connect.js +16 -12
  90. package/dist/routes/credentials.js +16 -40
  91. package/dist/routes/mcp.js +145 -95
  92. package/dist/routes/oauth-management.js +11 -5
  93. package/dist/routes/oauth.js +3 -9
  94. package/dist/routes/operator.js +24 -23
  95. package/dist/routes/shared.d.ts +60 -20
  96. package/dist/routes/shared.js +187 -96
  97. package/dist/routes/ui-config.js +437 -97
  98. package/dist/routes/ui-data.js +2 -2
  99. package/dist/routes/ui.js +9 -16
  100. package/dist/runtime/call-admission.js +9 -0
  101. package/dist/server.js +5 -0
  102. package/dist/storage/keys.js +9 -1
  103. package/dist/types.d.ts +11 -0
  104. package/dist/ui.d.ts +1 -1
  105. package/dist/ui.js +16 -14
  106. package/dist/version.d.ts +1 -1
  107. package/dist/version.js +1 -1
  108. package/dist/workspace-address.js +9 -0
  109. package/dist/workspace-subject.js +23 -0
  110. package/documentation/README.md +46 -22
  111. package/documentation/architecture.md +355 -50
  112. package/documentation/auth.md +775 -243
  113. package/documentation/code-mode.md +14 -11
  114. package/documentation/deploying.md +121 -24
  115. package/documentation/integrating.md +92 -21
  116. package/documentation/meta-tools.md +14 -9
  117. package/documentation/migration-0.33.md +344 -0
  118. package/documentation/operating.md +3 -1
  119. package/documentation/operator-tests.md +115 -28
  120. package/documentation/operator-ui.md +326 -61
  121. package/documentation/provider-auth-migration.md +37 -23
  122. package/documentation/provider-migration-0.29.md +2 -2
  123. package/examples/worker/AGENTS.md +30 -8
  124. package/examples/worker/README.md +92 -53
  125. package/examples/worker/src/connecta.config.ts +36 -20
  126. package/examples/worker/wrangler.jsonc +3 -0
  127. package/package.json +3 -2
  128. package/templates/node/.env.example +3 -0
  129. package/templates/node/AGENTS.md +27 -6
  130. package/templates/node/Dockerfile +2 -2
  131. package/templates/node/README.md +67 -42
  132. package/templates/node/docker-compose.yml +3 -2
  133. package/templates/node/package.json +4 -3
  134. package/templates/node/src/adopt-token.ts +23 -0
  135. package/templates/node/src/connecta.config.ts +28 -12
  136. package/templates/node/src/rotate-token.ts +17 -0
  137. 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,154 @@ 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; follow [the pool layout](#organizing-an-agents-pools).
70
+
71
+ ## Connect a shared credential
72
+
73
+ Shared auth belongs to the deployment. Every admitted caller of that
74
+ connector uses the same downstream account. Only a signed-in interactive
75
+ maintainer can change it; an agent token cannot perform the setup.
76
+
77
+ 1. Configure the connector in `connectors` with `authScope: "shared"`
78
+ (the default) and either OAuth or a credential slot. Include it only in
79
+ the intended pools. For a user's account used by an agent, give it its
80
+ own id such as `notes_alice` and expose it only in `assistant-alice`.
81
+ 2. Enable the encrypted vault over the deployment's existing storage before
82
+ saving credentials or OAuth tokens. Keep its base64 32-byte key in secret
83
+ storage and preserve it across restarts. Node's template reads
84
+ `CONNECTA_CREDENTIAL_KEY`; Workers read `CREDENTIAL_ENCRYPTION_KEY`.
85
+ See the platform README's credential-vault setup. Enable `operatorUi()`
86
+ for credential-slot editing and human auth with a maintainer roster.
87
+ 3. Typecheck and restart/redeploy. Sign in as a maintainer, open the
88
+ connector's page, and use its Connection tab. For a credential slot, enter
89
+ and save the downstream token. For OAuth, connect and finish the browser
90
+ consent as the intended downstream account. An MCP maintainer can also
91
+ call `authorize_connector` for that id and follow its signed browser link;
92
+ that link grants no extra authority.
93
+ 4. Check connected status, then make a bounded read from the intended pool.
94
+ Check the downstream account, not just successful authentication. For
95
+ `notes_alice`, the maintainer must authorize Alice's intended account.
96
+
97
+ For example, this connector expects a Bearer token entered in the UI; the
98
+ secret never belongs in the configuration:
99
+
100
+ ```ts
101
+ remoteMcp("crm", {
102
+ url: "https://crm.example.com/mcp",
103
+ authScope: "shared",
104
+ auth: { type: "credential", credential: { label: "CRM API token" } },
105
+ });
106
+ // OAuth alternative: auth: { type: "oauth" }
107
+ ```
108
+
109
+ To rotate a saved token, save its replacement in the same slot; subsequent
110
+ calls use it. Revoke the retired token at the downstream service. For OAuth,
111
+ use Restart to authorize afresh, Continue to resume a pending consent, and
112
+ Disconnect to invalidate the local grant and catalog. To remove a credential,
113
+ use the connector's credential delete control. Clearing a slot with no saved
114
+ value returns 409 with `problem: "credential_required"`; it does not revoke
115
+ anything downstream. An unreadable stored value can still be cleared for
116
+ recovery. These changes do not change
117
+ pool membership. Environment-backed header auth has no editable vault slot:
118
+ rotate that secret in the deployment's secret store and restart/redeploy.
119
+
120
+ ## Issue, rotate, or revoke an agent token
121
+
122
+ Each declared agent has exactly one token slot. Keep `accessTokens(storage)`
123
+ on the same database as the server. Declare the agent and its pools in code
124
+ before issuing a token, for example:
125
+
126
+ ```ts
127
+ agents: ["assistant"],
128
+ accessTokens: accessTokens(storage),
129
+ pools: { assistant: { tools: ["crm", "tickets"], members: ["agent:assistant"] } },
130
+ ```
131
+
132
+ Typecheck and restart/redeploy, then use either procedure:
133
+
134
+ - **Signed-in maintainer:** with human auth and `operatorUi()` enabled, open
135
+ the Access tokens page, select `assistant`, and issue, rotate, or revoke
136
+ its token. Save an issued secret privately immediately; it appears once.
137
+ The UI reads the slot revision and sends it with the operation. If the slot
138
+ changed, reload and decide again.
139
+ - **Trusted local provisioning:** the Node template's
140
+ `npm run --silent rotate-token -- assistant` both issues and rotates.
141
+ Run it with the same environment and database path as the server; local
142
+ commands do not load `.env`. Store stdout privately. For revocation, add a
143
+ small trusted script such as the following to `src/revoke-token.ts` and
144
+ run `npx tsx src/revoke-token.ts` with that same environment. Workers use
145
+ the maintainer UI or trusted tooling bound to the same D1 database;
146
+ a local SQLite script cannot manage a Worker's slots.
147
+
148
+ ```ts
149
+ import { AccessTokenManager } from "@zackbart/connecta/auth/access-tokens";
150
+ import config from "./connecta.config.js";
151
+
152
+ const { storage, agents } = config(process.env);
153
+ const manager = new AccessTokenManager(storage!, { agents: agents ?? [] });
154
+ const slot = (await manager.list()).find((entry) => entry.name === "assistant");
155
+ if (!slot) throw new Error("Declare assistant before managing its token");
156
+ await manager.revoke("assistant", { expect: slot.revision });
157
+ ```
158
+
159
+ Set the client URL to `https://connecta.example.com/agent/assistant` and
160
+ `Authorization: Bearer <saved-cta-token>`. Its one token also works on every
161
+ personal pool listing `agent:assistant`; the client picks the pool per turn.
162
+ Behind Access, configure the [agent bypass](./deploying.md#agent-endpoints-behind-cloudflare-access).
163
+ Run [doctor](./deploying.md#verify-and-diagnose) with the new token. After a
164
+ rotation, replace the client's saved secret immediately: the old token stops
165
+ admitting on its next request. After revocation, confirm the old token gets 401. Already admitted requests are not recalled. Never put tokens in source,
166
+ activity, or committed output, and never use `/mcp` with a `cta_` token.
167
+
168
+ If a rotation reports `outcome_unknown`, inspect the slot; an issued secret
169
+ that was not delivered cannot be recovered. Rotate again deliberately for a
170
+ replacement. For older tokens, follow [adoption](#agent-tokens-and-upgrading-from-earlier-tokens)
171
+ instead of assuming they still admit after an upgrade.
172
+
24
173
  ## Credential recognition and admission
25
174
 
26
175
  Inbound auth decides who may reach the MCP endpoint. People use `clerkAuth`
@@ -52,84 +201,236 @@ The `cta_` syntax is reserved even without the optional token verifier: it
52
201
  refuses rather than becoming an ambient human or an open anonymous request.
53
202
  Async recognition hooks fail at construction; unexpected rejected promises
54
203
  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
204
+ Agents reach pools only at [agent endpoints](#agent-endpoints). Every human
205
+ route (`/mcp`, `/mcp/<pool>`, operator pages and APIs, `/connect`, OAuth
206
+ starts and callbacks) refuses a `cta_` token before any recognition hook,
207
+ token storage read, cookie, session, or Access context is consulted: 401 with
208
+ the endpoint's challenge on MCP endpoints and operator data routes, and 403 on
209
+ routes that require an interactive person. The built-in `cta_` adapter is
210
+ never a candidate there, so a refused `cta_` token cannot fall back to an
211
+ ambient identity. Human routes then ask only human providers. Replace custom `final` and
57
212
  `finalRefusals` handling with recognition; handshake redirects and admission
58
213
  policy refusals are provider verdicts too. Recognition grants no identity.
59
214
 
60
- `authorize` returns `{ ok: true, userId?, subjectId?, principal? }` or a refusal
215
+ `authorize` returns `{ ok: true, userId?, subjectId?, principal?, agent? }` or a refusal
61
216
  carrying a `Response`. On 401, routing and challenge selection use the first
62
217
  provider that actually answers the endpoint's protected-resource metadata
63
218
  request. Its `challenge(request, baseUrl)` supplies `WWW-Authenticate`, including
64
219
  `scope`; an unrelated metadata hook does not own resource discovery. Machine-only
65
220
  endpoints challenge with `Bearer` and advertise no authorization server. With
66
- Clerk, even a refused `cta_` token receives Clerk's resource metadata challenge.
221
+ Clerk, a `cta_` token refused at `/mcp` or `/mcp/<pool>` still receives Clerk's
222
+ resource metadata challenge.
67
223
  An open deployment with connectors warns at construction.
68
224
 
69
225
  Clerk denial logs carry checked fixed reason codes. Provider user IDs, email
70
226
  addresses, and email domains stay out of denial logs, activity, and status,
71
227
  including email lookup failures and admission policy refusals.
72
228
 
73
- ## Managed client tokens and upgrading from v0.23
229
+ ## Agent tokens and upgrading from earlier tokens
74
230
 
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.
231
+ Machine clients are **agents**, declared by name in code. `agents: string[]`
232
+ lists them: each name matches `^[a-z0-9_-]+$`, at most 64 characters, and is
233
+ unique; anything else, or agents without `accessTokens`, throws at
234
+ construction. `describeConfig()` lists the names and never token state.
235
+ `accessTokens` from `@zackbart/connecta/auth/access-tokens` installs the `cta_`
236
+ inbound adapter and, beside `ui`, the Access tokens page and `/ui/access-tokens`
237
+ routes. Omitted, none of it loads or is served. It takes no options;
238
+ `accessTokens(storage, { maxActive })` throws, since each declared agent has
239
+ exactly one token slot.
78
240
 
79
241
  ```ts
80
242
  import { accessTokens } from "@zackbart/connecta/auth/access-tokens";
81
243
  createConnecta({
82
244
  storage,
245
+ agents: ["assistant", "ci"],
83
246
  accessTokens: accessTokens(storage),
84
247
  auth: clerkAuth({ publishableKey, secretKey }),
85
248
  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
- },
249
+ maintainers: ["your-operator-id"],
250
+ pools: { ci: { tools: ["github"], members: ["agent:ci"] } },
92
251
  connectors, executor,
93
252
  readOnly: false, // required: true refuses every write call
94
253
  });
95
254
  ```
96
255
 
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.
256
+ **Slots.** Each declared agent has one slot: `none`, `issued`, or `revoked`.
257
+ Nothing outside code adds, renames, or rebinds an agent. An interactive
258
+ maintainer issues the first token, rotates it, or revokes it; `AccessTokenManager` exposes
259
+ the same `rotate(name)` and `revoke(name)` to trusted provisioning, and the
260
+ Node template wraps rotation as `npm run --silent rotate-token -- <agent>`.
261
+ Secrets carry 256 random bits and are returned once, on issue or rotate; only
262
+ SHA-256 digests persist. Listing returns `{ name, state, issuedAt?, revokedAt?,
263
+ revision }` per declared agent, never a secret, digest, or prefix.
264
+
265
+ **Revisions and `expect`.** A slot's `revision` is opaque: 22 base64url
266
+ characters, the first 128 bits of a domain-separated SHA-256 of the agent name
267
+ and the raw stored slot (`none` hashes the absent slot). Every commit stores a
268
+ new slot value, since an issued slot names a fresh record id and a revoked one
269
+ carries a random `revocationId` generated once per revoking call, so the
270
+ revision changes on every issue, rotate, revoke, and adopt and on nothing
271
+ else. The stored slot holds a record id or revocation id and times, never a
272
+ secret or digest, and those ids leave only hashed. The token
273
+ routes return it on every slot: `GET /ui/access-tokens` lists it, and issue,
274
+ rotate, and revoke return the new slot's. Operator contract v2's
275
+ `OperatorAgent.token.revision` carries the same value.
276
+
277
+ The token routes require the revision the caller decided from:
278
+
279
+ | Route | `expect` |
280
+ | ------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
281
+ | `POST /ui/access-tokens/<agent>` (issue, rotate) | JSON body `{ "expect": "<revision>" }`, `Content-Type: application/json` |
282
+ | `DELETE /ui/access-tokens/<agent>` (revoke) | the same body, or `?expect=<revision>` for a client that cannot send a body with DELETE; not both |
283
+
284
+ A missing or malformed `expect` is 400; a body that is not JSON is 415 and one
285
+ over 1,024 characters is 413. They are checked after authorization and before
286
+ the operation. If the slot's current revision differs, the route answers 409
287
+ `{ "error": "…", "code": "slot_changed" }` before minting a secret or writing
288
+ anything; reload the list and decide again. The check is part of the commit,
289
+ not only the read: the slot's compare-and-set compares against exactly the raw
290
+ slot whose revision matched, so a change that lands between the read and the
291
+ commit refuses with `slot_changed` too, and this call publishes nothing. A
292
+ revocation under `expect` never revokes a token issued after its read. A call
293
+ decides that its own write landed only by an id no other call can produce: a
294
+ rotation or adoption by the fresh record id its slot names, a revocation by its
295
+ `revocationId`. Under `expect`, a compare-and-set that answers "not committed"
296
+ is `slot_changed` at once, so of two conditional revocations of one slot,
297
+ even within one millisecond, exactly one succeeds; an unanswered one is read
298
+ back and succeeds only if the slot carries its own `revocationId`. Issuing
299
+ from a `none`-era revision over a slot that now exists, or rotating over
300
+ someone else's fresh rotation, therefore cannot happen. `aborted` (499) and
301
+ `outcome_unknown` (503) are unchanged. `AccessTokenManager.rotate(name,
302
+ issuedBy, { expect })`, `revoke(name, { expect })`, and `adopt(tokenId, name,
303
+ { expect })` take the same optional precondition and refuse with an
304
+ `AccessTokenError` whose `code` is `slot_changed`; without `expect` they behave
305
+ as before, which is what the template scripts use.
306
+
307
+ **Admission.** A `cta_` token admits only while its record is bound to an agent
308
+ currently declared in code and that agent's slot names it. Every other case
309
+ refuses with the same 401 as an unknown token: an undeclared agent, a revoked or
310
+ rotated-out token, a token bound to a human principal, and an unbound token from
311
+ an earlier release. Removing an agent from code stops its token on the next
312
+ request without any storage write. An admitted agent's identity is:
313
+
314
+ ```ts
315
+ {
316
+ actor: { kind: "access_token", id: "<name>", namespace: "connecta:access-tokens:v1" },
317
+ subject: { namespace: "connecta:access-tokens:v1", id: "agent:<name>" },
318
+ agent: "<name>",
319
+ interactive: false,
320
+ }
321
+ ```
322
+
323
+ `identity.agent` is set only by the built-in adapter; a result from any other
324
+ inbound provider that carries `agent` is refused like an unknown token. An
325
+ agent has no principal, so personal connectors are absent for it and it never
326
+ reads activity. Its subject partitions result handles per
327
+ agent. Activity attributes its calls to the agent name. Pools list an agent as
328
+ `"agent:<name>"`, and the agent reaches each such pool at `/agent/<pool>`
329
+ only. An agent is never a maintainer, and every human endpoint refuses its
330
+ token. An agent never becomes an interactive operator: machine
331
+ tokens manage neither tokens nor connection credentials.
332
+
333
+ **The slot is the only authority.** A token admits iff its digest's lookup
334
+ names a record, that record carries a currently declared `agent`, and that
335
+ agent's slot is `issued` naming exactly that record. Nothing else counts: a
336
+ lookup or record the slot does not name is inert garbage. Every mutation
337
+ commits by one `compareAndSet` on the slot and only moves forward; no security
338
+ outcome depends on cleanup or on undoing a write.
339
+
340
+ - **Rotate** writes the new record and its lookup (inert), checks the
341
+ request's signal, then compare-and-sets the slot from what it read to
342
+ `issued` with the new record. That is the commit: from it the new secret
343
+ admits and the previous one refuses, so after `rotate` returns the old secret
344
+ fails on its next request. Of concurrent rotations exactly one commits and
345
+ returns a secret; the others publish nothing and fail with `slot_changed`
346
+ under `expect` (always, on the route) or a conflict without it. An unanswered commit is read back, never assumed or replayed.
347
+ If the request has ended once the commit is decided, the secret cannot be
348
+ delivered, so the slot moves forward to `revoked` by compare-and-set; if that
349
+ cannot be settled, the call fails with `outcome_unknown` (503 on the
350
+ route), never success. After an `outcome_unknown` rotation the operator UI
351
+ may show the slot as `issued` although no secret was returned; that secret
352
+ cannot be recovered, so rotate again to get a replacement.
353
+ - **Revoke** reads the slot, checks the signal, and compare-and-sets it to
354
+ `revoked`. An abort before that commit changes nothing. Without `expect`, a
355
+ slot that changed before the commit is revoked as it now stands; with it,
356
+ the revocation refuses with `slot_changed`.
357
+ - **Nothing is deleted on the request path.** Rotate, revoke, and adopt never
358
+ delete or compare-and-set away a lookup or record, except the record and
359
+ lookup the same call just wrote while they are known to be uncommitted, each
360
+ removed by one conditional delete. Previous, revoked, and superseded records
361
+ and lookups stay behind, inert, because no slot names them. Storage therefore
362
+ grows by one small record (and, for rotation, one lookup) per rotation or
363
+ adoption, bounded by the number of agents times their rotations. An operator
364
+ maintenance sweep for this inert data is follow-up work.
365
+ - **One operation deadline.** One wall-clock deadline is set when rotate,
366
+ revoke, or adopt starts: 2 seconds, or the request's own `deadline` option if
367
+ sooner. It bounds every storage await in the operation: the adoption claim
368
+ and its read-back, record and lookup writes before the commit, the commit's
369
+ own acknowledgement, and everything after it (reading back an unanswered
370
+ commit, revoking an undeliverable rotation, completing an adoption's lookup
371
+ step). Each storage call is raced against it and none starts after it. A
372
+ timeout before the commit reports `aborted` if the caller has left, since
373
+ nothing was published, and `outcome_unknown` otherwise; a pending claim
374
+ acknowledgement is always `outcome_unknown`, and the claim, if it landed,
375
+ leaves the token resumable by the same agent. An acknowledgement still
376
+ pending at the deadline is an unanswered commit with no time left to read it
377
+ back, and any success decided after the deadline is reported as
378
+ `outcome_unknown` instead. Records written before an uncertain commit stay.
379
+ An operation still in flight at the deadline is abandoned rather than
380
+ cancelled, which is safe: it can only be a read; a write of the call's own
381
+ record or lookup, inert until a slot names it; the claim, inert to admission;
382
+ the commit itself, whose outcome is then reported as unknown and whose slot
383
+ alone decides what admits; the revoke compare-and-set from `issued` naming
384
+ one record to `revoked`, which can only stop a token and fails if the slot
385
+ changed; or the adoption compare-and-set of a lookup from the legacy record to
386
+ the new one, which fails if anyone else moved it. If an abandoned revoke never lands, the
387
+ undelivered rotation secret may keep admitting, but it never left the server:
388
+ a liveness problem reported as `outcome_unknown`, never a credential exposure.
389
+
390
+ The routes pass the request's `AbortSignal` to `rotate`, `revoke`, and `adopt`
391
+ (`{ signal }`); a signal already aborted performs no storage call at all. Authorization caches no token, and D1 and SQLite are strongly
392
+ consistent; admitted requests are not recalled. Management writes require an
393
+ exact same-origin Origin; responses are private and non-cacheable.
394
+
395
+ **Upgrading.** Keep `accessTokens(storage)` on **the same storage and key
396
+ namespace**. The `access-token:v1:record:*` and `access-token:v1:lookup:*`
397
+ records stay readable, and slots are added beside them as
398
+ `access-token:v1:agent:<name>`. A stored token stops admitting until an agent
399
+ adopts it. Trusted provisioning runs `AccessTokenManager.adopt(tokenId,
400
+ agentName)`, which never changes the legacy record. It first sets a write-once
401
+ claim, `access-token:v1:adopted:<legacy id>`, from absent to the agent's name;
402
+ the claim is never deleted, so once any adoption has started the legacy token
403
+ is consumed for that agent forever, even after the agent's token is revoked or
404
+ rotated away, and a retired secret can never be adopted again. The claim is
405
+ inert to admission: an adoption aborted after it leaves the token adoptable
406
+ only by the same agent. It then writes a new record
407
+ carrying the agent and the legacy digest, checks the signal, commits by
408
+ compare-and-setting the agent's slot from none to `issued` with that record,
409
+ then moves the digest's lookup from the legacy record to the new one by
410
+ compare-and-set, from which the machine's existing `cta_` secret admits as the
411
+ agent. Adopt delivers no secret, so once committed it completes even if the
412
+ request ended. Running the same adoption again, when the slot already names a
413
+ record with this digest, completes the lookup step and succeeds; there is no
414
+ release step. If the lookup step cannot be confirmed the call says so and asks
415
+ for a rerun. Adoption refuses a human-bound, revoked, or already adopted token,
416
+ a token claimed by another agent, a token another agent's slot already carries,
417
+ and an agent whose slot names anything else. Of two agents racing for one
418
+ token, the claim admits one; the other is refused before its commit.
419
+ `unboundTokens()` lists the legacy records adopt accepts: unbound, unrevoked,
420
+ never claimed, still owning their digest's lookup, and carried by no declared
421
+ agent's slot. The Node template wraps both as
422
+ `npm run adopt-token`.
423
+ Human-bound tokens, which earlier UI issuance tied to the issuing operator,
424
+ cannot be adopted and stay refused; issue the agent a new token instead. Rows
425
+ recorded before the upgrade keep their token-record actor ids. Grant callbacks
426
+ that matched token record ids with `actor.id` match agent names through
427
+ `identity.agent` instead.
128
428
 
129
429
  ## Origins
130
430
 
131
431
  `allowedOrigins?: readonly string[] | "*"` bounds which browsers may speak to
132
- the MCP endpoint. A disallowed `Origin` on `/mcp` or any `/mcp/<pool>` gets a
432
+ the MCP endpoint. A disallowed `Origin` on `/mcp`, any `/mcp/<pool>`, or any
433
+ path in the agent namespace gets a
133
434
  fixed 403 before HTTPS redirects, admission, auth, and CORS preflight — the
134
435
  check cannot depend on anything a caller has yet proved. The default admits the
135
436
  `publicUrl` origin plus HTTP(S) loopback at any port; a supplied list replaces
@@ -143,7 +444,7 @@ silently never matching. A permitted origin still has to authenticate.
143
444
  One authorization yields three roles: the **actor** identifies the caller in
144
445
  activity, the **subject** owns transient results such as `connecta.result` pages,
145
446
  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
447
+ 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
448
  `principal: { namespace, id }` from `authorize`, accepted whenever it
148
449
  validates, or else one derived from `userId` and the provider's
149
450
  `activityActorNamespace`, so only the derived path needs a namespace.
@@ -160,129 +461,143 @@ Its `subject` option, a deployment-config function from the admitted identity
160
461
  to a downstream account (a Clerk principal id to a Workspace address, say) or
161
462
  one fixed address, is applied once per call and only to a request an inbound
162
463
  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
464
+ deployment's anonymous admission. An agent never reaches the function; it acts
465
+ as its pool's `owner` ([Pool owners](#pool-owners-for-google-workspace)).
466
+ Otherwise, or on `undefined`, the call fails
164
467
  `auth_required` before any request leaves. Arguments, headers, and programs
165
468
  never reach it; a deployment's own connectors still cannot read the caller.
166
469
 
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,
470
+ Access is read from one place, deployment code. Top-level
471
+ `maintainers` lists the people who reach plain `/mcp`, which serves the whole
472
+ catalog with the deployment's `readOnly`, and the operator routes. Each pool's
473
+ `members` lists who reaches that pool, people at `/mcp/<pool>` and agents at
474
+ `/agent/<pool>`, which serve exactly that pool's `tools`. A maintainer opens no pool implicitly; list them as a member to use
475
+ one. Nothing intersects these lists with anything else, and a request never
476
+ names its own scope.
477
+
478
+ A member reference is one of:
479
+
480
+ - a bare principal id, `"user_2abc"`, resolved in the namespace of the
481
+ deployment's one interactive provider (its `activityActorNamespace`: Clerk's
482
+ Frontend API URL, or `"cloudflare-access"`);
483
+ - `{ namespace, id }`, required when a deployment has zero or several
484
+ interactive providers, or when the id contains `:`;
485
+ - `"agent:<name>"`, naming an agent declared in `agents` (pool `members` only).
486
+
487
+ A person matches when the admitted principal equals the reference exactly; an
488
+ agent matches by `identity.agent`, which only the built-in token adapter sets.
489
+ Construction throws on a bare id without exactly one interactive
490
+ provider, a bare id containing `:`, an undeclared agent, a repeated reference,
491
+ an empty or missing pool `members`, an agent in `maintainers`, `maintainers`
492
+ missing or empty when a provider that admits people is configured (any
493
+ provider other than the built-in agent-token adapter), and a non-empty
494
+ `maintainers` on a deployment with no such provider. With agents alone
495
+ (`accessTokens` as the only inbound auth), `maintainers` stays empty, nobody
496
+ can be a maintainer, and plain `/mcp` and every operator API admit nobody; the
497
+ agents reach only `/agent/<pool>` for the pools that list them. With no inbound auth at all, the
498
+ deployment is open (see [pools](#pools)). `describeConfig()` lists
499
+ `maintainers` as `{ namespace, id }` and each pool's resolved `members`.
500
+
501
+ Pool tools are enforced in the scoped registry view, below the catalog service,
179
502
  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
503
+ `connecta.search`, `connecta.describe`, and `connecta.call` read one filtered
504
+ list. Connector-level discovery, guides, and `authorize_connector` keep a
505
+ connector when any of its tools is in the pool — `docs.read` permits the
506
+ `docs` handoff. Without one it is absent: discovery omits it, and
507
+ `authorize_connector` returns the same "Unknown connector" refusal as for a
508
+ connector that does not exist. A tool outside the pool fails exactly like one
186
509
  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.
510
+ the whole security claim, in one place on purpose.
191
511
 
192
- ### Team read sets
512
+ `identity.connectorAccess`, including guarded
513
+ `{ tool, requireReadOnly: true }` grants, the top-level `grant`, and pool
514
+ `grant` are removed; each fails construction with a message naming its
515
+ replacement. A guarded read grant becomes a pool with `readOnly: true`.
193
516
 
194
- Keep team membership in deployment code and review exact addresses:
517
+ ### Team pools
518
+
519
+ Keep membership in deployment code; static lists can be built with ordinary
520
+ code at construction:
195
521
 
196
522
  ```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;
523
+ const engineering = ["engineer-id"];
524
+ const marketing = ["marketer-id"];
201
525
  createConnecta({
202
526
  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
- },
527
+ maintainers: ["owner-id"],
528
+ pools: {
529
+ engineering: { tools: ["issues", "campaigns.list"], members: engineering },
530
+ marketing: { tools: ["campaigns", "issues.search"], members: marketing },
531
+ reporting: { tools: ["issues", "campaigns"], readOnly: true, members: [...engineering, ...marketing] },
212
532
  },
213
533
  connectors: [issues, campaigns, billing], executor,
214
534
  readOnly: false, // required: true refuses every write call
215
535
  });
216
536
  ```
217
537
 
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
538
+ Engineering's endpoint serves every `issues` tool and only `campaigns.list`;
539
+ Marketing's every `campaigns` tool and only `issues.search`. Neither pool
540
+ serves `billing`; only the owner's plain `/mcp` does. A person in both pools
541
+ uses two endpoints, never a union.
542
+
543
+ | Catalog change | Exact `connector.tool` in a pool |
544
+ | ----------------------- | ----------------------------------------------------------------------- |
545
+ | New name | Excluded until reviewed and added |
546
+ | Removed or renamed name | Unreachable; warned when the scoped view reads the catalog |
547
+ | Stored write verdict | Still in the pool; `call_tool` refuses writes and write paths take over |
548
+ | Schema change alone | Does not revoke, unless it breaks a reviewed digest: then it is a write |
549
+
550
+ Names imply no safety. Review schemas, annotations, and downstream behavior
233
551
  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.
552
+ upgrades; fresh cached facts are classified against the current review on
553
+ every read; downstreams can change behavior while keeping read-only
554
+ annotations. For `api()`, the author owns declarations and handlers. Connecta
555
+ enforces loaded declarations, not absence of side effects. Restricted
556
+ downstream credentials do not replace pools; a `readOnly` pool refuses every
557
+ write whatever the catalog says.
238
558
 
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
559
  Failed remote loads are errors, not empty catalogs. A transient first-page tool
242
560
  listing failure may serve complete facts in the same auth partition for five
243
561
  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.
562
+ use this fallback. The expired deadline cannot authorize auth recovery.
563
+ Personal OAuth ownership and credential administration are separate from
564
+ endpoint access; writes follow the endpoint's `readOnly` setting and client
565
+ approval.
246
566
 
247
567
  ## Migrating static bearer clients
248
568
 
249
569
  The `/auth/bearer` export, `bearerToken`, and its `assertedPrincipal` header
250
570
  mode are removed. Existing static secrets cannot be converted into `cta_`
251
- secrets. Use a separate stored token for each machine or human owner:
571
+ secrets. Declare each machine as an agent instead:
252
572
 
253
573
  1. Configure the existing D1 or SQLite store as `storage`, and install
254
574
  `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.
575
+ 2. Add the machine's name to `agents`. Issue its token from the Access tokens
576
+ page as a signed-in maintainer, or from trusted
577
+ deployment code with
578
+ `new AccessTokenManager(storage, { agents }).rotate("machine-name", "deployment-provisioning")`.
579
+ The Node template provides `npm run --silent rotate-token -- machine-name`.
580
+ 3. List `"agent:machine-name"` in the `members` of each pool it uses. Agents
581
+ are never maintainers, so a machine that used plain `/mcp` moves to a pool
582
+ holding its tools.
583
+ 4. Save the once-returned secret in the client's secret store and point the
584
+ client at `/agent/<pool>` with `Authorization: Bearer cta_…`. Behind Worker
585
+ Access, add the [agent bypass application](./deploying.md#agent-endpoints-behind-cloudflare-access)
586
+ so that token is the only credential; an Access-only machine no longer admits.
266
587
  5. Verify MCP initialization and the intended grants. Confirm the old static
267
588
  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.
589
+ Remove `X-Connecta-Principal`. An agent has no principal, so a client that
590
+ acted for a human with personal connectors has no token replacement; that
591
+ human signs in instead.
592
+
593
+ Rotation and revocation act on the next request. Keep token text out of logs
594
+ and reports.
279
595
 
280
596
  ## Pools
281
597
 
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
598
+ A pool is a named slice of the deployment served to its members: people at
599
+ `/mcp/<pool>` and agents at `/agent/<pool>`. A support team sees three Notion
600
+ tools and Linear, a calendar bot sees one tool, both over the same credentials. Private remote catalog
286
601
  cache entries remain partitioned by the admitted pool.
287
602
 
288
603
  ```ts
@@ -291,56 +606,87 @@ createConnecta({
291
606
  accessTokens: accessTokens(storage),
292
607
  auth: clerkAuth({ publishableKey, secretKey }),
293
608
  readOnly: false, // required; pools without their own readOnly inherit it
294
- grant: ({ actor }) => actor.kind !== "access_token", // machines use their pools
609
+ maintainers: ["user_owner"],
610
+ agents: ["calendar-bot"],
295
611
  pools: {
296
- support: {
297
- 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 ?? ""),
308
- },
612
+ support: { tools: ["linear", "notion.search_pages", "notion.fetch_page"], members: supportTeam },
613
+ calendar_bot: { tools: ["calendar.create_event"], members: ["agent:calendar-bot"] },
614
+ reporting: { tools: ["linear", "notion"], readOnly: true, members: analysts },
309
615
  },
310
- identity: { connectorAccess },
311
616
  connectors, executor,
312
617
  });
313
618
  ```
314
619
 
315
620
  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.
621
+ identity. It keeps plain `/mcp` with the whole catalog, as the construction
622
+ warning says, every pool path answers the 404 because nobody can be a member,
623
+ and every agent endpoint answers the machine-only 401 because no agent can be
624
+ admitted.
625
+
626
+ | Rule | Contract |
627
+ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
628
+ | 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. |
629
+ | 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. |
630
+ | Agents | An agent member reaches its pools only at `/agent/<pool>`, with its `cta_` token. Human endpoints refuse `cta_`; see [agent endpoints](#agent-endpoints). |
631
+ | 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 retired `trust` or `grant`, and addresses absent from an `api()` static catalog throw. Remote catalogs are checked at lazy load. |
632
+ | 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. |
633
+ | Drift | No wildcards; pool tools are exact names. Missing live tools are unreachable and warned once while in a bounded 1,024-entry FIFO. |
634
+
635
+ ### Agent endpoints
636
+
637
+ `/agent/<pool>` is where agents use a pool. It exists for deployments behind
638
+ Cloudflare Access, which [bypasses `/agent/*`](./deploying.md#agent-endpoints-behind-cloudflare-access)
639
+ at the edge so an agent sends one credential, and it behaves the same on Node.
640
+
641
+ - **Path.** Only a raw request pathname byte-equal to `/agent/` plus a
642
+ well-formed pool name (`[a-z0-9_-]+`) is an agent endpoint. Connecta routes
643
+ the agent namespace before every other route: any path that starts with
644
+ `/agent` once escapes are decoded, case is folded, and slashes are
645
+ collapsed (`/agent`, `/agent/`, `/agent/x/`, `/agent/x/y`, `/AGENT/x`,
646
+ `/%61gent/x`, `//agent/x`, `/agent/%2f..`, `/agentx`) answers the pool 404
647
+ before any admission or auth, so a path Access bypasses never reaches a
648
+ human route. The query string is not part of the match.
649
+ - **Auth.** Only the built-in `cta_` adapter is consulted. Clerk and Access
650
+ adapters never run; `ctx.access`, which Access attaches even to a bypassed
651
+ path, is never read or passed on; cookies, Clerk handshake parameters,
652
+ `Cf-Access-Jwt-Assertion`, and Access service headers are ignored. A
653
+ missing, malformed, unknown, revoked, or non-`cta_` credential gets 401 with
654
+ `WWW-Authenticate: Bearer` and no `resource_metadata`.
655
+ - **Membership.** An admitted agent must be listed in that pool's `members`.
656
+ A non-member agent, a pool that lists no agents, and an undeclared but
657
+ well-formed pool name get the identical pool 404 after auth, logged as
658
+ `not_pool_member` or `pool_not_declared`. Unauthenticated callers get the
659
+ same 401 for declared and undeclared names, so pool names are not
660
+ anonymously enumerable.
661
+ - **Discovery.** Agent endpoints publish no protected-resource metadata:
662
+ `/.well-known/oauth-protected-resource/agent/<pool>` is the ordinary 404,
663
+ declared or not. Agents hold a provisioned token and never run OAuth
664
+ discovery, so there is no authorization server to name and no resource URL
665
+ worth advertising.
666
+ - **Handles.** Result handles are bound to `/agent/<pool>`, so one minted
667
+ there pages nowhere else, including `/mcp/<pool>` and another agent pool.
327
668
 
328
669
  Protected-resource metadata stays public at both default aliases,
329
670
  `/.well-known/oauth-protected-resource` and
330
671
  `/.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.
672
+ Discovery does not authenticate callers or evaluate membership, so fresh
673
+ OAuth clients can discover how to authenticate. Admission applies after auth
674
+ to MCP requests and result/page handles. Handles remain bound to their
675
+ originating endpoint; paging re-admits the caller at that endpoint. A pool
676
+ cannot read a `/mcp` handle, and `/mcp` cannot read a pool's handle.
677
+
678
+ Operator catalog (`/ui/data`, `/ui/connectors/<id>`), activity, shared
679
+ connector auth, and access-token routes require a maintainer after their own
680
+ authentication. Anyone else gets a private JSON 404 `{ "error": "not found" }`.
681
+ `/ui/api/config` serves a maintainer the whole operator contract and a person
682
+ listed in a pool's `members` only their own view: their pools without owner or
683
+ members, and the connectors in them with the tools those pools grant, their
684
+ own connection on personal connectors, and what they may do about it. It
685
+ carries no shared credential state, agents, other members, owners, or
686
+ activity. Anyone else gets the same 404, and an agent token the machine-only
687
+ 401 every human route gives it; see the
688
+ [operator contract](./operator-ui.md#operator-data-contract). The public HTML
689
+ shell carries no catalog data.
344
690
 
345
691
  Each endpoint is writable or read-only. The deployment's `readOnly` sets the
346
692
  plain `/mcp` endpoint and is required: a deployment that omits it fails at
@@ -355,19 +701,172 @@ through `call_destructive_tool`, still run.
355
701
  Guides are documentation, not discovery: provider `SKILL.md` and usage guides,
356
702
  surfaced through `queryAnalysis.guideSummary`, `skills`, `connecta.skill`, and
357
703
  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. |
704
+ read-only endpoint refuses. Resource reads and pool tools are the same on
705
+ both. The removed `trust` option fails construction with a message naming
706
+ `readOnly`.
707
+
708
+ ### Pool owners for Google Workspace
709
+
710
+ An agent has no Workspace account. On a Workspace connector (`gmail()`,
711
+ `drive()`, `sheets()`, `slides()`, `forms()`, `docs()`) whose `subject` is a
712
+ mapping function, the caller decides whose account a call opens:
713
+
714
+ | Caller | Acts as |
715
+ | ----------------------------- | ------------------------------------------------------------------------------- |
716
+ | A person, in any pool | Their own account, through the `subject` mapping. A pool `owner` never applies. |
717
+ | An agent in a pool | The pool's `owner`, resolved before the mapping, which never sees the agent. |
718
+ | An agent, pool has no `owner` | Nothing: the call fails `auth_required` before any request leaves. |
719
+
720
+ `owner` is a Workspace email address, checked at construction and listed in
721
+ `describeConfig()` (`null` when unset). Over HTTP, `/ui/api/config` serves it
722
+ only to an authenticated maintainer; anyone else, a pool member and an open
723
+ deployment's anonymous caller included, reads `null`, the same as an unset
724
+ owner. Core copies it from the pool the
725
+ request was admitted through; no argument, header, or program can choose or
726
+ change it, and the `auth_required` message never names an address. One
727
+ `gmail()` then serves a pool per person:
728
+
729
+ ```ts
730
+ const team = [
731
+ { id: "alice", email: "alice@example.com" },
732
+ { id: "bob", email: "bob@example.com" },
733
+ ];
734
+ createConnecta({
735
+ auth: clerkAuth({ publishableKey, secretKey }),
736
+ storage,
737
+ accessTokens: accessTokens(storage),
738
+ readOnly: false,
739
+ maintainers: ["alice"],
740
+ agents: ["assistant"],
741
+ pools: Object.fromEntries(
742
+ team.map((person) => [
743
+ `assistant-${person.id}`,
744
+ { tools: ["gmail", "drive"], owner: person.email, members: [person.id, "agent:assistant"] },
745
+ ]),
746
+ ),
747
+ connectors: [
748
+ gmail("gmail", { purpose, serviceAccount, subject: (who) => emailOf(who.principal?.id) }),
749
+ drive("drive", { purpose, serviceAccount, subject: (who) => emailOf(who.principal?.id) }),
750
+ ],
751
+ executor,
752
+ });
753
+ ```
754
+
755
+ The agent harness, not the model, picks the pool for each turn, which is what
756
+ keeps one person's mailbox from another's. Adding a person to someone else's
757
+ pool opens nothing of the owner's Workspace account to them, since people act
758
+ as themselves through the mapping. That holds only for Workspace mappings:
759
+ every other connector in the pool, including a shared-auth connector holding
760
+ the owner's own account, runs the same for every member
761
+ ([members of a personal pool](#organizing-an-agents-pools)).
762
+
763
+ A fixed `subject` address is the explicit shared-account exception: every
764
+ caller, person or agent, acts as it in every pool that lists the connector,
765
+ and pool owners do not apply. Keep fixed-subject connectors out of pools whose
766
+ members must not share that account.
767
+
768
+ ### Organizing an agent's pools
769
+
770
+ Use this layout for every agent's pools. It gives each person's own accounts
771
+ to the agent only when that person is the one talking, without a second
772
+ connection. This section holds the complete rules.
773
+
774
+ 1. **One connection per agent.** Each agent has exactly one Connecta
775
+ connection, and its client (the harness, never the model) chooses the
776
+ pool's URL for each turn. Never mount a second Connecta connection beside
777
+ it: the model would get two sets of meta-tools, and `execute_code` cannot
778
+ join results across connections. The agent's one `cta_` token works on
779
+ every pool that lists it.
780
+ 2. **Base pool `<agent>`.** `/agent/<agent>` serves pool `<agent>`: shared and
781
+ organization connectors only, with no `owner`. Use it in group chats,
782
+ scheduled runs, and any turn without exactly one identified person.
783
+ 3. **Personal pool `<agent>-<user>`.** `/agent/<agent>-<user>` serves pool
784
+ `<agent>-<user>`: the base plus that user's own connectors. Use it only in
785
+ a one-on-one with that user. `<user>` is a short, stable handle you choose
786
+ (`alice`), never a provider id, principal id, or address; pool names match
787
+ `[a-z0-9_-]+`.
788
+ 4. **Define the base once.** Every personal pool spreads one base constant, so
789
+ a change to the base reaches every pool.
790
+ 5. **`owner` is the user's Workspace address.** Set `owner` on
791
+ `<agent>-<user>` to that user's Google Workspace email address; it is
792
+ checked as an address at construction, and it makes Workspace connectors
793
+ act as that user for the agent ([pool owners](#pool-owners-for-google-workspace)).
794
+ The base pool has no owner, so keep Workspace connectors out of it: there
795
+ they are absent, as in any pool that does not list them. (A Workspace
796
+ connector listed in a pool without an owner fails `auth_required` for an
797
+ agent.)
798
+ 6. **A user's own account is its own connector id.** Agents have no
799
+ principal, so `authScope: "personal"` connectors never appear for them.
800
+ Give each user's account its own connector id with shared auth, which a
801
+ maintainer connects once (`notes_alice`, `notes_bob`), and list it only in
802
+ that user's pool. Google Workspace is the exception: one connector whose
803
+ `subject` is a mapping function, with each personal pool's `owner`
804
+ choosing the account.
805
+ 7. **Changes stay in config.** Adding an account for a user adds its
806
+ connector and lists it in that user's pool `tools`; the client does not
807
+ change. Adding a user adds one `<agent>-<user>` pool.
808
+ 8. **No other person in a personal pool.** The only human member an
809
+ `<agent>-<user>` pool may have is that same user, and usually it has none.
810
+ Never add another person: its per-user connectors (`notes_alice`) use that
811
+ user's account for everyone the pool admits, so another member would read
812
+ and write the user's account as their own. A person's own use normally
813
+ goes through their own pools or plain `/mcp`, not the agent's.
814
+
815
+ ```ts
816
+ import type { GoogleWorkspaceOptions } from "@zackbart/connecta/providers/gmail";
817
+
818
+ const assistant = { tools: ["crm", "tickets"], members: ["agent:assistant"] };
819
+ const emailOf = (id?: string) => (id === "user_alice" ? "alice@example.com" : undefined);
820
+ const workspace: GoogleWorkspaceOptions = {
821
+ purpose: "Mail and files of the person in a one-on-one",
822
+ serviceAccount,
823
+ subject: (who) => emailOf(who.principal?.id),
824
+ };
825
+
826
+ createConnecta({
827
+ auth: clerkAuth({ publishableKey, secretKey }),
828
+ storage,
829
+ accessTokens: accessTokens(storage),
830
+ readOnly: false,
831
+ maintainers: ["user_alice"],
832
+ agents: ["assistant"],
833
+ pools: {
834
+ assistant,
835
+ "assistant-alice": {
836
+ ...assistant,
837
+ tools: [...assistant.tools, "notes_alice", "gmail", "drive"],
838
+ owner: "alice@example.com",
839
+ },
840
+ "assistant-bob": {
841
+ ...assistant,
842
+ tools: [...assistant.tools, "notes_bob", "gmail"],
843
+ owner: "bob@example.com",
844
+ },
845
+ },
846
+ connectors: [
847
+ crm,
848
+ tickets,
849
+ // Shared auth: a maintainer connects each user's own account once.
850
+ remoteMcp("notes_alice", { url: "https://notes.example.com/mcp", auth: { type: "oauth" } }),
851
+ remoteMcp("notes_bob", { url: "https://notes.example.com/mcp", auth: { type: "oauth" } }),
852
+ gmail("gmail", workspace),
853
+ drive("drive", workspace),
854
+ ],
855
+ executor,
856
+ });
857
+ ```
858
+
859
+ The agent's client picks the pool from who it is talking to, for example:
860
+
861
+ ```ts
862
+ const handles: Record<string, string | undefined> = { "<alice's sender id>": "alice", "<bob's sender id>": "bob" };
863
+ const user = turn.isDirect ? handles[turn.verifiedSender] : undefined;
864
+ const url = `https://connecta.example.com/agent/${user ? `assistant-${user}` : "assistant"}`;
865
+ ```
866
+
867
+ Rule 8 is why the example lists only the agent in each pool. People act as
868
+ themselves only through a Workspace mapping; a shared-auth connector such as
869
+ `notes_alice` acts as Alice for whoever the pool admits.
371
870
 
372
871
  ## Shared and personal auth
373
872
 
@@ -403,7 +902,7 @@ namespace: the live **epoch**, the latest consent's state digest, and one
403
902
  authorization server's client registration, tokens, and discovery. Each
404
903
  consent has a **flow record**, `oauth:flow:<sha256(state)>`, holding its
405
904
  epoch, consent URL, and PKCE verifier for fifteen minutes; the state itself is
406
- stored nowhere. Keys come from `src/storage/keys.ts`.
905
+ stored nowhere.
407
906
 
408
907
  With a vault, the grant's body and each verifier are sealed with its AES-GCM
409
908
  key, the additional authenticated data naming the connector, owner partition,
@@ -819,30 +1318,57 @@ anything, so SDK hook order cannot mix servers or cross a reset.
819
1318
 
820
1319
  ## Management permissions
821
1320
 
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.
1321
+ Management follows `maintainers` and pool `members`; no resolver configures it. The former `identity.credentialAdministration`,
1322
+ `identity.personalConnection`, `identity.accessTokenManagement`, and
1323
+ `identity.activityAccess` each fail construction naming their replacement,
1324
+ and so does the `identity` section itself, even empty.
1325
+
1326
+ | Action | Who may |
1327
+ | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
1328
+ | Shared credentials and OAuth grants | An interactive maintainer |
1329
+ | 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 |
1330
+ | Agent tokens: list, issue, rotate, revoke | An interactive maintainer |
1331
+ | Activity reads | A maintainer principal, interactive or not, within the optional `readGate` |
1332
+
1333
+ Agents and other machine credentials manage nothing. Shared auth is
1334
+ deployment-wide, so every step on a shared connector takes an interactive
1335
+ maintainer: issuing a link through `authorize_connector`, a program's or a
1336
+ failed call's auth handoff, or an elicitation; starting it at `/connect/<id>`;
1337
+ completing the OAuth callback, which re-checks at completion; and
1338
+ `/ui/oauth/<id>` and `/ui/credentials/<id>`. A pool member may connect and
1339
+ disconnect only a personal connector in one of their pools: through
1340
+ `authorize_connector` at `/mcp/<pool>`, the `/connect/<id>` link and its
1341
+ callback, which are bound to the principal that requested them, and
1342
+ `/ui/oauth/<id>` and `/ui/credentials/<id>`, which resolve only their pools'
1343
+ connectors and refuse a shared one with 403. A person who is neither gets the
1344
+ private 404 every operator route gives. A maintainer also manages their own
1345
+ personal connectors, which plain `/mcp` includes, in their own partition. A
1346
+ maintainer's link continues in the operator UI; a member's link starts the
1347
+ authorization directly, as it does on a deployment without the UI. A member's
1348
+ operator contract names, per personal connector, the actions their permission,
1349
+ the deployment and the connector's hooks support: credential `save` with a
1350
+ vault, `clear` once their own partition holds a value, and `test` only when that
1351
+ value matches the declared shape and a matching test hook exists; OAuth
1352
+ `connect` and `restart` with `startAuth`, `finishAuth` and `verifyState`, and
1353
+ `disconnect` with `disconnectAuth`. `/ui/oauth/<id>` gates each verb on those
1354
+ same hooks, so a connector provisioned out of band can still be disconnected,
1355
+ and `/ui/credentials/<id>` refuses to clear a partition that holds nothing
1356
+ with 409 `credential_required`, for members and maintainers alike.
1357
+
1358
+ Activity is read by maintainers alone. An authenticated maintainer principal
1359
+ reads it without an interactive session, including a non-interactive API
1360
+ principal; pool members, agents, subject-only callers, and anonymous requests
1361
+ get the private 404. The optional activity `readGate` still narrows it. Reads
1362
+ remain GET-only and filter connector/tool grants, personal ownership and
1363
+ recorded pool access before any actor-label lookup. Personal rows use the
1364
+ admitted principal in the existing typed actor id and namespace fields; rows
1365
+ without a provable owner are withheld. Non-interactive reads return typed
1366
+ actors without directory labels. Old rows without a pool remain subject to
1367
+ connector, tool and owner checks. A maintainer sees rows from plain `/mcp` and
1368
+ from every declared pool within that pool's tools; rows from a pool no longer
1369
+ declared are withheld. Agent token lifecycle routes require an interactive
1370
+ maintainer with a stable principal; they issue, rotate, and revoke declared
1371
+ agents' tokens, and never create, rename, or bind one.
846
1372
 
847
1373
  Personal activity rows must carry the checked `principal` actor basis as well as
848
1374
  a matching principal ID and namespace. Legacy subject actors cannot prove
@@ -854,18 +1380,10 @@ boundary reads returns 503 without a cursor; cancellation starts no further read
854
1380
  ```ts
855
1381
  createConnecta({
856
1382
  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
- },
1383
+ // Administers shared credentials, agent tokens, and activity.
1384
+ maintainers: ["owner-id"],
1385
+ // Each member connects their own account on personal_linear.
1386
+ pools: { team: { tools: ["shared_docs", "personal_linear", "notion.search_pages"], members: team } },
869
1387
  connectors, executor,
870
1388
  readOnly: false, // required: true refuses every write call
871
1389
  });
@@ -889,14 +1407,18 @@ the token against the Worker-level Access application's AUD. A nonempty trusted
889
1407
  `ctx.access.getIdentity()` supplies `user_uuid`, with `email` as fallback, in the
890
1408
  stable `cloudflare-access` principal namespace. Missing identity, service
891
1409
  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.
1410
+ request at the edge, but connecta requires a `cta_` token at `/agent/<pool>`
1411
+ for machine identity.
893
1412
 
894
1413
  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.
1414
+ caller's JWT. Missing or malformed trusted context returns 401. A rejected
1415
+ human identity, including a service identity or an unusable user id, returns 403. A thrown identity lookup or its five-second deadline returns retryable
1416
+ 503 with `problem: "identity_unavailable"` and `Retry-After: 1`. Every refusal
1417
+ carries `Cache-Control: no-store`. None of these failures admits an identity,
1418
+ and an unavailable lookup never falls back to another provider. The operator
1419
+ UI can re-request admission after a failure without a browser reload. It does not support Node, a `cloudflared` origin, or a Service Binding
1420
+ hop.
1421
+ Connecta configuration still decides connector access and management: `maintainers` and pool `members`.
900
1422
 
901
1423
  Protect the Worker with a Worker-level Access application whose destination is
902
1424
  `{ "type": "worker", "worker_id": "<the Worker script tag>" }`. A traditional
@@ -921,13 +1443,16 @@ covers. For another client add its exact URI or the narrowest wildcard covering
921
1443
  it, never its whole origin. A missing entry lets discovery succeed and
922
1444
  registration fail later, which looks like a broken MCP server, not a setting.
923
1445
 
924
- Do not add a bypass for the discovery routes; a fully automated client uses a
1446
+ Do not add a bypass for the discovery routes or any human path. The one
1447
+ bypass connecta expects is the [agent application for `/agent/*`](./deploying.md#agent-endpoints-behind-cloudflare-access),
1448
+ behind which an agent's `cta_` token is its only credential. Worker-level
1449
+ Access runs before every other connecta route, so `/health`, operator pages,
1450
+ downstream OAuth callbacks, `/mcp`, and `/mcp/<pool>` all require Access, and a
1451
+ `cta_` token is not a standalone edge credential there because Cloudflare
1452
+ rejects the request before connecta sees it. Unattended tools that need those
1453
+ paths, such as `connecta doctor` reading `/health`, use a
925
1454
  [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
1455
+ through `CF-Access-Client-Id` and `CF-Access-Client-Secret`. Custom public
931
1456
  webhooks live outside connecta and need their own Access routing policy. The
932
1457
  [Worker example](../examples/worker/) carries the whole deployment shape.
933
1458
 
@@ -995,7 +1520,7 @@ with no audience/resource claim from exact client IDs dedicated to this
995
1520
  deployment. Never share these clients with another resource server. A present
996
1521
  malformed or mismatched binding is rejected even for an allowlisted client.
997
1522
  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
1523
+ maintainers and pool members still decide access. Profile scopes do not identify a
999
1524
  resource, and `scopes` remains metadata, not an admission rule.
1000
1525
 
1001
1526
  Each new DCR registration gets a new client ID and needs an operator update
@@ -1146,8 +1671,8 @@ Clerk sign-in returns to the same link. Expired session cookies use Clerk's hand
1146
1671
  identity checks or code exchange; responses preserve session cookies. Access supplies trusted Worker identity
1147
1672
  at the edge. Protect `/connect/*` and `/oauth/callback/*` with the MCP endpoint's Access application.
1148
1673
 
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
1674
+ For both connector scopes, the browser must match the initiating principal and still be permitted to manage the
1675
+ 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
1676
  callback checks state, identity, permission, and RFC 9207 `iss` before consuming
1152
1677
  the handoff, interpreting an error, or exchanging a code. A supplied issuer must
1153
1678
  exactly match the consent. A server advertising issuer-response support also
@@ -1290,7 +1815,7 @@ owner's access token as `Authorization: Bearer` with these rules:
1290
1815
  preventing repeated refreshes by later program calls.
1291
1816
  - Handlers can name no storage, sealing, or owner partition; the registry owns them.
1292
1817
  - 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).
1818
+ workerd never quotes a downstream's Content-Type in its own log.
1294
1819
 
1295
1820
  The grant record and consents, epoch fencing, vault sealing, shared/personal ownership,
1296
1821
  callback state/principal checks, `authorize_connector`, Connect, Restart, and
@@ -1320,7 +1845,14 @@ is retained across requests, and rejected writes are never replayed. An omitted
1320
1845
  `describe()` includes neither the key nor the minted tokens. The maintained
1321
1846
  provider skill explains App permissions and installation prerequisites.
1322
1847
 
1323
- ## Source and tests
1848
+ ## Contributor reference
1849
+
1850
+ ### Source and tests
1851
+
1852
+ [Decision 0007](https://github.com/zackbart/connecta/blob/main/decisions/0007-agent-pool-layout.md)
1853
+ explains the pool layout; [decision 0006](https://github.com/zackbart/connecta/blob/main/decisions/0006-pools-are-the-access-model.md)
1854
+ explains pools as the access model. `test/identity-scope.test.ts` checks identity
1855
+ boundaries, and `src/storage/keys.ts` owns credential key families.
1324
1856
 
1325
1857
  Inbound adapters live in [src/auth](https://github.com/zackbart/connecta/tree/main/src/auth);
1326
1858
  [auth routes](https://github.com/zackbart/connecta/blob/main/src/routes/oauth.ts)