diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e95b86a --- /dev/null +++ b/.env.example @@ -0,0 +1,42 @@ +# Local docker-compose overrides. Copy to .env (gitignored) for `docker compose +# up` / `make up` on a dev box: cp .env.example .env +# +# Compose auto-reads a repo-root .env for ${VAR} interpolation in +# docker-compose.yml. In production these live in /etc/thermograph.env instead +# (loaded by the systemd unit), so this file is only for local runs. + +# Database password. Compose uses it to initialize the postgres container AND to +# build the app's THERMOGRAPH_DATABASE_URL. Change it before first `up`. +POSTGRES_PASSWORD=change-me + +# --- Everything below is optional -- docker-compose.yml already defaults each +# --- of these, so a plain `make up` works with none of it set. Uncomment to +# --- override. + +# TimescaleDB image tag. Defaults to the floating latest-pg18 tag. Pin it to an +# exact minor (e.g. 2.17.2-pg18) before any host of this stack could ever +# replicate with another -- see docker-compose.yml's db service comment. +# TIMESCALEDB_TAG=latest-pg18 + +# Postgres sizing. Terraform sets these per host in prod/beta (prod DB_MEMORY +# 16g); local/beta default to 8g / 2 CPUs. +# DB_MEMORY=8g +# DB_CPUS=2 + +# Backend uvicorn worker count and CPU cap. Terraform raises these on bigger +# hosts; defaults keep a plain `docker compose up` identical to before. +# WORKERS=4 +# APP_CPUS=4 + +# Frontend CPU cap. +# FRONTEND_CPUS=2 + +# Registry + per-service image path/tag. Local dev normally builds each image +# in its own app repo (thermograph-backend / thermograph-frontend) tagged +# :local, which is the default here -- only set these to pull a specific +# published build instead of building locally. +# REGISTRY_HOST=git.thermograph.org +# BACKEND_IMAGE_PATH=emi/thermograph-backend/app +# BACKEND_IMAGE_TAG=local +# FRONTEND_IMAGE_PATH=emi/thermograph-frontend/app +# FRONTEND_IMAGE_TAG=local diff --git a/DEPLOY-DEV.md b/DEPLOY-DEV.md new file mode 100644 index 0000000..eba88bf --- /dev/null +++ b/DEPLOY-DEV.md @@ -0,0 +1,172 @@ +# Dev CI/CD → LAN server on this machine + +Parallel to the prod pipeline (`main` → VPS, see `DEPLOY.md`), the **`dev`** +branch continuously deploys to a **LAN server running on this computer**. Git +hosting and CI are self-hosted **Forgejo** (`git.thermograph.org`, reachable +at `http://10.10.0.2:3080` over the WireGuard mesh) — GitHub is retired. + +``` +open PR ──▶ CI (build + boot/health, on the LAN Forgejo runner) + │ green + ▼ + merge into dev (explicit — Forgejo does not auto-merge on its own; + see "Landing a PR" below) + │ + ▼ + LAN runner (thermograph-lan label) runs deploy/deploy-dev.sh + │ + ▼ + ~/thermograph-dev updated ─▶ docker compose stack (backend + frontend + + Postgres 18) brought up, backend on 0.0.0.0:8137 +``` + +Reachable at `http://:8137/` from any device on your Wi-Fi. + +The dev server runs the **same containerized stack as prod** (`docker-compose.yml`), +overlaid with `docker-compose.dev.yml`: **uncapped** (no CPU limits, unlike prod's +backend=4 / frontend=2 / db=2) and backend published on `0.0.0.0:8137` for the +LAN (prod binds loopback behind Caddy; frontend has no published port here +either way, no Caddy to reach it directly, so it's only reached through +backend's own reverse-proxy fallback). Bring it up by hand with `make dev-up`. + +## Why it's built this way + +Forgejo has native branch protection + required-status-checks + auto-merge +(unlike GitHub on a private free-tier repo, where those are paywalled). But +"auto-merge" here still means opting a PR in — either clicking **"Auto merge +when checks succeed"** in the web UI, or merging explicitly once CI is green +via the API (`POST .../pulls/{n}/merge`, `{"Do": "squash"}`). Nothing merges +a ready PR for you automatically just because checks pass, unlike GitHub's +old in-workflow `ci-cd.yml` (retired along with the rest of `.github/`). + +## Moving parts + +| File | Purpose | +|------|---------| +| `.forgejo/workflows/pr-build.yml` | required status check for PRs into `dev` (calls `build.yml`) | +| `.forgejo/workflows/deploy-dev.yml` | build + deploy on pushes to `dev` (direct, or a PR merge) | +| `.forgejo/workflows/build.yml` | shared build gate: deps, backend tests, JS syntax check, boot/health check | +| `deploy/deploy-dev.sh` | pull `dev` into `~/thermograph-dev`, `docker compose up` the uncapped LAN stack, health check | +| `docker-compose.dev.yml` | dev overlay: no CPU limits, backend published on `0.0.0.0:8137` (frontend unpublished) | +| `deploy/provision-dev-lan.sh` | one-time sudo-free bootstrap of the checkout + runner | +| `deploy/forgejo/register-lan-runner.sh` | registers the Forgejo Actions runner on this machine | + +`deploy-dev.yml`'s `deploy` job runs on the `thermograph-lan` label — **not** +`[self-hosted, thermograph-lan]` (the array form is what the original GitHub +version used; GitHub implicitly tags every self-hosted runner `self-hosted`, +but Forgejo's runner has only the labels it was explicitly registered with, +so the array form is permanently unschedulable there — a real bug found and +fixed once, worth knowing about if a "deploy" job ever silently stops +appearing in the Actions history again). + +`deploy-dev.sh`'s git auth (`GH_TOKEN`/`GITHUB_TOKEN`, Forgejo's per-job +token exposed under that name for compatibility) is scoped to `REPO_URL`'s +own host via a per-command `http.:///.extraheader`, not a +hardcoded one — important if the Forgejo host ever changes, since a stale +hardcoded host would make git silently skip the header (no error) and fall +through to whatever ambient credentials happen to be available, not fail +loudly. + +## The self-hosted runner + +A Forgejo Actions **runner** (`forgejo-runner`) runs on this machine, labels +`docker` (containerized jobs, node:20-bookworm, with the host's docker.sock +automounted in — `container.docker_host: automount` in +`~/forgejo-runner/config.yaml` — so `docker build`/`push` work without +privileged Docker-in-Docker) and `thermograph-lan` (host-native jobs, for +`deploy-dev.sh`'s systemctl/docker-compose calls). Installed under +`~/forgejo-runner`, runs as the `forgejo-runner` systemd `--user` service. + +```bash +# status / logs +systemctl --user status forgejo-runner +journalctl --user -u forgejo-runner -f + +# re-register if the token/host ever changes +bash deploy/forgejo/register-lan-runner.sh +``` + +`git.thermograph.org`'s public DNS resolves to beta's **public** IP, which +the registry's mesh-only ACL (see `deploy/forgejo/README.md`) rejects for +`/v2/` paths — `docker build-push.yml` pushes run against the *host's* +automounted daemon, so it's this **host's** own resolver that needs to route +git.thermograph.org over the mesh, not anything settable from a job +container. If registry pushes ever start failing with an HTTP/TLS mismatch +or a 403 on `/v2/`, check `getent hosts git.thermograph.org` here — it needs +to resolve to beta's WireGuard IP (`10.10.0.2`), typically via a `/etc/hosts` +line, not the public one. + +## The LAN dev service (Docker Compose) + +Runs as a Docker Compose stack, not a bare systemd unit — `docker ps --filter +name=thermograph-dev` shows `thermograph-dev-backend-1`, `thermograph-dev-frontend-1` +(repo-split Stage 4 split the single "app" container in two — frontend has no +published port, reached only through backend's own reverse-proxy fallback) and +`thermograph-dev-db-1` (TimescaleDB). A legacy pre-container +`thermograph-dev.service` systemd --user unit may still exist from before this +cutover; it's stale and `deploy-dev.sh` stops/disables it on every run — don't +trust `systemctl --user status thermograph-dev` as a liveness check, use `docker +ps` instead. + +```bash +docker ps --filter name=thermograph-dev # is it up? +docker compose -f docker-compose.yml -f docker-compose.dev.yml logs -f backend frontend # app logs (from ~/thermograph-dev) +``` + +Bootstrap (or rebuild) it by hand: + +```bash +bash deploy/provision-dev-lan.sh +``` + +Config: port/base path are baked into the unit from `deploy/deploy-dev.sh` +(`PORT`, `THERMOGRAPH_BASE`). Change them there and redeploy. The dedicated +checkout at `~/thermograph-dev` is separate from your working tree, so a deploy +never touches uncommitted edits in `~/Code/Thermograph`. + +## Before monitoring a PR to land + +A PR needs an explicit merge once CI is green — but first confirm it's +actually set up to land, or you'll monitor a PR that can never merge: + +1. **Not conflicting** — `GET /api/v1/repos/{owner}/{repo}/pulls/{n}` should + show `"mergeable": true`. If not, merge `forgejo/dev` in and resolve now — + don't monitor a PR that can't merge. +2. **Required check actually configured** — `GET + /api/v1/repos/{owner}/{repo}/branch_protections/dev` should list + `status_check_contexts` containing the *exact* string Forgejo reports for + that PR's check (workflow display name + job name + event, e.g. `"PR build + (required check) / build (pull_request)"` — not just the job name + `"build"`). A mismatch here means no PR can ever satisfy the check even + with fully green CI; this bit us once already. +3. **CI actually running** — `GET + /api/v1/repos/{owner}/{repo}/commits/{sha}/status` shows a status, not an + empty list. If empty, check the runner is up + (`systemctl --user status forgejo-runner`). + +Only start monitoring once all three pass. Once CI is green and mergeable is +true, merge explicitly: `POST /api/v1/repos/{owner}/{repo}/pulls/{n}/merge` +with `{"Do": "squash", "delete_branch_after_merge": true}`. + +## Day-to-day + +- **Ship:** open a PR into `dev`, confirm CI is green, merge it explicitly — + the merge triggers the deploy here. +- **Manual redeploy:** trigger `deploy-dev.yml` via `workflow_dispatch` + (`POST /api/v1/repos/{owner}/{repo}/actions/workflows/deploy-dev.yml/dispatches`, + `{"ref": "dev"}`), or locally `APP_DIR=~/thermograph-dev bash deploy/deploy-dev.sh`. +- **Promote to prod:** merge/fast-forward `dev` into `main` and push — that + triggers the VPS pipeline in `DEPLOY.md`. + +## Monitoring + +Logs and dashboards moved to a **separate project** — a central Grafana + Loki +stack fed by a Grafana Alloy agent on every node (including this LAN dev box), +over the WireGuard mesh (`thermograph-observability` on Forgejo; UI at +`https://dashboard.thermograph.org`). The dev node's Alloy agent ships its +container/app logs under `host="dev"`, so LAN dev shows up alongside prod and +beta in the same dashboards. This replaced the old `scripts/dashboard.py`. + +For a quick terminal check without Grafana, the app still serves raw counters at +the gated `GET /api/v2/metrics` route (loopback-only; set +`THERMOGRAPH_METRICS_TOKEN` to read it over an SSH tunnel). diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..800ad23 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,385 @@ +# Deploying Thermograph to a prod VPS + +> **Prod and beta are now provisioned by Terraform** (`terraform/README.md`) and +> run as a **`docker compose` stack** (backend + frontend + Postgres/TimescaleDB; +> repo-split Stage 4 split the single "app" service in two), not the +> manual venv + systemd model this document originally described. Current shape: +> prod = new box `169.58.46.181`, branch `release`, deployed with `terraform +> apply`; beta = old box `75.119.132.91`, branch `main`, deployed when `main` is +> pushed (`.forgejo/workflows/deploy.yml` SSHes in and runs `deploy/deploy.sh`, +> which `docker compose pull`s the image `build-push.yml` already pushed for +> that commit and `up`s it -- repo-split Stage 6's registry-pull cutover; +> `deploy.sh` no longer builds in place). The **manual walkthrough below +> (`provision.sh`, `thermograph.service`, the venv) is legacy** — kept as a +> from-scratch, no-Terraform reference; the process model is compose, so the +> systemd/venv specifics no longer match how prod/beta actually run. + +Pipeline (beta, on `main`): **push to `main` on Forgejo → `build-push.yml` builds ++ pushes the image, tagged by SHA → Forgejo Actions SSHes to beta → +`deploy/deploy.sh` (`git reset` + `docker login` + `docker compose pull && up`, +schema migration runs in the app entrypoint) → Caddy fronts it with Let's Encrypt +TLS.** Prod (on `release`) is deployed with `terraform apply`, not a push +trigger — its `remote-exec` provisioner does the same pull-instead-of-build. +(GitHub is retired/archived — Forgejo, self-hosted at `git.thermograph.org` / +`10.10.0.2:3080` over the WireGuard mesh, is now the sole git host and CI.) + +Files that make this work: + +| File | Where it lives in prod | Purpose | +|------|------------------------|---------| +| `.forgejo/workflows/deploy.yml` | Forgejo | CI job that SSHes in and runs the deploy script (beta, on `main`) | +| `.forgejo/workflows/build-push.yml` | Forgejo | builds + pushes the SHA-tagged image `deploy.sh`/Terraform pull | +| `deploy/deploy.sh` | `/opt/thermograph/deploy/deploy.sh` | `git reset` + `docker login` + `docker compose pull && up` + health check + warm | +| `docker-compose.yml` | `/opt/thermograph/docker-compose.yml` | app + Postgres/TimescaleDB services (the process model) | +| `deploy/thermograph.env.example` | `/etc/thermograph.env` | Postgres password, VAPID/auth secrets, `WORKERS`, sizing, base path | +| `deploy/Caddyfile` | `/etc/caddy/Caddyfile` | reverse proxy + automatic HTTPS | +| `terraform/` | run from your machine | provisions the box + brings the stack up (replaces `provision.sh`) | +| `deploy/provision.sh`, `deploy/thermograph.service` | *(legacy)* | pre-compose venv+systemd bootstrap — superseded by Terraform | + +The app serves on **loopback only**; Caddy is the only thing exposed to the +internet (ports 80/443). The parquet cache in `data/cache/` lives inside the +`/opt/thermograph` checkout and is gitignored, so `git pull` never touches it. + +**Any deploy host needs a `/etc/hosts` entry for the registry.** +`git.thermograph.org`'s public DNS resolves to beta's public IP, and beta's +own Caddy rejects `/v2/*` (the registry API) from anything outside the +WireGuard mesh (10.10.0.0/24) — confirmed live: `docker login`/`pull` from +prod failed with a 403 until adding `10.10.0.2 git.thermograph.org` to +`/etc/hosts` (10.10.0.2 is beta's mesh IP; every deploy host is already on +the mesh, this is purely a DNS-routing gap, not a connectivity one). Beta +itself doesn't need this — it's the box Forgejo runs on, so `git.thermograph.org` +just resolves to itself either way. `build-push.yml`'s own header comment +documents the same requirement for the CI runner host. + +**Current production layout** (`deploy/Caddyfile`): the app owns +**`thermograph.org`** at its root (`THERMOGRAPH_BASE=/`, so uvicorn serves `/`, +`/calendar`, `/api/v2/…` with no prefix), and **`emigriffith.dev`** serves a +static portfolio at its root with `emigriffith.dev/thermograph*` permanently +redirecting to `thermograph.org` (prefix stripped). Both domains' `A` records +point at the same VPS; Caddy provisions a separate cert for each. + +> **Note:** `deploy.sh` (the CI deploy) only pulls code, reinstalls deps, and +> restarts the app service — it does **not** touch Caddy or `/etc/thermograph.env`. +> After changing `deploy/Caddyfile` or the env, copy it onto the VPS and reload +> Caddy / restart the service by hand (see Part 2 step 4 and Part 3). + +--- + +## Part 1 — Keys: how to manage them + +You need **two** separate SSH keypairs. Don't reuse one for both. + +### Key A — your login key (you ↔ VPS), you probably already have this +This is how *you* SSH in to run `provision.sh`. Nothing to set up here beyond +your existing access. + +### Key B — the CI deploy key (Forgejo Actions ↔ VPS), you create this +A dedicated key whose **private half** lives in a Forgejo Actions secret and +whose **public half** is authorized on the VPS's deploy user. It should only +be able to log in as `deploy` and run the deploy script. + +Generate it on your machine (no passphrase — CI can't type one): + +```bash +ssh-keygen -t ed25519 -C "thermograph-ci" -f ~/.ssh/thermograph_ci -N "" +``` + +That makes two files: +- `~/.ssh/thermograph_ci` → **private** key → goes into the Forgejo Actions secret `SSH_KEY` +- `~/.ssh/thermograph_ci.pub` → **public** key → goes on the VPS + +Install the **public** key on the VPS (as the `deploy` user): + +```bash +ssh-copy-id -i ~/.ssh/thermograph_ci.pub deploy@YOUR_VPS_IP +# or manually: append the .pub line to /home/deploy/.ssh/authorized_keys +``` + +Put the **private** key into Forgejo → repo **Settings → Actions → Secrets → +Add Secret**. Set all of these: + +| Secret | Value | +|--------|-------| +| `SSH_KEY` | full contents of `~/.ssh/thermograph_ci` (the private key, incl. BEGIN/END lines) | +| `SSH_HOST` | VPS IP or hostname | +| `SSH_USER` | `deploy` | +| `SSH_PORT` | `22` (or your custom SSH port) | + +No `tea` CLI is installed for a command-line alternative; use the web UI, or +the REST API with a personal access token: +`POST /api/v1/repos/{owner}/{repo}/actions/secrets/{name}` (body `{"data": "..."}`). + +**Why I can't do this for you:** Forgejo secrets are write-only and require +your Forgejo auth; the VPS's `authorized_keys` requires your SSH access. Both +are credentials only you should hold. + +### Deploy key for cloning a private repo (optional) +If the Forgejo repo is **private**, the VPS also needs to *pull* from it. Two +options: +- Make `provision.sh`'s `REPO_URL` an **HTTPS** URL with an embedded personal + access token (what the live VPS checkout actually uses today — see its + `origin` remote), or +- Add the `deploy` user's own key (`ssh-keygen` on the VPS) as a **read-only + Deploy Key** in the repo (Settings → Deploy keys). This is separate from Key B. + +If the repo is **public**, skip this — `git pull` needs no auth. + +--- + +## Part 2 — Install (once, on the VPS) + +1. **Create the Forgejo repo and push** (this project isn't versioned yet): + + ```bash + cd ~/Code/Thermograph + git init && git add -A && git commit -m "Initial commit" + # create an empty private repo on the Forgejo instance first (web UI or + # POST /api/v1/user/repos), then: + git remote add origin https:///OWNER/thermograph.git + git push -u origin main + ``` + +2. **Point DNS** for each domain you serve (`thermograph.org` for the app, + `emigriffith.dev` for the portfolio) at the VPS IP with an `A` record. Let's + Encrypt won't issue a cert until the name resolves to the box. + +3. **Provision the box.** Copy `deploy/provision.sh` over (or clone the repo) + and edit `REPO_URL` at the top, then: + + ```bash + REPO_URL="https:///OWNER/thermograph.git" bash deploy/provision.sh + ``` + + It installs Python + git + Caddy, creates the `deploy` user, clones to + `/opt/thermograph`, installs the systemd unit + env file, grants the deploy + user a password-less `systemctl restart thermograph` (so CI can restart it), + and starts the service. + +4. **Install the Caddy config** and reload. `deploy/Caddyfile` already carries the + real domains (`thermograph.org` + `emigriffith.dev`); edit them if yours differ, + then copy it into place, validate, and reload: + + ```bash + sudo cp /opt/thermograph/deploy/Caddyfile /etc/caddy/Caddyfile + sudo caddy validate --config /etc/caddy/Caddyfile # syntax check before reload + sudo systemctl reload caddy # Caddy fetches each domain's TLS cert automatically + ``` + + Do this again any time `deploy/Caddyfile` changes — the CI deploy does not ship + it. Set the app's base path to match in `/etc/thermograph.env` + (`THERMOGRAPH_BASE=/` for the app-at-root layout), then `sudo systemctl restart + thermograph`. + +5. **Install Key B** (Part 1) so CI can log in. + +6. **Verify:** open `https://YOUR_DOMAIN/` — you should get a clean padlock. + +--- + +## Part 3 — Day-to-day + +- **Deploy:** just `git push` to `main`. Actions runs `deploy.sh` and + health-checks the service. Watch it under the repo's **Actions** tab on + Forgejo (or query `/api/v1/repos/{owner}/{repo}/actions/tasks`). +- **Manual deploy / rollback:** `ssh deploy@vps '/opt/thermograph/deploy/deploy.sh'` + (set `BRANCH=` or check out a tag first to roll back). +- **Logs:** `journalctl -u thermograph -f` (app), `/var/log/caddy/thermograph.log` (proxy). +- **Restart:** `sudo systemctl restart thermograph`. +- **Change port / base path:** edit `/etc/thermograph.env`, then `sudo systemctl + restart thermograph`. Changing the base path usually pairs with a Caddy change + (proxy target / redirects) — update `/etc/caddy/Caddyfile` and reload Caddy too. +- **Change routing / domains:** edit `/etc/caddy/Caddyfile` (or copy the repo's), + `sudo caddy validate --config /etc/caddy/Caddyfile`, then `sudo systemctl reload + caddy`. The CI deploy never ships the Caddyfile, so this is always by hand. + +## Security notes +- App is bound to `127.0.0.1` — not reachable except through Caddy. +- Firewall: allow only `22`, `80`, `443` (e.g. `ufw allow 22,80,443/tcp`). +- The CI sudoers rule is scoped to exactly `systemctl restart/status thermograph`. +- Rotate Key B by regenerating it and updating the `SSH_KEY` secret + + `authorized_keys`. + +## Accounts & notifications + +Accounts, subscriptions, and notifications are **authoritative and not +regenerable** — back them up. On **prod/beta they live in Postgres/TimescaleDB** +(the compose `db` service, on the `pgdata` volume) alongside the climate record; +on **LAN dev** they fall back to SQLite (`data/accounts.sqlite`) when +`THERMOGRAPH_DATABASE_URL` isn't a Postgres URL. + +- **Back up the Postgres volume** (e.g. `pg_dump` the `thermograph` database) as + part of the VPS backup routine — losing it wipes all accounts and alerts. (On + a SQLite LAN dev box, back up `data/accounts.sqlite` and its `-wal`/`-shm` + sidecars instead.) +- **Multiple workers, one notifier (leader election).** Prod runs several uvicorn + workers (`WORKERS`, currently 8 on prod / 4 on beta). The subscription + evaluator and the recurring scheduler must run in exactly one, so the workers + elect a single leader — a host-local `flock` (`THERMOGRAPH_SINGLETON_LOCK`, + set by the compose app service to `/app/data/notifier.lock`), or a cluster-wide + Postgres advisory lock (`THERMOGRAPH_SINGLETON_PG=1`) if the notifier must be + single across *hosts*. See `backend/core/singleton.py`. `THERMOGRAPH_ROLE` + (`web`/`worker`/`all`, default `all`) further restricts which processes may own + it, so a stateless web tier can scale without scaling notifiers. +- **Env vars** (all optional, sensible defaults): + - `THERMOGRAPH_COOKIE_SECURE` — set to `1` when serving over HTTPS (the VPS/TLS + deploy) so the session cookie is `Secure`. Leave unset for plain-HTTP LAN, where + a `Secure` cookie would never be sent. + - `THERMOGRAPH_SESSION_TTL_DAYS` — login/cookie lifetime (default 30). + - `THERMOGRAPH_NOTIFY_INTERVAL` — seconds between evaluation passes (default 900). + - `THERMOGRAPH_NOTIFY_ARCHIVE_FETCHES` — max missing-archive fetches per pass + (default 4). A newly-subscribed cell with no cached ~45-year archive is fetched + once (then only read); this caps how many such fetches one pass may do so a burst + of new subscriptions can't exhaust the archive quota. + - `THERMOGRAPH_ENABLE_NOTIFIER` — set to `0` to disable the background evaluator. + - `THERMOGRAPH_AUTH_SECRET` — signing secret for (future) email reset/verify + tokens; unused today, a per-boot random value is used if unset. + +### Web Push (VAPID) + +Push notifications are signed with a VAPID keypair. If `THERMOGRAPH_VAPID_PRIVATE_KEY` +/ `THERMOGRAPH_VAPID_PUBLIC_KEY` are unset, the app generates a pair into +**`data/vapid.json`** on first run. That's fine **only if the file persists** — the +key the browser subscribed with must keep matching the key the server signs with. If +the keys ever change (regenerated `data/vapid.json`, a non-persistent data dir), every +existing subscription silently stops delivering: the push service returns 401/403 and +the app records it (`logs/errors/*.jsonl`, tagged `"phase":"push"`) but the `/push/test` +call still returns 202 with `sent:0, failed:N`. To be safe, **pin the keys** in +`/etc/thermograph.env` (see `deploy/thermograph.env.example` for the generate command). +After changing keys, users must toggle alerts off/on to re-subscribe. + +Diagnose a "test says sent but nothing arrives": check the `/push/test` response +(`failed:N` ⇒ delivery rejected) or `tail logs/errors/*.jsonl | grep push`, and +`docker compose logs backend | grep -i push` for the exact status code. + +## Homepage "unusual right now" feed + +`backend/api/homepage.py` sweeps the tracked cities, grades each one's latest +observation from the parquet cache, and writes the ranked result to +`data/homepage.json` for the homepage template. It runs on the notifier's timer +(hourly guard) and at the tail of `warm_cities.py`, so a deploy repopulates it. + +**It spends a small amount of forecast quota, and has to.** Nothing else keeps +the recent/forecast cache current for the tracked cities: `warm_cities.py` skips +any city whose archive is already cached, so it never re-fetches their forecast +bundle, and the notifier only touches cells somebody has subscribed to. Reading +that cache without refreshing it made the strip present days-old readings as +"right now". Each pass therefore tops up the **stalest** cells first, capped: + +- `THERMOGRAPH_HOMEPAGE_REFRESH` — recent/forecast fetches per pass (default 40, + one per cell). At the hourly cadence that is ~960/day. Set `0` to disable, in + which case the strip only ranks cities something else happened to refresh. +- `THERMOGRAPH_HOMEPAGE_CITIES` — how many cities the strip ranks over (default + 250). The strip shows ~12 cards; ranking over a smaller set that is genuinely + fresh beats ranking over every cached city when most of those are stale. + +Readings older than a day are dropped rather than ranked, so an empty strip means +the cache went cold — not that the weather is unremarkable. + +## Outbound email + +The app never talks to a mail provider directly. It speaks plain SMTP to +**Postfix running as a send-only null client on `127.0.0.1:25`** +(`deploy/provision-mail.sh`, run once as root). `backend/mailer.py` is the only +code that sends, over stdlib `smtplib` — no new dependency. + +Why route through a local MTA rather than a provider's API: + +- **Delivery policy stays swappable.** Direct-to-MX or relayed through a + transactional provider is a Postfix setting; the app's config is just + "localhost:25" either way, so switching needs no code change or redeploy. +- **Postfix queues and retries.** A handler hands the message off in + microseconds; a slow or briefly-down upstream can't stall a request or lose a + signup. +- **Nothing new is exposed.** `inet_interfaces = loopback-only`, so the box + accepts mail from itself and nothing else. A loopback socket is not a + filesystem write, so the hardened unit (`ProtectSystem=full`, + `ReadWritePaths=…`) needs no change. + +**Default is safe:** `THERMOGRAPH_MAIL_BACKEND` defaults to `console` — it logs +the message and sends nothing — so LAN dev and the test suite exercise the whole +signup path with no mail server and no risk of mailing a real person. Production +opts in with `=smtp`. + +### Deliverability (do this before mailing real subscribers) + +Mail from a bare VPS IP is very often junked no matter how Postfix is configured, +because the IP has no sending reputation. Either: + +- **Relay through a provider** (recommended) — `RELAYHOST` + credentials in + `provision-mail.sh`. They handle SPF/DKIM alignment and reputation. +- **Direct to MX** — then you own the DNS work: an **SPF** TXT record, **DKIM** + (opendkim) with the public key published, a **DMARC** TXT record, and a + **PTR / reverse-DNS** record on the VPS IP pointing at `mail.thermograph.org`. + Missing rDNS alone is enough for Gmail and Outlook to junk everything. + +Check placement with , which scores all four at once. + +- **Env vars** (all in `deploy/thermograph.env.example`): + `THERMOGRAPH_MAIL_BACKEND` (`console` | `smtp` | `disabled`), + `THERMOGRAPH_SMTP_HOST` / `_PORT` / `_USER` / `_PASSWORD` / `_STARTTLS`, + `THERMOGRAPH_MAIL_FROM`, `THERMOGRAPH_MAIL_REPLY_TO`. +- **`THERMOGRAPH_AUTH_SECRET` must be set to a fixed value** before any + confirmation or password-reset link is mailed. It currently defaults to a + per-boot random value, so every outstanding link would break on restart. +- **Digest signups** land in the `pending_digest` table in the accounts DB + (Postgres on prod/beta; authoritative, back it up). The form ships ahead of + delivery on purpose: + addresses are collected now and confirmed once SMTP is live. +- **Logs:** `journalctl -u postfix -f`; queue with `mailq`. + +## SEO content pages + +Crawlable, server-rendered pages (Jinja2) sit alongside the interactive tool and +link into it: `/climate` (hub), `/climate/` (per-city), `/climate//`, +`/climate//records`, `/glossary`, `/about`, plus `/robots.txt` and +`/sitemap.xml`. The routable city set is `backend/cities.json` (~1000 metros: top +~500 global + ~250 core-English + ~250 extended-English/high-proficiency), +regenerated with `python gen_cities.py [n_global] [n_english] [n_extended]`. + +- A **values filter** in `gen_cities.py` drops cities in countries that criminalize + LGBTQ+ people (plus China/Pakistan/Indonesia by choice) via `EXCLUDE_CC`, except a + hand-kept list of notable/tourist/high-English hubs in `KEEP_SLUGS` (Lagos, Nairobi, + Cairo, Dubai, KL, Shanghai×5-from-China, Karachi, Jakarta, …). Excluded slots are + backfilled from the next-ranked non-excluded cities, so the total stays ~1000. Edit + either set and regenerate to change the policy. + +- **Per-city blurbs** live in `backend/cities_flavor.json` (a short Wikipedia summary + per city, CC BY-SA, attributed on the page). `python gen_flavor.py` fetches + Wikipedia's free REST summary API and validates each match by coordinates; it's + **incremental** by default (only fetches cities missing a blurb, and prunes ones no + longer in cities.json) — pass `--full` to rebuild. ~94% of cities get a blurb; the + rest render without one. +- **Notable weather events** are a small **hand-curated** list in `backend/city_events.py` + (`{slug: {text, url}}`) — one verified iconic event per city (Katrina/New Orleans, + Harvey/Houston, the 1952 Great Smog/London, …). Auto-sourcing these from search + proved unreliable (wrong matches), so they're edited by hand; a city not in the list + simply shows its flavor blurb. Add entries freely — a test checks the slugs are valid. + +- **Archive warming runs automatically on every deploy.** `deploy.sh` (prod) and + `deploy-dev.sh` (dev) launch `warm_cities.py` detached in the background after the + health check, so the ~750 city pages serve from cache and a crawl can't burst the + archive API quota. It's idempotent (skips already-cached cells), so only the first + deploy does the full ~25-min warm; later deploys just top up new cities. A page + also self-heals (fetches its archive once) if hit before warming finishes. To run + it by hand: `cd backend && python warm_cities.py --pace 2`. Logs: prod + `logs/warm-cities.log`; dev `journalctl --user -u thermograph-warm-cities`. +- **Submit the sitemap** in Google Search Console: `https://thermograph.org/sitemap.xml`. +- `jinja2` is a new dependency (already in `requirements.txt`). + +## Monitoring + +Fleet-wide logs and dashboards live in a **separate project** — a central +Grafana + Loki stack fed by a Grafana Alloy agent on every node, over the +WireGuard mesh (`thermograph-observability` on Forgejo; UI at +**`https://dashboard.thermograph.org`**, Google SSO). It ingests every +container's stdout, Caddy's access logs, and the app's structured JSON logs +(`logs/{errors,access,audit}/*.jsonl`). This replaced the old SSH-tailed +`scripts/dashboard.py`. + +The app still exposes raw counters at the gated `GET /api/v2/metrics` route +(loopback-only; refuses any request carrying a proxy `X-Forwarded-*` header, so +it's never reachable through Caddy). It reports inbound requests per endpoint, +outbound calls per upstream source, and background-daemon heartbeats +(e.g. the subscription notifier). Read it over an SSH tunnel with +`THERMOGRAPH_METRICS_TOKEN` (set in `/etc/thermograph.env`) as the `token=` +query param — handy for a quick check without opening Grafana. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fd349bb --- /dev/null +++ b/Makefile @@ -0,0 +1,61 @@ +# docker-compose orchestration only. The app-level targets that used to live +# here (run, lan-run, stop, migrate, migrate-cache, test, shots, indexnow, +# venv, clean) moved to the backend/frontend app repos along with the code +# they operate on. + +.PHONY: up down db-up db-down dev-up dev-down om-up om-down om-backfill + +# --- docker-compose stack (backend + frontend + PostgreSQL) --------------------- +# Requires a POSTGRES_PASSWORD (copy .env.example -> .env). See docker-compose.yml. + +# Pull each service's published image and start the whole stack (backend + +# frontend + db) in the background. docker-compose.yml no longer has a +# top-level `build:` for backend/frontend -- each ships as its OWN image +# (BACKEND_IMAGE_TAG / FRONTEND_IMAGE_TAG), built in its own app repo (a plain +# `docker build` there, or that repo's own build-push.yml in CI) -- so `up` +# here only pulls + runs; it can no longer build in place. +up: + docker compose pull + docker compose up -d + +# Stop and remove the stack's containers (named volumes persist). +down: + docker compose down + +# Start just the Postgres service (e.g. to run the app against it from a venv). +db-up: + docker compose up -d db + +# Stop the Postgres service. +db-down: + docker compose stop db + +# The LAN dev stack: uncapped (no CPU limits) and published on 0.0.0.0:8137 so +# phones on the Wi-Fi can reach it (the base file is prod: capped + loopback). +DEV_COMPOSE = docker compose -f docker-compose.yml -f docker-compose.dev.yml +dev-up: + POSTGRES_PASSWORD=$${POSTGRES_PASSWORD:-thermograph-dev} $(DEV_COMPOSE) up -d --build + +dev-down: + $(DEV_COMPOSE) down + +# Self-hosted Open-Meteo (prod-only overlay). Serves the ERA5 archive from object +# storage so the app is off the public archive API. See deploy/openmeteo/README.md +# for the object-storage bucket + rclone mount this expects on the host. +OM_COMPOSE = docker compose -f docker-compose.yml -f docker-compose.openmeteo.yml +om-up: + $(OM_COMPOSE) up -d --build + +om-down: + $(OM_COMPOSE) down + +# One-time historical backfill: write the full 1980→present archive (~1–1.5 TB of +# .om) to object storage. Runs each dataset once (no --repeat-interval), independent +# of the app/db, so it can run before the stack is flipped over. Hours-long. +OM_LAND_VARS = temperature_2m,dew_point_2m,precipitation,shortwave_radiation,wind_u_component_10m,wind_v_component_10m +OM_ERA5_VARS = wind_gusts_10m,temperature_2m,dew_point_2m,precipitation,shortwave_radiation,wind_u_component_10m,wind_v_component_10m +om-backfill: + $(OM_COMPOSE) run --rm --no-deps open-meteo-sync-land \ + sync copernicus_era5_land $(OM_LAND_VARS) --past-days $${OM_BACKFILL_DAYS:-17000} + $(OM_COMPOSE) run --rm --no-deps open-meteo-sync-era5 \ + sync copernicus_era5 $(OM_ERA5_VARS) --past-days $${OM_BACKFILL_DAYS:-17000} diff --git a/deploy/migrate-db.py b/deploy/migrate-db.py new file mode 100644 index 0000000..851976f --- /dev/null +++ b/deploy/migrate-db.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Apply pending SQL migrations to the accounts database. + +This project has no Alembic. SQLAlchemy ``create_all()`` only creates *missing +tables*, so column additions to the long-lived accounts DB are done with the +hand-written ``.sql`` files in ``deploy/migrations/``. This runner applies the +ones a given database hasn't seen yet and records them in a ``schema_migrations`` +table, so it is safe (and cheap) to run on every deploy. + +It resolves the database exactly as the app does (``db.py``): +``THERMOGRAPH_ACCOUNTS_DB`` if set, otherwise ``/data/accounts.sqlite``. + +Fresh database: the app's ``create_all()`` builds the *current* schema on first +start, so there is nothing to ALTER. This runner detects that (no file yet, or no +``user`` table) and records the existing migrations as a baseline instead of +replaying them, so they never run against a schema that already has the columns. + +Usage (wired into deploy.sh / deploy-dev.sh, but runnable by hand too): + + /opt/thermograph/.venv/bin/python deploy/migrate-db.py +""" +import glob +import os +import shutil +import sqlite3 +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.dirname(HERE) +MIGRATIONS_DIR = os.path.join(HERE, "migrations") +DB_PATH = os.environ.get("THERMOGRAPH_ACCOUNTS_DB") or os.path.join(REPO, "data", "accounts.sqlite") + + +def _applied(conn: sqlite3.Connection) -> set[str]: + conn.execute( + "CREATE TABLE IF NOT EXISTS schema_migrations (" + "name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)" + ) + return {row[0] for row in conn.execute("SELECT name FROM schema_migrations")} + + +def _record(conn: sqlite3.Connection, name: str) -> None: + conn.execute( + "INSERT OR IGNORE INTO schema_migrations(name, applied_at) " + "VALUES (?, datetime('now'))", + (name,), + ) + + +def _has_user_table(conn: sqlite3.Connection) -> bool: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='user'" + ).fetchone() + return row is not None + + +def main() -> None: + migrations = sorted(glob.glob(os.path.join(MIGRATIONS_DIR, "*.sql"))) + if not migrations: + print("migrate-db: no migrations found") + return + + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + fresh = not os.path.exists(DB_PATH) + conn = sqlite3.connect(DB_PATH) + try: + applied = _applied(conn) + pending = [m for m in migrations if os.path.basename(m) not in applied] + if not pending: + print(f"migrate-db: up to date ({len(applied)} applied) -> {DB_PATH}") + return + + # Fresh DB (or one whose accounts schema hasn't been built yet): the app's + # create_all() will produce the current schema on next start, so record the + # existing migrations as a baseline rather than ALTERing a table that isn't + # there. Only genuinely older databases need the ALTERs replayed. + if fresh or not _has_user_table(conn): + for m in pending: + _record(conn, os.path.basename(m)) + conn.commit() + print(f"migrate-db: fresh database, baselined {len(pending)} migration(s) -> {DB_PATH}") + return + + # These are the authoritative, non-regenerable accounts. Back the DB up + # before touching it, next to the DB with a timestamp. + backup = f"{DB_PATH}.bak-{time.strftime('%Y%m%d-%H%M%S')}" + shutil.copy2(DB_PATH, backup) + print(f"migrate-db: backed up -> {backup}") + + for m in pending: + name = os.path.basename(m) + with open(m, encoding="utf-8") as f: + sql = f.read() + try: + conn.executescript(sql) + except sqlite3.OperationalError as e: + msg = str(e).lower() + # Idempotent: a column/index this migration adds already exists + # (applied by hand before, or created fresh from the model). Treat + # as already-applied and record it; re-raise anything unexpected. + if "duplicate column" in msg or "already exists" in msg: + print(f"migrate-db: {name} already present in schema, recording") + else: + conn.rollback() + print(f"migrate-db: FAILED on {name}: {e}", file=sys.stderr) + raise + _record(conn, name) + conn.commit() + print(f"migrate-db: applied {name}") + + print(f"migrate-db: done ({len(pending)} newly recorded) -> {DB_PATH}") + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/deploy/migrations/001-user-discord-id.sql b/deploy/migrations/001-user-discord-id.sql new file mode 100644 index 0000000..69d09aa --- /dev/null +++ b/deploy/migrations/001-user-discord-id.sql @@ -0,0 +1,17 @@ +-- Adds User.discord_id for Discord account linking (backend/discord_link.py). +-- +-- This project has no Alembic: SQLAlchemy create_all() only creates *missing +-- tables*, so it will NOT add this column to an existing accounts DB. deploy.sh +-- and deploy-dev.sh run deploy/migrate-db.py, which applies the pending files +-- here on every deploy (tracked in a schema_migrations table, backing the DB up +-- first). A fresh DB gets the column + uniqueness from the model and is baselined +-- rather than ALTERed. To apply by hand instead (back the DB up first): +-- +-- sqlite3 /opt/thermograph/data/accounts.sqlite < deploy/migrations/001-user-discord-id.sql + +ALTER TABLE user ADD COLUMN discord_id VARCHAR(32); + +-- SQLite can't add a UNIQUE column via ALTER, so enforce it with a partial unique +-- index (NULLs are allowed to repeat; a real id links to at most one account). +CREATE UNIQUE INDEX IF NOT EXISTS ix_user_discord_id + ON user (discord_id) WHERE discord_id IS NOT NULL; diff --git a/deploy/migrations/002-user-discord-dm.sql b/deploy/migrations/002-user-discord-dm.sql new file mode 100644 index 0000000..ef1683d --- /dev/null +++ b/deploy/migrations/002-user-discord-dm.sql @@ -0,0 +1,9 @@ +-- Adds User.discord_dm for Discord DM alert opt-in (notify.py DM channel). +-- Same no-Alembic caveat as 001: create_all won't add a column to an existing +-- accounts DB. deploy/migrate-db.py applies this on every deploy; existing rows +-- default to 0 (DMs off) until the user opts in by linking Discord. To apply by +-- hand instead (back the DB up first): +-- +-- sqlite3 /opt/thermograph/data/accounts.sqlite < deploy/migrations/002-user-discord-dm.sql + +ALTER TABLE user ADD COLUMN discord_dm BOOLEAN NOT NULL DEFAULT 0; diff --git a/deploy/provision-dev-lan.sh b/deploy/provision-dev-lan.sh new file mode 100755 index 0000000..1e976bc --- /dev/null +++ b/deploy/provision-dev-lan.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# One-time bootstrap for the Thermograph LAN dev server on THIS machine. +# +# Sudo-free: the app runs as a Docker Compose stack (see deploy-dev.sh), and +# `linger` keeps the runner/services running across logout/reboot. Re-runnable +# (idempotent). +# +# bash deploy/provision-dev-lan.sh +set -euo pipefail + +APP_DIR="${APP_DIR:-$HOME/thermograph-dev}" +REPO_URL="${REPO_URL:-http://10.10.0.2:3080/emi/thermograph.git}" +BRANCH="${BRANCH:-dev}" +here="$(cd "$(dirname "$0")" && pwd)" + +echo "==> Enabling linger so the service survives logout/reboot" +loginctl enable-linger "$USER" \ + || echo " (couldn't enable linger; the service still runs while you're logged in)" + +echo "==> Cloning/refreshing $APP_DIR and starting the service" +APP_DIR="$APP_DIR" REPO_URL="$REPO_URL" BRANCH="$BRANCH" bash "$here/deploy-dev.sh" + +cat < 'bash -s' < this`), after the encrypted vault +# file has been scp'd to /tmp/tg-.yaml. NON-DESTRUCTIVE go/no-go for the cutover: +# renders the vault and diffs the KEY=VALUE set against the live /etc/thermograph.env. +# Prints only PASS/FAIL and any differing key NAMES — never secret values. No git, no +# writes; touches nothing. (The encrypted file is safe to ship; only ciphertext.) +set -euo pipefail +key=/etc/thermograph/age.key +env_name="$(sudo cat /etc/thermograph/secrets-env)" +host="/tmp/tg-${env_name}.yaml" +common="/tmp/tg-common.yaml" + +command -v sops >/dev/null || { echo "FAIL: sops not installed"; exit 1; } +[ -f "$host" ] || { echo "FAIL: $host not found — scp the encrypted vault file first"; exit 1; } + +keymat="$(sudo grep '^AGE-SECRET-KEY-' "$key")" +tmp="$(mktemp)"; live="$(mktemp)"; trap 'rm -f "$tmp" "$live"' EXIT +: > "$tmp" +[ -f "$common" ] && SOPS_AGE_KEY="$keymat" sops -d --input-type yaml --output-type dotenv "$common" >> "$tmp" +SOPS_AGE_KEY="$keymat" sops -d --input-type yaml --output-type dotenv "$host" >> "$tmp" + +sudo cat /etc/thermograph.env > "$live" +python3 - "$live" "$tmp" <<'PY' +import sys +def load(p): + out={} + for line in open(p, encoding="utf-8"): + s=line.rstrip("\n").strip() + if not s or s.startswith("#") or "=" not in s: continue + k,_,v=s.partition("="); out[k.strip()]=v + return out +live,rend=load(sys.argv[1]),load(sys.argv[2]) +lost=sorted(set(live)-set(rend)); added=sorted(set(rend)-set(live)) +changed=sorted(k for k in live if k in rend and live[k]!=rend[k]) +if lost or added or changed: + print("FAIL — the render would NOT match the live env") + if lost: print(" lost keys:", lost) + if added: print(" extra keys:", added) + if changed: print(" value-changed keys:", changed) + sys.exit(1) +print(f"PASS — all {len(live)} keys render byte-identical to the live /etc/thermograph.env") +PY diff --git a/deploy/twa/README.md b/deploy/twa/README.md new file mode 100644 index 0000000..fa30d5f --- /dev/null +++ b/deploy/twa/README.md @@ -0,0 +1,85 @@ +# Thermograph Android app (Trusted Web Activity) + +The Android app is a **Trusted Web Activity (TWA)**: a thin native shell that opens +`https://thermograph.org` full-screen in the user's Chrome engine. It *is* the live +PWA — no content is duplicated, and the existing service worker + VAPID **web push +keeps working** inside the app via Android notification delegation (no Firebase, no +backend change). `enableNotifications: true` in `twa-manifest.json` turns that on +and wires the Android 13+ `POST_NOTIFICATIONS` runtime permission. + +This folder holds the build config. **Building the APK is a local/manual step** — +it needs a JDK, the Android SDK, and a signing keystore that only you should hold. +The current goal is a **signed APK you sideload for testing**; Play Store +submission ($25 one-time) is deferred. + +## What's in the repo +- `twa-manifest.json` — Bubblewrap config (reference values; see comment inside). +- `../../frontend/.well-known/assetlinks.json` — Digital Asset Links, served at + `https://thermograph.org/.well-known/assetlinks.json`. Its fingerprint is a + **placeholder** until you generate the signing key (step 4). + +## One-time build (you) + +Prereqs: Node 18+, JDK 17. Bubblewrap can download the Android SDK for you. + +```bash +npm install -g @bubblewrap/cli + +# 1. Scaffold from the live manifest (creates ./twa-manifest.json + a keystore). +# Accept defaults, then reconcile with this repo's twa-manifest.json — in +# particular set: packageId = org.thermograph.twa, enableNotifications = true. +bubblewrap init --manifest https://thermograph.org/manifest.webmanifest + +# 2. (Or) copy this repo's config and let Bubblewrap fill the SDK bits: +# cp deploy/twa/twa-manifest.json ./twa-manifest.json && bubblewrap update + +# 3. Build the signed APK (and .aab for the store, for later). +bubblewrap build +# -> ./app-release-signed.apk and ./app-release-bundle.aab +``` + +### 4. Wire Digital Asset Links (required — or the app opens with a URL bar) + +```bash +# Print the signing key's SHA-256 fingerprint: +bubblewrap fingerprint list # or: keytool -list -v -keystore android-keystore.jks +``` + +Copy the `SHA256` value into `frontend/.well-known/assetlinks.json`, replacing +`REPLACE_WITH_TWA_SIGNING_KEY_SHA256_FINGERPRINT`, then **deploy the site** so the +new file is live. Confirm: + +```bash +curl -s https://thermograph.org/.well-known/assetlinks.json # must show your fingerprint, Content-Type: application/json +``` + +> If you later publish to Play Store with **Play App Signing**, add *Google's* app +> signing certificate fingerprint (from the Play Console) to `assetlinks.json` too — +> the upload-key fingerprint alone won't verify the store build. + +### 5. Sideload and test + +```bash +adb install app-release-signed.apk +``` + +On the device: +- Launch Thermograph — it must open **full-screen with no browser address bar** + (that confirms assetlinks verified). +- Subscribe to a city (or trigger a test alert) and confirm the "unusual weather" + push arrives as a **native Android notification**. +- Grant the notifications permission when prompted (Android 13+). + +## Keep the keystore safe +`android-keystore.jks` is required for **every future update** and for the +eventual Play Store listing. Losing it means a new package identity and a fresh +install for every user. Store it out of the repo, backed up. + +## Deferred +- **Play Store listing** ($25 one-time Google Play developer account). Upload the + `.aab`; the store review for a well-formed TWA is light. Not part of this pass. + +## Why no backend changes +Android delegates the TWA's web notifications to the OS, so `backend/push.py` +(VAPID) and `backend/notify.py` deliver push to the app unchanged. Nothing in +`backend/` is touched by this track. diff --git a/deploy/twa/twa-manifest.json b/deploy/twa/twa-manifest.json new file mode 100644 index 0000000..a71434d --- /dev/null +++ b/deploy/twa/twa-manifest.json @@ -0,0 +1,33 @@ +{ + "_comment": "Bubblewrap config for the Thermograph Trusted Web Activity. `bubblewrap init --manifest https://thermograph.org/manifest.webmanifest` scaffolds a file like this; the values below are the ones that matter for us (packageId must match /.well-known/assetlinks.json, enableNotifications wires web-push delegation, colors come from manifest.webmanifest). See README.md in this folder.", + "packageId": "org.thermograph.twa", + "host": "thermograph.org", + "name": "Thermograph", + "launcherName": "Thermograph", + "display": "standalone", + "orientation": "default", + "themeColor": "#f0803c", + "themeColorDark": "#0f1216", + "navigationColor": "#171b21", + "navigationColorDark": "#0f1216", + "navigationColorDivider": "#2a323c", + "navigationColorDividerDark": "#2a323c", + "backgroundColor": "#171b21", + "enableNotifications": true, + "startUrl": "/", + "webManifestUrl": "https://thermograph.org/manifest.webmanifest", + "iconUrl": "https://thermograph.org/logo.png?v=3", + "maskableIconUrl": "https://thermograph.org/logo-maskable-512.png?v=3", + "monochromeIconUrl": "https://thermograph.org/favicon.svg?v=3", + "fallbackType": "customtabs", + "features": {}, + "signingKey": { + "path": "./android-keystore.jks", + "alias": "thermograph" + }, + "appVersionName": "1.0.0", + "appVersionCode": 1, + "minSdkVersion": 23, + "shortcuts": [], + "generatorApp": "bubblewrap-cli" +}