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)