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 <noreply@anthropic.com>
This commit is contained in:
Khalid A
2026-04-17 21:03:15 -05:00
parent ec92b6c7c6
commit 498d873c47
2 changed files with 45 additions and 20 deletions

View File

@@ -44,14 +44,17 @@ async function handleInbound(body: unknown, deps: WebhookSmsRouterDeps) {
} }
// Check if sender is authorized for this site // 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); const site = findAuthorizedSite(from, to);
if (!site) { if (site === null) {
logger.info({ event: 'sms.unauthorized', from: maskPhone(from), to: maskPhone(to), messageId }, 'Unauthorized sender'); logger.info({ event: 'sms.unauthorized', from: maskPhone(from), to: maskPhone(to), messageId }, 'Unauthorized sender');
return; return;
} }
const phoneHash = hashPhone(from); 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 // Idempotency check
if (messageId && !claimOnce(`sms:${messageId}`, 3600)) { if (messageId && !claimOnce(`sms:${messageId}`, 3600)) {

View File

@@ -3,8 +3,13 @@ import path from 'node:path';
import { smsSitesConfigSchema, type SmsSitesConfig } from '@dynamic-sites/shared'; import { smsSitesConfigSchema, type SmsSitesConfig } from '@dynamic-sites/shared';
import { logger } from '../logger.js'; 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 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 cachedConfig: SmsSitesConfig | null = null;
let configLoadedAt = 0; let configLoadedAt = 0;
@@ -14,21 +19,33 @@ function loadConfig(): SmsSitesConfig | null {
const now = Date.now(); const now = Date.now();
if (cachedConfig && now - configLoadedAt < CONFIG_TTL_MS) return cachedConfig; if (cachedConfig && now - configLoadedAt < CONFIG_TTL_MS) return cachedConfig;
for (const configPath of CONFIG_SEARCH_PATHS) {
try { try {
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8'); const raw = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
const result = smsSitesConfigSchema.safeParse(parsed); const result = smsSitesConfigSchema.safeParse(parsed);
if (!result.success) { if (!result.success) {
logger.warn({ event: 'sms.config_invalid', errors: result.error.message }, 'Invalid SMS sites config'); logger.warn({ event: 'sms.config_invalid', path: configPath, errors: result.error.message }, 'Invalid SMS sites config');
return cachedConfig; continue;
} }
cachedConfig = result.data; cachedConfig = result.data;
configLoadedAt = now; configLoadedAt = now;
logger.info({ event: 'sms.config_loaded', path: configPath, sites: cachedConfig.sites.length }, 'SMS sites config loaded');
return cachedConfig; return cachedConfig;
} catch (err) { } catch {
logger.warn({ event: 'sms.config_missing', error: (err as Error).message }, 'Could not load SMS sites config'); // Try next path
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. * Used to filter out Telnyx delivery receipts / echo of our own sent messages.
*/ */
export function isOwnNumber(phone: string): boolean { export function isOwnNumber(phone: string): boolean {
const config = loadConfig(); return getOwnNumbers().includes(phone);
if (!config) return false;
return config.sites.some(site => site.phoneNumber === phone);
} }
/** /**
* Check if a sender is authorized to make edits for a given site. * Check if a sender is authorized to make edits for a given site.
* Returns the matching site config if authorized, or null. * 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(); 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 => const site = config.sites.find(site =>
site.phoneNumber === to && site.allowedSenders.includes(from) site.phoneNumber === to && site.allowedSenders.includes(from)