@vellumai/credential-executor 0.6.6 → 0.7.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 (61) hide show
  1. package/Dockerfile +1 -1
  2. package/bun.lock +15 -7
  3. package/node_modules/@vellumai/credential-storage/src/__tests__/package-boundary.test.ts +32 -6
  4. package/node_modules/@vellumai/egress-proxy/src/__tests__/package-boundary.test.ts +32 -1
  5. package/node_modules/@vellumai/{ces-contracts → service-contracts}/bun.lock +1 -1
  6. package/node_modules/@vellumai/{ces-contracts → service-contracts}/package.json +4 -2
  7. package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/__tests__/contracts.test.ts +5 -1
  8. package/node_modules/@vellumai/service-contracts/src/__tests__/package-boundary.test.ts +155 -0
  9. package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +23 -0
  10. package/node_modules/@vellumai/service-contracts/src/index.ts +25 -0
  11. package/node_modules/@vellumai/{ces-contracts/src/index.ts → service-contracts/src/transport.ts} +6 -28
  12. package/node_modules/@vellumai/service-contracts/src/trust-rules.ts +116 -0
  13. package/package.json +5 -4
  14. package/src/__tests__/bulk-set-credentials.test.ts +1 -1
  15. package/src/__tests__/ces-migrations-002-api-keys.test.ts +185 -0
  16. package/src/__tests__/ces-migrations-runner.test.ts +227 -0
  17. package/src/__tests__/cli.test.ts +139 -0
  18. package/src/__tests__/command-executor.test.ts +71 -42
  19. package/src/__tests__/http-executor.test.ts +1 -1
  20. package/src/__tests__/local-materializers.test.ts +1 -1
  21. package/src/__tests__/local-token-refresh.test.ts +65 -38
  22. package/src/__tests__/managed-integration.test.ts +1 -1
  23. package/src/__tests__/managed-lazy-getters.test.ts +1 -1
  24. package/src/__tests__/managed-materializers.test.ts +1 -1
  25. package/src/__tests__/managed-rejection.test.ts +1 -1
  26. package/src/__tests__/toolstore.test.ts +65 -20
  27. package/src/__tests__/transport.test.ts +13 -4
  28. package/src/audit/store.ts +2 -2
  29. package/src/cli.ts +158 -0
  30. package/src/commands/executor.ts +2 -2
  31. package/src/grants/rpc-handlers.ts +1 -1
  32. package/src/http/__tests__/credential-routes-normalization.test.ts +202 -0
  33. package/src/http/audit.ts +1 -1
  34. package/src/http/credential-routes.ts +53 -7
  35. package/src/http/executor.ts +2 -2
  36. package/src/http/policy.ts +1 -1
  37. package/src/main.ts +120 -50
  38. package/src/managed-errors.ts +2 -2
  39. package/src/managed-lazy-getters.ts +4 -4
  40. package/src/managed-main.ts +9 -3
  41. package/src/materializers/local-oauth-lookup.ts +7 -6
  42. package/src/materializers/local-token-refresh.ts +25 -15
  43. package/src/materializers/local.ts +1 -1
  44. package/src/migrations/001-no-op.ts +19 -0
  45. package/src/migrations/002-api-keys-to-credentials.ts +60 -0
  46. package/src/migrations/registry.ts +15 -0
  47. package/src/migrations/runner.ts +146 -0
  48. package/src/migrations/types.ts +54 -0
  49. package/src/paths.ts +15 -11
  50. package/src/server.ts +2 -2
  51. package/src/subjects/local.ts +2 -2
  52. package/src/subjects/managed.ts +1 -1
  53. package/node_modules/@vellumai/ces-contracts/src/__tests__/trust-rules.test.ts +0 -471
  54. package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +0 -436
  55. /package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/__tests__/grants.test.ts +0 -0
  56. /package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/error.ts +0 -0
  57. /package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/grants.ts +0 -0
  58. /package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/handles.ts +0 -0
  59. /package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/rendering.ts +0 -0
  60. /package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/rpc.ts +0 -0
  61. /package/node_modules/@vellumai/{ces-contracts → service-contracts}/tsconfig.json +0 -0
@@ -1,5 +1,12 @@
1
1
  import { describe, expect, test, beforeEach, afterEach } from "bun:test";
2
- import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync, symlinkSync } from "node:fs";
2
+ import {
3
+ mkdirSync,
4
+ rmSync,
5
+ existsSync,
6
+ readFileSync,
7
+ writeFileSync,
8
+ symlinkSync,
9
+ } from "node:fs";
3
10
  import { join } from "node:path";
4
11
  import { tmpdir } from "node:os";
5
12
  import { randomUUID } from "node:crypto";
@@ -47,11 +54,17 @@ function createTestArchive(
47
54
  { stdout: "pipe", stderr: "pipe" },
48
55
  );
49
56
  if (proc.exitCode !== 0) {
50
- throw new Error(`Failed to create test archive: ${new TextDecoder().decode(proc.stderr).trim()}`);
57
+ throw new Error(
58
+ `Failed to create test archive: ${new TextDecoder().decode(proc.stderr).trim()}`,
59
+ );
51
60
  }
52
61
  return Buffer.from(readFileSync(archivePath));
53
62
  } finally {
54
- try { rmSync(stagingDir, { recursive: true, force: true }); } catch { /* best effort */ }
63
+ try {
64
+ rmSync(stagingDir, { recursive: true, force: true });
65
+ } catch {
66
+ /* best effort */
67
+ }
55
68
  }
56
69
  }
57
70
 
@@ -78,11 +91,17 @@ function createSymlinkArchive(
78
91
  { stdout: "pipe", stderr: "pipe" },
79
92
  );
80
93
  if (proc.exitCode !== 0) {
81
- throw new Error(`Failed to create symlink test archive: ${new TextDecoder().decode(proc.stderr).trim()}`);
94
+ throw new Error(
95
+ `Failed to create symlink test archive: ${new TextDecoder().decode(proc.stderr).trim()}`,
96
+ );
82
97
  }
83
98
  return Buffer.from(readFileSync(archivePath));
84
99
  } finally {
85
- try { rmSync(stagingDir, { recursive: true, force: true }); } catch { /* best effort */ }
100
+ try {
101
+ rmSync(stagingDir, { recursive: true, force: true });
102
+ } catch {
103
+ /* best effort */
104
+ }
86
105
  }
87
106
  }
88
107
 
@@ -93,7 +112,10 @@ const SAMPLE_BUNDLE_BYTES = createTestArchive("bin/test-cli");
93
112
  const SAMPLE_BUNDLE_DIGEST = computeDigest(SAMPLE_BUNDLE_BYTES);
94
113
 
95
114
  /** A different archive to test digest mismatches. */
96
- const TAMPERED_BUNDLE_BYTES = createTestArchive("bin/test-cli", "#!/usr/bin/env bash\nrm -rf /\n");
115
+ const TAMPERED_BUNDLE_BYTES = createTestArchive(
116
+ "bin/test-cli",
117
+ "#!/usr/bin/env bash\nrm -rf /\n",
118
+ );
97
119
 
98
120
  /**
99
121
  * Build a minimal valid SecureCommandManifest for testing.
@@ -110,9 +132,7 @@ function buildSecureManifest(
110
132
  commandProfiles: {
111
133
  "read-data": {
112
134
  description: "Read-only data access",
113
- allowedArgvPatterns: [
114
- { name: "list", tokens: ["list", "<resource>"] },
115
- ],
135
+ allowedArgvPatterns: [{ name: "list", tokens: ["list", "<resource>"] }],
116
136
  deniedSubcommands: ["admin"],
117
137
  allowedNetworkTargets: [
118
138
  { hostPattern: "api.example.com", protocols: ["https"] },
@@ -159,7 +179,7 @@ beforeEach(() => {
159
179
  mkdirSync(testTmpDir, { recursive: true });
160
180
 
161
181
  // Point CES data root to the temp directory so tests are isolated
162
- process.env["BASE_DATA_DIR"] = testTmpDir;
182
+ process.env["CREDENTIAL_SECURITY_DIR"] = testTmpDir;
163
183
  });
164
184
 
165
185
  afterEach(() => {
@@ -168,7 +188,7 @@ afterEach(() => {
168
188
  } catch {
169
189
  // Best effort cleanup
170
190
  }
171
- delete process.env["BASE_DATA_DIR"];
191
+ delete process.env["CREDENTIAL_SECURITY_DIR"];
172
192
  });
173
193
 
174
194
  // ---------------------------------------------------------------------------
@@ -280,7 +300,9 @@ describe("manifest validation helpers", () => {
280
300
  });
281
301
 
282
302
  test("rejects data: URL", () => {
283
- const err = validateSourceUrl("data:application/octet-stream;base64,AA==");
303
+ const err = validateSourceUrl(
304
+ "data:application/octet-stream;base64,AA==",
305
+ );
284
306
  expect(err).not.toBeNull();
285
307
  expect(err).toContain("data:");
286
308
  });
@@ -375,7 +397,9 @@ describe("publishBundle — digest mismatch rejection", () => {
375
397
  if (existsSync(toolstoreDir)) {
376
398
  const { readdirSync } = require("node:fs");
377
399
  const entries = readdirSync(toolstoreDir) as string[];
378
- const stagingDirs = entries.filter((e: string) => e.startsWith(".staging-"));
400
+ const stagingDirs = entries.filter((e: string) =>
401
+ e.startsWith(".staging-"),
402
+ );
379
403
  expect(stagingDirs).toHaveLength(0);
380
404
  }
381
405
  });
@@ -450,7 +474,10 @@ describe("publishBundle — immutable and deduplicated by digest", () => {
450
474
  expect(firstResult.success).toBe(true);
451
475
 
452
476
  // Publish a second, different bundle (real tar.gz archive)
453
- const otherBytes = createTestArchive("bin/test-cli", "#!/usr/bin/env bash\necho other\n");
477
+ const otherBytes = createTestArchive(
478
+ "bin/test-cli",
479
+ "#!/usr/bin/env bash\necho other\n",
480
+ );
454
481
  const otherDigest = computeDigest(otherBytes);
455
482
  const otherManifest = buildSecureManifest({
456
483
  bundleDigest: otherDigest,
@@ -464,7 +491,8 @@ describe("publishBundle — immutable and deduplicated by digest", () => {
464
491
  expectedDigest: otherDigest,
465
492
  bundleId: "other-cli",
466
493
  version: "2.0.0",
467
- sourceUrl: "https://releases.example.com/other-cli/v2.0.0/bundle.tar.gz",
494
+ sourceUrl:
495
+ "https://releases.example.com/other-cli/v2.0.0/bundle.tar.gz",
468
496
  secureCommandManifest: otherManifest,
469
497
  }),
470
498
  );
@@ -631,7 +659,9 @@ describe("publishBundle — symlink escape prevention", () => {
631
659
  // Create a real entrypoint
632
660
  const entrypointPath = join(stagingDir, "bin/test-cli");
633
661
  mkdirSync(join(stagingDir, "bin"), { recursive: true });
634
- writeFileSync(entrypointPath, "#!/usr/bin/env bash\necho hello\n", { mode: 0o755 });
662
+ writeFileSync(entrypointPath, "#!/usr/bin/env bash\necho hello\n", {
663
+ mode: 0o755,
664
+ });
635
665
 
636
666
  // Create a symlink that escapes
637
667
  symlinkSync("/etc/passwd", join(stagingDir, "bin/evil-link"));
@@ -663,16 +693,27 @@ describe("publishBundle — symlink escape prevention", () => {
663
693
  expect(result.error).toContain("symlink");
664
694
  expect(result.error).toContain("outside the bundle directory");
665
695
  } finally {
666
- try { rmSync(stagingDir, { recursive: true, force: true }); } catch { /* best effort */ }
696
+ try {
697
+ rmSync(stagingDir, { recursive: true, force: true });
698
+ } catch {
699
+ /* best effort */
700
+ }
667
701
  }
668
702
  });
669
703
 
670
704
  test("accepts bundle with internal symlinks (not escaping)", () => {
671
705
  // Create an archive with a symlink that points within the bundle
672
- const stagingDir = join(tmpdir(), `ces-test-internal-symlink-${randomUUID()}`);
706
+ const stagingDir = join(
707
+ tmpdir(),
708
+ `ces-test-internal-symlink-${randomUUID()}`,
709
+ );
673
710
  try {
674
711
  mkdirSync(join(stagingDir, "bin"), { recursive: true });
675
- writeFileSync(join(stagingDir, "bin/test-cli"), "#!/usr/bin/env bash\necho hello\n", { mode: 0o755 });
712
+ writeFileSync(
713
+ join(stagingDir, "bin/test-cli"),
714
+ "#!/usr/bin/env bash\necho hello\n",
715
+ { mode: 0o755 },
716
+ );
676
717
  // Create a symlink within the bundle: bin/alias -> test-cli (relative)
677
718
  symlinkSync("test-cli", join(stagingDir, "bin/alias"));
678
719
 
@@ -701,7 +742,11 @@ describe("publishBundle — symlink escape prevention", () => {
701
742
 
702
743
  expect(result.success).toBe(true);
703
744
  } finally {
704
- try { rmSync(stagingDir, { recursive: true, force: true }); } catch { /* best effort */ }
745
+ try {
746
+ rmSync(stagingDir, { recursive: true, force: true });
747
+ } catch {
748
+ /* best effort */
749
+ }
705
750
  }
706
751
  });
707
752
 
@@ -21,7 +21,7 @@ import {
21
21
  CES_PROTOCOL_VERSION,
22
22
  type HandshakeAck,
23
23
  type RpcEnvelope,
24
- } from "@vellumai/ces-contracts";
24
+ } from "@vellumai/service-contracts/credential-rpc";
25
25
 
26
26
  import { getCesDataRoot, getBootstrapSocketPath, getHealthPort } from "../paths.js";
27
27
  import { CesRpcServer, type RpcHandlerRegistry } from "../server.js";
@@ -393,16 +393,25 @@ describe("CES data paths", () => {
393
393
  });
394
394
 
395
395
  test("getBootstrapSocketPath respects CES_BOOTSTRAP_SOCKET env var", () => {
396
- const saved = process.env["CES_BOOTSTRAP_SOCKET"];
396
+ const savedSocket = process.env["CES_BOOTSTRAP_SOCKET"];
397
+ const savedDir = process.env["CES_BOOTSTRAP_SOCKET_DIR"];
398
+ // CES_BOOTSTRAP_SOCKET_DIR takes precedence; clear it so the
399
+ // CES_BOOTSTRAP_SOCKET fallback is actually exercised.
400
+ delete process.env["CES_BOOTSTRAP_SOCKET_DIR"];
397
401
  process.env["CES_BOOTSTRAP_SOCKET"] = "/tmp/test-ces.sock";
398
402
  try {
399
403
  expect(getBootstrapSocketPath()).toBe("/tmp/test-ces.sock");
400
404
  } finally {
401
- if (saved !== undefined) {
402
- process.env["CES_BOOTSTRAP_SOCKET"] = saved;
405
+ if (savedSocket !== undefined) {
406
+ process.env["CES_BOOTSTRAP_SOCKET"] = savedSocket;
403
407
  } else {
404
408
  delete process.env["CES_BOOTSTRAP_SOCKET"];
405
409
  }
410
+ if (savedDir !== undefined) {
411
+ process.env["CES_BOOTSTRAP_SOCKET_DIR"] = savedDir;
412
+ } else {
413
+ delete process.env["CES_BOOTSTRAP_SOCKET_DIR"];
414
+ }
406
415
  }
407
416
  });
408
417
  });
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Persists token-free audit record summaries to `audit.jsonl` inside
5
5
  * the CES-private data root. Each line is a self-contained JSON object
6
- * conforming to the `AuditRecordSummary` schema from `@vellumai/ces-contracts`.
6
+ * conforming to the `AuditRecordSummary` schema from `@vellumai/service-contracts`.
7
7
  *
8
8
  * Design principles:
9
9
  * - **Append-only**: Records are appended one per line. The file is never
@@ -27,7 +27,7 @@ import {
27
27
  } from "node:fs";
28
28
  import { dirname, join } from "node:path";
29
29
 
30
- import type { AuditRecordSummary } from "@vellumai/ces-contracts";
30
+ import type { AuditRecordSummary } from "@vellumai/service-contracts/credential-rpc";
31
31
 
32
32
  import { getCesAuditDir } from "../paths.js";
33
33
 
package/src/cli.ts ADDED
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * CES CLI — lightweight credential CRUD for the CES container.
4
+ *
5
+ * Operates directly on the encrypted key store (`keys.enc` + `store.key`)
6
+ * without requiring the RPC server, HTTP routes, or a running assistant.
7
+ *
8
+ * Usage:
9
+ * ces list
10
+ * ces get <account>
11
+ * ces set <account> <value>
12
+ * ces delete <account>
13
+ *
14
+ * Account format: `credential/<service>/<field>` (e.g. `credential/vellum/platform_organization_id`)
15
+ *
16
+ * Environment variables:
17
+ * CREDENTIAL_SECURITY_DIR — directory containing `keys.enc` + `store.key`
18
+ * CES_ASSISTANT_DATA_MOUNT — fallback root for `<mount>/.vellum/protected/`
19
+ *
20
+ * When neither is set, defaults to `~/.vellum/protected/` (local mode).
21
+ */
22
+
23
+ import { join } from "node:path";
24
+ import { homedir } from "node:os";
25
+ import { createLocalSecureKeyBackend } from "./materializers/local-secure-key-backend.js";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Path resolution (mirrors managed-main.ts)
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function resolveVellumRoot(): string {
32
+ const secDir = process.env["CREDENTIAL_SECURITY_DIR"]?.trim();
33
+ if (secDir) {
34
+ // CREDENTIAL_SECURITY_DIR points directly at the dir containing
35
+ // keys.enc, but createLocalSecureKeyBackend wants the parent
36
+ // (.vellum root) and appends /protected/ itself — unless
37
+ // CREDENTIAL_SECURITY_DIR is set, in which case the backend reads
38
+ // from that dir directly. So we pass dirname(secDir) as vellumRoot.
39
+ // Actually, looking at resolveSecurityDir(): if CREDENTIAL_SECURITY_DIR
40
+ // is set it uses that directly, ignoring vellumRoot. So vellumRoot
41
+ // can be anything — the env var takes precedence.
42
+ return join(secDir, "..");
43
+ }
44
+
45
+ const mount = process.env["CES_ASSISTANT_DATA_MOUNT"]?.trim();
46
+ if (mount) {
47
+ return join(mount, ".vellum");
48
+ }
49
+
50
+ return join(homedir(), ".vellum");
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // CLI
55
+ // ---------------------------------------------------------------------------
56
+
57
+ async function main(): Promise<void> {
58
+ const [command, ...args] = process.argv.slice(2);
59
+
60
+ if (!command || command === "--help" || command === "-h") {
61
+ printUsage();
62
+ process.exit(0);
63
+ }
64
+
65
+ const vellumRoot = resolveVellumRoot();
66
+ const backend = createLocalSecureKeyBackend(vellumRoot);
67
+
68
+ switch (command) {
69
+ case "list": {
70
+ const accounts = await backend.list();
71
+ if (accounts.length === 0) {
72
+ console.log("(no credentials stored)");
73
+ } else {
74
+ for (const account of accounts.sort()) {
75
+ console.log(account);
76
+ }
77
+ }
78
+ break;
79
+ }
80
+
81
+ case "get": {
82
+ const account = args[0];
83
+ if (!account) {
84
+ console.error("Usage: ces get <account>");
85
+ process.exit(1);
86
+ }
87
+ const value = await backend.get(account);
88
+ if (value === undefined) {
89
+ console.error(`Not found: ${account}`);
90
+ process.exit(1);
91
+ }
92
+ // Write raw value to stdout (no trailing newline for piping)
93
+ process.stdout.write(value);
94
+ break;
95
+ }
96
+
97
+ case "set": {
98
+ const account = args[0];
99
+ const value = args[1];
100
+ if (!account || value === undefined) {
101
+ console.error("Usage: ces set <account> <value>");
102
+ process.exit(1);
103
+ }
104
+ const ok = await backend.set(account, value);
105
+ if (ok) {
106
+ console.log(`Set: ${account}`);
107
+ } else {
108
+ console.error(`Failed to set: ${account}`);
109
+ process.exit(1);
110
+ }
111
+ break;
112
+ }
113
+
114
+ case "delete": {
115
+ const account = args[0];
116
+ if (!account) {
117
+ console.error("Usage: ces delete <account>");
118
+ process.exit(1);
119
+ }
120
+ const result = await backend.delete(account);
121
+ if (result === "deleted") {
122
+ console.log(`Deleted: ${account}`);
123
+ } else {
124
+ console.error(`Not found: ${account}`);
125
+ process.exit(1);
126
+ }
127
+ break;
128
+ }
129
+
130
+ default:
131
+ console.error(`Unknown command: ${command}`);
132
+ printUsage();
133
+ process.exit(1);
134
+ }
135
+ }
136
+
137
+ function printUsage(): void {
138
+ console.log(`CES CLI — credential CRUD for the encrypted key store
139
+
140
+ Usage:
141
+ ces list List all credential accounts
142
+ ces get <account> Get a credential value
143
+ ces set <account> <value> Set a credential value
144
+ ces delete <account> Delete a credential
145
+
146
+ Account format:
147
+ credential/<service>/<field>
148
+ Example: credential/vellum/platform_organization_id
149
+
150
+ Environment:
151
+ CREDENTIAL_SECURITY_DIR Directory containing keys.enc + store.key
152
+ CES_ASSISTANT_DATA_MOUNT Fallback: <mount>/.vellum/protected/`);
153
+ }
154
+
155
+ main().catch((err) => {
156
+ console.error(`Fatal: ${err instanceof Error ? err.message : err}`);
157
+ process.exit(1);
158
+ });
@@ -74,7 +74,7 @@ import {
74
74
  type WorkspaceOutput,
75
75
  type CopybackResult,
76
76
  } from "./workspace.js";
77
- import { hashProposal, type AuditRecordSummary, type CommandGrantProposal } from "@vellumai/ces-contracts";
77
+ import { hashProposal, type AuditRecordSummary, type CommandGrantProposal } from "@vellumai/service-contracts/credential-rpc";
78
78
 
79
79
  import type { AuditStore } from "../audit/store.js";
80
80
  import type { PersistentGrantStore } from "../grants/persistent-store.js";
@@ -737,7 +737,7 @@ function checkGrant(
737
737
 
738
738
  // Check temporary grants — build the same proposal shape that the
739
739
  // approval bridge produces, then hash with the canonical algorithm
740
- // from `@vellumai/ces-contracts` so the hashes align.
740
+ // from `@vellumai/service-contracts` so the hashes align.
741
741
  const tempProposal: CommandGrantProposal = {
742
742
  type: "command",
743
743
  credentialHandle: request.credentialHandle,
@@ -23,7 +23,7 @@ import type {
23
23
  ListAuditRecords,
24
24
  ListAuditRecordsResponse,
25
25
  PersistentGrantRecord,
26
- } from "@vellumai/ces-contracts";
26
+ } from "@vellumai/service-contracts/credential-rpc";
27
27
 
28
28
  import type { PersistentGrantStore, PersistentGrant } from "./persistent-store.js";
29
29
  import type { TemporaryGrantStore } from "./temporary-store.js";
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Tests for credential account key normalization in the HTTP routes.
3
+ *
4
+ * Verifies that colon-separated account names (e.g. `vellum:platform_organization_id`)
5
+ * are transparently normalized to the internal slash-separated format
6
+ * (e.g. `credential/vellum/platform_organization_id`) so that credentials
7
+ * stored via direct HTTP are findable by the gateway and assistant.
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-token-normalization";
21
+
22
+ function makeDeps(): { deps: CredentialRouteDeps; store: Map<string, string> } {
23
+ const store = new Map<string, string>();
24
+ const backend: SecureKeyBackend = {
25
+ get: async (account: string) => store.get(account),
26
+ set: async (account: string, value: string) => {
27
+ store.set(account, value);
28
+ return true;
29
+ },
30
+ delete: async (account: string) => {
31
+ if (!store.has(account)) return "not-found";
32
+ store.delete(account);
33
+ return "deleted";
34
+ },
35
+ list: async () => [...store.keys()],
36
+ };
37
+ return { deps: { backend, serviceToken: SERVICE_TOKEN }, store };
38
+ }
39
+
40
+ function makeRequest(
41
+ method: string,
42
+ path: string,
43
+ body?: unknown,
44
+ ): Request {
45
+ const url = `http://localhost:8090${path}`;
46
+ const headers: Record<string, string> = {
47
+ "Content-Type": "application/json",
48
+ Authorization: `Bearer ${SERVICE_TOKEN}`,
49
+ };
50
+ const init: RequestInit = { method, headers };
51
+ if (body !== undefined) {
52
+ init.body = JSON.stringify(body);
53
+ }
54
+ return new Request(url, init);
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Tests
59
+ // ---------------------------------------------------------------------------
60
+
61
+ describe("credential route key normalization", () => {
62
+ it("normalizes colon-separated key on POST (set)", async () => {
63
+ const { deps, store } = makeDeps();
64
+
65
+ const req = makeRequest(
66
+ "POST",
67
+ "/v1/credentials/vellum%3Aplatform_organization_id",
68
+ { value: "org-uuid-123" },
69
+ );
70
+ const res = await handleCredentialRoute(req, deps);
71
+
72
+ expect(res).not.toBeNull();
73
+ expect(res!.status).toBe(200);
74
+ const body = await res!.json();
75
+ expect(body.ok).toBe(true);
76
+ expect(body.account).toBe("credential/vellum/platform_organization_id");
77
+
78
+ // Verify it was stored under the normalized key
79
+ expect(store.get("credential/vellum/platform_organization_id")).toBe("org-uuid-123");
80
+ expect(store.has("vellum:platform_organization_id")).toBe(false);
81
+ });
82
+
83
+ it("normalizes colon-separated key on GET", async () => {
84
+ const { deps, store } = makeDeps();
85
+ store.set("credential/vellum/platform_user_id", "user-uuid-456");
86
+
87
+ const req = makeRequest(
88
+ "GET",
89
+ "/v1/credentials/vellum%3Aplatform_user_id",
90
+ );
91
+ const res = await handleCredentialRoute(req, deps);
92
+
93
+ expect(res).not.toBeNull();
94
+ expect(res!.status).toBe(200);
95
+ const body = await res!.json();
96
+ expect(body.value).toBe("user-uuid-456");
97
+ });
98
+
99
+ it("normalizes colon-separated key on DELETE", async () => {
100
+ const { deps, store } = makeDeps();
101
+ store.set("credential/vellum/temp_cred", "temp-value");
102
+
103
+ const req = makeRequest(
104
+ "DELETE",
105
+ "/v1/credentials/vellum%3Atemp_cred",
106
+ );
107
+ const res = await handleCredentialRoute(req, deps);
108
+
109
+ expect(res).not.toBeNull();
110
+ expect(res!.status).toBe(200);
111
+ expect(store.has("credential/vellum/temp_cred")).toBe(false);
112
+ });
113
+
114
+ it("passes through keys already in credential/ format", async () => {
115
+ const { deps, store } = makeDeps();
116
+
117
+ const req = makeRequest(
118
+ "POST",
119
+ "/v1/credentials/credential%2Fvellum%2Fassistant_api_key",
120
+ { value: "api-key-789" },
121
+ );
122
+ const res = await handleCredentialRoute(req, deps);
123
+
124
+ expect(res).not.toBeNull();
125
+ expect(res!.status).toBe(200);
126
+ expect(store.get("credential/vellum/assistant_api_key")).toBe("api-key-789");
127
+ });
128
+
129
+ it("passes through oauth/ prefixed keys", async () => {
130
+ const { deps, store } = makeDeps();
131
+
132
+ const req = makeRequest(
133
+ "POST",
134
+ "/v1/credentials/oauth%2Fconnection%2Faccess_token",
135
+ { value: "token-abc" },
136
+ );
137
+ const res = await handleCredentialRoute(req, deps);
138
+
139
+ expect(res).not.toBeNull();
140
+ expect(res!.status).toBe(200);
141
+ expect(store.get("oauth/connection/access_token")).toBe("token-abc");
142
+ });
143
+
144
+ it("normalizes colon-separated keys in bulk set", async () => {
145
+ const { deps, store } = makeDeps();
146
+
147
+ const req = makeRequest("POST", "/v1/credentials/bulk", {
148
+ credentials: [
149
+ { account: "vellum:platform_organization_id", value: "org-1" },
150
+ { account: "vellum:platform_user_id", value: "user-1" },
151
+ { account: "credential/vellum/assistant_api_key", value: "key-1" },
152
+ ],
153
+ });
154
+ const res = await handleCredentialRoute(req, deps);
155
+
156
+ expect(res).not.toBeNull();
157
+ expect(res!.status).toBe(200);
158
+ const body = await res!.json();
159
+
160
+ expect(body.results).toHaveLength(3);
161
+ expect(body.results[0].account).toBe("credential/vellum/platform_organization_id");
162
+ expect(body.results[1].account).toBe("credential/vellum/platform_user_id");
163
+ expect(body.results[2].account).toBe("credential/vellum/assistant_api_key");
164
+
165
+ expect(store.get("credential/vellum/platform_organization_id")).toBe("org-1");
166
+ expect(store.get("credential/vellum/platform_user_id")).toBe("user-1");
167
+ expect(store.get("credential/vellum/assistant_api_key")).toBe("key-1");
168
+ });
169
+
170
+ it("splits multi-colon keys at the last colon", async () => {
171
+ const { deps, store } = makeDeps();
172
+
173
+ const req = makeRequest(
174
+ "POST",
175
+ "/v1/credentials/integration%3Agoogle%3Aaccess_token",
176
+ { value: "google-token" },
177
+ );
178
+ const res = await handleCredentialRoute(req, deps);
179
+
180
+ expect(res).not.toBeNull();
181
+ expect(res!.status).toBe(200);
182
+ const body = await res!.json();
183
+ // "integration:google:access_token" splits at last colon →
184
+ // service="integration:google", field="access_token"
185
+ expect(body.account).toBe("credential/integration:google/access_token");
186
+ expect(store.get("credential/integration:google/access_token")).toBe("google-token");
187
+ });
188
+
189
+ it("returns normalized key in response body", async () => {
190
+ const { deps } = makeDeps();
191
+
192
+ const req = makeRequest(
193
+ "POST",
194
+ "/v1/credentials/slack_channel%3Abot_token",
195
+ { value: "xoxb-test" },
196
+ );
197
+ const res = await handleCredentialRoute(req, deps);
198
+
199
+ const body = await res!.json();
200
+ expect(body.account).toBe("credential/slack_channel/bot_token");
201
+ });
202
+ });
package/src/http/audit.ts CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { randomUUID } from "node:crypto";
17
17
 
18
- import type { AuditRecordSummary } from "@vellumai/ces-contracts";
18
+ import type { AuditRecordSummary } from "@vellumai/service-contracts/credential-rpc";
19
19
  import { derivePathTemplate } from "./path-template.js";
20
20
 
21
21
  // ---------------------------------------------------------------------------