@zackbart/connecta 0.10.0 → 0.10.1

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +10 -4
  3. package/dist/access-tokens.d.ts +32 -0
  4. package/dist/access-tokens.d.ts.map +1 -0
  5. package/dist/access-tokens.js +225 -0
  6. package/dist/access-tokens.js.map +1 -0
  7. package/dist/index.d.ts +11 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +12 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/operator-ui/generated.d.ts +2 -2
  12. package/dist/operator-ui/generated.d.ts.map +1 -1
  13. package/dist/operator-ui/generated.js +2 -2
  14. package/dist/operator-ui/generated.js.map +1 -1
  15. package/dist/operator-ui/model.d.ts +2 -0
  16. package/dist/operator-ui/model.d.ts.map +1 -1
  17. package/dist/operator-ui/model.js.map +1 -1
  18. package/dist/routes/access-tokens.d.ts +7 -0
  19. package/dist/routes/access-tokens.d.ts.map +1 -0
  20. package/dist/routes/access-tokens.js +84 -0
  21. package/dist/routes/access-tokens.js.map +1 -0
  22. package/dist/routes/shared.d.ts +3 -0
  23. package/dist/routes/shared.d.ts.map +1 -1
  24. package/dist/routes/shared.js.map +1 -1
  25. package/dist/routes/ui.d.ts.map +1 -1
  26. package/dist/routes/ui.js +9 -1
  27. package/dist/routes/ui.js.map +1 -1
  28. package/dist/server.d.ts.map +1 -1
  29. package/dist/server.js +5 -0
  30. package/dist/server.js.map +1 -1
  31. package/dist/storage/file.d.ts.map +1 -1
  32. package/dist/storage/file.js +5 -0
  33. package/dist/storage/file.js.map +1 -1
  34. package/dist/storage/memory.d.ts.map +1 -1
  35. package/dist/storage/memory.js +8 -0
  36. package/dist/storage/memory.js.map +1 -1
  37. package/dist/types.d.ts +5 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/ui.d.ts +4 -4
  40. package/dist/ui.d.ts.map +1 -1
  41. package/dist/ui.js +44 -1
  42. package/dist/ui.js.map +1 -1
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +1 -1
  46. package/src/access-tokens.ts +289 -0
  47. package/src/index.ts +33 -1
  48. package/src/operator-ui/browser.css +63 -0
  49. package/src/operator-ui/browser.ts +288 -2
  50. package/src/operator-ui/generated.ts +2 -2
  51. package/src/operator-ui/model.ts +6 -0
  52. package/src/routes/access-tokens.ts +115 -0
  53. package/src/routes/shared.ts +3 -0
  54. package/src/routes/ui.ts +9 -0
  55. package/src/server.ts +5 -0
  56. package/src/storage/file.ts +5 -0
  57. package/src/storage/memory.ts +8 -0
  58. package/src/types.ts +5 -0
  59. package/src/ui.ts +50 -1
  60. package/src/version.ts +1 -1
package/dist/version.d.ts CHANGED
@@ -4,5 +4,5 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export declare const CONNECTA_VERSION = "0.10.0";
7
+ export declare const CONNECTA_VERSION = "0.10.1";
8
8
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -4,5 +4,5 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export const CONNECTA_VERSION = "0.10.0";
7
+ export const CONNECTA_VERSION = "0.10.1";
8
8
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zackbart/connecta",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "One MCP to rule them all — a single MCP endpoint aggregating many downstream connectors behind a code-first surface of seven meta-tools.",
@@ -0,0 +1,289 @@
1
+ import type { AuthResult, InboundAuth, KVStorage } from "./types.js";
2
+
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 encoder = new TextEncoder();
12
+
13
+ interface StoredAccessToken {
14
+ version: 1;
15
+ id: string;
16
+ name: string;
17
+ tokenHash: string;
18
+ tokenPrefix: string;
19
+ createdAt: string;
20
+ createdBy: string;
21
+ revokedAt?: string;
22
+ revokedBy?: string;
23
+ }
24
+
25
+ interface TokenLookup {
26
+ version: 1;
27
+ id: string;
28
+ }
29
+
30
+ export interface AccessTokenMetadata {
31
+ id: string;
32
+ name: string;
33
+ tokenPrefix: string;
34
+ createdAt: string;
35
+ revokedAt?: string;
36
+ }
37
+
38
+ export interface CreatedAccessToken {
39
+ token: string;
40
+ accessToken: AccessTokenMetadata;
41
+ }
42
+
43
+ function recordKey(id: string): string {
44
+ return `${RECORD_PREFIX}${id}`;
45
+ }
46
+
47
+ function lookupKey(hash: string): string {
48
+ return `${LOOKUP_PREFIX}${hash}`;
49
+ }
50
+
51
+ function bytesToBase64Url(bytes: Uint8Array): string {
52
+ let binary = "";
53
+ for (const byte of bytes) binary += String.fromCharCode(byte);
54
+ return btoa(binary)
55
+ .replaceAll("+", "-")
56
+ .replaceAll("/", "_")
57
+ .replace(/=+$/u, "");
58
+ }
59
+
60
+ function bytesToHex(bytes: Uint8Array): string {
61
+ return [...bytes]
62
+ .map((byte) => byte.toString(16).padStart(2, "0"))
63
+ .join("");
64
+ }
65
+
66
+ async function hashToken(token: string): Promise<string> {
67
+ return bytesToHex(
68
+ new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(token))),
69
+ );
70
+ }
71
+
72
+ function normalizeName(value: unknown): string {
73
+ if (typeof value !== "string") {
74
+ throw new Error("Token name must be a string");
75
+ }
76
+ const compact = value.replace(/\s+/gu, " ").trim();
77
+ if (!compact) throw new Error("Token name cannot be empty");
78
+ if (Array.from(compact).length > MAX_NAME_CHARACTERS) {
79
+ throw new Error(
80
+ `Token name cannot exceed ${MAX_NAME_CHARACTERS} characters`,
81
+ );
82
+ }
83
+ return compact;
84
+ }
85
+
86
+ function parseRecord(raw: string): StoredAccessToken {
87
+ try {
88
+ const value = JSON.parse(raw) as Partial<StoredAccessToken>;
89
+ if (
90
+ value.version !== 1 ||
91
+ typeof value.id !== "string" ||
92
+ !/^[0-9a-f-]{36}$/u.test(value.id) ||
93
+ typeof value.name !== "string" ||
94
+ typeof value.tokenHash !== "string" ||
95
+ !/^[0-9a-f]{64}$/u.test(value.tokenHash) ||
96
+ typeof value.tokenPrefix !== "string" ||
97
+ typeof value.createdAt !== "string" ||
98
+ typeof value.createdBy !== "string" ||
99
+ (value.revokedAt !== undefined &&
100
+ typeof value.revokedAt !== "string") ||
101
+ (value.revokedBy !== undefined &&
102
+ typeof value.revokedBy !== "string")
103
+ ) {
104
+ throw new Error("invalid token record");
105
+ }
106
+ return value as StoredAccessToken;
107
+ } catch {
108
+ throw new Error("Stored access token metadata is invalid or corrupted");
109
+ }
110
+ }
111
+
112
+ function parseLookup(raw: string): TokenLookup | null {
113
+ try {
114
+ const value = JSON.parse(raw) as Partial<TokenLookup>;
115
+ return value.version === 1 && typeof value.id === "string"
116
+ ? { version: 1, id: value.id }
117
+ : null;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ function metadata(record: StoredAccessToken): AccessTokenMetadata {
124
+ return {
125
+ id: record.id,
126
+ name: record.name,
127
+ tokenPrefix: record.tokenPrefix,
128
+ createdAt: record.createdAt,
129
+ ...(record.revokedAt ? { revokedAt: record.revokedAt } : {}),
130
+ };
131
+ }
132
+
133
+ function unauthorized(): AuthResult {
134
+ return {
135
+ ok: false,
136
+ response: new Response(JSON.stringify({ error: "unauthorized" }), {
137
+ status: 401,
138
+ headers: {
139
+ "Content-Type": "application/json",
140
+ "WWW-Authenticate": "Bearer",
141
+ },
142
+ }),
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Deployment-scoped personal access tokens. Secret material is never
148
+ * recoverable: authentication indexes a SHA-256 digest of a random 256-bit
149
+ * token, while separately enumerable metadata powers operator management.
150
+ */
151
+ export class AccessTokenManager {
152
+ readonly auth: InboundAuth;
153
+ private readonly maxActive: number;
154
+
155
+ constructor(
156
+ private readonly storage: KVStorage,
157
+ options: { maxActive?: number } = {},
158
+ ) {
159
+ if (!storage.list) {
160
+ throw new Error(
161
+ "accessTokens requires a storage adapter that implements list(prefix)",
162
+ );
163
+ }
164
+ const maxActive = options.maxActive ?? DEFAULT_MAX_ACTIVE;
165
+ if (
166
+ !Number.isInteger(maxActive) ||
167
+ maxActive < 1 ||
168
+ maxActive > MAX_CONFIGURED_ACTIVE
169
+ ) {
170
+ throw new Error(
171
+ `accessTokens.maxActive must be a whole number from 1 to ${MAX_CONFIGURED_ACTIVE}`,
172
+ );
173
+ }
174
+ this.maxActive = maxActive;
175
+ this.auth = {
176
+ kind: "access_token",
177
+ activityActorNamespace: "connecta:access-tokens:v1",
178
+ activityActorLabel: async (id) => {
179
+ try {
180
+ return (await this.read(id))?.name;
181
+ } catch {
182
+ return undefined;
183
+ }
184
+ },
185
+ authorize: (request) => this.authorize(request),
186
+ };
187
+ }
188
+
189
+ private async read(id: string): Promise<StoredAccessToken | null> {
190
+ const raw = await this.storage.get(recordKey(id));
191
+ return raw ? parseRecord(raw) : null;
192
+ }
193
+
194
+ async list(): Promise<AccessTokenMetadata[]> {
195
+ const keys = await this.storage.list!(RECORD_PREFIX);
196
+ const records = await Promise.all(
197
+ keys.map(async (key) => {
198
+ const raw = await this.storage.get(key);
199
+ return raw ? parseRecord(raw) : null;
200
+ }),
201
+ );
202
+ return records
203
+ .filter((record): record is StoredAccessToken => Boolean(record))
204
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
205
+ .map(metadata);
206
+ }
207
+
208
+ async create(name: unknown, createdBy: string): Promise<CreatedAccessToken> {
209
+ const normalizedName = normalizeName(name);
210
+ const active = (await this.list()).filter((token) => !token.revokedAt);
211
+ if (active.length >= this.maxActive) {
212
+ throw new Error(
213
+ `This deployment already has the maximum of ${this.maxActive} active access tokens`,
214
+ );
215
+ }
216
+ const secretBytes = crypto.getRandomValues(new Uint8Array(TOKEN_BYTES));
217
+ const token = TOKEN_PREFIX + bytesToBase64Url(secretBytes);
218
+ const hash = await hashToken(token);
219
+ if (await this.storage.get(lookupKey(hash))) {
220
+ throw new Error("Access token collision; create another token");
221
+ }
222
+ const record: StoredAccessToken = {
223
+ version: 1,
224
+ id: crypto.randomUUID(),
225
+ name: normalizedName,
226
+ tokenHash: hash,
227
+ tokenPrefix: token.slice(0, 12),
228
+ createdAt: new Date().toISOString(),
229
+ createdBy,
230
+ };
231
+ await this.storage.set(recordKey(record.id), JSON.stringify(record));
232
+ try {
233
+ await this.storage.set(
234
+ lookupKey(hash),
235
+ JSON.stringify({ version: 1, id: record.id } satisfies TokenLookup),
236
+ );
237
+ } catch (error) {
238
+ await this.storage.delete(recordKey(record.id)).catch(() => {});
239
+ throw error;
240
+ }
241
+ return { token, accessToken: metadata(record) };
242
+ }
243
+
244
+ async rename(
245
+ id: string,
246
+ name: unknown,
247
+ ): Promise<AccessTokenMetadata | null> {
248
+ const record = await this.read(id);
249
+ if (!record) return null;
250
+ record.name = normalizeName(name);
251
+ await this.storage.set(recordKey(id), JSON.stringify(record));
252
+ return metadata(record);
253
+ }
254
+
255
+ async revoke(
256
+ id: string,
257
+ revokedBy: string,
258
+ ): Promise<AccessTokenMetadata | null> {
259
+ const record = await this.read(id);
260
+ if (!record) return null;
261
+ if (!record.revokedAt) {
262
+ // Admission disappears first. A metadata-write failure may leave the UI
263
+ // calling the record active, but can never leave a token labelled
264
+ // revoked while its lookup still admits requests.
265
+ await this.storage.delete(lookupKey(record.tokenHash));
266
+ record.revokedAt = new Date().toISOString();
267
+ record.revokedBy = revokedBy;
268
+ await this.storage.set(recordKey(id), JSON.stringify(record));
269
+ }
270
+ return metadata(record);
271
+ }
272
+
273
+ private async authorize(request: Request): Promise<AuthResult> {
274
+ const header = request.headers.get("authorization") ?? "";
275
+ const match = /^Bearer\s+(.+)$/iu.exec(header);
276
+ const token = match?.[1];
277
+ if (!token || !TOKEN_VALUE_RE.test(token)) return unauthorized();
278
+ const hash = await hashToken(token);
279
+ const lookupRaw = await this.storage.get(lookupKey(hash));
280
+ if (!lookupRaw) return unauthorized();
281
+ const lookup = parseLookup(lookupRaw);
282
+ if (!lookup) return unauthorized();
283
+ const record = await this.read(lookup.id);
284
+ if (!record || record.revokedAt || record.tokenHash !== hash) {
285
+ return unauthorized();
286
+ }
287
+ return { ok: true, subjectId: record.id };
288
+ }
289
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  credentialTestRule,
4
4
  describeCredentialTestMismatch,
5
5
  } from "./credentials.js";
6
+ import { AccessTokenManager } from "./access-tokens.js";
6
7
  import { Registry } from "./registry.js";
7
8
  import { createFetchHandler } from "./server.js";
8
9
  import { droppedBrandingUrls, droppedUiAuthUrls } from "./ui.js";
@@ -49,6 +50,12 @@ export interface ConnectaCredentialsConfig {
49
50
  encryptionKey?: string;
50
51
  }
51
52
 
53
+ /** Operator-issued credentials for clients connecting to this deployment. */
54
+ export interface ConnectaAccessTokensConfig {
55
+ /** Maximum simultaneously active access tokens. Defaults to 100. */
56
+ maxActive?: number;
57
+ }
58
+
52
59
  /** Tool-catalog caching, persistence, stale fallback, and probe deadlines. */
53
60
  export interface ConnectaDiscoveryConfig {
54
61
  /**
@@ -148,6 +155,11 @@ export interface ConnectaConfig {
148
155
  activity?: ConnectaActivityConfig;
149
156
  /** Operator credential vault settings. */
150
157
  credentials?: ConnectaCredentialsConfig;
158
+ /**
159
+ * Named, revocable Bearer tokens for MCP clients. Creation and mutation
160
+ * require an eligible Clerk operator; token secrets are returned once.
161
+ */
162
+ accessTokens?: ConnectaAccessTokensConfig;
151
163
  /** Tool-catalog caching, persistence, stale fallback, and probe deadlines. */
152
164
  discovery?: ConnectaDiscoveryConfig;
153
165
  /** Deployment-wide call deadlines and result paging threshold. */
@@ -468,6 +480,19 @@ export function createConnecta(config: ConnectaConfig): Connecta {
468
480
  const credentialVault = encryptionKey
469
481
  ? new CredentialVault(storage, encryptionKey)
470
482
  : undefined;
483
+ const configuredAuth = normalizeAuth(config.auth);
484
+ const accessTokens = config.accessTokens
485
+ ? new AccessTokenManager(storage, config.accessTokens)
486
+ : undefined;
487
+ if (
488
+ accessTokens &&
489
+ !configuredAuth.some((provider) => provider.uiAuth?.kind === "clerk")
490
+ ) {
491
+ throw new Error(
492
+ "accessTokens requires a Clerk auth provider: only an eligible Clerk " +
493
+ "operator may create, rename, or revoke deployment access tokens",
494
+ );
495
+ }
471
496
  const registry = new Registry(config.connectors, {
472
497
  storage,
473
498
  logger,
@@ -488,7 +513,9 @@ export function createConnecta(config: ConnectaConfig): Connecta {
488
513
  ? { maxBatchResultBytes: config.calls.maxBatchResultBytes }
489
514
  : {}),
490
515
  });
491
- const inboundAuth = normalizeAuth(config.auth);
516
+ const inboundAuth = normalizeAuth(
517
+ accessTokens ? [accessTokens.auth, ...configuredAuth] : configuredAuth,
518
+ );
492
519
  warnInsecureConfig(config, inboundAuth, logger);
493
520
  const requestAdmission = admissionController(
494
521
  config.admission?.requests,
@@ -542,6 +569,7 @@ export function createConnecta(config: ConnectaConfig): Connecta {
542
569
  ? { discoveryConcurrency: config.discovery.concurrency }
543
570
  : {}),
544
571
  ...(credentialVault !== undefined ? { credentialVault } : {}),
572
+ ...(accessTokens !== undefined ? { accessTokens } : {}),
545
573
  ...(config.deploymentInfo !== undefined
546
574
  ? { deploymentInfo: config.deploymentInfo }
547
575
  : {}),
@@ -580,6 +608,10 @@ export { validateToolInput } from "./validate.js";
580
608
  export type { ValidateToolInputOptions } from "./validate.js";
581
609
  export { bearerToken } from "./auth/bearer.js";
582
610
  export type { BearerTokenOptions } from "./auth/bearer.js";
611
+ export type {
612
+ AccessTokenMetadata,
613
+ CreatedAccessToken,
614
+ } from "./access-tokens.js";
583
615
  export { memoryStorage } from "./storage/memory.js";
584
616
  export { CONNECTA_VERSION } from "./version.js";
585
617
  // Registry is reachable through `Connecta.registry`, so its type is public;
@@ -285,6 +285,69 @@
285
285
  .credential-field input { min-width: 0; width: 100%; }
286
286
  .danger { text-decoration-style: double; }
287
287
 
288
+ .token-create {
289
+ border-bottom: 1px solid var(--rule);
290
+ border-top: 1px solid var(--rule);
291
+ padding: .75rem 0;
292
+ }
293
+ .token-create > label {
294
+ display: block;
295
+ margin-bottom: .5rem;
296
+ }
297
+ .token-create input { flex: 1 1 18rem; }
298
+ .token-create button,
299
+ .token-card button,
300
+ .token-reveal button {
301
+ align-items: center;
302
+ display: inline-flex;
303
+ min-height: 2.75rem;
304
+ }
305
+ .token-reveal {
306
+ background: var(--ink);
307
+ color: var(--paper);
308
+ margin-top: 1.5rem;
309
+ padding: 1rem 1.25rem;
310
+ }
311
+ .token-reveal .meta,
312
+ .token-reveal .cap { color: #bbb; }
313
+ .token-reveal-head,
314
+ .token-card-head {
315
+ align-items: baseline;
316
+ display: flex;
317
+ flex-wrap: wrap;
318
+ gap: .25rem var(--gap);
319
+ justify-content: space-between;
320
+ }
321
+ .token-secret {
322
+ border-bottom: 1px solid #555;
323
+ border-top: 1px solid #555;
324
+ margin-top: .75rem;
325
+ }
326
+ .token-secret code {
327
+ color: var(--paper);
328
+ user-select: all;
329
+ }
330
+ .token-ledger {
331
+ border-bottom: 1px solid var(--rule);
332
+ margin-top: 1.5rem;
333
+ }
334
+ .token-card {
335
+ border-top: 1px solid var(--rule);
336
+ padding: .75rem 0 .75rem 1.25rem;
337
+ position: relative;
338
+ }
339
+ .token-card::before {
340
+ background: var(--ink);
341
+ bottom: 0;
342
+ content: "";
343
+ left: .25rem;
344
+ position: absolute;
345
+ top: 0;
346
+ width: 1px;
347
+ }
348
+ .token-card.revoked { color: var(--muted); }
349
+ .token-card.revoked::before { background: var(--rule); }
350
+
288
351
  details { margin-top: .75rem; }
289
352
  summary { cursor: pointer; list-style: none; width: max-content; }
290
353
  summary::-webkit-details-marker { display: none; }