@zackbart/connecta 0.26.1 → 0.26.3

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,45 @@ All notable changes to this package are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.26.3 — 2026-09-28
8
+
9
+ This patch lets downstream OAuth connections use a public client metadata
10
+ URL when a server supports URL-based client IDs but restricts dynamic
11
+ registration. Existing OAuth configurations keep their current behavior.
12
+ The Node template now pins 0.26.3.
13
+
14
+ ### Added
15
+
16
+ - `remoteMcp` OAuth auth accepts `clientMetadataUrl` and default `scope`.
17
+ The SDK negotiates URL-based client IDs, falling back to dynamic registration
18
+ when unsupported. Existing PKCE, issuer binding, encrypted storage, refresh,
19
+ and disconnect remain in use. Invalid settings fail at construction.
20
+
21
+ ## 0.26.2 — 2026-09-27
22
+
23
+ This patch restores named client tokens as an optional auth module. Deployments
24
+ upgrading from v0.23 can keep their existing stored records and client secrets,
25
+ without rotation, by configuring `accessTokens(storage)` with the same storage
26
+ namespace and preserving their identity grants. The old boolean configuration
27
+ must be replaced. Deployments that omit the module are unchanged. The eight MCP
28
+ tools remain unchanged, and the Node template now pins 0.26.2.
29
+
30
+ ### Added
31
+
32
+ - `@zackbart/connecta/auth/access-tokens` verifies v0.23 `cta_…` tokens and
33
+ restores create, show-once, list, rename, and revoke in the operator UI.
34
+ `identity.accessTokenManagement` explicitly permits interactive operators;
35
+ client tokens cannot administer tokens or connection credentials. New issuance
36
+ requires atomic storage and reserves capacity across concurrent instances.
37
+
38
+ ### Fixed
39
+
40
+ - Existing token ids, activity labels, and principal bindings survive the upgrade.
41
+ Malformed or corrupt records fail closed, including principal fields that
42
+ JavaScript regular expressions previously coerced into strings. Revocation
43
+ deletes the admission lookup before updating metadata.
44
+
45
+
7
46
  ## 0.26.1 — 2026-09-26
8
47
 
9
48
  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
+ }
@@ -850,6 +850,8 @@ export class KvOAuthProvider {
850
850
  signal;
851
851
  clientBinding;
852
852
  onReset;
853
+ scope;
854
+ clientMetadataUrl;
853
855
  /**
854
856
  * The reset generation this provider's flow started under. Every OAuth value
855
857
  * it writes carries this epoch, so a late write can land after a reset without
@@ -869,7 +871,9 @@ export class KvOAuthProvider {
869
871
  /** Stable connector configuration bound to dynamically registered clients. */
870
872
  clientBinding,
871
873
  /** Request-local observer so the operator route drains every reset it began. */
872
- onReset) {
874
+ onReset,
875
+ /** SDK uses this only when the authorization server advertises support. */
876
+ clientMetadataUrl, scope) {
873
877
  this.connectorId = connectorId;
874
878
  this.storage = storage;
875
879
  this.redirectUri = redirectUri;
@@ -879,6 +883,9 @@ export class KvOAuthProvider {
879
883
  this.signal = signal;
880
884
  this.clientBinding = clientBinding;
881
885
  this.onReset = onReset;
886
+ this.scope = scope;
887
+ if (clientMetadataUrl !== undefined)
888
+ this.clientMetadataUrl = clientMetadataUrl;
882
889
  }
883
890
  /**
884
891
  * Stamp the force-reauth generation the current connect flow started under.
@@ -1339,6 +1346,7 @@ export class KvOAuthProvider {
1339
1346
  grant_types: ["authorization_code", "refresh_token"],
1340
1347
  response_types: ["code"],
1341
1348
  token_endpoint_auth_method: "none",
1349
+ ...(this.scope !== undefined ? { scope: this.scope } : {}),
1342
1350
  };
1343
1351
  }
1344
1352
  async clientInformation(ctx) {
@@ -38,6 +38,10 @@ export type RemoteMcpAuth = {
38
38
  headers: Record<string, string>;
39
39
  } | RemoteMcpCredentialAuth | {
40
40
  type: "oauth";
41
+ /** Public HTTPS client metadata document, for servers supporting URL-based client IDs. */
42
+ clientMetadataUrl?: string;
43
+ /** Space-separated default scopes when the resource server does not advertise them. */
44
+ scope?: string;
41
45
  };
42
46
  /**
43
47
  * Apply a maintained provider's slot copy and header framing to credential
@@ -396,6 +396,22 @@ function learnedUrlSafeFetch(connectorId, configured, baseFetch) {
396
396
  * server or hide other connectors).
397
397
  */
398
398
  export function remoteMcp(id, opts) {
399
+ const clientMetadataUrl = opts.auth?.type === "oauth" ? opts.auth.clientMetadataUrl : undefined;
400
+ const oauthScope = opts.auth?.type === "oauth" ? opts.auth.scope : undefined;
401
+ if (oauthScope !== undefined && !/^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/.test(oauthScope)) {
402
+ throw new Error(`[connecta] connector "${id}" OAuth scope must contain space-separated scope tokens.`);
403
+ }
404
+ if (clientMetadataUrl !== undefined) {
405
+ let valid = false;
406
+ try {
407
+ const url = new URL(clientMetadataUrl);
408
+ valid = url.protocol === "https:" && url.pathname !== "/" &&
409
+ !url.username && !url.password && !url.hash;
410
+ }
411
+ catch { /* Invalid configuration is rejected below without echoing its value. */ }
412
+ if (!valid)
413
+ throw new Error(`[connecta] connector "${id}" clientMetadataUrl must be an HTTPS URL with a non-root path, without credentials or a fragment.`);
414
+ }
399
415
  if (opts.authScope === "personal" && opts.auth?.type === "headers") {
400
416
  throw new Error(`[connecta] connector "${id}" cannot combine authScope "personal" ` +
401
417
  "with static headers. Use credential or OAuth auth so each principal " +
@@ -597,11 +613,13 @@ export function remoteMcp(id, opts) {
597
613
  grant_types: ["authorization_code", "refresh_token"],
598
614
  response_types: ["code"],
599
615
  token_endpoint_auth_method: "none",
616
+ ...(oauthScope !== undefined ? { scope: oauthScope } : {}),
600
617
  },
601
618
  authScope: opts.authScope ?? "shared",
602
619
  versionNegotiation: opts.versionNegotiation ?? "auto",
603
620
  redirects: opts.redirects ?? "none",
604
- }), (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset));
621
+ clientMetadataUrl,
622
+ }), (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset), clientMetadataUrl, oauthScope);
605
623
  if (state)
606
624
  state.provider = provider;
607
625
  return provider;
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
+ }