From ec92b6c7c668941612ff1a8f714865d287e34269 Mon Sep 17 00:00:00 2001 From: Khalid A Date: Fri, 17 Apr 2026 20:40:52 -0500 Subject: [PATCH] Fix SMS echo loop and empty manifest in Docker Two bugs fixed: 1. SMS echo loop: Telnyx delivers our own outbound messages back to the webhook, causing the system to process its own replies as new requests. Added isOwnNumber() check to skip messages from system phone numbers. 2. Sender authorization: Added findAuthorizedSite() to verify that the sender is in the allowedSenders list for the receiving phone number, preventing unauthorized messages from being processed. 3. Empty manifest: The server Dockerfile runs from /app/server/ but REPO_ROOT defaulted to '.', causing content/sections/ to resolve to /app/server/content/sections/ (doesn't exist) instead of /app/content/sections/. Added ENV REPO_ROOT=/app to the Dockerfile. Added new sms/config.ts module that loads config/sms-sites.json at runtime (with 60-second cache) and provides isOwnNumber() and findAuthorizedSite() checks. Co-Authored-By: Claude Sonnet 4.6 --- server/Dockerfile | 1 + server/src/routes/webhook-sms.ts | 17 +++++++++- server/src/sms/config.ts | 57 ++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 server/src/sms/config.ts diff --git a/server/Dockerfile b/server/Dockerfile index 72d8d2b..5f00c36 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -21,6 +21,7 @@ COPY config ./config WORKDIR /app/server ENV NODE_ENV=production +ENV REPO_ROOT=/app EXPOSE 3001 CMD ["npx", "tsx", "src/index.ts"] diff --git a/server/src/routes/webhook-sms.ts b/server/src/routes/webhook-sms.ts index af85cd6..61e6459 100644 --- a/server/src/routes/webhook-sms.ts +++ b/server/src/routes/webhook-sms.ts @@ -4,6 +4,7 @@ import type { EditQueue } from '../queue/edit-queue.js'; import { parseTelnyxInboundMessage } from '../sms/parse.js'; import { sendSms } from '../sms/reply.js'; import { SMS_TEMPLATES } from '../sms/templates.js'; +import { isOwnNumber, findAuthorizedSite } from '../sms/config.js'; import { claimOnce, checkSmsRateLimit, hashPhone, getPendingProposalByPhone, updateProposalStatus } from '../db.js'; import { logger, maskPhone } from '../logger.js'; @@ -35,8 +36,22 @@ async function handleInbound(body: unknown, deps: WebhookSmsRouterDeps) { } const { messageId, from, to, text, hasMedia } = parsed; + + // Skip messages from our own numbers (prevents echo/loop from delivery receipts) + if (isOwnNumber(from)) { + logger.info({ event: 'sms.own_number_skipped', from: maskPhone(from), messageId }, 'Skipped message from own number'); + return; + } + + // Check if sender is authorized for this site + const site = findAuthorizedSite(from, to); + if (!site) { + 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 }, '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 new file mode 100644 index 0000000..0a13f9f --- /dev/null +++ b/server/src/sms/config.ts @@ -0,0 +1,57 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { smsSitesConfigSchema, type SmsSitesConfig } from '@dynamic-sites/shared'; +import { logger } from '../logger.js'; + +const REPO_ROOT = process.env.REPO_ROOT || '.'; +const CONFIG_PATH = path.join(REPO_ROOT, 'config/sms-sites.json'); + +let cachedConfig: SmsSitesConfig | null = null; +let configLoadedAt = 0; +const CONFIG_TTL_MS = 60_000; // Reload every minute + +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'); + return cachedConfig; + } + 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; + } +} + +/** + * Check if a phone number is one of our own system numbers (the Telnyx numbers we send from). + * 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); +} + +/** + * Check if a sender is authorized to make edits for a given site. + * Returns the matching site config if authorized, or null. + */ +export function findAuthorizedSite(from: string, to: string): { siteId: string; repoRoot: string } | null { + const config = loadConfig(); + if (!config) return null; + + const site = config.sites.find(site => + site.phoneNumber === to && site.allowedSenders.includes(from) + ); + if (!site) return null; + return { siteId: site.siteId, repoRoot: site.repoRoot || '.' }; +} \ No newline at end of file