@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.
- package/CHANGELOG.md +63 -0
- package/README.md +15 -7
- package/bin/connecta.mjs +24 -6
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.d.ts +167 -23
- package/dist/access-tokens.js +676 -140
- package/dist/agent-identity.js +47 -0
- package/dist/auth/cloudflare-access.js +13 -5
- package/dist/call-admission-context.js +15 -0
- package/dist/call-admission.js +30 -43
- package/dist/call-recovery.js +6 -2
- package/dist/config-value-sources.js +11 -9
- package/dist/config.d.ts +37 -75
- package/dist/config.js +110 -36
- package/dist/connector-access.d.ts +4 -34
- package/dist/connector-access.js +15 -99
- package/dist/connector-caller.d.ts +7 -1
- package/dist/connector-caller.js +3 -2
- package/dist/connectors/api-connector.d.ts +27 -4
- package/dist/connectors/api-connector.js +2 -0
- package/dist/connectors/remote-mcp.js +2 -0
- package/dist/describe-config.d.ts +29 -7
- package/dist/describe-config.js +7 -17
- package/dist/index.d.ts +4 -3
- package/dist/index.js +29 -10
- package/dist/invocation.js +5 -3
- package/dist/membership.d.ts +51 -0
- package/dist/membership.js +121 -0
- package/dist/module-contracts.d.ts +23 -6
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +2 -4
- package/dist/operator-ui/agents.d.ts +74 -0
- package/dist/operator-ui/agents.js +104 -0
- package/dist/operator-ui/contract.d.ts +149 -28
- package/dist/operator-ui/endpoints.js +192 -0
- package/dist/operator-ui/fix-prompts.js +4 -4
- package/dist/operator-ui/generated.js +5 -4
- package/dist/operator-ui/member-page.js +112 -0
- package/dist/operator-ui/model.d.ts +14 -6
- package/dist/operator-ui/view.d.ts +84 -19
- package/dist/operator-ui/view.js +82 -21
- package/dist/page-styles.js +1 -1
- package/dist/provider.d.ts +61 -8
- package/dist/provider.js +46 -7
- package/dist/providers/_shared/google/workspace.d.ts +53 -16
- package/dist/providers/_shared/google/workspace.js +30 -8
- package/dist/providers/_shared/rest/tools.js +2 -2
- package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
- package/dist/providers/_shared/rest/value-safety.js +699 -7
- package/dist/providers/ccb/index.js +1 -1
- package/dist/providers/cloudflare/index.js +3 -3
- package/dist/providers/cloudflare/rest.js +25 -3
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/cloudflare/value-safety.js +32 -0
- package/dist/providers/docs/index.js +3 -3
- package/dist/providers/docs/skill.generated.js +2 -2
- package/dist/providers/drive/index.js +3 -3
- package/dist/providers/drive/skill.generated.js +2 -2
- package/dist/providers/forms/index.js +3 -3
- package/dist/providers/forms/skill.generated.js +2 -2
- package/dist/providers/gmail/index.js +10 -8
- package/dist/providers/gmail/skill.generated.js +2 -2
- package/dist/providers/mixpanel/index.js +1 -1
- package/dist/providers/notion/index.d.ts +7 -0
- package/dist/providers/notion/index.js +100 -11
- package/dist/providers/notion/rest.js +45 -5
- package/dist/providers/notion/skill.generated.js +2 -2
- package/dist/providers/notion/value-safety.js +30 -5
- package/dist/providers/overflow/index.js +1 -1
- package/dist/providers/planning-center/index.js +1 -1
- package/dist/providers/sheets/index.d.ts +3 -0
- package/dist/providers/sheets/index.js +6 -3
- package/dist/providers/sheets/skill.generated.js +2 -2
- package/dist/providers/slides/index.js +3 -3
- package/dist/providers/slides/skill.generated.js +2 -2
- package/dist/providers/stripe/rest.js +9 -2
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/stripe/value-safety.js +22 -0
- package/dist/providers/vercel/rest.js +20 -2
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/providers/vercel/value-safety.js +22 -0
- package/dist/registry.d.ts +2 -3
- package/dist/registry.js +8 -9
- package/dist/routes/access-tokens.js +82 -75
- package/dist/routes/activity-disclosure.js +17 -18
- package/dist/routes/activity.js +34 -54
- package/dist/routes/actor-labels.js +33 -4
- package/dist/routes/agent-path.js +43 -0
- package/dist/routes/connect.js +16 -12
- package/dist/routes/credentials.js +16 -40
- package/dist/routes/mcp.js +145 -95
- package/dist/routes/oauth-management.js +11 -5
- package/dist/routes/oauth.js +3 -9
- package/dist/routes/operator.js +24 -23
- package/dist/routes/shared.d.ts +60 -20
- package/dist/routes/shared.js +187 -96
- package/dist/routes/ui-config.js +437 -97
- package/dist/routes/ui-data.js +2 -2
- package/dist/routes/ui.js +9 -16
- package/dist/runtime/call-admission.js +9 -0
- package/dist/server.js +5 -0
- package/dist/storage/keys.js +9 -1
- package/dist/types.d.ts +11 -0
- package/dist/ui.d.ts +1 -1
- package/dist/ui.js +16 -14
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/workspace-address.js +9 -0
- package/dist/workspace-subject.js +23 -0
- package/documentation/README.md +46 -22
- package/documentation/architecture.md +355 -50
- package/documentation/auth.md +775 -243
- package/documentation/code-mode.md +14 -11
- package/documentation/deploying.md +121 -24
- package/documentation/integrating.md +92 -21
- package/documentation/meta-tools.md +14 -9
- package/documentation/migration-0.33.md +344 -0
- package/documentation/operating.md +3 -1
- package/documentation/operator-tests.md +115 -28
- package/documentation/operator-ui.md +326 -61
- package/documentation/provider-auth-migration.md +37 -23
- package/documentation/provider-migration-0.29.md +2 -2
- package/examples/worker/AGENTS.md +30 -8
- package/examples/worker/README.md +92 -53
- package/examples/worker/src/connecta.config.ts +36 -20
- package/examples/worker/wrangler.jsonc +3 -0
- package/package.json +3 -2
- package/templates/node/.env.example +3 -0
- package/templates/node/AGENTS.md +27 -6
- package/templates/node/Dockerfile +2 -2
- package/templates/node/README.md +67 -42
- package/templates/node/docker-compose.yml +3 -2
- package/templates/node/package.json +4 -3
- package/templates/node/src/adopt-token.ts +23 -0
- package/templates/node/src/connecta.config.ts +28 -12
- package/templates/node/src/rotate-token.ts +17 -0
- package/templates/node/src/provision-token.ts +0 -17
package/documentation/auth.md
CHANGED
|
@@ -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,
|
|
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 | [
|
|
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
|
-
|
|
56
|
-
|
|
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,
|
|
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
|
-
##
|
|
229
|
+
## Agent tokens and upgrading from earlier tokens
|
|
74
230
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
the
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
`docs
|
|
184
|
-
|
|
185
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
198
|
-
const marketing =
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
219
|
-
`campaigns`
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
|
225
|
-
|
|
|
226
|
-
|
|
|
227
|
-
|
|
|
228
|
-
|
|
|
229
|
-
|
|
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
|
|
235
|
-
can change behavior while keeping read-only
|
|
236
|
-
owns declarations and handlers. Connecta
|
|
237
|
-
absence of side effects. Restricted
|
|
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.
|
|
245
|
-
|
|
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.
|
|
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.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
3.
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
4. Save the once-returned secret in the client's secret store and
|
|
264
|
-
`Authorization: Bearer cta_…`. Behind Worker
|
|
265
|
-
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
283
|
-
`/mcp/<pool
|
|
284
|
-
|
|
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
|
-
|
|
609
|
+
maintainers: ["user_owner"],
|
|
610
|
+
agents: ["calendar-bot"],
|
|
295
611
|
pools: {
|
|
296
|
-
support: {
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
332
|
-
OAuth clients can discover how to authenticate.
|
|
333
|
-
to MCP requests and result/page handles. Handles remain bound
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
360
|
-
`
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
|
369
|
-
|
|
|
370
|
-
|
|
|
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.
|
|
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
|
-
|
|
823
|
-
`
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
|
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
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
1150
|
-
shared
|
|
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
|
|
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
|
-
##
|
|
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)
|