Files
rfc-app/deploy/nginx/ohm.wiggleverse.org.conf
T
Ben Stull 999c4b65ef §22 M3 frontend: /p/<project>/ routing, runtime branding, directory, 308s (v0.35.0)
Implements the M3-frontend slice of the §22 multi-project track, per
docs/superpowers/specs/2026-06-03-m3-frontend-design.md (design merged in
#10). Completes the runtime-config cut 0.33.0 (M3-backend Plan A) began.

Frontend:
- DeploymentProvider boots GET /api/deployment → {name, tagline,
  defaultProjectId, projects}; brandTitle() neutral 'RFC' pre-fetch fallback.
- /p/:projectId/* routing with generic /e/<slug> segment. ProjectLayout
  fetches /api/projects/:id, applies per-project theme (reset on switch),
  provides ProjectContext, guards the corpus (served only for the default;
  others get NotServedPlaceholder — decouples this slice from Plan B).
- Directory at / (2+ projects) with N=1 redirect into the single project;
  ProjectSwitcher in deployment chrome; entry-noun by project type.
- VITE_APP_NAME hard cut: removed from vite.config + index.html; the 6 brand
  reads now use deployment.name via context; static <title>RFC</title> + JS
  document.title. Internal /rfc·/proposals links → /p/<project>/e|proposals
  via lib/entryPaths.

Backend:
- GET /api/deployment returns default_project_id (the guard contract).
- Server-side 308s: /rfc/<slug>, /rfc/<slug>/pr/<n>, /proposals/<n> →
  /p/<default>/… . nginx (testing + prod) routes /rfc/ and /proposals/ to
  the backend.

Tests: 3 new backend redirect/deployment tests (438 pass); Vitest unit for
DeploymentProvider, ProjectLayout (theme/guard/404), Directory (11 pass);
clean build with no VITE_APP_NAME. Playwright e2e deferred until Tier-1 seeds
a registry (see CHANGELOG 0.35.0 step 5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 05:58:59 -07:00

170 lines
8.4 KiB
Plaintext

# nginx vhost for the RFC app — single-process FastAPI behind nginx,
# frontend served as static files from the Vite build output.
#
# Install:
# sudo cp deploy/nginx/ohm.wiggleverse.org.conf \
# /etc/nginx/sites-available/ohm.wiggleverse.org
# sudo ln -s /etc/nginx/sites-available/ohm.wiggleverse.org \
# /etc/nginx/sites-enabled/
# sudo nginx -t && sudo systemctl reload nginx
#
# TLS: this vhost terminates with the *.wiggleverse.org wildcard cert installed
# on the VM (see the ssl_* directives in the 443 block below). This REPLACES the
# old per-host certbot cert — the live file certbot previously rewrote on the VM
# is superseded once you install this one. Put the cert files in place first:
# sudo install -m 600 -o root -g root privkey.pem /etc/ssl/private/wiggleverse-wildcard.key
# sudo install -m 644 fullchain.pem /etc/ssl/certs/wiggleverse-wildcard.crt
# then `sudo nginx -t && sudo systemctl reload nginx`. Once cut over, retire the
# old cert: `sudo certbot delete --cert-name ohm.wiggleverse.org`.
#
# Cloudflare: with an origin wildcard in place, set SSL/TLS mode to Full (strict)
# and flip ohm to the orange cloud (proxied). If this is a Cloudflare Origin CA
# cert it ONLY validates behind the proxy — so cut the cert over and proxy in the
# same change; do not leave ohm grey-cloud with an Origin CA cert.
# HTTP → HTTPS redirect. Cloudflare also redirects at the edge once proxied, but
# keep the origin honest for direct hits.
server {
listen 80;
listen [::]:80;
server_name ohm.wiggleverse.org;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ohm.wiggleverse.org;
# TLS — *.wiggleverse.org wildcard installed on the VM (see top comment).
# These files must exist before `nginx -t` / reload, or nginx fails to start.
ssl_certificate /etc/ssl/certs/wiggleverse-wildcard.crt;
ssl_certificate_key /etc/ssl/private/wiggleverse-wildcard.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSLwiggle:10m;
ssl_session_timeout 1d;
# NOTE: once proxied behind Cloudflare, $remote_addr is a Cloudflare edge IP.
# To restore the real client IP (for logging / Turnstile / rate limits), add a
# real_ip block (set_real_ip_from <CF ranges>; real_ip_header CF-Connecting-IP;).
# Left out here to avoid baking stale CF ranges into the repo — see follow-up.
# v0.25.0 security hardening (audit 0026 M2/L8)
#
# NOTE: these response headers live in the HTTPS (443) server block. They use
# `add_header ... always` so they also apply to nginx-generated error
# responses (4xx/5xx), not just 200s.
#
# `server_tokens off` (L8) — suppress the nginx version in the
# Server header and on error pages so we don't advertise the
# build to scanners.
server_tokens off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Content-Security-Policy (M2). Tuned to what the SPA actually loads:
# - default-src 'self': everything not called out below is same-origin.
# - script-src 'self' + challenges.cloudflare.com: the only external
# <script> tag the app injects is the CloudFlare Turnstile widget
# (frontend/src/components/TurnstileWidget.jsx). Amplitude and
# mermaid are BUNDLED (dynamic `import()` from node_modules, served
# from 'self'), so they need no extra script origin — *.amplitude.com
# is listed defensively in case a future SDK build script-injects.
# script-src DELIBERATELY OMITS 'unsafe-inline' — no inline <script>
# is used, so we keep XSS-via-inline-script blocked.
# - style-src 'unsafe-inline' IS REQUIRED by the current build: the
# JSX uses inline `style={...}` attributes throughout and mermaid
# injects <style> blocks at render time. Removing it would break
# layout; tightening this is a future build-side change (nonce/hash).
# - img-src 'self' data: https: — markdown/RFC bodies may embed remote
# images and data: URIs; svg/mermaid output uses data: too.
# - font-src 'self' data: — bundled fonts plus data: webfonts.
# - connect-src 'self' + *.amplitude.com + challenges.cloudflare.com:
# the app's API/auth/SSE are same-origin (nginx proxy); Amplitude
# Analytics + Session Replay (shipped at sampleRate 1) POST to
# *.amplitude.com; Turnstile verifies via challenges.cloudflare.com.
# - worker-src 'self' blob: — Amplitude Session Replay spins up a
# Web Worker from a blob: URL for capture/compression; without
# blob: here session replay breaks for every consenting user.
# - frame-src challenges.cloudflare.com — the Turnstile challenge
# renders in an iframe from that origin.
# - frame-ancestors 'none' — clickjacking defense, pairs with
# X-Frame-Options DENY for older agents.
# - base-uri 'self'; object-src 'none' — lock down <base>/<object>.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://challenges.cloudflare.com https://*.amplitude.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://*.amplitude.com https://challenges.cloudflare.com; worker-src 'self' blob:; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always;
# Static SPA assets live in the Vite build output. The systemd unit
# runs as user `rfc-app`; make sure nginx (usually `www-data`) can
# read this path. Either group-add www-data into rfc-app's group, or
# chmod o+r on the dist/ tree.
root /opt/rfc-app/frontend/dist;
index index.html;
# API routes are proxied to the FastAPI process. SSE chat streams
# need proxy_buffering off so chunks reach the browser immediately;
# the long read_timeout matches a slow LLM turn.
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
}
location /auth/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# §22.10: the old corpus-root URLs (/rfc/<slug>, /rfc/<slug>/pr/<n>,
# /proposals/<n>) are 308-redirected to the project-scoped /p/<default>/…
# routes by the backend, so route them to FastAPI rather than serving the
# SPA index.html. Must precede the `location /` SPA fallback.
location /rfc/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /proposals/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA fallback — any non-asset path falls back to index.html so
# React Router can take over.
location / {
try_files $uri $uri/ /index.html;
}
# Cache the hashed JS/CSS bundles aggressively; Vite includes a
# content-hash in the filename so updates bust the cache for free.
location ~* \.(js|css|woff2?|ttf|otf|eot|png|jpg|jpeg|gif|svg|ico)$ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Reasonable upload cap. Adjust if RFC bodies grow large.
client_max_body_size 4M;
}