Lightweight per-VM runtime for the SPV website constructor. Runs on a fresh Ubuntu 22.04 VM and:
- accepts site configs + asset/document uploads from the admin orchestrator over an SSH tunnel
- renders 11 types of content blocks to static HTML via a React server-side renderer CLI
- serves the rendered sites via nginx
- provisions per-site Let's Encrypt certs via certbot
This repo is what bootstrap.sh configures. The admin orchestrator lives in a separate repo and reaches this VM's host-api over an SSH tunnel — host-api never binds to a public interface.
shared/blocks.ts— canonical block schema shared byte-identically with the admin repo.host-api/— Fastify 4 service bound to127.0.0.1:3001. Bearer-authenticated (token at/etc/ff-host/bearer).renderer/— Node CLI that reads/var/sites/<slug>/config.jsonand writes static HTML to/var/www/<slug>.new/.nginx/— basenginx.conf+ per-site template with/pdf-streamproxy and CSP isolation for the PDF.js viewer.systemd/ff-host-api.service— hardened unit file for the host-api.bootstrap.sh— idempotent setup for a fresh VM.
/opt/ff-host/ # this repo cloned here
/etc/ff-host/bearer # 0400, admin-injected token
/etc/ff-host/env # 0400, admin email + paths
/var/sites/<slug>/
config.json
assets/ # images
documents/ # PDFs only
/var/sites/_shared/pdf-viewer/ # pdfjs-dist viewer, symlinked into each site's _pdf/
/var/www/<slug>/ # rendered static output (served by nginx)
/var/www/<slug>.new/ # transient during deploy
/var/www/<slug>.prev/ # last-known-good, for rollback
/var/log/ff-bootstrap.json # per-step bootstrap status (JSONL)
/var/log/ff-host-audit.jsonl # append-only audit log
On a fresh Ubuntu 22.04 VM:
# Admin supplies API_TOKEN via SSH env. Bearer is never generated by bootstrap.
sudo API_TOKEN=<long-random-token> ADMIN_EMAIL=ops@example.com bash bootstrap.shRe-running is safe — every step is check-then-act and emits a JSON status line to /var/log/ff-bootstrap.json. The bearer file is written once under a [ -f … ] || guard.
All require Authorization: Bearer <token> except /healthz and /pdf-stream (which uses its own signed-token gate).
| Method | Path | Purpose |
|---|---|---|
| GET | /healthz |
Liveness. {ok:true}. Unauthenticated. |
| GET | /capabilities |
{renderer_version, supported_schema_versions:[1]}. |
| GET | /sites |
List slugs. |
| GET | /sites/:slug |
Return current SiteConfig. |
| PUT | /sites/:slug |
Validate schema, write atomically, render, swap into /var/www. |
| DELETE | /sites/:slug |
Remove site data and rendered output. |
| PUT | /sites/:slug/assets/:name |
Upload image (`image/png |
| PUT | /sites/:slug/documents/:id |
Upload a PDF. Rejects anything whose first 5 bytes aren't %PDF- or whose Content-Type isn't application/pdf. |
| DELETE | /sites/:slug/documents/:id |
Remove a PDF. |
| GET | /sites/:slug/documents/:id/token?sid=<sessionId> |
Mint a 60-second HS256 stream token bound to {slug, id, sid}. |
| GET | /pdf-stream?t=…&id=…&slug=… |
Stream a PDF. Gated by signed token + Referer check. Single-use nonce. |
| POST | /sites/:slug/domains |
Body {domain}. Writes nginx conf, reloads, runs certbot --nginx. |
| DELETE | /sites/:slug/domains |
Remove the site's nginx conf and reload. |
| POST | /self-update |
git pull && npm ci && npm run build && systemctl restart ff-host-api. |
- host-api binds only to
127.0.0.1:3001— admin reaches it via SSH tunnel. - Bootstrap is strictly idempotent; per-step JSONL status in
/var/log/ff-bootstrap.json. - The bearer token is injected by admin via SSH env var (
API_TOKEN), not generated by bootstrap. Written to/etc/ff-host/bearer(0400) under a[ -f … ] ||guard. - Atomic on-disk deploys: render to
<slug>.new/,rename(2)over<slug>/, keep previous as<slug>.prev/. SiteConfig.schema_versionmust equalSCHEMA_VERSION; mismatched PUTs return 400.- Documents are PDF-only — enforced via magic bytes and Content-Type. No overrides.
/pdf-streamsetsContent-Disposition: inline,Cache-Control: private, no-store, max-age=0,Pragma: no-cache,X-Content-Type-Options: nosniff. nginx setsproxy_no_cache 1; proxy_cache_bypass 1;.- Stream tokens are HS256, 60 s TTL, bound to
{slug, documentId, sessionId}with a single-use in-memory nonce. Referer must match one of the site's configuredserver_namevalues. POST /self-updateis bearer-protected and restarts the service after the response flushes.- Append-only audit log at
/var/log/ff-host-audit.jsonl:{ts, route, slug?, actor_ip, status}. No tokens, no bodies, no file bytes. systemd-timesyncdenabled by bootstrap.certbot --nginx -d <domain> --non-interactive --agree-tos -m <email>— admin is responsible for DNS preflight.
- HTTPS on host-api (SSH tunnel handles transport).
- Admin-side anything (separate repo).
- Rich-text sanitization (renderer uses
dangerouslySetInnerHTMLwith// TODO Phase 2: DOMPurifymarkers). - i18n, backups, fleet-update UI.
cd host-api && npm install && npm run build
cd ../renderer && npm install && npm run buildTo exercise the service locally, point it at scratch dirs:
export API_TOKEN=devtoken
export FF_BEARER_FILE=/tmp/ff-bearer; echo -n devtoken > $FF_BEARER_FILE
export FF_SITES_DIR=/tmp/ff-sites
export FF_WWW_DIR=/tmp/ff-www
export FF_AUDIT_FILE=/tmp/ff-audit.jsonl
export FF_NGINX_SITES=/tmp/ff-nginx
export FF_NGINX_TEMPLATE=$(pwd)/../nginx/site.conf.template
export FF_REPO_DIR=$(pwd)/..
export ADMIN_EMAIL=dev@example.com
node dist/index.jsThen from another terminal: curl -H "Authorization: Bearer devtoken" http://127.0.0.1:3001/capabilities.