From 619de0bb06230fc47c89d30dabeebd07db225712 Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 4 Jul 2026 11:00:41 -0700 Subject: [PATCH 1/9] feat(web): add VITE_FORCE_DEMO build flag for standalone demo --- .../2026-07-04-move-demo-to-static-site.md | 587 ++++++++++++++++++ ...6-07-04-move-demo-to-static-site-design.md | 195 ++++++ web/src/demo/mode.ts | 22 +- web/src/vite-env.d.ts | 7 + web/vite.config.ts | 173 +++--- 5 files changed, 895 insertions(+), 89 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-04-move-demo-to-static-site.md create mode 100644 docs/superpowers/specs/2026-07-04-move-demo-to-static-site-design.md diff --git a/docs/superpowers/plans/2026-07-04-move-demo-to-static-site.md b/docs/superpowers/plans/2026-07-04-move-demo-to-static-site.md new file mode 100644 index 0000000..2cab7f7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-move-demo-to-static-site.md @@ -0,0 +1,587 @@ +# Move Demo to `demo.diffsentry.app` + Refresh Marketing Site — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Relocate the public demo off the operator's real instance (`api.diffsentry.app`) to a standalone container at `demo.diffsentry.app`, and refresh the marketing site (`diffsentry.app`) to match the current product and link to the relocated demo. + +**Architecture:** The demo is the product's own Vite/React `web/dist` bundle running in fixture-backed "demo mode" (zero network calls). A build-time `VITE_FORCE_DEMO` flag hard-locks demo mode and serves it at the domain **root**; that build is served by a tiny standalone nginx container on host port **3027**, reached via a new cloudflared public hostname. The real instance turns the demo off (`DISABLE_DEMO=1`). The marketing site keeps its deliberate single-screen design but refreshes copy and adds a "Try the live demo" CTA. + +**Tech Stack:** DiffSentry — Node/TypeScript, Vite 5 + React 18 + React Router 6 (`web/`), `vite-plugin-pwa`, Docker, nginx:alpine. DiffSentry-site — static HTML + vanilla `app.js` + Tailwind Play CDN, nginx:alpine in Docker. cloudflared tunnel for ingress. + +## Global Constraints + +- **Two repos, coordinate per AGENTS.md.** Product changes that affect the site get a matching `Sync site:` update. Run `git` only inside each child repo; both are on branch `move-demo`. +- **Ports (no collisions):** real instance `3005`, site container host `8088`, **demo container host `3027`**. +- **`web/` has NO test framework** (only `dev`/`build`/`typecheck`). Do not add one for this work. Verify web changes with `tsc --noEmit`, `vite build` output assertions, and a Playwright runtime smoke. +- **Demo mode must make ZERO network calls** (`/api/*`). This is the load-bearing safety property; the Playwright smoke asserts it. +- **Never point `demo.diffsentry.app` at `localhost:3005`.** It is a *separate* container on `3027`; `api.diffsentry.app → localhost:3005` stays untouched. +- **Site CSP forbids inline scripts / `on*=` handlers.** Behavior lives in `app.js`. Linking out to `demo.diffsentry.app` is a plain anchor (navigation, not `connect-src`) — no site CSP change needed. +- **Site asset cache is 30d `immutable`.** Any edit to `index.html` or `app.js` MUST bump `asset-version` meta AND every matching `?v=N` string AND the `VER` constant in `app.js`, together. Any *new* served file must be added to the site `Dockerfile` per-file `COPY` list. +- **Real provider surface** (authoritative, from `.env.example`): `AI_PROVIDER=anthropic|openai|openai-compatible`; keys `ANTHROPIC_API_KEY/_MODEL/_BASE_URL`, `OPENAI_API_KEY/_MODEL/_BASE_URL`, `LOCAL_AI_BASE_URL/_MODEL/_API_KEY`. The vars `DIFFSENTRY_SELF_HOSTED` and `TELEMETRY` are NOT real — do not reintroduce them. +- **Spec:** `DiffSentry/docs/superpowers/specs/2026-07-04-move-demo-to-static-site-design.md`. + +--- + +# Part A — Product repo (`DiffSentry/`): relocate the demo + +All Part A commands run from the `DiffSentry/` repo root. + +## Task A1: Build-time force-demo flag (`mode.ts`, env typing, `vite.config`, PWA disable) + +**Files:** +- Modify: `web/src/demo/mode.ts` +- Modify: `web/src/vite-env.d.ts` +- Modify: `web/vite.config.ts` (currently `export default defineConfig({ plugins: [react(), VitePWA({…})], … })`) + +**Interfaces:** +- Produces: `export const FORCE_DEMO: boolean`, `export const DEMO: boolean`, `export const DEMO_BASENAME: string` from `web/src/demo/mode.ts`. `FORCE_DEMO` is true only in a `VITE_FORCE_DEMO=true` build. `DEMO_BASENAME` is `"/"` when `FORCE_DEMO`, else `"/demo"`. Detection of the `/demo` URL keys off the literal `"/demo"`, never off `DEMO_BASENAME`. + +- [ ] **Step 1: Rewrite `web/src/demo/mode.ts`** + +Replace the exported constants at the bottom of the file (keep the file's existing doc comment and the `demoPathActive()` / `demoQueryFlag()` functions, but make `demoPathActive()` test the literal `/demo`, not the basename): + +```ts +export const DEMO_PATH = "/demo"; // literal /demo route — used for URL detection only + +/** True when the current location's path is the /demo route (or a sub-route). */ +export function demoPathActive(): boolean { + if (typeof window === "undefined") return false; + const p = window.location.pathname; + return p === DEMO_PATH || p.startsWith(DEMO_PATH + "/"); +} + +/** True when ?demo=true is present in the query string. */ +function demoQueryFlag(): boolean { + if (typeof window === "undefined") return false; + return new URLSearchParams(window.location.search).get("demo") === "true"; +} + +/** + * Build-time force flag. A `VITE_FORCE_DEMO=true` build (the standalone + * demo.diffsentry.app site) is hard-locked to demo mode and served at the + * domain root. Injected via `define` in vite.config.ts. + */ +export const FORCE_DEMO: boolean = import.meta.env.VITE_FORCE_DEMO === "true"; + +/** Whether the app is running in demo mode. Evaluated once at module load. */ +export const DEMO: boolean = FORCE_DEMO || demoPathActive() || demoQueryFlag(); + +/** + * React Router basename. A forced standalone build serves at root ("/"); a + * normal build serves the demo under /demo. NOTE: detection above must never + * use this constant — if it were "/", `startsWith("/")` matches every path. + */ +export const DEMO_BASENAME: string = FORCE_DEMO ? "/" : DEMO_PATH; +``` + +- [ ] **Step 2: Augment `ImportMetaEnv` in `web/src/vite-env.d.ts`** + +Append after the existing `/// ` lines: + +```ts +interface ImportMetaEnv { + readonly VITE_FORCE_DEMO?: string; +} +interface ImportMeta { + readonly env: ImportMetaEnv; +} +``` + +- [ ] **Step 3: Convert `web/vite.config.ts` to the function form; inject the flag and disable PWA when forced** + +Read the current file first. Change `export default defineConfig({ … })` to a function that reads the env var, then (a) add a `define` that hard-injects `import.meta.env.VITE_FORCE_DEMO`, and (b) pass `disable: forceDemo` to the existing `VitePWA({ … })` call (keep all its current options; `disable` keeps the `virtual:pwa-register` module as a no-op so `usePWA.tsx`'s import still resolves, and emits no service worker): + +```ts +export default defineConfig(() => { + const forceDemo = process.env.VITE_FORCE_DEMO === "true"; + return { + // …everything the config already returns (build, server, resolve, etc.)… + define: { + "import.meta.env.VITE_FORCE_DEMO": JSON.stringify(process.env.VITE_FORCE_DEMO ?? ""), + }, + plugins: [ + react(), + VitePWA({ + disable: forceDemo, // no service worker in the public demo build + // …keep every existing VitePWA option below unchanged… + }), + ], + }; +}); +``` + +**Contingency (if the forced build in Task A3 fails on `virtual:pwa-register`):** `VitePWA({ disable: true })` is expected to keep that virtual module as a no-op. If a given `vite-plugin-pwa` version instead drops the module (build error: cannot resolve `virtual:pwa-register`), do NOT remove the plugin. Instead keep it enabled but non-emitting — set `selfDestroying: true` (and leave `injectRegister: null`, already set) under `forceDemo` — or guard the import in `web/src/pwa/usePWA.tsx` behind a dynamic `import()` skipped when `FORCE_DEMO`. Re-run Task A3 step 3 to confirm no `sw.js` is emitted. + +- [ ] **Step 4: Verify a normal build still typechecks and behaves** + +Run: `npm --prefix web run typecheck` +Expected: exits 0, no errors (confirms the `ImportMetaEnv` augmentation and `mode.ts` types are valid). + +- [ ] **Step 5: Verify a forced build typechecks** + +Run: `VITE_FORCE_DEMO=true npm --prefix web run typecheck` +Expected: exits 0. + +- [ ] **Step 6: Commit** + +```bash +git add web/src/demo/mode.ts web/src/vite-env.d.ts web/vite.config.ts +git commit -m "feat(web): add VITE_FORCE_DEMO build flag for standalone demo" +``` + +## Task A2: Guard the `/demo` redirect under force-demo (`main.tsx`) + +Under a forced root build, `DEMO` is true but `demoPathActive()` is false (path is `/`), so the existing redirect at `main.tsx:41` would `window.location.replace("")` — an infinite reload loop. Gate it off. + +**Files:** +- Modify: `web/src/main.tsx:6` (import) and `:41` (condition) + +**Interfaces:** +- Consumes: `FORCE_DEMO` from `web/src/demo/mode.ts` (Task A1). + +- [ ] **Step 1: Import `FORCE_DEMO`** + +Change the import on `web/src/main.tsx:6` from: +```ts +import { DEMO, DEMO_BASENAME, demoPathActive } from "./demo/mode"; +``` +to: +```ts +import { DEMO, DEMO_BASENAME, FORCE_DEMO, demoPathActive } from "./demo/mode"; +``` + +- [ ] **Step 2: Gate the redirect** + +Change the condition on `web/src/main.tsx:41` from: +```ts + if (DEMO && !demoPathActive()) { +``` +to: +```ts + // A forced root build (FORCE_DEMO) already serves at "/"; never redirect it + // to /demo — that would loop. Only ?demo=true on a normal build bounces. + if (DEMO && !FORCE_DEMO && !demoPathActive()) { +``` + +- [ ] **Step 3: Verify typecheck (both modes)** + +Run: `npm --prefix web run typecheck && VITE_FORCE_DEMO=true npm --prefix web run typecheck` +Expected: both exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add web/src/main.tsx +git commit -m "fix(web): don't redirect the forced root demo build to /demo" +``` + +## Task A3: Add the demo build script + +**Files:** +- Modify: `package.json` (root `scripts`) + +- [ ] **Step 1: Add the script** + +In root `package.json` `scripts`, add after `build:web`: +```json +"build:web:demo": "VITE_FORCE_DEMO=true npm --prefix web run build", +``` + +- [ ] **Step 2: Run it (may take ~30–90s → run in background if preferred)** + +Run: `npm run build:web:demo` +Expected: completes; `web/dist/index.html` exists. + +- [ ] **Step 3: Verify NO service worker was emitted** + +Run: `ls web/dist/sw.js web/dist/registerSW.js 2>/dev/null; echo "exit=$?"` +Expected: no such files (a non-zero `ls` / empty output). Confirms `VitePWA({ disable: true })` worked. + +- [ ] **Step 4: Verify the flag is baked into the bundle** + +Run: `grep -rl "VITE_FORCE_DEMO" web/dist/assets 2>/dev/null; grep -roh "true" web/dist/index.html >/dev/null; echo "built"` +Expected: prints `built`. (The env expression is replaced at build; the presence check is a sanity gate — the real behavioral check is Task A6.) + +- [ ] **Step 5: Commit** + +```bash +git add package.json +git commit -m "build: add build:web:demo (forced standalone demo bundle)" +``` + +## Task A4: Standalone demo container (`Dockerfile.demo` + demo nginx conf) + +**Files:** +- Create: `Dockerfile.demo` +- Create: `deploy/demo-nginx.conf` + +- [ ] **Step 1: Create `deploy/demo-nginx.conf`** + +```nginx +server { + listen 80; + listen [::]:80; + server_name demo.diffsentry.app localhost; + root /usr/share/nginx/html; + index index.html; + + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + # Built React app: self scripts only, Google Fonts, no external API (demo makes + # zero network calls). 'unsafe-inline' style is needed for injected component + # styles (CodeMirror, etc.). No 'unsafe-eval'. + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'" always; + + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + + # Hashed Vite assets are content-addressed and safe to cache hard. + location /assets/ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # SPA client routing: unknown paths serve the app shell. + location / { + try_files $uri $uri/ /index.html; + } + + location = /health { + add_header Content-Type text/plain; + return 200 'ok'; + } +} +``` + +- [ ] **Step 2: Create `Dockerfile.demo`** + +```dockerfile +# Standalone public demo (demo.diffsentry.app). Builds the SPA with demo mode +# hard-forced (VITE_FORCE_DEMO=true, served at root, no service worker), then +# serves the static bundle with nginx. No server, DB, keys, or .diffsentry.yaml. +FROM node:22-alpine AS build +WORKDIR /app +COPY web/package.json web/package-lock.json ./web/ +RUN npm --prefix web ci +COPY web/ ./web/ +ENV VITE_FORCE_DEMO=true +RUN npm --prefix web run build + +FROM nginx:alpine +COPY deploy/demo-nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/web/dist /usr/share/nginx/html +EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:80/health || exit 1 +``` + +- [ ] **Step 3: Build the image (slow → run in background)** + +Run: `docker build -f Dockerfile.demo -t diffsentry-demo .` +Expected: builds successfully; final `nginx:alpine` stage tagged `diffsentry-demo`. + +- [ ] **Step 4: Run and smoke the container** + +```bash +docker run -d --name diffsentry-demo-test -p 3027:80 diffsentry-demo +sleep 2 +curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:3027/ # expect 200 +curl -sS http://localhost:3027/ | grep -o '
' | head -1 # expect the SPA root div +curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:3027/health # expect 200 +``` +Expected: `200`, `
`, `200`. + +- [ ] **Step 5: Tear down the test container** + +Run: `docker rm -f diffsentry-demo-test` +Expected: removed. + +- [ ] **Step 6: Commit** + +```bash +git add Dockerfile.demo deploy/demo-nginx.conf +git commit -m "feat: standalone demo container (Dockerfile.demo + nginx)" +``` + +## Task A5: Wire the demo service into `docker-compose.yml` + +**Files:** +- Modify: `docker-compose.yml` (add a `demo` service alongside the existing `diffsentry` service) + +- [ ] **Step 1: Add the service** + +Add under `services:` (do not touch the existing `diffsentry` service): +```yaml + demo: + build: + context: . + dockerfile: Dockerfile.demo + ports: + - "3027:80" + restart: unless-stopped + container_name: diffsentry-demo +``` + +- [ ] **Step 2: Build + start only the demo service** + +```bash +docker compose build demo +docker compose up -d demo +sleep 2 +curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:3027/ # expect 200 +``` +Expected: `200`. + +- [ ] **Step 3: Commit** + +```bash +git add docker-compose.yml +git commit -m "feat: add demo service (host port 3027) to compose" +``` + +## Task A6: Runtime smoke — assert ZERO `/api` calls (Playwright) + +This is the meaningful behavioral test: the demo must render from fixtures and make no network calls. Requires the demo container running on `3027` (Task A5). + +- [ ] **Step 1: Load the demo and capture network requests** + +Using the Playwright MCP tools: +1. `browser_navigate` → `http://localhost:3027/` +2. `browser_snapshot` → confirm the dashboard shell renders (Overview) and the demo banner ("You're in demo mode") is present. +3. `browser_network_requests` → capture all requests. + +- [ ] **Step 2: Assert no backend calls and no CSP violations** + +- Assert: **no** request URL contains `/api/` (reads short-circuit to fixtures; no SSE/`/api/v1/events` either). +- `browser_console_messages` → assert **no** `Content-Security-Policy` violation errors and no uncaught errors. +- Navigate a deep link `http://localhost:3027/repos/acme/checkout-api/pr/142` → confirm it resolves at root (no bounce to `/demo`, no reload loop) and renders the fixture PR. + +Expected: zero `/api/*` requests; no CSP errors; deep link renders. + +**Contingency (only if a `/api/*` request appears):** it will be the SSE `EventStreamProvider` opening a stream. Fix by guarding it under `DEMO` in `web/src/realtime/useEventStream.tsx` (skip opening the `EventSource` when `DEMO` is true), rebuild (`npm run build:web:demo` / `docker compose build demo`), and re-run this task. Commit that fix separately: `fix(web): don't open the realtime SSE stream in demo mode`. + +- [ ] **Step 3: Record the result** in the plan checkbox (no code commit unless the contingency fix was needed). + +--- + +# Part B — Site repo (`DiffSentry-site/`): content refresh + demo link + +All Part B commands run from the `DiffSentry-site/` repo root. Preserve the deliberate single-screen design — enrich the hero column, do not add a scrolling section. + +## Task B1: Hero refresh — subhead, capability chips, demo CTA (`index.html`) + +**Files:** +- Modify: `DiffSentry-site/index.html` (hero prose `:250-252`, CTA row `:254-265`) + +- [ ] **Step 1: Refresh the hero prose** (`index.html:250-252`) + +Replace: +```html +

+ A GitHub PR review bot that runs on your own infrastructure. It leaves inline comments, writes walkthroughs, suggests fixes, and answers follow-up questions right in the thread. Your data, your keys, your rules. +

+``` +with: +```html +

+ A GitHub PR review bot that runs on your own infrastructure — inline comments, walkthroughs, suggested fixes, and in-thread chat, plus a full command center to triage findings, track AI spend, and watch review health. Any model: Claude, GPT, or a local one. Your data, your keys, your rules. +

+ + +
+ Command-center dashboard + Findings triage + AI cost tracking + Any model — local too + Static-analysis aware +
+``` + +- [ ] **Step 2: Make the demo the primary CTA** (`index.html:254-265`) + +Replace the CTA `
` (View on GitHub primary + Read the docs) with a "Try the live demo" primary, "View on GitHub" secondary (nav-style outline), and keep "Read the docs": +```html + +``` + +- [ ] **Step 3: Also add a "Live demo" link in the nav** (`index.html:203`, before the GitHub button) + +Insert immediately before the existing `GitHub` in the nav, wrapping both in a flex gap if needed: +```html + +``` +(Confirm the nav's right-hand cluster is a flex row; if it currently holds only the GitHub button directly, wrap the two anchors in `
`.) + +- [ ] **Step 4: Verify structure is intact** + +Run: `grep -c "demo.diffsentry.app" index.html` +Expected: `>= 2` (nav link + hero CTA). + +(asset-version bump for this file happens in Task B3.) + +## Task B2: Provider-accurate decorative snippets + console copy (`app.js`) + +The background diff snippets currently imply Anthropic-only and use non-existent env vars. Make them provider-accurate using the real surface (see Global Constraints). + +**Files:** +- Modify: `DiffSentry-site/app.js` — the `.diffsentry.yaml` snippet (`:155-162`), the env snippet (`:194-199`), and the `tldr()` console string (`~:483`) + +- [ ] **Step 1: Fix the `.diffsentry.yaml` snippet** (`app.js:155-162`) + +Replace the block: +```js + [ + [' ', '# .diffsentry.yaml'], + [' ', 'model: claude-opus-4'], + ['+', 'review:'], + ['+', ' inline_comments: true'], + ['+', ' walkthrough: true'], + ['-', ' auto_approve: false'], + [' ', ' max_files: 50'], + ], +``` +with (real keys from the product's `.diffsentry.yaml` surface — `reviews.profile`, `walkthrough.*`, `chat.auto_reply`): +```js + [ + [' ', '# .diffsentry.yaml'], + [' ', 'reviews:'], + ['+', ' profile: assertive'], + ['+', ' request_changes_workflow: true'], + [' ', ' walkthrough:'], + ['+', ' enabled: true'], + ['-', ' sequence_diagrams: false'], + ['+', ' sequence_diagrams: true'], + ], +``` + +- [ ] **Step 2: Fix the env snippet** (`app.js:194-199`) — provider-agnostic, real vars only + +Replace: +```js + [ + [' ', '# Your keys, your infra'], + [' ', 'export ANTHROPIC_API_KEY=sk-...'], + ['+', 'export DIFFSENTRY_SELF_HOSTED=1'], + [' ', 'export GITHUB_APP_ID=...'], + ['-', 'export TELEMETRY=on'], + ['+', 'export TELEMETRY=off'], + ], +``` +with: +```js + [ + [' ', '# Your keys, your infra — any provider'], + [' ', 'export AI_PROVIDER=anthropic # or openai, or openai-compatible'], + ['+', 'export ANTHROPIC_API_KEY=sk-ant-...'], + ['-', '# export OPENAI_API_KEY=sk-...'], + ['+', '# export LOCAL_AI_BASE_URL=http://localhost:11434/v1 # Ollama'], + [' ', 'export GITHUB_APP_ID=...'], + ], +``` + +- [ ] **Step 3: Refresh the `tldr()` console string** (`app.js`, the `tldr()` line ~483) + +Read the current `tldr()` string, then replace it with: +```js + "Self-hosted GitHub PR review bot. Inline comments, walkthroughs, suggested fixes, in-thread chat + a command center (triage, cost, health). Any model — Claude, GPT, or local. Your infra, your keys. MIT.", +``` + +- [ ] **Step 4: Verify no non-existent vars remain** + +Run: `grep -nE "DIFFSENTRY_SELF_HOSTED|TELEMETRY|claude-opus-4|auto_approve" app.js; echo "exit=$?"` +Expected: no matches (grep exits 1 / prints nothing). + +(VER bump happens in Task B3.) + +## Task B3: Bump `asset-version` v13 → v14 and rebuild the site + +Required because Task B1/B2 edited `index.html` and `app.js`, which nginx serves `immutable` for 30 days. + +**Files:** +- Modify: `DiffSentry-site/index.html` (meta `:18` + every `?v=13`), `DiffSentry-site/app.js` (`VER` constant `~:401`) + +- [ ] **Step 1: Bump all version strings together** + +```bash +# index.html: the asset-version meta + every ?v=13 query string +sed -i '' 's/content="v13"/content="v14"/; s/?v=13/?v=14/g' index.html +# app.js: the VER constant +sed -i '' "s/VER = 'v13'/VER = 'v14'/" app.js +``` + +- [ ] **Step 2: Verify consistency (no stale v13 remains)** + +Run: `grep -n "v13" index.html app.js; echo "exit=$?"` +Expected: no matches (grep prints nothing / exits 1). + +Run: `grep -c "v14" index.html` +Expected: `>= 6` (meta + favicon + svg + apple-touch + og + app.js query). + +- [ ] **Step 3: Build + serve the site container and smoke assets** + +```bash +docker compose build +docker compose up -d +sleep 2 +curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:8088/ # expect 200 +curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:8088/app.js?v=14 # expect 200 +curl -sS http://localhost:8088/ | grep -c "demo.diffsentry.app" # expect >= 2 +``` +Expected: `200`, `200`, `>= 2`. + +- [ ] **Step 4: Playwright CSP + link smoke** + +`browser_navigate` → `http://localhost:8088/`; `browser_console_messages` → assert no CSP violations; confirm the "Try the live demo" button and nav "Live demo" link point to `https://demo.diffsentry.app`. + +- [ ] **Step 5: Commit (site repo)** + +```bash +git add index.html app.js +git commit -m "Sync site: relocate demo CTA to demo.diffsentry.app + refresh copy for current product (v14)" +``` + +--- + +# Part C — Manual cutover (operator: luke) + +These are **operator actions**, not code. Do them in order after Parts A & B are deployed. + +- [ ] **C1: Deploy the demo container** on the box that runs the tunnel: `docker compose up -d demo` (from `DiffSentry/`). Verify locally: `curl -I http://localhost:3027/` → `200`. + +- [ ] **C2: Add the cloudflared public hostname.** In the tunnel config, add `demo.diffsentry.app` → `http://localhost:3027`. + - Zero Trust dashboard → your tunnel → *Public Hostnames* → Add (auto-creates the DNS CNAME), **or** add an ingress rule in `config.yml` and run `cloudflared tunnel route dns demo.diffsentry.app`, then restart `cloudflared`. + - No path routing, no new certs (the tunnel + Cloudflare terminate TLS). `api.diffsentry.app → localhost:3005` is unchanged. + - Verify: `curl -I https://demo.diffsentry.app/` → `200`; the demo loads the fixture dashboard. + +- [ ] **C3: Turn the demo off on the real instance.** Set `DISABLE_DEMO=1` in the `api.diffsentry.app` deployment env and restart it. + - Verify: `curl -s -o /dev/null -w "%{http_code}\n" https://api.diffsentry.app/demo` → `404`. Confirm your authenticated dashboard at `api.diffsentry.app` still works. + +- [ ] **C4: Deploy the refreshed site.** `docker compose up -d` from `DiffSentry-site/`. Verify `https://diffsentry.app` shows the new copy and the demo CTA reaches `https://demo.diffsentry.app`. + +--- + +## Self-review notes (author) + +- **Spec coverage:** host = subdomain (Part C2); force-demo root build (A1–A3); demo container on 3027 (A4–A5); PWA off (A1 step 3 + A3 step 3); redirect-loop guard (A2); real-instance `DISABLE_DEMO=1` (C3); site content refresh + demo CTA + multi-provider accuracy (B1–B2); asset-version bump (B3); no-network-call verification (A6). All spec sections mapped. +- **No test framework in `web/`** — Part A uses typecheck + build-output + Playwright runtime assertions by design (Global Constraints), which is the honest fit for a framework-less workspace and tests the property that actually matters (zero `/api` calls). +- **Type/name consistency:** `FORCE_DEMO`, `DEMO`, `DEMO_BASENAME`, `DEMO_PATH`, `demoPathActive` used identically across A1/A2. `demoPathActive()` keys off `DEMO_PATH` (never the basename) — the footgun the spec called out. diff --git a/docs/superpowers/specs/2026-07-04-move-demo-to-static-site-design.md b/docs/superpowers/specs/2026-07-04-move-demo-to-static-site-design.md new file mode 100644 index 0000000..c71062a --- /dev/null +++ b/docs/superpowers/specs/2026-07-04-move-demo-to-static-site-design.md @@ -0,0 +1,195 @@ +# Move the public demo to `demo.diffsentry.app` + refresh the marketing site + +- **Date:** 2026-07-04 +- **Status:** Approved (design) +- **Scope:** cross-repo (`DiffSentry`, `DiffSentry-site`) + Cloudflare Tunnel infra +- **Owner:** luke + +## Problem + +The public demo is currently served from the operator's **real** DiffSentry +instance at `api.diffsentry.app` (same origin the operator uses day-to-day). We +want the demo off the personal instance and onto its own home so the real +instance carries no public, unauthenticated surface. Separately, the marketing +site (`diffsentry.app`) has **zero "Sync site" commits** and ~50 product PRs of +drift — its copy no longer matches what the product does. + +Two goals: + +1. **Move the demo** off `api.diffsentry.app` to a dedicated host. +2. **Update the marketing site** content to reflect the current product, and + link to the relocated demo. + +## Key facts (from codebase exploration) + +- **The demo is the whole dashboard SPA in "demo mode,"** not a page. It is the + same `web/dist` Vite/React bundle as the authenticated dashboard, keyed on URL: + `/demo` path or `?demo=true` (`web/src/demo/mode.ts:13-33`). +- **In demo mode it makes zero network calls.** Every read resolves from bundled + client-side fixtures (`web/src/demo/fixtures.ts`); every write is refused + client-side (`web/src/api/client.ts:47,109`). No AI, no SQLite, no auth, no + `.diffsentry.yaml`. It is already effectively a static app. +- **Server coupling is thin:** the same Express process serves the SPA shell for + `/demo`, gated by `DISABLE_DEMO` (`src/server.ts:63-72`). Turning that off on + the real instance fully removes the public demo there. +- **The marketing site is a no-build-step vanilla page** — `index.html` + `app.js` + + Tailwind CDN, served by nginx in Docker. It was deliberately simplified to a + single screen on 2026-06-05. It has **no demo link today**; all CTAs point to + the GitHub repo. Its CSP forbids inline scripts; assets are `immutable` for 30d + and gated behind an `asset-version` meta (currently `v13`) + `?v=N` query + strings; every served file is a per-file `COPY` in the Dockerfile. +- **Do not confuse the demo with the Public Impact share** feature + (`/share/impact/:id`, `DISABLE_PUBLIC_SHARE`) — that one is DB-backed and out of + scope. + +## Goals / Non-goals + +**Goals** +- Serve the demo at **`demo.diffsentry.app`**, independent of the real instance. +- Demo serves at the domain **root** with clean URLs (no `/demo` prefix in the + public URL). +- Real instance (`api.diffsentry.app`) stops serving the demo. +- Marketing site copy is accurate to the current product and links to the demo. + +**Non-goals** +- No change to the demo's fixture data or the dashboard UI itself. +- No rebuild of the marketing site off its no-build-step vanilla stack. +- No change to the Public Impact share feature. +- No new hosting paradigm (stay on containers behind the existing cloudflared + tunnel; Cloudflare Pages noted only as an alternative). + +## Decision 1 — Host: `demo.diffsentry.app` (subdomain), not `diffsentry.app/demo` + +On cloudflared, a subdomain is materially cleaner than a subpath: + +| | `demo.diffsentry.app` (chosen) | `diffsentry.app/demo` | +|---|---|---| +| Tunnel config | one public hostname → auto DNS | order-sensitive `^/demo` path ingress rule | +| Demo build | served at root, standard Vite `base:"/"` | needs `base:"/demo/"` or vendored bundle in site nginx | +| Marketing site | untouched (one link) | image/route entangled with a heavy React bundle | +| Redeploys | demo rebuilds independently | coupled | + +The only thing the subpath buys is a single domain in the URL, which does not +justify the routing entanglement. + +## Decision 2 — Force demo mode at build time; serve at root + +Add a build-time flag so a standalone build is hard-locked to demo mode and +served at `/` (no `/demo` prefix). Small, additive, and preserves all existing +behavior (`/demo` path and `?demo=true` still work on any normal build). + +Product-repo changes (`DiffSentry`): + +- **`web/src/demo/mode.ts`** — introduce a build-time force flag and fold it into + the exported `DEMO`. Keep the **detection** path (`/demo`) separate from the + **router basename** so force mode doesn't break `demoPathActive()`: + ```ts + export const FORCE_DEMO = import.meta.env.VITE_FORCE_DEMO === "true"; + const DEMO_PATH = "/demo"; // used for URL detection only + // demoPathActive() must keep testing DEMO_PATH (NOT the basename): if the + // basename became "/", `p.startsWith("/")` would be true for every path. + export const DEMO: boolean = FORCE_DEMO || demoPathActive() || demoQueryFlag(); + // Router basename: force build serves at root; otherwise under /demo. + export const DEMO_BASENAME = FORCE_DEMO ? "/" : DEMO_PATH; + ``` + (Declare `VITE_FORCE_DEMO` in the web `vite-env.d.ts` `ImportMetaEnv`.) +- **`web/src/main.tsx:41-49`** — skip the `/demo` redirect when `FORCE_DEMO` (the + app is already at root; `demoPathActive()` would be false and must not trigger a + redirect loop). Gate the redirect on `DEMO && !FORCE_DEMO && !demoPathActive()`. +- **`web/src/router.tsx:108`** — already uses `DEMO_BASENAME`; no change once the + constant resolves to `/` under force mode. Verify `basename:"/"` is a no-op. +- **PWA:** disable the service worker / install prompt in the force-demo build + (the public demo should not offer to "install DiffSentry Command Center" or + precache a shell). Gate `VitePWA(...)` on `!VITE_FORCE_DEMO`, or add a + `build:web:demo` script that builds with the flag and PWA off. Confirm no SW is + emitted in the demo bundle. +- **Build script** — add `build:web:demo` (e.g. `VITE_FORCE_DEMO=true npm --prefix + web run build`) producing `web/dist` suitable for static serving at root. + +Alternative considered (rejected for URL aesthetics): serve today's `web/dist` +unchanged and `return 302 /demo/` at nginx root — zero product change, but public +URLs read `demo.diffsentry.app/demo/…`. + +## Decision 3 — Demo deployment lives in the product repo + +The demo *is* the product's web build, so its container home is `DiffSentry` +(rebuilding there keeps it in sync automatically): + +- A **SPA-only image** (nginx:alpine) that serves the force-demo `web/dist` at + root on a container port, with a static-site-appropriate CSP (self + Tailwind + is not used here — this is the built React app, so `script-src 'self'` plus + whatever the Vite bundle needs; fonts from Google as the app already uses). + Mirror the security headers from the site's nginx.conf. +- A **`demo` service** wired into deployment (compose service or equivalent) + exposing host port **3027** locally for the tunnel to reach (the real instance + keeps `3005`; the site container keeps `8088`). +- No server, DB, private key, or `.diffsentry.yaml` mounted. + +## Decision 4 — Marketing site content refresh (`DiffSentry-site`) + +Keep the minimalist single-screen instinct; close the accuracy gap: + +- Keep the hero; refresh the subhead. +- Add **one tasteful "what it does now" strip** — a small set of maturity signals: + command-center dashboard, findings triage (accept/dismiss/snooze), AI cost + tracking, multi-provider incl. local / OpenAI-compatible, static-analysis-aware + reviews (lint/typecheck/SAST), repo health scorecards. Not a feature wall. +- **Fix stale claims** in the decorative `app.js` snippets: today they imply + Anthropic-only (`ANTHROPIC_API_KEY`, `model: claude-opus-4`). Update to reflect + multi-provider / BYOK and reconcile config keys against the real + `.diffsentry.yaml`. (Ground truth: `DiffSentry-site/.diffsentry.yaml` and the + product README.) +- Add a prominent **"Try the live demo → demo.diffsentry.app"** CTA in the hero. +- Respect CSP: no inline scripts / `on*=` handlers; behavior goes in `app.js`. + Linking out to `demo.diffsentry.app` is a plain anchor — no CSP change needed + (it is a navigation, not `connect-src`). +- **Bump `asset-version` v13 → v14** and every matching `?v=` string + the `VER` + constant in `app.js` (enforced by the site's own `.diffsentry.yaml` check). +- Any new asset must be added to the Dockerfile per-file `COPY` list. + +## What the operator does manually (Cloudflare / DNS) + +1. Add a **public hostname** to the existing cloudflared tunnel: + `demo.diffsentry.app` → `http://localhost:3027`. (Your `api.diffsentry.app → + localhost:3005` personal instance is untouched — the demo is a separate + container on its own port.) + - Zero Trust dashboard → tunnel → Public Hostnames (auto-creates the DNS + CNAME), **or** `config.yml` ingress + `cloudflared tunnel route dns + demo.diffsentry.app`. +2. **No path routing, no new certs** — the tunnel + Cloudflare terminate TLS. +3. **Cleanup on the real instance:** set `DISABLE_DEMO=1` on the + `api.diffsentry.app` deployment and restart, so `/demo` there returns 404. + +## Rollout / cutover sequence + +1. Land the product-repo demo-build changes + demo image/service. +2. Deploy the demo container; add the tunnel public hostname; verify + `demo.diffsentry.app` loads the fixture dashboard at root, makes no network + calls, and refuses writes. +3. Land the marketing-site refresh with the demo CTA; deploy; bump asset-version. +4. Set `DISABLE_DEMO=1` on `api.diffsentry.app`; confirm `/demo` 404s there. +5. Coordinate the two repos per AGENTS.md (product change that affects the site → + matching `Sync site:` update). + +## Risks / watch-items + +- **Redirect loop** if `main.tsx` still redirects under force-demo — must gate on + `!FORCE_DEMO`. Verify no reload loop at root. +- **Service worker** from a prior visit to `api.diffsentry.app/demo` could cache + an old shell; disabling PWA in the demo build avoids emitting one going forward. + (Different origin anyway, so cross-contamination is unlikely.) +- **CSP mismatch** in the demo nginx: the built React app's needs differ from the + marketing page's; derive the demo CSP from what the Vite bundle actually loads. +- **asset-version drift** on the site: bump meta + all `?v=` + `VER` together or + nginx serves stale immutable assets for 30 days. +- **Dockerfile COPY omissions** on the site: any new asset not in the COPY list + 404s in the container. + +## Verification + +- Demo: load `demo.diffsentry.app`, DevTools Network shows **no** `/api` calls; + attempting a write action surfaces the client-side `403 demo_readonly`; deep + links resolve at root; no SW registered. +- Real instance: `curl -sS https://api.diffsentry.app/demo` → 404. +- Site: renders with refreshed copy + working demo link; asset-version bumped; + container serves all assets (no 404s); CSP unbroken (no console violations). diff --git a/web/src/demo/mode.ts b/web/src/demo/mode.ts index bbb83f5..887b9cd 100644 --- a/web/src/demo/mode.ts +++ b/web/src/demo/mode.ts @@ -10,13 +10,13 @@ // regardless of what the server happens to expose. The server-side /demo route // only serves the static SPA shell (see src/server.ts); it mounts no data API. -export const DEMO_BASENAME = "/demo"; +export const DEMO_PATH = "/demo"; // literal /demo route — used for URL detection only /** True when the current location's path is the /demo route (or a sub-route). */ export function demoPathActive(): boolean { if (typeof window === "undefined") return false; const p = window.location.pathname; - return p === DEMO_BASENAME || p.startsWith(DEMO_BASENAME + "/"); + return p === DEMO_PATH || p.startsWith(DEMO_PATH + "/"); } /** True when ?demo=true is present in the query string. */ @@ -26,8 +26,18 @@ function demoQueryFlag(): boolean { } /** - * Whether the app is running in demo mode. Evaluated once at module load. - * `?demo=true` at any path counts as demo too; main.tsx redirects those to the - * canonical /demo route so React Router's basename stays consistent. + * Build-time force flag. A `VITE_FORCE_DEMO=true` build (the standalone + * demo.diffsentry.app site) is hard-locked to demo mode and served at the + * domain root. Injected via `define` in vite.config.ts. */ -export const DEMO: boolean = demoPathActive() || demoQueryFlag(); +export const FORCE_DEMO: boolean = import.meta.env.VITE_FORCE_DEMO === "true"; + +/** Whether the app is running in demo mode. Evaluated once at module load. */ +export const DEMO: boolean = FORCE_DEMO || demoPathActive() || demoQueryFlag(); + +/** + * React Router basename. A forced standalone build serves at root ("/"); a + * normal build serves the demo under /demo. NOTE: detection above must never + * use this constant — if it were "/", `startsWith("/")` matches every path. + */ +export const DEMO_BASENAME: string = FORCE_DEMO ? "/" : DEMO_PATH; diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts index 64251fb..0d66390 100644 --- a/web/src/vite-env.d.ts +++ b/web/src/vite-env.d.ts @@ -1,2 +1,9 @@ /// /// + +interface ImportMetaEnv { + readonly VITE_FORCE_DEMO?: string; +} +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 8d1c635..dc9ecea 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -6,92 +6,99 @@ import path from "node:path"; // The SPA is served by the same Express process in production (static files // out of web/dist). In dev we run Vite on its own port and proxy /api to the // backend on 3005 so the single-origin cookie/session model still works. -export default defineConfig({ - plugins: [ - react(), - // PWA: installable + offline app shell. The service worker precaches the - // built shell (JS/CSS/HTML/icons) and the Google Fonts it depends on, and - // falls back to the cached index.html for offline navigations. - // - // SECURITY: we deliberately register NO runtime-caching rule for /api, so - // authenticated JSON responses are never written to the Cache Storage that - // persists across sessions/users on a shared device. Offline "last-viewed - // data" is instead handled in-app by a user-scoped, logout-purged - // TanStack Query persister (see src/lib/persist.ts) — not the SW. - VitePWA({ - // Surface an explicit "update available" prompt instead of silently - // reloading — this is a command center where a reload could interrupt a - // privileged action mid-flight. We register the SW ourselves so we can - // drive that prompt (see src/pwa/registerSW.ts). - registerType: "prompt", - injectRegister: null, - includeAssets: ["favicon.svg", "apple-touch-icon.png"], - manifest: { - name: "DiffSentry Command Center", - short_name: "DiffSentry", - description: "Self-hosted AI PR review — review ops dashboard.", - id: "/", - start_url: "/", - scope: "/", - display: "standalone", - orientation: "any", - background_color: "#0a0c13", - theme_color: "#0a0c13", - categories: ["developer", "productivity"], - icons: [ - { src: "icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }, - { src: "icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" }, - { src: "icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" }, - { src: "maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" }, - ], - }, - workbox: { - globPatterns: ["**/*.{js,css,html,svg,png,ico,woff,woff2}"], - cleanupOutdatedCaches: true, - clientsClaim: true, - // Offline navigations serve the cached app shell. Never hand the SPA - // shell to the API, the webhook, the legacy dashboard, the SSE stream, - // or the health check — those must always hit the network. - navigateFallback: "/index.html", - navigateFallbackDenylist: [/^\/api/, /^\/webhook/, /^\/dashboard/, /^\/health/, /^\/stream/], - runtimeCaching: [ - { - urlPattern: ({ url }) => url.origin === "https://fonts.googleapis.com", - handler: "StaleWhileRevalidate", - options: { cacheName: "google-fonts-stylesheets" }, - }, - { - urlPattern: ({ url }) => url.origin === "https://fonts.gstatic.com", - handler: "CacheFirst", - options: { - cacheName: "google-fonts-webfonts", - expiration: { maxEntries: 16, maxAgeSeconds: 60 * 60 * 24 * 365 }, - cacheableResponse: { statuses: [0, 200] }, +export default defineConfig(() => { + const forceDemo = process.env.VITE_FORCE_DEMO === "true"; + return { + define: { + "import.meta.env.VITE_FORCE_DEMO": JSON.stringify(process.env.VITE_FORCE_DEMO ?? ""), + }, + plugins: [ + react(), + // PWA: installable + offline app shell. The service worker precaches the + // built shell (JS/CSS/HTML/icons) and the Google Fonts it depends on, and + // falls back to the cached index.html for offline navigations. + // + // SECURITY: we deliberately register NO runtime-caching rule for /api, so + // authenticated JSON responses are never written to the Cache Storage that + // persists across sessions/users on a shared device. Offline "last-viewed + // data" is instead handled in-app by a user-scoped, logout-purged + // TanStack Query persister (see src/lib/persist.ts) — not the SW. + VitePWA({ + disable: forceDemo, // no service worker in the public demo build + // Surface an explicit "update available" prompt instead of silently + // reloading — this is a command center where a reload could interrupt a + // privileged action mid-flight. We register the SW ourselves so we can + // drive that prompt (see src/pwa/registerSW.ts). + registerType: "prompt", + injectRegister: null, + includeAssets: ["favicon.svg", "apple-touch-icon.png"], + manifest: { + name: "DiffSentry Command Center", + short_name: "DiffSentry", + description: "Self-hosted AI PR review — review ops dashboard.", + id: "/", + start_url: "/", + scope: "/", + display: "standalone", + orientation: "any", + background_color: "#0a0c13", + theme_color: "#0a0c13", + categories: ["developer", "productivity"], + icons: [ + { src: "icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }, + { src: "icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" }, + { src: "icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" }, + { src: "maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" }, + ], + }, + workbox: { + globPatterns: ["**/*.{js,css,html,svg,png,ico,woff,woff2}"], + cleanupOutdatedCaches: true, + clientsClaim: true, + // Offline navigations serve the cached app shell. Never hand the SPA + // shell to the API, the webhook, the legacy dashboard, the SSE stream, + // or the health check — those must always hit the network. + navigateFallback: "/index.html", + navigateFallbackDenylist: [/^\/api/, /^\/webhook/, /^\/dashboard/, /^\/health/, /^\/stream/], + runtimeCaching: [ + { + urlPattern: ({ url }) => url.origin === "https://fonts.googleapis.com", + handler: "StaleWhileRevalidate", + options: { cacheName: "google-fonts-stylesheets" }, }, - }, - ], + { + urlPattern: ({ url }) => url.origin === "https://fonts.gstatic.com", + handler: "CacheFirst", + options: { + cacheName: "google-fonts-webfonts", + expiration: { maxEntries: 16, maxAgeSeconds: 60 * 60 * 24 * 365 }, + cacheableResponse: { statuses: [0, 200] }, + }, + }, + ], + }, + // Keep the SW out of `vite dev` so local development isn't fighting a + // cache; it builds and runs only in production (`npm run build`). + devOptions: { enabled: false }, + }), + ], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), }, - // Keep the SW out of `vite dev` so local development isn't fighting a - // cache; it builds and runs only in production (`npm run build`). - devOptions: { enabled: false }, - }), - ], - resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), }, - }, - server: { - port: 5174, - proxy: { - "/api": { - target: "http://localhost:3005", - changeOrigin: true, + server: { + port: 5174, + proxy: { + "/api": { + target: "http://localhost:3005", + changeOrigin: true, + }, }, }, - }, - build: { - outDir: "dist", - sourcemap: false, - }, + build: { + outDir: "dist", + sourcemap: false, + }, + }; }); From d4e1ae297723ce7cc475c9b99563e3be751c01cb Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 4 Jul 2026 11:01:01 -0700 Subject: [PATCH 2/9] fix(web): don't redirect the forced root demo build to /demo --- web/src/main.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/src/main.tsx b/web/src/main.tsx index f235701..d44400b 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider } from "react-router-dom"; import { router } from "./router"; -import { DEMO, DEMO_BASENAME, demoPathActive } from "./demo/mode"; +import { DEMO, DEMO_BASENAME, FORCE_DEMO, demoPathActive } from "./demo/mode"; import { AuthProvider } from "./auth/useAuth"; import { EventStreamProvider } from "./realtime/useEventStream"; import { ToastProvider } from "./realtime/toast"; @@ -38,7 +38,9 @@ async function bootstrap() { // route (path + non-demo query + hash) under /demo so a deep link like // /repos/acme/checkout-api/pr/142?demo=true lands on the analogous demo page // rather than the demo root. Abort this render; the redirect reloads under /demo. - if (DEMO && !demoPathActive()) { + // A forced root build (FORCE_DEMO) already serves at "/"; never redirect it + // to /demo — that would loop. Only ?demo=true on a normal build bounces. + if (DEMO && !FORCE_DEMO && !demoPathActive()) { const query = new URLSearchParams(window.location.search); query.delete("demo"); const qs = query.toString(); From c0b5b8efaca50af0e8a66e3c5fab9a59e414239f Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 4 Jul 2026 11:01:28 -0700 Subject: [PATCH 3/9] build: add build:web:demo (forced standalone demo bundle) --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index fcdaf1a..2ecefc1 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "npm run build:server && npm run build:web", "build:server": "tsc", "build:web": "npm --prefix web ci && npm --prefix web run build", + "build:web:demo": "VITE_FORCE_DEMO=true npm --prefix web run build", "test": "vitest run", "test:watch": "vitest", "lint": "eslint .", From 847e438565a96a8acbe8052cef75ce3e3cb16c21 Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 4 Jul 2026 11:08:34 -0700 Subject: [PATCH 4/9] feat: standalone demo container (Dockerfile.demo + nginx) Co-Authored-By: Claude Opus 4.8 --- Dockerfile.demo | 16 ++++++++++++++++ deploy/demo-nginx.conf | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 Dockerfile.demo create mode 100644 deploy/demo-nginx.conf diff --git a/Dockerfile.demo b/Dockerfile.demo new file mode 100644 index 0000000..edb196d --- /dev/null +++ b/Dockerfile.demo @@ -0,0 +1,16 @@ +# Standalone public demo (demo.diffsentry.app). Builds the SPA with demo mode +# hard-forced (VITE_FORCE_DEMO=true, served at root, no service worker), then +# serves the static bundle with nginx. No server, DB, keys, or .diffsentry.yaml. +FROM node:22-alpine AS build +WORKDIR /app +COPY web/package.json web/package-lock.json ./web/ +RUN npm --prefix web ci +COPY web/ ./web/ +ENV VITE_FORCE_DEMO=true +RUN npm --prefix web run build + +FROM nginx:alpine +COPY deploy/demo-nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/web/dist /usr/share/nginx/html +EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:80/health || exit 1 diff --git a/deploy/demo-nginx.conf b/deploy/demo-nginx.conf new file mode 100644 index 0000000..81fc0be --- /dev/null +++ b/deploy/demo-nginx.conf @@ -0,0 +1,34 @@ +server { + listen 80; + listen [::]:80; + server_name demo.diffsentry.app localhost; + root /usr/share/nginx/html; + index index.html; + + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + # Built React app: self scripts only, Google Fonts, no external API (demo makes + # zero network calls). 'unsafe-inline' style is needed for injected component + # styles (CodeMirror, etc.). No 'unsafe-eval'. + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'" always; + + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + + # Hashed Vite assets are content-addressed and safe to cache hard. + location /assets/ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # SPA client routing: unknown paths serve the app shell. + location / { + try_files $uri $uri/ /index.html; + } + + location = /health { + add_header Content-Type text/plain; + return 200 'ok'; + } +} From 3a1092fb93bc285e196803de254d719a66cd4e2c Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 4 Jul 2026 11:08:34 -0700 Subject: [PATCH 5/9] feat: add demo service (host port 3027) to compose Co-Authored-By: Claude Opus 4.8 --- docker-compose.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 6ceccb4..606b0e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,5 +10,14 @@ services: - diffsentry-data:/app/data restart: unless-stopped + demo: + build: + context: . + dockerfile: Dockerfile.demo + ports: + - "3027:80" + restart: unless-stopped + container_name: diffsentry-demo + volumes: diffsentry-data: From 372b027a03f2744d33459d1172189030fdafa6f0 Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 4 Jul 2026 11:33:51 -0700 Subject: [PATCH 6/9] fix(demo): harden CSP with pinned inline-script hash + .dockerignore - CSP: pin sha256 of the app's inline theme/FOUC bootstrap instead of opening script-src to 'unsafe-inline', matching the diffsentry.app nginx precedent. A future edit to that bootstrap is fail-loud (visible CSP console error), caught by the demo smoke. - Add .dockerignore so 'COPY web/ ./web/' can't overlay the host's platform-specific node_modules onto the container npm ci; build context is ~6kB and reproducible from source (applies to the main Dockerfile too). Verified via Playwright on :3027 => 0 console errors, 0 /api calls. Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 18 ++++++++++++++++++ deploy/demo-nginx.conf | 13 +++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0c7681d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +# Build-context hygiene. Both Dockerfile and Dockerfile.demo run `npm ci` and +# build fresh INSIDE the image; a later `COPY web/ ./` (or `COPY web/ ./web/`) +# must not overlay the host's platform-specific node_modules onto the +# container's install, nor drag stale build output into the context. +**/node_modules +**/dist +web/dev-dist + +# VCS, scratch, and local-only tooling +.git +.gitignore +.superpowers +.playwright-mcp +**/*.log + +# Never bake secrets into the build context (mounted at runtime, not built in) +.env +*.pem diff --git a/deploy/demo-nginx.conf b/deploy/demo-nginx.conf index 81fc0be..8682cd3 100644 --- a/deploy/demo-nginx.conf +++ b/deploy/demo-nginx.conf @@ -8,10 +8,15 @@ server { add_header X-Frame-Options SAMEORIGIN always; add_header X-Content-Type-Options nosniff always; add_header Referrer-Policy strict-origin-when-cross-origin always; - # Built React app: self scripts only, Google Fonts, no external API (demo makes - # zero network calls). 'unsafe-inline' style is needed for injected component - # styles (CodeMirror, etc.). No 'unsafe-eval'. - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'" always; + # Built React app, fixtures only — no secrets, no auth, no user input, no API + # (demo makes zero network calls). The app's index.html ships one inline + # theme/FOUC bootstrap script; rather than open script-src to 'unsafe-inline' + # (which the sibling diffsentry.app nginx deliberately avoids), we pin its + # sha256 so only that exact script runs inline. If that bootstrap is ever + # edited, this hash must be recomputed — the mismatch is fail-loud (a visible + # CSP console error), so the demo smoke catches it. 'unsafe-inline' style + # covers injected component styles (CodeMirror, etc.). No 'unsafe-eval'. + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-UDmC3IkULnzs9gwNDZm9m/uQ3xynmJVzhBrYq5MnW6Q='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'" always; gzip on; gzip_types text/css application/javascript application/json image/svg+xml; From 9736fbc4f021e4d15272543529ca573776bebf61 Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 4 Jul 2026 12:49:49 -0700 Subject: [PATCH 7/9] fix(demo): set /health MIME via default_type, not add_header add_header Content-Type appends a header on top of the one nginx already sets from default_type (application/octet-stream in the base image) for the return, yielding a duplicate/nondeterministic Content-Type. default_type sets a single, correct text/plain. Body 'ok' unchanged. Co-Authored-By: Claude Opus 4.8 --- deploy/demo-nginx.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/demo-nginx.conf b/deploy/demo-nginx.conf index 8682cd3..8dbb5bd 100644 --- a/deploy/demo-nginx.conf +++ b/deploy/demo-nginx.conf @@ -33,7 +33,7 @@ server { } location = /health { - add_header Content-Type text/plain; + default_type text/plain; return 200 'ok'; } } From ba41efb97864437cd0723867df031412aced42f1 Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 4 Jul 2026 15:19:34 -0700 Subject: [PATCH 8/9] fix(demo): mark the SPA shell no-store so a stale index.html can't reference gone bundles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit index.html is the Vite release manifest (points at hashed /assets that are replaced each deploy); a cached shell could request bundles that no longer exist. Default to Cache-Control: no-store at the server level; the /assets location overrides it with immutable for the content-addressed bundles. Server level (not a per-location add_header on the shell) so index.html keeps the security + CSP headers — a location add_header would drop that inheritance. Co-Authored-By: Claude Opus 4.8 --- deploy/demo-nginx.conf | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/deploy/demo-nginx.conf b/deploy/demo-nginx.conf index 8dbb5bd..13e6ea1 100644 --- a/deploy/demo-nginx.conf +++ b/deploy/demo-nginx.conf @@ -18,6 +18,15 @@ server { # covers injected component styles (CodeMirror, etc.). No 'unsafe-eval'. add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-UDmC3IkULnzs9gwNDZm9m/uQ3xynmJVzhBrYq5MnW6Q='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'" always; + # The SPA shell (index.html) is the release manifest — it points at hashed + # /assets bundles that are replaced on every deploy. A stale cached shell would + # request bundles that no longer exist, i.e. broken loads. Default everything + # to no-store; the /assets location below overrides this with immutable for the + # content-addressed bundles. Set at server level (not a per-location add_header + # on the shell) so index.html keeps the security + CSP headers above — a + # location-level add_header would drop that inheritance. + add_header Cache-Control "no-store" always; + gzip on; gzip_types text/css application/javascript application/json image/svg+xml; From 59b77adde2c7b496ed0ae600ba1b64fcb2c53551 Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 4 Jul 2026 15:24:17 -0700 Subject: [PATCH 9/9] fix(demo): re-assert security headers on the /assets location Defining add_header inside location /assets/ replaces (does not merge) the inherited server-level headers, so the hashed JS/CSS were shipping without X-Frame-Options / X-Content-Type-Options / Referrer-Policy. Re-assert them in the block. CSP is intentionally not repeated: a CSP response header is inert on a static sub-resource (only the document's CSP governs script/style loads), so there's no benefit to duplicating the long policy string. Co-Authored-By: Claude Opus 4.8 --- deploy/demo-nginx.conf | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/deploy/demo-nginx.conf b/deploy/demo-nginx.conf index 13e6ea1..77759de 100644 --- a/deploy/demo-nginx.conf +++ b/deploy/demo-nginx.conf @@ -33,7 +33,16 @@ server { # Hashed Vite assets are content-addressed and safe to cache hard. location /assets/ { expires 30d; - add_header Cache-Control "public, immutable"; + add_header Cache-Control "public, immutable" always; + # nginx replaces (does not merge) inherited add_headers when a location + # defines its own, so re-assert the security headers here — otherwise the + # hashed JS/CSS ship without them. CSP is intentionally omitted: a CSP + # *response* header has no effect on a static sub-resource (script/style/ + # font) — only the enclosing document's CSP governs it — so there is nothing + # to gain by duplicating the long policy string here. + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; } # SPA client routing: unknown paths serve the app shell.