# Technical Architecture **Document:** 08 — Technical Architecture **Status:** Approved for Milestone 1 **Depends on:** `docs/04-data-sources.md`, `docs/05-flyability-scoring.md`, `docs/06-database-schema.md`, `api/openapi.yaml` --- ## 1. System Overview ``` ┌─────────────────────────────────────────────┐ │ Browser │ │ React SPA + MapLibre GL JS │ │ (map view · spot detail · forecast panel) │ └──────┬──────────────────────┬───────────────┘ │ REST/JSON (HTTPS) │ vector/raster tiles ▼ ▼ ┌──────────────────┐ ┌─────────────────────────┐ │ API Service │ │ Tile source (external) │ │ Node.js/Fastify │ │ OSM raster / OpenFree- │ │ │ │ Map vector (docs/04) │ │ ┌──────────────┐ │ └─────────────────────────┘ │ │ Scoring │ │ │ │ Engine (lib) │ │ ┌────────────────────┐ │ └──────────────┘ │ HTTPS │ Open-Meteo API │ │ ┌──────────────┐─┼───────▶│ (primary weather) │ │ │ Weather │ │ └────────────────────┘ │ │ Gateway │─┼───────▶┌────────────────────┐ │ └──────────────┘ │ │ NOAA NWS API │ └────────┬─────────┘ │ (US fallback) │ │ └────────────────────┘ ▼ ┌─────────────────────┐ ┌────────────────────┐ │ PostgreSQL 15 + │ │ S3-compatible │ │ PostGIS (spots, │ │ object store │ │ users, cache, …) │ │ (spot photos) │ └─────────────────────┘ └────────────────────┘ ``` A deliberately boring two-tier architecture: one SPA, one API service, one database, plus an object store for photos. Everything self-hostable; every external dependency is a free, open data source per `docs/04`. ## 2. Frontend - **Stack:** React 18 + TypeScript, Vite build, **MapLibre GL JS** for the map (open-source Mapbox-GL fork; vector tiles, smooth zoom, no API-key lock-in — see docs/04 evaluation). - **State:** TanStack Query for server state (spots, forecasts — these are caches of server truth, not app state); a small Zustand store for UI state (selected profile, units, panel open/closed). - **Map data flow:** viewport change → debounced (300 ms) `GET /v1/spots?bbox=…&profile=…` → pins colored by current-hour score band. Pin GeoJSON is rendered as a MapLibre symbol layer, not DOM markers, so thousands of spots stay at 60 fps. - **Offline-tolerance:** last successful spot list and forecast per visited spot persist in `localStorage` with their fetch timestamps; the UI shows stale data with an explicit "as of HH:MM" badge rather than failing on flaky field connectivity (kite spots are exactly where mobile coverage is bad). - **Wind visualization:** direction arrows are pure SVG rotated by forecast bearing; the forecast panel renders the 48-hour score sparkline with plain SVG — no chart library dependency for v1. - **Accessibility:** score bands always pair color with a letter (A–E) and label; map interactions all have list-view equivalents (`/spots` list route) for keyboard/screen-reader users. ## 3. Backend (API Service) - **Stack:** Node.js 20+ (LTS), TypeScript, **Fastify** (schema-validated routes — the OpenAPI contract in `api/openapi.yaml` is the source of truth; route schemas are generated from it in CI to prevent drift), `pg` driver, `postgres` connection pooling built in. - **Why Node over Python here:** one language across the stack (the scoring engine is shared logic that the frontend may eventually run client-side for instant what-if sliders); Fastify's JSON-schema validation maps 1:1 onto the OpenAPI contract; the workload is I/O-bound proxying + light arithmetic, not numeric computing. - **Process model:** single stateless process, horizontally scalable behind any reverse proxy. No sticky sessions; auth is bearer JWT (short-lived access + DB-stored refresh tokens, `auth_tokens` table). ### 3.1 Modules | Module | Responsibility | |---|---| | `routes/` | HTTP layer only; validation, auth guards, serialization. Mirrors `api/openapi.yaml` paths exactly. | | `weather-gateway/` | The **only** code that talks to Open-Meteo/NOAA. Snap → cache lookup (`forecast_cache`) → upstream fetch on miss → normalize to internal payload shape → store with TTL. Provider selection: NOAA when point is inside CONUS bbox **and** Open-Meteo failed or operator config prefers it; Open-Meteo otherwise. Circuit breaker: 3 consecutive upstream failures → serve stale cache (flagged `"stale": true`) for up to 6 h. | | `scoring/` | Pure functions implementing `docs/05` exactly. Zero I/O — takes a normalized forecast payload + spot rows + profile row, returns scored hours with breakdowns. Property-tested against the worked examples in the rubric. | | `spots/` | CRUD + geospatial queries + moderation state machine (`pending → published/flagged/archived`). | | `accounts/` | OAuth (GitHub + Google via standard OIDC code flow) and email magic links; issues JWTs. | | `jobs/` | In-process scheduled tasks (via `node-cron`-style timer): cache sweep (hourly, deletes expired `forecast_cache` and `auth_tokens` rows), nightly OSM obstacle refresh for spots with `osm_id` provenance. | ### 3.2 Request flow: the hot path `GET /v1/spots/{id}/forecast?profile=sport_dual` 1. Load spot row + sectors + obstacles (3 indexed reads, ~1 ms). 2. `snap_grid(lat), snap_grid(lon)` → `forecast_cache` lookup. 3. Hit → step 5. Miss → fetch Open-Meteo hourly (`wind_speed_10m, wind_gusts_10m, wind_direction_10m, precipitation, precipitation_probability, cape, weather_code, temperature_2m`, 48 h + 7-day), normalize, upsert with 30-min TTL. 4. (Failure → fallback provider → stale-serve → 503 with `Retry-After` as last resort.) 5. Scoring engine computes per-hour scores for the requested profile (pure CPU, ~µs per hour). 6. Respond; `Cache-Control: public, max-age=300` so CDNs/browsers absorb repeat hits. Scores are **never persisted per hour** — recomputing from the cached payload is cheaper and avoids invalidation bugs when rubric constants change. ## 4. External Service Budget & Politeness | Service | Constraint | Our mitigation | |---|---|---| | Open-Meteo | free non-commercial fair use (~10k calls/day guidance) | 0.05° grid snap + 30-min TTL ⇒ one call serves every user near a location for 30 min. Worst case 10k distinct grid cells/day before throttling concerns; we add an internal per-day call budget with alerting at 70%. | | NOAA api.weather.gov | requires `User-Agent` with contact info; unpublished rate limits | Identify as `kitemap (contact@…)`; only used as US fallback. | | OSM tiles | tile usage policy: attribution, no heavy scraping | Default config points at OpenFreeMap vector tiles (no key, generous policy); self-hosters can point `TILE_URL` anywhere. Attribution rendered always. | | Overpass (obstacle refresh) | shared community resource | Nightly job, ≤ 1 req/2 s, only for spots changed or new since last run. | ## 5. Security & Privacy - **Auth:** OIDC (GitHub, Google) or email magic link; access JWT 15 min, refresh token 30 days (hashed in `auth_tokens`, rotated on use). No passwords stored, ever. - **Authorization:** role-gated (user/moderator/admin) at the route layer; row-level rules in service code (you can edit your own review; moderators can transition spot status). - **Input hygiene:** every route body/query validated against JSON Schema before handler code runs (Fastify-native). Markdown in descriptions sanitized server-side on render-out (allowlist). - **Privacy:** no tracking/analytics scripts in v1; geolocation is requested in-browser only and never sent to our server (the bbox query reveals viewport, which is inherent). User deletion cascades per `docs/06 §6`. - **Rate limiting:** token-bucket per IP at the proxy (60 req/min anonymous, 240 authenticated); write endpoints additionally per-user (e.g., 10 spot submissions/day). ## 6. Deployment & Operations - **Packaging:** one `Dockerfile` for the API (multi-stage, distroless runtime), one static bundle for the SPA (served by any static host or the same proxy). `docker-compose.yml` (Milestone 2 deliverable) wires API + Postgres/PostGIS + MinIO for one-command self-hosting — self-hostability is a charter requirement. - **Config:** 12-factor env vars only: `DATABASE_URL`, `TILE_URL`, `OBJECT_STORE_*`, `JWT_SECRET`, `OAUTH_*`, `WEATHER_PROVIDER_PREF`, `CONTACT_EMAIL` (for NOAA User-Agent). - **Migrations:** ordered SQL files applied by a tiny runner at deploy time, recorded in `schema_migrations` (baseline already in `db/schema.sql`). - **Observability:** structured JSON logs (pino); `/healthz` (process up) and `/readyz` (DB reachable); Prometheus metrics endpoint (`/metrics`): request latency histograms, upstream weather call counters, cache hit ratio (the single most important operational number). - **Backups:** nightly `pg_dump`; photos are object-store-replicated. `forecast_cache` is excluded from backups (pure derived data). ## 7. Scaling Path & Deliberate Deferrals | Pressure point | v1 answer | Escalation path (when measured, not before) | |---|---|---| | Forecast cache read QPS | Postgres table | Add Redis/keydb in front; gateway interface already isolates this | | Map pin volume at low zoom | bbox query + zoom-gated density | Server-side clustering or pre-built vector tile layer of spots | | Scoring CPU | per-request compute | Memoize per (grid-cell, profile, rubric_version) — trivially cacheable because inputs are already cache-keyed | | Global spot search | pg_trgm | External search engine only if trigram proves insufficient | | Realtime wind (stations) | not in v1 | Milestone 4 candidate: open station feeds (e.g., NOAA NDBC buoys) as a "now" overlay | **Explicitly out of scope for v1:** native mobile apps (the SPA is responsive and installable as a PWA), websockets/live updates (forecasts change half-hourly at most), multi-region deployment, and any paid API integration (charter-prohibited). ## 8. Testing Strategy (acceptance criteria for Milestone 2) - **Scoring engine:** unit + property tests; the four worked examples in `docs/05 §9` are mandatory fixtures; fuzz inputs must never produce scores outside 0–100 or a gated score above its cap. - **Weather gateway:** contract tests against recorded Open-Meteo/NOAA fixtures (no live calls in CI); explicit tests for fallback, stale-serve, and circuit-breaker transitions. - **API:** every OpenAPI operation exercised against an ephemeral PostGIS container (Testcontainers); responses validated against the spec schemas. - **Frontend:** component tests for the forecast panel rendering all six score bands and all gate/cap reason strings; Playwright smoke for the map → spot → forecast happy path.