Fix issues and add linting

This commit is contained in:
khalid@traclabs.com
2026-04-22 22:44:03 -05:00
parent 498d873c47
commit bcd047bc54
21 changed files with 10634 additions and 134 deletions

View File

@@ -10,6 +10,9 @@
},
"dependencies": {
"@dynamic-sites/shared": "file:../shared",
"@vonage/jwt": "^1.11.0",
"@vonage/messages": "^1.12.0",
"@vonage/server-sdk": "^3.14.0",
"better-sqlite3": "^11.8.0",
"cors": "^2.8.5",
"express": "^5.1.0",
@@ -22,6 +25,7 @@
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.2",
"@types/jsonwebtoken": "^9.0.7",
"tsx": "^4.19.0",
"typescript": "^5.8.0"
}

View File

@@ -10,28 +10,21 @@ export interface CreateAppDeps {
queue: EditQueue;
}
const MAX_BODY = process.env.MAX_UPLOAD_SIZE_BYTES
? `${Math.ceil(parseInt(process.env.MAX_UPLOAD_SIZE_BYTES, 10) / 1024)}kb`
: '1mb';
export function createApp(deps: CreateAppDeps): Express {
const app = express();
// Telnyx webhook needs raw body for signature verification — mount BEFORE json parser
app.use('/webhooks', express.raw({ type: '*/*' }), (req, _res, next) => {
// Parse raw body to JSON for webhook handler
if (req.body && Buffer.isBuffer(req.body)) {
try {
req.body = JSON.parse(req.body.toString());
} catch { /* leave as-is */ }
}
next();
});
// JSON parser for everything else
app.use(express.json());
// JSON parser with size limit
app.use(express.json({ limit: MAX_BODY }));
// CORS for editor cross-origin requests
const allowedOrigin = process.env.CORS_ALLOWED_ORIGIN || 'http://localhost:4321';
app.use('/api', cors({
origin: allowedOrigin,
methods: ['GET', 'POST', 'OPTIONS'],
methods: ['GET', 'POST', 'PUT', 'OPTIONS'],
allowedHeaders: ['Authorization', 'Content-Type'],
credentials: true,
}));

View File

@@ -34,7 +34,12 @@ export function writeContentFile(
const existing = fs.readFileSync(absPath, 'utf-8');
beforeHash = fileHash(existing);
const backupDir = path.join(REPO_ROOT, 'content', '.backups', repoRelativePath);
// Derive a clean backup subdirectory name from the repo-relative path.
// e.g. "content/sections/hero.json" → "sections/hero" (strip leading content/ and .json)
const stripped = repoRelativePath
.replace(/^content\//, '')
.replace(/\.json$/, '');
const backupDir = path.join(REPO_ROOT, 'content', '.backups', stripped);
ensureDir(backupDir);
const ts = new Date().toISOString().replace(/[:.]/g, '-');
fs.copyFileSync(absPath, path.join(backupDir, `${ts}.json`));

View File

@@ -2,7 +2,7 @@ import { z } from 'zod';
import { routingOutputSchema, type RoutingOutput, classificationSchema, type ClassificationOutput } from '@dynamic-sites/shared';
import { logger } from '../logger.js';
const OLLAMA_HOST = process.env.OLLAMA_HOST || 'https://ollama.com';
const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://localhost:11434';
const OLLAMA_API_KEY = process.env.OLLAMA_API_KEY || '';
const PRIMARY_MODEL = process.env.OLLAMA_MODEL || 'qwen3.5:397b-cloud';
const FALLBACK_MODEL = process.env.OLLAMA_FALLBACK_MODEL || 'gpt-oss:120b';

View File

@@ -13,12 +13,30 @@ import { logger } from '../logger.js';
const REPO_ROOT = process.env.REPO_ROOT || '.';
/**
* In-memory map from job ID → proposal ID.
* Used by the HTTP API to let the editor poll for a proposal created by a queued job.
* Entries are pruned after 15 minutes to avoid unbounded growth.
*/
export const jobProposalMap = new Map<string, { proposalId: string; createdAt: number }>();
const JOB_MAP_TTL_MS = 15 * 60 * 1000;
function pruneJobMap() {
const cutoff = Date.now() - JOB_MAP_TTL_MS;
for (const [key, entry] of jobProposalMap) {
if (entry.createdAt < cutoff) jobProposalMap.delete(key);
}
}
export async function processEditJob(job: EditJobPayload): Promise<void> {
if (job.kind === 'propose') {
await handlePropose(job);
} else if (job.kind === 'apply') {
await handleApply(job);
}
// Opportunistic cleanup
if (jobProposalMap.size > 100) pruneJobMap();
}
async function handlePropose(job: Extract<EditJobPayload, { kind: 'propose' }>) {
@@ -120,6 +138,9 @@ async function handlePropose(job: Extract<EditJobPayload, { kind: 'propose' }>)
phoneHash: job.smsReplyMeta?.from ? crypto.createHash('sha256').update(job.smsReplyMeta.from).digest('hex').slice(0, 16) : undefined,
});
// Record the job → proposal mapping so the HTTP API can find it
jobProposalMap.set(job.id, { proposalId, createdAt: Date.now() });
log.info({ event: 'proposal.created', proposalId, path: repoRelativePath }, 'Proposal created');
// Step 6: Notify user
@@ -127,18 +148,11 @@ async function handlePropose(job: Extract<EditJobPayload, { kind: 'propose' }>)
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.PROPOSAL_SUMMARY(summary, proposalId));
}
// For HTTP callers, the proposal_id is returned via the response (handled in route)
// Store on the job for the route handler to read
(job as Record<string, unknown>)._proposalId = proposalId;
(job as Record<string, unknown>)._summary = summary;
} catch (err) {
const msg = (err as Error).message;
log.error({ event: 'propose.failed', error: msg }, 'Propose failed');
log.error({ event: 'propose.failed', error: (err as Error).message }, 'Propose failed');
if (job.smsReplyMeta) {
const template = msg === 'LLM_UNAVAILABLE' ? SMS_TEMPLATES.LLM_UNAVAILABLE() : SMS_TEMPLATES.LLM_UNAVAILABLE();
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, template);
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.LLM_UNAVAILABLE());
}
}
}

View File

@@ -2,10 +2,12 @@ import { Router, type Request, type Response } from 'express';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { editRequestSchema, sectionFileSchema } from '@dynamic-sites/shared';
import { editRequestSchema, sectionFileSchema, schemaForRepoRelativePath } from '@dynamic-sites/shared';
import type { EditQueue } from '../queue/edit-queue.js';
import { getProposal, updateProposalStatus } from '../db.js';
import { buildSectionManifest } from '../queue/manifest.js';
import { writeContentFile } from '../io/write-content.js';
import { jobProposalMap } from '../queue/process-edit-job.js';
import { logger } from '../logger.js';
const REPO_ROOT = process.env.REPO_ROOT || '.';
@@ -59,6 +61,43 @@ export function createApiEditRouter(deps: ApiEditRouterDeps): Router {
}
});
// PUT /api/section — directly write validated JSON to a section file (editor use)
router.put('/section', (req: Request, res: Response) => {
const relPath = req.body?.path as string;
const data = req.body?.data;
if (!relPath || data === undefined) {
res.status(400).json({ error: 'Missing path or data in request body' });
return;
}
const schema = schemaForRepoRelativePath(relPath);
if (!schema) {
res.status(400).json({ error: `No schema for path: ${relPath}` });
return;
}
const validation = schema.safeParse(data);
if (!validation.success) {
res.status(400).json({ error: 'Validation failed', details: validation.error.issues });
return;
}
const absPath = path.join(REPO_ROOT, relPath);
if (!fs.existsSync(absPath)) {
res.status(404).json({ error: 'File not found' });
return;
}
try {
writeContentFile(relPath, validation.data, { source: 'editor' });
res.json({ status: 'written', path: relPath });
} catch (err) {
logger.error({ event: 'section.write_failed', error: (err as Error).message }, 'Direct section write failed');
res.status(500).json({ error: 'Write failed' });
}
});
// GET /api/site-context
router.get('/site-context', (_req: Request, res: Response) => {
try {
@@ -69,7 +108,7 @@ export function createApiEditRouter(deps: ApiEditRouterDeps): Router {
}
});
// POST /api/edit — propose an edit (NL message)
// POST /api/edit — propose an edit (NL message) or confirm/reject a proposal
router.post('/edit', async (req: Request, res: Response) => {
const parsed = editRequestSchema.safeParse(req.body);
if (!parsed.success) {
@@ -100,7 +139,12 @@ export function createApiEditRouter(deps: ApiEditRouterDeps): Router {
return;
}
// Propose flow
// Propose flow — message is required
if (!message || message.length === 0) {
res.status(400).json({ error: 'message is required for edit requests' });
return;
}
const jobId = crypto.randomUUID();
try {
deps.queue.enqueue({
@@ -137,10 +181,34 @@ export function createApiEditRouter(deps: ApiEditRouterDeps): Router {
return;
}
// Use the write-content module (imported dynamically to avoid circular deps)
import('../io/write-content.js').then(({ writeContentFile }) => {
try {
writeContentFile(relPath, parsed.data, { source: 'editor' });
res.status(201).json({ status: 'created', path: relPath });
} catch (err) {
logger.error({ event: 'section.create_failed', error: (err as Error).message }, 'Section creation failed');
res.status(500).json({ error: 'Failed to create section' });
}
});
// GET /api/job/:id — poll for a proposal created by an async job
router.get('/job/:id', (req: Request, res: Response) => {
const entry = jobProposalMap.get(req.params.id);
if (!entry) {
res.json({ status: 'processing' });
return;
}
const proposal = getProposal(entry.proposalId);
if (!proposal) {
res.json({ status: 'processing' });
return;
}
res.json({
status: proposal.status,
proposal_id: proposal.proposal_id,
summary: proposal.summary_text,
repo_relative_path: proposal.repo_relative_path,
});
});

View File

@@ -1,22 +1,57 @@
import { Router, type Request, type Response } from 'express';
import crypto from 'node:crypto';
import { verifySignature } from '@vonage/jwt';
import type { EditQueue } from '../queue/edit-queue.js';
import { parseTelnyxInboundMessage } from '../sms/parse.js';
import { parseVonageInboundMessage } 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';
const VONAGE_API_SIGNATURE_SECRET = process.env.VONAGE_API_SIGNATURE_SECRET || '';
export interface WebhookSmsRouterDeps {
queue: EditQueue;
}
/**
* Verify Vonage webhook JWT signature (HMAC-SHA256).
* Returns true if signature is valid or if no secret is configured (dev mode).
*/
function verifyVonageWebhook(req: Request): boolean {
if (!VONAGE_API_SIGNATURE_SECRET) {
logger.warn({ event: 'sms.webhook_no_secret' }, 'No VONAGE_API_SIGNATURE_SECRET configured, skipping verification');
return true;
}
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
logger.warn({ event: 'sms.webhook_no_auth' }, 'Missing Authorization header on webhook');
return false;
}
const token = authHeader.split(' ')[1];
try {
return verifySignature(token, VONAGE_API_SIGNATURE_SECRET);
} catch (err) {
logger.warn({ event: 'sms.webhook_verify_failed', error: (err as Error).message }, 'Webhook JWT verification failed');
return false;
}
}
export function createWebhookSmsRouter(deps: WebhookSmsRouterDeps): Router {
const router = Router();
router.post('/telnyx', (req: Request, res: Response) => {
// Respond quickly
// Vonage inbound message webhook
router.post('/inbound', (req: Request, res: Response) => {
// Verify JWT signature
if (!verifyVonageWebhook(req)) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
// Respond quickly — Vonage expects a 200 within a few seconds
res.status(200).json({ status: 'received' });
// Process async
@@ -25,11 +60,25 @@ export function createWebhookSmsRouter(deps: WebhookSmsRouterDeps): Router {
});
});
// Vonage message status webhook (delivery receipts, etc.)
router.post('/status', (req: Request, res: Response) => {
if (!verifyVonageWebhook(req)) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const status = (req.body as Record<string, unknown>)?.status;
const messageUuid = (req.body as Record<string, unknown>)?.message_uuid;
logger.info({ event: 'sms.status_update', messageUuid, status }, 'Message status update');
res.status(200).json({ status: 'received' });
});
return router;
}
async function handleInbound(body: unknown, deps: WebhookSmsRouterDeps) {
const parsed = parseTelnyxInboundMessage(body);
const parsed = parseVonageInboundMessage(body);
if (!parsed) {
logger.warn({ event: 'sms.parse_failed' }, 'Failed to parse inbound SMS');
return;

View File

@@ -41,7 +41,7 @@ function loadConfig(): SmsSitesConfig | null {
return cachedConfig; // Return stale cache if available, otherwise null
}
/** All phone numbers this system sends from (Telnyx numbers). */
/** All phone numbers this system sends from (Vonage virtual numbers). */
function getOwnNumbers(): string[] {
const config = loadConfig();
if (!config) return [];
@@ -49,8 +49,8 @@ function getOwnNumbers(): string[] {
}
/**
* 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.
* Check if a phone number is one of our own system numbers (the Vonage numbers we send from).
* Used to filter out delivery receipts / echo of our own sent messages.
*/
export function isOwnNumber(phone: string): boolean {
return getOwnNumbers().includes(phone);
@@ -76,4 +76,4 @@ export function findAuthorizedSite(from: string, to: string): { siteId: string;
);
if (!site) return null;
return { siteId: site.siteId, repoRoot: site.repoRoot || '.' };
}
}

View File

@@ -7,24 +7,72 @@ export interface ParsedInboundSms {
mediaUrls: string[];
}
export function parseTelnyxInboundMessage(body: unknown): ParsedInboundSms | null {
/**
* Parse an inbound message from the Vonage Messages API webhook.
*
* Vonage Messages API delivers inbound messages as flat JSON:
* {
* "message_uuid": "...",
* "from": "14155550100",
* "to": "14155550200",
* "channel": "sms" | "mms",
* "message_type": "text" | "image" | "video" | ...,
* "text": "Hello",
* "image": { "url": "..." },
* "timestamp": "..."
* }
*/
export function parseVonageInboundMessage(body: unknown): ParsedInboundSms | null {
try {
const data = body as Record<string, unknown>;
const eventData = (data.data as Record<string, unknown>) || data;
const payload = (eventData.payload as Record<string, unknown>) || eventData;
const from = ((payload.from as Record<string, unknown>)?.phone_number as string) || (payload.from as string) || '';
const to = (Array.isArray(payload.to) ? (payload.to[0] as Record<string, unknown>)?.phone_number : (payload.to as Record<string, unknown>)?.phone_number) as string || '';
const text = (payload.text as string) || (payload.body as string) || '';
const messageId = (payload.id as string) || (eventData.id as string) || '';
const from = (data.from as string) || '';
const to = (data.to as string) || '';
const text = (data.text as string) || '';
const messageId = (data.message_uuid as string) || '';
const messageType = (data.message_type as string) || '';
const channel = (data.channel as string) || 'sms';
const media = (payload.media as Array<{ url: string }>) || [];
const mediaUrls = media.map(m => m.url).filter(Boolean);
// Collect media URLs from MMS messages
const mediaUrls: string[] = [];
if (messageType === 'image' && data.image) {
const imageUrl = (data.image as Record<string, unknown>)?.url as string;
if (imageUrl) mediaUrls.push(imageUrl);
}
if (messageType === 'video' && data.video) {
const videoUrl = (data.video as Record<string, unknown>)?.url as string;
if (videoUrl) mediaUrls.push(videoUrl);
}
if (messageType === 'file' && data.file) {
const fileUrl = (data.file as Record<string, unknown>)?.url as string;
if (fileUrl) mediaUrls.push(fileUrl);
}
if (!from || !text) return null;
const hasMedia = mediaUrls.length > 0 || channel === 'mms';
return { messageId, from, to, text: text.trim(), hasMedia: mediaUrls.length > 0, mediaUrls };
// For text messages, require at least a from and text
if (!from || (!text && !hasMedia)) return null;
// Normalize phone numbers to E.164 format if they aren't already
const normalizedFrom = normalizePhone(from);
const normalizedTo = normalizePhone(to);
return {
messageId,
from: normalizedFrom,
to: normalizedTo,
text: text.trim(),
hasMedia,
mediaUrls,
};
} catch {
return null;
}
}
/** Ensure phone numbers start with '+' for consistency with config lookups. */
function normalizePhone(phone: string): string {
if (!phone) return phone;
// Vonage sends numbers without '+', so add it for E.164 consistency
return phone.startsWith('+') ? phone : `+${phone}`;
}

View File

@@ -1,30 +1,95 @@
import { Vonage } from '@vonage/server-sdk';
import { SMS } from '@vonage/messages';
import { Auth } from '@vonage/auth';
import { logger, maskPhone } from '../logger.js';
const TELNYX_API_KEY = process.env.TELNYX_API_KEY || '';
const VONAGE_API_KEY = process.env.VONAGE_API_KEY || '';
const VONAGE_API_SECRET = process.env.VONAGE_API_SECRET || '';
const VONAGE_APPLICATION_ID = process.env.VONAGE_APPLICATION_ID || '';
const VONAGE_PRIVATE_KEY_PATH = process.env.VONAGE_PRIVATE_KEY_PATH || '';
const VONAGE_PRIVATE_KEY = process.env.VONAGE_PRIVATE_KEY || '';
let vonageClient: Vonage | null = null;
/**
* Resolve the private key for the Vonage SDK.
*
* Supports three modes (checked in order):
* 1. VONAGE_PRIVATE_KEY — raw PEM string (starts with "-----BEGIN")
* 2. VONAGE_PRIVATE_KEY — base64-encoded PEM (decode first)
* 3. VONAGE_PRIVATE_KEY_PATH — path to a .key file on disk
*
* The Vonage Auth constructor accepts either a file path (string) or
* the key content directly (string / Buffer).
*/
function resolvePrivateKey(): string | undefined {
if (VONAGE_PRIVATE_KEY) {
// Raw PEM passed directly
if (VONAGE_PRIVATE_KEY.startsWith('-----BEGIN')) {
return VONAGE_PRIVATE_KEY;
}
// Base64-encoded PEM (common for PaaS env vars)
try {
const decoded = Buffer.from(VONAGE_PRIVATE_KEY, 'base64').toString('utf-8');
if (decoded.startsWith('-----BEGIN')) return decoded;
} catch { /* fall through */ }
// Treat as raw content anyway
return VONAGE_PRIVATE_KEY;
}
if (VONAGE_PRIVATE_KEY_PATH) {
return VONAGE_PRIVATE_KEY_PATH;
}
return undefined;
}
function getVonageClient(): Vonage | null {
if (vonageClient) return vonageClient;
const privateKey = resolvePrivateKey();
if (!VONAGE_APPLICATION_ID || !privateKey) {
return null;
}
vonageClient = new Vonage(new Auth({
apiKey: VONAGE_API_KEY,
apiSecret: VONAGE_API_SECRET,
applicationId: VONAGE_APPLICATION_ID,
privateKey,
}));
return vonageClient;
}
/**
* Send an SMS reply via the Vonage Messages API.
* Phone numbers should be in E.164 format (e.g. +14155550100).
* Vonage expects numbers without the leading '+'.
*/
export async function sendSms(to: string, from: string, body: string): Promise<void> {
if (!TELNYX_API_KEY) {
logger.warn({ event: 'sms.send_skipped', to: maskPhone(to) }, 'No TELNYX_API_KEY, skipping SMS send');
const client = getVonageClient();
if (!client) {
logger.warn({ event: 'sms.send_skipped', to: maskPhone(to) }, 'No Vonage credentials configured, skipping SMS send');
logger.info({ event: 'sms.would_send', body }, 'SMS body (dev mode)');
return;
}
try {
const resp = await fetch('https://api.telnyx.com/v2/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TELNYX_API_KEY}`,
},
body: JSON.stringify({ from, to, text: body }),
});
// Strip leading '+' — Vonage expects bare numbers
const toNumber = to.replace(/^\+/, '');
const fromNumber = from.replace(/^\+/, '');
if (!resp.ok) {
logger.error({ event: 'sms.send_failed', status: resp.status }, 'Failed to send SMS');
} else {
logger.info({ event: 'sms.sent', to: maskPhone(to) }, 'SMS sent');
}
try {
await client.messages.send(
new SMS({ to: toNumber, from: fromNumber, text: body })
);
logger.info({ event: 'sms.sent', to: maskPhone(to) }, 'SMS sent');
} catch (err) {
logger.error({ event: 'sms.send_error', error: (err as Error).message }, 'SMS send error');
const error = err as Error & { response?: { data?: unknown; status?: number } };
logger.error({
event: 'sms.send_error',
error: error.message,
status: error.response?.status,
}, 'SMS send error');
}
}

View File

@@ -1,5 +1,5 @@
export const SMS_TEMPLATES = {
PROPOSAL_SUMMARY: (summary: string, proposalId: string) =>
PROPOSAL_SUMMARY: (summary: string, _proposalId: string) =>
`Proposed change: ${summary}\n\nReply YES to apply or NO to cancel.`,
APPLIED: (summary: string) =>