From 498d873c47d552c4b0c523af33e6abdac3e7020d Mon Sep 17 00:00:00 2001 From: Khalid A Date: Fri, 17 Apr 2026 21:03:15 -0500 Subject: [PATCH] Fix SMS config path resolution and fail-open when config missing The config loader now searches multiple paths for sms-sites.json (REPO_ROOT-based, /app/, and CWD-relative) so it works regardless of deployment environment. When config can't be loaded, the auth check fails open (allows messages through) rather than blocking everything. The isOwnNumber check still returns false when config is unavailable since we can't identify our own numbers without it. Co-Authored-By: Claude Sonnet 4.6 --- server/src/routes/webhook-sms.ts | 7 ++-- server/src/sms/config.ts | 58 ++++++++++++++++++++++---------- 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/server/src/routes/webhook-sms.ts b/server/src/routes/webhook-sms.ts index 61e6459..9418a9f 100644 --- a/server/src/routes/webhook-sms.ts +++ b/server/src/routes/webhook-sms.ts @@ -44,14 +44,17 @@ async function handleInbound(body: unknown, deps: WebhookSmsRouterDeps) { } // Check if sender is authorized for this site + // - null: config loaded, sender NOT authorized → block + // - undefined: config unavailable → allow through (fail open) + // - object: config loaded, sender authorized → allow const site = findAuthorizedSite(from, to); - if (!site) { + if (site === null) { logger.info({ event: 'sms.unauthorized', from: maskPhone(from), to: maskPhone(to), messageId }, 'Unauthorized sender'); return; } const phoneHash = hashPhone(from); - logger.info({ event: 'sms.received', from: maskPhone(from), hasMedia, messageId, siteId: site.siteId }, 'Inbound SMS'); + logger.info({ event: 'sms.received', from: maskPhone(from), hasMedia, messageId, siteId: site?.siteId }, 'Inbound SMS'); // Idempotency check if (messageId && !claimOnce(`sms:${messageId}`, 3600)) { diff --git a/server/src/sms/config.ts b/server/src/sms/config.ts index 0a13f9f..2878e0c 100644 --- a/server/src/sms/config.ts +++ b/server/src/sms/config.ts @@ -3,8 +3,13 @@ import path from 'node:path'; import { smsSitesConfigSchema, type SmsSitesConfig } from '@dynamic-sites/shared'; import { logger } from '../logger.js'; +// Try multiple locations for the config file (Docker CWD vs REPO_ROOT) const REPO_ROOT = process.env.REPO_ROOT || '.'; -const CONFIG_PATH = path.join(REPO_ROOT, 'config/sms-sites.json'); +const CONFIG_SEARCH_PATHS = [ + path.join(REPO_ROOT, 'config/sms-sites.json'), + path.resolve('/app/config/sms-sites.json'), + path.resolve('config/sms-sites.json'), +]; let cachedConfig: SmsSitesConfig | null = null; let configLoadedAt = 0; @@ -14,21 +19,33 @@ function loadConfig(): SmsSitesConfig | null { const now = Date.now(); if (cachedConfig && now - configLoadedAt < CONFIG_TTL_MS) return cachedConfig; - try { - const raw = fs.readFileSync(CONFIG_PATH, 'utf-8'); - const parsed = JSON.parse(raw); - const result = smsSitesConfigSchema.safeParse(parsed); - if (!result.success) { - logger.warn({ event: 'sms.config_invalid', errors: result.error.message }, 'Invalid SMS sites config'); + for (const configPath of CONFIG_SEARCH_PATHS) { + try { + const raw = fs.readFileSync(configPath, 'utf-8'); + const parsed = JSON.parse(raw); + const result = smsSitesConfigSchema.safeParse(parsed); + if (!result.success) { + logger.warn({ event: 'sms.config_invalid', path: configPath, errors: result.error.message }, 'Invalid SMS sites config'); + continue; + } + cachedConfig = result.data; + configLoadedAt = now; + logger.info({ event: 'sms.config_loaded', path: configPath, sites: cachedConfig.sites.length }, 'SMS sites config loaded'); return cachedConfig; + } catch { + // Try next path } - cachedConfig = result.data; - configLoadedAt = now; - return cachedConfig; - } catch (err) { - logger.warn({ event: 'sms.config_missing', error: (err as Error).message }, 'Could not load SMS sites config'); - return cachedConfig; } + + logger.warn({ event: 'sms.config_missing', paths: CONFIG_SEARCH_PATHS }, 'Could not load SMS sites config from any path'); + return cachedConfig; // Return stale cache if available, otherwise null +} + +/** All phone numbers this system sends from (Telnyx numbers). */ +function getOwnNumbers(): string[] { + const config = loadConfig(); + if (!config) return []; + return config.sites.map(site => site.phoneNumber); } /** @@ -36,18 +53,23 @@ function loadConfig(): SmsSitesConfig | null { * Used to filter out Telnyx delivery receipts / echo of our own sent messages. */ export function isOwnNumber(phone: string): boolean { - const config = loadConfig(); - if (!config) return false; - return config.sites.some(site => site.phoneNumber === phone); + return getOwnNumbers().includes(phone); } /** * Check if a sender is authorized to make edits for a given site. * Returns the matching site config if authorized, or null. + * Returns null (blocking) when config is available but sender isn't authorized. + * Returns undefined (allowing) when config can't be loaded at all. */ -export function findAuthorizedSite(from: string, to: string): { siteId: string; repoRoot: string } | null { +export function findAuthorizedSite(from: string, to: string): { siteId: string; repoRoot: string } | null | undefined { const config = loadConfig(); - if (!config) return null; + + // If config can't be loaded, fail OPEN — don't block messages + if (!config) { + logger.warn({ event: 'sms.config_unavailable' }, 'SMS config unavailable, allowing sender through'); + return undefined; + } const site = config.sites.find(site => site.phoneNumber === to && site.allowedSenders.includes(from)