@vellumai/credential-executor 0.7.0 → 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.
package/src/main.ts CHANGED
@@ -21,9 +21,12 @@
21
21
 
22
22
  import { mkdirSync } from "node:fs";
23
23
  import { homedir } from "node:os";
24
- import { join } from "node:path";
24
+ import { dirname, join } from "node:path";
25
25
 
26
- import { CES_PROTOCOL_VERSION, CesRpcMethod } from "@vellumai/service-contracts/credential-rpc";
26
+ import {
27
+ CES_PROTOCOL_VERSION,
28
+ CesRpcMethod,
29
+ } from "@vellumai/service-contracts/credential-rpc";
27
30
  import { StaticCredentialMetadataStore } from "@vellumai/credential-storage";
28
31
 
29
32
  import { AuditStore } from "./audit/store.js";
@@ -36,6 +39,7 @@ import {
36
39
  } from "./grants/rpc-handlers.js";
37
40
  import { TemporaryGrantStore } from "./grants/temporary-store.js";
38
41
  import { LocalMaterialiser } from "./materializers/local.js";
42
+ import type { SecureKeyBackend } from "@vellumai/credential-storage";
39
43
  import { createLocalSecureKeyBackend } from "./materializers/local-secure-key-backend.js";
40
44
  import { createLocalOAuthLookup } from "./materializers/local-oauth-lookup.js";
41
45
  import { createLocalTokenRefreshFn } from "./materializers/local-token-refresh.js";
@@ -57,9 +61,14 @@ import {
57
61
  type RpcHandlerRegistry,
58
62
  type SessionIdRef,
59
63
  } from "./server.js";
60
- import { deleteBundleFromToolstore, publishBundle } from "./toolstore/publish.js";
64
+ import {
65
+ deleteBundleFromToolstore,
66
+ publishBundle,
67
+ } from "./toolstore/publish.js";
61
68
  import { validateSourceUrl } from "./toolstore/manifest.js";
62
69
  import { buildCesEgressHooks } from "./commands/egress-hooks.js";
70
+ import { CES_MIGRATIONS } from "./migrations/registry.js";
71
+ import { runCesMigrations } from "./migrations/runner.js";
63
72
 
64
73
  // ---------------------------------------------------------------------------
65
74
  // Data directory bootstrap
@@ -78,19 +87,46 @@ function ensureDataDirs(): void {
78
87
  }
79
88
 
80
89
  // ---------------------------------------------------------------------------
81
- // Vellum root resolution (mirrors assistant/src/util/platform.ts)
90
+ // Path resolution
82
91
  // ---------------------------------------------------------------------------
83
92
 
84
- function getVellumRootDir(): string {
85
- const baseDataDir = process.env["BASE_DATA_DIR"]?.trim();
86
- return join(baseDataDir || homedir(), ".vellum");
93
+ /**
94
+ * Resolve the workspace directory.
95
+ *
96
+ * Priority:
97
+ * 1. `VELLUM_WORKSPACE_DIR` env var (set by the platform template)
98
+ * 2. Default: `~/.vellum/workspace`
99
+ */
100
+ function getWorkspaceDir(): string {
101
+ return (
102
+ process.env["VELLUM_WORKSPACE_DIR"]?.trim() ||
103
+ join(homedir(), ".vellum", "workspace")
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Resolve the CES security directory (contains key stores, encryption data).
109
+ *
110
+ * Priority:
111
+ * 1. `CREDENTIAL_SECURITY_DIR` env var (set by the platform template for
112
+ * the CES container — `/ces-security` in managed mode)
113
+ * 2. Default: `~/.vellum/protected` (local mode)
114
+ */
115
+ function getSecurityDir(): string {
116
+ return (
117
+ process.env["CREDENTIAL_SECURITY_DIR"]?.trim() ||
118
+ join(homedir(), ".vellum", "protected")
119
+ );
87
120
  }
88
121
 
89
122
  // ---------------------------------------------------------------------------
90
123
  // Build RPC handler registry
91
124
  // ---------------------------------------------------------------------------
92
125
 
93
- function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
126
+ function buildHandlers(
127
+ sessionIdRef: SessionIdRef,
128
+ secureKeyBackend: SecureKeyBackend,
129
+ ): RpcHandlerRegistry {
94
130
  // -- Grant stores ----------------------------------------------------------
95
131
  const persistentGrantStore = new PersistentGrantStore(
96
132
  getCesGrantsDir("local"),
@@ -106,10 +142,9 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
106
142
  // -- Credential backend (local) --------------------------------------------
107
143
  // In local mode CES shares the filesystem with the assistant and can access
108
144
  // the same credential metadata and secure-key stores.
109
- const vellumRoot = getVellumRootDir();
145
+ const workspaceDir = getWorkspaceDir();
110
146
  const credentialMetadataPath = join(
111
- vellumRoot,
112
- "workspace",
147
+ workspaceDir,
113
148
  "data",
114
149
  "credentials",
115
150
  "metadata.json",
@@ -120,33 +155,27 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
120
155
 
121
156
  // Read-only OAuth connection lookup backed by the assistant's SQLite
122
157
  // database. CES opens the database in read-only mode.
123
- const oauthConnections = createLocalOAuthLookup(vellumRoot);
124
-
125
- // CES-native SecureKeyBackend that reads from the assistant's encrypted
126
- // key store file. Read-only — CES never writes or deletes keys.
127
- const secureKeyBackend = createLocalSecureKeyBackend(vellumRoot);
158
+ const oauthConnections = createLocalOAuthLookup(workspaceDir);
128
159
 
129
160
  const localMaterialiser = new LocalMaterialiser({
130
161
  secureKeyBackend,
131
- tokenRefreshFn: createLocalTokenRefreshFn(vellumRoot, secureKeyBackend),
162
+ tokenRefreshFn: createLocalTokenRefreshFn(workspaceDir, secureKeyBackend),
132
163
  });
133
164
 
134
165
  // -- Build handler registry ------------------------------------------------
135
166
 
136
167
  // Start with the HTTP handler (make_authenticated_request)
137
- const handlers = buildHandlersWithHttp(
138
- {
139
- persistentGrantStore,
140
- temporaryGrantStore,
141
- localMaterialiser,
142
- localSubjectDeps: {
143
- metadataStore,
144
- oauthConnections,
145
- },
146
- auditStore,
147
- sessionId: sessionIdRef,
168
+ const handlers = buildHandlersWithHttp({
169
+ persistentGrantStore,
170
+ temporaryGrantStore,
171
+ localMaterialiser,
172
+ localSubjectDeps: {
173
+ metadataStore,
174
+ oauthConnections,
148
175
  },
149
- );
176
+ auditStore,
177
+ sessionId: sessionIdRef,
178
+ });
150
179
 
151
180
  // Register run_authenticated_command handler
152
181
  registerCommandExecutionHandler(handlers, {
@@ -174,7 +203,9 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
174
203
  }
175
204
  }
176
205
 
177
- const matResult = await localMaterialiser.materialise(subjectResult.subject);
206
+ const matResult = await localMaterialiser.materialise(
207
+ subjectResult.subject,
208
+ );
178
209
  if (!matResult.ok) {
179
210
  return { ok: false as const, error: matResult.error };
180
211
  }
@@ -189,11 +220,19 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
189
220
  cesMode: "local",
190
221
  egressHooks: buildCesEgressHooks(),
191
222
  },
192
- defaultWorkspaceDir: join(vellumRoot, "workspace"),
223
+ defaultWorkspaceDir: workspaceDir,
193
224
  });
194
225
 
195
226
  // Register manage_secure_command_tool handler
196
- const toolRegistry = new Map<string, { toolName: string; credentialHandle: string; description: string; bundleDigest: string }>();
227
+ const toolRegistry = new Map<
228
+ string,
229
+ {
230
+ toolName: string;
231
+ credentialHandle: string;
232
+ description: string;
233
+ bundleDigest: string;
234
+ }
235
+ >();
197
236
 
198
237
  registerManageSecureCommandToolHandler(handlers, {
199
238
  downloadBundle: async (sourceUrl: string) => {
@@ -202,13 +241,17 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
202
241
  throw new Error(urlError);
203
242
  }
204
243
  const MAX_BUNDLE_SIZE = 100 * 1024 * 1024; // 100 MB
205
- const resp = await fetch(sourceUrl, { signal: AbortSignal.timeout(60_000) });
244
+ const resp = await fetch(sourceUrl, {
245
+ signal: AbortSignal.timeout(60_000),
246
+ });
206
247
  if (!resp.ok) {
207
248
  throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
208
249
  }
209
250
  const contentLength = resp.headers.get("content-length");
210
251
  if (contentLength && parseInt(contentLength, 10) > MAX_BUNDLE_SIZE) {
211
- throw new Error(`Bundle too large: ${contentLength} bytes (max ${MAX_BUNDLE_SIZE})`);
252
+ throw new Error(
253
+ `Bundle too large: ${contentLength} bytes (max ${MAX_BUNDLE_SIZE})`,
254
+ );
212
255
  }
213
256
  // Stream the body and enforce the size limit on actual bytes received,
214
257
  // since Content-Length can be absent (chunked encoding) or lie.
@@ -221,7 +264,9 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
221
264
  for await (const chunk of body) {
222
265
  totalBytes += chunk.byteLength;
223
266
  if (totalBytes > MAX_BUNDLE_SIZE) {
224
- throw new Error(`Bundle too large: received >${MAX_BUNDLE_SIZE} bytes (max ${MAX_BUNDLE_SIZE})`);
267
+ throw new Error(
268
+ `Bundle too large: received >${MAX_BUNDLE_SIZE} bytes (max ${MAX_BUNDLE_SIZE})`,
269
+ );
225
270
  }
226
271
  chunks.push(chunk);
227
272
  }
@@ -232,7 +277,9 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
232
277
  const entry = toolRegistry.get(toolName);
233
278
  const removed = toolRegistry.delete(toolName);
234
279
  if (removed && entry?.bundleDigest) {
235
- const stillInUse = Array.from(toolRegistry.values()).some(t => t.bundleDigest === entry.bundleDigest);
280
+ const stillInUse = Array.from(toolRegistry.values()).some(
281
+ (t) => t.bundleDigest === entry.bundleDigest,
282
+ );
236
283
  if (!stillInUse) {
237
284
  deleteBundleFromToolstore(entry.bundleDigest, "local");
238
285
  }
@@ -248,50 +295,57 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
248
295
  handlers[CesRpcMethod.RecordGrant] = createRecordGrantHandler({
249
296
  persistentGrantStore,
250
297
  temporaryGrantStore,
251
- }) as typeof handlers[string];
298
+ }) as (typeof handlers)[string];
252
299
 
253
300
  handlers[CesRpcMethod.ListGrants] = createListGrantsHandler({
254
301
  persistentGrantStore,
255
- }) as typeof handlers[string];
302
+ }) as (typeof handlers)[string];
256
303
 
257
304
  handlers[CesRpcMethod.RevokeGrant] = createRevokeGrantHandler({
258
305
  persistentGrantStore,
259
- }) as typeof handlers[string];
306
+ }) as (typeof handlers)[string];
260
307
 
261
308
  // Register audit record handler
262
309
  handlers[CesRpcMethod.ListAuditRecords] = createListAuditRecordsHandler({
263
310
  auditStore,
264
- }) as typeof handlers[string];
311
+ }) as (typeof handlers)[string];
265
312
 
266
313
  // Register credential CRUD handlers
267
314
  handlers[CesRpcMethod.GetCredential] = (async (req: { account: string }) => {
268
315
  const value = await secureKeyBackend.get(req.account);
269
316
  return { found: value !== undefined, value };
270
- }) as typeof handlers[string];
317
+ }) as (typeof handlers)[string];
271
318
 
272
- handlers[CesRpcMethod.SetCredential] = (async (req: { account: string; value: string }) => {
319
+ handlers[CesRpcMethod.SetCredential] = (async (req: {
320
+ account: string;
321
+ value: string;
322
+ }) => {
273
323
  const ok = await secureKeyBackend.set(req.account, req.value);
274
324
  return { ok };
275
- }) as typeof handlers[string];
325
+ }) as (typeof handlers)[string];
276
326
 
277
- handlers[CesRpcMethod.DeleteCredential] = (async (req: { account: string }) => {
327
+ handlers[CesRpcMethod.DeleteCredential] = (async (req: {
328
+ account: string;
329
+ }) => {
278
330
  const result = await secureKeyBackend.delete(req.account);
279
331
  return { result };
280
- }) as typeof handlers[string];
332
+ }) as (typeof handlers)[string];
281
333
 
282
334
  handlers[CesRpcMethod.ListCredentials] = (async () => {
283
335
  const accounts = await secureKeyBackend.list();
284
336
  return { accounts };
285
- }) as typeof handlers[string];
337
+ }) as (typeof handlers)[string];
286
338
 
287
- handlers[CesRpcMethod.BulkSetCredentials] = (async (req: { credentials: Array<{ account: string; value: string }> }) => {
339
+ handlers[CesRpcMethod.BulkSetCredentials] = (async (req: {
340
+ credentials: Array<{ account: string; value: string }>;
341
+ }) => {
288
342
  const results = [];
289
343
  for (const { account, value } of req.credentials) {
290
344
  const ok = await secureKeyBackend.set(account, value);
291
345
  results.push({ account, ok });
292
346
  }
293
347
  return { results };
294
- }) as typeof handlers[string];
348
+ }) as (typeof handlers)[string];
295
349
 
296
350
  return handlers;
297
351
  }
@@ -306,7 +360,9 @@ async function main(): Promise<void> {
306
360
  initLogger({ dir: getCesLogDir(), retentionDays: 30 });
307
361
  const log = getLogger("main");
308
362
 
309
- log.info(`Starting CES v${CES_PROTOCOL_VERSION} (local mode, stdio transport)`);
363
+ log.info(
364
+ `Starting CES v${CES_PROTOCOL_VERSION} (local mode, stdio transport)`,
365
+ );
310
366
 
311
367
  const controller = new AbortController();
312
368
 
@@ -318,11 +374,25 @@ async function main(): Promise<void> {
318
374
  process.on("SIGTERM", shutdown);
319
375
  process.on("SIGINT", shutdown);
320
376
 
377
+ // Build the credential backend and run one-time migrations before starting
378
+ // the RPC server. Migrations complete synchronously before any connection
379
+ // is accepted — the backend is then passed to buildHandlers so it is not
380
+ // re-instantiated.
381
+ const secureKeyBackend = createLocalSecureKeyBackend(
382
+ dirname(getSecurityDir()),
383
+ );
384
+ await runCesMigrations(
385
+ getCesDataRoot("local"),
386
+ secureKeyBackend,
387
+ CES_MIGRATIONS,
388
+ );
389
+ log.info("CES local startup: migrations complete");
390
+
321
391
  // Build the handler registry with all available RPC implementations.
322
392
  // Use a mutable ref so audit records capture the handshake session ID
323
393
  // once it's negotiated (the handshake completes before any RPC call).
324
394
  const sessionIdRef: SessionIdRef = { current: `ces-local-${Date.now()}` };
325
- const handlers = buildHandlers(sessionIdRef);
395
+ const handlers = buildHandlers(sessionIdRef, secureKeyBackend);
326
396
 
327
397
  const rpcLog = getLogger("rpc");
328
398
  const server = new CesRpcServer({
@@ -63,6 +63,8 @@ import type { SecureKeyBackend } from "@vellumai/credential-storage";
63
63
  import { createLocalSecureKeyBackend } from "./materializers/local-secure-key-backend.js";
64
64
  import { handleCredentialRoute, type CredentialRouteDeps } from "./http/credential-routes.js";
65
65
  import { handleLogExportRoute } from "./http/log-export-routes.js";
66
+ import { CES_MIGRATIONS } from "./migrations/registry.js";
67
+ import { runCesMigrations } from "./migrations/runner.js";
66
68
 
67
69
  // ---------------------------------------------------------------------------
68
70
  // Logging
@@ -543,6 +545,10 @@ async function main(): Promise<void> {
543
545
  const vellumRoot = join(assistantDataMount, ".vellum");
544
546
  const secureKeyBackend = createLocalSecureKeyBackend(vellumRoot);
545
547
 
548
+ // Run one-time credential store migrations before accepting connections.
549
+ await runCesMigrations(getCesDataRoot("managed"), secureKeyBackend, CES_MIGRATIONS);
550
+ log.info("CES managed startup: migrations complete");
551
+
546
552
  // Set up credential CRUD routes if a service token is configured.
547
553
  // The assistant and gateway use CES_SERVICE_TOKEN to authenticate
548
554
  // credential management requests over HTTP.
@@ -65,12 +65,12 @@ function rowToRecord(row: OAuthConnectionRow): OAuthConnectionRecord {
65
65
  * Create a read-only OAuth connection lookup backed by the assistant's
66
66
  * SQLite database.
67
67
  *
68
- * @param vellumRoot - The Vellum root directory (e.g. `~/.vellum`).
68
+ * @param workspaceDir - The workspace directory (e.g. `~/.vellum/workspace`).
69
69
  */
70
70
  export function createLocalOAuthLookup(
71
- vellumRoot: string,
71
+ workspaceDir: string,
72
72
  ): OAuthConnectionLookup {
73
- const dbPath = join(vellumRoot, "workspace", "data", "db", "assistant.db");
73
+ const dbPath = join(workspaceDir, "data", "db", "assistant.db");
74
74
 
75
75
  return {
76
76
  getById(connectionId: string): OAuthConnectionRecord | undefined {
@@ -80,9 +80,10 @@ export function createLocalOAuthLookup(
80
80
  try {
81
81
  db = new Database(dbPath, { readonly: true });
82
82
  const row = db
83
- .query<OAuthConnectionRow, [string, string]>(
84
- `SELECT * FROM oauth_connections WHERE id = ? AND status = ? LIMIT 1`,
85
- )
83
+ .query<
84
+ OAuthConnectionRow,
85
+ [string, string]
86
+ >(`SELECT * FROM oauth_connections WHERE id = ? AND status = ? LIMIT 1`)
86
87
  .get(connectionId, "active");
87
88
 
88
89
  if (!row) return undefined;
@@ -88,20 +88,24 @@ async function resolveRefreshConfig(
88
88
 
89
89
  // 1. Look up the connection to get oauth_app_id and provider_key
90
90
  const conn = db
91
- .query<OAuthConnectionRow, [string, string]>(
92
- `SELECT id, oauth_app_id, provider_key FROM oauth_connections WHERE id = ? AND status = ? LIMIT 1`,
93
- )
91
+ .query<
92
+ OAuthConnectionRow,
93
+ [string, string]
94
+ >(`SELECT id, oauth_app_id, provider_key FROM oauth_connections WHERE id = ? AND status = ? LIMIT 1`)
94
95
  .get(connectionId, "active");
95
96
 
96
97
  if (!conn) {
97
- return { error: `No active OAuth connection found for "${connectionId}"` };
98
+ return {
99
+ error: `No active OAuth connection found for "${connectionId}"`,
100
+ };
98
101
  }
99
102
 
100
103
  // 2. Look up the app to get client_id and client_secret_credential_path
101
104
  const app = db
102
- .query<OAuthAppRow, [string]>(
103
- `SELECT id, provider_key, client_id, client_secret_credential_path FROM oauth_apps WHERE id = ? LIMIT 1`,
104
- )
105
+ .query<
106
+ OAuthAppRow,
107
+ [string]
108
+ >(`SELECT id, provider_key, client_id, client_secret_credential_path FROM oauth_apps WHERE id = ? LIMIT 1`)
105
109
  .get(conn.oauth_app_id);
106
110
 
107
111
  if (!app) {
@@ -110,9 +114,10 @@ async function resolveRefreshConfig(
110
114
 
111
115
  // 3. Look up the provider to get token_url and auth method
112
116
  const provider = db
113
- .query<OAuthProviderRow, [string]>(
114
- `SELECT provider_key, token_url, refresh_url, token_endpoint_auth_method, token_exchange_body_format FROM oauth_providers WHERE provider_key = ? LIMIT 1`,
115
- )
117
+ .query<
118
+ OAuthProviderRow,
119
+ [string]
120
+ >(`SELECT provider_key, token_url, refresh_url, token_endpoint_auth_method, token_exchange_body_format FROM oauth_providers WHERE provider_key = ? LIMIT 1`)
116
121
  .get(conn.provider_key);
117
122
 
118
123
  if (!provider) {
@@ -123,7 +128,9 @@ async function resolveRefreshConfig(
123
128
  const tokenUrl = provider.refresh_url || provider.token_url;
124
129
 
125
130
  if (!tokenUrl || !app.client_id) {
126
- return { error: `Missing OAuth2 refresh config for "${conn.provider_key}"` };
131
+ return {
132
+ error: `Missing OAuth2 refresh config for "${conn.provider_key}"`,
133
+ };
127
134
  }
128
135
 
129
136
  // 4. Retrieve the client secret from secure storage
@@ -131,8 +138,11 @@ async function resolveRefreshConfig(
131
138
  app.client_secret_credential_path,
132
139
  );
133
140
 
134
- const authMethod = (provider.token_endpoint_auth_method as TokenEndpointAuthMethod | null) ?? "client_secret_post";
135
- const bodyFormat = (provider.token_exchange_body_format as "form" | "json" | null) ?? "form";
141
+ const authMethod =
142
+ (provider.token_endpoint_auth_method as TokenEndpointAuthMethod | null) ??
143
+ "client_secret_post";
144
+ const bodyFormat =
145
+ (provider.token_exchange_body_format as "form" | "json" | null) ?? "form";
136
146
 
137
147
  return {
138
148
  tokenUrl,
@@ -238,10 +248,10 @@ async function performTokenRefresh(
238
248
  * @param secureKeyBackend - Backend for retrieving the OAuth client secret.
239
249
  */
240
250
  export function createLocalTokenRefreshFn(
241
- vellumRoot: string,
251
+ workspaceDir: string,
242
252
  secureKeyBackend: SecureKeyBackend,
243
253
  ): TokenRefreshFn {
244
- const dbPath = join(vellumRoot, "workspace", "data", "db", "assistant.db");
254
+ const dbPath = join(workspaceDir, "data", "db", "assistant.db");
245
255
 
246
256
  return async (
247
257
  connectionId: string,
@@ -0,0 +1,19 @@
1
+ import type { CesMigration } from "./types.js";
2
+
3
+ /**
4
+ * No-op foundation migration.
5
+ *
6
+ * Establishes the CES migration system and seeds the checkpoint file for
7
+ * all existing installations. The next real migration (API key → credential
8
+ * key rekeying) will follow this one.
9
+ */
10
+ export const noOpMigration: CesMigration = {
11
+ id: "001-no-op",
12
+ description: "Seed CES migration checkpoint (no-op foundation)",
13
+ run(_backend): void {
14
+ // Intentionally empty — seeds the checkpoint file for existing installs.
15
+ },
16
+ down(_backend): void {
17
+ // Intentionally empty — nothing to reverse.
18
+ },
19
+ };
@@ -0,0 +1,60 @@
1
+ import type { CesMigration } from "./types.js";
2
+
3
+ /**
4
+ * Providers whose bare API-key entries (e.g. `anthropic`) must be moved to
5
+ * the canonical `credential/{provider}/api_key` namespace.
6
+ *
7
+ * Note: `elevenlabs` is intentionally omitted — it was already migrated by
8
+ * `migrateElevenLabsToCredential()` in the Swift layer before CES migrations
9
+ * were introduced.
10
+ */
11
+ const PROVIDERS_TO_MIGRATE = [
12
+ "anthropic",
13
+ "openai",
14
+ "gemini",
15
+ "ollama",
16
+ "fireworks",
17
+ "openrouter",
18
+ "brave",
19
+ "perplexity",
20
+ "deepgram",
21
+ "xai",
22
+ ] as const;
23
+
24
+ export const apiKeyToCredentialsMigration: CesMigration = {
25
+ id: "002-api-keys-to-credentials",
26
+ description:
27
+ "Rekey bare provider API keys to credential/{provider}/api_key namespace",
28
+
29
+ async run(backend): Promise<void> {
30
+ for (const provider of PROVIDERS_TO_MIGRATE) {
31
+ const bareValue = await backend.get(provider);
32
+ if (bareValue === undefined) continue; // nothing to migrate for this provider
33
+
34
+ const credKey = `credential/${provider}/api_key`;
35
+ const existingCred = await backend.get(credKey);
36
+ if (existingCred === undefined) {
37
+ // Write new key first — safe to re-run if we crash after this.
38
+ // Skip delete if the write fails so the bare key is preserved for retry.
39
+ const ok = await backend.set(credKey, bareValue);
40
+ if (!ok) continue;
41
+ }
42
+ // Always delete old bare key (idempotent: harmless if already absent)
43
+ await backend.delete(provider);
44
+ }
45
+ },
46
+
47
+ async down(backend): Promise<void> {
48
+ for (const provider of PROVIDERS_TO_MIGRATE) {
49
+ const credKey = `credential/${provider}/api_key`;
50
+ const credValue = await backend.get(credKey);
51
+ if (credValue === undefined) continue;
52
+
53
+ const existingBare = await backend.get(provider);
54
+ if (existingBare === undefined) {
55
+ await backend.set(provider, credValue);
56
+ }
57
+ await backend.delete(credKey);
58
+ }
59
+ },
60
+ };
@@ -0,0 +1,15 @@
1
+ import { apiKeyToCredentialsMigration } from "./002-api-keys-to-credentials.js";
2
+ import { noOpMigration } from "./001-no-op.js";
3
+ import type { CesMigration } from "./types.js";
4
+
5
+ /**
6
+ * Ordered list of all CES data migrations.
7
+ *
8
+ * New migrations are appended to the end. Never reorder or remove entries —
9
+ * the runner uses array position for ordering and the `id` field for
10
+ * checkpoint tracking.
11
+ */
12
+ export const CES_MIGRATIONS: CesMigration[] = [
13
+ noOpMigration,
14
+ apiKeyToCredentialsMigration,
15
+ ];