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
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,52 @@

All notable changes to this package are documented here.

## Unreleased

This release lets one deployment serve several authenticated people without
becoming an account system. Connector visibility comes from deployment config,
and downstream auth may be shared across the tenant or isolated per human.
Existing connectors stay shared, every visible connector stays visible, and
every interactive human stays an operator unless the deployment opts into the
new identity rules. A signed-in human may manage authentication for every
connector their code-derived view includes; operator status separately controls
tokens and global activity. Worker deployments also gain the complete Managed
OAuth callback allowlist in the shipped example and agent instructions.

### Added

- **Identity-derived connector views.** `identity.connectorAccess` selects
declared connector ids from the authenticated actor, subject, and principal.
The view reaches discovery, direct and program calls, status, catalogs,
observed output shapes, and paged results. Unknown ids and resolver failures
fail closed.
- **Shared and personal connector auth.** `authScope: "personal"` partitions
connector storage, encrypted credentials, OAuth state and tokens, catalogs,
and runtime observations by a hashed principal identity. Interactive members
manage auth for every visible connector: their own partition for personal
auth, or the deployment-wide grant for shared auth. `identity.operatorAccess`
reserves deployment access tokens and global activity for configured
operators.
- **Principal-bound access tokens and OAuth callbacks.** A new connecta access
token retains its creator's principal without gaining operator rights.
Personal OAuth handoffs bind a hash of state to the initiating principal for
15 minutes before the public callback can exchange a code.

### Changed

- **Result pages follow authenticated subjects.** `get_result` storage is now
partitioned for every namespaced subject, including existing Clerk, Access,
and connecta-token callers. In-flight result ids created before upgrading do
not cross that storage boundary; finish paging them before deployment when
that matters.
- **Worker Managed OAuth setup.** The Worker guide, source comment, upgrade
guide, and local `AGENTS.md` require Claude's fixed callback plus ChatGPT's
stable and callback-id forms in
`dynamic_client_registration.allowed_uris`.
- **One tenant, several people.** The ethos now refuses a connecta-owned account
model while allowing externally authenticated principals, config-derived
connector visibility, and personal downstream credentials inside one tenant.

## 0.21.2 — 2026-08-31

This patch closes two runtime isolation gaps: concurrent request scopes now
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ payload-free activity log. Worker deployments can use Cloudflare Access for
both MCP and operator identity; Node deployments and existing Workers can use
Clerk.

One deployment may serve several authenticated people inside the same tenant.
Configuration can derive connector visibility from the admitted identity, and
each connector may keep one shared downstream grant or a separate encrypted
grant per human. Connecta does not own accounts or groups; Clerk or Cloudflare
Access remains the identity provider. See [inbound auth](./documentation/auth.md#principals-visibility-and-operators)
and [shared and personal auth](./documentation/storage-and-credentials.md#shared-and-personal-auth).

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
and why.
Expand Down
22 changes: 14 additions & 8 deletions documentation/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ read top to bottom.
| 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. |
| 4 | `/health` | Open JSON: status, connector count, `serverInfo`, the configured executor's sanitized name when it has one, catalog-drift counts, admission snapshots, reserved route names, and `deployment` when `deploymentInfo` is set. Payload-free by construction, and it never joins the MCP queue. |
| 5 | `/oauth/callback/<connectorId>` | Downstream-OAuth completion, open, `verifyState` before `finishAuth`. |
| 5 | `/oauth/callback/<connectorId>` | Downstream-OAuth completion, open, `verifyState` before `finishAuth`. Personal flows first resolve the short-lived state hash to the principal partition. |
| 6 | `/favicon.*`, `/ui` → `/`, the operator shells, `/ui/data` | The operator surface ([operator UI](./operator-ui.md)). The shells are open and data-free; `/ui/data` behind them is gated. Built-ins are matched before connector routes, so a connector cannot shadow a page. |
| 7 | `/ui/activity` | Gated, plus the optional `activity.readGate`. `GET` only; 404 with no `activity.store.list`. |
| 8 | `/mcp` | **Admission before auth**, then the auth gate, then a fresh MCP server. |
Expand All @@ -93,11 +93,15 @@ any one file and a reordering reads like a harmless refactor.
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))
3. **Derive the registry view.** Auth supplies a namespaced subject and, for a
human, a principal. `identity.connectorAccess` selects declared connector
ids. Personal connectors use the principal partition; result paging uses
the subject partition. No caller parameter selects either.
4. **Refuse `?toolkit=`.** Caller-selected toolkits were removed ([#178](https://github.com/zackbart/connecta/issues/178))
but the URLs naming them were handed out, so the parameter is a 404 rather
than silently serving the full registry. Retiring a scoping boundary into
fail-open is the one outcome worse than the 404.
4. **Serve.** A fresh `McpServer` per request, the seven meta-tools registered
5. **Serve.** A fresh `McpServer` per request, the seven meta-tools registered
against the registry, the Apps shell resource registered (and
`resources/list` deliberately answering with nothing), and the response
handed back.
Expand All @@ -109,7 +113,7 @@ owns or hands out, and a change usually belongs in exactly one of them:

| Module | Owns |
| --- | --- |
| `src/registry.ts` | The connector set, address resolution, catalog TTL/persistence/completeness, shared refresh single-flight, connector health, per-connector call limiters, and drift. Construction-time refusals live here. |
| `src/registry.ts` | The connector set, identity-scoped views, personal storage partitions, address resolution, catalog TTL/persistence/completeness, refresh single-flight, connector health, per-connector call limiters, and drift. Construction-time refusals live here. |
| `src/catalog-service.ts` | Request-local tool listing, search, and describe. It coalesces reads inside one request and opts agent reads into the runtime's deferred catalog channel when one exists. |
| `src/invocation.ts` | One tool call: argument validation, call admission, per-attempt timeout, retry with the connector's own `Retry-After` honoured exactly or declined, result unwrapping, size capping, and the activity record. |
| `src/catalog.ts` | Ranking, description summarizing, and the compact schema renderer discovery shows. |
Expand Down Expand Up @@ -184,10 +188,12 @@ src/

## Sharp edges

- **The registry is shared; the request is not.** Anything you cache on the
registry is visible to every later request in that isolate. Anything you
cache per request dies with it. Putting a downstream client on the wrong side
of that line is the highest-severity mistake available here.
- **The root registry is shared; identity views are partitioned.** Shared
connector caches are visible to later requests in the isolate. Personal
connectors use a bounded principal registry, and transient results use the
authenticated subject. Anything cached per request still dies with it.
Putting a downstream client or credential on the wrong side of those lines
is the highest-severity mistake available here.
- **Route order is behavior.** Moving a built-in below the connector dispatch
hands a connector the ability to shadow it. Moving a mutation route below the
wildcard `OPTIONS` opts it into CORS preflight.
Expand Down
98 changes: 89 additions & 9 deletions documentation/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,63 @@ 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.

## Principals, visibility, and operators

Connecta distinguishes three identities. The actor is the exact caller written
to activity. The subject is any stable authenticated caller and owns transient
results such as `get_result` pages. The principal is the human owner of personal
connector auth. An interactive Clerk or Access user supplies all three. A
Cloudflare service identity has an actor and subject but no principal. A
connecta access token has its own actor and subject and inherits the principal
that created it, so agents using that token reach the creator's personal
connections without becoming operators.

`identity.connectorAccess` derives the connector ids a caller may discover and
invoke. The resolver receives authenticated identity data, never request input,
and returns `"all"` or a list of ids declared in `connectors`. An unknown id or
a thrown resolver fails the request closed.

`identity.connectorAccess` is also the credential-management boundary. A
signed-in human may save, test, disconnect, and authorize every visible
connector: personal auth changes only that principal's partition, while shared
auth changes the deployment-wide grant for everyone who can see the connector.
Use `authScope: "personal"` when one member must not rotate another member's
connection.

`identity.operatorAccess` separately reserves deployment-wide administration:
access-token creation and global activity history. Omit the resolver to
preserve the prior rule that every interactive human is an operator. When it is
configured, activity history is operator-only because its global event stream
contains other principals' connector names and actors.

```ts
createConnecta({
auth: cloudflareAccessAuth(),
identity: {
connectorAccess: ({ principal }) =>
principal?.id === "user_a"
? ["shared_docs", "personal_linear"]
: ["shared_docs"],
operatorAccess: ({ id }) => id === "user_a",
},
connectors: [
remoteMcp("shared_docs", { url: "https://example.com/mcp" }),
remoteMcp("personal_linear", {
url: "https://mcp.linear.app/mcp",
authScope: "personal",
auth: { type: "oauth" },
}),
],
executor,
});
```

Identity namespaces matter. Built-in Clerk and Access providers supply one.
A custom interactive provider must set `activityActorNamespace` before its
users can own personal auth. It may still use the legacy operator behavior
without one, but connecta will not merge unnamespaced users into personal
storage.

## Cloudflare Access on Workers

[`cloudflareAccessAuth()`](https://developers.cloudflare.com/workers/configuration/cloudflare-access/)
Expand Down Expand Up @@ -35,7 +92,8 @@ 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
A human identity gets MCP and personal-connection access. It gets operator
access unless `identity.operatorAccess` says otherwise. 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
Expand All @@ -48,7 +106,27 @@ the Worker. Enable [**Managed OAuth**](https://developers.cloudflare.com/cloudfl
on that Worker-level 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
Worker identity. Managed OAuth allows no hosted client callback by default, so
enable Dynamic Client Registration and add all three values to **Allowed
redirect URIs**:

```text
https://claude.ai/api/mcp/auth_callback
https://chatgpt.com/connector_platform_oauth_redirect
https://chatgpt.com/connector/oauth/*
```

Cloudflare exposes that list as
`oauth_configuration.dynamic_client_registration.allowed_uris`. It belongs to
the Access application's Managed OAuth settings, not the Access policy that
selects admitted identities. Claude uses the fixed first value. ChatGPT may use
its stable callback or a callback-id path covered by the third value. If a
client registers a different redirect, add that exact URI or the narrowest path
wildcard that covers it; do not allow the client's whole origin. Without these
entries discovery succeeds and client registration fails later, which makes a
missing allowlist look like a broken MCP server.

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.
Expand Down Expand Up @@ -102,7 +180,9 @@ secure.
Each token has an immutable ID. Activity records store that ID and resolve its
current friendly name only while an authorized operator reads activity.
Revoked records remain as metadata tombstones so historical calls keep their
friendly attribution.
friendly attribution. New tokens also retain the creating principal. Their MCP
requests use that principal's connector visibility and personal auth while the
token itself remains the activity actor and result owner.

Access tokens authenticate MCP clients; they are never operator credentials.
Creation, rename, and revocation require the same eligible human identity and
Expand All @@ -113,20 +193,20 @@ Issuance and revocation inherit the consistency guarantees of the configured
storage adapter. Use strongly consistent storage when either change must take
effect globally without a convergence window.

Operator credential mutation is a separate, narrower boundary. The
Human 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 operator. An MCP
bearer is never treated as an operator credential, even when it can call every
connector.
mutation API requires same-origin requests from an admitted interactive human.
That human may mutate only visible connector slots. An MCP bearer is never
treated as a browser 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;
- an interactive operator opens that URL, signs in, and updates the
- an interactive human with connector access opens that URL, signs in, and updates the
credential; and
- a bearer-only deployment still returns the handoff honestly, but mutation
remains unavailable until interactive operator auth is configured.
remains unavailable until interactive user 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
13 changes: 13 additions & 0 deletions documentation/connectors.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Connectors

Every connector may set `authScope: "shared" | "personal"`. Shared is the
default and keeps one deployment-wide downstream grant. Personal auth requires
a stable human principal and partitions connector state, credentials, OAuth,
catalogs, and observed shapes by that principal. Connector visibility is a
separate deployment rule under `identity.connectorAccess`; hiding a connector
does not change who owns its auth. See [shared and personal auth](./storage-and-credentials.md#shared-and-personal-auth).

`authScope` partitions connecta-owned context, not arbitrary variables captured
by connector code. A custom personal connector must read auth from
`ctx.credential` or `ctx.storage`; a secret closed over by its handler remains
shared JavaScript state. `remoteMcp()` rejects the equivalent mistake when
literal headers are combined with personal scope.

Connectors are the boundary between Connecta's fixed meta-tool surface and
downstream capabilities. Prefer a prebuilt connection when Connecta maintains
one for the provider. Use `api()` to define a deliberate HTTP API surface and
Expand Down
7 changes: 4 additions & 3 deletions documentation/meta-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,9 +307,10 @@ credential.

The tool accepts no secret. `force` applies only to OAuth and may discard its
stored grant before restarting consent. Static credential values are written
only through the same-origin, Clerk-operator credential route. After OAuth
consent or an operator update, retry the original operation; a static update is
read from the vault on the next call and needs no redeploy.
only through the same-origin interactive-user credential route, and only for a
connector visible to that user. After OAuth consent or a human update, retry
the original operation; a static update is read from the vault on the next call
and needs no redeploy.

## Routing recovery

Expand Down
Loading