@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
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.ts",
|
|
9
9
|
"./credential-rpc": "./src/credential-rpc.ts",
|
|
10
|
+
"./ingress": "./src/ingress.ts",
|
|
11
|
+
"./twilio-ingress": "./src/twilio-ingress.ts",
|
|
10
12
|
"./trust-rules": "./src/trust-rules.ts",
|
|
11
13
|
"./handles": "./src/handles.ts",
|
|
12
14
|
"./grants": "./src/grants.ts",
|
|
@@ -33,6 +33,8 @@ describe("package independence", () => {
|
|
|
33
33
|
"../transport.ts",
|
|
34
34
|
"../credential-rpc.ts",
|
|
35
35
|
"../trust-rules.ts",
|
|
36
|
+
"../ingress.ts",
|
|
37
|
+
"../twilio-ingress.ts",
|
|
36
38
|
"../error.ts",
|
|
37
39
|
];
|
|
38
40
|
|
|
@@ -219,6 +221,7 @@ describe("ToolResponseBaseSchema", () => {
|
|
|
219
221
|
result: { html: "<html></html>" },
|
|
220
222
|
});
|
|
221
223
|
expect(result.success).toBe(true);
|
|
224
|
+
if (!result.success) throw new Error("Expected successful response");
|
|
222
225
|
expect(result.result).toEqual({ html: "<html></html>" });
|
|
223
226
|
});
|
|
224
227
|
|
|
@@ -238,6 +241,7 @@ describe("ToolResponseBaseSchema", () => {
|
|
|
238
241
|
},
|
|
239
242
|
});
|
|
240
243
|
expect(result.success).toBe(false);
|
|
244
|
+
if (result.success) throw new Error("Expected failed response");
|
|
241
245
|
expect(result.error.code).toBe("TOOL_FAILED");
|
|
242
246
|
});
|
|
243
247
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
normalizeHttpPublicBaseUrl,
|
|
5
|
+
normalizePublicBaseUrl,
|
|
6
|
+
} from "../ingress.js";
|
|
7
|
+
import {
|
|
8
|
+
buildTwilioConnectActionUrl,
|
|
9
|
+
buildTwilioMediaStreamUrl,
|
|
10
|
+
buildTwilioPhoneNumberWebhookUrls,
|
|
11
|
+
buildTwilioRelayUrl,
|
|
12
|
+
buildTwilioVoiceWebhookUrl,
|
|
13
|
+
resolveTwilioPublicBaseUrl,
|
|
14
|
+
} from "../twilio-ingress.js";
|
|
15
|
+
|
|
16
|
+
describe("normalizePublicBaseUrl", () => {
|
|
17
|
+
test("trims whitespace and trailing slashes", () => {
|
|
18
|
+
expect(normalizePublicBaseUrl(" https://example.test/path/// ")).toBe(
|
|
19
|
+
"https://example.test/path",
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("rejects non-string and empty values", () => {
|
|
24
|
+
expect(normalizePublicBaseUrl(undefined)).toBeUndefined();
|
|
25
|
+
expect(normalizePublicBaseUrl(" ")).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("normalizeHttpPublicBaseUrl", () => {
|
|
30
|
+
test("normalizes valid HTTP and HTTPS URLs", () => {
|
|
31
|
+
expect(normalizeHttpPublicBaseUrl(" HTTPS://EXAMPLE.TEST/twilio ")).toBe(
|
|
32
|
+
"https://example.test/twilio",
|
|
33
|
+
);
|
|
34
|
+
expect(normalizeHttpPublicBaseUrl("https://example.test/twilio///")).toBe(
|
|
35
|
+
"https://example.test/twilio",
|
|
36
|
+
);
|
|
37
|
+
expect(normalizeHttpPublicBaseUrl("https://example.test")).toBe(
|
|
38
|
+
"https://example.test/",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("rejects non-HTTP URLs and malformed values", () => {
|
|
43
|
+
expect(normalizeHttpPublicBaseUrl("ftp://example.test")).toBeUndefined();
|
|
44
|
+
expect(normalizeHttpPublicBaseUrl("notaurl")).toBeUndefined();
|
|
45
|
+
expect(normalizeHttpPublicBaseUrl("")).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("rejects query strings and fragments instead of mutating them", () => {
|
|
49
|
+
expect(
|
|
50
|
+
normalizeHttpPublicBaseUrl("https://example.test/twilio?token=abc/"),
|
|
51
|
+
).toBeUndefined();
|
|
52
|
+
expect(
|
|
53
|
+
normalizeHttpPublicBaseUrl("https://example.test/twilio#section/"),
|
|
54
|
+
).toBeUndefined();
|
|
55
|
+
expect(
|
|
56
|
+
normalizeHttpPublicBaseUrl("https://example.test/twilio?"),
|
|
57
|
+
).toBeUndefined();
|
|
58
|
+
expect(
|
|
59
|
+
normalizeHttpPublicBaseUrl("https://example.test/twilio#"),
|
|
60
|
+
).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("Twilio ingress helpers", () => {
|
|
65
|
+
test("resolves public base URL with fallback", () => {
|
|
66
|
+
expect(
|
|
67
|
+
resolveTwilioPublicBaseUrl({
|
|
68
|
+
publicBaseUrl: " https://twilio.example.test/twilio/ ",
|
|
69
|
+
}),
|
|
70
|
+
).toBe("https://twilio.example.test/twilio");
|
|
71
|
+
expect(
|
|
72
|
+
resolveTwilioPublicBaseUrl({
|
|
73
|
+
publicBaseUrl: " ",
|
|
74
|
+
}),
|
|
75
|
+
).toBeUndefined();
|
|
76
|
+
expect(
|
|
77
|
+
resolveTwilioPublicBaseUrl({
|
|
78
|
+
publicBaseUrl: " ",
|
|
79
|
+
}, "https://fallback.example.test/"),
|
|
80
|
+
).toBe("https://fallback.example.test");
|
|
81
|
+
expect(
|
|
82
|
+
resolveTwilioPublicBaseUrl({}, "https://fallback.example.test/"),
|
|
83
|
+
).toBe("https://fallback.example.test");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("builds Twilio webhook and WebSocket URLs from one base URL", () => {
|
|
87
|
+
expect(buildTwilioVoiceWebhookUrl("https://example.test")).toBe(
|
|
88
|
+
"https://example.test/webhooks/twilio/voice",
|
|
89
|
+
);
|
|
90
|
+
expect(buildTwilioVoiceWebhookUrl("https://example.test", "call-123")).toBe(
|
|
91
|
+
"https://example.test/webhooks/twilio/voice?callSessionId=call-123",
|
|
92
|
+
);
|
|
93
|
+
expect(buildTwilioConnectActionUrl("https://example.test")).toBe(
|
|
94
|
+
"https://example.test/webhooks/twilio/connect-action",
|
|
95
|
+
);
|
|
96
|
+
expect(buildTwilioRelayUrl("https://example.test")).toBe(
|
|
97
|
+
"wss://example.test/webhooks/twilio/relay",
|
|
98
|
+
);
|
|
99
|
+
expect(buildTwilioMediaStreamUrl("http://example.test")).toBe(
|
|
100
|
+
"ws://example.test/webhooks/twilio/media-stream",
|
|
101
|
+
);
|
|
102
|
+
expect(buildTwilioPhoneNumberWebhookUrls("https://example.test")).toEqual({
|
|
103
|
+
statusCallbackUrl: "https://example.test/webhooks/twilio/status",
|
|
104
|
+
voiceUrl: "https://example.test/webhooks/twilio/voice",
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
*
|
|
7
7
|
* - `@vellumai/service-contracts/credential-rpc` — transport, RPC, handles, grants, rendering, error
|
|
8
8
|
* - `@vellumai/service-contracts/trust-rules` — trust-rule types and parsing helpers
|
|
9
|
+
* - `@vellumai/service-contracts/twilio-ingress` — shared Twilio ingress config constants
|
|
10
|
+
* - `@vellumai/service-contracts/ingress` — shared public ingress URL helpers
|
|
9
11
|
*
|
|
10
12
|
* Fine-grained subpaths are also available for low-friction migration:
|
|
11
|
-
* `./rpc`, `./handles`, `./grants`, `./rendering`, `./error`, `./trust-rules`
|
|
13
|
+
* `./rpc`, `./handles`, `./grants`, `./rendering`, `./error`, `./trust-rules`, `./ingress`, `./twilio-ingress`
|
|
12
14
|
*
|
|
13
15
|
* Neutral wire-protocol contracts for communication between the assistant
|
|
14
16
|
* daemon and the Credential Execution Service (CES). This package is
|
|
@@ -23,3 +25,5 @@ export * from "./grants.js";
|
|
|
23
25
|
export * from "./rpc.js";
|
|
24
26
|
export * from "./rendering.js";
|
|
25
27
|
export * from "./trust-rules.js";
|
|
28
|
+
export * from "./ingress.js";
|
|
29
|
+
export * from "./twilio-ingress.js";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function normalizePublicBaseUrl(value: unknown): string | undefined {
|
|
2
|
+
if (typeof value !== "string") return undefined;
|
|
3
|
+
const normalized = value.trim().replace(/\/+$/, "");
|
|
4
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function normalizeHttpPublicBaseUrl(value: unknown): string | undefined {
|
|
8
|
+
if (typeof value !== "string") return undefined;
|
|
9
|
+
const trimmed = value.trim();
|
|
10
|
+
if (trimmed.length === 0) return undefined;
|
|
11
|
+
if (/[?#]/.test(trimmed)) return undefined;
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const url = new URL(trimmed);
|
|
15
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
if (!url.hostname) return undefined;
|
|
19
|
+
url.pathname = url.pathname.replace(/\/+$/, "") || "/";
|
|
20
|
+
return url.toString();
|
|
21
|
+
} catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { normalizePublicBaseUrl } from "./ingress.js";
|
|
2
|
+
|
|
3
|
+
export const TWILIO_VOICE_WEBHOOK_PATH = "/webhooks/twilio/voice";
|
|
4
|
+
export const TWILIO_STATUS_WEBHOOK_PATH = "/webhooks/twilio/status";
|
|
5
|
+
export const TWILIO_CONNECT_ACTION_WEBHOOK_PATH =
|
|
6
|
+
"/webhooks/twilio/connect-action";
|
|
7
|
+
export const TWILIO_RELAY_WEBHOOK_PATH = "/webhooks/twilio/relay";
|
|
8
|
+
export const TWILIO_MEDIA_STREAM_WEBHOOK_PATH = "/webhooks/twilio/media-stream";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sentinel placeholder embedded in TwiML by the assistant where the real
|
|
12
|
+
* public base URL should go. The gateway replaces `wss://__VELLUM_PUBLIC_BASE_URL__/…`
|
|
13
|
+
* with the actual public URL (from Velay registration, config, or the
|
|
14
|
+
* `X-Vellum-Ingress-URL` header) before returning TwiML to Twilio.
|
|
15
|
+
*
|
|
16
|
+
* The placeholder uses `https://` so that `buildTwilioRelayUrl` /
|
|
17
|
+
* `buildTwilioMediaStreamUrl` can apply the standard `http→ws` scheme
|
|
18
|
+
* conversion, producing `wss://__VELLUM_PUBLIC_BASE_URL__/…` in the output.
|
|
19
|
+
*/
|
|
20
|
+
export const TWILIO_PUBLIC_BASE_URL_PLACEHOLDER =
|
|
21
|
+
"https://__VELLUM_PUBLIC_BASE_URL__";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The WebSocket-scheme form of the placeholder that appears in TwiML after
|
|
25
|
+
* the `http→ws` scheme conversion applied by the URL builders.
|
|
26
|
+
*/
|
|
27
|
+
export const TWILIO_PUBLIC_BASE_WSS_PLACEHOLDER =
|
|
28
|
+
"wss://__VELLUM_PUBLIC_BASE_URL__";
|
|
29
|
+
|
|
30
|
+
export { normalizePublicBaseUrl } from "./ingress.js";
|
|
31
|
+
|
|
32
|
+
export type TwilioPhoneNumberWebhookUrls = {
|
|
33
|
+
statusCallbackUrl: string;
|
|
34
|
+
voiceUrl: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function resolveTwilioPublicBaseUrl(
|
|
38
|
+
ingress: { publicBaseUrl?: unknown } | undefined,
|
|
39
|
+
fallbackPublicBaseUrl?: unknown,
|
|
40
|
+
): string | undefined {
|
|
41
|
+
const publicBaseUrl = normalizePublicBaseUrl(ingress?.publicBaseUrl);
|
|
42
|
+
if (publicBaseUrl) return publicBaseUrl;
|
|
43
|
+
|
|
44
|
+
return normalizePublicBaseUrl(fallbackPublicBaseUrl);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildTwilioVoiceWebhookUrl(
|
|
48
|
+
baseUrl: string,
|
|
49
|
+
callSessionId?: string,
|
|
50
|
+
): string {
|
|
51
|
+
if (callSessionId) {
|
|
52
|
+
return `${baseUrl}${TWILIO_VOICE_WEBHOOK_PATH}?callSessionId=${callSessionId}`;
|
|
53
|
+
}
|
|
54
|
+
return `${baseUrl}${TWILIO_VOICE_WEBHOOK_PATH}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildTwilioStatusWebhookUrl(baseUrl: string): string {
|
|
58
|
+
return `${baseUrl}${TWILIO_STATUS_WEBHOOK_PATH}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildTwilioConnectActionUrl(baseUrl: string): string {
|
|
62
|
+
return `${baseUrl}${TWILIO_CONNECT_ACTION_WEBHOOK_PATH}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildTwilioRelayUrl(baseUrl: string): string {
|
|
66
|
+
return `${toTwilioWebSocketBaseUrl(baseUrl)}${TWILIO_RELAY_WEBHOOK_PATH}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function buildTwilioMediaStreamUrl(baseUrl: string): string {
|
|
70
|
+
return `${toTwilioWebSocketBaseUrl(baseUrl)}${TWILIO_MEDIA_STREAM_WEBHOOK_PATH}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildTwilioPhoneNumberWebhookUrls(
|
|
74
|
+
baseUrl: string,
|
|
75
|
+
): TwilioPhoneNumberWebhookUrls {
|
|
76
|
+
return {
|
|
77
|
+
statusCallbackUrl: buildTwilioStatusWebhookUrl(baseUrl),
|
|
78
|
+
voiceUrl: buildTwilioVoiceWebhookUrl(baseUrl),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function toTwilioWebSocketBaseUrl(baseUrl: string): string {
|
|
83
|
+
return baseUrl.replace(/^http(s?)/, "ws$1");
|
|
84
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vellumai/credential-executor",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"bin": {
|
|
10
10
|
"credential-executor": "./src/main.ts",
|
|
11
|
-
"credential-executor-managed": "./src/managed-main.ts"
|
|
11
|
+
"credential-executor-managed": "./src/managed-main.ts",
|
|
12
|
+
"ces": "./src/cli.ts"
|
|
12
13
|
},
|
|
13
14
|
"scripts": {
|
|
14
15
|
"dev": "bun run src/main.ts",
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { apiKeyToCredentialsMigration } from "../migrations/002-api-keys-to-credentials.js";
|
|
4
|
+
import type { SecureKeyBackend } from "@vellumai/credential-storage";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Creates an in-memory SecureKeyBackend backed by a Map<string, string>.
|
|
12
|
+
* Allows us to assert state before/after migration without relying on mocked
|
|
13
|
+
* function call tracking.
|
|
14
|
+
*/
|
|
15
|
+
function makeMapBackend(
|
|
16
|
+
initial: Record<string, string> = {},
|
|
17
|
+
): SecureKeyBackend & { store: Map<string, string> } {
|
|
18
|
+
const store = new Map<string, string>(Object.entries(initial));
|
|
19
|
+
return {
|
|
20
|
+
store,
|
|
21
|
+
get: (_key: string) => Promise.resolve(store.get(_key)),
|
|
22
|
+
set: (_key: string, value: string) => {
|
|
23
|
+
store.set(_key, value);
|
|
24
|
+
return Promise.resolve(true);
|
|
25
|
+
},
|
|
26
|
+
delete: (_key: string) => {
|
|
27
|
+
const existed = store.has(_key);
|
|
28
|
+
store.delete(_key);
|
|
29
|
+
return Promise.resolve({ deleted: existed });
|
|
30
|
+
},
|
|
31
|
+
list: () => Promise.resolve([...store.keys()]),
|
|
32
|
+
} as unknown as SecureKeyBackend & { store: Map<string, string> };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Tests
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
describe("apiKeyToCredentialsMigration (002)", () => {
|
|
40
|
+
// -------------------------------------------------------------------------
|
|
41
|
+
// run()
|
|
42
|
+
// -------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
describe("run()", () => {
|
|
45
|
+
test("bare key present — writes credential key and deletes bare key", async () => {
|
|
46
|
+
const backend = makeMapBackend({ anthropic: "sk-ant-123" });
|
|
47
|
+
|
|
48
|
+
await apiKeyToCredentialsMigration.run(backend);
|
|
49
|
+
|
|
50
|
+
expect(backend.store.get("credential/anthropic/api_key")).toBe(
|
|
51
|
+
"sk-ant-123",
|
|
52
|
+
);
|
|
53
|
+
expect(backend.store.has("anthropic")).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("idempotent — credential key already exists: bare key deleted, credential value unchanged", async () => {
|
|
57
|
+
const backend = makeMapBackend({
|
|
58
|
+
anthropic: "sk-ant-new",
|
|
59
|
+
"credential/anthropic/api_key": "sk-ant-existing",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await apiKeyToCredentialsMigration.run(backend);
|
|
63
|
+
|
|
64
|
+
// Credential key must NOT be overwritten
|
|
65
|
+
expect(backend.store.get("credential/anthropic/api_key")).toBe(
|
|
66
|
+
"sk-ant-existing",
|
|
67
|
+
);
|
|
68
|
+
// Bare key must be removed
|
|
69
|
+
expect(backend.store.has("anthropic")).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("no bare key for provider — no write and no delete for that provider", async () => {
|
|
73
|
+
// Store only has a key for openai; anthropic has nothing
|
|
74
|
+
const backend = makeMapBackend({ openai: "sk-openai-abc" });
|
|
75
|
+
|
|
76
|
+
await apiKeyToCredentialsMigration.run(backend);
|
|
77
|
+
|
|
78
|
+
// openai should be migrated
|
|
79
|
+
expect(backend.store.get("credential/openai/api_key")).toBe(
|
|
80
|
+
"sk-openai-abc",
|
|
81
|
+
);
|
|
82
|
+
expect(backend.store.has("openai")).toBe(false);
|
|
83
|
+
|
|
84
|
+
// anthropic: credential key should NOT exist (no accidental write)
|
|
85
|
+
expect(backend.store.has("credential/anthropic/api_key")).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("multiple providers — each handled independently", async () => {
|
|
89
|
+
const backend = makeMapBackend({
|
|
90
|
+
anthropic: "sk-ant-multi",
|
|
91
|
+
openai: "sk-openai-multi",
|
|
92
|
+
gemini: "gemini-key",
|
|
93
|
+
brave: "brave-key",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await apiKeyToCredentialsMigration.run(backend);
|
|
97
|
+
|
|
98
|
+
// All bare keys gone
|
|
99
|
+
for (const provider of ["anthropic", "openai", "gemini", "brave"]) {
|
|
100
|
+
expect(backend.store.has(provider)).toBe(false);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// All credential keys present
|
|
104
|
+
expect(backend.store.get("credential/anthropic/api_key")).toBe(
|
|
105
|
+
"sk-ant-multi",
|
|
106
|
+
);
|
|
107
|
+
expect(backend.store.get("credential/openai/api_key")).toBe(
|
|
108
|
+
"sk-openai-multi",
|
|
109
|
+
);
|
|
110
|
+
expect(backend.store.get("credential/gemini/api_key")).toBe("gemini-key");
|
|
111
|
+
expect(backend.store.get("credential/brave/api_key")).toBe("brave-key");
|
|
112
|
+
|
|
113
|
+
// Providers that had no bare key should have no credential key
|
|
114
|
+
for (const provider of [
|
|
115
|
+
"ollama",
|
|
116
|
+
"fireworks",
|
|
117
|
+
"openrouter",
|
|
118
|
+
"perplexity",
|
|
119
|
+
"deepgram",
|
|
120
|
+
"xai",
|
|
121
|
+
]) {
|
|
122
|
+
expect(backend.store.has(`credential/${provider}/api_key`)).toBe(false);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("set() failure — bare key preserved, credential key absent", async () => {
|
|
127
|
+
const backend = makeMapBackend({ anthropic: "sk-ant-123" });
|
|
128
|
+
// Simulate a write failure
|
|
129
|
+
backend.set = (_key: string, _value: string) => Promise.resolve(false);
|
|
130
|
+
|
|
131
|
+
await apiKeyToCredentialsMigration.run(backend);
|
|
132
|
+
|
|
133
|
+
// Bare key must survive — it was not deleted because set() failed
|
|
134
|
+
expect(backend.store.get("anthropic")).toBe("sk-ant-123");
|
|
135
|
+
// Credential key must not exist
|
|
136
|
+
expect(backend.store.has("credential/anthropic/api_key")).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("run() is idempotent — running twice leaves store in same state as once", async () => {
|
|
140
|
+
const backend = makeMapBackend({
|
|
141
|
+
anthropic: "sk-ant-idem",
|
|
142
|
+
openai: "sk-openai-idem",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await apiKeyToCredentialsMigration.run(backend);
|
|
146
|
+
// Capture state after first run
|
|
147
|
+
const afterFirst = new Map(backend.store);
|
|
148
|
+
|
|
149
|
+
await apiKeyToCredentialsMigration.run(backend);
|
|
150
|
+
// State after second run must match first run
|
|
151
|
+
expect(backend.store).toEqual(afterFirst);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// -------------------------------------------------------------------------
|
|
156
|
+
// down()
|
|
157
|
+
// -------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
describe("down()", () => {
|
|
160
|
+
test("reverses a migrated key back to bare name", async () => {
|
|
161
|
+
const backend = makeMapBackend({
|
|
162
|
+
"credential/anthropic/api_key": "sk-ant-rev",
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await apiKeyToCredentialsMigration.down(backend);
|
|
166
|
+
|
|
167
|
+
expect(backend.store.get("anthropic")).toBe("sk-ant-rev");
|
|
168
|
+
expect(backend.store.has("credential/anthropic/api_key")).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("idempotent — bare key already exists: credential key deleted, bare key value unchanged", async () => {
|
|
172
|
+
const backend = makeMapBackend({
|
|
173
|
+
"credential/anthropic/api_key": "sk-ant-cred",
|
|
174
|
+
anthropic: "sk-ant-original",
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await apiKeyToCredentialsMigration.down(backend);
|
|
178
|
+
|
|
179
|
+
// Bare key value must NOT be overwritten
|
|
180
|
+
expect(backend.store.get("anthropic")).toBe("sk-ant-original");
|
|
181
|
+
// Credential key must be removed
|
|
182
|
+
expect(backend.store.has("credential/anthropic/api_key")).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|