@vellumai/credential-executor 0.7.0 → 0.7.2

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.
@@ -0,0 +1,146 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
4
+ import type { SecureKeyBackend } from "@vellumai/credential-storage";
5
+
6
+ import { getLogger } from "../logger.js";
7
+ import type { CesMigration, CesMigrationStatus } from "./types.js";
8
+
9
+ const log = getLogger("ces-migrations");
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Checkpoint file
13
+ // ---------------------------------------------------------------------------
14
+
15
+ type CheckpointFile = {
16
+ applied: Record<string, { appliedAt: string; status?: CesMigrationStatus }>;
17
+ };
18
+
19
+ function getCheckpointPath(cesDataRoot: string): string {
20
+ return join(cesDataRoot, ".ces-migrations.json");
21
+ }
22
+
23
+ function loadCheckpoints(cesDataRoot: string): CheckpointFile {
24
+ const path = getCheckpointPath(cesDataRoot);
25
+ if (!existsSync(path)) {
26
+ return { applied: {} };
27
+ }
28
+ try {
29
+ const raw = readFileSync(path, "utf-8");
30
+ const data = JSON.parse(raw);
31
+ if (
32
+ typeof data === "object" &&
33
+ data != null &&
34
+ typeof data.applied === "object" &&
35
+ data.applied != null
36
+ ) {
37
+ return data as CheckpointFile;
38
+ }
39
+ log.warn(
40
+ "CES migration checkpoint file has unexpected structure; treating as fresh state",
41
+ );
42
+ } catch {
43
+ log.warn(
44
+ "CES migration checkpoint file is malformed; treating as fresh state",
45
+ );
46
+ }
47
+ return { applied: {} };
48
+ }
49
+
50
+ function saveCheckpoints(
51
+ cesDataRoot: string,
52
+ checkpoints: CheckpointFile,
53
+ ): void {
54
+ const path = getCheckpointPath(cesDataRoot);
55
+ mkdirSync(dirname(path), { recursive: true });
56
+ const tmpPath = path + ".tmp";
57
+ writeFileSync(tmpPath, JSON.stringify(checkpoints, null, 2) + "\n", "utf-8");
58
+ renameSync(tmpPath, path);
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Runner
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Run all pending CES migrations in registry order.
67
+ *
68
+ * - Skips migrations that already have a checkpoint entry (`"completed"` or
69
+ * `"failed"`). Only `"started"` and `"rolling_back"` entries are cleared
70
+ * and re-run on the next startup (crash recovery — migrations must be
71
+ * idempotent).
72
+ * - Marks failed migrations as `"failed"` and continues startup; a failed
73
+ * migration does not block the RPC server from starting.
74
+ *
75
+ * @param cesDataRoot The CES-private data root (from `getCesDataRoot(mode)`).
76
+ * The checkpoint file is stored here as `.ces-migrations.json`.
77
+ * @param backend The active `SecureKeyBackend` instance, passed directly to
78
+ * each migration's `run()` function.
79
+ * @param migrations Ordered list of migrations from the registry.
80
+ */
81
+ export async function runCesMigrations(
82
+ cesDataRoot: string,
83
+ backend: SecureKeyBackend,
84
+ migrations: CesMigration[],
85
+ ): Promise<void> {
86
+ // Validate uniqueness.
87
+ const seen = new Set<string>();
88
+ for (const m of migrations) {
89
+ if (seen.has(m.id)) {
90
+ throw new Error(`Duplicate CES migration id: "${m.id}"`);
91
+ }
92
+ seen.add(m.id);
93
+ }
94
+
95
+ const checkpoints = loadCheckpoints(cesDataRoot);
96
+
97
+ // Clear any interrupted checkpoints so they re-run.
98
+ for (const [id, entry] of Object.entries(checkpoints.applied)) {
99
+ if (entry.status === "started" || entry.status === "rolling_back") {
100
+ log.warn(
101
+ `CES migration "${id}" was interrupted during a previous run; will re-run`,
102
+ );
103
+ delete checkpoints.applied[id];
104
+ }
105
+ }
106
+
107
+ for (const migration of migrations) {
108
+ if (checkpoints.applied[migration.id]) {
109
+ continue;
110
+ }
111
+
112
+ log.info(
113
+ `Running CES migration: ${migration.id} — ${migration.description}`,
114
+ );
115
+
116
+ // Mark as started before executing (crash recovery observability).
117
+ checkpoints.applied[migration.id] = {
118
+ appliedAt: new Date().toISOString(),
119
+ status: "started",
120
+ };
121
+ saveCheckpoints(cesDataRoot, checkpoints);
122
+
123
+ try {
124
+ await migration.run(backend);
125
+ } catch (error) {
126
+ log.error(
127
+ { migrationId: migration.id, error },
128
+ `CES migration failed: ${migration.id} — marking as failed and continuing`,
129
+ );
130
+ checkpoints.applied[migration.id] = {
131
+ appliedAt: new Date().toISOString(),
132
+ status: "failed",
133
+ };
134
+ saveCheckpoints(cesDataRoot, checkpoints);
135
+ continue;
136
+ }
137
+
138
+ checkpoints.applied[migration.id] = {
139
+ appliedAt: new Date().toISOString(),
140
+ status: "completed",
141
+ };
142
+ saveCheckpoints(cesDataRoot, checkpoints);
143
+
144
+ log.info(`CES migration completed: ${migration.id}`);
145
+ }
146
+ }
@@ -0,0 +1,54 @@
1
+ import type { SecureKeyBackend } from "@vellumai/credential-storage";
2
+
3
+ /**
4
+ * A single CES data migration.
5
+ *
6
+ * Migrations run at CES startup — once per installation, tracked via a
7
+ * checkpoint file in the CES-private data root. They are the right place
8
+ * for one-time transformations of the credential store (key renames,
9
+ * format changes, etc.) that must happen before the RPC server accepts
10
+ * connections.
11
+ *
12
+ * **Idempotency**: `run` and `down` must both be safe to re-run. The
13
+ * runner re-executes any migration whose checkpoint was left in `"started"`
14
+ * state (i.e. the process crashed mid-migration).
15
+ *
16
+ * **Ordering**: Migrations are executed in registry order. Never reorder
17
+ * or remove an entry from the registry once it has been released.
18
+ */
19
+ export interface CesMigration {
20
+ /**
21
+ * Unique identifier used as the checkpoint key.
22
+ * Convention: `"NNN-short-description"`, e.g. `"001-no-op"`.
23
+ * Must be unique across all registered migrations.
24
+ */
25
+ id: string;
26
+
27
+ /** Human-readable description logged when the migration runs. */
28
+ description: string;
29
+
30
+ /**
31
+ * Apply the migration.
32
+ *
33
+ * Receives the active `SecureKeyBackend` so the migration can read,
34
+ * write, and delete credential store entries without re-opening the
35
+ * encrypted store.
36
+ */
37
+ run(backend: SecureKeyBackend): void | Promise<void>;
38
+
39
+ /**
40
+ * Reverse the migration (best-effort).
41
+ *
42
+ * Some migrations are forward-only (e.g. dropping a key that is no
43
+ * longer used). In those cases, implement `down` as a no-op and document
44
+ * why reversal is not meaningful.
45
+ */
46
+ down(backend: SecureKeyBackend): void | Promise<void>;
47
+ }
48
+
49
+ /** Checkpoint status values written to `.ces-migrations.json`. */
50
+ export type CesMigrationStatus =
51
+ | "started"
52
+ | "completed"
53
+ | "rolling_back"
54
+ | "failed";
package/src/paths.ts CHANGED
@@ -43,12 +43,19 @@ export function getCesMode(): CesMode {
43
43
  // ---------------------------------------------------------------------------
44
44
 
45
45
  /**
46
- * Resolve the Vellum root directory, respecting `BASE_DATA_DIR` for
47
- * multi-instance deployments. Mirrors the logic in `assistant/src/util/platform.ts`.
46
+ * Resolve the CES security directory.
47
+ *
48
+ * Priority:
49
+ * 1. `CREDENTIAL_SECURITY_DIR` env var (set by the platform template for the
50
+ * CES container — `/ces-security` in managed mode)
51
+ * 2. Default: `~/.vellum/protected` (local mode shares the filesystem with
52
+ * the gateway)
48
53
  */
49
- function getVellumRootDir(): string {
50
- const baseDataDir = process.env["BASE_DATA_DIR"]?.trim();
51
- return join(baseDataDir || homedir(), ".vellum");
54
+ export function getSecurityDir(): string {
55
+ return (
56
+ process.env["CREDENTIAL_SECURITY_DIR"]?.trim() ||
57
+ join(homedir(), ".vellum", "protected")
58
+ );
52
59
  }
53
60
 
54
61
  /**
@@ -62,18 +69,15 @@ const DEFAULT_MANAGED_CES_DATA_ROOT = "/ces-data";
62
69
  /**
63
70
  * Return the CES-private data root.
64
71
  *
65
- * - Local: `<vellumRoot>/protected/credential-executor/`
72
+ * - Local: `<securityDir>/credential-executor/`
66
73
  * - Managed: `CES_DATA_DIR` env var, or `/ces-data` by default
67
74
  */
68
75
  export function getCesDataRoot(mode?: CesMode): string {
69
76
  const resolvedMode = mode ?? getCesMode();
70
77
  if (resolvedMode === "managed") {
71
- return (
72
- process.env["CES_DATA_DIR"] ??
73
- DEFAULT_MANAGED_CES_DATA_ROOT
74
- );
78
+ return process.env["CES_DATA_DIR"] ?? DEFAULT_MANAGED_CES_DATA_ROOT;
75
79
  }
76
- return join(getVellumRootDir(), "protected", "credential-executor");
80
+ return join(getSecurityDir(), "credential-executor");
77
81
  }
78
82
 
79
83
  // ---------------------------------------------------------------------------