@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.
- package/node_modules/@vellumai/service-contracts/package.json +2 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +4 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +107 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +5 -1
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +24 -0
- package/node_modules/@vellumai/service-contracts/src/twilio-ingress.ts +84 -0
- package/package.json +3 -2
- package/src/__tests__/ces-migrations-002-api-keys.test.ts +185 -0
- package/src/__tests__/ces-migrations-runner.test.ts +227 -0
- package/src/__tests__/cli.test.ts +139 -0
- package/src/__tests__/command-executor.test.ts +70 -41
- package/src/__tests__/local-token-refresh.test.ts +65 -38
- package/src/__tests__/toolstore.test.ts +65 -20
- package/src/__tests__/transport.test.ts +12 -3
- package/src/cli.ts +158 -0
- package/src/http/__tests__/credential-routes-normalization.test.ts +202 -0
- package/src/http/credential-routes.ts +53 -7
- package/src/main.ts +120 -50
- package/src/managed-main.ts +6 -0
- package/src/materializers/local-oauth-lookup.ts +7 -6
- package/src/materializers/local-token-refresh.ts +25 -15
- package/src/migrations/001-no-op.ts +19 -0
- package/src/migrations/002-api-keys-to-credentials.ts +60 -0
- package/src/migrations/registry.ts +15 -0
- package/src/migrations/runner.ts +146 -0
- package/src/migrations/types.ts +54 -0
- package/src/paths.ts +15 -11
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 {
|
|
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 {
|
|
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
|
-
//
|
|
90
|
+
// Path resolution
|
|
82
91
|
// ---------------------------------------------------------------------------
|
|
83
92
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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(
|
|
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
|
|
145
|
+
const workspaceDir = getWorkspaceDir();
|
|
110
146
|
const credentialMetadataPath = join(
|
|
111
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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(
|
|
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:
|
|
223
|
+
defaultWorkspaceDir: workspaceDir,
|
|
193
224
|
});
|
|
194
225
|
|
|
195
226
|
// Register manage_secure_command_tool handler
|
|
196
|
-
const toolRegistry = new Map<
|
|
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, {
|
|
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(
|
|
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(
|
|
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(
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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(
|
|
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({
|
package/src/managed-main.ts
CHANGED
|
@@ -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
|
|
68
|
+
* @param workspaceDir - The workspace directory (e.g. `~/.vellum/workspace`).
|
|
69
69
|
*/
|
|
70
70
|
export function createLocalOAuthLookup(
|
|
71
|
-
|
|
71
|
+
workspaceDir: string,
|
|
72
72
|
): OAuthConnectionLookup {
|
|
73
|
-
const dbPath = join(
|
|
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<
|
|
84
|
-
|
|
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<
|
|
92
|
-
|
|
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 {
|
|
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<
|
|
103
|
-
|
|
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<
|
|
114
|
-
|
|
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 {
|
|
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 =
|
|
135
|
-
|
|
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
|
-
|
|
251
|
+
workspaceDir: string,
|
|
242
252
|
secureKeyBackend: SecureKeyBackend,
|
|
243
253
|
): TokenRefreshFn {
|
|
244
|
-
const dbPath = join(
|
|
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
|
+
];
|