@vellumai/vellum-gateway 0.4.46 → 0.4.48

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/ARCHITECTURE.md CHANGED
@@ -584,11 +584,11 @@ Entry points:
584
584
 
585
585
  Both paths converge at:
586
586
  → Daemon handler validates token via Telegram getMe API
587
- → setSecureKey("credential:telegram:bot_token", token)
587
+ → setSecureKey("credential/telegram/bot_token", token)
588
588
  → upsertCredentialMetadata("telegram", "bot_token", {})
589
589
  → Stores bot username in config at telegram.botUsername
590
590
  → Auto-generates webhook secret if missing
591
- → setSecureKey("credential:telegram:webhook_secret", secret)
591
+ → setSecureKey("credential/telegram/webhook_secret", secret)
592
592
  → upsertCredentialMetadata("telegram", "webhook_secret", {})
593
593
  → On storage failure: rolls back bot_token + metadata, returns error
594
594
  → If webhook secret already exists: upserts metadata anyway (self-heal)
@@ -660,7 +660,7 @@ The Slack channel requires two tokens:
660
660
  | App token | `xapp-...` | Used for `apps.connections.open` to establish the Socket Mode WebSocket connection |
661
661
  | Bot token | `xoxb-...` | Used for `chat.postMessage` to send outbound messages and for `auth.test` validation |
662
662
 
663
- Both tokens are stored in secure storage (`credential:slack_channel:app_token`, `credential:slack_channel:bot_token`) via the assistant's Slack channel config endpoints (see `assistant/ARCHITECTURE.md`). The gateway reads them via its `credential-reader` module using the same broker-first fallback strategy as Telegram credentials.
663
+ Both tokens are stored in secure storage (`credential/slack_channel/app_token`, `credential/slack_channel/bot_token`) via the assistant's Slack channel config endpoints (see `assistant/ARCHITECTURE.md`). The gateway reads them via its `credential-reader` module using the same broker-first fallback strategy as Telegram credentials.
664
664
 
665
665
  **Auto-reconnect behavior:**
666
666
 
@@ -1057,7 +1057,7 @@ The resolution is performed by `resolveCallerIdentity()` in `call-domain.ts`:
1057
1057
 
1058
1058
  1. **Per-call override** — If `callerIdentityMode` is provided in the call input and `calls.callerIdentity.allowPerCallOverride` is enabled, the requested mode is used (source: `per_call_override`).
1059
1059
  2. **Implicit default** — Otherwise, `assistant_number` is always used (source: `implicit_default`). There is no configurable default mode — this is a strict policy.
1060
- 3. **User number lookup** — For `user_number` mode (explicit only), the number is resolved from (in priority order): `calls.callerIdentity.userNumber` config (source: `user_config`), `TWILIO_USER_PHONE_NUMBER` environment variable (source: `env_var`), or the `credential:twilio:user_phone_number` secure key (source: `secure_key`).
1060
+ 3. **User number lookup** — For `user_number` mode (explicit only), the number is resolved from (in priority order): `calls.callerIdentity.userNumber` config (source: `user_config`), `TWILIO_USER_PHONE_NUMBER` environment variable (source: `env_var`), or the `credential/twilio/user_phone_number` secure key (source: `secure_key`).
1061
1061
  4. **Eligibility check** — User numbers are verified against the Twilio API to confirm they can be used as an outbound caller ID.
1062
1062
 
1063
1063
  Both the resolved mode and source are logged at info level on success, and rejections are logged at warn level.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.4.46",
3
+ "version": "0.4.48",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./twilio/verify": "./src/twilio/verify.ts",
@@ -1,4 +1,5 @@
1
1
  import { describe, test, expect, mock, beforeEach } from "bun:test";
2
+ import { credentialKey } from "../credential-key.js";
2
3
 
3
4
  // ---------------------------------------------------------------------------
4
5
  // Mock readCredential so tests don't touch the real credential store
@@ -38,8 +39,8 @@ beforeEach(() => {
38
39
  describe("CredentialCache", () => {
39
40
  test("returns the value from readCredential", async () => {
40
41
  const cache = new CredentialCache();
41
- const result = await cache.get("credential:test:key");
42
- expect(result).toBe("value-for-credential:test:key");
42
+ const result = await cache.get(credentialKey("test", "key"));
43
+ expect(result).toBe(`value-for-${credentialKey("test", "key")}`);
43
44
  expect(callCount).toBe(1);
44
45
  });
45
46
 
@@ -4,6 +4,7 @@ import { createServer, type Server } from "node:net";
4
4
  import { join } from "node:path";
5
5
  import { hostname, tmpdir, userInfo } from "node:os";
6
6
  import { createCipheriv, pbkdf2Sync, randomBytes } from "node:crypto";
7
+ import { credentialKey } from "../credential-key.js";
7
8
 
8
9
  // ---------------------------------------------------------------------------
9
10
  // Logger mock — captures all log calls so the secret-leak test can inspect them
@@ -261,8 +262,8 @@ describe("readTelegramCredentials", () => {
261
262
  ]);
262
263
 
263
264
  writeEncryptedStore({
264
- "credential:telegram:bot_token": "enc-bot-token",
265
- "credential:telegram:webhook_secret": "enc-webhook-secret",
265
+ [credentialKey("telegram", "bot_token")]: "enc-bot-token",
266
+ [credentialKey("telegram", "webhook_secret")]: "enc-webhook-secret",
266
267
  });
267
268
 
268
269
  const result = await readTelegramCredentials();
@@ -280,7 +281,7 @@ describe("readTelegramCredentials", () => {
280
281
  describe("readCredential broker integration", () => {
281
282
  test("returns undefined when broker env var is unset", async () => {
282
283
  delete process.env.VELLUM_KEYCHAIN_BROKER_SOCKET;
283
- const result = await readCredential("credential:test:key");
284
+ const result = await readCredential(credentialKey("test", "key"));
284
285
  expect(result).toBeUndefined();
285
286
  });
286
287
 
@@ -289,10 +290,10 @@ describe("readCredential broker integration", () => {
289
290
  delete process.env.VELLUM_KEYCHAIN_BROKER_SOCKET;
290
291
 
291
292
  writeEncryptedStore({
292
- "credential:test:key": "encrypted-value",
293
+ [credentialKey("test", "key")]: "encrypted-value",
293
294
  });
294
295
 
295
- const result = await readCredential("credential:test:key");
296
+ const result = await readCredential(credentialKey("test", "key"));
296
297
  expect(result).toBe("encrypted-value");
297
298
  });
298
299
 
@@ -301,10 +302,10 @@ describe("readCredential broker integration", () => {
301
302
  writeBrokerToken(TEST_TOKEN);
302
303
 
303
304
  writeEncryptedStore({
304
- "credential:test:key": "encrypted-value",
305
+ [credentialKey("test", "key")]: "encrypted-value",
305
306
  });
306
307
 
307
- const result = await readCredential("credential:test:key");
308
+ const result = await readCredential(credentialKey("test", "key"));
308
309
  expect(result).toBe("encrypted-value");
309
310
  });
310
311
 
@@ -313,22 +314,22 @@ describe("readCredential broker integration", () => {
313
314
  // Don't write a token file
314
315
 
315
316
  writeEncryptedStore({
316
- "credential:test:key": "encrypted-value",
317
+ [credentialKey("test", "key")]: "encrypted-value",
317
318
  });
318
319
 
319
- const result = await readCredential("credential:test:key");
320
+ const result = await readCredential(credentialKey("test", "key"));
320
321
  expect(result).toBe("encrypted-value");
321
322
  });
322
323
 
323
324
  test("reads credential from broker when available", async () => {
324
325
  const broker = createMockBroker({
325
- "credential:test:key": "broker-secret-value",
326
+ [credentialKey("test", "key")]: "broker-secret-value",
326
327
  });
327
328
  try {
328
329
  process.env.VELLUM_KEYCHAIN_BROKER_SOCKET = broker.socketPath;
329
330
  writeBrokerToken(TEST_TOKEN);
330
331
 
331
- const result = await readCredential("credential:test:key");
332
+ const result = await readCredential(credentialKey("test", "key"));
332
333
  expect(result).toBe("broker-secret-value");
333
334
  } finally {
334
335
  broker.close();
@@ -337,17 +338,17 @@ describe("readCredential broker integration", () => {
337
338
 
338
339
  test("broker result takes priority over encrypted store", async () => {
339
340
  const broker = createMockBroker({
340
- "credential:test:key": "broker-value",
341
+ [credentialKey("test", "key")]: "broker-value",
341
342
  });
342
343
  try {
343
344
  process.env.VELLUM_KEYCHAIN_BROKER_SOCKET = broker.socketPath;
344
345
  writeBrokerToken(TEST_TOKEN);
345
346
 
346
347
  writeEncryptedStore({
347
- "credential:test:key": "encrypted-value",
348
+ [credentialKey("test", "key")]: "encrypted-value",
348
349
  });
349
350
 
350
- const result = await readCredential("credential:test:key");
351
+ const result = await readCredential(credentialKey("test", "key"));
351
352
  expect(result).toBe("broker-value");
352
353
  } finally {
353
354
  broker.close();
@@ -355,17 +356,17 @@ describe("readCredential broker integration", () => {
355
356
  });
356
357
 
357
358
  test("falls back to encrypted store when broker returns not found", async () => {
358
- // Broker has no entry for "credential:test:key"
359
+ // Broker has no entry for the test credential key
359
360
  const broker = createMockBroker({});
360
361
  try {
361
362
  process.env.VELLUM_KEYCHAIN_BROKER_SOCKET = broker.socketPath;
362
363
  writeBrokerToken(TEST_TOKEN);
363
364
 
364
365
  writeEncryptedStore({
365
- "credential:test:key": "encrypted-value",
366
+ [credentialKey("test", "key")]: "encrypted-value",
366
367
  });
367
368
 
368
- const result = await readCredential("credential:test:key");
369
+ const result = await readCredential(credentialKey("test", "key"));
369
370
  expect(result).toBe("encrypted-value");
370
371
  } finally {
371
372
  broker.close();
@@ -385,13 +386,13 @@ describe("secret leak prevention", () => {
385
386
  test("broker read does not leak secret values into logs", async () => {
386
387
  const secretValue = "super-secret-broker-credential-value";
387
388
  const broker = createMockBroker({
388
- "credential:leak-test:key": secretValue,
389
+ [credentialKey("leak-test", "key")]: secretValue,
389
390
  });
390
391
  try {
391
392
  process.env.VELLUM_KEYCHAIN_BROKER_SOCKET = broker.socketPath;
392
393
  writeBrokerToken(TEST_TOKEN);
393
394
 
394
- const result = await readCredential("credential:leak-test:key");
395
+ const result = await readCredential(credentialKey("leak-test", "key"));
395
396
  expect(result).toBe(secretValue);
396
397
 
397
398
  const serialized = allLogStrings();
@@ -408,10 +409,10 @@ describe("secret leak prevention", () => {
408
409
  delete process.env.VELLUM_KEYCHAIN_BROKER_SOCKET;
409
410
 
410
411
  writeEncryptedStore({
411
- "credential:leak-test:key": secretValue,
412
+ [credentialKey("leak-test", "key")]: secretValue,
412
413
  });
413
414
 
414
- const result = await readCredential("credential:leak-test:key");
415
+ const result = await readCredential(credentialKey("leak-test", "key"));
415
416
  expect(result).toBe(secretValue);
416
417
 
417
418
  const serialized = allLogStrings();
@@ -427,8 +428,8 @@ describe("secret leak prevention", () => {
427
428
  { service: "telegram", field: "webhook_secret" },
428
429
  ]);
429
430
  writeEncryptedStore({
430
- "credential:telegram:bot_token": secretValue,
431
- "credential:telegram:webhook_secret": "webhook-secret-value",
431
+ [credentialKey("telegram", "bot_token")]: secretValue,
432
+ [credentialKey("telegram", "webhook_secret")]: "webhook-secret-value",
432
433
  });
433
434
 
434
435
  const result = await readTelegramCredentials();
@@ -106,8 +106,8 @@ function writeEncryptedStore(botToken: string, webhookSecret: string): void {
106
106
  version: 1,
107
107
  salt: salt.toString("hex"),
108
108
  entries: {
109
- "credential:telegram:bot_token": encrypt(botToken, key),
110
- "credential:telegram:webhook_secret": encrypt(webhookSecret, key),
109
+ "credential/telegram/bot_token": encrypt(botToken, key),
110
+ "credential/telegram/webhook_secret": encrypt(webhookSecret, key),
111
111
  },
112
112
  };
113
113
 
@@ -2,11 +2,12 @@ import { describe, test, expect } from "bun:test";
2
2
  import { createTelegramWebhookHandler } from "../http/routes/telegram-webhook.js";
3
3
  import type { GatewayConfig } from "../config.js";
4
4
  import type { CredentialCache } from "../credential-cache.js";
5
+ import { credentialKey } from "../credential-key.js";
5
6
 
6
7
  function makeCaches() {
7
8
  const credentials = {
8
9
  get: async (key: string) => {
9
- if (key === "credential:telegram:webhook_secret") return "wh-sec";
10
+ if (key === credentialKey("telegram", "webhook_secret")) return "wh-sec";
10
11
  return undefined;
11
12
  },
12
13
  invalidate: () => {},
@@ -1,6 +1,7 @@
1
1
  import { describe, test, expect, mock, beforeEach } from "bun:test";
2
2
  import type { GatewayConfig } from "../config.js";
3
3
  import type { CredentialCache } from "../credential-cache.js";
4
+ import { credentialKey } from "../credential-key.js";
4
5
  import { initSigningKey, mintToken } from "../auth/token-service.js";
5
6
  import { CURRENT_POLICY_EPOCH } from "../auth/policy.js";
6
7
 
@@ -69,7 +70,7 @@ function makeRequest(body: unknown): Request {
69
70
  function makeCaches() {
70
71
  const credentials = {
71
72
  get: async (key: string) => {
72
- if (key === "credential:slack_channel:bot_token")
73
+ if (key === credentialKey("slack_channel", "bot_token"))
73
74
  return "xoxb-test-bot-token";
74
75
  return undefined;
75
76
  },
@@ -2,6 +2,7 @@ import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
2
2
  import type { GatewayConfig } from "../config.js";
3
3
  import type { ConfigFileCache } from "../config-file-cache.js";
4
4
  import type { CredentialCache } from "../credential-cache.js";
5
+ import { credentialKey } from "../credential-key.js";
5
6
 
6
7
  type FetchFn = (
7
8
  input: string | URL | Request,
@@ -75,7 +76,7 @@ function makeCaches(...args: [] | [string | undefined]) {
75
76
  const botToken = args.length === 0 ? "xoxb-test-bot-token" : args[0];
76
77
  const credentials = {
77
78
  get: async (key: string) => {
78
- if (key === "credential:slack_channel:bot_token") return botToken;
79
+ if (key === credentialKey("slack_channel", "bot_token")) return botToken;
79
80
  return undefined;
80
81
  },
81
82
  invalidate: () => {},
@@ -329,7 +330,7 @@ describe("slack-deliver endpoint", () => {
329
330
  let callCount = 0;
330
331
  const credentials = {
331
332
  get: async (key: string, opts?: { force?: boolean }) => {
332
- if (key === "credential:slack_channel:bot_token") {
333
+ if (key === credentialKey("slack_channel", "bot_token")) {
333
334
  callCount++;
334
335
  // First call returns undefined; second call with force returns the token
335
336
  if (callCount === 1 && !opts?.force) return undefined;
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, mock, test } from "bun:test";
2
2
  import type { ConfigFileCache } from "../config-file-cache.js";
3
3
  import type { CredentialCache } from "../credential-cache.js";
4
+ import { credentialKey } from "../credential-key.js";
4
5
 
5
6
  type FetchFn = (
6
7
  input: string | URL | Request,
@@ -35,7 +36,7 @@ function makeConfigFile(): ConfigFileCache {
35
36
  function makeCredentials(botToken: string): CredentialCache {
36
37
  return {
37
38
  get: async (key: string) => {
38
- if (key === "credential:telegram:bot_token") return botToken;
39
+ if (key === credentialKey("telegram", "bot_token")) return botToken;
39
40
  return undefined;
40
41
  },
41
42
  invalidate: () => {},
@@ -2,6 +2,7 @@ import { describe, test, expect, mock, afterEach } from "bun:test";
2
2
  import type { GatewayConfig } from "../config.js";
3
3
  import type { ConfigFileCache } from "../config-file-cache.js";
4
4
  import type { CredentialCache } from "../credential-cache.js";
5
+ import { credentialKey } from "../credential-key.js";
5
6
  import { initSigningKey, mintToken } from "../auth/token-service.js";
6
7
  import { CURRENT_POLICY_EPOCH } from "../auth/policy.js";
7
8
 
@@ -95,7 +96,8 @@ afterEach(() => {
95
96
  function makeCaches(configFile?: ConfigFileCache) {
96
97
  const credentials = {
97
98
  get: async (key: string) => {
98
- if (key === "credential:telegram:bot_token") return "test-bot-token";
99
+ if (key === credentialKey("telegram", "bot_token"))
100
+ return "test-bot-token";
99
101
  return undefined;
100
102
  },
101
103
  invalidate: () => {},
@@ -3,6 +3,7 @@ import type { RuntimeAttachmentMeta } from "../runtime/client.js";
3
3
  import type { GatewayConfig } from "../config.js";
4
4
  import type { CredentialCache } from "../credential-cache.js";
5
5
  import type { ConfigFileCache } from "../config-file-cache.js";
6
+ import { credentialKey } from "../credential-key.js";
6
7
  import { initSigningKey } from "../auth/token-service.js";
7
8
 
8
9
  const TEST_SIGNING_KEY = Buffer.from("test-signing-key-at-least-32-bytes-long");
@@ -49,7 +50,7 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
49
50
  /** Mock credential cache that provides a test bot token. */
50
51
  const testCreds: CredentialCache = {
51
52
  get: async (key: string) => {
52
- if (key === "credential:telegram:bot_token") return "test-bot-token";
53
+ if (key === credentialKey("telegram", "bot_token")) return "test-bot-token";
53
54
  return undefined;
54
55
  },
55
56
  invalidate: () => {},
@@ -1,6 +1,7 @@
1
1
  import { describe, test, expect, mock, afterEach, beforeEach } from "bun:test";
2
2
  import type { GatewayConfig } from "../config.js";
3
3
  import type { CredentialCache } from "../credential-cache.js";
4
+ import { credentialKey } from "../credential-key.js";
4
5
  import { initSigningKey } from "../auth/token-service.js";
5
6
 
6
7
  const TEST_SIGNING_KEY = Buffer.from("test-signing-key-at-least-32-bytes-long");
@@ -80,8 +81,10 @@ function makeWebhookRequest(
80
81
  function makeCaches(webhookSecret: string | undefined = "test-webhook-secret") {
81
82
  const credentials = {
82
83
  get: async (key: string) => {
83
- if (key === "credential:telegram:webhook_secret") return webhookSecret;
84
- if (key === "credential:telegram:bot_token") return "test-bot-token";
84
+ if (key === credentialKey("telegram", "webhook_secret"))
85
+ return webhookSecret;
86
+ if (key === credentialKey("telegram", "bot_token"))
87
+ return "test-bot-token";
85
88
  return undefined;
86
89
  },
87
90
  invalidate: () => {},
@@ -1,6 +1,7 @@
1
1
  import { describe, test, expect, mock, afterEach } from "bun:test";
2
2
  import type { CredentialCache } from "../credential-cache.js";
3
3
  import type { ConfigFileCache } from "../config-file-cache.js";
4
+ import { credentialKey } from "../credential-key.js";
4
5
 
5
6
  type FetchFn = (
6
7
  input: string | URL | Request,
@@ -48,8 +49,8 @@ function makeCaches(
48
49
  ? (opts.ingressUrl ?? undefined)
49
50
  : "https://example.ngrok.io";
50
51
  const credentialMap: Record<string, string | undefined> = {
51
- "credential:telegram:bot_token": botToken,
52
- "credential:telegram:webhook_secret": webhookSecret,
52
+ [credentialKey("telegram", "bot_token")]: botToken,
53
+ [credentialKey("telegram", "webhook_secret")]: webhookSecret,
53
54
  };
54
55
  const credentials = {
55
56
  get: async (key: string) => credentialMap[key],
@@ -3,6 +3,7 @@ import { createHmac } from "node:crypto";
3
3
  import type { GatewayConfig } from "../config.js";
4
4
  import type { CredentialCache } from "../credential-cache.js";
5
5
  import type { ConfigFileCache } from "../config-file-cache.js";
6
+ import { credentialKey } from "../credential-key.js";
6
7
  import { initSigningKey } from "../auth/token-service.js";
7
8
 
8
9
  const TEST_SIGNING_KEY = Buffer.from("test-signing-key-at-least-32-bytes-long");
@@ -147,7 +148,7 @@ function makeCaches(
147
148
  const { authToken = AUTH_TOKEN, ingressUrl } = opts;
148
149
  const credentials = {
149
150
  get: async (key: string, _opts?: { force?: boolean }) => {
150
- if (key === "credential:twilio:auth_token") return authToken;
151
+ if (key === credentialKey("twilio", "auth_token")) return authToken;
151
152
  return undefined;
152
153
  },
153
154
  invalidate: () => {},
@@ -2,6 +2,7 @@ import { describe, test, expect, mock, afterEach } from "bun:test";
2
2
  import type { GatewayConfig } from "../config.js";
3
3
  import type { CredentialCache } from "../credential-cache.js";
4
4
  import type { ConfigFileCache } from "../config-file-cache.js";
5
+ import { credentialKey } from "../credential-key.js";
5
6
 
6
7
  type FetchFn = (
7
8
  input: string | URL | Request,
@@ -60,9 +61,10 @@ function makeConfigFile(overrides?: { maxRetries?: number }): ConfigFileCache {
60
61
  function makeCaches(opts?: { maxRetries?: number }) {
61
62
  const credentials = {
62
63
  get: async (key: string) => {
63
- if (key === "credential:whatsapp:access_token")
64
+ if (key === credentialKey("whatsapp", "access_token"))
64
65
  return "test-access-token";
65
- if (key === "credential:whatsapp:phone_number_id") return "test-phone-id";
66
+ if (key === credentialKey("whatsapp", "phone_number_id"))
67
+ return "test-phone-id";
66
68
  return undefined;
67
69
  },
68
70
  invalidate: () => {},
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Single source of truth for credential key format in the secure store.
3
+ *
4
+ * Keys follow the pattern: credential/{service}/{field}
5
+ *
6
+ * This mirrors the assistant's credential-key.ts helper to ensure both
7
+ * packages use the same key format when reading/writing credentials.
8
+ */
9
+
10
+ /**
11
+ * Build a credential key for the secure store.
12
+ *
13
+ * @returns A key of the form `credential/{service}/{field}`
14
+ */
15
+ export function credentialKey(service: string, field: string): string {
16
+ return `credential/${service}/${field}`;
17
+ }
@@ -10,6 +10,7 @@ import { existsSync, readFileSync } from "node:fs";
10
10
  import { createConnection } from "node:net";
11
11
  import { hostname, userInfo } from "node:os";
12
12
  import { join } from "node:path";
13
+ import { credentialKey } from "./credential-key.js";
13
14
  import { getLogger } from "./logger.js";
14
15
 
15
16
  const log = getLogger("credential-reader");
@@ -319,9 +320,11 @@ export async function readTelegramCredentials(): Promise<TelegramCredentials | n
319
320
 
320
321
  if (!hasBotToken || !hasWebhookSecret) return null;
321
322
 
322
- const botToken = await readCredential("credential:telegram:bot_token");
323
+ const botToken = await readCredential(
324
+ credentialKey("telegram", "bot_token"),
325
+ );
323
326
  const webhookSecret = await readCredential(
324
- "credential:telegram:webhook_secret",
327
+ credentialKey("telegram", "webhook_secret"),
325
328
  );
326
329
 
327
330
  if (!botToken || !webhookSecret) {
@@ -367,8 +370,12 @@ export async function readTwilioCredentials(): Promise<TwilioCredentials | null>
367
370
 
368
371
  if (!hasAccountSid || !hasAuthToken) return null;
369
372
 
370
- const accountSid = await readCredential("credential:twilio:account_sid");
371
- const authToken = await readCredential("credential:twilio:auth_token");
373
+ const accountSid = await readCredential(
374
+ credentialKey("twilio", "account_sid"),
375
+ );
376
+ const authToken = await readCredential(
377
+ credentialKey("twilio", "auth_token"),
378
+ );
372
379
 
373
380
  if (!accountSid || !authToken) {
374
381
  log.warn(
@@ -420,8 +427,12 @@ export async function readSlackChannelCredentials(): Promise<SlackChannelCredent
420
427
 
421
428
  if (!hasBotToken || !hasAppToken) return null;
422
429
 
423
- const botToken = await readCredential("credential:slack_channel:bot_token");
424
- const appToken = await readCredential("credential:slack_channel:app_token");
430
+ const botToken = await readCredential(
431
+ credentialKey("slack_channel", "bot_token"),
432
+ );
433
+ const appToken = await readCredential(
434
+ credentialKey("slack_channel", "app_token"),
435
+ );
425
436
 
426
437
  if (!botToken || !appToken) {
427
438
  log.warn(
@@ -492,14 +503,16 @@ export async function readWhatsAppCredentials(): Promise<WhatsAppCredentials | n
492
503
  return null;
493
504
 
494
505
  const phoneNumberId = await readCredential(
495
- "credential:whatsapp:phone_number_id",
506
+ credentialKey("whatsapp", "phone_number_id"),
496
507
  );
497
508
  const accessToken = await readCredential(
498
- "credential:whatsapp:access_token",
509
+ credentialKey("whatsapp", "access_token"),
510
+ );
511
+ const appSecret = await readCredential(
512
+ credentialKey("whatsapp", "app_secret"),
499
513
  );
500
- const appSecret = await readCredential("credential:whatsapp:app_secret");
501
514
  const webhookVerifyToken = await readCredential(
502
- "credential:whatsapp:webhook_verify_token",
515
+ credentialKey("whatsapp", "webhook_verify_token"),
503
516
  );
504
517
 
505
518
  if (!phoneNumberId || !accessToken || !appSecret || !webhookVerifyToken) {
@@ -1,6 +1,7 @@
1
1
  import type { GatewayConfig } from "../../config.js";
2
2
  import type { ConfigFileCache } from "../../config-file-cache.js";
3
3
  import type { CredentialCache } from "../../credential-cache.js";
4
+ import { credentialKey } from "../../credential-key.js";
4
5
  import { fetchImpl } from "../../fetch.js";
5
6
  import { getLogger } from "../../logger.js";
6
7
  import { checkDeliverAuth } from "../middleware/deliver-auth.js";
@@ -328,14 +329,16 @@ export function createSlackDeliverHandler(
328
329
 
329
330
  // Resolve bot token from cache
330
331
  let botToken = caches?.credentials
331
- ? await caches.credentials.get("credential:slack_channel:bot_token")
332
+ ? await caches.credentials.get(
333
+ credentialKey("slack_channel", "bot_token"),
334
+ )
332
335
  : undefined;
333
336
 
334
337
  // One-shot force retry: if token is missing and caches are available,
335
338
  // force-refresh and retry once.
336
339
  if (!botToken && caches?.credentials) {
337
340
  botToken = await caches.credentials.get(
338
- "credential:slack_channel:bot_token",
341
+ credentialKey("slack_channel", "bot_token"),
339
342
  { force: true },
340
343
  );
341
344
  if (botToken) {
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, mock, beforeEach } from "bun:test";
2
2
  import type { GatewayConfig } from "../../config.js";
3
3
  import type { CredentialCache } from "../../credential-cache.js";
4
+ import { credentialKey } from "../../credential-key.js";
4
5
 
5
6
  // --- Mocks ----------------------------------------------------------------
6
7
 
@@ -104,7 +105,8 @@ function postRequest(body: string): Request {
104
105
  function makeCaches() {
105
106
  const credentials = {
106
107
  get: async (key: string) => {
107
- if (key === "credential:telegram:webhook_secret") return "test-secret";
108
+ if (key === credentialKey("telegram", "webhook_secret"))
109
+ return "test-secret";
108
110
  return undefined;
109
111
  },
110
112
  invalidate: () => {},
@@ -2,6 +2,7 @@ import { buildTelegramTransportMetadata } from "../../channels/transport-hints.j
2
2
  import type { ConfigFileCache } from "../../config-file-cache.js";
3
3
  import type { GatewayConfig } from "../../config.js";
4
4
  import type { CredentialCache } from "../../credential-cache.js";
5
+ import { credentialKey } from "../../credential-key.js";
5
6
  import { DedupCache } from "../../dedup-cache.js";
6
7
  import { handleInbound } from "../../handlers/handle-inbound.js";
7
8
  import { getLogger } from "../../logger.js";
@@ -72,7 +73,9 @@ export function createTelegramWebhookHandler(
72
73
 
73
74
  // Verify webhook secret from cache
74
75
  const webhookSecret = caches?.credentials
75
- ? await caches.credentials.get("credential:telegram:webhook_secret")
76
+ ? await caches.credentials.get(
77
+ credentialKey("telegram", "webhook_secret"),
78
+ )
76
79
  : undefined;
77
80
 
78
81
  let secretVerified =
@@ -82,7 +85,7 @@ export function createTelegramWebhookHandler(
82
85
  // force-refresh the webhook secret and retry once.
83
86
  if (!secretVerified && caches?.credentials) {
84
87
  const freshSecret = await caches.credentials.get(
85
- "credential:telegram:webhook_secret",
88
+ credentialKey("telegram", "webhook_secret"),
86
89
  { force: true },
87
90
  );
88
91
  if (freshSecret) {
@@ -1,6 +1,7 @@
1
1
  import { beforeEach, describe, expect, it, mock } from "bun:test";
2
2
  import type { GatewayConfig } from "../../config.js";
3
3
  import type { CredentialCache } from "../../credential-cache.js";
4
+ import { credentialKey } from "../../credential-key.js";
4
5
 
5
6
  const handleInboundMock = mock(() =>
6
7
  Promise.resolve({ forwarded: true, rejected: false }),
@@ -99,11 +100,13 @@ const baseConfig: GatewayConfig = {
99
100
  function makeCaches() {
100
101
  const credentials = {
101
102
  get: async (key: string) => {
102
- if (key === "credential:whatsapp:app_secret") return "test-app-secret";
103
- if (key === "credential:whatsapp:webhook_verify_token")
103
+ if (key === credentialKey("whatsapp", "app_secret"))
104
+ return "test-app-secret";
105
+ if (key === credentialKey("whatsapp", "webhook_verify_token"))
104
106
  return "verify-token";
105
- if (key === "credential:whatsapp:phone_number_id") return "test-phone-id";
106
- if (key === "credential:whatsapp:access_token")
107
+ if (key === credentialKey("whatsapp", "phone_number_id"))
108
+ return "test-phone-id";
109
+ if (key === credentialKey("whatsapp", "access_token"))
107
110
  return "test-access-token";
108
111
  return undefined;
109
112
  },
@@ -189,17 +192,17 @@ describe("whatsapp-webhook", () => {
189
192
  const caches = {
190
193
  credentials: {
191
194
  get: async (key: string, opts?: { force?: boolean }) => {
192
- if (key === "credential:whatsapp:app_secret") {
195
+ if (key === credentialKey("whatsapp", "app_secret")) {
193
196
  callCount++;
194
197
  // First call (non-forced) returns undefined; second call (forced) returns a valid secret
195
198
  if (callCount === 1 && !opts?.force) return undefined;
196
199
  return "refreshed-app-secret";
197
200
  }
198
- if (key === "credential:whatsapp:webhook_verify_token")
201
+ if (key === credentialKey("whatsapp", "webhook_verify_token"))
199
202
  return "verify-token";
200
- if (key === "credential:whatsapp:phone_number_id")
203
+ if (key === credentialKey("whatsapp", "phone_number_id"))
201
204
  return "test-phone-id";
202
- if (key === "credential:whatsapp:access_token")
205
+ if (key === credentialKey("whatsapp", "access_token"))
203
206
  return "test-access-token";
204
207
  return undefined;
205
208
  },
@@ -227,12 +230,12 @@ describe("whatsapp-webhook", () => {
227
230
  credentials: {
228
231
  get: async (key: string) => {
229
232
  // Always return undefined for app_secret — both normal and forced reads
230
- if (key === "credential:whatsapp:app_secret") return undefined;
231
- if (key === "credential:whatsapp:webhook_verify_token")
233
+ if (key === credentialKey("whatsapp", "app_secret")) return undefined;
234
+ if (key === credentialKey("whatsapp", "webhook_verify_token"))
232
235
  return "verify-token";
233
- if (key === "credential:whatsapp:phone_number_id")
236
+ if (key === credentialKey("whatsapp", "phone_number_id"))
234
237
  return "test-phone-id";
235
- if (key === "credential:whatsapp:access_token")
238
+ if (key === credentialKey("whatsapp", "access_token"))
236
239
  return "test-access-token";
237
240
  return undefined;
238
241
  },
@@ -3,6 +3,7 @@ import { buildWhatsAppTransportMetadata } from "../../channels/transport-hints.j
3
3
  import type { GatewayConfig } from "../../config.js";
4
4
  import type { ConfigFileCache } from "../../config-file-cache.js";
5
5
  import type { CredentialCache } from "../../credential-cache.js";
6
+ import { credentialKey } from "../../credential-key.js";
6
7
  import { StringDedupCache } from "../../dedup-cache.js";
7
8
  import { handleInbound } from "../../handlers/handle-inbound.js";
8
9
  import { getLogger } from "../../logger.js";
@@ -62,7 +63,7 @@ export function createWhatsAppWebhookHandler(
62
63
  // Resolve the verify token from cache
63
64
  const verifyToken = caches?.credentials
64
65
  ? await caches.credentials.get(
65
- "credential:whatsapp:webhook_verify_token",
66
+ credentialKey("whatsapp", "webhook_verify_token"),
66
67
  )
67
68
  : undefined;
68
69
 
@@ -112,7 +113,7 @@ export function createWhatsAppWebhookHandler(
112
113
 
113
114
  // Resolve app secret from cache
114
115
  const appSecret = caches?.credentials
115
- ? await caches.credentials.get("credential:whatsapp:app_secret")
116
+ ? await caches.credentials.get(credentialKey("whatsapp", "app_secret"))
116
117
  : undefined;
117
118
 
118
119
  // If the initial cache read returned undefined but a credential cache is available,
@@ -121,7 +122,7 @@ export function createWhatsAppWebhookHandler(
121
122
  let effectiveAppSecret = appSecret;
122
123
  if (!effectiveAppSecret && caches?.credentials) {
123
124
  effectiveAppSecret = await caches.credentials.get(
124
- "credential:whatsapp:app_secret",
125
+ credentialKey("whatsapp", "app_secret"),
125
126
  { force: true },
126
127
  );
127
128
  if (effectiveAppSecret) {
@@ -151,7 +152,7 @@ export function createWhatsAppWebhookHandler(
151
152
  // force-refresh the app secret and retry once.
152
153
  if (!signatureValid && caches?.credentials) {
153
154
  const freshAppSecret = await caches.credentials.get(
154
- "credential:whatsapp:app_secret",
155
+ credentialKey("whatsapp", "app_secret"),
155
156
  { force: true },
156
157
  );
157
158
  if (freshAppSecret) {
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ import { ConfigFileCache } from "./config-file-cache.js";
14
14
  import { ConfigFileWatcher } from "./config-file-watcher.js";
15
15
  import { loadConfig } from "./config.js";
16
16
  import { CredentialCache } from "./credential-cache.js";
17
+ import { credentialKey } from "./credential-key.js";
17
18
  import {
18
19
  CredentialWatcher,
19
20
  type CredentialChangeEvent,
@@ -843,10 +844,10 @@ async function main() {
843
844
  }
844
845
 
845
846
  const botToken = await credentialCache.get(
846
- "credential:slack_channel:bot_token",
847
+ credentialKey("slack_channel", "bot_token"),
847
848
  );
848
849
  const appToken = await credentialCache.get(
849
- "credential:slack_channel:app_token",
850
+ credentialKey("slack_channel", "app_token"),
850
851
  );
851
852
  if (!botToken || !appToken) return;
852
853
 
@@ -1,5 +1,6 @@
1
1
  import type { CredentialCache } from "../credential-cache.js";
2
2
  import type { ConfigFileCache } from "../config-file-cache.js";
3
+ import { credentialKey } from "../credential-key.js";
3
4
  import { fetchImpl } from "../fetch.js";
4
5
  import { getLogger } from "../logger.js";
5
6
 
@@ -173,7 +174,9 @@ export async function callTelegramApi<T>(
173
174
  ): Promise<T> {
174
175
  let botToken: string | undefined;
175
176
  if (opts?.credentials) {
176
- botToken = await opts.credentials.get("credential:telegram:bot_token");
177
+ botToken = await opts.credentials.get(
178
+ credentialKey("telegram", "bot_token"),
179
+ );
177
180
  }
178
181
 
179
182
  if (!botToken) {
@@ -205,7 +208,9 @@ export async function callTelegramApiMultipart<T>(
205
208
  ): Promise<T> {
206
209
  let botToken: string | undefined;
207
210
  if (opts?.credentials) {
208
- botToken = await opts.credentials.get("credential:telegram:bot_token");
211
+ botToken = await opts.credentials.get(
212
+ credentialKey("telegram", "bot_token"),
213
+ );
209
214
  }
210
215
 
211
216
  if (!botToken) {
@@ -1,6 +1,7 @@
1
1
  import { fileTypeFromBuffer } from "file-type";
2
2
  import type { ConfigFileCache } from "../config-file-cache.js";
3
3
  import type { CredentialCache } from "../credential-cache.js";
4
+ import { credentialKey } from "../credential-key.js";
4
5
  import { fetchImpl } from "../fetch.js";
5
6
  import { callTelegramApi } from "./api.js";
6
7
 
@@ -39,7 +40,7 @@ export async function downloadTelegramFile(
39
40
  }
40
41
 
41
42
  const botToken = opts?.credentials
42
- ? await opts.credentials.get("credential:telegram:bot_token")
43
+ ? await opts.credentials.get(credentialKey("telegram", "bot_token"))
43
44
  : undefined;
44
45
 
45
46
  const apiBaseUrl =
@@ -3,6 +3,7 @@ import type { ApprovalPayload } from "../http/routes/telegram-deliver.js";
3
3
  import type { GatewayConfig } from "../config.js";
4
4
  import type { CredentialCache } from "../credential-cache.js";
5
5
  import type { ConfigFileCache } from "../config-file-cache.js";
6
+ import { credentialKey } from "../credential-key.js";
6
7
 
7
8
  // Mock fetch at the transport level (same pattern as all other test files)
8
9
  // instead of mocking ./api.js — mock.module for api.js leaks across test
@@ -54,7 +55,7 @@ const sampleApproval: ApprovalPayload = {
54
55
  /** Mock credential cache providing test bot token. */
55
56
  const testCreds: CredentialCache = {
56
57
  get: async (key: string) => {
57
- if (key === "credential:telegram:bot_token") return "test-bot-token";
58
+ if (key === credentialKey("telegram", "bot_token")) return "test-bot-token";
58
59
  return undefined;
59
60
  },
60
61
  invalidate: () => {},
@@ -1,5 +1,6 @@
1
1
  import type { CredentialCache } from "../credential-cache.js";
2
2
  import type { ConfigFileCache } from "../config-file-cache.js";
3
+ import { credentialKey } from "../credential-key.js";
3
4
  import { callTelegramApi } from "./api.js";
4
5
  import { getLogger } from "../logger.js";
5
6
 
@@ -36,9 +37,11 @@ export async function reconcileTelegramWebhook(
36
37
  let botToken: string | undefined;
37
38
  let webhookSecret: string | undefined;
38
39
  if (caches?.credentials) {
39
- botToken = await caches.credentials.get("credential:telegram:bot_token");
40
+ botToken = await caches.credentials.get(
41
+ credentialKey("telegram", "bot_token"),
42
+ );
40
43
  webhookSecret = await caches.credentials.get(
41
- "credential:telegram:webhook_secret",
44
+ credentialKey("telegram", "webhook_secret"),
42
45
  );
43
46
  }
44
47
 
@@ -1,6 +1,7 @@
1
1
  import type { CredentialCache } from "../credential-cache.js";
2
2
  import type { ConfigFileCache } from "../config-file-cache.js";
3
3
  import type { GatewayConfig } from "../config.js";
4
+ import { credentialKey } from "../credential-key.js";
4
5
  import { getLogger } from "../logger.js";
5
6
  import { verifyTwilioSignature } from "./verify.js";
6
7
 
@@ -202,7 +203,7 @@ export async function validateTwilioWebhookRequest(
202
203
 
203
204
  // Resolve the auth token from cache
204
205
  let authToken = caches?.credentials
205
- ? await caches.credentials.get("credential:twilio:auth_token")
206
+ ? await caches.credentials.get(credentialKey("twilio", "auth_token"))
206
207
  : undefined;
207
208
 
208
209
  // Resolve ingress URL from cache
@@ -220,7 +221,7 @@ export async function validateTwilioWebhookRequest(
220
221
  // One-shot force retry: if missing and caches available, try force refresh
221
222
  if (!authToken && caches?.credentials) {
222
223
  const freshAuthToken = await caches.credentials.get(
223
- "credential:twilio:auth_token",
224
+ credentialKey("twilio", "auth_token"),
224
225
  { force: true },
225
226
  );
226
227
  if (freshAuthToken) {
@@ -293,7 +294,7 @@ export async function validateTwilioWebhookRequest(
293
294
  // force-refresh the auth token and ingress URL then retry once.
294
295
  if (validatingIndex === -1 && caches?.credentials) {
295
296
  const freshAuthToken = await caches.credentials.get(
296
- "credential:twilio:auth_token",
297
+ credentialKey("twilio", "auth_token"),
297
298
  { force: true },
298
299
  );
299
300
  let freshIngressUrl: string | undefined;
@@ -1,5 +1,6 @@
1
1
  import type { CredentialCache } from "../credential-cache.js";
2
2
  import type { ConfigFileCache } from "../config-file-cache.js";
3
+ import { credentialKey } from "../credential-key.js";
3
4
  import { fetchImpl } from "../fetch.js";
4
5
  import { getLogger } from "../logger.js";
5
6
 
@@ -158,10 +159,10 @@ async function resolveWhatsAppCredentials(caches?: WhatsAppApiCaches): Promise<{
158
159
  let accessToken: string | undefined;
159
160
  if (caches?.credentials) {
160
161
  phoneNumberId = await caches.credentials.get(
161
- "credential:whatsapp:phone_number_id",
162
+ credentialKey("whatsapp", "phone_number_id"),
162
163
  );
163
164
  accessToken = await caches.credentials.get(
164
- "credential:whatsapp:access_token",
165
+ credentialKey("whatsapp", "access_token"),
165
166
  );
166
167
  }
167
168
  return { phoneNumberId, accessToken };