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

@@ -3,6 +3,8 @@ API_EDIT_SECRET=change-me-to-a-random-string
# LLM (required to actually process edits) # LLM (required to actually process edits)
OLLAMA_API_KEY= OLLAMA_API_KEY=
# For Ollama Cloud use https://ollama.com, for local Ollama use http://localhost:11434
OLLAMA_HOST=https://ollama.com
# Paths # Paths
REPO_ROOT=. REPO_ROOT=.
@@ -11,9 +13,18 @@ IDEMPOTENCY_DB_PATH=./data/dynamic-sites.db
# SSR cache # SSR cache
SITE_DATA_TTL_MS=500 SITE_DATA_TTL_MS=500
# SMS (Telnyx) # SMS (Vonage)
TELNYX_PUBLIC_KEY= VONAGE_API_KEY=your_vonage_api_key
TELNYX_API_KEY= VONAGE_API_SECRET=your_vonage_api_secret
VONAGE_APPLICATION_ID=your_vonage_application_id
VONAGE_API_SIGNATURE_SECRET=your_vonage_signature_secret
# Vonage private key — use ONE of these two options:
# Option A: path to the .key file on disk (local / Docker file mount)
VONAGE_PRIVATE_KEY_PATH=./private.key
# Option B: raw PEM content or base64-encoded PEM (for PaaS / Dokploy env vars)
# To base64-encode: base64 -w0 private.key
# VONAGE_PRIVATE_KEY=
# CORS # CORS
CORS_ALLOWED_ORIGIN=http://localhost:4321 CORS_ALLOWED_ORIGIN=http://localhost:4321

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ data/
.env .env
content/.backups/ content/.backups/
.astro/ .astro/
private.key

View File

@@ -7,7 +7,7 @@ An LLM-powered website editing framework. Edit your site via SMS, a web API, or
``` ```
┌─────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────┐
│ Channels │ │ Channels │
│ SMS (Telnyx) │ POST /api/edit │ /editor │ │ SMS (Vonage) │ POST /api/edit │ /editor │
└───────┬─────────┴────────┬─────────┴─────┬──────┘ └───────┬─────────┴────────┬─────────┴─────┬──────┘
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
@@ -46,6 +46,7 @@ An LLM-powered website editing framework. Edit your site via SMS, a web API, or
### Prerequisites ### Prerequisites
- Node.js 22+ - Node.js 22+
- npm - npm
- A Vonage API account with a Messages API-enabled application
### Local Development ### Local Development
@@ -69,9 +70,23 @@ Copy `.env.example` to `.env` and set at minimum:
- `API_EDIT_SECRET` — shared secret for API auth and editor login - `API_EDIT_SECRET` — shared secret for API auth and editor login
- `OLLAMA_API_KEY` — required for LLM-powered edits - `OLLAMA_API_KEY` — required for LLM-powered edits
- `VONAGE_API_KEY` — your Vonage API key (from Dashboard)
- `VONAGE_API_SECRET` — your Vonage API secret (from Dashboard)
- `VONAGE_APPLICATION_ID` — your Vonage application ID
- `VONAGE_PRIVATE_KEY_PATH` — path to the `private.key` file generated when creating the Vonage application
- `VONAGE_API_SIGNATURE_SECRET` — webhook signature secret (from Dashboard → API Settings)
See `.env.example` for all options. See `.env.example` for all options.
### Vonage Setup
1. Create a Vonage application in the Dashboard with Messages capability enabled.
2. Set the inbound message webhook URL to `https://dynamicsites.kadil.dev/webhooks/inbound` (POST).
3. Set the status webhook URL to `https://dynamicsites.kadil.dev/webhooks/status` (POST).
4. Under API Settings, ensure Messages API is set as the default for SMS.
5. Copy the generated `private.key` to the project root.
6. Note your signature secret from Dashboard → API Settings for webhook verification.
### Docker ### Docker
```bash ```bash
@@ -105,7 +120,7 @@ docker compose up -d
│ ├── queue/ # FIFO queue + job processor │ ├── queue/ # FIFO queue + job processor
│ ├── routes/ # API edit, SMS webhook, health │ ├── routes/ # API edit, SMS webhook, health
│ ├── llm/ # Ollama client with retry/validation │ ├── llm/ # Ollama client with retry/validation
│ ├── sms/ # Telnyx parse, reply, templates │ ├── sms/ # Vonage parse, reply, templates
│ └── io/ # Filesystem writer (atomic, with backup) │ └── io/ # Filesystem writer (atomic, with backup)
├── src/ # Astro SSR site ├── src/ # Astro SSR site
│ ├── pages/ │ ├── pages/

View File

@@ -38,8 +38,14 @@ services:
- API_EDIT_SECRET=${API_EDIT_SECRET:-change-me} - API_EDIT_SECRET=${API_EDIT_SECRET:-change-me}
- OLLAMA_API_KEY=${OLLAMA_API_KEY:-} - OLLAMA_API_KEY=${OLLAMA_API_KEY:-}
- OLLAMA_HOST=${OLLAMA_HOST:-https://ollama.com} - OLLAMA_HOST=${OLLAMA_HOST:-https://ollama.com}
- TELNYX_API_KEY=${TELNYX_API_KEY:-} - VONAGE_API_KEY=${VONAGE_API_KEY:-}
- TELNYX_PUBLIC_KEY=${TELNYX_PUBLIC_KEY:-} - VONAGE_API_SECRET=${VONAGE_API_SECRET:-}
- VONAGE_APPLICATION_ID=${VONAGE_APPLICATION_ID:-}
# Use VONAGE_PRIVATE_KEY (base64 or raw PEM) for PaaS/Dokploy deployments,
# or VONAGE_PRIVATE_KEY_PATH with a file mount for local/Docker deployments.
- VONAGE_PRIVATE_KEY=${VONAGE_PRIVATE_KEY:-}
- VONAGE_PRIVATE_KEY_PATH=${VONAGE_PRIVATE_KEY_PATH:-}
- VONAGE_API_SIGNATURE_SECRET=${VONAGE_API_SIGNATURE_SECRET:-}
- CORS_ALLOWED_ORIGIN=http://localhost:4321 - CORS_ALLOWED_ORIGIN=http://localhost:4321
- LOG_LEVEL=${LOG_LEVEL:-info} - LOG_LEVEL=${LOG_LEVEL:-info}
- PROPOSAL_TTL_MS=${PROPOSAL_TTL_MS:-900000} - PROPOSAL_TTL_MS=${PROPOSAL_TTL_MS:-900000}

34
eslint.config.js Normal file
View File

@@ -0,0 +1,34 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
// Global ignores
{
ignores: [
'dist/**',
'server/dist/**',
'.astro/**',
'node_modules/**',
'scripts/**',
],
},
// Base recommended rules
js.configs.recommended,
...tseslint.configs.recommended,
// Project-wide overrides
{
rules: {
// Allow unused vars/args when prefixed with _
'@typescript-eslint/no-unused-vars': ['warn', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
}],
// Empty catch blocks are intentional in this codebase (fail-through patterns)
'no-empty': ['error', { allowEmptyCatch: true }],
// We use unknown instead of any; warn when any slips in
'@typescript-eslint/no-explicit-any': 'warn',
},
},
);

10108
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -14,9 +14,11 @@
"dev:server": "npm run dev --workspace=server", "dev:server": "npm run dev --workspace=server",
"build": "npm run check:content && astro build", "build": "npm run check:content && astro build",
"start": "node dist/server/entry.mjs", "start": "node dist/server/entry.mjs",
"check:content": "node scripts/validate-content.js && node scripts/check-canonical.js", "check:content": "npx tsx scripts/validate-content.js && npx tsx scripts/check-canonical.js",
"lint": "eslint . --ext .ts,.tsx",
"lint:fix": "eslint . --ext .ts,.tsx --fix",
"test": "vitest run", "test": "vitest run",
"check": "npm run check:content && npm test" "check": "npm run lint && npm run check:content && npm test"
}, },
"dependencies": { "dependencies": {
"astro": "^5.8.0", "astro": "^5.8.0",
@@ -28,9 +30,13 @@
"@dynamic-sites/shared": "file:shared" "@dynamic-sites/shared": "file:shared"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0",
"@types/react": "^19.1.2", "@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.1.2",
"eslint": "^9.17.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"typescript-eslint": "^8.20.0",
"vitest": "^3.1.0" "vitest": "^3.1.0"
} }
} }

View File

@@ -10,6 +10,9 @@
}, },
"dependencies": { "dependencies": {
"@dynamic-sites/shared": "file:../shared", "@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", "better-sqlite3": "^11.8.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^5.1.0", "express": "^5.1.0",
@@ -22,6 +25,7 @@
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.2", "@types/express": "^5.0.2",
"@types/jsonwebtoken": "^9.0.7",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.8.0" "typescript": "^5.8.0"
} }

View File

@@ -10,28 +10,21 @@ export interface CreateAppDeps {
queue: EditQueue; 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 { export function createApp(deps: CreateAppDeps): Express {
const app = express(); const app = express();
// Telnyx webhook needs raw body for signature verification — mount BEFORE json parser // JSON parser with size limit
app.use('/webhooks', express.raw({ type: '*/*' }), (req, _res, next) => { app.use(express.json({ limit: MAX_BODY }));
// 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());
// CORS for editor cross-origin requests // CORS for editor cross-origin requests
const allowedOrigin = process.env.CORS_ALLOWED_ORIGIN || 'http://localhost:4321'; const allowedOrigin = process.env.CORS_ALLOWED_ORIGIN || 'http://localhost:4321';
app.use('/api', cors({ app.use('/api', cors({
origin: allowedOrigin, origin: allowedOrigin,
methods: ['GET', 'POST', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'OPTIONS'],
allowedHeaders: ['Authorization', 'Content-Type'], allowedHeaders: ['Authorization', 'Content-Type'],
credentials: true, credentials: true,
})); }));

View File

@@ -34,7 +34,12 @@ export function writeContentFile(
const existing = fs.readFileSync(absPath, 'utf-8'); const existing = fs.readFileSync(absPath, 'utf-8');
beforeHash = fileHash(existing); 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); ensureDir(backupDir);
const ts = new Date().toISOString().replace(/[:.]/g, '-'); const ts = new Date().toISOString().replace(/[:.]/g, '-');
fs.copyFileSync(absPath, path.join(backupDir, `${ts}.json`)); 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 { routingOutputSchema, type RoutingOutput, classificationSchema, type ClassificationOutput } from '@dynamic-sites/shared';
import { logger } from '../logger.js'; 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 OLLAMA_API_KEY = process.env.OLLAMA_API_KEY || '';
const PRIMARY_MODEL = process.env.OLLAMA_MODEL || 'qwen3.5:397b-cloud'; const PRIMARY_MODEL = process.env.OLLAMA_MODEL || 'qwen3.5:397b-cloud';
const FALLBACK_MODEL = process.env.OLLAMA_FALLBACK_MODEL || 'gpt-oss:120b'; 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 || '.'; 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> { export async function processEditJob(job: EditJobPayload): Promise<void> {
if (job.kind === 'propose') { if (job.kind === 'propose') {
await handlePropose(job); await handlePropose(job);
} else if (job.kind === 'apply') { } else if (job.kind === 'apply') {
await handleApply(job); await handleApply(job);
} }
// Opportunistic cleanup
if (jobProposalMap.size > 100) pruneJobMap();
} }
async function handlePropose(job: Extract<EditJobPayload, { kind: 'propose' }>) { 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, 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'); log.info({ event: 'proposal.created', proposalId, path: repoRelativePath }, 'Proposal created');
// Step 6: Notify user // 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)); 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) { } catch (err) {
const msg = (err as Error).message; log.error({ event: 'propose.failed', error: (err as Error).message }, 'Propose failed');
log.error({ event: 'propose.failed', error: msg }, 'Propose failed');
if (job.smsReplyMeta) { if (job.smsReplyMeta) {
const template = msg === 'LLM_UNAVAILABLE' ? SMS_TEMPLATES.LLM_UNAVAILABLE() : SMS_TEMPLATES.LLM_UNAVAILABLE(); await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.LLM_UNAVAILABLE());
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, template);
} }
} }
} }

View File

@@ -2,10 +2,12 @@ import { Router, type Request, type Response } from 'express';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; 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 type { EditQueue } from '../queue/edit-queue.js';
import { getProposal, updateProposalStatus } from '../db.js'; import { getProposal, updateProposalStatus } from '../db.js';
import { buildSectionManifest } from '../queue/manifest.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'; import { logger } from '../logger.js';
const REPO_ROOT = process.env.REPO_ROOT || '.'; 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 // GET /api/site-context
router.get('/site-context', (_req: Request, res: Response) => { router.get('/site-context', (_req: Request, res: Response) => {
try { 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) => { router.post('/edit', async (req: Request, res: Response) => {
const parsed = editRequestSchema.safeParse(req.body); const parsed = editRequestSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
@@ -100,7 +139,12 @@ export function createApiEditRouter(deps: ApiEditRouterDeps): Router {
return; 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(); const jobId = crypto.randomUUID();
try { try {
deps.queue.enqueue({ deps.queue.enqueue({
@@ -137,10 +181,34 @@ export function createApiEditRouter(deps: ApiEditRouterDeps): Router {
return; return;
} }
// Use the write-content module (imported dynamically to avoid circular deps) try {
import('../io/write-content.js').then(({ writeContentFile }) => {
writeContentFile(relPath, parsed.data, { source: 'editor' }); writeContentFile(relPath, parsed.data, { source: 'editor' });
res.status(201).json({ status: 'created', path: relPath }); 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 { Router, type Request, type Response } from 'express';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { verifySignature } from '@vonage/jwt';
import type { EditQueue } from '../queue/edit-queue.js'; 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 { sendSms } from '../sms/reply.js';
import { SMS_TEMPLATES } from '../sms/templates.js'; import { SMS_TEMPLATES } from '../sms/templates.js';
import { isOwnNumber, findAuthorizedSite } from '../sms/config.js'; import { isOwnNumber, findAuthorizedSite } from '../sms/config.js';
import { claimOnce, checkSmsRateLimit, hashPhone, getPendingProposalByPhone, updateProposalStatus } from '../db.js'; import { claimOnce, checkSmsRateLimit, hashPhone, getPendingProposalByPhone, updateProposalStatus } from '../db.js';
import { logger, maskPhone } from '../logger.js'; import { logger, maskPhone } from '../logger.js';
const VONAGE_API_SIGNATURE_SECRET = process.env.VONAGE_API_SIGNATURE_SECRET || '';
export interface WebhookSmsRouterDeps { export interface WebhookSmsRouterDeps {
queue: EditQueue; 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 { export function createWebhookSmsRouter(deps: WebhookSmsRouterDeps): Router {
const router = Router(); const router = Router();
router.post('/telnyx', (req: Request, res: Response) => { // Vonage inbound message webhook
// Respond quickly 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' }); res.status(200).json({ status: 'received' });
// Process async // 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; return router;
} }
async function handleInbound(body: unknown, deps: WebhookSmsRouterDeps) { async function handleInbound(body: unknown, deps: WebhookSmsRouterDeps) {
const parsed = parseTelnyxInboundMessage(body); const parsed = parseVonageInboundMessage(body);
if (!parsed) { if (!parsed) {
logger.warn({ event: 'sms.parse_failed' }, 'Failed to parse inbound SMS'); logger.warn({ event: 'sms.parse_failed' }, 'Failed to parse inbound SMS');
return; return;

View File

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

View File

@@ -7,24 +7,72 @@ export interface ParsedInboundSms {
mediaUrls: string[]; 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 { try {
const data = body as Record<string, unknown>; 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 from = (data.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 to = (data.to as string) || '';
const text = (payload.text as string) || (payload.body as string) || ''; const text = (data.text as string) || '';
const messageId = (payload.id as string) || (eventData.id 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 }>) || []; // Collect media URLs from MMS messages
const mediaUrls = media.map(m => m.url).filter(Boolean); 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 { } catch {
return null; 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'; 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> { export async function sendSms(to: string, from: string, body: string): Promise<void> {
if (!TELNYX_API_KEY) { const client = getVonageClient();
logger.warn({ event: 'sms.send_skipped', to: maskPhone(to) }, 'No TELNYX_API_KEY, skipping SMS send');
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)'); logger.info({ event: 'sms.would_send', body }, 'SMS body (dev mode)');
return; return;
} }
try { // Strip leading '+' — Vonage expects bare numbers
const resp = await fetch('https://api.telnyx.com/v2/messages', { const toNumber = to.replace(/^\+/, '');
method: 'POST', const fromNumber = from.replace(/^\+/, '');
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TELNYX_API_KEY}`,
},
body: JSON.stringify({ from, to, text: body }),
});
if (!resp.ok) { try {
logger.error({ event: 'sms.send_failed', status: resp.status }, 'Failed to send SMS'); await client.messages.send(
} else { new SMS({ to: toNumber, from: fromNumber, text: body })
logger.info({ event: 'sms.sent', to: maskPhone(to) }, 'SMS sent'); );
}
logger.info({ event: 'sms.sent', to: maskPhone(to) }, 'SMS sent');
} catch (err) { } 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 = { 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.`, `Proposed change: ${summary}\n\nReply YES to apply or NO to cancel.`,
APPLIED: (summary: string) => APPLIED: (summary: string) =>

View File

@@ -109,8 +109,10 @@ export const smsSitesConfigSchema = z.object({
export type SmsSitesConfig = z.infer<typeof smsSitesConfigSchema>; export type SmsSitesConfig = z.infer<typeof smsSitesConfigSchema>;
// ── Edit Request ── // ── Edit Request ──
// message is required for propose requests but optional for confirm requests.
// The handler enforces that message is non-empty when confirm is absent.
export const editRequestSchema = z.object({ export const editRequestSchema = z.object({
message: z.string().min(1), message: z.string().default(''),
repo_relative_path: z.string().optional(), repo_relative_path: z.string().optional(),
proposal_id: z.string().optional(), proposal_id: z.string().optional(),
confirm: z.enum(['yes', 'no']).optional(), confirm: z.enum(['yes', 'no']).optional(),

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
interface ManifestEntry { interface ManifestEntry {
id: string; id: string;
@@ -28,28 +28,39 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
const [proposalId, setProposalId] = useState<string | null>(null); const [proposalId, setProposalId] = useState<string | null>(null);
const [proposalSummary, setProposalSummary] = useState<string>(''); const [proposalSummary, setProposalSummary] = useState<string>('');
const headers = { // Keep a ref to the polling interval so we can clean it up
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const headers = useRef({
'Authorization': `Bearer ${apiSecret}`, 'Authorization': `Bearer ${apiSecret}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; });
// Clean up polling interval on unmount
useEffect(() => {
return () => {
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
};
}, []);
const fetchManifest = useCallback(async () => { const fetchManifest = useCallback(async () => {
try { try {
const res = await fetch(`${orchestratorUrl}/api/manifest`, { headers }); const res = await fetch(`${orchestratorUrl}/api/manifest`, { headers: headers.current });
const data = await res.json(); const data = await res.json();
setSections(data.sections || []); setSections(data.sections || []);
} catch (err) { } catch {
setStatus('Failed to load sections'); setStatus('Failed to load sections');
} }
}, [orchestratorUrl, apiSecret]); }, [orchestratorUrl]);
useEffect(() => { fetchManifest(); }, [fetchManifest]); useEffect(() => { fetchManifest(); }, [fetchManifest]);
const loadSection = async (entry: ManifestEntry) => { const loadSection = async (entry: ManifestEntry) => {
setSelectedSection(entry); setSelectedSection(entry);
setView('edit'); setView('edit');
setStatus('');
try { try {
const res = await fetch(`${orchestratorUrl}/api/section?path=${encodeURIComponent(entry.repo_relative_path)}`, { headers }); const res = await fetch(`${orchestratorUrl}/api/section?path=${encodeURIComponent(entry.repo_relative_path)}`, { headers: headers.current });
const data = await res.json(); const data = await res.json();
setSectionJson(JSON.stringify(data, null, 2)); setSectionJson(JSON.stringify(data, null, 2));
} catch { } catch {
@@ -60,21 +71,32 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
const saveSection = async () => { const saveSection = async () => {
if (!selectedSection) return; if (!selectedSection) return;
setLoading(true); setLoading(true);
setStatus('Saving...'); setStatus('Validating & saving...');
try { try {
const parsed = JSON.parse(sectionJson); const parsed = JSON.parse(sectionJson);
const res = await fetch(`${orchestratorUrl}/api/edit`, {
method: 'POST', // Write the JSON directly via PUT /api/section
headers, const res = await fetch(`${orchestratorUrl}/api/section`, {
method: 'PUT',
headers: headers.current,
body: JSON.stringify({ body: JSON.stringify({
message: `Direct JSON update to ${selectedSection.repo_relative_path}`, path: selectedSection.repo_relative_path,
repo_relative_path: selectedSection.repo_relative_path, data: parsed,
}), }),
}); });
const data = await res.json(); const result = await res.json();
setStatus(data.status === 'processing' ? 'Edit submitted — processing...' : `Status: ${data.status}`);
if (res.ok) {
setStatus('Saved successfully.');
fetchManifest();
} else {
const details = result.details
? result.details.map((d: { path: string[]; message: string }) => `${d.path.join('.')}: ${d.message}`).join(', ')
: result.error;
setStatus(`Save failed: ${details}`);
}
} catch (err) { } catch (err) {
setStatus('Save failed'); setStatus(`Invalid JSON: ${(err as Error).message}`);
} }
setLoading(false); setLoading(false);
}; };
@@ -85,16 +107,22 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
setStatus('Sending to AI...'); setStatus('Sending to AI...');
setProposalId(null); setProposalId(null);
setProposalSummary(''); setProposalSummary('');
// Clear any existing poll
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
try { try {
const res = await fetch(`${orchestratorUrl}/api/edit`, { const res = await fetch(`${orchestratorUrl}/api/edit`, {
method: 'POST', method: 'POST',
headers, headers: headers.current,
body: JSON.stringify({ message: nlMessage }), body: JSON.stringify({ message: nlMessage }),
}); });
const data = await res.json(); const data = await res.json();
if (data.job_id) { if (data.job_id) {
setStatus('Processing... checking for proposal.'); setStatus('Processing... waiting for AI proposal.');
// Poll for proposal
pollForProposal(data.job_id); pollForProposal(data.job_id);
} else { } else {
setStatus(`Response: ${JSON.stringify(data)}`); setStatus(`Response: ${JSON.stringify(data)}`);
@@ -105,22 +133,36 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
setLoading(false); setLoading(false);
}; };
const pollForProposal = async (jobId: string) => { const pollForProposal = (jobId: string) => {
// Simple poll: check recent proposals. In production, use SSE or websockets.
let attempts = 0; let attempts = 0;
const interval = setInterval(async () => {
pollIntervalRef.current = setInterval(async () => {
attempts++; attempts++;
if (attempts > 30) { if (attempts > 30) {
clearInterval(interval); if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
setStatus('Timed out waiting for proposal. The AI may still be processing.'); setStatus('Timed out waiting for proposal. The AI may still be processing.');
return; return;
} }
try { try {
// Re-fetch manifest to see if anything changed, or check proposal endpoint const res = await fetch(`${orchestratorUrl}/api/job/${jobId}`, { headers: headers.current });
const res = await fetch(`${orchestratorUrl}/api/manifest`, { headers });
const data = await res.json(); const data = await res.json();
setSections(data.sections || []);
} catch { /* ignore */ } if (data.status === 'pending' && data.proposal_id) {
// Proposal is ready — show confirm UI
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
setProposalId(data.proposal_id);
setProposalSummary(data.summary || 'Change proposed.');
setStatus('');
} else if (data.status === 'applied' || data.status === 'rejected' || data.status === 'expired') {
// Already resolved
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
setStatus(`Proposal was ${data.status}.`);
}
// status === 'processing' → keep polling
} catch { /* ignore network errors during polling */ }
}, 2000); }, 2000);
}; };
@@ -130,13 +172,14 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
try { try {
const res = await fetch(`${orchestratorUrl}/api/edit`, { const res = await fetch(`${orchestratorUrl}/api/edit`, {
method: 'POST', method: 'POST',
headers, headers: headers.current,
body: JSON.stringify({ message: '', confirm, proposal_id: proposalId }), body: JSON.stringify({ confirm, proposal_id: proposalId }),
}); });
const data = await res.json(); await res.json();
setStatus(confirm === 'yes' ? 'Applied! Refreshing...' : 'Cancelled.'); setStatus(confirm === 'yes' ? 'Applied! Refreshing...' : 'Cancelled.');
setProposalId(null); setProposalId(null);
setProposalSummary(''); setProposalSummary('');
setNlMessage('');
if (confirm === 'yes') { if (confirm === 'yes') {
setTimeout(() => { fetchManifest(); }, 1000); setTimeout(() => { fetchManifest(); }, 1000);
} }
@@ -232,8 +275,10 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
onChange={e => setSectionJson(e.target.value)} onChange={e => setSectionJson(e.target.value)}
/> />
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem' }}> <div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem' }}>
<button style={styles.btn} onClick={saveSection} disabled={loading}>Save</button> <button style={styles.btn} onClick={saveSection} disabled={loading}>
<button style={styles.btnOutline} onClick={() => { setView('sections'); setSelectedSection(null); }}>Back</button> {loading ? 'Saving...' : 'Save'}
</button>
<button style={styles.btnOutline} onClick={() => { setView('sections'); setSelectedSection(null); setStatus(''); }}>Back</button>
</div> </div>
</div> </div>
)} )}
@@ -243,15 +288,15 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
<div> <div>
<h3 style={styles.subhead}>Describe Your Edit</h3> <h3 style={styles.subhead}>Describe Your Edit</h3>
<p style={{ fontSize: '0.9rem', color: '#6b6b6b', marginBottom: '1rem' }}> <p style={{ fontSize: '0.9rem', color: '#6b6b6b', marginBottom: '1rem' }}>
Tell the AI what you want to change. For example: "Update the hero headline to say Grand Opening This Weekend" Tell the AI what you want to change. For example: &ldquo;Update the hero headline to say Grand Opening This Weekend&rdquo;
or "Hide the promo banner" or "Add a new event on May 15th". or &ldquo;Hide the promo banner&rdquo; or &ldquo;Add a new event on May 15th&rdquo;.
</p> </p>
<input <input
style={styles.input} style={styles.input}
placeholder="Describe what you want to change..." placeholder="Describe what you want to change..."
value={nlMessage} value={nlMessage}
onChange={e => setNlMessage(e.target.value)} onChange={e => setNlMessage(e.target.value)}
onKeyDown={e => e.key === 'Enter' && submitNlEdit()} onKeyDown={e => e.key === 'Enter' && !loading && submitNlEdit()}
/> />
<button style={styles.btn} onClick={submitNlEdit} disabled={loading || !nlMessage.trim()}> <button style={styles.btn} onClick={submitNlEdit} disabled={loading || !nlMessage.trim()}>
{loading ? 'Processing...' : 'Submit Edit'} {loading ? 'Processing...' : 'Submit Edit'}
@@ -262,8 +307,8 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
<strong>Proposed change:</strong> <strong>Proposed change:</strong>
<p style={{ margin: '0.5rem 0' }}>{proposalSummary}</p> <p style={{ margin: '0.5rem 0' }}>{proposalSummary}</p>
<div style={{ display: 'flex', gap: '0.5rem' }}> <div style={{ display: 'flex', gap: '0.5rem' }}>
<button style={styles.btn} onClick={() => confirmProposal('yes')}>Yes, Apply</button> <button style={styles.btn} onClick={() => confirmProposal('yes')} disabled={loading}>Yes, Apply</button>
<button style={styles.btnDanger} onClick={() => confirmProposal('no')}>No, Cancel</button> <button style={styles.btnDanger} onClick={() => confirmProposal('no')} disabled={loading}>No, Cancel</button>
</div> </div>
</div> </div>
)} )}
@@ -271,7 +316,7 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
)} )}
{/* ── Create Section ── */} {/* ── Create Section ── */}
{view === 'create' && <CreateSection orchestratorUrl={orchestratorUrl} headers={headers} onCreated={() => { setView('sections'); fetchManifest(); }} />} {view === 'create' && <CreateSection orchestratorUrl={orchestratorUrl} headers={headers.current} onCreated={() => { setView('sections'); fetchManifest(); }} />}
{status && <p style={styles.status}>{status}</p>} {status && <p style={styles.status}>{status}</p>}
</div> </div>

View File

@@ -3,37 +3,63 @@ import path from 'node:path';
import { parseSiteBundle, type SiteBundle } from './site-bundle.ts'; import { parseSiteBundle, type SiteBundle } from './site-bundle.ts';
const REPO_ROOT = process.env.REPO_ROOT || '.'; const REPO_ROOT = process.env.REPO_ROOT || '.';
const TTL = parseInt(process.env.SITE_DATA_TTL_MS || '2000', 10); const TTL = parseInt(process.env.SITE_DATA_TTL_MS || '500', 10);
let cached: { data: SiteBundle; loadedAt: number } | null = null; let cached: { data: SiteBundle; loadedAt: number } | null = null;
/** Fallback bundle used when content files are missing or unreadable. */
function fallbackBundle(): SiteBundle {
return {
siteContext: {
businessName: 'Site',
tone: 'professional and friendly',
},
sections: [],
events: { events: [] },
};
}
export function loadSiteData(): SiteBundle { export function loadSiteData(): SiteBundle {
const now = Date.now(); const now = Date.now();
if (cached && now - cached.loadedAt < TTL) { if (cached && now - cached.loadedAt < TTL) {
return cached.data; return cached.data;
} }
const siteContextRaw = JSON.parse( try {
fs.readFileSync(path.join(REPO_ROOT, 'site-context.json'), 'utf-8') const siteContextRaw = JSON.parse(
); fs.readFileSync(path.join(REPO_ROOT, 'site-context.json'), 'utf-8')
);
const eventsRaw = JSON.parse( let eventsRaw: unknown = { events: [] };
fs.readFileSync(path.join(REPO_ROOT, 'content/events.json'), 'utf-8') const eventsPath = path.join(REPO_ROOT, 'content/events.json');
); if (fs.existsSync(eventsPath)) {
const sectionsDir = path.join(REPO_ROOT, 'content/sections');
const sectionRaws: unknown[] = [];
if (fs.existsSync(sectionsDir)) {
for (const file of fs.readdirSync(sectionsDir).filter(f => f.endsWith('.json'))) {
try { try {
sectionRaws.push(JSON.parse(fs.readFileSync(path.join(sectionsDir, file), 'utf-8'))); eventsRaw = JSON.parse(fs.readFileSync(eventsPath, 'utf-8'));
} catch { } catch {
// skip invalid files // Use empty events if file is corrupt
} }
} }
}
const data = parseSiteBundle(siteContextRaw, eventsRaw, sectionRaws); const sectionsDir = path.join(REPO_ROOT, 'content/sections');
cached = { data, loadedAt: now }; const sectionRaws: unknown[] = [];
return data; if (fs.existsSync(sectionsDir)) {
for (const file of fs.readdirSync(sectionsDir).filter(f => f.endsWith('.json'))) {
try {
sectionRaws.push(JSON.parse(fs.readFileSync(path.join(sectionsDir, file), 'utf-8')));
} catch {
// skip invalid files
}
}
}
const data = parseSiteBundle(siteContextRaw, eventsRaw, sectionRaws);
cached = { data, loadedAt: now };
return data;
} catch {
// If site-context.json is missing or corrupt, return a minimal fallback
// so the SSR server doesn't crash on every request.
const fb = fallbackBundle();
cached = { data: fb, loadedAt: now };
return fb;
}
} }