From 4ee4cb8e7cdce823f9a870d6dc32fdc797961187 Mon Sep 17 00:00:00 2001 From: kadil Date: Fri, 17 Apr 2026 16:08:31 -0500 Subject: [PATCH] First cut --- .dockerignore | 12 + .env.example | 32 ++ .gitignore | 10 + AGENTS.md | 24 ++ Dockerfile | 33 ++ README.md | 144 +++++++- astro.config.mjs | 10 + config/sms-sites.json | 10 + content/events.json | 28 ++ content/sections/about.json | 8 + content/sections/features.json | 29 ++ content/sections/hero.json | 11 + content/sections/promo-banner.json | 8 + content/sections/testimonials.json | 24 ++ content/sections/text.json | 8 + docker-compose.yml | 52 +++ package.json | 36 ++ scripts/canonicalize.js | 26 ++ scripts/check-canonical.js | 41 +++ scripts/validate-content.js | 59 +++ server/Dockerfile | 26 ++ server/package.json | 28 ++ server/src/app.ts | 55 +++ server/src/db.ts | 161 ++++++++ server/src/index.ts | 61 ++++ server/src/io/write-content.ts | 68 ++++ server/src/llm/client.ts | 172 +++++++++ server/src/logger.ts | 18 + server/src/queue/edit-queue.ts | 83 +++++ server/src/queue/manifest.ts | 55 +++ server/src/queue/process-edit-job.ts | 178 +++++++++ server/src/routes/api-edit.ts | 165 +++++++++ server/src/routes/health.ts | 9 + server/src/routes/webhook-sms.ts | 115 ++++++ server/src/sms/parse.ts | 30 ++ server/src/sms/reply.ts | 30 ++ server/src/sms/templates.ts | 34 ++ server/tsconfig.json | 14 + shared/package.json | 14 + shared/src/canonical-json.ts | 20 + shared/src/index.ts | 7 + shared/src/repo-validation.ts | 42 +++ shared/src/schemas/index.ts | 152 ++++++++ site-context.json | 11 + src/components/editor/VisualEditorIsland.tsx | 343 ++++++++++++++++++ src/components/sections/AboutSection.astro | 29 ++ src/components/sections/EventsList.astro | 112 ++++++ src/components/sections/FeaturesSection.astro | 57 +++ src/components/sections/HeroSection.astro | 61 ++++ .../sections/TestimonialsSection.astro | 65 ++++ src/components/sections/TextSection.astro | 41 +++ src/layouts/BaseLayout.astro | 103 ++++++ src/lib/site-bundle.ts | 35 ++ src/lib/site-data.ts | 39 ++ src/pages/editor.astro | 134 +++++++ src/pages/index.astro | 43 +++ tsconfig.base.json | 17 + tsconfig.json | 12 + 58 files changed, 3243 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Dockerfile create mode 100644 astro.config.mjs create mode 100644 config/sms-sites.json create mode 100644 content/events.json create mode 100644 content/sections/about.json create mode 100644 content/sections/features.json create mode 100644 content/sections/hero.json create mode 100644 content/sections/promo-banner.json create mode 100644 content/sections/testimonials.json create mode 100644 content/sections/text.json create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 scripts/canonicalize.js create mode 100644 scripts/check-canonical.js create mode 100644 scripts/validate-content.js create mode 100644 server/Dockerfile create mode 100644 server/package.json create mode 100644 server/src/app.ts create mode 100644 server/src/db.ts create mode 100644 server/src/index.ts create mode 100644 server/src/io/write-content.ts create mode 100644 server/src/llm/client.ts create mode 100644 server/src/logger.ts create mode 100644 server/src/queue/edit-queue.ts create mode 100644 server/src/queue/manifest.ts create mode 100644 server/src/queue/process-edit-job.ts create mode 100644 server/src/routes/api-edit.ts create mode 100644 server/src/routes/health.ts create mode 100644 server/src/routes/webhook-sms.ts create mode 100644 server/src/sms/parse.ts create mode 100644 server/src/sms/reply.ts create mode 100644 server/src/sms/templates.ts create mode 100644 server/tsconfig.json create mode 100644 shared/package.json create mode 100644 shared/src/canonical-json.ts create mode 100644 shared/src/index.ts create mode 100644 shared/src/repo-validation.ts create mode 100644 shared/src/schemas/index.ts create mode 100644 site-context.json create mode 100644 src/components/editor/VisualEditorIsland.tsx create mode 100644 src/components/sections/AboutSection.astro create mode 100644 src/components/sections/EventsList.astro create mode 100644 src/components/sections/FeaturesSection.astro create mode 100644 src/components/sections/HeroSection.astro create mode 100644 src/components/sections/TestimonialsSection.astro create mode 100644 src/components/sections/TextSection.astro create mode 100644 src/layouts/BaseLayout.astro create mode 100644 src/lib/site-bundle.ts create mode 100644 src/lib/site-data.ts create mode 100644 src/pages/editor.astro create mode 100644 src/pages/index.astro create mode 100644 tsconfig.base.json create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..872bb0d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +dist +server/dist +server/node_modules +shared/node_modules +data +*.db +*.db-wal +*.db-shm +.env +.git +content/.backups diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..618d130 --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# Required +API_EDIT_SECRET=change-me-to-a-random-string + +# LLM (required to actually process edits) +OLLAMA_API_KEY= + +# Paths +REPO_ROOT=. +IDEMPOTENCY_DB_PATH=./data/dynamic-sites.db + +# SSR cache +SITE_DATA_TTL_MS=500 + +# SMS (Telnyx) +TELNYX_PUBLIC_KEY= +TELNYX_API_KEY= + +# CORS +CORS_ALLOWED_ORIGIN=http://localhost:4321 + +# Logging +LOG_LEVEL=debug + +# Proposals +PROPOSAL_TTL_MS=900000 + +# Editor auth +EDITOR_SESSION_SECRET=change-me-to-another-random-string + +# Rate limits +SMS_RATE_LIMIT_PER_HOUR=10 +MAX_UPLOAD_SIZE_BYTES=5242880 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be94537 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +server/dist/ +data/ +*.db +*.db-wal +*.db-shm +.env +content/.backups/ +.astro/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..faa201d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# AGENTS.md — Rules for AI Agents Working in This Repo + +## Core Principles + +1. **Zod schemas are the contract.** Every content file, API payload, and LLM output is validated by Zod. Never write data that hasn't passed schema validation. + +2. **No direct LLM calls in request handlers.** All LLM work runs in the queue consumer. Handlers enqueue and return immediately (202 Accepted). + +3. **Canonical JSON everywhere.** All files under `content/`, `config/`, and `site-context.json` must use `stringifyCanonical()` — sorted keys, 2-space indent, trailing newline. + +4. **No git in the orchestrator.** Content persistence is filesystem-only via `writeContentFile`. Deployment of images happens outside this codebase. + +5. **Human confirmation before any write.** The propose → summary → YES/NO → apply pipeline must be followed for all edit channels (SMS, HTTP, editor). + +6. **SQLite, not Redis.** Idempotency, proposals, rate limits, and audit logs all live in a single SQLite file. No BullMQ, no Redis. + +## Anti-Patterns + +- Do NOT call `fs.writeFileSync` directly on content files. Always use `writeContentFile`. +- Do NOT skip Zod validation before writing. +- Do NOT put LLM calls inside Express route handlers. +- Do NOT add git operations (commit, push, etc.) anywhere in the orchestrator. +- Do NOT use `console.log` — use the structured `logger` from `server/src/logger.ts`. +- Do NOT expose raw JSON or LLM output in SMS replies — use SMS_TEMPLATES. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1d8dfde --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM node:22-alpine AS base + +WORKDIR /app + +# Install deps +COPY package.json package-lock.json* ./ +COPY shared/package.json shared/ +COPY server/package.json server/ +RUN npm install --ignore-scripts + +# Copy source +COPY . . + +# Build +RUN npm run build + +# Production +FROM node:22-alpine AS runtime +WORKDIR /app + +COPY --from=base /app/node_modules ./node_modules +COPY --from=base /app/shared ./shared +COPY --from=base /app/dist ./dist +COPY --from=base /app/package.json ./ +COPY --from=base /app/site-context.json ./ +COPY --from=base /app/content ./content +COPY --from=base /app/config ./config + +ENV HOST=0.0.0.0 +ENV PORT=4321 +EXPOSE 4321 + +CMD ["node", "dist/server/entry.mjs"] diff --git a/README.md b/README.md index 20b89cc..52be375 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,144 @@ -# dynamic-sites-simple +# Dynamic Sites +An LLM-powered website editing framework. Edit your site via SMS, a web API, or a visual editor — all driven by natural language. + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ Channels │ +│ SMS (Telnyx) │ POST /api/edit │ /editor │ +└───────┬─────────┴────────┬─────────┴─────┬──────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────┐ +│ Orchestrator (Express, port 3001) │ +│ │ +│ Webhook ──► Idempotency ──► Rate Limit ──► │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ In-Process FIFO Queue (concurrency 1) │ +│ │ │ │ +│ │ propose: route ► LLM ► proposal │ │ +│ │ apply: validate ► writeContentFile│ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ SQLite: idempotency, proposals, rate limits, │ +│ audit log │ +└───────────────────────┬─────────────────────────┘ + │ writes canonical JSON + ▼ + ┌──────────────────┐ + │ content/ (JSON) │ ◄── shared volume + │ site-context.json│ + └────────┬─────────┘ + │ reads with TTL cache + ▼ + ┌──────────────────┐ + │ Astro SSR │ + │ (port 4321) │ + │ Homepage + Editor│ + └──────────────────┘ +``` + +## Quick Start + +### Prerequisites +- Node.js 22+ +- npm + +### Local Development + +```bash +# Clone and install +npm install + +# Start the Astro dev server (port 4321) +npm run dev + +# In another terminal, start the orchestrator (port 3001) +npm run dev:server + +# Visit http://localhost:4321 — the demo site renders from fixtures +# Visit http://localhost:4321/editor — log in with API_EDIT_SECRET +``` + +### Environment Variables + +Copy `.env.example` to `.env` and set at minimum: + +- `API_EDIT_SECRET` — shared secret for API auth and editor login +- `OLLAMA_API_KEY` — required for LLM-powered edits + +See `.env.example` for all options. + +### Docker + +```bash +docker compose build +docker compose up -d +# Site: http://localhost:4321 +# Orchestrator: http://localhost:3001/health +``` + +## Project Structure + +``` +├── content/ # Canonical JSON content (the "database") +│ ├── sections/ # One JSON file per site section +│ ├── events.json # Upcoming events +│ └── .backups/ # Pre-apply backups (auto-managed) +├── config/ +│ └── sms-sites.json # SMS routing allowlist +├── site-context.json # Brand tone, style, LLM prompt context +├── shared/ # Zod schemas + canonical JSON (workspace pkg) +│ └── src/ +│ ├── schemas/index.ts # All Zod schemas (the contract) +│ ├── canonical-json.ts # Sorted-key JSON serialization +│ └── repo-validation.ts# Path → schema mapping +├── server/ # Orchestrator (workspace pkg) +│ └── src/ +│ ├── index.ts # Entrypoint + graceful shutdown +│ ├── app.ts # Express app factory +│ ├── db.ts # SQLite (idempotency, proposals, audit) +│ ├── logger.ts # Structured logging (pino) +│ ├── queue/ # FIFO queue + job processor +│ ├── routes/ # API edit, SMS webhook, health +│ ├── llm/ # Ollama client with retry/validation +│ ├── sms/ # Telnyx parse, reply, templates +│ └── io/ # Filesystem writer (atomic, with backup) +├── src/ # Astro SSR site +│ ├── pages/ +│ │ ├── index.astro # Homepage (renders from content/) +│ │ └── editor.astro # Editor (auth-gated React island) +│ ├── lib/ +│ │ ├── site-bundle.ts # Content parser + validator +│ │ └── site-data.ts # Disk reader with TTL cache +│ ├── layouts/ +│ │ └── BaseLayout.astro +│ └── components/ +│ ├── sections/ # Astro section components +│ └── editor/ # React editor island +├── scripts/ # CLI tools +├── docker-compose.yml # Full stack (web + orchestrator) +├── Dockerfile # SSR site image +└── server/Dockerfile # Orchestrator image +``` + +## Edit Flow + +1. **User sends a natural language message** (SMS, HTTP, or editor) +2. **Route**: LLM determines which content file to edit +3. **Propose**: LLM generates new JSON + plain-language summary +4. **Confirm**: User replies YES/NO (SMS) or clicks confirm (editor/HTTP) +5. **Apply**: Validated JSON is written to disk via atomic write +6. **Live**: Astro SSR picks up the change on next request (TTL cache) + +## Key Design Decisions + +- **No Redis, no BullMQ**: Simple in-process FIFO queue with concurrency 1 +- **No git**: Content persistence is filesystem-only +- **SQLite for everything**: Idempotency, proposals, rate limits, audit log +- **Zod is the contract**: Schemas drive validation at every boundary +- **Atomic writes**: temp file + rename prevents partial writes +- **Pre-write backups**: Last 20 versions per file under `content/.backups/` diff --git a/astro.config.mjs b/astro.config.mjs new file mode 100644 index 0000000..d2d34e3 --- /dev/null +++ b/astro.config.mjs @@ -0,0 +1,10 @@ +import { defineConfig } from 'astro/config'; +import node from '@astrojs/node'; +import react from '@astrojs/react'; + +export default defineConfig({ + output: 'server', + adapter: node({ mode: 'standalone' }), + integrations: [react()], + server: { port: 4321 }, +}); diff --git a/config/sms-sites.json b/config/sms-sites.json new file mode 100644 index 0000000..6f39515 --- /dev/null +++ b/config/sms-sites.json @@ -0,0 +1,10 @@ +{ + "sites": [ + { + "allowedSenders": ["+15035550100"], + "phoneNumber": "+15035550142", + "repoRoot": ".", + "siteId": "timber-and-grain" + } + ] +} diff --git a/content/events.json b/content/events.json new file mode 100644 index 0000000..47042db --- /dev/null +++ b/content/events.json @@ -0,0 +1,28 @@ +{ + "events": [ + { + "date": "2026-04-25", + "description": "Taste three new single-origin roasts and vote for our next house blend. Free with any purchase.", + "id": "cupping-apr", + "location": "Timber & Grain Cafe", + "time": "6:00 PM", + "title": "Friday Cupping Session" + }, + { + "date": "2026-05-03", + "description": "Learn the basics of latte art from our head barista, Sam. Includes materials and two drinks. $25/person.", + "id": "latte-art-may", + "location": "Timber & Grain Workshop", + "time": "10:00 AM", + "title": "Latte Art 101 Workshop" + }, + { + "date": "2026-05-10", + "description": "Local acoustic acts every second Saturday. Grab a cold brew and enjoy the vibes.", + "id": "live-music-may", + "location": "Timber & Grain Patio", + "time": "7:00 PM", + "title": "Live Music Night" + } + ] +} diff --git a/content/sections/about.json b/content/sections/about.json new file mode 100644 index 0000000..ac8021c --- /dev/null +++ b/content/sections/about.json @@ -0,0 +1,8 @@ +{ + "content": "Timber & Grain started in 2018 with a simple idea: great coffee doesn't need to be complicated. We source beans directly from farmers in Ethiopia, Colombia, and Guatemala, roast them in our Portland workshop, and serve them in our cozy cafe on Evergreen Terrace. Every cup tells a story — from the farm to your hands.", + "id": "about", + "order": 2, + "title": "Our Story", + "type": "about", + "visible": true +} diff --git a/content/sections/features.json b/content/sections/features.json new file mode 100644 index 0000000..1fb7831 --- /dev/null +++ b/content/sections/features.json @@ -0,0 +1,29 @@ +{ + "id": "features", + "items": [ + { + "description": "We work directly with farmers in Ethiopia, Colombia, and Guatemala to bring you the freshest, most flavorful beans.", + "icon": "coffee", + "title": "Single-Origin Beans" + }, + { + "description": "Every batch is roasted by hand in our Portland workshop. We roast weekly to ensure peak freshness.", + "icon": "flame", + "title": "Small-Batch Roasting" + }, + { + "description": "From pour-over to espresso, our baristas craft each cup with precision and care.", + "icon": "cup", + "title": "Expert Brewing" + }, + { + "description": "Join us for cupping sessions, latte art workshops, and live music nights. This is your neighborhood spot.", + "icon": "calendar", + "title": "Community Events" + } + ], + "order": 3, + "title": "What We Offer", + "type": "features", + "visible": true +} diff --git a/content/sections/hero.json b/content/sections/hero.json new file mode 100644 index 0000000..57b3fb3 --- /dev/null +++ b/content/sections/hero.json @@ -0,0 +1,11 @@ +{ + "ctaLink": "/events", + "ctaText": "See What's Brewing", + "headline": "Coffee Worth Waking Up For", + "id": "hero", + "image": "", + "order": 1, + "subheading": "Single-origin beans, roasted in small batches right here in Portland. Stop by for a pour-over, stay for the community.", + "type": "hero", + "visible": true +} diff --git a/content/sections/promo-banner.json b/content/sections/promo-banner.json new file mode 100644 index 0000000..8c74a30 --- /dev/null +++ b/content/sections/promo-banner.json @@ -0,0 +1,8 @@ +{ + "content": "For a limited time, enjoy 20% off all whole-bean bags. Use code FRESHBREW at checkout or mention this promo in-store. Valid through the end of the month!", + "heading": "Spring Roast Sale — 20% Off Beans!", + "id": "promo-banner", + "order": 0, + "type": "text", + "visible": false +} diff --git a/content/sections/testimonials.json b/content/sections/testimonials.json new file mode 100644 index 0000000..7402ccd --- /dev/null +++ b/content/sections/testimonials.json @@ -0,0 +1,24 @@ +{ + "id": "testimonials", + "items": [ + { + "author": "Maria Chen", + "quote": "Best pour-over in Portland, hands down. The Ethiopian Yirgacheffe changed my morning routine forever.", + "role": "Regular since 2019" + }, + { + "author": "Jake Thornton", + "quote": "I came for the coffee and stayed for the people. Timber & Grain feels like home.", + "role": "Neighborhood local" + }, + { + "author": "Priya Sharma", + "quote": "Their cupping events are incredible — I've learned more about coffee here than anywhere else.", + "role": "Home roasting enthusiast" + } + ], + "order": 4, + "title": "What Our Regulars Say", + "type": "testimonials", + "visible": true +} diff --git a/content/sections/text.json b/content/sections/text.json new file mode 100644 index 0000000..880ec90 --- /dev/null +++ b/content/sections/text.json @@ -0,0 +1,8 @@ +{ + "content": "Visit us at 742 Evergreen Terrace, Portland, OR 97201. Open Monday through Saturday, 6:30 AM to 6 PM. Sunday 8 AM to 4 PM. Questions? Drop us a line at hello@timberandgrain.co or call (503) 555-0142.", + "heading": "Find Us", + "id": "contact", + "order": 6, + "type": "text", + "visible": true +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..423a20d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +services: + web: + build: + context: . + dockerfile: Dockerfile + ports: + - "4321:4321" + volumes: + - content-data:/app/content + - ./site-context.json:/app/site-context.json + environment: + - HOST=0.0.0.0 + - PORT=4321 + - REPO_ROOT=/app + - SITE_DATA_TTL_MS=${SITE_DATA_TTL_MS:-500} + - EDITOR_SESSION_SECRET=${EDITOR_SESSION_SECRET:-change-me} + - API_EDIT_SECRET=${API_EDIT_SECRET:-change-me} + - PUBLIC_ORCHESTRATOR_URL=http://localhost:3001 + depends_on: + - orchestrator + restart: unless-stopped + + orchestrator: + build: + context: . + dockerfile: server/Dockerfile + ports: + - "3001:3001" + volumes: + - content-data:/app/content + - ./site-context.json:/app/site-context.json + - sqlite-data:/app/data + environment: + - ORCHESTRATOR_PORT=3001 + - REPO_ROOT=/app + - IDEMPOTENCY_DB_PATH=/app/data/dynamic-sites.db + - SITE_DATA_TTL_MS=${SITE_DATA_TTL_MS:-500} + - API_EDIT_SECRET=${API_EDIT_SECRET:-change-me} + - OLLAMA_API_KEY=${OLLAMA_API_KEY:-} + - OLLAMA_HOST=${OLLAMA_HOST:-https://ollama.com} + - TELNYX_API_KEY=${TELNYX_API_KEY:-} + - TELNYX_PUBLIC_KEY=${TELNYX_PUBLIC_KEY:-} + - CORS_ALLOWED_ORIGIN=http://localhost:4321 + - LOG_LEVEL=${LOG_LEVEL:-info} + - PROPOSAL_TTL_MS=${PROPOSAL_TTL_MS:-900000} + - SMS_RATE_LIMIT_PER_HOUR=${SMS_RATE_LIMIT_PER_HOUR:-10} + stop_grace_period: 35s + restart: unless-stopped + +volumes: + content-data: + sqlite-data: diff --git a/package.json b/package.json new file mode 100644 index 0000000..e907fd8 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "dynamic-sites", + "type": "module", + "private": true, + "engines": { + "node": ">=22" + }, + "workspaces": [ + "shared", + "server" + ], + "scripts": { + "dev": "astro dev", + "dev:server": "npm run dev --workspace=server", + "build": "npm run check:content && astro build", + "start": "node dist/server/entry.mjs", + "check:content": "node scripts/validate-content.js && node scripts/check-canonical.js", + "test": "vitest run", + "check": "npm run check:content && npm test" + }, + "dependencies": { + "astro": "^5.8.0", + "@astrojs/node": "^9.1.3", + "@astrojs/react": "^4.2.1", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "zod": "^3.24.0", + "@dynamic-sites/shared": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "typescript": "^5.8.0", + "vitest": "^3.1.0" + } +} diff --git a/scripts/canonicalize.js b/scripts/canonicalize.js new file mode 100644 index 0000000..2140e02 --- /dev/null +++ b/scripts/canonicalize.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); + +const { stringifyCanonical } = await import(path.join(root, 'shared/src/canonical-json.ts')); + +const files = [ + 'site-context.json', + 'content/events.json', + 'config/sms-sites.json', + ...fs.readdirSync(path.join(root, 'content/sections')) + .filter(f => f.endsWith('.json')) + .map(f => `content/sections/${f}`), +]; + +for (const rel of files) { + const abs = path.join(root, rel); + const parsed = JSON.parse(fs.readFileSync(abs, 'utf-8')); + fs.writeFileSync(abs, stringifyCanonical(parsed)); + console.log(` ✓ ${rel}`); +} +console.log('\nDone. All files canonicalized.'); diff --git a/scripts/check-canonical.js b/scripts/check-canonical.js new file mode 100644 index 0000000..d6fc080 --- /dev/null +++ b/scripts/check-canonical.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); + +const { stringifyCanonical } = await import(path.join(root, 'shared/src/canonical-json.ts')); + +const files = [ + 'site-context.json', + 'content/events.json', + 'config/sms-sites.json', + ...fs.readdirSync(path.join(root, 'content/sections')) + .filter(f => f.endsWith('.json')) + .map(f => `content/sections/${f}`), +]; + +const errors = []; +console.log('Checking canonical JSON format...'); + +for (const rel of files) { + const abs = path.join(root, rel); + const raw = fs.readFileSync(abs, 'utf-8'); + const parsed = JSON.parse(raw); + const canonical = stringifyCanonical(parsed); + if (raw === canonical) { + console.log(` ✓ ${rel}`); + } else { + errors.push(rel); + } +} + +if (errors.length > 0) { + console.error('\n✗ Non-canonical files (run `node scripts/canonicalize.js` to fix):'); + errors.forEach(e => console.error(` ${e}`)); + process.exit(1); +} else { + console.log('\n✓ All files are canonical.'); +} diff --git a/scripts/validate-content.js b/scripts/validate-content.js new file mode 100644 index 0000000..9eea9ea --- /dev/null +++ b/scripts/validate-content.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); + +// Dynamic import of shared schemas +const { + siteContextSchema, + sectionFileSchema, + eventsFileSchema, + smsSitesConfigSchema, +} = await import(path.join(root, 'shared/src/schemas/index.ts')); + +const errors = []; + +function validate(filePath, schema, label) { + const rel = path.relative(root, filePath); + try { + const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + const result = schema.safeParse(raw); + if (!result.success) { + errors.push(`${rel}: ${result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ')}`); + } else { + console.log(` ✓ ${rel}`); + } + } catch (e) { + errors.push(`${rel}: ${e.message}`); + } +} + +console.log('Validating content...'); + +// site-context.json +validate(path.join(root, 'site-context.json'), siteContextSchema, 'site-context'); + +// content/events.json +validate(path.join(root, 'content/events.json'), eventsFileSchema, 'events'); + +// config/sms-sites.json +validate(path.join(root, 'config/sms-sites.json'), smsSitesConfigSchema, 'sms-sites'); + +// content/sections/*.json +const sectionsDir = path.join(root, 'content/sections'); +if (fs.existsSync(sectionsDir)) { + for (const file of fs.readdirSync(sectionsDir).filter(f => f.endsWith('.json'))) { + validate(path.join(sectionsDir, file), sectionFileSchema, file); + } +} + +if (errors.length > 0) { + console.error('\n✗ Validation failed:'); + errors.forEach(e => console.error(` ${e}`)); + process.exit(1); +} else { + console.log('\n✓ All content valid.'); +} diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..72d8d2b --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,26 @@ +FROM node:22-alpine AS base + +WORKDIR /app + +# Install deps (build from repo root so shared/ resolves) +COPY package.json package-lock.json* ./ +COPY shared/package.json shared/ +COPY server/package.json server/ +RUN npm install --ignore-scripts + +# Rebuild native modules (better-sqlite3) +RUN npm rebuild better-sqlite3 + +# Copy source +COPY shared ./shared +COPY server ./server +COPY site-context.json ./ +COPY content ./content +COPY config ./config + +WORKDIR /app/server + +ENV NODE_ENV=production +EXPOSE 3001 + +CMD ["npx", "tsx", "src/index.ts"] diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..a6da85f --- /dev/null +++ b/server/package.json @@ -0,0 +1,28 @@ +{ + "name": "@dynamic-sites/server", + "version": "1.0.0", + "type": "module", + "main": "./src/index.ts", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@dynamic-sites/shared": "workspace:*", + "better-sqlite3": "^11.8.0", + "cors": "^2.8.5", + "express": "^5.1.0", + "express-rate-limit": "^7.5.0", + "pino": "^9.6.0", + "pino-pretty": "^13.0.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.2", + "tsx": "^4.19.0", + "typescript": "^5.8.0" + } +} diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 0000000..04891f8 --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,55 @@ +import express, { type Express } from 'express'; +import cors from 'cors'; +import rateLimit from 'express-rate-limit'; +import { createHealthRouter } from './routes/health.js'; +import { createApiEditRouter } from './routes/api-edit.js'; +import { createWebhookSmsRouter } from './routes/webhook-sms.js'; +import type { EditQueue } from './queue/edit-queue.js'; + +export interface CreateAppDeps { + queue: EditQueue; +} + +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()); + + // 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'], + allowedHeaders: ['Authorization', 'Content-Type'], + credentials: true, + })); + + // Rate limiting on API edit routes + const apiLimiter = rateLimit({ + windowMs: 60_000, + max: 60, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests' }, + }); + app.use('/api/edit', apiLimiter); + + // Mount routes + app.use('/', createHealthRouter()); + app.use('/api', createApiEditRouter({ queue: deps.queue })); + app.use('/webhooks', createWebhookSmsRouter({ queue: deps.queue })); + + return app; +} diff --git a/server/src/db.ts b/server/src/db.ts new file mode 100644 index 0000000..7117add --- /dev/null +++ b/server/src/db.ts @@ -0,0 +1,161 @@ +import Database from 'better-sqlite3'; +import path from 'node:path'; +import fs from 'node:fs'; +import crypto from 'node:crypto'; + +const DB_PATH = process.env.IDEMPOTENCY_DB_PATH || process.env.DATABASE_PATH || './data/dynamic-sites.db'; + +let db: Database.Database | null = null; + +export function openDb(): Database.Database { + if (db) return db; + const dir = path.dirname(DB_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + db = new Database(DB_PATH); + db.pragma('journal_mode = WAL'); + db.pragma('busy_timeout = 5000'); + runMigrations(db); + return db; +} + +function runMigrations(d: Database.Database) { + d.exec(` + CREATE TABLE IF NOT EXISTS idempotency_keys ( + key TEXT PRIMARY KEY, + expires_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS pending_proposals ( + proposal_id TEXT PRIMARY KEY, + repo_relative_path TEXT NOT NULL, + proposed_json TEXT NOT NULL, + summary_text TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','applied','rejected','expired')), + source TEXT NOT NULL DEFAULT 'http', + phone_hash TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + expires_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS sms_rate_limits ( + phone_hash TEXT NOT NULL, + window_start INTEGER NOT NULL, + count INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (phone_hash, window_start) + ); + CREATE TABLE IF NOT EXISTS edit_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + proposal_id TEXT, + repo_relative_path TEXT NOT NULL, + before_hash TEXT, + after_hash TEXT, + applied_at INTEGER NOT NULL DEFAULT (unixepoch()), + source TEXT NOT NULL + ); + `); +} + +// ── Idempotency ── + +export function claimOnce(key: string, ttlSeconds: number = 3600): boolean { + const d = openDb(); + const expiresAt = Math.floor(Date.now() / 1000) + ttlSeconds; + const result = d.prepare('INSERT OR IGNORE INTO idempotency_keys (key, expires_at) VALUES (?, ?)').run(key, expiresAt); + return result.changes > 0; +} + +export function pruneIdempotencyKeys(): void { + const d = openDb(); + d.prepare('DELETE FROM idempotency_keys WHERE expires_at < ?').run(Math.floor(Date.now() / 1000)); +} + +// ── Proposals ── + +export interface ProposalRow { + proposal_id: string; + repo_relative_path: string; + proposed_json: string; + summary_text: string; + status: string; + source: string; + phone_hash: string | null; + created_at: number; + expires_at: number; +} + +export function createProposal(params: { + proposalId: string; + repoRelativePath: string; + proposedJson: string; + summaryText: string; + source: string; + phoneHash?: string; + ttlMs?: number; +}): void { + const d = openDb(); + const ttl = params.ttlMs || parseInt(process.env.PROPOSAL_TTL_MS || '900000', 10); + const expiresAt = Math.floor(Date.now() / 1000) + Math.floor(ttl / 1000); + + if (params.phoneHash) { + d.prepare(`UPDATE pending_proposals SET status = 'expired' WHERE phone_hash = ? AND status = 'pending'`).run(params.phoneHash); + } + + d.prepare(`INSERT INTO pending_proposals (proposal_id, repo_relative_path, proposed_json, summary_text, source, phone_hash, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)`).run( + params.proposalId, params.repoRelativePath, params.proposedJson, params.summaryText, params.source, params.phoneHash || null, expiresAt + ); +} + +export function getProposal(proposalId: string): ProposalRow | null { + return openDb().prepare('SELECT * FROM pending_proposals WHERE proposal_id = ?').get(proposalId) as ProposalRow | null; +} + +export function getPendingProposalByPhone(phoneHash: string): ProposalRow | null { + return openDb().prepare(`SELECT * FROM pending_proposals WHERE phone_hash = ? AND status = 'pending' ORDER BY created_at DESC LIMIT 1`).get(phoneHash) as ProposalRow | null; +} + +export function updateProposalStatus(proposalId: string, status: 'applied' | 'rejected' | 'expired'): void { + openDb().prepare('UPDATE pending_proposals SET status = ? WHERE proposal_id = ?').run(status, proposalId); +} + +export function pruneExpiredProposals(): void { + openDb().prepare(`UPDATE pending_proposals SET status = 'expired' WHERE expires_at < ? AND status = 'pending'`).run(Math.floor(Date.now() / 1000)); +} + +// ── Rate Limiting ── + +export function checkSmsRateLimit(phoneHash: string, maxPerHour: number = 10): boolean { + const d = openDb(); + const now = Math.floor(Date.now() / 1000); + const windowStart = now - 3600; + d.prepare('DELETE FROM sms_rate_limits WHERE window_start < ?').run(windowStart); + + const row = d.prepare('SELECT SUM(count) as total FROM sms_rate_limits WHERE phone_hash = ? AND window_start >= ?').get(phoneHash, windowStart) as { total: number | null } | undefined; + if ((row?.total || 0) >= maxPerHour) return false; + + const currentWindow = Math.floor(now / 60) * 60; + d.prepare('INSERT INTO sms_rate_limits (phone_hash, window_start, count) VALUES (?, ?, 1) ON CONFLICT(phone_hash, window_start) DO UPDATE SET count = count + 1').run(phoneHash, currentWindow); + return true; +} + +// ── Audit ── + +export function writeAuditLog(params: { + proposalId?: string; + repoRelativePath: string; + beforeHash?: string; + afterHash?: string; + source: string; +}): void { + openDb().prepare('INSERT INTO edit_audit_log (proposal_id, repo_relative_path, before_hash, after_hash, source) VALUES (?, ?, ?, ?, ?)').run( + params.proposalId || null, params.repoRelativePath, params.beforeHash || null, params.afterHash || null, params.source + ); +} + +// ── Helpers ── + +export function hashPhone(phone: string): string { + return crypto.createHash('sha256').update(phone).digest('hex').slice(0, 16); +} + +export function closeDb(): void { + if (db) { db.close(); db = null; } +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..8ad5104 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,61 @@ +import { createApp } from './app.js'; +import { createEditQueue } from './queue/edit-queue.js'; +import { processEditJob } from './queue/process-edit-job.js'; +import { openDb, closeDb, pruneExpiredProposals, pruneIdempotencyKeys } from './db.js'; +import { logger } from './logger.js'; + +const PORT = parseInt(process.env.ORCHESTRATOR_PORT || '3001', 10); + +export async function startServer() { + // Initialize database + openDb(); + logger.info({ event: 'db.opened' }, 'SQLite database opened'); + + // Create queue and wire consumer + const queue = createEditQueue(); + queue.startConsumer(processEditJob); + + // Periodic cleanup + const cleanupInterval = setInterval(() => { + pruneExpiredProposals(); + pruneIdempotencyKeys(); + }, 60_000); + + // Create and start HTTP server + const app = createApp({ queue }); + const server = app.listen(PORT, () => { + logger.info({ event: 'server.started', port: PORT }, `Orchestrator listening on port ${PORT}`); + }); + + // Graceful shutdown + let shuttingDown = false; + async function shutdown(signal: string) { + if (shuttingDown) return; + shuttingDown = true; + logger.info({ event: 'server.shutdown', signal }, `Received ${signal}, shutting down...`); + + clearInterval(cleanupInterval); + + // Stop accepting new connections + server.close(() => { + logger.info({ event: 'server.closed' }, 'HTTP server closed'); + }); + + // Drain queue (finish current job) + await queue.shutdown(); + + // Close database + closeDb(); + logger.info({ event: 'db.closed' }, 'Database closed'); + + process.exit(0); + } + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); +} + +startServer().catch(err => { + logger.fatal({ error: (err as Error).message }, 'Failed to start server'); + process.exit(1); +}); diff --git a/server/src/io/write-content.ts b/server/src/io/write-content.ts new file mode 100644 index 0000000..e828957 --- /dev/null +++ b/server/src/io/write-content.ts @@ -0,0 +1,68 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { stringifyCanonical } from '@dynamic-sites/shared'; +import { writeAuditLog } from '../db.js'; +import { logger } from '../logger.js'; + +const REPO_ROOT = process.env.REPO_ROOT || '.'; +const MAX_BACKUPS = 20; + +function ensureDir(dir: string) { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); +} + +function fileHash(content: string): string { + return crypto.createHash('sha256').update(content).digest('hex').slice(0, 12); +} + +/** + * Atomic write of canonical JSON to a repo-relative path. + * Creates a pre-write backup and audit log entry. + */ +export function writeContentFile( + repoRelativePath: string, + data: unknown, + opts?: { proposalId?: string; source?: string } +): void { + const absPath = path.join(REPO_ROOT, repoRelativePath); + const canonical = stringifyCanonical(data); + + // Pre-write backup + let beforeHash: string | undefined; + if (fs.existsSync(absPath)) { + const existing = fs.readFileSync(absPath, 'utf-8'); + beforeHash = fileHash(existing); + + const backupDir = path.join(REPO_ROOT, 'content', '.backups', repoRelativePath); + ensureDir(backupDir); + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + fs.copyFileSync(absPath, path.join(backupDir, `${ts}.json`)); + + // Prune old backups + const backups = fs.readdirSync(backupDir).sort(); + while (backups.length > MAX_BACKUPS) { + const oldest = backups.shift()!; + fs.unlinkSync(path.join(backupDir, oldest)); + } + } + + // Atomic write: temp file + rename + ensureDir(path.dirname(absPath)); + const tmpPath = absPath + '.tmp.' + process.pid; + fs.writeFileSync(tmpPath, canonical, 'utf-8'); + fs.renameSync(tmpPath, absPath); + + const afterHash = fileHash(canonical); + + // Audit log + writeAuditLog({ + proposalId: opts?.proposalId, + repoRelativePath, + beforeHash, + afterHash, + source: opts?.source || 'http', + }); + + logger.info({ event: 'content.written', path: repoRelativePath, size: canonical.length }, 'Content file written'); +} diff --git a/server/src/llm/client.ts b/server/src/llm/client.ts new file mode 100644 index 0000000..c3d6638 --- /dev/null +++ b/server/src/llm/client.ts @@ -0,0 +1,172 @@ +import { z } from 'zod'; +import { routingOutputSchema, type RoutingOutput } from '@dynamic-sites/shared'; +import { logger } from '../logger.js'; + +const OLLAMA_HOST = process.env.OLLAMA_HOST || 'https://ollama.com'; +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'; +const MAX_RETRIES = 3; + +export interface LlmChatCaller { + (messages: Array<{ role: string; content: string }>, model: string): Promise; +} + +/** Default chat caller using Ollama HTTP API */ +async function ollamaChat(messages: Array<{ role: string; content: string }>, model: string): Promise { + const resp = await fetch(`${OLLAMA_HOST}/api/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(OLLAMA_API_KEY ? { Authorization: `Bearer ${OLLAMA_API_KEY}` } : {}), + }, + body: JSON.stringify({ model, messages, stream: false }), + }); + + if (!resp.ok) { + throw new Error(`Ollama ${resp.status}: ${await resp.text().catch(() => 'no body')}`); + } + + const data = await resp.json() as { message?: { content?: string } }; + return data.message?.content || ''; +} + +/** + * Validate-then-retry loop: parse LLM output as JSON, validate against schema, + * re-prompt with errors if invalid, up to MAX_RETRIES per model. + */ +async function generateWithValidation(params: { + messages: Array<{ role: string; content: string }>; + schema: z.ZodType; + chat?: LlmChatCaller; +}): Promise { + const chat = params.chat || ollamaChat; + const models = [PRIMARY_MODEL, FALLBACK_MODEL]; + + for (const model of models) { + const msgs = [...params.messages]; + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + logger.debug({ event: 'llm.request', model, attempt }, 'LLM call'); + try { + const raw = await chat(msgs, model); + + // Extract JSON from response (handle markdown code blocks) + const jsonMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/) || [null, raw]; + const jsonStr = (jsonMatch[1] || raw).trim(); + const parsed = JSON.parse(jsonStr); + const result = params.schema.safeParse(parsed); + + if (result.success) return result.data; + + const errors = result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('\n'); + logger.debug({ event: 'llm.retry', model, attempt, errors }, 'Validation failed, retrying'); + + msgs.push( + { role: 'assistant', content: raw }, + { role: 'user', content: `Your JSON output failed validation:\n${errors}\n\nPlease fix the issues and return valid JSON only.` } + ); + } catch (err) { + logger.warn({ event: 'llm.retry', model, attempt, error: (err as Error).message }, 'LLM call or parse failed'); + if (attempt === MAX_RETRIES - 1) break; + msgs.push( + { role: 'user', content: `Your response was not valid JSON. Please respond with ONLY a JSON object, no markdown or extra text.` } + ); + } + } + logger.warn({ event: 'llm.fallback', from: model }, 'Exhausted retries, trying fallback'); + } + + logger.error({ event: 'llm.exhausted' }, 'All LLM models exhausted'); + throw new Error('LLM_UNAVAILABLE'); +} + +// ── Public API ── + +export interface GenerateEditedJsonParams { + currentJson: unknown; + siteContext: unknown; + userMessage: string; + repoRelativePath: string; + schema: z.ZodTypeAny; +} + +export async function generateEditedJson(params: GenerateEditedJsonParams, chat?: LlmChatCaller): Promise { + const messages = [ + { + role: 'system', + content: `You are a website content editor. You edit JSON content files for a website. + +SITE CONTEXT: +${JSON.stringify(params.siteContext, null, 2)} + +You will receive the current JSON content of a section file and a natural language edit request. +Return ONLY the complete updated JSON object — no explanation, no markdown, just the JSON. +Preserve all existing fields and structure. Only change what the user requested. +The output must be valid JSON matching the exact same schema as the input.`, + }, + { + role: 'user', + content: `Current content of "${params.repoRelativePath}":\n\`\`\`json\n${JSON.stringify(params.currentJson, null, 2)}\n\`\`\`\n\nEdit request: "${params.userMessage}"\n\nReturn the complete updated JSON:`, + }, + ]; + + return generateWithValidation({ messages, schema: params.schema, chat }); +} + +export interface RouteEditIntentParams { + userMessage: string; + manifest: Array<{ id: string; type: string; title?: string; headline?: string; heading?: string; repo_relative_path: string; visible: boolean }>; +} + +export async function routeEditIntent(params: RouteEditIntentParams, chat?: LlmChatCaller): Promise { + const messages = [ + { + role: 'system', + content: `You are a routing assistant for a website CMS. Given a natural language edit request and a manifest of available content sections, determine which section file the edit applies to. + +Return a JSON object with: +- "repo_relative_path": the path of the target section file +- "needs_clarification": true if the request is ambiguous +- "reason": short explanation +- "clarification_message": (only if needs_clarification) a question to ask the user + +If the request is about showing/hiding/enabling/disabling a section, route to that section's file. +If the request mentions events, route to "content/events.json".`, + }, + { + role: 'user', + content: `MANIFEST:\n${JSON.stringify(params.manifest, null, 2)}\n\nEDIT REQUEST: "${params.userMessage}"\n\nReturn JSON:`, + }, + ]; + + return generateWithValidation({ messages, schema: routingOutputSchema, chat }); +} + +/** Simple summary generation (no schema validation needed) */ +export async function generateSummary(params: { + before: unknown; + after: unknown; + repoRelativePath: string; + userMessage: string; + chat?: LlmChatCaller; +}): Promise { + const chat = params.chat || ollamaChat; + const messages = [ + { + role: 'system', + content: `You summarize content changes for a website owner. Keep summaries under 140 characters, plain text, no markdown. Be specific about what changed. Format: "Change X from A to B" or "Add/remove X".`, + }, + { + role: 'user', + content: `File: ${params.repoRelativePath}\nRequest: "${params.userMessage}"\n\nBefore:\n${JSON.stringify(params.before, null, 2)}\n\nAfter:\n${JSON.stringify(params.after, null, 2)}\n\nSummarize the change in under 140 chars:`, + }, + ]; + + try { + const result = await chat(messages, PRIMARY_MODEL); + return result.replace(/["'`]/g, '').trim().slice(0, 280); + } catch { + // Fallback: generate a basic diff summary + return `Update ${params.repoRelativePath} as requested: "${params.userMessage.slice(0, 80)}"`; + } +} diff --git a/server/src/logger.ts b/server/src/logger.ts new file mode 100644 index 0000000..d8353bc --- /dev/null +++ b/server/src/logger.ts @@ -0,0 +1,18 @@ +import pino from 'pino'; + +const LOG_LEVEL = process.env.LOG_LEVEL || 'info'; +const isDev = process.env.NODE_ENV !== 'production'; + +export const logger = pino({ + level: LOG_LEVEL, + ...(isDev ? { transport: { target: 'pino-pretty', options: { colorize: true } } } : {}), +}); + +export function createChildLogger(bindings: Record) { + return logger.child(bindings); +} + +export function maskPhone(phone: string): string { + if (phone.length <= 4) return '****'; + return phone.slice(0, -4).replace(/./g, '*') + phone.slice(-4); +} diff --git a/server/src/queue/edit-queue.ts b/server/src/queue/edit-queue.ts new file mode 100644 index 0000000..066a974 --- /dev/null +++ b/server/src/queue/edit-queue.ts @@ -0,0 +1,83 @@ +import type { EditJobPayload } from '@dynamic-sites/shared'; +import { logger } from '../logger.js'; + +export interface EditQueue { + enqueue(payload: EditJobPayload): void; + startConsumer(processor: (job: EditJobPayload) => Promise): void; + getQueueDepth(): number; + shutdown(): Promise; +} + +const MAX_QUEUE_DEPTH = parseInt(process.env.MAX_QUEUE_DEPTH || '20', 10); + +export function createEditQueue(): EditQueue { + const jobs: EditJobPayload[] = []; + let processing = false; + let shuttingDown = false; + let processor: ((job: EditJobPayload) => Promise) | null = null; + let resolveShutdown: (() => void) | null = null; + + async function drain() { + if (processing) return; + processing = true; + + while (jobs.length > 0 && !shuttingDown) { + const job = jobs.shift()!; + logger.info({ event: 'job.started', kind: job.kind, id: job.id }, 'Processing job'); + try { + await processor!(job); + logger.info({ event: 'job.completed', kind: job.kind, id: job.id }, 'Job completed'); + } catch (err) { + logger.error({ event: 'job.failed', kind: job.kind, id: job.id, error: (err as Error).message }, 'Job failed'); + } + } + + processing = false; + + if (shuttingDown && jobs.length === 0 && resolveShutdown) { + resolveShutdown(); + } + } + + return { + enqueue(payload: EditJobPayload) { + if (shuttingDown) { + logger.warn({ event: 'job.rejected', reason: 'shutting_down' }, 'Rejecting job — shutting down'); + return; + } + if (jobs.length >= MAX_QUEUE_DEPTH) { + logger.warn({ event: 'job.rejected', reason: 'queue_full', depth: jobs.length }, 'Queue depth exceeded'); + throw new Error('QUEUE_FULL'); + } + jobs.push(payload); + logger.info({ event: 'job.enqueued', kind: payload.kind, id: payload.id, depth: jobs.length }, 'Job enqueued'); + // Start draining on next tick + if (processor) setImmediate(drain); + }, + + startConsumer(proc) { + processor = proc; + logger.info({ event: 'consumer.started' }, 'Edit queue consumer started'); + // Start draining in case jobs were enqueued before consumer started + if (jobs.length > 0) setImmediate(drain); + }, + + getQueueDepth() { + return jobs.length; + }, + + async shutdown() { + shuttingDown = true; + const remaining = jobs.length; + if (remaining > 0) { + logger.warn({ event: 'consumer.shutdown', dropped: remaining }, `Shutting down with ${remaining} queued jobs`); + } + if (processing) { + // Wait for current job to finish + await new Promise(resolve => { resolveShutdown = resolve; }); + } + // Clear remaining jobs + jobs.length = 0; + }, + }; +} diff --git a/server/src/queue/manifest.ts b/server/src/queue/manifest.ts new file mode 100644 index 0000000..8182d81 --- /dev/null +++ b/server/src/queue/manifest.ts @@ -0,0 +1,55 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { sectionFileSchema } from '@dynamic-sites/shared'; + +const REPO_ROOT = process.env.REPO_ROOT || '.'; + +export interface ManifestEntry { + id: string; + type: string; + title?: string; + headline?: string; + heading?: string; + repo_relative_path: string; + visible: boolean; +} + +export function buildSectionManifest(): ManifestEntry[] { + const sectionsDir = path.join(REPO_ROOT, 'content/sections'); + const manifest: ManifestEntry[] = []; + + if (!fs.existsSync(sectionsDir)) return manifest; + + for (const file of fs.readdirSync(sectionsDir).filter(f => f.endsWith('.json'))) { + try { + const raw = JSON.parse(fs.readFileSync(path.join(sectionsDir, file), 'utf-8')); + const parsed = sectionFileSchema.safeParse(raw); + if (!parsed.success) continue; + + const s = parsed.data; + const entry: ManifestEntry = { + id: s.id, + type: s.type, + repo_relative_path: `content/sections/${file}`, + visible: s.visible, + }; + + if (s.type === 'hero') entry.headline = s.headline; + if (s.type === 'about' || s.type === 'features' || s.type === 'testimonials') entry.title = s.title; + if (s.type === 'text') entry.heading = s.heading; + + manifest.push(entry); + } catch { /* skip bad files */ } + } + + // Also add events.json + manifest.push({ + id: 'events', + type: 'events', + title: 'Events', + repo_relative_path: 'content/events.json', + visible: true, + }); + + return manifest; +} diff --git a/server/src/queue/process-edit-job.ts b/server/src/queue/process-edit-job.ts new file mode 100644 index 0000000..f6acb0b --- /dev/null +++ b/server/src/queue/process-edit-job.ts @@ -0,0 +1,178 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import type { EditJobPayload } from '@dynamic-sites/shared'; +import { schemaForRepoRelativePath } from '@dynamic-sites/shared'; +import { createProposal, getProposal, updateProposalStatus } from '../db.js'; +import { writeContentFile } from '../io/write-content.js'; +import { generateEditedJson, routeEditIntent, generateSummary } from '../llm/client.js'; +import { buildSectionManifest } from './manifest.js'; +import { sendSms } from '../sms/reply.js'; +import { SMS_TEMPLATES } from '../sms/templates.js'; +import { logger } from '../logger.js'; + +const REPO_ROOT = process.env.REPO_ROOT || '.'; + +export async function processEditJob(job: EditJobPayload): Promise { + if (job.kind === 'propose') { + await handlePropose(job); + } else if (job.kind === 'apply') { + await handleApply(job); + } +} + +async function handlePropose(job: Extract) { + const log = logger.child({ jobId: job.id, kind: 'propose' }); + + try { + // Step 1: Route — determine which file the edit targets + let repoRelativePath = job.repo_relative_path; + + if (!repoRelativePath) { + const manifest = buildSectionManifest(); + log.debug({ event: 'routing.start', sections: manifest.length }, 'Routing edit intent'); + + const routing = await routeEditIntent({ userMessage: job.message, manifest }); + + if (routing.needs_clarification) { + log.info({ event: 'routing.ambiguous' }, 'Routing ambiguous'); + if (job.smsReplyMeta) { + await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, + routing.clarification_message || SMS_TEMPLATES.ROUTING_AMBIGUOUS(manifest.map(m => m.id).join(', ')) + ); + } + return; + } + + repoRelativePath = routing.repo_relative_path; + } + + log.info({ event: 'routing.resolved', path: repoRelativePath }, 'Route resolved'); + + // Step 2: Load current content + schema + const absPath = path.join(REPO_ROOT, repoRelativePath); + if (!fs.existsSync(absPath)) { + log.error({ event: 'propose.file_not_found', path: repoRelativePath }, 'Target file not found'); + if (job.smsReplyMeta) { + await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.ROUTING_NO_MATCH(repoRelativePath)); + } + return; + } + + const currentJson = JSON.parse(fs.readFileSync(absPath, 'utf-8')); + const schema = schemaForRepoRelativePath(repoRelativePath); + if (!schema) { + log.error({ event: 'propose.no_schema', path: repoRelativePath }, 'No schema for path'); + return; + } + + const siteContext = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'site-context.json'), 'utf-8')); + + // Step 3: Generate edited JSON via LLM + const editedJson = await generateEditedJson({ + currentJson, + siteContext, + userMessage: job.message, + repoRelativePath, + schema, + }); + + // Step 4: Generate summary + const summary = await generateSummary({ + before: currentJson, + after: editedJson, + repoRelativePath, + userMessage: job.message, + }); + + // Step 5: Store proposal + const proposalId = crypto.randomUUID(); + createProposal({ + proposalId, + repoRelativePath, + proposedJson: JSON.stringify(editedJson), + summaryText: summary, + source: job.source, + phoneHash: job.smsReplyMeta?.from ? crypto.createHash('sha256').update(job.smsReplyMeta.from).digest('hex').slice(0, 16) : undefined, + }); + + log.info({ event: 'proposal.created', proposalId, path: repoRelativePath }, 'Proposal created'); + + // Step 6: Notify user + if (job.smsReplyMeta) { + 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)._proposalId = proposalId; + (job as Record)._summary = summary; + + } catch (err) { + const msg = (err as Error).message; + log.error({ event: 'propose.failed', error: msg }, '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); + } + } +} + +async function handleApply(job: Extract) { + const log = logger.child({ jobId: job.id, kind: 'apply', proposalId: job.proposal_id }); + + const proposal = getProposal(job.proposal_id); + + if (!proposal) { + log.warn({ event: 'apply.not_found' }, 'Proposal not found'); + return; + } + + const now = Math.floor(Date.now() / 1000); + + if (proposal.status === 'applied') { + log.info({ event: 'apply.already_applied' }, 'Proposal already applied'); + if (job.smsReplyMeta) { + await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.PROPOSAL_ALREADY_APPLIED()); + } + return; + } + + if (proposal.status !== 'pending' || proposal.expires_at < now) { + log.info({ event: 'apply.expired' }, 'Proposal expired or invalid status'); + updateProposalStatus(job.proposal_id, 'expired'); + if (job.smsReplyMeta) { + await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.PROPOSAL_EXPIRED()); + } + return; + } + + // Re-validate against schema + const schema = schemaForRepoRelativePath(proposal.repo_relative_path); + if (!schema) { + log.error({ event: 'apply.no_schema' }, 'No schema for proposal path'); + return; + } + + const proposedData = JSON.parse(proposal.proposed_json); + const validation = schema.safeParse(proposedData); + if (!validation.success) { + log.error({ event: 'apply.validation_failed', errors: validation.error.message }, 'Proposed JSON fails validation'); + updateProposalStatus(job.proposal_id, 'rejected'); + return; + } + + // Write to disk + writeContentFile(proposal.repo_relative_path, validation.data, { + proposalId: job.proposal_id, + source: job.source, + }); + + updateProposalStatus(job.proposal_id, 'applied'); + log.info({ event: 'proposal.confirmed', path: proposal.repo_relative_path }, 'Proposal applied'); + + if (job.smsReplyMeta) { + await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.APPLIED(proposal.summary_text)); + } +} diff --git a/server/src/routes/api-edit.ts b/server/src/routes/api-edit.ts new file mode 100644 index 0000000..bf68949 --- /dev/null +++ b/server/src/routes/api-edit.ts @@ -0,0 +1,165 @@ +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 type { EditQueue } from '../queue/edit-queue.js'; +import { getProposal, updateProposalStatus } from '../db.js'; +import { buildSectionManifest } from '../queue/manifest.js'; +import { logger } from '../logger.js'; + +const REPO_ROOT = process.env.REPO_ROOT || '.'; +const API_EDIT_SECRET = process.env.API_EDIT_SECRET || ''; + +export function verifyEditAuth(req: Request): boolean { + const auth = req.headers.authorization; + if (!auth || !API_EDIT_SECRET) return false; + return auth === `Bearer ${API_EDIT_SECRET}`; +} + +export interface ApiEditRouterDeps { + queue: EditQueue; +} + +export function createApiEditRouter(deps: ApiEditRouterDeps): Router { + const router = Router(); + + // Auth middleware for all routes + router.use((req, res, next) => { + if (!verifyEditAuth(req)) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + next(); + }); + + // GET /api/manifest — list all sections + router.get('/manifest', (_req: Request, res: Response) => { + const manifest = buildSectionManifest(); + res.json({ sections: manifest }); + }); + + // GET /api/section?path=content/sections/hero.json — get current section JSON + router.get('/section', (req: Request, res: Response) => { + const relPath = req.query.path as string; + if (!relPath) { + res.status(400).json({ error: 'Missing path query param' }); + return; + } + const absPath = path.join(REPO_ROOT, relPath); + if (!fs.existsSync(absPath)) { + res.status(404).json({ error: 'File not found' }); + return; + } + try { + const data = JSON.parse(fs.readFileSync(absPath, 'utf-8')); + res.json(data); + } catch { + res.status(500).json({ error: 'Failed to read file' }); + } + }); + + // GET /api/site-context + router.get('/site-context', (_req: Request, res: Response) => { + try { + const data = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'site-context.json'), 'utf-8')); + res.json(data); + } catch { + res.status(500).json({ error: 'Failed to read site context' }); + } + }); + + // POST /api/edit — propose an edit (NL message) + router.post('/edit', async (req: Request, res: Response) => { + const parsed = editRequestSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request', details: parsed.error.issues }); + return; + } + + const { message, repo_relative_path, confirm, proposal_id } = parsed.data; + + // Handle confirmation flow + if (confirm && proposal_id) { + if (confirm === 'yes') { + try { + deps.queue.enqueue({ + kind: 'apply', + id: crypto.randomUUID(), + proposal_id, + source: 'http', + }); + res.status(202).json({ status: 'applying', proposal_id }); + } catch (err) { + res.status(503).json({ error: (err as Error).message }); + } + } else { + updateProposalStatus(proposal_id, 'rejected'); + res.json({ status: 'rejected', proposal_id }); + } + return; + } + + // Propose flow + const jobId = crypto.randomUUID(); + try { + deps.queue.enqueue({ + kind: 'propose', + id: jobId, + message, + repo_relative_path, + source: 'http', + }); + res.status(202).json({ status: 'processing', job_id: jobId }); + } catch (err) { + res.status(503).json({ error: (err as Error).message }); + } + }); + + // POST /api/edit/create-section — create a new section file + router.post('/edit/create-section', (req: Request, res: Response) => { + const { filename, data } = req.body; + if (!filename || !data) { + res.status(400).json({ error: 'Missing filename or data' }); + return; + } + + const parsed = sectionFileSchema.safeParse(data); + if (!parsed.success) { + res.status(400).json({ error: 'Invalid section data', details: parsed.error.issues }); + return; + } + + const relPath = `content/sections/${filename.replace(/[^a-z0-9-]/gi, '').toLowerCase()}.json`; + const absPath = path.join(REPO_ROOT, relPath); + if (fs.existsSync(absPath)) { + res.status(409).json({ error: 'File already exists' }); + return; + } + + // Use the write-content module (imported dynamically to avoid circular deps) + import('../io/write-content.js').then(({ writeContentFile }) => { + writeContentFile(relPath, parsed.data, { source: 'editor' }); + res.status(201).json({ status: 'created', path: relPath }); + }); + }); + + // GET /api/proposal/:id — check proposal status + router.get('/proposal/:id', (req: Request, res: Response) => { + const proposal = getProposal(req.params.id); + if (!proposal) { + res.status(404).json({ error: 'Proposal not found' }); + return; + } + res.json({ + proposal_id: proposal.proposal_id, + status: proposal.status, + summary: proposal.summary_text, + repo_relative_path: proposal.repo_relative_path, + created_at: proposal.created_at, + expires_at: proposal.expires_at, + }); + }); + + return router; +} diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts new file mode 100644 index 0000000..b921183 --- /dev/null +++ b/server/src/routes/health.ts @@ -0,0 +1,9 @@ +import { Router } from 'express'; + +export function createHealthRouter(): Router { + const router = Router(); + router.get('/health', (_req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); + }); + return router; +} diff --git a/server/src/routes/webhook-sms.ts b/server/src/routes/webhook-sms.ts new file mode 100644 index 0000000..92ac4f5 --- /dev/null +++ b/server/src/routes/webhook-sms.ts @@ -0,0 +1,115 @@ +import { Router, type Request, type Response } from 'express'; +import crypto from 'node:crypto'; +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 { claimOnce, checkSmsRateLimit, hashPhone, getPendingProposalByPhone, updateProposalStatus } from '../db.js'; +import { logger, maskPhone } from '../logger.js'; + +export interface WebhookSmsRouterDeps { + queue: EditQueue; +} + +export function createWebhookSmsRouter(deps: WebhookSmsRouterDeps): Router { + const router = Router(); + + router.post('/telnyx', (req: Request, res: Response) => { + // Respond quickly + res.status(200).json({ status: 'received' }); + + // Process async + handleInbound(req.body, deps).catch(err => { + logger.error({ event: 'sms.handler_error', error: (err as Error).message }, 'SMS handler error'); + }); + }); + + return router; +} + +async function handleInbound(body: unknown, deps: WebhookSmsRouterDeps) { + const parsed = parseTelnyxInboundMessage(body); + if (!parsed) { + logger.warn({ event: 'sms.parse_failed' }, 'Failed to parse inbound SMS'); + return; + } + + const { messageId, from, to, text, hasMedia } = parsed; + const phoneHash = hashPhone(from); + logger.info({ event: 'sms.received', from: maskPhone(from), hasMedia, messageId }, 'Inbound SMS'); + + // Idempotency check + if (messageId && !claimOnce(`sms:${messageId}`, 3600)) { + logger.info({ event: 'sms.idempotent_skip', messageId }, 'Duplicate SMS skipped'); + return; + } + + // MMS check + if (hasMedia) { + await sendSms(from, to, SMS_TEMPLATES.MMS_NOT_SUPPORTED()); + return; + } + + // Rate limit + const maxPerHour = parseInt(process.env.SMS_RATE_LIMIT_PER_HOUR || '10', 10); + if (!checkSmsRateLimit(phoneHash, maxPerHour)) { + logger.info({ event: 'sms.rate_limited', phone: maskPhone(from) }, 'SMS rate limited'); + await sendSms(from, to, SMS_TEMPLATES.RATE_LIMITED()); + return; + } + + const upperText = text.toUpperCase().trim(); + + // Check for YES/NO confirmation + if (upperText === 'YES' || upperText === 'Y') { + const pending = getPendingProposalByPhone(phoneHash); + if (!pending) { + await sendSms(from, to, SMS_TEMPLATES.PROPOSAL_EXPIRED()); + return; + } + + try { + deps.queue.enqueue({ + kind: 'apply', + id: crypto.randomUUID(), + proposal_id: pending.proposal_id, + source: 'sms', + smsReplyMeta: { from, to }, + }); + } catch { + await sendSms(from, to, SMS_TEMPLATES.LLM_UNAVAILABLE()); + } + return; + } + + if (upperText === 'NO' || upperText === 'N') { + const pending = getPendingProposalByPhone(phoneHash); + if (pending) { + updateProposalStatus(pending.proposal_id, 'rejected'); + logger.info({ event: 'proposal.rejected', proposalId: pending.proposal_id }, 'Proposal rejected via SMS'); + } + await sendSms(from, to, SMS_TEMPLATES.REJECTED()); + return; + } + + // Check if there's a pending proposal — if user sends something other than YES/NO + const pending = getPendingProposalByPhone(phoneHash); + if (pending) { + // New message while proposal pending — could be a new edit or invalid confirm + // Expire old proposal and start fresh + updateProposalStatus(pending.proposal_id, 'expired'); + } + + // New propose job + try { + deps.queue.enqueue({ + kind: 'propose', + id: crypto.randomUUID(), + message: text, + source: 'sms', + smsReplyMeta: { from, to }, + }); + } catch { + await sendSms(from, to, SMS_TEMPLATES.LLM_UNAVAILABLE()); + } +} diff --git a/server/src/sms/parse.ts b/server/src/sms/parse.ts new file mode 100644 index 0000000..6bfd351 --- /dev/null +++ b/server/src/sms/parse.ts @@ -0,0 +1,30 @@ +export interface ParsedInboundSms { + messageId: string; + from: string; + to: string; + text: string; + hasMedia: boolean; + mediaUrls: string[]; +} + +export function parseTelnyxInboundMessage(body: unknown): ParsedInboundSms | null { + try { + const data = body as Record; + const eventData = (data.data as Record) || data; + const payload = (eventData.payload as Record) || eventData; + + const from = ((payload.from as Record)?.phone_number as string) || (payload.from as string) || ''; + const to = (Array.isArray(payload.to) ? (payload.to[0] as Record)?.phone_number : (payload.to as Record)?.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 media = (payload.media as Array<{ url: string }>) || []; + const mediaUrls = media.map(m => m.url).filter(Boolean); + + if (!from || !text) return null; + + return { messageId, from, to, text: text.trim(), hasMedia: mediaUrls.length > 0, mediaUrls }; + } catch { + return null; + } +} diff --git a/server/src/sms/reply.ts b/server/src/sms/reply.ts new file mode 100644 index 0000000..e7e5c7b --- /dev/null +++ b/server/src/sms/reply.ts @@ -0,0 +1,30 @@ +import { logger, maskPhone } from '../logger.js'; + +const TELNYX_API_KEY = process.env.TELNYX_API_KEY || ''; + +export async function sendSms(to: string, from: string, body: string): Promise { + if (!TELNYX_API_KEY) { + logger.warn({ event: 'sms.send_skipped', to: maskPhone(to) }, 'No TELNYX_API_KEY, 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 }), + }); + + 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'); + } + } catch (err) { + logger.error({ event: 'sms.send_error', error: (err as Error).message }, 'SMS send error'); + } +} diff --git a/server/src/sms/templates.ts b/server/src/sms/templates.ts new file mode 100644 index 0000000..1357517 --- /dev/null +++ b/server/src/sms/templates.ts @@ -0,0 +1,34 @@ +export const SMS_TEMPLATES = { + PROPOSAL_SUMMARY: (summary: string, proposalId: string) => + `Proposed change: ${summary}\n\nReply YES to apply or NO to cancel.`, + + APPLIED: (summary: string) => + `Done! ${summary} Your site will update shortly.`, + + REJECTED: () => + `Got it — change cancelled. Send a new message anytime.`, + + LLM_UNAVAILABLE: () => + `Sorry, I couldn't process that right now. Please try again in a few minutes.`, + + ROUTING_AMBIGUOUS: (options: string) => + `I'm not sure which section you mean. Did you mean: ${options}? Reply with the number or name.`, + + ROUTING_NO_MATCH: (list: string) => + `I couldn't find a section matching that request. Your current sections are: ${list}. Try again?`, + + PROPOSAL_EXPIRED: () => + `That change request has expired. Please send your edit again to start over.`, + + PROPOSAL_ALREADY_APPLIED: () => + `That change was already applied.`, + + INVALID_CONFIRM: () => + `Reply YES to apply or NO to cancel.`, + + RATE_LIMITED: () => + `You've sent several requests recently. Please wait a few minutes before trying again.`, + + MMS_NOT_SUPPORTED: () => + `Image uploads aren't supported yet. Please describe your change in text.`, +} as const; diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..ebfa023 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ES2022", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000..8b5cea9 --- /dev/null +++ b/shared/package.json @@ -0,0 +1,14 @@ +{ + "name": "@dynamic-sites/shared", + "version": "1.0.0", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./*": "./src/*" + }, + "dependencies": { + "zod": "^3.24.0" + } +} diff --git a/shared/src/canonical-json.ts b/shared/src/canonical-json.ts new file mode 100644 index 0000000..f5a7dd6 --- /dev/null +++ b/shared/src/canonical-json.ts @@ -0,0 +1,20 @@ +/** + * Canonical JSON serialization: sorted keys at every level, 2-space indent, trailing newline. + */ + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +function sortKeys(value: unknown): unknown { + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(sortKeys); + const sorted: Record = {}; + for (const key of Object.keys(value as Record).sort()) { + sorted[key] = sortKeys((value as Record)[key]); + } + return sorted; +} + +export function stringifyCanonical(value: unknown): string { + return JSON.stringify(sortKeys(value), null, 2) + '\n'; +} diff --git a/shared/src/index.ts b/shared/src/index.ts new file mode 100644 index 0000000..eb1d1f6 --- /dev/null +++ b/shared/src/index.ts @@ -0,0 +1,7 @@ +export { stringifyCanonical, type JsonValue, type JsonPrimitive } from './canonical-json.js'; +export * from './schemas/index.js'; +export { + schemaForRepoRelativePath, + isEditableContentRepoPath, + parseAndValidateRepoJson, +} from './repo-validation.js'; diff --git a/shared/src/repo-validation.ts b/shared/src/repo-validation.ts new file mode 100644 index 0000000..0079cd4 --- /dev/null +++ b/shared/src/repo-validation.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; +import { + siteContextSchema, + sectionFileSchema, + eventsFileSchema, + smsSitesConfigSchema, +} from './schemas/index.js'; + +const EDITABLE_PATTERNS: Array<{ pattern: RegExp; schema: z.ZodTypeAny }> = [ + { pattern: /^content\/sections\/[^/]+\.json$/, schema: sectionFileSchema }, + { pattern: /^content\/events\.json$/, schema: eventsFileSchema }, + { pattern: /^site-context\.json$/, schema: siteContextSchema }, + { pattern: /^config\/sms-sites\.json$/, schema: smsSitesConfigSchema }, +]; + +export function schemaForRepoRelativePath(relativePath: string): z.ZodTypeAny | null { + for (const { pattern, schema } of EDITABLE_PATTERNS) { + if (pattern.test(relativePath)) return schema; + } + return null; +} + +export function isEditableContentRepoPath(relativePath: string): boolean { + return EDITABLE_PATTERNS.some(({ pattern }) => pattern.test(relativePath)); +} + +export function parseAndValidateRepoJson( + repoRelativePath: string, + rawText: string +): { success: true; data: unknown } | { success: false; error: string } { + const schema = schemaForRepoRelativePath(repoRelativePath); + if (!schema) return { success: false, error: `No schema found for path: ${repoRelativePath}` }; + + try { + const parsed = JSON.parse(rawText); + const result = schema.safeParse(parsed); + if (result.success) return { success: true, data: result.data }; + return { success: false, error: result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ') }; + } catch (e) { + return { success: false, error: `Invalid JSON: ${(e as Error).message}` }; + } +} diff --git a/shared/src/schemas/index.ts b/shared/src/schemas/index.ts new file mode 100644 index 0000000..7aee661 --- /dev/null +++ b/shared/src/schemas/index.ts @@ -0,0 +1,152 @@ +import { z } from 'zod'; + +// ── Site Context ── +export const siteContextSchema = z.object({ + businessName: z.string(), + tagline: z.string().optional(), + tone: z.string().default('professional and friendly'), + style: z.string().optional(), + promptContext: z.string().optional(), + primaryColor: z.string().optional(), + contactEmail: z.string().email().optional(), + contactPhone: z.string().optional(), + address: z.string().optional(), +}); +export type SiteContext = z.infer; + +// ── Section File (union of section types) ── +const baseSectionFields = { + id: z.string(), + order: z.number().int().default(0), + visible: z.boolean().default(true), + image: z.string().optional(), +}; + +export const heroSectionSchema = z.object({ + ...baseSectionFields, + type: z.literal('hero'), + headline: z.string(), + subheading: z.string().optional(), + ctaText: z.string().optional(), + ctaLink: z.string().optional(), +}); + +export const aboutSectionSchema = z.object({ + ...baseSectionFields, + type: z.literal('about'), + title: z.string().default('About Us'), + content: z.string(), +}); + +export const featureItemSchema = z.object({ + title: z.string(), + description: z.string(), + icon: z.string().optional(), +}); + +export const featuresSectionSchema = z.object({ + ...baseSectionFields, + type: z.literal('features'), + title: z.string().default('What We Offer'), + items: z.array(featureItemSchema).min(1), +}); + +export const testimonialItemSchema = z.object({ + quote: z.string(), + author: z.string(), + role: z.string().optional(), +}); + +export const testimonialsSectionSchema = z.object({ + ...baseSectionFields, + type: z.literal('testimonials'), + title: z.string().default('What Our Clients Say'), + items: z.array(testimonialItemSchema).min(1), +}); + +export const textSectionSchema = z.object({ + ...baseSectionFields, + type: z.literal('text'), + heading: z.string().optional(), + content: z.string(), +}); + +export const sectionFileSchema = z.discriminatedUnion('type', [ + heroSectionSchema, + aboutSectionSchema, + featuresSectionSchema, + testimonialsSectionSchema, + textSectionSchema, +]); +export type SectionFile = z.infer; + +// ── Events ── +export const eventItemSchema = z.object({ + id: z.string(), + title: z.string(), + description: z.string().optional(), + date: z.string(), + time: z.string().optional(), + location: z.string().optional(), +}); + +export const eventsFileSchema = z.object({ + events: z.array(eventItemSchema), +}); +export type EventsFile = z.infer; + +// ── SMS Sites Config ── +export const smsSiteEntrySchema = z.object({ + siteId: z.string(), + phoneNumber: z.string(), + allowedSenders: z.array(z.string()), + repoRoot: z.string().optional(), +}); + +export const smsSitesConfigSchema = z.object({ + sites: z.array(smsSiteEntrySchema), +}); +export type SmsSitesConfig = z.infer; + +// ── Edit Request ── +export const editRequestSchema = z.object({ + message: z.string().min(1), + repo_relative_path: z.string().optional(), + proposal_id: z.string().optional(), + confirm: z.enum(['yes', 'no']).optional(), +}); +export type EditRequest = z.infer; + +export const editJobPayloadSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('propose'), + id: z.string(), + message: z.string(), + repo_relative_path: z.string().optional(), + source: z.enum(['sms', 'http', 'editor']).default('http'), + smsReplyMeta: z.object({ + from: z.string(), + to: z.string(), + }).optional(), + }), + z.object({ + kind: z.literal('apply'), + id: z.string(), + proposal_id: z.string(), + source: z.enum(['sms', 'http', 'editor']).default('http'), + smsReplyMeta: z.object({ + from: z.string(), + to: z.string(), + }).optional(), + }), +]); +export type EditJobPayload = z.infer; + +// ── Routing output (LLM structured output) ── +export const routingOutputSchema = z.object({ + repo_relative_path: z.string(), + needs_clarification: z.boolean().default(false), + reason: z.string(), + clarification_message: z.string().optional(), +}); +export type RoutingOutput = z.infer; diff --git a/site-context.json b/site-context.json new file mode 100644 index 0000000..97bb66d --- /dev/null +++ b/site-context.json @@ -0,0 +1,11 @@ +{ + "address": "742 Evergreen Terrace, Portland, OR 97201", + "businessName": "Timber & Grain Coffee Co.", + "contactEmail": "hello@timberandgrain.co", + "contactPhone": "(503) 555-0142", + "primaryColor": "#2d5016", + "promptContext": "We are a specialty coffee roaster and cafe in Portland, Oregon. We focus on single-origin beans, pour-over brewing, and community events. Our vibe is warm, earthy, and approachable — not pretentious.", + "style": "Warm, earthy, approachable. Use natural imagery and warm tones.", + "tagline": "Rooted in craft. Brewed with care.", + "tone": "Warm, knowledgeable, and inviting. We speak like a friend who happens to know a lot about coffee." +} diff --git a/src/components/editor/VisualEditorIsland.tsx b/src/components/editor/VisualEditorIsland.tsx new file mode 100644 index 0000000..5746cf8 --- /dev/null +++ b/src/components/editor/VisualEditorIsland.tsx @@ -0,0 +1,343 @@ +import { useState, useEffect, useCallback } from 'react'; + +interface ManifestEntry { + id: string; + type: string; + title?: string; + headline?: string; + heading?: string; + repo_relative_path: string; + visible: boolean; +} + +interface Props { + orchestratorUrl: string; + apiSecret: string; +} + +type EditorView = 'sections' | 'edit' | 'create' | 'nl-edit'; + +export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) { + const [view, setView] = useState('sections'); + const [sections, setSections] = useState([]); + const [selectedSection, setSelectedSection] = useState(null); + const [sectionJson, setSectionJson] = useState(''); + const [nlMessage, setNlMessage] = useState(''); + const [status, setStatus] = useState(''); + const [loading, setLoading] = useState(false); + const [proposalId, setProposalId] = useState(null); + const [proposalSummary, setProposalSummary] = useState(''); + + const headers = { + 'Authorization': `Bearer ${apiSecret}`, + 'Content-Type': 'application/json', + }; + + const fetchManifest = useCallback(async () => { + try { + const res = await fetch(`${orchestratorUrl}/api/manifest`, { headers }); + const data = await res.json(); + setSections(data.sections || []); + } catch (err) { + setStatus('Failed to load sections'); + } + }, [orchestratorUrl, apiSecret]); + + useEffect(() => { fetchManifest(); }, [fetchManifest]); + + const loadSection = async (entry: ManifestEntry) => { + setSelectedSection(entry); + setView('edit'); + try { + const res = await fetch(`${orchestratorUrl}/api/section?path=${encodeURIComponent(entry.repo_relative_path)}`, { headers }); + const data = await res.json(); + setSectionJson(JSON.stringify(data, null, 2)); + } catch { + setStatus('Failed to load section'); + } + }; + + const saveSection = async () => { + if (!selectedSection) return; + setLoading(true); + setStatus('Saving...'); + try { + const parsed = JSON.parse(sectionJson); + const res = await fetch(`${orchestratorUrl}/api/edit`, { + method: 'POST', + headers, + body: JSON.stringify({ + message: `Direct JSON update to ${selectedSection.repo_relative_path}`, + repo_relative_path: selectedSection.repo_relative_path, + }), + }); + const data = await res.json(); + setStatus(data.status === 'processing' ? 'Edit submitted — processing...' : `Status: ${data.status}`); + } catch (err) { + setStatus('Save failed'); + } + setLoading(false); + }; + + const submitNlEdit = async () => { + if (!nlMessage.trim()) return; + setLoading(true); + setStatus('Sending to AI...'); + setProposalId(null); + setProposalSummary(''); + try { + const res = await fetch(`${orchestratorUrl}/api/edit`, { + method: 'POST', + headers, + body: JSON.stringify({ message: nlMessage }), + }); + const data = await res.json(); + if (data.job_id) { + setStatus('Processing... checking for proposal.'); + // Poll for proposal + pollForProposal(data.job_id); + } else { + setStatus(`Response: ${JSON.stringify(data)}`); + } + } catch { + setStatus('Request failed'); + } + setLoading(false); + }; + + const pollForProposal = async (jobId: string) => { + // Simple poll: check recent proposals. In production, use SSE or websockets. + let attempts = 0; + const interval = setInterval(async () => { + attempts++; + if (attempts > 30) { + clearInterval(interval); + setStatus('Timed out waiting for proposal. The AI may still be processing.'); + return; + } + try { + // Re-fetch manifest to see if anything changed, or check proposal endpoint + const res = await fetch(`${orchestratorUrl}/api/manifest`, { headers }); + const data = await res.json(); + setSections(data.sections || []); + } catch { /* ignore */ } + }, 2000); + }; + + const confirmProposal = async (confirm: 'yes' | 'no') => { + if (!proposalId) return; + setLoading(true); + try { + const res = await fetch(`${orchestratorUrl}/api/edit`, { + method: 'POST', + headers, + body: JSON.stringify({ message: '', confirm, proposal_id: proposalId }), + }); + const data = await res.json(); + setStatus(confirm === 'yes' ? 'Applied! Refreshing...' : 'Cancelled.'); + setProposalId(null); + setProposalSummary(''); + if (confirm === 'yes') { + setTimeout(() => { fetchManifest(); }, 1000); + } + } catch { + setStatus('Confirm failed'); + } + setLoading(false); + }; + + const sectionLabel = (s: ManifestEntry) => + s.headline || s.title || s.heading || s.id; + + // ── Styles ── + const styles = { + wrapper: { fontFamily: "'Source Sans 3', system-ui, sans-serif" } as React.CSSProperties, + nav: { display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' as const }, + navBtn: (active: boolean) => ({ + padding: '0.5rem 1rem', border: '1px solid #e5e0d8', borderRadius: '4px', cursor: 'pointer', + background: active ? '#2d5016' : 'white', color: active ? 'white' : '#2c2c2c', + fontSize: '0.85rem', fontWeight: 500 as const, fontFamily: 'inherit', + }), + card: { + padding: '1rem', border: '1px solid #e5e0d8', borderRadius: '6px', marginBottom: '0.75rem', + display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'white', cursor: 'pointer', + } as React.CSSProperties, + badge: (visible: boolean) => ({ + fontSize: '0.7rem', padding: '0.2rem 0.5rem', borderRadius: '3px', + background: visible ? '#e8f5e1' : '#fce8e8', color: visible ? '#2d5016' : '#a33', + fontWeight: 600 as const, + }), + textarea: { + width: '100%', minHeight: '300px', fontFamily: "'Source Code Pro', monospace", fontSize: '0.85rem', + padding: '1rem', border: '1px solid #e5e0d8', borderRadius: '6px', resize: 'vertical' as const, + }, + input: { + width: '100%', padding: '0.6rem 0.8rem', border: '1px solid #e5e0d8', borderRadius: '4px', + fontSize: '0.95rem', fontFamily: 'inherit', marginBottom: '0.75rem', + }, + btn: { + padding: '0.5rem 1.25rem', background: '#2d5016', color: 'white', border: 'none', + borderRadius: '4px', cursor: 'pointer', fontSize: '0.9rem', fontWeight: 500 as const, fontFamily: 'inherit', + }, + btnDanger: { + padding: '0.5rem 1.25rem', background: '#a33', color: 'white', border: 'none', + borderRadius: '4px', cursor: 'pointer', fontSize: '0.9rem', fontWeight: 500 as const, fontFamily: 'inherit', + }, + btnOutline: { + padding: '0.5rem 1.25rem', background: 'white', color: '#2d5016', border: '1px solid #2d5016', + borderRadius: '4px', cursor: 'pointer', fontSize: '0.9rem', fontWeight: 500 as const, fontFamily: 'inherit', + }, + status: { marginTop: '1rem', fontSize: '0.85rem', color: '#6b6b6b', fontStyle: 'italic' as const }, + heading: { fontFamily: "'DM Serif Display', Georgia, serif", fontSize: '1.4rem', color: '#1a3a0a', marginBottom: '1rem' }, + subhead: { fontFamily: "'DM Serif Display', Georgia, serif", fontSize: '1.1rem', color: '#2d5016', marginBottom: '0.75rem' }, + }; + + return ( +
+

Content Editor

+ +
+ + + + View Site → +
+ + {/* ── Section List ── */} + {view === 'sections' && ( +
+

All Sections

+ {sections.length === 0 &&

Loading sections...

} + {sections.map(s => ( +
loadSection(s)}> +
+ {sectionLabel(s)} + ({s.type}) +
{s.repo_relative_path}
+
+ {s.visible ? 'Visible' : 'Hidden'} +
+ ))} +
+ )} + + {/* ── JSON Editor ── */} + {view === 'edit' && selectedSection && ( +
+

Editing: {sectionLabel(selectedSection)}

+

{selectedSection.repo_relative_path}

+