diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d63c848 --- /dev/null +++ b/.env.example @@ -0,0 +1,59 @@ +# Ropeway Alignment — server config. +# Copy to your own env file (e.g. ~/.config/ropeway/env) and edit. +# See docs/DEPLOY_VERCEL_TUNNEL.md for the production deploy guide. + +# ---- Core ---- +# Postgres recommended in production. Default SQLite is fine for dev. +# ROPEWAY_DATABASE_URL=postgresql+psycopg://ropeway:****@localhost:5432/ropeway +ROPEWAY_DATABASE_URL=sqlite:///./data/server.db + +# JWT signing key — generate once, keep secret: +# openssl rand -hex 32 +ROPEWAY_SECRET_KEY=replace-with-openssl-rand-hex-32 + +# JWT lifetime (minutes). Default 12 h. +ROPEWAY_TOKEN_TTL_MIN=720 + +# ---- CORS (Vercel SPA origins) ---- +# Comma-separated list. Use '*' only for local dev. +# At '*' the server auto-drops credentials (browsers reject the combo). +# Production: list every Vercel domain explicitly. +ROPEWAY_CORS_ORIGINS=https://your-app.vercel.app,https://your-app-git-main.vercel.app + +# ---- P28: rate limit + API keys ---- +# Per-key (or per-IP if no key) requests per minute. 0 disables. +ROPEWAY_RATE_LIMIT_PER_MIN=120 + +# Comma list of server-to-server API keys. The Vercel BFF puts one +# here and sends X-API-Key: on every request. Empty disables +# API-key auth (JWT remains the only auth path). +# openssl rand -hex 24 +ROPEWAY_API_KEYS=replace-with-openssl-rand-hex-24 + +# ---- COMM-1: OAuth (optional) ---- +# ROPEWAY_GOOGLE_OAUTH_CLIENT_ID= +# ROPEWAY_GOOGLE_OAUTH_CLIENT_SECRET= +# ROPEWAY_GOOGLE_OAUTH_REDIRECT_URI=https://your-app.vercel.app/auth/google/callback +# ROPEWAY_MICROSOFT_OAUTH_CLIENT_ID= +# ROPEWAY_MICROSOFT_OAUTH_CLIENT_SECRET= +# ROPEWAY_MICROSOFT_OAUTH_REDIRECT_URI=https://your-app.vercel.app/auth/microsoft/callback + +# ---- COMM-2: Razorpay billing (optional) ---- +# ROPEWAY_RAZORPAY_KEY_ID= +# ROPEWAY_RAZORPAY_KEY_SECRET= +# ROPEWAY_RAZORPAY_PLAN_PROFESSIONAL= +# ROPEWAY_RAZORPAY_PLAN_ENTERPRISE= +# ROPEWAY_RAZORPAY_WEBHOOK_SECRET= + +# ---- COMM-4: Observability (optional) ---- +# ROPEWAY_SENTRY_DSN= +# ROPEWAY_SENTRY_TRACES=0.0 +# ROPEWAY_PROMETHEUS=1 +# ROPEWAY_STRUCTURED_LOGS=1 +# ROPEWAY_RELEASE=v0.7.0 + +# ---- Misc ---- +ROPEWAY_API_TITLE=Ropeway Alignment API +ROPEWAY_API_VERSION=0.7.0 +ROPEWAY_JWT_ALG=HS256 +ROPEWAY_TELEMETRY=0 diff --git a/docs/DEPLOY_VERCEL_TUNNEL.md b/docs/DEPLOY_VERCEL_TUNNEL.md new file mode 100644 index 0000000..3e48d94 --- /dev/null +++ b/docs/DEPLOY_VERCEL_TUNNEL.md @@ -0,0 +1,195 @@ +# Deploy: Vercel SPA → Cloudflare Tunnel → local compute box + +The architecture this guide targets: + +``` +[ Engineer's browser ] + │ HTTPS + ▼ +[ Vercel — Next.js SPA + edge functions ] ← static + thin backend-for-frontend + │ HTTPS via Cloudflare Tunnel + ▼ +[ Your machine — FastAPI (ropeway serve) ] ← all computation lives here + Postgres / SQLite + DEM cache +``` + +Why this shape: + +* **Free Vercel hosting** for the web interface. +* **Compute stays at home** — the GA, RL, DEM sampling, PyVista, etc. run on + your hardware. No cloud GPU bill. +* **Cloudflare Tunnel** punches a stable HTTPS URL to your box without + exposing a public port — Cloudflare terminates TLS and forwards to + `localhost:8000`. + +## One-time setup on your box + +### 1. Configure the FastAPI server + +Set the production env vars. Pick concrete values for the bold ones — the +defaults are dev-only. + +```bash +# In ~/.config/ropeway/env, or systemd EnvironmentFile=, or .envrc — your call. + +# Database (PostgreSQL recommended in production): +ROPEWAY_DATABASE_URL=postgresql+psycopg://ropeway:****@localhost:5432/ropeway + +# JWT signing key — generate once, keep secret: +ROPEWAY_SECRET_KEY=$(openssl rand -hex 32) + +# CORS: list the Vercel preview + production domains explicitly. +# DO NOT use '*' here — the server will keep working but browsers will +# refuse cookie/credential requests with a wildcard origin. +ROPEWAY_CORS_ORIGINS=https://your-app.vercel.app,https://your-app-git-main.vercel.app + +# P28 rate limit (per-IP-or-API-key, per minute). 0 disables. +ROPEWAY_RATE_LIMIT_PER_MIN=120 + +# P28 API keys — one per Vercel function that calls back to you. +# The Vercel BFF puts this in its env and sends X-API-Key on every request. +ROPEWAY_API_KEYS=vercel-bff-key-$(openssl rand -hex 12) + +# Optional OAuth (COMM-1): +# ROPEWAY_GOOGLE_OAUTH_CLIENT_ID=... +# ROPEWAY_GOOGLE_OAUTH_CLIENT_SECRET=... +# ROPEWAY_GOOGLE_OAUTH_REDIRECT_URI=https://your-app.vercel.app/auth/google/callback + +# Optional observability (COMM-4): +# ROPEWAY_SENTRY_DSN=... +# ROPEWAY_PROMETHEUS=1 +# ROPEWAY_STRUCTURED_LOGS=1 +``` + +A complete `.env.example` ships at the repo root. + +### 2. Run the API as a service + +Quick path (foreground): + +```bash +ropeway serve --host 127.0.0.1 --port 8000 +``` + +systemd unit (better — survives reboot, restart-on-crash): + +```ini +# /etc/systemd/system/ropeway.service +[Unit] +Description=Ropeway Alignment FastAPI +After=network-online.target + +[Service] +EnvironmentFile=/home/harsh-pandhe/.config/ropeway/env +WorkingDirectory=/home/harsh-pandhe/GitHub/Autonomous-Ropeway-Alignment +ExecStart=/home/harsh-pandhe/GitHub/Autonomous-Ropeway-Alignment/.venv/bin/ropeway serve --host 127.0.0.1 --port 8000 +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl enable --now ropeway.service +``` + +### 3. Install Cloudflare Tunnel + +```bash +# Install cloudflared (Linux). +curl -fL -o /tmp/cloudflared.deb \ + https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb +sudo dpkg -i /tmp/cloudflared.deb + +# Authenticate (opens a browser to pick a zone). +cloudflared tunnel login + +# Create a named tunnel + a public DNS hostname. +cloudflared tunnel create ropeway +cloudflared tunnel route dns ropeway api.your-domain.com +``` + +Tunnel config at `~/.cloudflared/config.yml`: + +```yaml +tunnel: ropeway +credentials-file: /home/harsh-pandhe/.cloudflared/.json +ingress: + - hostname: api.your-domain.com + service: http://localhost:8000 + - service: http_status:404 +``` + +Run as a service: + +```bash +sudo cloudflared service install +sudo systemctl enable --now cloudflared +``` + +Verify: `curl https://api.your-domain.com/health` → `{"status":"ok"}`. + +### 4. Don't have a domain? Use a Quick Tunnel + +```bash +cloudflared tunnel --url http://localhost:8000 +# prints something like https://random-words-here.trycloudflare.com +``` + +Good for the engineer-trial phase. Move to a named tunnel + your domain +before you accept paying customers. + +## Vercel side + +The Next.js SPA (Phase P35+) ships with these env vars on Vercel: + +```bash +ROPEWAY_API_BASE=https://api.your-domain.com +ROPEWAY_API_KEY=vercel-bff-key-... # matches one of ROPEWAY_API_KEYS +``` + +Pattern: Vercel functions (route handlers / server actions) call the local +box on the user's behalf, attaching the API key. The browser never sees the +key. End users authenticate to the SPA with password JWT or OAuth (Phase +COMM-1 + P26); the SPA forwards their JWT in addition to the BFF's +X-API-Key. + +## Smoke-test the round trip + +```bash +# 1. From anywhere, hit the tunnel: +curl -fsS https://api.your-domain.com/health + +# 2. With API-key auth (when ROPEWAY_API_KEYS is set): +curl -fsS -H "X-API-Key: vercel-bff-key-..." \ + https://api.your-domain.com/auth/oauth/providers +# {"providers": []} or whatever you configured + +# 3. Rate limit smoke test (when ROPEWAY_RATE_LIMIT_PER_MIN > 0): +for i in $(seq 1 200); do + curl -s -o /dev/null -w "%{http_code} " https://api.your-domain.com/health +done +# Watch 200s flip to 429 once you cross the threshold. +``` + +## Operational notes + +* **DEM cache.** The case studies download Copernicus tiles into + `data/dem/`. Put a few common tiles there before opening the tunnel — + `find_dem_tile` will warm up faster, and a tunnel restart never invalidates + the cache. +* **Database backups.** SQLite is in-repo (`data/server.db`); back up the + file. Postgres: `pg_dump` on a schedule. +* **Updates.** `git pull && pip install -e ".[server,dev,ui,viz3d,rl]" && + systemctl restart ropeway`. Alembic migrations land in Phase P59. +* **Killing the tunnel** does not lose data — only the public URL goes away. + Restart `cloudflared` to come back online. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| Browser CORS error from Vercel | wildcard `ROPEWAY_CORS_ORIGINS=*` + cookies | Set concrete Vercel origins; the server auto-drops credentials at `*` | +| `401 invalid or missing API key` | Vercel env `ROPEWAY_API_KEY` mismatch | Ensure it's in the server's `ROPEWAY_API_KEYS` comma list | +| `429` floods | Rate limit too tight under load | Raise `ROPEWAY_RATE_LIMIT_PER_MIN`, or move to Redis (P57) | +| Tunnel up, API 502 | systemd unit not running | `systemctl status ropeway; journalctl -u ropeway -n 100` | diff --git a/src/ropeway/server/api.py b/src/ropeway/server/api.py index 432546c..3424ee9 100644 --- a/src/ropeway/server/api.py +++ b/src/ropeway/server/api.py @@ -141,12 +141,22 @@ def create_app(settings: Settings | None = None) -> FastAPI: configure_sentry(release=settings.api_version) app = FastAPI(title=settings.api_title, version=settings.api_version) + # P28b — CORS for the Vercel SPA. Browsers reject the + # ``allow_origins=['*']`` + ``allow_credentials=True`` combination, + # so we negotiate: if the operator left origins at the wildcard + # default, drop credentials (still useful for token-in-header auth + # like X-API-Key + Bearer JWT); when concrete origins are listed + # (e.g. https://your-app.vercel.app) we keep credentials so cookie- + # based session flows work too. + cors_origins = list(settings.cors_origins) or ["*"] + allow_credentials = "*" not in cors_origins app.add_middleware( CORSMiddleware, - allow_origins=list(settings.cors_origins) or ["*"], - allow_credentials=True, + allow_origins=cors_origins, + allow_credentials=allow_credentials, allow_methods=["*"], allow_headers=["*"], + expose_headers=["X-Request-ID", "Retry-After"], ) # ---- Phase 14: htmx shareable-link demo (unauthenticated, synthetic) ---- diff --git a/tests/test_cors_vercel.py b/tests/test_cors_vercel.py new file mode 100644 index 0000000..3602f6d --- /dev/null +++ b/tests/test_cors_vercel.py @@ -0,0 +1,132 @@ +"""P28b — CORS negotiates safely between wildcard + concrete origins, +plus a sanity check that the Vercel deploy guide ships the keys.""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + +from ropeway.server.api import create_app +from ropeway.server.config import Settings + + +def _client(tmp_path, *, origins: tuple[str, ...]) -> TestClient: + s = Settings( + database_url=f"sqlite:///{tmp_path}/api.db", + secret_key="t", + cors_origins=origins, + ) + return TestClient(create_app(s)) + + +# --------------------------------------------------------------------------- +# Wildcard origin: credentials must NOT be allowed (browser would reject) +# --------------------------------------------------------------------------- + + +def test_wildcard_origin_disables_credentials(tmp_path): + client = _client(tmp_path, origins=("*",)) + r = client.options( + "/health", + headers={ + "Origin": "https://anything.example.com", + "Access-Control-Request-Method": "GET", + }, + ) + # Starlette echoes back '*' (no credentials). + assert r.status_code in (200, 204) + assert r.headers.get("access-control-allow-origin") == "*" + assert "access-control-allow-credentials" not in {k.lower() for k in r.headers} + + +# --------------------------------------------------------------------------- +# Concrete Vercel origins: credentials allowed, origin echoed +# --------------------------------------------------------------------------- + + +def test_concrete_origin_allows_credentials(tmp_path): + vercel = "https://your-app.vercel.app" + client = _client(tmp_path, origins=(vercel,)) + r = client.options( + "/health", + headers={ + "Origin": vercel, + "Access-Control-Request-Method": "GET", + }, + ) + assert r.status_code in (200, 204) + assert r.headers.get("access-control-allow-origin") == vercel + assert r.headers.get("access-control-allow-credentials") == "true" + + +def test_concrete_origin_rejects_other_origins(tmp_path): + vercel = "https://your-app.vercel.app" + client = _client(tmp_path, origins=(vercel,)) + r = client.options( + "/health", + headers={ + "Origin": "https://evil.example.com", + "Access-Control-Request-Method": "GET", + }, + ) + # Starlette refuses preflight from a disallowed origin. + allow = r.headers.get("access-control-allow-origin") + assert allow != "https://evil.example.com" + + +def test_retry_after_listed_in_expose_headers_preflight(tmp_path): + """The middleware must advertise Retry-After in Access-Control- + Expose-Headers so a browser can read the value on a P28 429.""" + vercel = "https://your-app.vercel.app" + client = _client(tmp_path, origins=(vercel,)) + r = client.options( + "/health", + headers={ + "Origin": vercel, + "Access-Control-Request-Method": "GET", + }, + ) + # Some Starlette versions surface expose-headers only on the actual + # response; check the simple GET too. + expose = (r.headers.get("access-control-expose-headers", "") + or client.get("/health", + headers={"Origin": vercel}).headers.get( + "access-control-expose-headers", "")) + assert "Retry-After" in expose + + +# --------------------------------------------------------------------------- +# Documentation shipped +# --------------------------------------------------------------------------- + + +def test_deploy_vercel_tunnel_doc_exists(): + p = Path("docs/DEPLOY_VERCEL_TUNNEL.md") + assert p.exists() + text = p.read_text() + # The guide must mention every operator-critical concept. + for keyword in ( + "Cloudflare Tunnel", + "ROPEWAY_CORS_ORIGINS", + "ROPEWAY_API_KEYS", + "ROPEWAY_RATE_LIMIT_PER_MIN", + "systemd", + "cloudflared tunnel", + "X-API-Key", + ): + assert keyword in text, f"deploy guide missing keyword: {keyword}" + + +def test_env_example_exists_with_required_keys(): + p = Path(".env.example") + assert p.exists() + text = p.read_text() + for keyword in ( + "ROPEWAY_DATABASE_URL", + "ROPEWAY_SECRET_KEY", + "ROPEWAY_CORS_ORIGINS", + "ROPEWAY_RATE_LIMIT_PER_MIN", + "ROPEWAY_API_KEYS", + ): + assert keyword in text, f".env.example missing key: {keyword}"