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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ All notable changes to this package are documented here.

## Unreleased

## 0.26.2 — 2026-09-27

This patch restores named client tokens as an optional auth module. Deployments
upgrading from v0.23 can keep their existing stored records and client secrets,
without rotation, by configuring `accessTokens(storage)` with the same storage
namespace and preserving their identity grants. The old boolean configuration
must be replaced. Deployments that omit the module are unchanged. The eight MCP
tools remain unchanged, and the Node template now pins 0.26.2.

### Added

- `@zackbart/connecta/auth/access-tokens` verifies v0.23 `cta_…` tokens and
restores create, show-once, list, rename, and revoke in the operator UI.
`identity.accessTokenManagement` explicitly permits interactive operators;
client tokens cannot administer tokens or connection credentials. New issuance
requires atomic storage and reserves capacity across concurrent instances.

### Fixed

- Existing token ids, activity labels, and principal bindings survive the upgrade.
Malformed or corrupt records fail closed, including principal fields that
JavaScript regular expressions previously coerced into strings. Revocation
deletes the admission lookup before updating metadata.


## 0.26.1 — 2026-09-26

This patch fixes slow OAuth restarts, unbounded downstream authorization waits,
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,14 @@ breaks and what a deployment can ignore.
Built for its author's deployments first and published openly. Breaking
changes are expected before 1.0. See the [changelog](./CHANGELOG.md) and
[security policy](./SECURITY.md).

### Existing client tokens

Upgrading from v0.23 does not require rotating managed `cta_…` tokens. Import
`accessTokens` from `@zackbart/connecta/auth/access-tokens`, replace the old
`accessTokens: true` with `accessTokens: accessTokens(storage)`, and keep the
same persistent storage namespace and identity/tool/pool grant rules. Enable
`identity.accessTokenManagement` only for the interactive operators who should
manage tokens. New issuance needs storage with atomic `compareAndSet`; older
storage adapters can still verify existing tokens. See the package's
`documentation/auth.md` for the migration and storage requirements.
2 changes: 1 addition & 1 deletion documentation/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,6 @@ compiling and configuring the real thing.
connector limiters, then the executor, then the Connecta's runtime; Node's `listen()` calls it on
SIGTERM/SIGINT.
- **Structural mistakes throw at construction.** A duplicate connector id, an
invalid admission rule, the removed `accessTokens` option, a missing executor:
invalid admission rule, the old boolean `accessTokens` option, a missing executor:
all refuse to boot (`test/config.test.ts`, `test/registry.test.ts`). Starting
in the wrong shape is worse than not starting.
65 changes: 63 additions & 2 deletions documentation/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ static bearers are checked first, then other providers in configuration order.
An `InboundAuth` provider's `authorize(request, baseUrl, runtimeContext)`
returns either `{ ok: true, userId?, subjectId?, principal? }` or a refusal
carrying its own `Response`, so the provider owns its challenge. Connecta
issues no tokens of its own and serves no token-management routes.
issues managed client tokens only when the optional `accessTokens` module is configured.

The bearer adapter challenges with `WWW-Authenticate: Bearer` and deliberately
omits `resource_metadata`: its credential is configured out of band, so it has no
Expand All @@ -16,6 +16,67 @@ or the edge own OAuth discovery. An open deployment with any connector warns at
construction — including API connectors carrying static auth headers, and with
sharper wording for credential and OAuth connectors.

## Managed client tokens and upgrading from v0.23

Import `accessTokens` from `@zackbart/connecta/auth/access-tokens` and pass its
module to `createConnecta`. It installs its inbound adapter and, beside `ui`,
the Access tokens page and `/ui/access-tokens` lifecycle routes. Omitting the
module loads none of its implementation and serves none of those routes.

```ts
import { accessTokens } from "@zackbart/connecta/auth/access-tokens";

createConnecta({
storage,
accessTokens: accessTokens(storage),
auth: clerkAuth({ publishableKey, secretKey }),
ui: operatorUi(),
identity: {
connectorAccess, // Keep the existing principal and token-id grant rules.
accessTokenManagement: ({ principal }) =>
principal?.namespace === "clerk:your-existing-namespace" &&
principal.id === "your-operator-id",
},
connectors,
executor,
});
```

For a v0.23 deployment, replace the old `accessTokens: true` or options object
with `accessTokens: accessTokens(storage)`, using **the same storage and key
namespace**. Keep the existing `access-token:v1:record:*` and
`access-token:v1:lookup:*` records. Their unrevoked `cta_…` secrets keep working;
no secret recovery, rewrite, or client rotation is needed. Preserve the existing
identity namespaces and connector/pool grant callbacks too. Storage compatibility
does not translate deployment configuration or invent replacement grants.

The adapter preserves `access_token` actor ids, the
`connecta:access-tokens:v1` activity namespace, friendly names, and any stored
principal. A token without a principal stays unbound. Every request evaluates
current identity/tool/pool grants; a token never becomes an interactive operator.
Names are labels, never permissions. New UI-issued tokens belong to the issuing
human's principal, and any explicitly permitted token manager can list, rename,
or revoke deployment tokens. Static and managed bearers cannot manage tokens or
connection credentials. Operators need an interactive auth provider.

Storage must implement `list`. Existing-token verification, rename, and revoke
also work on older adapters without `compareAndSet`; **new issuance requires
atomic `compareAndSet`**, because counting records before writing admits too many
concurrent creates. Active capacity defaults to 100, configurable with
`accessTokens(storage, { maxActive: 200 })`, up to 1,000. A durable reservation
counts before a secret is written. An interrupted create can consume capacity
without returning a token; it is never automatically retried or released after
an uncertain write. Avoid creating new tokens through old-version instances once
new-version issuance has started, since those instances do not honor reservations.

Secrets contain 256 random bits and only their SHA-256 digests persist. Creation
returns the secret once; list and rename never return it. Revocation removes its
lookup before updating metadata, and authorization has no token cache. A strongly
consistent store makes revocation effective on the next authorization; an
eventually consistent backend retains its own propagation delay. Requests already
admitted are not recalled. Management writes require an exact same-origin Origin,
and responses are private and non-cacheable.

## Origins

`allowedOrigins?: readonly string[] | "*"` bounds which browsers may speak to
Expand Down Expand Up @@ -409,7 +470,7 @@ from caller input.
interactive human, the one default here that is open, because a single-operator
deployment would otherwise be locked out of its own event stream. Team
deployments should set it. There is no general administrator role and no
token-management authority.
implicit token-management authority. `identity.accessTokenManagement` is a separate boolean permission, false by default, evaluated only for interactive humans. Lifecycle routes also require a stable principal.

```ts
createConnecta({
Expand Down
2 changes: 1 addition & 1 deletion ethos.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ carries them.
| Optional deployment modules | accepted | typed slots select UI, activity, vault, and inbound auth; core keeps discovery, execution, invocation, and enforcement |
| Artifacts module | accepted | a built-in connector for team-only sandboxed pages over stored JSON; immutable versions let writes skip approval. Supersedes [#287](https://github.com/zackbart/connecta/issues/287) |
| Plugin lifecycle, provider registry, or marketplace | refused | modules are deployment code, not runtime installs; prebuilt connections are imports, discovered in docs ([#297](https://github.com/zackbart/connecta/issues/297)) |
| Connecta-issued access tokens | removed | inbound providers authenticate clients; bearer auth stays an optional adapter |
| Connecta-issued access tokens | accepted | optional /auth/access-tokens preserves v0.23 secrets; config owns grants, interactive management needs explicit permission ([#619](https://github.com/zackbart/connecta/issues/619)) |
| Expanded Notion page create/update options | refused | different workflows, not missing fields; use `api()` ([#408](https://github.com/zackbart/connecta/issues/408)) |
| Resources, prompts, and downstream MCP Apps templates | refused | tools only; clients own presentation ([#266](https://github.com/zackbart/connecta/issues/266)) |
| Protocol sessions, server push, elicitation passthrough | refused | stateless per request; elicitation has no route |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zackbart/connecta",
"version": "0.26.1",
"version": "0.26.2",
"type": "module",
"sideEffects": false,
"description": "One MCP to rule them all — a single MCP endpoint aggregating many downstream connectors behind a code-first surface of eight meta-tools.",
Expand Down Expand Up @@ -114,6 +114,10 @@
"./artifacts": {
"types": "./dist/artifacts.d.ts",
"import": "./dist/artifacts.js"
},
"./auth/access-tokens": {
"types": "./dist/access-tokens.d.ts",
"import": "./dist/access-tokens.js"
}
},
"scripts": {
Expand Down
7 changes: 6 additions & 1 deletion scripts/bundle-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"Gzip-9 bytes of each Web-facing entry as scripts/bundle-size.mjs bundles it. baselineGzip is the P1-S19 measurement at 0.25.0, with the Effect core in place; main before the conversion (4f536a8) was 235,346 B at the root and 263,949 B for the Worker example.",
"Caps: every entry gets baseline + 60,000 B. The conversion-era allowances are retired: the root's flat 386,000 B cap covered a rewrite that is finished, and the +160 KB held for HttpApi in ./ui, ./activity and the Worker example was never spent, because P1-S18 measured HttpApi and did not use it.",
"A cap moves only in a change that says why. Raising one to make a check pass is the failure this file exists to catch.",
"./artifacts is measured where it was introduced (#562): the store, the hand-written HTML tokenizer, the Markdown renderer, and validation, with no Effect and no dependency. The connector, guide, and page routes that follow it spend from the same baseline + 60,000 B allowance."
"./artifacts is measured where it was introduced (#562): the store, the hand-written HTML tokenizer, the Markdown renderer, and validation, with no Effect and no dependency. The connector, guide, and page routes that follow it spend from the same baseline + 60,000 B allowance.",
"./auth/access-tokens starts at 4,222 B gzip in #619. Its legacy record verifier and operator lifecycle use no runtime dependency; the cap uses the existing baseline + 60,000 B policy."
],
"entries": {
".": {
Expand Down Expand Up @@ -73,6 +74,10 @@
"examples/worker": {
"baselineGzip": 314336,
"maxGzip": 374336
},
"./auth/access-tokens": {
"baselineGzip": 4222,
"maxGzip": 64222
}
}
}
38 changes: 38 additions & 0 deletions scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,9 @@ if (typeof core.createConnecta !== "function") throw new Error("missing core");
if (typeof core.validateToolInput !== "function") {
throw new Error("missing validateToolInput");
}
const tokenModule = await import("@zackbart/connecta/auth/access-tokens");
if (typeof tokenModule.accessTokens !== "function") throw new Error("missing managed-token module");
if ("accessTokens" in core || "AccessTokenManager" in core) throw new Error("token implementation leaked into core");
const jsonSchema = await import("@zackbart/connecta/json-schema");
if (typeof jsonSchema.Validator !== "function") {
throw new Error("missing Validator re-export");
Expand Down Expand Up @@ -725,6 +728,41 @@ try {
await stopChild(deployment);
}

// Exercise the installed package and real QuickJS with an original v0.23
// secret. The configured static bearer is different, so only the restored
// module can admit this doctor request.
const legacyTokens = JSON.parse(await readFile(
join(root, "test", "fixtures", "access-tokens-v023.json"), "utf8",
));
const legacyState = join(generatedRoot, "legacy-token-state.json");
await writeFile(legacyState, JSON.stringify(Object.fromEntries(
Object.entries(legacyTokens.records).map(([key, value]) => [key, { value }]),
)));
await writeFile(join(generatedRoot, "src", "managed-tokens.ts"),
'import { accessTokens } from "@zackbart/connecta/auth/access-tokens";\n' +
generatedSource.replace(createCall, createCall + ' accessTokens: accessTokens(storage),\n'),
);
const legacyPort = await freePort();
let legacyOutput = "";
const legacyDeployment = spawn(generatedTsx, ["src/managed-tokens.ts"], {
cwd: generatedRoot,
env: { ...process.env, CONNECTA_TOKEN: smokeToken, CONNECTA_STATE_FILE: legacyState, PORT: String(legacyPort) },
stdio: ["ignore", "pipe", "pipe"],
});
const retainLegacyOutput = chunk => { legacyOutput = (legacyOutput + chunk.toString()).slice(-8_000); };
legacyDeployment.stdout.on("data", retainLegacyOutput);
legacyDeployment.stderr.on("data", retainLegacyOutput);
try {
await waitForHealth(`http://127.0.0.1:${legacyPort}/health`, legacyDeployment, () => legacyOutput);
const doctorOutput = run(
join(generatedRoot, "node_modules", ".bin", process.platform === "win32" ? "connecta.cmd" : "connecta"),
["doctor", "--url", `http://127.0.0.1:${legacyPort}`], generatedRoot,
{ CONNECTA_TOKEN: legacyTokens.bound.token },
);
if (!doctorOutput.includes("QuickJS executed")) throw new Error("Legacy token doctor did not prove execution");
console.log("v0.23 token compatibility: doctor passed with the original secret");
} finally { await stopChild(legacyDeployment); }

// The generated deployment is also the container: `connecta init` ships the
// Dockerfile and Compose file, so the source that just answered over tsx has
// to answer again from `docker compose up` (#344). Docker is not a
Expand Down
Loading
Loading