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 <noreply@anthropic.com>
This commit is contained in:
Khalid A
2026-04-17 20:40:52 -05:00
parent 3cf3694ee7
commit ec92b6c7c6
3 changed files with 74 additions and 1 deletions

View File

@@ -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)) {