@zackbart/connecta 0.26.1 → 0.26.2

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 CHANGED
@@ -4,6 +4,31 @@ All notable changes to this package are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.26.2 — 2026-09-27
8
+
9
+ This patch restores named client tokens as an optional auth module. Deployments
10
+ upgrading from v0.23 can keep their existing stored records and client secrets,
11
+ without rotation, by configuring `accessTokens(storage)` with the same storage
12
+ namespace and preserving their identity grants. The old boolean configuration
13
+ must be replaced. Deployments that omit the module are unchanged. The eight MCP
14
+ tools remain unchanged, and the Node template now pins 0.26.2.
15
+
16
+ ### Added
17
+
18
+ - `@zackbart/connecta/auth/access-tokens` verifies v0.23 `cta_…` tokens and
19
+ restores create, show-once, list, rename, and revoke in the operator UI.
20
+ `identity.accessTokenManagement` explicitly permits interactive operators;
21
+ client tokens cannot administer tokens or connection credentials. New issuance
22
+ requires atomic storage and reserves capacity across concurrent instances.
23
+
24
+ ### Fixed
25
+
26
+ - Existing token ids, activity labels, and principal bindings survive the upgrade.
27
+ Malformed or corrupt records fail closed, including principal fields that
28
+ JavaScript regular expressions previously coerced into strings. Revocation
29
+ deletes the admission lookup before updating metadata.
30
+
31
+
7
32
  ## 0.26.1 — 2026-09-26
8
33
 
9
34
  This patch fixes slow OAuth restarts, unbounded downstream authorization waits,
package/README.md CHANGED
@@ -132,3 +132,14 @@ breaks and what a deployment can ignore.
132
132
  Built for its author's deployments first and published openly. Breaking
133
133
  changes are expected before 1.0. See the [changelog](./CHANGELOG.md) and
134
134
  [security policy](./SECURITY.md).
135
+
136
+ ### Existing client tokens
137
+
138
+ Upgrading from v0.23 does not require rotating managed `cta_…` tokens. Import
139
+ `accessTokens` from `@zackbart/connecta/auth/access-tokens`, replace the old
140
+ `accessTokens: true` with `accessTokens: accessTokens(storage)`, and keep the
141
+ same persistent storage namespace and identity/tool/pool grant rules. Enable
142
+ `identity.accessTokenManagement` only for the interactive operators who should
143
+ manage tokens. New issuance needs storage with atomic `compareAndSet`; older
144
+ storage adapters can still verify existing tokens. See the package's
145
+ `documentation/auth.md` for the migration and storage requirements.
@@ -0,0 +1,38 @@
1
+ import type { IdentityReference, InboundAuth, KVStorage } from "./types.js";
2
+ import type { AccessTokensModule } from "./module-contracts.js";
3
+ export interface AccessTokenMetadata {
4
+ id: string;
5
+ name: string;
6
+ tokenPrefix: string;
7
+ createdAt: string;
8
+ revokedAt?: string;
9
+ }
10
+ export interface CreatedAccessToken {
11
+ token: string;
12
+ accessToken: AccessTokenMetadata;
13
+ }
14
+ /**
15
+ * Deployment-scoped personal access tokens. Secret material is never
16
+ * recoverable: authentication indexes a SHA-256 digest of a random 256-bit
17
+ * token, while separately enumerable metadata powers operator management.
18
+ */
19
+ export declare class AccessTokenManager {
20
+ private readonly storage;
21
+ readonly auth: InboundAuth;
22
+ private readonly maxActive;
23
+ constructor(storage: KVStorage, options?: {
24
+ maxActive?: number;
25
+ });
26
+ private read;
27
+ list(): Promise<AccessTokenMetadata[]>;
28
+ create(name: unknown, createdBy: string | IdentityReference): Promise<CreatedAccessToken>;
29
+ rename(id: string, name: unknown): Promise<AccessTokenMetadata | null>;
30
+ revoke(id: string, revokedBy: string): Promise<AccessTokenMetadata | null>;
31
+ private updateRecord;
32
+ private updateActive;
33
+ private authorize;
34
+ }
35
+ /** Opt in using the same storage namespace that held the v0.23 records. */
36
+ export declare function accessTokens(storage: KVStorage, options?: {
37
+ maxActive?: number;
38
+ }): AccessTokensModule;
@@ -0,0 +1,294 @@
1
+ import { routeAccessTokens } from "./routes/access-tokens.js";
2
+ import { validIdentityReference } from "./identity.js";
3
+ const TOKEN_PREFIX = "cta_";
4
+ const TOKEN_BYTES = 32;
5
+ const TOKEN_VALUE_RE = /^cta_[A-Za-z0-9_-]{43}$/;
6
+ const RECORD_PREFIX = "access-token:v1:record:";
7
+ const LOOKUP_PREFIX = "access-token:v1:lookup:";
8
+ const MAX_NAME_CHARACTERS = 80;
9
+ const DEFAULT_MAX_ACTIVE = 100;
10
+ const MAX_CONFIGURED_ACTIVE = 1_000;
11
+ const ACTIVE_KEY = "access-token:v1:active";
12
+ const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
13
+ const encoder = new TextEncoder();
14
+ function recordKey(id) {
15
+ return `${RECORD_PREFIX}${id}`;
16
+ }
17
+ function lookupKey(hash) {
18
+ return `${LOOKUP_PREFIX}${hash}`;
19
+ }
20
+ function bytesToBase64Url(bytes) {
21
+ let binary = "";
22
+ for (const byte of bytes)
23
+ binary += String.fromCharCode(byte);
24
+ return btoa(binary)
25
+ .replaceAll("+", "-")
26
+ .replaceAll("/", "_")
27
+ .replace(/=+$/u, "");
28
+ }
29
+ function bytesToHex(bytes) {
30
+ return [...bytes]
31
+ .map((byte) => byte.toString(16).padStart(2, "0"))
32
+ .join("");
33
+ }
34
+ async function hashToken(token) {
35
+ return bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(token))));
36
+ }
37
+ function normalizeName(value) {
38
+ if (typeof value !== "string") {
39
+ throw new Error("Token name must be a string");
40
+ }
41
+ const compact = value.replace(/\s+/gu, " ").trim();
42
+ if (!compact)
43
+ throw new Error("Token name cannot be empty");
44
+ if (Array.from(compact).length > MAX_NAME_CHARACTERS) {
45
+ throw new Error(`Token name cannot exceed ${MAX_NAME_CHARACTERS} characters`);
46
+ }
47
+ return compact;
48
+ }
49
+ function parseRecord(raw) {
50
+ try {
51
+ const value = JSON.parse(raw);
52
+ if (value.version !== 1 ||
53
+ typeof value.id !== "string" ||
54
+ !ID_RE.test(value.id) ||
55
+ typeof value.name !== "string" ||
56
+ !value.name.trim() ||
57
+ Array.from(value.name).length > MAX_NAME_CHARACTERS ||
58
+ typeof value.tokenHash !== "string" ||
59
+ !/^[0-9a-f]{64}$/u.test(value.tokenHash) ||
60
+ typeof value.tokenPrefix !== "string" ||
61
+ !/^cta_[A-Za-z0-9_-]{8}$/u.test(value.tokenPrefix) ||
62
+ typeof value.createdAt !== "string" ||
63
+ !Number.isFinite(Date.parse(value.createdAt)) ||
64
+ typeof value.createdBy !== "string" ||
65
+ (value.principal !== undefined &&
66
+ !validIdentityReference(value.principal)) ||
67
+ (value.revokedAt !== undefined &&
68
+ (typeof value.revokedAt !== "string" || !value.revokedAt || !Number.isFinite(Date.parse(value.revokedAt)))) ||
69
+ (value.revokedBy !== undefined &&
70
+ typeof value.revokedBy !== "string")) {
71
+ throw new Error("invalid token record");
72
+ }
73
+ return value;
74
+ }
75
+ catch {
76
+ throw new Error("Stored access token metadata is invalid or corrupted");
77
+ }
78
+ }
79
+ function parseLookup(raw) {
80
+ try {
81
+ const value = JSON.parse(raw);
82
+ return value.version === 1 && typeof value.id === "string" && ID_RE.test(value.id)
83
+ ? { version: 1, id: value.id }
84
+ : null;
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ function metadata(record) {
91
+ return {
92
+ id: record.id,
93
+ name: record.name,
94
+ tokenPrefix: record.tokenPrefix,
95
+ createdAt: record.createdAt,
96
+ ...(record.revokedAt ? { revokedAt: record.revokedAt } : {}),
97
+ };
98
+ }
99
+ function unauthorized() {
100
+ return {
101
+ ok: false,
102
+ response: new Response(JSON.stringify({ error: "unauthorized" }), {
103
+ status: 401,
104
+ headers: {
105
+ "Content-Type": "application/json",
106
+ "WWW-Authenticate": "Bearer",
107
+ },
108
+ }),
109
+ };
110
+ }
111
+ /**
112
+ * Deployment-scoped personal access tokens. Secret material is never
113
+ * recoverable: authentication indexes a SHA-256 digest of a random 256-bit
114
+ * token, while separately enumerable metadata powers operator management.
115
+ */
116
+ export class AccessTokenManager {
117
+ storage;
118
+ auth;
119
+ maxActive;
120
+ constructor(storage, options = {}) {
121
+ this.storage = storage;
122
+ if (!storage.list) {
123
+ throw new Error("accessTokens requires a storage adapter that implements list(prefix)");
124
+ }
125
+ const maxActive = options.maxActive ?? DEFAULT_MAX_ACTIVE;
126
+ if (!Number.isInteger(maxActive) ||
127
+ maxActive < 1 ||
128
+ maxActive > MAX_CONFIGURED_ACTIVE) {
129
+ throw new Error(`accessTokens.maxActive must be a whole number from 1 to ${MAX_CONFIGURED_ACTIVE}`);
130
+ }
131
+ this.maxActive = maxActive;
132
+ this.auth = {
133
+ kind: "access_token",
134
+ activityActorNamespace: "connecta:access-tokens:v1",
135
+ activityActorLabel: async (id) => {
136
+ try {
137
+ return (await this.read(id))?.name;
138
+ }
139
+ catch {
140
+ return undefined;
141
+ }
142
+ },
143
+ authorize: (request) => this.authorize(request).catch(() => unauthorized()),
144
+ };
145
+ }
146
+ async read(id) {
147
+ if (!ID_RE.test(id))
148
+ return null;
149
+ const raw = await this.storage.get(recordKey(id));
150
+ const record = raw ? parseRecord(raw) : null;
151
+ if (record && record.id !== id)
152
+ throw new Error("Stored access token id mismatch");
153
+ return record;
154
+ }
155
+ async list() {
156
+ const keys = await this.storage.list(RECORD_PREFIX);
157
+ const records = await Promise.all(keys.map(async (key) => {
158
+ const raw = await this.storage.get(key);
159
+ return raw ? parseRecord(raw) : null;
160
+ }));
161
+ return records
162
+ .filter((record) => Boolean(record))
163
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
164
+ .map(metadata);
165
+ }
166
+ async create(name, createdBy) {
167
+ const normalizedName = normalizeName(name);
168
+ if (!this.storage.compareAndSet) {
169
+ throw new Error("Creating access tokens requires atomic compareAndSet storage");
170
+ }
171
+ if (typeof createdBy !== "string" && !validIdentityReference(createdBy)) {
172
+ throw new Error("Invalid token owner");
173
+ }
174
+ const secretBytes = crypto.getRandomValues(new Uint8Array(TOKEN_BYTES));
175
+ const token = TOKEN_PREFIX + bytesToBase64Url(secretBytes);
176
+ const hash = await hashToken(token);
177
+ if (await this.storage.get(lookupKey(hash))) {
178
+ throw new Error("Access token collision; create another token");
179
+ }
180
+ const record = {
181
+ version: 1,
182
+ id: crypto.randomUUID(),
183
+ name: normalizedName,
184
+ tokenHash: hash,
185
+ tokenPrefix: token.slice(0, 12),
186
+ createdAt: new Date().toISOString(),
187
+ createdBy: typeof createdBy === "string"
188
+ ? createdBy
189
+ : `${createdBy.namespace}:${createdBy.id}`,
190
+ ...(typeof createdBy === "string"
191
+ ? {}
192
+ : { principal: { ...createdBy } }),
193
+ };
194
+ await this.updateActive(record.id, true);
195
+ await this.storage.set(recordKey(record.id), JSON.stringify(record));
196
+ // A failed lookup write may have committed. Keep its reservation and
197
+ // metadata, so a manager can revoke it without ever returning the secret.
198
+ await this.storage.set(lookupKey(hash), JSON.stringify({ version: 1, id: record.id }));
199
+ return { token, accessToken: metadata(record) };
200
+ }
201
+ async rename(id, name) {
202
+ const normalized = normalizeName(name);
203
+ const record = await this.updateRecord(id, current => ({ ...current, name: normalized }));
204
+ return record ? metadata(record) : null;
205
+ }
206
+ async revoke(id, revokedBy) {
207
+ const record = await this.read(id);
208
+ if (!record)
209
+ return null;
210
+ // Remove admission first. A metadata failure cannot leave a successful
211
+ // revocation's lookup alive. Retry deletion even if already marked revoked.
212
+ await this.storage.delete(lookupKey(record.tokenHash));
213
+ const updated = await this.updateRecord(id, current => current.revokedAt ? current : {
214
+ ...current, revokedAt: new Date().toISOString(), revokedBy,
215
+ });
216
+ if (this.storage.compareAndSet)
217
+ await this.updateActive(id, false);
218
+ return updated ? metadata(updated) : null;
219
+ }
220
+ async updateRecord(id, update) {
221
+ if (!ID_RE.test(id))
222
+ return null;
223
+ for (let attempt = 0; attempt < 32; attempt++) {
224
+ const raw = await this.storage.get(recordKey(id));
225
+ if (!raw)
226
+ return null;
227
+ const record = parseRecord(raw);
228
+ if (record.id !== id)
229
+ throw new Error("Stored access token id mismatch");
230
+ const next = update(record);
231
+ if (this.storage.compareAndSet) {
232
+ // A rename racing revocation must retain revokedAt, including when
233
+ // an in-flight create has not yet published its lookup.
234
+ if (!await this.storage.compareAndSet(recordKey(id), raw, JSON.stringify(next)))
235
+ continue;
236
+ }
237
+ else {
238
+ // Legacy adapters cannot create, so no late lookup writer exists.
239
+ // Revocation's deleted lookup still refuses a stale metadata write.
240
+ await this.storage.set(recordKey(id), JSON.stringify(next));
241
+ }
242
+ return next;
243
+ }
244
+ throw new Error("Access token metadata is busy; retry the operation");
245
+ }
246
+ async updateActive(id, adding) {
247
+ const cas = this.storage.compareAndSet;
248
+ if (!cas)
249
+ throw new Error("Creating access tokens requires atomic compareAndSet storage");
250
+ for (let attempt = 0; attempt < 32; attempt++) {
251
+ const raw = await this.storage.get(ACTIVE_KEY);
252
+ const ids = raw === null
253
+ ? (await this.list()).filter(token => !token.revokedAt).map(token => token.id)
254
+ : JSON.parse(raw);
255
+ if (!Array.isArray(ids) || !ids.every(value => typeof value === "string" && ID_RE.test(value)) || new Set(ids).size !== ids.length) {
256
+ throw new Error("Stored access token capacity is invalid");
257
+ }
258
+ if (adding && ids.length >= this.maxActive)
259
+ throw new Error(`This deployment already has the maximum of ${this.maxActive} active access tokens`);
260
+ const next = adding ? [...ids, id] : ids.filter(value => value !== id);
261
+ if (await cas.call(this.storage, ACTIVE_KEY, raw, JSON.stringify(next)))
262
+ return;
263
+ }
264
+ throw new Error("Access token capacity is busy; retry the operation");
265
+ }
266
+ async authorize(request) {
267
+ const header = request.headers.get("authorization") ?? "";
268
+ const match = /^Bearer\s+(.+)$/iu.exec(header);
269
+ const token = match?.[1];
270
+ if (!token || !TOKEN_VALUE_RE.test(token))
271
+ return unauthorized();
272
+ const hash = await hashToken(token);
273
+ const lookupRaw = await this.storage.get(lookupKey(hash));
274
+ if (!lookupRaw)
275
+ return unauthorized();
276
+ const lookup = parseLookup(lookupRaw);
277
+ if (!lookup)
278
+ return unauthorized();
279
+ const record = await this.read(lookup.id);
280
+ if (!record || record.revokedAt || record.tokenHash !== hash || record.tokenPrefix !== token.slice(0, 12)) {
281
+ return unauthorized();
282
+ }
283
+ return {
284
+ ok: true,
285
+ subjectId: record.id,
286
+ ...(record.principal ? { principal: { ...record.principal } } : {}),
287
+ };
288
+ }
289
+ }
290
+ /** Opt in using the same storage namespace that held the v0.23 records. */
291
+ export function accessTokens(storage, options = {}) {
292
+ const manager = new AccessTokenManager(storage, options);
293
+ return { auth: manager.auth, handle: context => routeAccessTokens(context, manager) };
294
+ }
package/dist/identity.js CHANGED
@@ -2,6 +2,8 @@ const IDENTITY_PART_RE = /^[\x21-\x7e]{1,256}$/;
2
2
  const encoder = new TextEncoder();
3
3
  export function validIdentityReference(value) {
4
4
  return Boolean(value &&
5
+ typeof value.namespace === "string" &&
6
+ typeof value.id === "string" &&
5
7
  IDENTITY_PART_RE.test(value.namespace) &&
6
8
  IDENTITY_PART_RE.test(value.id));
7
9
  }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { CredentialVault } from "./credential-contract.js";
2
2
  import { Registry } from "./registry.js";
3
3
  import type { ConnectorGrant } from "./connector-access.js";
4
- import type { ActivityModule, ArtifactsModule, OperatorSurface } from "./module-contracts.js";
5
- export type { ActivityModule, ArtifactsModule, OperatorSurface, } from "./module-contracts.js";
4
+ import type { AccessTokensModule, ActivityModule, ArtifactsModule, OperatorSurface } from "./module-contracts.js";
5
+ export type { AccessTokensModule, ActivityModule, ArtifactsModule, OperatorSurface, } from "./module-contracts.js";
6
6
  export type { CredentialVault, CredentialMetadata } from "./credential-contract.js";
7
7
  import type { AuthenticatedIdentity, Connector, Executor, IdentityReference, InboundAuth, KVStorage, Logger } from "./types.js";
8
8
  /** Tool-catalog caching, persistence, stale fallback, and probe deadlines. */
@@ -180,6 +180,8 @@ export interface ConnectaIdentityConfig {
180
180
  activityAccess?(principal: Readonly<IdentityReference>): boolean | Promise<boolean>;
181
181
  /** Shared credential and OAuth administration. Defaults to none. */
182
182
  credentialAdministration?(identity: Readonly<AuthenticatedIdentity>): ConnectorPermission | Promise<ConnectorPermission>;
183
+ /** Client-token lifecycle management by interactive humans. Defaults to false. */
184
+ accessTokenManagement?(identity: Readonly<AuthenticatedIdentity>): boolean | Promise<boolean>;
183
185
  /** Connecting or changing the caller's personal account. Defaults to none. */
184
186
  personalConnection?(identity: Readonly<AuthenticatedIdentity>): ConnectorPermission | Promise<ConnectorPermission>;
185
187
  }
@@ -230,6 +232,8 @@ export interface ConnectaConfig {
230
232
  * because the links it hands out are shared.
231
233
  */
232
234
  artifacts?: ArtifactsModule;
235
+ /** Optional managed client tokens, created by accessTokens() from /auth/access-tokens. */
236
+ accessTokens?: AccessTokensModule;
233
237
  /** Optional dedicated HTTPS origin that serves only artifact pages and their library. */
234
238
  artifactOrigin?: string;
235
239
  /** Tool-catalog caching, persistence, stale fallback, and probe deadlines. */
package/dist/index.js CHANGED
@@ -59,6 +59,7 @@ const CONFIG_SCHEMA = {
59
59
  activityAccess: null,
60
60
  credentialAdministration: null,
61
61
  personalConnection: null,
62
+ accessTokenManagement: null,
62
63
  },
63
64
  pools: null,
64
65
  storage: null,
@@ -68,6 +69,7 @@ const CONFIG_SCHEMA = {
68
69
  vault: null,
69
70
  ui: null,
70
71
  artifacts: null,
72
+ accessTokens: null,
71
73
  artifactOrigin: null,
72
74
  discovery: {
73
75
  concurrency: null,
@@ -144,7 +146,6 @@ function rejectUnknownOptions(paths) {
144
146
  throw new Error(`Unknown Connecta configuration option${paths.length === 1 ? "" : "s"}:\n` +
145
147
  paths.map((path) => `- ${path}`).join("\n") +
146
148
  (paths.includes("ConnectaConfig.credentials") ? "\nUse vault: encryptedCredentialVault(storage, key) from @zackbart/connecta/credentials." : "") +
147
- (paths.includes("ConnectaConfig.accessTokens") ? "\nConnecta-issued tokens were removed. Configure an inbound auth adapter instead." : "") +
148
149
  (paths.includes("ConnectaConfig.branding") ? "\nMove branding into ui: operatorUi({ branding }) from @zackbart/connecta/ui." : ""));
149
150
  }
150
151
  /** Reject JavaScript typos and removed options before construction does work. */
@@ -172,6 +173,12 @@ function assertKnownConfig(config) {
172
173
  typeof activity.recordTool !== "function")) {
173
174
  throw new Error("ConnectaConfig.activity must be created with activityHistory(...)");
174
175
  }
176
+ if (config.accessTokens !== undefined && (!config.accessTokens ||
177
+ typeof config.accessTokens.auth?.authorize !== "function" ||
178
+ config.accessTokens.auth.interactiveOperator ||
179
+ typeof config.accessTokens.handle !== "function")) {
180
+ throw new Error("ConnectaConfig.accessTokens must be created with accessTokens(storage) from @zackbart/connecta/auth/access-tokens; reuse your existing storage to preserve tokens");
181
+ }
175
182
  const artifacts = config.artifacts;
176
183
  if (artifacts !== undefined) {
177
184
  const connector = artifacts?.connector;
@@ -495,7 +502,10 @@ export function createConnecta(config) {
495
502
  const storage = config.storage ?? memoryStorage();
496
503
  const logger = resolveLogger(config.logger);
497
504
  const credentialVault = config.vault;
498
- const configuredAuth = normalizeAuth(config.auth);
505
+ const configuredAuth = normalizeAuth([
506
+ ...(config.accessTokens ? [config.accessTokens.auth] : []),
507
+ ...normalizeAuth(config.auth),
508
+ ]);
499
509
  const serverInfo = {
500
510
  ...config.serverInfo,
501
511
  name: config.serverInfo?.name ?? "connecta",
@@ -635,6 +645,7 @@ export function createConnecta(config) {
635
645
  activity: config.activity?.store,
636
646
  activityModule: config.activity,
637
647
  artifactsModule: config.artifacts,
648
+ accessTokens: config.accessTokens,
638
649
  activityReadGate: config.activity?.readGate,
639
650
  activityDeploymentId: config.activity?.deploymentId,
640
651
  executor,
@@ -37,3 +37,8 @@ export interface ArtifactsModule {
37
37
  /** Bind the optional refresh runner after core has built its registry and executor. */
38
38
  bindRefresh?(runtime: ArtifactRefreshRuntime): void;
39
39
  }
40
+ /** Optional client-token authentication and operator lifecycle. */
41
+ export interface AccessTokensModule {
42
+ readonly auth: import("./types.js").InboundAuth;
43
+ handle(context: RouteContext): Promise<Response | null>;
44
+ }