@vellumai/vellum-gateway 0.4.40 → 0.4.42

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.4.40",
3
+ "version": "0.4.42",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./twilio/verify": "./src/twilio/verify.ts",
@@ -0,0 +1,107 @@
1
+ import type { GatewayConfig } from "./config.js";
2
+
3
+ type ConfigFileMapping =
4
+ | {
5
+ key: string;
6
+ field: string;
7
+ configField: keyof GatewayConfig;
8
+ type: "string";
9
+ }
10
+ | {
11
+ key: string;
12
+ field: string;
13
+ configField: keyof GatewayConfig;
14
+ type: "record";
15
+ }
16
+ | {
17
+ key: string;
18
+ field: string;
19
+ configField: keyof GatewayConfig;
20
+ type: "normalized-record";
21
+ };
22
+
23
+ export const CONFIG_FILE_MAPPINGS: ConfigFileMapping[] = [
24
+ {
25
+ key: "sms",
26
+ field: "phoneNumber",
27
+ configField: "twilioPhoneNumber",
28
+ type: "string",
29
+ },
30
+ {
31
+ key: "sms",
32
+ field: "assistantPhoneNumbers",
33
+ configField: "assistantPhoneNumbers",
34
+ type: "normalized-record",
35
+ },
36
+ {
37
+ key: "email",
38
+ field: "address",
39
+ configField: "assistantEmail",
40
+ type: "string",
41
+ },
42
+ {
43
+ key: "twilio",
44
+ field: "accountSid",
45
+ configField: "twilioAccountSid",
46
+ type: "string",
47
+ },
48
+ {
49
+ key: "ingress",
50
+ field: "publicBaseUrl",
51
+ configField: "ingressPublicBaseUrl",
52
+ type: "string",
53
+ },
54
+ ];
55
+
56
+ /** Iterate entries and keep only those whose value is a non-empty, non-whitespace string. */
57
+ function normalizeRecord(raw: unknown): Record<string, string> | undefined {
58
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
59
+ const result: Record<string, string> = {};
60
+ for (const [k, v] of Object.entries(raw)) {
61
+ if (typeof v === "string" && v.trim() !== "") {
62
+ result[k] = v;
63
+ }
64
+ }
65
+ return Object.keys(result).length > 0 ? result : undefined;
66
+ }
67
+
68
+ function resolveRawValue(mapping: ConfigFileMapping, raw: unknown): unknown {
69
+ switch (mapping.type) {
70
+ case "string":
71
+ return typeof raw === "string" ? raw || undefined : undefined;
72
+ case "record":
73
+ return raw && typeof raw === "object" && !Array.isArray(raw)
74
+ ? raw
75
+ : undefined;
76
+ case "normalized-record":
77
+ return normalizeRecord(raw);
78
+ }
79
+ }
80
+
81
+ export function applyConfigFileMappings(
82
+ data: Record<string, unknown>,
83
+ changedKeys: Set<string>,
84
+ config: GatewayConfig,
85
+ ): void {
86
+ for (const mapping of CONFIG_FILE_MAPPINGS) {
87
+ if (!changedKeys.has(mapping.key)) continue;
88
+ const section = data[mapping.key] as Record<string, unknown> | undefined;
89
+ const raw = section?.[mapping.field];
90
+ (config as Record<string, unknown>)[mapping.configField] = resolveRawValue(
91
+ mapping,
92
+ raw,
93
+ );
94
+ }
95
+ }
96
+
97
+ export function readConfigFileDefaults(
98
+ data: Record<string, unknown>,
99
+ ): Partial<Record<keyof GatewayConfig, unknown>> {
100
+ const defaults: Partial<Record<keyof GatewayConfig, unknown>> = {};
101
+ for (const mapping of CONFIG_FILE_MAPPINGS) {
102
+ const section = data[mapping.key] as Record<string, unknown> | undefined;
103
+ const raw = section?.[mapping.field];
104
+ defaults[mapping.configField] = resolveRawValue(mapping, raw);
105
+ }
106
+ return defaults;
107
+ }
package/src/config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getLogger, type LogFileConfig } from "./logger.js";
4
+ import { readConfigFileDefaults } from "./config-file-mappings.js";
4
5
  import {
5
6
  getRootDir,
6
7
  readCredential,
@@ -311,51 +312,31 @@ export async function loadConfig(): Promise<GatewayConfig> {
311
312
  let twilioAccountSid =
312
313
  process.env.TWILIO_ACCOUNT_SID || twilioCreds?.accountSid || undefined;
313
314
 
314
- // Phone number: env var > config file sms.phoneNumber > credential store
315
- let twilioPhoneNumber: string | undefined =
316
- process.env.TWILIO_PHONE_NUMBER || undefined;
317
- let assistantPhoneNumbers: Record<string, string> | undefined;
318
- let assistantEmail: string | undefined;
315
+ // Read config.json defaults for fields that can come from the config file
316
+ let configFileData: Record<string, unknown> = {};
319
317
  try {
320
318
  const cfgPath = join(getRootDir(), "workspace", "config.json");
321
319
  const raw = readFileSync(cfgPath, "utf-8");
322
320
  const data = JSON.parse(raw);
323
- if (
324
- !twilioPhoneNumber &&
325
- data?.sms?.phoneNumber &&
326
- typeof data.sms.phoneNumber === "string"
327
- ) {
328
- twilioPhoneNumber = data.sms.phoneNumber;
329
- }
330
- if (
331
- !twilioAccountSid &&
332
- data?.twilio?.accountSid &&
333
- typeof data.twilio.accountSid === "string"
334
- ) {
335
- twilioAccountSid = data.twilio.accountSid;
336
- }
337
- const rawMapping = data?.sms?.assistantPhoneNumbers;
338
- if (
339
- rawMapping &&
340
- typeof rawMapping === "object" &&
341
- !Array.isArray(rawMapping)
342
- ) {
343
- const normalized: Record<string, string> = {};
344
- for (const [assistantId, phoneNumber] of Object.entries(
345
- rawMapping as Record<string, unknown>,
346
- )) {
347
- if (typeof phoneNumber === "string" && phoneNumber.trim().length > 0) {
348
- normalized[assistantId] = phoneNumber;
349
- }
350
- }
351
- assistantPhoneNumbers = normalized;
352
- }
353
- if (data?.email?.address && typeof data.email.address === "string") {
354
- assistantEmail = data.email.address;
321
+ if (data && typeof data === "object" && !Array.isArray(data)) {
322
+ configFileData = data as Record<string, unknown>;
355
323
  }
356
324
  } catch {
357
325
  // config file may not exist yet
358
326
  }
327
+ const configDefaults = readConfigFileDefaults(configFileData);
328
+
329
+ // Phone number: env var > config file > credential store
330
+ let twilioPhoneNumber: string | undefined =
331
+ process.env.TWILIO_PHONE_NUMBER ||
332
+ (configDefaults.twilioPhoneNumber as string | undefined);
333
+ if (!twilioAccountSid) {
334
+ twilioAccountSid = configDefaults.twilioAccountSid as string | undefined;
335
+ }
336
+ const assistantPhoneNumbers = configDefaults.assistantPhoneNumbers as
337
+ | Record<string, string>
338
+ | undefined;
339
+ const assistantEmail = configDefaults.assistantEmail as string | undefined;
359
340
  if (!twilioPhoneNumber) {
360
341
  twilioPhoneNumber =
361
342
  (await readCredential("credential:twilio:phone_number")) || undefined;
@@ -503,7 +484,9 @@ export async function loadConfig(): Promise<GatewayConfig> {
503
484
  }
504
485
  const trustProxy = trustProxyRaw === "true";
505
486
 
506
- const ingressPublicBaseUrl = process.env.INGRESS_PUBLIC_BASE_URL || undefined;
487
+ const ingressPublicBaseUrl =
488
+ process.env.INGRESS_PUBLIC_BASE_URL ||
489
+ (configDefaults.ingressPublicBaseUrl as string | undefined);
507
490
 
508
491
  const logFileDir = process.env.GATEWAY_LOG_DIR || undefined;
509
492
 
@@ -0,0 +1,102 @@
1
+ import type { GatewayConfig } from "./config.js";
2
+ import type { CredentialChangeEvent } from "./credential-watcher.js";
3
+
4
+ type CredentialServiceMapping = {
5
+ /** Key on CredentialChangeEvent indicating whether this service changed. */
6
+ changedKey: keyof CredentialChangeEvent & `${string}Changed`;
7
+ /** Key on CredentialChangeEvent containing the credentials object. */
8
+ credentialsKey: keyof CredentialChangeEvent & `${string}Credentials`;
9
+ /** Whether to skip updates when env vars provide these credentials. */
10
+ envGuarded: boolean;
11
+ /** Human-friendly name used in log messages (e.g. "Slack channel"). Falls back to capitalizing changedKey. */
12
+ displayName?: string;
13
+ /** Maps credential object fields to GatewayConfig fields. */
14
+ fields: Array<{
15
+ credField: string;
16
+ configField: keyof GatewayConfig;
17
+ }>;
18
+ };
19
+
20
+ export function buildCredentialServiceMappings(opts: {
21
+ telegramFromEnv: boolean;
22
+ slackFromEnv: boolean;
23
+ }): CredentialServiceMapping[] {
24
+ return [
25
+ {
26
+ changedKey: "telegramChanged",
27
+ credentialsKey: "telegramCredentials",
28
+ envGuarded: opts.telegramFromEnv,
29
+ fields: [
30
+ { credField: "botToken", configField: "telegramBotToken" },
31
+ { credField: "webhookSecret", configField: "telegramWebhookSecret" },
32
+ ],
33
+ },
34
+ {
35
+ changedKey: "twilioChanged",
36
+ credentialsKey: "twilioCredentials",
37
+ envGuarded: false,
38
+ fields: [
39
+ { credField: "accountSid", configField: "twilioAccountSid" },
40
+ { credField: "authToken", configField: "twilioAuthToken" },
41
+ ],
42
+ },
43
+ {
44
+ changedKey: "whatsappChanged",
45
+ credentialsKey: "whatsappCredentials",
46
+ envGuarded: false,
47
+ fields: [
48
+ { credField: "phoneNumberId", configField: "whatsappPhoneNumberId" },
49
+ { credField: "accessToken", configField: "whatsappAccessToken" },
50
+ { credField: "appSecret", configField: "whatsappAppSecret" },
51
+ {
52
+ credField: "webhookVerifyToken",
53
+ configField: "whatsappWebhookVerifyToken",
54
+ },
55
+ ],
56
+ },
57
+ {
58
+ changedKey: "slackChannelChanged",
59
+ credentialsKey: "slackChannelCredentials",
60
+ envGuarded: opts.slackFromEnv,
61
+ displayName: "Slack channel",
62
+ fields: [
63
+ { credField: "botToken", configField: "slackChannelBotToken" },
64
+ { credField: "appToken", configField: "slackChannelAppToken" },
65
+ ],
66
+ },
67
+ ];
68
+ }
69
+
70
+ export function applyCredentialChanges(
71
+ event: CredentialChangeEvent,
72
+ config: GatewayConfig,
73
+ mappings: CredentialServiceMapping[],
74
+ log: { info: (msg: string) => void },
75
+ ): Set<string> {
76
+ const changedServices = new Set<string>();
77
+ for (const mapping of mappings) {
78
+ if (!event[mapping.changedKey]) continue;
79
+ if (mapping.envGuarded) continue;
80
+ const creds = event[mapping.credentialsKey] as Record<
81
+ string,
82
+ unknown
83
+ > | null;
84
+ for (const { credField, configField } of mapping.fields) {
85
+ (config as Record<string, unknown>)[configField] = creds
86
+ ? (creds as Record<string, unknown>)[credField]
87
+ : undefined;
88
+ }
89
+ // Extract a human-friendly service name from the changedKey (e.g. "telegramChanged" -> "Telegram")
90
+ const serviceName = mapping.changedKey.replace("Changed", "");
91
+ const capitalizedName =
92
+ serviceName.charAt(0).toUpperCase() + serviceName.slice(1);
93
+ const logName = mapping.displayName ?? capitalizedName;
94
+ log.info(
95
+ creds
96
+ ? `${logName} credentials loaded from credential vault`
97
+ : `${logName} credentials cleared`,
98
+ );
99
+ changedServices.add(serviceName);
100
+ }
101
+ return changedServices;
102
+ }
@@ -43,16 +43,7 @@ export class CredentialWatcher {
43
43
  private watcher: FSWatcher | null = null;
44
44
  private watchingDirectory = false;
45
45
  private debounceTimer: ReturnType<typeof setTimeout> | null = null;
46
- private lastBotToken: string | undefined;
47
- private lastWebhookSecret: string | undefined;
48
- private lastTwilioAccountSid: string | undefined;
49
- private lastTwilioAuthToken: string | undefined;
50
- private lastWhatsAppPhoneNumberId: string | undefined;
51
- private lastWhatsAppAccessToken: string | undefined;
52
- private lastWhatsAppAppSecret: string | undefined;
53
- private lastWhatsAppWebhookVerifyToken: string | undefined;
54
- private lastSlackChannelBotToken: string | undefined;
55
- private lastSlackChannelAppToken: string | undefined;
46
+ private lastSerialized: Map<string, string> = new Map();
56
47
  private polling = false;
57
48
  private pendingPoll = false;
58
49
  private callback: CredentialChangeCallback;
@@ -157,90 +148,38 @@ export class CredentialWatcher {
157
148
  const whatsappCredentials = await readWhatsAppCredentials();
158
149
  const slackChannelCredentials = await readSlackChannelCredentials();
159
150
 
160
- const newBotToken = telegramCredentials?.botToken;
161
- const newWebhookSecret = telegramCredentials?.webhookSecret;
162
- const newTwilioAccountSid = twilioCredentials?.accountSid;
163
- const newTwilioAuthToken = twilioCredentials?.authToken;
164
- const newWhatsAppPhoneNumberId = whatsappCredentials?.phoneNumberId;
165
- const newWhatsAppAccessToken = whatsappCredentials?.accessToken;
166
- const newWhatsAppAppSecret = whatsappCredentials?.appSecret;
167
- const newWhatsAppWebhookVerifyToken =
168
- whatsappCredentials?.webhookVerifyToken;
169
- const newSlackChannelBotToken = slackChannelCredentials?.botToken;
170
- const newSlackChannelAppToken = slackChannelCredentials?.appToken;
171
-
172
- const telegramChanged =
173
- newBotToken !== this.lastBotToken ||
174
- newWebhookSecret !== this.lastWebhookSecret;
175
-
176
- const twilioChanged =
177
- newTwilioAccountSid !== this.lastTwilioAccountSid ||
178
- newTwilioAuthToken !== this.lastTwilioAuthToken;
179
-
180
- const whatsappChanged =
181
- newWhatsAppPhoneNumberId !== this.lastWhatsAppPhoneNumberId ||
182
- newWhatsAppAccessToken !== this.lastWhatsAppAccessToken ||
183
- newWhatsAppAppSecret !== this.lastWhatsAppAppSecret ||
184
- newWhatsAppWebhookVerifyToken !== this.lastWhatsAppWebhookVerifyToken;
185
-
186
- const slackChannelChanged =
187
- newSlackChannelBotToken !== this.lastSlackChannelBotToken ||
188
- newSlackChannelAppToken !== this.lastSlackChannelAppToken;
189
-
190
- if (
191
- !telegramChanged &&
192
- !twilioChanged &&
193
- !whatsappChanged &&
194
- !slackChannelChanged
195
- ) {
196
- return;
151
+ const services = {
152
+ telegram: { creds: telegramCredentials, key: "telegram" },
153
+ twilio: { creds: twilioCredentials, key: "twilio" },
154
+ whatsapp: { creds: whatsappCredentials, key: "whatsapp" },
155
+ slackChannel: { creds: slackChannelCredentials, key: "slackChannel" },
156
+ };
157
+
158
+ const changedServices = new Set<string>();
159
+ for (const [name, { creds }] of Object.entries(services)) {
160
+ const newVal = creds ? JSON.stringify(creds) : undefined;
161
+ const oldVal = this.lastSerialized.get(name);
162
+ if (newVal !== oldVal) {
163
+ changedServices.add(name);
164
+ if (newVal !== undefined) {
165
+ this.lastSerialized.set(name, newVal);
166
+ } else {
167
+ this.lastSerialized.delete(name);
168
+ }
169
+ }
197
170
  }
198
171
 
199
- this.lastBotToken = newBotToken;
200
- this.lastWebhookSecret = newWebhookSecret;
201
- this.lastTwilioAccountSid = newTwilioAccountSid;
202
- this.lastTwilioAuthToken = newTwilioAuthToken;
203
- this.lastWhatsAppPhoneNumberId = newWhatsAppPhoneNumberId;
204
- this.lastWhatsAppAccessToken = newWhatsAppAccessToken;
205
- this.lastWhatsAppAppSecret = newWhatsAppAppSecret;
206
- this.lastWhatsAppWebhookVerifyToken = newWhatsAppWebhookVerifyToken;
207
- this.lastSlackChannelBotToken = newSlackChannelBotToken;
208
- this.lastSlackChannelAppToken = newSlackChannelAppToken;
209
-
210
- if (telegramChanged) {
211
- log.info(
212
- { hasCredentials: !!telegramCredentials },
213
- "Telegram credentials changed",
214
- );
215
- }
216
- if (twilioChanged) {
217
- log.info(
218
- { hasCredentials: !!twilioCredentials },
219
- "Twilio credentials changed",
220
- );
221
- }
222
- if (whatsappChanged) {
223
- log.info(
224
- { hasCredentials: !!whatsappCredentials },
225
- "WhatsApp credentials changed",
226
- );
227
- }
228
- if (slackChannelChanged) {
229
- log.info(
230
- { hasCredentials: !!slackChannelCredentials },
231
- "Slack channel credentials changed",
232
- );
233
- }
172
+ if (changedServices.size === 0) return;
234
173
 
235
174
  this.callback({
236
175
  telegramCredentials,
237
- telegramChanged,
176
+ telegramChanged: changedServices.has("telegram"),
238
177
  twilioCredentials,
239
- twilioChanged,
178
+ twilioChanged: changedServices.has("twilio"),
240
179
  whatsappCredentials,
241
- whatsappChanged,
180
+ whatsappChanged: changedServices.has("whatsapp"),
242
181
  slackChannelCredentials,
243
- slackChannelChanged,
182
+ slackChannelChanged: changedServices.has("slackChannel"),
244
183
  });
245
184
  } finally {
246
185
  this.polling = false;
package/src/index.ts CHANGED
@@ -12,6 +12,10 @@ import {
12
12
  } from "./auth/token-exchange.js";
13
13
  import { ConfigFileWatcher } from "./config-file-watcher.js";
14
14
  import { loadConfig, isSlackChannelConfigured } from "./config.js";
15
+ import {
16
+ buildCredentialServiceMappings,
17
+ applyCredentialChanges,
18
+ } from "./credential-mappings.js";
15
19
  import { CredentialWatcher } from "./credential-watcher.js";
16
20
  import { createRuntimeProxyHandler } from "./http/routes/runtime-proxy.js";
17
21
  import {
@@ -63,6 +67,7 @@ import {
63
67
  type RouteDefinition,
64
68
  type GetClientIp,
65
69
  } from "./http/router.js";
70
+ import { applyConfigFileMappings } from "./config-file-mappings.js";
66
71
  import { callTelegramApi } from "./telegram/api.js";
67
72
  import { reconcileTelegramWebhook } from "./telegram/webhook-manager.js";
68
73
 
@@ -872,65 +877,30 @@ async function main() {
872
877
  process.env.SLACK_CHANNEL_BOT_TOKEN && process.env.SLACK_CHANNEL_APP_TOKEN
873
878
  );
874
879
 
875
- const credentialWatcher = new CredentialWatcher((event) => {
876
- if (event.telegramChanged && !telegramFromEnv) {
877
- if (event.telegramCredentials) {
878
- config.telegramBotToken = event.telegramCredentials.botToken;
879
- config.telegramWebhookSecret = event.telegramCredentials.webhookSecret;
880
- log.info("Telegram credentials loaded from credential vault");
881
- registerTelegramCommands();
882
- reconcileTelegramWebhook(config).catch((err) => {
883
- log.error(
884
- { err },
885
- "Failed to reconcile Telegram webhook after credential change",
886
- );
887
- });
888
- } else {
889
- config.telegramBotToken = undefined;
890
- config.telegramWebhookSecret = undefined;
891
- log.info("Telegram credentials cleared");
892
- }
893
- }
880
+ const credentialMappings = buildCredentialServiceMappings({
881
+ telegramFromEnv,
882
+ slackFromEnv,
883
+ });
894
884
 
895
- if (event.twilioChanged) {
896
- if (event.twilioCredentials) {
897
- config.twilioAccountSid = event.twilioCredentials.accountSid;
898
- config.twilioAuthToken = event.twilioCredentials.authToken;
899
- log.info("Twilio credentials loaded from credential vault");
900
- } else {
901
- config.twilioAccountSid = undefined;
902
- config.twilioAuthToken = undefined;
903
- log.info("Twilio credentials cleared");
904
- }
905
- }
885
+ const credentialWatcher = new CredentialWatcher((event) => {
886
+ const changed = applyCredentialChanges(
887
+ event,
888
+ config,
889
+ credentialMappings,
890
+ log,
891
+ );
906
892
 
907
- if (event.whatsappChanged) {
908
- if (event.whatsappCredentials) {
909
- config.whatsappPhoneNumberId = event.whatsappCredentials.phoneNumberId;
910
- config.whatsappAccessToken = event.whatsappCredentials.accessToken;
911
- config.whatsappAppSecret = event.whatsappCredentials.appSecret;
912
- config.whatsappWebhookVerifyToken =
913
- event.whatsappCredentials.webhookVerifyToken;
914
- log.info("WhatsApp credentials loaded from credential vault");
915
- } else {
916
- config.whatsappPhoneNumberId = undefined;
917
- config.whatsappAccessToken = undefined;
918
- config.whatsappAppSecret = undefined;
919
- config.whatsappWebhookVerifyToken = undefined;
920
- log.info("WhatsApp credentials cleared");
921
- }
893
+ // Side effects keyed by service name
894
+ if (changed.has("telegram") && isTelegramConfigured()) {
895
+ registerTelegramCommands();
896
+ reconcileTelegramWebhook(config).catch((err) => {
897
+ log.error(
898
+ { err },
899
+ "Failed to reconcile Telegram webhook after credential change",
900
+ );
901
+ });
922
902
  }
923
-
924
- if (event.slackChannelChanged && !slackFromEnv) {
925
- if (event.slackChannelCredentials) {
926
- config.slackChannelBotToken = event.slackChannelCredentials.botToken;
927
- config.slackChannelAppToken = event.slackChannelCredentials.appToken;
928
- log.info("Slack channel credentials loaded from credential vault");
929
- } else {
930
- config.slackChannelBotToken = undefined;
931
- config.slackChannelAppToken = undefined;
932
- log.info("Slack channel credentials cleared");
933
- }
903
+ if (changed.has("slackChannel")) {
934
904
  startSlackSocket();
935
905
  }
936
906
  });
@@ -938,63 +908,16 @@ async function main() {
938
908
  credentialWatcher.start();
939
909
 
940
910
  const configFileWatcher = new ConfigFileWatcher((event) => {
941
- if (event.changedKeys.has("sms")) {
942
- const sms = event.data.sms as
943
- | {
944
- phoneNumber?: string;
945
- assistantPhoneNumbers?: Record<string, string>;
946
- }
947
- | undefined;
948
- config.twilioPhoneNumber =
949
- typeof sms?.phoneNumber === "string"
950
- ? sms.phoneNumber || undefined
951
- : undefined;
952
- if (
953
- sms?.assistantPhoneNumbers &&
954
- typeof sms.assistantPhoneNumbers === "object" &&
955
- !Array.isArray(sms.assistantPhoneNumbers)
956
- ) {
957
- config.assistantPhoneNumbers = sms.assistantPhoneNumbers as Record<
958
- string,
959
- string
960
- >;
961
- } else {
962
- config.assistantPhoneNumbers = undefined;
963
- }
964
- }
965
-
966
- if (event.changedKeys.has("email")) {
967
- const email = event.data.email as { address?: string } | undefined;
968
- config.assistantEmail =
969
- typeof email?.address === "string"
970
- ? email.address || undefined
971
- : undefined;
972
- }
973
-
974
- if (event.changedKeys.has("twilio")) {
975
- const twilio = event.data.twilio as { accountSid?: string } | undefined;
976
- config.twilioAccountSid =
977
- typeof twilio?.accountSid === "string"
978
- ? twilio.accountSid || undefined
979
- : undefined;
980
- }
981
-
982
- if (event.changedKeys.has("ingress")) {
983
- const ingress = event.data.ingress as
984
- | { publicBaseUrl?: string }
985
- | undefined;
986
- config.ingressPublicBaseUrl =
987
- typeof ingress?.publicBaseUrl === "string"
988
- ? ingress.publicBaseUrl || undefined
989
- : undefined;
990
- if (isTelegramConfigured()) {
991
- reconcileTelegramWebhook(config).catch((err) => {
992
- log.error(
993
- { err },
994
- "Failed to reconcile Telegram webhook after ingress URL change",
995
- );
996
- });
997
- }
911
+ applyConfigFileMappings(event.data, event.changedKeys, config);
912
+
913
+ // Side effect: reconcile Telegram webhook when ingress URL changes
914
+ if (event.changedKeys.has("ingress") && isTelegramConfigured()) {
915
+ reconcileTelegramWebhook(config).catch((err) => {
916
+ log.error(
917
+ { err },
918
+ "Failed to reconcile Telegram webhook after ingress URL change",
919
+ );
920
+ });
998
921
  }
999
922
  });
1000
923