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