@zackbart/connecta 0.1.0

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 (116) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +249 -0
  4. package/SECURITY.md +25 -0
  5. package/assets/connecta-clay-hero.png +0 -0
  6. package/dist/activity.d.ts +82 -0
  7. package/dist/activity.d.ts.map +1 -0
  8. package/dist/activity.js +51 -0
  9. package/dist/activity.js.map +1 -0
  10. package/dist/auth/bearer.d.ts +10 -0
  11. package/dist/auth/bearer.d.ts.map +1 -0
  12. package/dist/auth/bearer.js +47 -0
  13. package/dist/auth/bearer.js.map +1 -0
  14. package/dist/auth/clerk.d.ts +20 -0
  15. package/dist/auth/clerk.d.ts.map +1 -0
  16. package/dist/auth/clerk.js +167 -0
  17. package/dist/auth/clerk.js.map +1 -0
  18. package/dist/auth/downstream-oauth.d.ts +78 -0
  19. package/dist/auth/downstream-oauth.d.ts.map +1 -0
  20. package/dist/auth/downstream-oauth.js +180 -0
  21. package/dist/auth/downstream-oauth.js.map +1 -0
  22. package/dist/catalog.d.ts +11 -0
  23. package/dist/catalog.d.ts.map +1 -0
  24. package/dist/catalog.js +144 -0
  25. package/dist/catalog.js.map +1 -0
  26. package/dist/connectors/api.d.ts +34 -0
  27. package/dist/connectors/api.d.ts.map +1 -0
  28. package/dist/connectors/api.js +36 -0
  29. package/dist/connectors/api.js.map +1 -0
  30. package/dist/connectors/remote-mcp.d.ts +33 -0
  31. package/dist/connectors/remote-mcp.d.ts.map +1 -0
  32. package/dist/connectors/remote-mcp.js +255 -0
  33. package/dist/connectors/remote-mcp.js.map +1 -0
  34. package/dist/credentials.d.ts +33 -0
  35. package/dist/credentials.d.ts.map +1 -0
  36. package/dist/credentials.js +177 -0
  37. package/dist/credentials.js.map +1 -0
  38. package/dist/execute.d.ts +40 -0
  39. package/dist/execute.d.ts.map +1 -0
  40. package/dist/execute.js +424 -0
  41. package/dist/execute.js.map +1 -0
  42. package/dist/executors/quickjs.d.ts +26 -0
  43. package/dist/executors/quickjs.d.ts.map +1 -0
  44. package/dist/executors/quickjs.js +267 -0
  45. package/dist/executors/quickjs.js.map +1 -0
  46. package/dist/favicon.d.ts +2 -0
  47. package/dist/favicon.d.ts.map +1 -0
  48. package/dist/favicon.js +49 -0
  49. package/dist/favicon.js.map +1 -0
  50. package/dist/index.d.ts +85 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +73 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/mcp-result.d.ts +9 -0
  55. package/dist/mcp-result.d.ts.map +1 -0
  56. package/dist/mcp-result.js +35 -0
  57. package/dist/mcp-result.js.map +1 -0
  58. package/dist/meta-tools.d.ts +100 -0
  59. package/dist/meta-tools.d.ts.map +1 -0
  60. package/dist/meta-tools.js +797 -0
  61. package/dist/meta-tools.js.map +1 -0
  62. package/dist/node.d.ts +9 -0
  63. package/dist/node.d.ts.map +1 -0
  64. package/dist/node.js +57 -0
  65. package/dist/node.js.map +1 -0
  66. package/dist/registry.d.ts +78 -0
  67. package/dist/registry.d.ts.map +1 -0
  68. package/dist/registry.js +299 -0
  69. package/dist/registry.js.map +1 -0
  70. package/dist/server.d.ts +31 -0
  71. package/dist/server.d.ts.map +1 -0
  72. package/dist/server.js +543 -0
  73. package/dist/server.js.map +1 -0
  74. package/dist/skills.d.ts +8 -0
  75. package/dist/skills.d.ts.map +1 -0
  76. package/dist/skills.js +60 -0
  77. package/dist/skills.js.map +1 -0
  78. package/dist/storage/file.d.ts +8 -0
  79. package/dist/storage/file.d.ts.map +1 -0
  80. package/dist/storage/file.js +53 -0
  81. package/dist/storage/file.js.map +1 -0
  82. package/dist/storage/memory.d.ts +4 -0
  83. package/dist/storage/memory.d.ts.map +1 -0
  84. package/dist/storage/memory.js +29 -0
  85. package/dist/storage/memory.js.map +1 -0
  86. package/dist/types.d.ts +219 -0
  87. package/dist/types.d.ts.map +1 -0
  88. package/dist/types.js +3 -0
  89. package/dist/types.js.map +1 -0
  90. package/dist/ui.d.ts +91 -0
  91. package/dist/ui.d.ts.map +1 -0
  92. package/dist/ui.js +1031 -0
  93. package/dist/ui.js.map +1 -0
  94. package/package.json +96 -0
  95. package/src/activity.ts +144 -0
  96. package/src/auth/bearer.ts +54 -0
  97. package/src/auth/clerk.ts +226 -0
  98. package/src/auth/downstream-oauth.ts +202 -0
  99. package/src/catalog.ts +166 -0
  100. package/src/connectors/api.ts +80 -0
  101. package/src/connectors/remote-mcp.ts +324 -0
  102. package/src/credentials.ts +259 -0
  103. package/src/execute.ts +550 -0
  104. package/src/executors/quickjs.ts +321 -0
  105. package/src/favicon.ts +53 -0
  106. package/src/index.ts +197 -0
  107. package/src/mcp-result.ts +43 -0
  108. package/src/meta-tools.ts +1120 -0
  109. package/src/node.ts +62 -0
  110. package/src/registry.ts +400 -0
  111. package/src/server.ts +758 -0
  112. package/src/skills.ts +63 -0
  113. package/src/storage/file.ts +62 -0
  114. package/src/storage/memory.ts +34 -0
  115. package/src/types.ts +254 -0
  116. package/src/ui.ts +1121 -0
@@ -0,0 +1,324 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
5
+ import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
6
+ import { KvOAuthProvider } from "../auth/downstream-oauth.js";
7
+ import type {
8
+ Connector,
9
+ ConnectorContext,
10
+ ConnectorStatus,
11
+ ToolDef,
12
+ } from "../types.js";
13
+
14
+ export type RemoteMcpAuth =
15
+ | { type: "headers"; headers: Record<string, string> }
16
+ | { type: "oauth" };
17
+
18
+ export interface RemoteMcpOptions {
19
+ url: string;
20
+ /** Human-readable display name; the connector id remains the address prefix. */
21
+ title?: string;
22
+ description?: string;
23
+ auth?: RemoteMcpAuth;
24
+ /**
25
+ * @internal Testing seam. When set, this transport is used instead of the
26
+ * HTTP transport, letting tests point the connector at an in-process MCP
27
+ * server (e.g. via InMemoryTransport). Not part of the public API.
28
+ */
29
+ _transportFactory?: (ctx: ConnectorContext) => Transport;
30
+ }
31
+
32
+ function msg(err: unknown): string {
33
+ return err instanceof Error ? err.message : String(err);
34
+ }
35
+
36
+ interface ConnectionState {
37
+ client: Client | null;
38
+ transport: Transport | null;
39
+ connecting: Promise<void> | null;
40
+ authRequired: boolean;
41
+ provider: KvOAuthProvider | null;
42
+ connectedGeneration: number | null;
43
+ }
44
+
45
+ /**
46
+ * Proxy a downstream remote MCP server. SDK clients and transports are scoped
47
+ * to one inbound request: reused by calls within a batch/execute_code run, but
48
+ * never carried into a later Cloudflare Worker request. Static-header auth
49
+ * passes headers via requestInit; "oauth" runs the full downstream OAuth flow
50
+ * via KvOAuthProvider.
51
+ *
52
+ * Auth failures degrade the connector to "auth_required" (never crash the
53
+ * server or hide other connectors).
54
+ */
55
+ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
56
+ // Weak keys ensure a completed request does not leave its SDK client,
57
+ // transport, response bodies, AbortSignals, or connection promise reachable
58
+ // from the isolate singleton. Those are request-bound in Cloudflare Workers.
59
+ const states = new WeakMap<object, ConnectionState>();
60
+ const isOauth = opts.auth?.type === "oauth";
61
+
62
+ const stateFor = (ctx: ConnectorContext): ConnectionState => {
63
+ const scope = ctx.requestScope ?? ctx;
64
+ let state = states.get(scope);
65
+ if (!state) {
66
+ state = {
67
+ client: null,
68
+ transport: null,
69
+ connecting: null,
70
+ authRequired: false,
71
+ provider: null,
72
+ connectedGeneration: null,
73
+ };
74
+ states.set(scope, state);
75
+ }
76
+ return state;
77
+ };
78
+
79
+ const getProvider = (
80
+ ctx: ConnectorContext,
81
+ state: ConnectionState,
82
+ ): KvOAuthProvider => {
83
+ state.provider ??= new KvOAuthProvider(
84
+ id,
85
+ ctx.storage,
86
+ `${ctx.baseUrl}/oauth/callback/${id}`,
87
+ );
88
+ return state.provider;
89
+ };
90
+
91
+ const buildTransport = (
92
+ ctx: ConnectorContext,
93
+ state: ConnectionState,
94
+ ): Transport => {
95
+ if (opts._transportFactory) return opts._transportFactory(ctx);
96
+ const url = new URL(opts.url);
97
+ if (opts.auth?.type === "oauth") {
98
+ return new StreamableHTTPClientTransport(url, {
99
+ authProvider: getProvider(ctx, state),
100
+ });
101
+ }
102
+ const headers =
103
+ opts.auth?.type === "headers" ? opts.auth.headers : undefined;
104
+ return new StreamableHTTPClientTransport(
105
+ url,
106
+ headers ? { requestInit: { headers } } : undefined,
107
+ );
108
+ };
109
+
110
+ const reset = (state: ConnectionState) => {
111
+ state.client = null;
112
+ state.transport = null;
113
+ state.connecting = null;
114
+ state.authRequired = false;
115
+ state.connectedGeneration = null;
116
+ };
117
+
118
+ const ensureConnected = async (
119
+ ctx: ConnectorContext,
120
+ state: ConnectionState,
121
+ ): Promise<void> => {
122
+ // Cross-isolate force re-auth: another isolate bumped the KV generation and
123
+ // wiped credentials. This request's cached client still speaks the old
124
+ // token — drop it so the next connect runs against current state.
125
+ if (state.client && isOauth && state.connectedGeneration !== null) {
126
+ if (
127
+ (await getProvider(ctx, state).generation()) !==
128
+ state.connectedGeneration
129
+ ) {
130
+ reset(state);
131
+ }
132
+ }
133
+ if (state.client) return;
134
+ state.connecting ??= (async () => {
135
+ const provider = isOauth ? getProvider(ctx, state) : null;
136
+ const genAtStart = provider ? await provider.generation() : 0;
137
+ // Stamp the provider so any saveTokens/saveClientInformation the SDK fires
138
+ // during this connect (code exchange, DCR) — or during a later refresh on
139
+ // the resulting client — is dropped if a concurrent force bumps the
140
+ // generation past this point, instead of re-persisting wiped credentials.
141
+ provider?.captureGeneration(genAtStart);
142
+ // The SDK defaults to AJV, which compiles every advertised outputSchema
143
+ // with `new Function`. Cloudflare Workers prohibit dynamic code
144
+ // generation, so a remote such as Stripe fails during tools/list unless
145
+ // the SDK's edge-safe validator is selected explicitly.
146
+ const c = new Client(
147
+ { name: "connecta", version: "0.1.0" },
148
+ { jsonSchemaValidator: new CfWorkerJsonSchemaValidator() },
149
+ );
150
+ const t = buildTransport(ctx, state);
151
+ state.transport = t;
152
+ try {
153
+ await c.connect(t);
154
+ // A force re-auth that landed WHILE we were connecting wiped the creds
155
+ // this client just bound to. Discard it rather than cache a connection
156
+ // that resurrects the wiped-and-reauthorized connector from a stale
157
+ // isolate. Surfaces as auth_required — the connector genuinely needs
158
+ // re-consent now.
159
+ if (provider && (await provider.generation()) !== genAtStart) {
160
+ try {
161
+ await c.close();
162
+ } catch {
163
+ // discarding either way
164
+ }
165
+ throw new UnauthorizedError(
166
+ "Connector was re-authorized during connect; reconnect required.",
167
+ );
168
+ }
169
+ state.client = c;
170
+ state.connectedGeneration = genAtStart;
171
+ state.authRequired = false;
172
+ } catch (err) {
173
+ // Only a real 401/UnauthorizedError means auth is the problem — a
174
+ // network error on an oauth connector must surface as "error", not
175
+ // "auth_required".
176
+ if (err instanceof UnauthorizedError) {
177
+ state.authRequired = true;
178
+ }
179
+ throw err;
180
+ } finally {
181
+ state.connecting = null;
182
+ }
183
+ })();
184
+ return state.connecting;
185
+ };
186
+
187
+ const connector: Connector = {
188
+ id,
189
+ title: opts.title,
190
+ kind: "mcp",
191
+ description: opts.description,
192
+
193
+ async listTools(ctx) {
194
+ const state = stateFor(ctx);
195
+ await ensureConnected(ctx, state);
196
+ const res = await state.client!.listTools();
197
+ return res.tools.map(
198
+ (t): ToolDef => ({
199
+ name: t.name,
200
+ description: t.description,
201
+ inputSchema: t.inputSchema as ToolDef["inputSchema"],
202
+ outputSchema: t.outputSchema as ToolDef["outputSchema"],
203
+ annotations: t.annotations as ToolDef["annotations"],
204
+ }),
205
+ );
206
+ },
207
+
208
+ async callTool(name, args, ctx) {
209
+ const state = stateFor(ctx);
210
+ await ensureConnected(ctx, state);
211
+ return state.client!.callTool(
212
+ {
213
+ name,
214
+ arguments: (args ?? {}) as Record<string, unknown>,
215
+ },
216
+ undefined,
217
+ ctx.timeoutMs || ctx.signal
218
+ ? {
219
+ ...(ctx.timeoutMs ? { timeout: ctx.timeoutMs } : {}),
220
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
221
+ }
222
+ : undefined,
223
+ );
224
+ },
225
+
226
+ async status(ctx): Promise<ConnectorStatus> {
227
+ const state = stateFor(ctx);
228
+ try {
229
+ await ensureConnected(ctx, state);
230
+ return { state: "ok" };
231
+ } catch (err) {
232
+ if (state.authRequired) {
233
+ const url = await getProvider(ctx, state).pendingAuthorizationUrl();
234
+ return {
235
+ state: "auth_required",
236
+ authorizationUrl: url,
237
+ message: "Authorization required — open the URL to connect.",
238
+ };
239
+ }
240
+ return { state: "error", message: msg(err) };
241
+ }
242
+ },
243
+
244
+ async finishAuth(code, ctx) {
245
+ const state = stateFor(ctx);
246
+ const provider = getProvider(ctx, state);
247
+ // The provider here may carry no captured generation, so its token saves
248
+ // fail open. That is safe only because generation advances solely via the
249
+ // force path, which always wipes oauth:state — so verifyState rejects any
250
+ // pre-force callback before this runs. Keep those two facts coupled.
251
+ const t = (state.transport ??
252
+ buildTransport(ctx, state)) as StreamableHTTPClientTransport;
253
+ await t.finishAuth(code);
254
+ await provider.clearPending();
255
+ // Reset so the next use reconnects with the freshly stored tokens.
256
+ reset(state);
257
+ },
258
+ };
259
+
260
+ if (opts.auth?.type === "oauth") {
261
+ connector.verifyState = async (oauthState, ctx) => {
262
+ const state = stateFor(ctx);
263
+ return getProvider(ctx, state).verifyState(oauthState);
264
+ };
265
+
266
+ connector.startAuth = async (ctx, startOpts) => {
267
+ const state = stateFor(ctx);
268
+ const p = getProvider(ctx, state);
269
+ if (startOpts?.force) {
270
+ // Wipe stored credentials and drop the live connection so the next
271
+ // connect attempt runs the flow from scratch (DCR + PKCE + consent).
272
+ // Fence the in-flight connect first: a late-completing attempt must not
273
+ // resurrect the credentials we're about to wipe, nor leave `client` set
274
+ // (which would make ensureConnected below report already-authorized and
275
+ // silently defeat force).
276
+ await state.connecting?.catch(() => {});
277
+ try {
278
+ await state.client?.close();
279
+ } catch {
280
+ // best-effort; the connection is being discarded either way
281
+ }
282
+ // Bump the shared generation FIRST so any other isolate — one mid-
283
+ // connect, or on its next tool call — sees the advance and drops its
284
+ // client instead of keeping the token we're about to revoke.
285
+ await p.bumpGeneration();
286
+ // Wipe KV before dropping in-memory state so nothing racing back in can
287
+ // write tokens over a half-cleared slot.
288
+ await p.invalidateCredentials("all");
289
+ await p.clearPending();
290
+ reset(state);
291
+ } else {
292
+ // A consent URL already outstanding? Re-issue it rather than re-running
293
+ // the SDK flow, which would overwrite the PKCE verifier and invalidate
294
+ // the URL the operator may be mid-consent on.
295
+ const pending = await p.pendingAuthorizationUrl();
296
+ if (pending) {
297
+ return {
298
+ state: "auth_required",
299
+ authorizationUrl: pending,
300
+ message: "Authorization required — open the URL to connect.",
301
+ };
302
+ }
303
+ }
304
+ try {
305
+ await ensureConnected(ctx, state);
306
+ return {
307
+ state: "ok",
308
+ message: "Already authorized — connection is healthy.",
309
+ };
310
+ } catch (err) {
311
+ if (state.authRequired) {
312
+ return {
313
+ state: "auth_required",
314
+ authorizationUrl: await p.pendingAuthorizationUrl(),
315
+ message: "Authorization required — open the URL to connect.",
316
+ };
317
+ }
318
+ return { state: "error", message: msg(err) };
319
+ }
320
+ };
321
+ }
322
+
323
+ return connector;
324
+ }
@@ -0,0 +1,259 @@
1
+ import type { ConnectorCredentialValues, KVStorage } from "./types.js";
2
+
3
+ const KEY_BYTES = 32;
4
+ const IV_BYTES = 12;
5
+ const MAX_CREDENTIAL_BYTES = 16_384;
6
+ const encoder = new TextEncoder();
7
+ const decoder = new TextDecoder();
8
+
9
+ interface Envelope {
10
+ version: 1;
11
+ algorithm: "AES-GCM";
12
+ iv: string;
13
+ ciphertext: string;
14
+ }
15
+
16
+ interface CredentialPlaintext {
17
+ values: ConnectorCredentialValues;
18
+ updatedAt: string;
19
+ updatedBy: string;
20
+ }
21
+
22
+ export interface CredentialFieldMetadata {
23
+ configured: true;
24
+ lastFour: string;
25
+ updatedAt: string;
26
+ }
27
+
28
+ export interface CredentialMetadata {
29
+ configured: true;
30
+ /** Backward-compatible metadata for the reserved single credential field. */
31
+ lastFour?: string;
32
+ updatedAt: string;
33
+ /** Per-field masked metadata for named multi-value credentials. */
34
+ fields?: Record<string, CredentialFieldMetadata>;
35
+ }
36
+
37
+ function storageKey(connectorId: string): string {
38
+ return `conn:${connectorId}:credential:v1`;
39
+ }
40
+
41
+ function bytesToBase64(bytes: Uint8Array): string {
42
+ let binary = "";
43
+ for (const byte of bytes) binary += String.fromCharCode(byte);
44
+ return btoa(binary);
45
+ }
46
+
47
+ function base64ToBytes(value: string): Uint8Array {
48
+ let binary: string;
49
+ try {
50
+ binary = atob(value);
51
+ } catch {
52
+ throw new Error(
53
+ "credentialEncryptionKey must be a base64-encoded 32-byte key",
54
+ );
55
+ }
56
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
57
+ }
58
+
59
+ function parseEnvelope(raw: string): Envelope {
60
+ try {
61
+ const parsed = JSON.parse(raw) as Partial<Envelope>;
62
+ if (
63
+ parsed.version !== 1 ||
64
+ parsed.algorithm !== "AES-GCM" ||
65
+ typeof parsed.iv !== "string" ||
66
+ typeof parsed.ciphertext !== "string"
67
+ ) {
68
+ throw new Error("invalid envelope");
69
+ }
70
+ return parsed as Envelope;
71
+ } catch {
72
+ throw new Error("Stored credential is invalid or corrupted");
73
+ }
74
+ }
75
+
76
+ function parsePlaintext(raw: string): CredentialPlaintext {
77
+ try {
78
+ const parsed = JSON.parse(raw) as Partial<CredentialPlaintext> & {
79
+ value?: unknown;
80
+ };
81
+ const values =
82
+ typeof parsed.value === "string"
83
+ ? { value: parsed.value }
84
+ : parsed.values;
85
+ if (
86
+ !values ||
87
+ typeof values !== "object" ||
88
+ Array.isArray(values) ||
89
+ !Object.entries(values).every(
90
+ ([field, value]) =>
91
+ /^[A-Za-z][A-Za-z0-9_-]*$/.test(field) &&
92
+ typeof value === "string",
93
+ ) ||
94
+ typeof parsed.updatedAt !== "string" ||
95
+ typeof parsed.updatedBy !== "string"
96
+ ) {
97
+ throw new Error("invalid plaintext");
98
+ }
99
+ return {
100
+ values: { ...values },
101
+ updatedAt: parsed.updatedAt,
102
+ updatedBy: parsed.updatedBy,
103
+ };
104
+ } catch {
105
+ throw new Error("Stored credential is invalid or corrupted");
106
+ }
107
+ }
108
+
109
+ function validateValues(
110
+ values: ConnectorCredentialValues,
111
+ ): ConnectorCredentialValues {
112
+ const entries = Object.entries(values);
113
+ if (entries.length === 0) throw new Error("Credential cannot be empty");
114
+ const normalized: ConnectorCredentialValues = {};
115
+ for (const [field, value] of entries) {
116
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(field)) {
117
+ throw new Error(`Invalid credential field "${field}"`);
118
+ }
119
+ if (typeof value !== "string" || !value.trim()) {
120
+ throw new Error(`Credential field "${field}" cannot be empty`);
121
+ }
122
+ normalized[field] = value;
123
+ }
124
+ if (
125
+ encoder.encode(JSON.stringify(normalized)).byteLength >
126
+ MAX_CREDENTIAL_BYTES
127
+ ) {
128
+ throw new Error(`Credential cannot exceed ${MAX_CREDENTIAL_BYTES} bytes`);
129
+ }
130
+ return normalized;
131
+ }
132
+
133
+ /**
134
+ * Encrypted, connector-scoped credential vault over the deployment's existing
135
+ * KVStorage. Only ciphertext enters KV; the AES-GCM key remains an environment
136
+ * secret outside the store.
137
+ */
138
+ export class CredentialVault {
139
+ private readonly key: Promise<CryptoKey>;
140
+
141
+ constructor(
142
+ private readonly storage: KVStorage,
143
+ encryptionKey: string,
144
+ ) {
145
+ const raw = base64ToBytes(encryptionKey.trim());
146
+ if (raw.byteLength !== KEY_BYTES) {
147
+ throw new Error(
148
+ "credentialEncryptionKey must be a base64-encoded 32-byte key",
149
+ );
150
+ }
151
+ this.key = crypto.subtle.importKey(
152
+ "raw",
153
+ raw,
154
+ { name: "AES-GCM" },
155
+ false,
156
+ ["encrypt", "decrypt"],
157
+ );
158
+ }
159
+
160
+ private additionalData(connectorId: string): Uint8Array {
161
+ return encoder.encode(`connecta:credential:${connectorId}:v1`);
162
+ }
163
+
164
+ private async read(connectorId: string): Promise<CredentialPlaintext | null> {
165
+ const raw = await this.storage.get(storageKey(connectorId));
166
+ if (!raw) return null;
167
+ const envelope = parseEnvelope(raw);
168
+ try {
169
+ const plaintext = await crypto.subtle.decrypt(
170
+ {
171
+ name: "AES-GCM",
172
+ iv: base64ToBytes(envelope.iv),
173
+ additionalData: this.additionalData(connectorId),
174
+ },
175
+ await this.key,
176
+ base64ToBytes(envelope.ciphertext),
177
+ );
178
+ return parsePlaintext(decoder.decode(plaintext));
179
+ } catch {
180
+ throw new Error("Stored credential could not be decrypted");
181
+ }
182
+ }
183
+
184
+ async get(connectorId: string, field = "value"): Promise<string | null> {
185
+ return (await this.read(connectorId))?.values[field] ?? null;
186
+ }
187
+
188
+ async getAll(
189
+ connectorId: string,
190
+ ): Promise<ConnectorCredentialValues | null> {
191
+ const credential = await this.read(connectorId);
192
+ return credential ? { ...credential.values } : null;
193
+ }
194
+
195
+ async metadata(connectorId: string): Promise<CredentialMetadata | null> {
196
+ const credential = await this.read(connectorId);
197
+ if (!credential) return null;
198
+ const fields = Object.fromEntries(
199
+ Object.entries(credential.values).map(([field, value]) => [
200
+ field,
201
+ {
202
+ configured: true as const,
203
+ lastFour: value.slice(-4),
204
+ updatedAt: credential.updatedAt,
205
+ },
206
+ ]),
207
+ );
208
+ const single = fields.value;
209
+ return {
210
+ configured: true,
211
+ ...(single ? { lastFour: single.lastFour } : {}),
212
+ updatedAt: credential.updatedAt,
213
+ fields,
214
+ };
215
+ }
216
+
217
+ async set(
218
+ connectorId: string,
219
+ value: string,
220
+ updatedBy: string,
221
+ ): Promise<CredentialMetadata> {
222
+ return this.setAll(connectorId, { value }, updatedBy);
223
+ }
224
+
225
+ async setAll(
226
+ connectorId: string,
227
+ values: ConnectorCredentialValues,
228
+ updatedBy: string,
229
+ ): Promise<CredentialMetadata> {
230
+ const normalized = validateValues(values);
231
+ const plaintext: CredentialPlaintext = {
232
+ values: normalized,
233
+ updatedAt: new Date().toISOString(),
234
+ updatedBy,
235
+ };
236
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
237
+ const ciphertext = await crypto.subtle.encrypt(
238
+ {
239
+ name: "AES-GCM",
240
+ iv,
241
+ additionalData: this.additionalData(connectorId),
242
+ },
243
+ await this.key,
244
+ encoder.encode(JSON.stringify(plaintext)),
245
+ );
246
+ const envelope: Envelope = {
247
+ version: 1,
248
+ algorithm: "AES-GCM",
249
+ iv: bytesToBase64(iv),
250
+ ciphertext: bytesToBase64(new Uint8Array(ciphertext)),
251
+ };
252
+ await this.storage.set(storageKey(connectorId), JSON.stringify(envelope));
253
+ return (await this.metadata(connectorId))!;
254
+ }
255
+
256
+ async delete(connectorId: string): Promise<void> {
257
+ await this.storage.delete(storageKey(connectorId));
258
+ }
259
+ }