@vellumai/credential-executor 0.6.1 → 0.6.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/bun.lock CHANGED
@@ -8,12 +8,12 @@
8
8
  "@vellumai/ces-contracts": "file:../packages/ces-contracts",
9
9
  "@vellumai/credential-storage": "file:../packages/credential-storage",
10
10
  "@vellumai/egress-proxy": "file:../packages/egress-proxy",
11
- "pino": "^9.6.0",
12
- "pino-pretty": "^13.1.3",
11
+ "pino": "9.14.0",
12
+ "pino-pretty": "13.1.3",
13
13
  },
14
14
  "devDependencies": {
15
- "@types/bun": "^1.2.4",
16
- "typescript": "^5.7.3",
15
+ "@types/bun": "1.3.10",
16
+ "typescript": "5.9.3",
17
17
  },
18
18
  },
19
19
  },
@@ -146,14 +146,12 @@ export function parseHandle(raw: string): ParseHandleResult {
146
146
  }
147
147
 
148
148
  case HandleType.LocalOAuth: {
149
- // providerKey is typically a bare name (e.g. "google"), but legacy handles
150
- // may contain a colon (e.g. "integration:google"), so we split on the
151
- // *last* "/" to separate providerKey from connectionId.
152
- const lastSlashIdx = rest.lastIndexOf("/");
149
+ // Split providerKey from connectionId.
150
+ const slashIdx = rest.indexOf("/");
153
151
  if (
154
- lastSlashIdx === -1 ||
155
- lastSlashIdx === 0 ||
156
- lastSlashIdx === rest.length - 1
152
+ slashIdx === -1 ||
153
+ slashIdx === 0 ||
154
+ slashIdx === rest.length - 1
157
155
  ) {
158
156
  return {
159
157
  ok: false,
@@ -164,8 +162,8 @@ export function parseHandle(raw: string): ParseHandleResult {
164
162
  ok: true,
165
163
  handle: {
166
164
  type: HandleType.LocalOAuth,
167
- providerKey: rest.slice(0, lastSlashIdx),
168
- connectionId: rest.slice(lastSlashIdx + 1),
165
+ providerKey: rest.slice(0, slashIdx),
166
+ connectionId: rest.slice(slashIdx + 1),
169
167
  raw,
170
168
  },
171
169
  };
@@ -65,6 +65,8 @@ export const CesRpcMethod = {
65
65
  DeleteCredential: "delete_credential",
66
66
  /** List all credential account names. */
67
67
  ListCredentials: "list_credentials",
68
+ /** Bulk-import credentials (set multiple at once). */
69
+ BulkSetCredentials: "bulk_set_credentials",
68
70
  } as const;
69
71
 
70
72
  export type CesRpcMethod =
@@ -513,6 +515,38 @@ export type ListCredentialsResponse = z.infer<
513
515
  typeof ListCredentialsResponseSchema
514
516
  >;
515
517
 
518
+ // ---------------------------------------------------------------------------
519
+ // bulk_set_credentials
520
+ // ---------------------------------------------------------------------------
521
+
522
+ export const BulkSetCredentialsSchema = z.object({
523
+ /** Array of credentials to set in bulk. */
524
+ credentials: z.array(
525
+ z.object({
526
+ /** The account name to store the credential under. */
527
+ account: z.string(),
528
+ /** The credential value to store. */
529
+ value: z.string(),
530
+ }),
531
+ ),
532
+ });
533
+ export type BulkSetCredentials = z.infer<typeof BulkSetCredentialsSchema>;
534
+
535
+ export const BulkSetCredentialsResponseSchema = z.object({
536
+ /** Per-credential results indicating success or failure. */
537
+ results: z.array(
538
+ z.object({
539
+ /** The account name that was set. */
540
+ account: z.string(),
541
+ /** Whether the credential was successfully stored. */
542
+ ok: z.boolean(),
543
+ }),
544
+ ),
545
+ });
546
+ export type BulkSetCredentialsResponse = z.infer<
547
+ typeof BulkSetCredentialsResponseSchema
548
+ >;
549
+
516
550
  // ---------------------------------------------------------------------------
517
551
  // Full RPC contract type map
518
552
  // ---------------------------------------------------------------------------
@@ -574,6 +608,10 @@ export interface CesRpcContract {
574
608
  request: ListCredentials;
575
609
  response: ListCredentialsResponse;
576
610
  };
611
+ [CesRpcMethod.BulkSetCredentials]: {
612
+ request: BulkSetCredentials;
613
+ response: BulkSetCredentialsResponse;
614
+ };
577
615
  }
578
616
 
579
617
  /**
@@ -632,4 +670,8 @@ export const CesRpcSchemas = {
632
670
  request: ListCredentialsSchema,
633
671
  response: ListCredentialsResponseSchema,
634
672
  },
673
+ [CesRpcMethod.BulkSetCredentials]: {
674
+ request: BulkSetCredentialsSchema,
675
+ response: BulkSetCredentialsResponseSchema,
676
+ },
635
677
  } as const;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/credential-executor",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,8 +21,8 @@
21
21
  "@vellumai/ces-contracts": "file:../packages/ces-contracts",
22
22
  "@vellumai/credential-storage": "file:../packages/credential-storage",
23
23
  "@vellumai/egress-proxy": "file:../packages/egress-proxy",
24
- "pino": "^9.6.0",
25
- "pino-pretty": "^13.1.3"
24
+ "pino": "9.14.0",
25
+ "pino-pretty": "13.1.3"
26
26
  },
27
27
  "bundledDependencies": [
28
28
  "@vellumai/ces-contracts",
@@ -30,7 +30,7 @@
30
30
  "@vellumai/egress-proxy"
31
31
  ],
32
32
  "devDependencies": {
33
- "@types/bun": "^1.2.4",
34
- "typescript": "^5.7.3"
33
+ "@types/bun": "1.3.10",
34
+ "typescript": "5.9.3"
35
35
  }
36
36
  }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Tests for the BulkSetCredentials RPC handler.
3
+ *
4
+ * Validates that the handler stores each credential independently and
5
+ * returns per-credential success/failure status.
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+
10
+ import { CesRpcMethod } from "@vellumai/ces-contracts";
11
+ import type { SecureKeyBackend } from "@vellumai/credential-storage";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Helpers
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /**
18
+ * Build a minimal BulkSetCredentials handler using the same logic as
19
+ * main.ts / managed-main.ts, backed by the given SecureKeyBackend.
20
+ */
21
+ function buildBulkSetHandler(secureKeyBackend: SecureKeyBackend) {
22
+ return async (req: { credentials: Array<{ account: string; value: string }> }) => {
23
+ const results = [];
24
+ for (const { account, value } of req.credentials) {
25
+ const ok = await secureKeyBackend.set(account, value);
26
+ results.push({ account, ok });
27
+ }
28
+ return { results };
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Create an in-memory SecureKeyBackend for testing.
34
+ * Optionally accepts a set of accounts that should fail on set().
35
+ */
36
+ function createMockBackend(failingAccounts: Set<string> = new Set()): SecureKeyBackend & { store: Map<string, string> } {
37
+ const store = new Map<string, string>();
38
+ return {
39
+ store,
40
+ async get(key: string) {
41
+ return store.get(key);
42
+ },
43
+ async set(key: string, value: string) {
44
+ if (failingAccounts.has(key)) {
45
+ return false;
46
+ }
47
+ store.set(key, value);
48
+ return true;
49
+ },
50
+ async delete(key: string) {
51
+ if (store.has(key)) {
52
+ store.delete(key);
53
+ return "deleted";
54
+ }
55
+ return "not-found";
56
+ },
57
+ async list() {
58
+ return Array.from(store.keys());
59
+ },
60
+ };
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Tests
65
+ // ---------------------------------------------------------------------------
66
+
67
+ describe("BulkSetCredentials handler", () => {
68
+ test("stores multiple credentials and returns ok: true for each", async () => {
69
+ const backend = createMockBackend();
70
+ const handler = buildBulkSetHandler(backend);
71
+
72
+ const result = await handler({
73
+ credentials: [
74
+ { account: "acct-1", value: "secret-1" },
75
+ { account: "acct-2", value: "secret-2" },
76
+ { account: "acct-3", value: "secret-3" },
77
+ ],
78
+ });
79
+
80
+ expect(result.results).toEqual([
81
+ { account: "acct-1", ok: true },
82
+ { account: "acct-2", ok: true },
83
+ { account: "acct-3", ok: true },
84
+ ]);
85
+
86
+ // Verify all credentials were actually stored
87
+ expect(await backend.get("acct-1")).toBe("secret-1");
88
+ expect(await backend.get("acct-2")).toBe("secret-2");
89
+ expect(await backend.get("acct-3")).toBe("secret-3");
90
+ });
91
+
92
+ test("partial failure returns mixed results without aborting the rest", async () => {
93
+ const failingAccounts = new Set(["acct-2"]);
94
+ const backend = createMockBackend(failingAccounts);
95
+ const handler = buildBulkSetHandler(backend);
96
+
97
+ const result = await handler({
98
+ credentials: [
99
+ { account: "acct-1", value: "secret-1" },
100
+ { account: "acct-2", value: "secret-2" },
101
+ { account: "acct-3", value: "secret-3" },
102
+ ],
103
+ });
104
+
105
+ expect(result.results).toEqual([
106
+ { account: "acct-1", ok: true },
107
+ { account: "acct-2", ok: false },
108
+ { account: "acct-3", ok: true },
109
+ ]);
110
+
111
+ // acct-1 and acct-3 should be stored; acct-2 should not
112
+ expect(await backend.get("acct-1")).toBe("secret-1");
113
+ expect(await backend.get("acct-2")).toBeUndefined();
114
+ expect(await backend.get("acct-3")).toBe("secret-3");
115
+ });
116
+
117
+ test("empty credentials array returns empty results array", async () => {
118
+ const backend = createMockBackend();
119
+ const handler = buildBulkSetHandler(backend);
120
+
121
+ const result = await handler({ credentials: [] });
122
+
123
+ expect(result.results).toEqual([]);
124
+ });
125
+
126
+ test("handler is registered under the correct RPC method key", () => {
127
+ // Verify the enum value matches what we register against
128
+ expect(CesRpcMethod.BulkSetCredentials).toBe("bulk_set_credentials");
129
+ });
130
+ });
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Tests for the POST /v1/credentials/bulk endpoint.
3
+ *
4
+ * Verifies:
5
+ * - Successful bulk set with multiple credentials
6
+ * - Validation failure (missing fields, non-array body)
7
+ * - Auth requirement (401 without token)
8
+ */
9
+
10
+ import { describe, it, expect } from "bun:test";
11
+
12
+ import { handleCredentialRoute } from "../credential-routes.js";
13
+ import type { CredentialRouteDeps } from "../credential-routes.js";
14
+ import type { SecureKeyBackend } from "@vellumai/credential-storage";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const SERVICE_TOKEN = "test-ces-service-token-12345";
21
+
22
+ function makeDeps(
23
+ overrides: Partial<SecureKeyBackend> = {},
24
+ ): CredentialRouteDeps {
25
+ const store = new Map<string, string>();
26
+ const backend: SecureKeyBackend = {
27
+ get: async (account: string) => store.get(account),
28
+ set: async (account: string, value: string) => {
29
+ store.set(account, value);
30
+ return true;
31
+ },
32
+ delete: async (account: string) => {
33
+ if (!store.has(account)) return "not-found";
34
+ store.delete(account);
35
+ return "deleted";
36
+ },
37
+ list: async () => [...store.keys()],
38
+ ...overrides,
39
+ };
40
+ return { backend, serviceToken: SERVICE_TOKEN };
41
+ }
42
+
43
+ function makeRequest(
44
+ opts: {
45
+ body?: unknown;
46
+ token?: string | null;
47
+ method?: string;
48
+ } = {},
49
+ ): Request {
50
+ const url = "http://localhost:8090/v1/credentials/bulk";
51
+ const headers: Record<string, string> = {
52
+ "Content-Type": "application/json",
53
+ };
54
+ if (opts.token !== null) {
55
+ headers["Authorization"] = `Bearer ${opts.token ?? SERVICE_TOKEN}`;
56
+ }
57
+
58
+ return new Request(url, {
59
+ method: opts.method ?? "POST",
60
+ headers,
61
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
62
+ });
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Tests
67
+ // ---------------------------------------------------------------------------
68
+
69
+ describe("POST /v1/credentials/bulk", () => {
70
+ it("sets multiple credentials and returns per-credential results", async () => {
71
+ const deps = makeDeps();
72
+ const res = await handleCredentialRoute(
73
+ makeRequest({
74
+ body: {
75
+ credentials: [
76
+ { account: "openai", value: "sk-abc" },
77
+ { account: "anthropic", value: "sk-xyz" },
78
+ ],
79
+ },
80
+ }),
81
+ deps,
82
+ );
83
+
84
+ expect(res).not.toBeNull();
85
+ expect(res!.status).toBe(200);
86
+
87
+ const body = await res!.json();
88
+ expect(body.results).toEqual([
89
+ { account: "openai", ok: true },
90
+ { account: "anthropic", ok: true },
91
+ ]);
92
+
93
+ // Verify credentials were actually stored
94
+ expect(await deps.backend.get("openai")).toBe("sk-abc");
95
+ expect(await deps.backend.get("anthropic")).toBe("sk-xyz");
96
+ });
97
+
98
+ it("reports per-credential failure when backend.set returns false", async () => {
99
+ let callCount = 0;
100
+ const deps = makeDeps({
101
+ set: async () => {
102
+ callCount++;
103
+ // Fail on the second call
104
+ return callCount !== 2;
105
+ },
106
+ });
107
+
108
+ const res = await handleCredentialRoute(
109
+ makeRequest({
110
+ body: {
111
+ credentials: [
112
+ { account: "a", value: "1" },
113
+ { account: "b", value: "2" },
114
+ { account: "c", value: "3" },
115
+ ],
116
+ },
117
+ }),
118
+ deps,
119
+ );
120
+
121
+ expect(res).not.toBeNull();
122
+ expect(res!.status).toBe(200);
123
+
124
+ const body = await res!.json();
125
+ expect(body.results).toEqual([
126
+ { account: "a", ok: true },
127
+ { account: "b", ok: false },
128
+ { account: "c", ok: true },
129
+ ]);
130
+ });
131
+
132
+ it("returns 400 when credentials field is not an array", async () => {
133
+ const deps = makeDeps();
134
+ const res = await handleCredentialRoute(
135
+ makeRequest({ body: { credentials: "not-an-array" } }),
136
+ deps,
137
+ );
138
+
139
+ expect(res).not.toBeNull();
140
+ expect(res!.status).toBe(400);
141
+
142
+ const body = await res!.json();
143
+ expect(body.error).toMatch(/credentials.*array/i);
144
+ });
145
+
146
+ it("returns 400 when credentials field is missing", async () => {
147
+ const deps = makeDeps();
148
+ const res = await handleCredentialRoute(
149
+ makeRequest({ body: {} }),
150
+ deps,
151
+ );
152
+
153
+ expect(res).not.toBeNull();
154
+ expect(res!.status).toBe(400);
155
+
156
+ const body = await res!.json();
157
+ expect(body.error).toMatch(/credentials.*array/i);
158
+ });
159
+
160
+ it("returns 400 when an entry is missing account field", async () => {
161
+ const deps = makeDeps();
162
+ const res = await handleCredentialRoute(
163
+ makeRequest({
164
+ body: {
165
+ credentials: [{ value: "sk-abc" }],
166
+ },
167
+ }),
168
+ deps,
169
+ );
170
+
171
+ expect(res).not.toBeNull();
172
+ expect(res!.status).toBe(400);
173
+
174
+ const body = await res!.json();
175
+ expect(body.error).toMatch(/account.*value/i);
176
+ });
177
+
178
+ it("returns 400 when an entry is missing value field", async () => {
179
+ const deps = makeDeps();
180
+ const res = await handleCredentialRoute(
181
+ makeRequest({
182
+ body: {
183
+ credentials: [{ account: "openai" }],
184
+ },
185
+ }),
186
+ deps,
187
+ );
188
+
189
+ expect(res).not.toBeNull();
190
+ expect(res!.status).toBe(400);
191
+
192
+ const body = await res!.json();
193
+ expect(body.error).toMatch(/account.*value/i);
194
+ });
195
+
196
+ it("returns 401 without Authorization header", async () => {
197
+ const deps = makeDeps();
198
+ const res = await handleCredentialRoute(
199
+ makeRequest({ token: null, body: { credentials: [] } }),
200
+ deps,
201
+ );
202
+
203
+ expect(res).not.toBeNull();
204
+ expect(res!.status).toBe(401);
205
+
206
+ const body = await res!.json();
207
+ expect(body.error).toMatch(/Missing Authorization/i);
208
+ });
209
+
210
+ it("returns 403 with wrong service token", async () => {
211
+ const deps = makeDeps();
212
+ const res = await handleCredentialRoute(
213
+ makeRequest({ token: "wrong-token", body: { credentials: [] } }),
214
+ deps,
215
+ );
216
+
217
+ expect(res).not.toBeNull();
218
+ expect(res!.status).toBe(403);
219
+
220
+ const body = await res!.json();
221
+ expect(body.error).toMatch(/Invalid service token/i);
222
+ });
223
+
224
+ it("non-POST methods fall through to single-account handler (bulk treated as account name)", async () => {
225
+ const deps = makeDeps();
226
+ const res = await handleCredentialRoute(
227
+ makeRequest({ method: "GET", body: undefined }),
228
+ deps,
229
+ );
230
+
231
+ expect(res).not.toBeNull();
232
+ // "bulk" is interpreted as an account name by the :account handler;
233
+ // no such credential exists, so we get 404.
234
+ expect(res!.status).toBe(404);
235
+ });
236
+
237
+ it("handles empty credentials array", async () => {
238
+ const deps = makeDeps();
239
+ const res = await handleCredentialRoute(
240
+ makeRequest({ body: { credentials: [] } }),
241
+ deps,
242
+ );
243
+
244
+ expect(res).not.toBeNull();
245
+ expect(res!.status).toBe(200);
246
+
247
+ const body = await res!.json();
248
+ expect(body.results).toEqual([]);
249
+ });
250
+ });
@@ -7,6 +7,7 @@
7
7
  *
8
8
  * Endpoints:
9
9
  * - `GET /v1/credentials` — list credential account names
10
+ * - `POST /v1/credentials/bulk` — bulk set credentials
10
11
  * - `GET /v1/credentials/:account` — get a credential value
11
12
  * - `POST /v1/credentials/:account` — set a credential value
12
13
  * - `DELETE /v1/credentials/:account` — delete a credential
@@ -96,6 +97,55 @@ export async function handleCredentialRoute(
96
97
  // Extract account from path: /v1/credentials/:account
97
98
  const accountSegment = pathname.slice(CREDENTIAL_PATH_PREFIX.length);
98
99
 
100
+ // POST /v1/credentials/bulk — bulk set credentials
101
+ // Only intercept POST; other methods (GET, DELETE) fall through to the
102
+ // :account handler so a credential literally named "bulk" stays accessible.
103
+ if (accountSegment === "/bulk" && req.method === "POST") {
104
+ let body: { credentials?: unknown };
105
+ try {
106
+ body = await req.json();
107
+ } catch {
108
+ return new Response(
109
+ JSON.stringify({ error: "Invalid JSON body" }),
110
+ { status: 400, headers: { "Content-Type": "application/json" } },
111
+ );
112
+ }
113
+
114
+ if (!Array.isArray(body.credentials)) {
115
+ return new Response(
116
+ JSON.stringify({ error: "Body must contain a 'credentials' array field" }),
117
+ { status: 400, headers: { "Content-Type": "application/json" } },
118
+ );
119
+ }
120
+
121
+ for (const entry of body.credentials) {
122
+ if (
123
+ typeof entry !== "object" ||
124
+ entry === null ||
125
+ typeof entry.account !== "string" ||
126
+ typeof entry.value !== "string"
127
+ ) {
128
+ return new Response(
129
+ JSON.stringify({
130
+ error: "Each credential entry must have string 'account' and 'value' fields",
131
+ }),
132
+ { status: 400, headers: { "Content-Type": "application/json" } },
133
+ );
134
+ }
135
+ }
136
+
137
+ const results: Array<{ account: string; ok: boolean }> = [];
138
+ for (const entry of body.credentials as Array<{ account: string; value: string }>) {
139
+ const ok = await backend.set(entry.account, entry.value);
140
+ results.push({ account: entry.account, ok: !!ok });
141
+ }
142
+
143
+ return new Response(
144
+ JSON.stringify({ results }),
145
+ { status: 200, headers: { "Content-Type": "application/json" } },
146
+ );
147
+ }
148
+
99
149
  // GET /v1/credentials — list all credential account names
100
150
  if (accountSegment === "" || accountSegment === "/") {
101
151
  if (req.method !== "GET") {
package/src/main.ts CHANGED
@@ -284,6 +284,15 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
284
284
  return { accounts };
285
285
  }) as typeof handlers[string];
286
286
 
287
+ handlers[CesRpcMethod.BulkSetCredentials] = (async (req: { credentials: Array<{ account: string; value: string }> }) => {
288
+ const results = [];
289
+ for (const { account, value } of req.credentials) {
290
+ const ok = await secureKeyBackend.set(account, value);
291
+ results.push({ account, ok });
292
+ }
293
+ return { results };
294
+ }) as typeof handlers[string];
295
+
287
296
  return handlers;
288
297
  }
289
298
 
@@ -344,6 +344,15 @@ function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef, assista
344
344
  return { accounts };
345
345
  }) as typeof handlers[string];
346
346
 
347
+ handlers[CesRpcMethod.BulkSetCredentials] = (async (req: { credentials: Array<{ account: string; value: string }> }) => {
348
+ const results = [];
349
+ for (const { account, value } of req.credentials) {
350
+ const ok = await secureKeyBackend.set(account, value);
351
+ results.push({ account, ok });
352
+ }
353
+ return { results };
354
+ }) as typeof handlers[string];
355
+
347
356
  return handlers;
348
357
  }
349
358