Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,51 @@

All notable changes to this package are documented here.

## 0.21.0 — 2026-08-28

Cloudflare Access becomes the canonical interactive-auth path for Worker
deployments. It authenticates both MCP clients and human operators before the
Worker runs, while connecta consumes only the trusted runtime identity. Clerk
is unchanged and remains supported: existing deployments can add Access,
verify the edge cutover, and remove Clerk later, with no storage migration or
token conversion. Node deployments can ignore this release beyond the version
pin.

### Added

- **Direct Worker Access auth.** `cloudflareAccessAuth()` ships from
`@zackbart/connecta/auth/cloudflare-access` with no dependency and no JWT
verifier. Human `ctx.access` identities may use MCP and operator routes;
service-token identities may use MCP but cannot mutate operator state. The
same suite runs under Node and workerd (#506).
- **Ambient operator sessions.** The operator shell selects Cloudflare Access
when the current invocation carries it, sends no browser-readable token, and
signs out through Cloudflare. A co-configured Clerk provider remains the
shell before Access is attached and the rollback path after it is detached
(#506).
- **Access-aware doctor.** `connecta doctor` accepts
`CF_ACCESS_CLIENT_ID`/`CF_ACCESS_CLIENT_SECRET` and sends the pair to health
and MCP requests. A partial pair fails before network access (#506).

### Changed

- **Cold reads stay in code mode.** One known canonical address still uses
`call_tool`, while an unknown-address read now starts with one
`execute_code` program and keeps discovery results off the model-facing
route. The current-version benchmark is reset to four deterministic
whole-agent cases covering both routes, exact provider semantics, private
pagination, forwarding bytes, tokens, and latency.
- **Worker deployment path.** The shipped Worker example uses Access and
Managed OAuth, carries a local `access.dev` identity, and documents service
tokens for unattended callers. Its Clerk shape stays beside the provider as
the reversible migration seam. Static connecta and operator-issued bearers
remain supported by core but are not standalone credentials through a
whole-Worker Access gate (#506).
- **Operator capability is vendor-neutral.** Inbound auth providers now declare
interactive-operator capability explicitly, and runtime context reaches
their authorization hook as an optional third argument. Existing custom
providers with the two-argument hook remain source-compatible (#506).

## 0.20.0 — 2026-08-26

This release removes the two side languages that had grown around the seven
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ Fifty issues in, one small object out. Your context window notices.

There is also an operator surface, off until you turn it on: sign-in, an
encrypted credential vault with rotation, revocable per-client tokens, and a
payload-free activity log.
payload-free activity log. Worker deployments can use Cloudflare Access for
both MCP and operator identity; Node deployments and existing Workers can use
Clerk.

Connecta is not a platform, a marketplace, a policy engine, or a multi-tenant
service. Those are decisions, and the [ethos](./ethos.md) records each one
Expand Down
29 changes: 23 additions & 6 deletions bin/connecta.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ function shellCd(path) {
function usage() {
console.log(`Usage:
connecta init [directory]
CONNECTA_TOKEN=<bearer> connecta doctor [--url http://localhost:8787]`);
CONNECTA_TOKEN=<bearer> connecta doctor [--url http://localhost:8787]
CF_ACCESS_CLIENT_ID=<id> CF_ACCESS_CLIENT_SECRET=<secret> connecta doctor --url https://worker.example`);
}

async function init() {
Expand Down Expand Up @@ -169,19 +170,35 @@ async function doctor() {
!loopbackHosts.has(parsedUrl.hostname)
) {
throw new Error(
"Refusing to send a bearer token over remote plaintext HTTP. Use HTTPS.",
"Refusing to send authentication credentials over remote plaintext HTTP. Use HTTPS.",
);
}
const baseUrl = requestedUrl.replace(/\/+$/, "");
const token = process.env.CONNECTA_TOKEN;
if (!token) {
const accessClientId = process.env.CF_ACCESS_CLIENT_ID;
const accessClientSecret = process.env.CF_ACCESS_CLIENT_SECRET;
if (Boolean(accessClientId) !== Boolean(accessClientSecret)) {
throw new Error(
"Set CONNECTA_TOKEN so doctor can inspect the MCP surface.",
"Set both CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET.",
);
}
if (!token && !accessClientId) {
throw new Error(
"Set CONNECTA_TOKEN or a CF_ACCESS_CLIENT_ID/CF_ACCESS_CLIENT_SECRET pair so doctor can inspect the MCP surface.",
);
}
const authHeaders = {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(accessClientId && accessClientSecret
? {
"CF-Access-Client-Id": accessClientId,
"CF-Access-Client-Secret": accessClientSecret,
}
: {}),
};

const health = await jsonResponse(
await doctorFetch(`${baseUrl}/health`),
await doctorFetch(`${baseUrl}/health`, { headers: authHeaders }),
);
if (health.status !== "ok") {
throw new Error(`Unexpected health status: ${String(health.status)}`);
Expand All @@ -206,7 +223,7 @@ async function doctor() {
await doctorFetch(`${baseUrl}/mcp`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
...authHeaders,
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
Expand Down
11 changes: 7 additions & 4 deletions documentation/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ read top to bottom.
| Order | Route | Notes |
| --- | --- | --- |
| 0 | HTTPS upgrade | 308 to `publicUrl` when it is HTTPS and the request arrived over HTTP. Path and query are *assigned* onto the configured URL, never resolved against it, so a `//host` pathname cannot replace the deployment origin. `/health` is exempt: a loopback container probe must not depend on public DNS and TLS. `/ui` is canonicalized to `/` while upgrading. |
| 0 | Cloudflare Access (Worker deployment, when enabled) | Edge admission before this route table. Managed OAuth owns its challenge and discovery metadata; an admitted direct invocation carries trusted identity in `ctx.access`. |
| 1 | `/ui/access-tokens[/<id>]`, `/ui/credentials/<id>[/<action>]`, `/ui/oauth/<id>` | Private mutation routes, matched **first** so nothing can shadow them and so they own their own `OPTIONS` — they answer it with a refusal rather than inheriting the wildcard CORS preflight. |
| 2 | `OPTIONS` | Each auth provider's `handleMetadata` gets a chance (CORS preflight for browser MCP clients); otherwise 204 with MCP CORS. |
| 3 | `/.well-known/*` | Auth providers' `handleMetadata`, open. 404 when none handles it. |
Expand All @@ -72,7 +73,7 @@ any one file and a reordering reads like a harmless refactor.
([request admission](./request-admission.md)). The permit is held until the
response *body* completes, not until the handler returns.
2. **Authorize.** Each `InboundAuth` provider's `authorize` in order, bearer
before Clerk. First `ok` admits; if all fail, the last provider's challenge
before interactive providers. First `ok` admits; if all fail, the last provider's challenge
response is returned. No providers configured means open — development
only, and it warns at construction.
3. **Refuse `?toolkit=`.** Toolkits were removed ([#178](https://github.com/zackbart/connecta/issues/178))
Expand Down Expand Up @@ -116,7 +117,9 @@ The Node-touching paths are `src/node.ts` (the `node:http` adapter),
subpath export — `@zackbart/connecta/node`, `@zackbart/connecta/quickjs` — and
must stay unreachable from the root entry. The optional Clerk adapter is behind
`./auth/clerk` for the adjacent reason: `@clerk/backend` is an optional peer,
not a dependency.
not a dependency. The zero-dependency Cloudflare Access adapter likewise stays
behind `./auth/cloudflare-access`: it is Web-API-pure, but its trust contract is
specific to a direct Worker invocation carrying `ctx.access`.

`test/purity.test.ts` walks the relative-import graph from `src/index.ts` and
fails on (a) any `node:` specifier in a reachable file and (b) the Node
Expand Down Expand Up @@ -156,7 +159,7 @@ src/
operator-ui/ the Preact app, its pure rules, and the built bundle
connectors/ remote-mcp.ts, api.ts, guarded-fetch.ts
providers/ the maintained prebuilt connections
auth/ bearer, clerk (optional peer), downstream OAuth
auth/ bearer, Cloudflare Access, clerk (optional peer), downstream OAuth
executors/ the QuickJS pool and child (Node only)
storage/ memory.ts, file.ts (Node only)
node.ts listen() + fileStorage re-export (Node only)
Expand All @@ -177,7 +180,7 @@ src/
the connector limiters, then the executor. Node's `listen()` calls it on
SIGTERM/SIGINT.
- **Structural mistakes throw at construction.** A duplicate connector id, an
invalid admission rule, `accessTokens` without a Clerk provider, a missing
invalid admission rule, `accessTokens` without an interactive operator provider, a missing
executor: all refuse to boot. A deployment that starts in the wrong shape is
worse than one that does not start.

Expand Down
70 changes: 63 additions & 7 deletions documentation/auth.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,64 @@
# Inbound auth

Inbound auth decides who may reach the MCP endpoint. A deployment may admit a
static bearer, operator-issued access tokens, Clerk identities, or a mixture.
Static bearers are checked first.
static bearer, operator-issued access tokens, Clerk identities, Cloudflare
Access identities on Workers, or a mixture. Static bearers are checked first;
the remaining providers keep configuration order. The first successful
identity owns the activity actor for that request.

## Cloudflare Access on Workers

[`cloudflareAccessAuth()`](https://developers.cloudflare.com/workers/configuration/cloudflare-access/)
is the Worker-specific path:

```ts
import { cloudflareAccessAuth } from
"@zackbart/connecta/auth/cloudflare-access";

createConnecta({
auth: cloudflareAccessAuth(),
connectors,
executor,
});
```

The adapter trusts only `ctx.access`, which Cloudflare creates after Access has
authenticated a request that directly invokes the Worker. It calls
`ctx.access.getIdentity()` and never reads `Cf-Access-Jwt-Assertion`, downloads
signing keys, or accepts a JWT from the caller. A missing context or unreadable
identity fails closed. This also means it is deliberately not a Node or
`cloudflared` origin adapter, and it does not survive a Service Binding hop:
those shapes need their own explicit trust boundary.

A human identity gets MCP and operator access. A Cloudflare service-token
identity gets MCP access and a stable activity subject, but no `userId`, so it
cannot write credentials, run downstream OAuth mutations, or issue connecta
tokens. Access policy decides who reaches the Worker; connecta does not mirror
email domains, groups, or device posture into a second policy layer.

Enable [**Managed OAuth**](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/managed-oauth/)
on the Access application for interactive MCP clients.
Cloudflare then owns the unauthenticated challenge and `/.well-known/`
metadata, issues opaque RFC 8707 tokens, and resolves them into the same trusted
Worker identity. Do not add a bypass for the discovery routes. A fully
automated client instead uses a [Cloudflare Access service token](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/)
through the
`CF-Access-Client-Id` and `CF-Access-Client-Secret` headers.

Worker-level Access runs before every connecta route. Consequently:

- `/health`, operator pages, downstream OAuth callbacks, connector-owned
routes, and `/mcp` all require Access unless a more-specific hostname/path
policy says otherwise;
- a static connecta bearer and a `cta_…` token are not standalone edge
credentials, because Cloudflare rejects them before connecta sees them; and
- a connector that intentionally exposes a public webhook needs a
more-specific Access application and bypass policy. Do not bypass connecta's
OAuth discovery paths when Managed OAuth is enabled.

The [Worker example](../examples/worker/) carries the complete deployment shape
and the [upgrade guide](./upgrading.md#0200--0210) gives the reversible Clerk
migration.

## Clerk configuration is checked at construction

Expand All @@ -17,7 +73,7 @@ request instead of a base64 stack on every route.

## Operator-issued access tokens

Set `accessTokens: {}` to let eligible Clerk operators create named Bearer
Set `accessTokens: {}` to let eligible interactive operators create named Bearer
tokens at `/tokens`:

```ts
Expand All @@ -41,7 +97,7 @@ Revoked records remain as metadata tombstones so historical calls keep their
friendly attribution.

Access tokens authenticate MCP clients; they are never operator credentials.
Creation, rename, and revocation require the same eligible Clerk identity and
Creation, rename, and revocation require the same eligible human identity and
same-origin mutation boundary as connector credentials. `maxActive` defaults
to 100 and can be set from 1 through 1,000.

Expand All @@ -51,18 +107,18 @@ effect globally without a convergence window.

Operator credential mutation is a separate, narrower boundary. The
`/credentials` shell contains no secret data before authentication, and the
mutation API requires same-origin requests from an admitted Clerk user. An MCP
mutation API requires same-origin requests from an admitted operator. An MCP
bearer is never treated as an operator credential, even when it can call every
connector.

This split is visible in recovery:

- a bearer-authenticated agent may receive `recovery: "operator_config"` and
pass its `operatorUrl` to a human;
- a Clerk-authenticated operator opens that URL, signs in, and updates the
- an interactive operator opens that URL, signs in, and updates the
credential; and
- a bearer-only deployment still returns the handoff honestly, but mutation
remains unavailable until Clerk operator auth is configured.
remains unavailable until interactive operator auth is configured.

See [meta-tools](./meta-tools.md#authorization-recovery) for the stable recovery
envelope and [storage and credentials](./storage-and-credentials.md) for vault
Expand Down
8 changes: 4 additions & 4 deletions documentation/code-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -889,11 +889,11 @@ the upstream `Executor` shape assignable.
| `X6` | `test/quickjs-executor.test.ts` (never-settling await) |
| `X7` | `P3`'s tests; the Workers superset is deliberately unused |

The surface itself is checked by `test/server.test.ts` (the exact seven-tool
list) and `test/code-first-surface.test.ts` (the fold's construction rules, the
The surface itself is checked by `test/server.test.ts` (the exact seven-tool list)
and `test/code-first-surface.test.ts` (the fold's construction rules, the
required executor, the refusals a removed top-level tool now gets, copy, and
measured size). There is one shape left to audit, so there is one audit:
measured size). The small whole-agent benchmark checks both read routes, provider semantics, and private pagination:

```sh
npm --prefix eval/current-version run audit
npm --prefix eval/current-version run benchmark
```
42 changes: 22 additions & 20 deletions documentation/meta-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,19 @@ Every deployment requires an executor and `tools/list` is exactly seven:
batching live in `connecta.search`, `connecta.describe`, and `connecta.batch`
inside a program ([#273](https://github.com/zackbart/connecta/issues/273)).

Code-first is what a model sees. Four overlapping ways to reach one connector
became two: `search_tools` then `call_tool` for a single cold read — measurably
cheaper direct than through a program — and `execute_code` for everything wider.
The consolidation removed overlapping routing choices while preserving the
cheaper direct path for one cold call. The [guest API contract](./code-mode.md)
is what a program is promised.

The route is chosen before discovery. A result that will be reduced, a call
whose arguments depend on an earlier result, or work with multiple operations
starts with one `execute_code` call and keeps discovery, calls, and reduction
inside it. Distinct operations get distinct short `connecta.search` queries in
that program. Only one unknown-address read takes the cheaper top-level
`search_tools` → `call_tool` path; a known address needs only `call_tool`.
Code-first is what a model sees. Read-only work has two routes: `call_tool` for
one known address, and `execute_code` when discovery or any wider work is
needed. Real hosted catalogs reversed the earlier synthetic result that made a
top-level cold search look cheaper. Keeping discovery inside the program avoids
returning every candidate schema to the model and removes a model round trip.
The [guest API contract](./code-mode.md) is what a program is promised.

The route is chosen before discovery. An unknown address, a result that will be
reduced, a call whose arguments depend on an earlier result, or work with
multiple operations starts with one `execute_code` call and keeps discovery,
calls, and reduction inside it. Distinct operations get distinct short
`connecta.search` queries in that program. A known address needs only
`call_tool`.

That routing is about read-only work, because that is the only work a program
can do. Anything unannotated, write-capable, or destructive is inadmissible
Expand Down Expand Up @@ -68,13 +68,15 @@ and a truncated line ends with the exact `+N more` count. This reads only the
configured registry: it loads no catalog, probes no credential, grants no
capability, and does not replace canonical discovery or addressing.

Start an unknown-address lookup with two to four distinctive action/object
terms, not the full request, and omit `limit` so the default eight-result page
stays small. When the integration is obvious, set `connector` to its id: a
scoped search loads that catalog alone, while an unscoped search must fan out
across every configured connector. Leave the search unscoped when the right
integration is genuinely ambiguous. Set `safety: "readOnly"` when the result is
headed to `call_tool` or generated code; `safety: "approvalRequired"` finds the
Start a lookup with two to four distinctive action/object terms, not the full
request. Read-only lookup belongs in `connecta.search` inside the program.
Top-level `search_tools` remains available for explicit catalog inspection and
approval-required discovery. Omit `limit` initially so the default
eight-result page stays small. When the integration is obvious, set
`connector` to its id: a scoped search loads that catalog alone, while an
unscoped search must fan out across every configured connector. Leave the
search unscoped when the right integration is genuinely ambiguous. Set
`safety: "readOnly"` for generated code; `safety: "approvalRequired"` finds the
complementary set that must cross `call_destructive_tool`. Omitting `safety`,
or setting it to `"all"`, preserves the complete configured catalog. This is
only a discovery filter: it neither grants authority nor changes invocation admission.
Expand Down
Loading