Author SHA1 Message Date
BBergle b1e5aaf908 Merge pull request 'docs: mark Phase 0 done, record D19 (auto-update, deferred)' (#12) from docs/phase-0-wrapup into test/deploy-login-smoke-test
Reviewed-on: #12
2026-09-21 22:59:58 -04:00
BBergleandClaude Sonnet 5 244fe525dd docs: mark Phase 0 done, record D19 (auto-update, deferred)
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 13s
CI / Migrations reversible (pull_request) Successful in 6s
CI / API (lint, types, tests) (pull_request) Successful in 54s
docs/PLAN.md's Phase 0 section and its "Done when"/Verification entries
still described the original Postgres+RLS, docker-compose, auto-redeploying
design — none of which is what actually got built and deployed. Marks it
done, states the two deliberate deviations plainly (SQLite not Postgres+RLS,
manual redeploy not automatic), and separates what's actually verified
(login persists a session, checked by scripts/smoke-test.sh after a real bug)
from what nobody has tried yet (PWA home-screen install).

docs/DECISIONS.md D19 records the auto-update investigation: the real fix
for Unraid's own "not available" update-check badge (a third, independent
place the D17 self-signed cert needed trusting — Unraid's PHP update
checker doesn't share Docker's own certs.d), the structural reason "up to
date" can't be fully trusted on this host even after that fix (CI builds on
the same dockerd the app runs on, so the local :latest tag is always fresh
regardless of whether the container was recreated from it), the failed
first Watchtower attempt (stale image, wrong Docker API version) and why
CI-triggers-a-redeploy was rejected again rather than reconsidered.
"Deliberately deferred" gets three new entries: finishing Watchtower,
migrating Gitea/CI to a dedicated VM (raised as the real fix for the
root cause D19 kept running into), and persisting the accumulated
host-local trust files across a reboot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 22:57:26 -04:00
BBergleandClaude Sonnet 5 8c88748f50 test(deploy): add a real login smoke test, not just a health check
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 14s
CI / Migrations reversible (pull_request) Successful in 12s
CI / API (lint, types, tests) (pull_request) Successful in 59s
Found on the actual first deployment: /api/v1/healthz proves the process is
up, but says nothing about whether login actually works, because the
session cookie is set with Secure in production. Test through a plain-HTTP
address (an IP, a bare port, skipping the reverse proxy) and /auth/login
still returns 200 with a valid body — the cookie is just silently dropped by
the client, so the very next request looks unauthenticated. From a browser
this looks exactly like "I logged in and it bounced me straight back to the
login screen," with no error anywhere to point at.

scripts/smoke-test.sh does the real round trip a browser does: login,
confirm a session cookie was actually stored (not just sent), then an
authenticated follow-up request confirming it succeeds and returns the
right account. Verified it actually catches what it's meant to catch before
committing: ran it against a throwaway account over plain HTTP against a
production-mode container and got the expected FAIL with a diagnostic
pointing at the Secure-cookie mismatch, then confirmed PASS once the
container's VELODROME_ENVIRONMENT was (inadvertently, in this case)
development instead.

Documented in deploy/README.md as the real post-deploy check, replacing
"hit /healthz and eyeball it" for anything involving auth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 22:54:51 -04:00
BBergle 65879e8659 Merge pull request 'feat(auth): add velodrome create-admin to bootstrap the first user' (#10) from feat/auth-create-admin-cli into main
CI / Repo hygiene (push) Successful in 2s
CI / Web (lint, typecheck, build) (push) Successful in 13s
CI / Migrations reversible (push) Successful in 5s
Release image / Build and push single-container image (push) Successful in 12s
CI / API (lint, types, tests) (push) Successful in 54s
Reviewed-on: #10
2026-09-21 22:34:57 -04:00
4 changed files with 207 additions and 12 deletions
+39 -3
View File
@@ -78,6 +78,30 @@ Nothing enforces the admin role yet — no admin-only endpoint exists — so tod
invited account only in the role recorded on it. Invite management in a later phase is what starts
reading it.
## Verify login actually works, not just that the API responds
`GET /api/v1/healthz` proves the process is up. It does **not** prove a real login works, because
the session cookie is set with `Secure` in production (`apps/api/velodrome/api/v1/auth.py`) —
browsers silently refuse to store a `Secure` cookie unless the request was actually served over
HTTPS. Test through a plain-HTTP address (an IP, a bare port, skipping the reverse proxy) and
`/auth/login` still returns 200 with a valid response body; the cookie is just quietly dropped, so
the very next request looks unauthenticated. From a browser this looks exactly like "I logged in
and it bounced me straight back to the login screen," with nothing that looks like an error. This
happened on the very first real deployment.
`scripts/smoke-test.sh` exists so this is caught by running a command, not by refreshing a browser
tab:
```sh
scripts/smoke-test.sh https://bike.bbergle.com you@example.com yourpassword
```
It logs in, confirms a session cookie was actually stored (not just sent), then makes an
authenticated follow-up request and confirms it succeeds and returns the right account. Run it
after every real deploy, against the actual public URL your users will use — testing against a
plain-HTTP IP will (correctly) tell you nothing about whether login works for anyone using the real
domain.
## Environment variables
All read by `apps/api/velodrome/config.py` (prefix `VELODROME_`) — the app and Alembic both read
@@ -130,8 +154,20 @@ It does **not** SSH into the host and recreate the running container — rolling
Unraid (pulling it and clicking "Apply" on the container, or via Unraid's own update-checking) is
left as a manual/Unraid-side step, not something CI does unattended.
Unraid's own "check for updates" is **not a reliable signal for this container specifically** — see
`docs/DECISIONS.md` D19. Because Gitea Actions builds on this same host's `dockerd`, every CI run
keeps the local `:latest` tag fresh regardless of whether the *running container* was ever
recreated from it, so the checker can say "up to date" while the running container is genuinely
stale. Don't wait for that badge; recreate deliberately after a merge you know should ship.
## What's not here yet
Backups (`docs/PLAN.md` calls for a systemd timer running `restic` against `/data`, independent of
CI) and the `import_inbox` USB-watch bind mount are both Phase 1+ concerns nothing in the schema
uses them yet.
- Backups (`docs/PLAN.md` calls for a systemd timer running `restic` against `/data`, independent
of CI) and the `import_inbox` USB-watch bind mount both Phase 1+ concerns, nothing in the
schema uses them yet.
- An auto-updater for the running container (attempted with Watchtower, deferred — D19).
- Persisting the host-local trust material from D17/D19 (`/etc/hosts`, `certs.d`, the CA bundle
entry) across a reboot — currently lost on restart, deliberately left that way pending a
decision about editing `/boot/config/go` (D19).
- Migrating Gitea + its Actions runners off this Unraid host onto a dedicated VM — the root cause
behind several of the fixes above, raised as a real future decision, not started (D19).
+59
View File
@@ -358,8 +358,67 @@ the deployed image has to carry.
---
### D19 — Auto-update: attempted, deferred; Unraid's own update checker needed a separate fix
**The immediate bug:** Unraid's Docker "check for updates" reported `not available` for `velodrome`
after D17's registry move. Root cause, found by reading the actual PHP source
(`dynamix.docker.manager`'s `DockerClient.php`): it queries the registry's manifest API directly
over `curl` from PHP, which is a completely different trust path from `dockerd`'s own — it doesn't
read Docker's `/etc/docker/certs.d` at all, only the OS-wide CA bundle. **Fixed** by also adding the
D17 self-signed cert to `/usr/local/share/ca-certificates/` and running `update-ca-certificates` on
the Unraid host — a third, independent place this cert now needs to be trusted (alongside
`certs.d` and the `/etc/hosts` entry from D17), and like those two, not yet persisted across a
reboot (`/boot/config/go` again — same deliberate non-decision as D17).
**A second, structural problem this exposed, not fixed:** even with the checker itself working,
"up to date" on this host doesn't reliably mean the *running container* matches the registry.
Gitea Actions builds directly on this same host's `dockerd` (DooD), which means every CI build also
leaves its own result sitting in the **local image cache** tagged `:latest` — so the local-vs-remote
digest comparison Unraid's checker does is comparing the registry against a tag that CI keeps fresh
on its own, independent of whether the `velodrome` *container* was ever recreated from it. Confirmed
directly: the checker reported "up to date" while the running container's actual manifest digest
(read via `docker inspect`) provably differed from the registry's current `Docker-Content-Digest`.
This is a consequence of building CI on the same host as the app runs, not a bug to patch around —
see the Gitea-to-VM item below.
**Attempted: Watchtower**, label-scoped (`WATCHTOWER_LABEL_ENABLE=true` + a
`com.centurylinklabs.watchtower.enable=true` label on `velodrome` only, specifically so it can never
touch any of the ~40 other containers on this host) with the CA bundle mounted in for the same
registry-trust reason as above. **Failed on the first attempt**`containrrr/watchtower`'s
published image talks a Docker API version (1.25) too old for this host's `dockerd`, a stale-image
problem, not a design problem. Not yet retried with a maintained fork. The `velodrome` container
does carry the watch-enable label already (added when it was recreated to pick up D18's CLI), so
turning this on later is "run the right watchtower image," not "redesign anything."
**Why not have CI redeploy the container directly** (it already has host `dockerd` access via DooD):
considered and explicitly rejected, again — see D16/D17's reasoning, which this doesn't change.
Turning every merge to `main` into an unattended production change on a personal server is a bigger
step than "install an auto-updater," and wasn't asked for.
**Until this is finished:** redeploying after a merge is `docker pull` + recreate, same as any
manual deploy — `deploy/README.md`'s "Publishing the image" section.
---
## Deliberately deferred
- **Finish the Watchtower auto-updater** (D19) — retry with a maintained image; `velodrome` is
already labeled for it.
- **Migrate Gitea + its Actions runners to a dedicated VM**, off the Unraid host the app itself
runs on. Raised explicitly (not yet started) after D17/D19 both turned out to be fighting the
same root cause from different angles: CI sharing a `dockerd` with ~40 unrelated production
containers means every registry-trust fix and every update-check quirk this session hit was more
contained, and more repeatable to reason about, than it should have needed to be. A dedicated VM
removes that coupling entirely — CI's own Docker config becomes free to change without any
blast-radius conversation about Plex or Vaultwarden ever again. Real migration work (new VM,
moving Gitea's and both runners' appdata, re-pointing `192.168.0.3`, updating every reference to
it across this repo and this session's own tooling), not a quick fix — a deliberate choice to do
later, not an oversight now.
- **Persist the D17/D19 host-local trust files across a reboot** (`/etc/hosts`, `certs.d`, the CA
bundle addition) via `/boot/config/go`. Left un-persisted through both decisions specifically
because editing anything under `/boot` was raised as a real concern mid-session — worth revisiting
together once, for all three at once, rather than as three separate asks.
- **Routing** (Valhalla/Photon/Overpass) — Phase 5, optional. Several GB of RAM for something
Komoot already does well.
- **Local LLM ride summaries** (Ollama) — Phase 4, behind a compose profile.
+32 -9
View File
@@ -529,13 +529,31 @@ These are the payoff for self-hosting — things Strava structurally cannot do.
Estimates assume one developer working evenings and weekends.
**Phase 0 — Scaffolding (2 weeks).** Monorepo, `uv`/`ruff`/`mypy --strict`, FastAPI skeleton with
`/healthz` and OpenAPI, Alembic baseline (users/invites/sessions **with RLS policies from the first
migration**), SvelteKit static SPA shell with login, manifest + service worker + precache passing
Lighthouse installability, VAPID keypair, Caddy, compose, Gitea Actions green, image in the registry,
deployed.
*Done when:* you log in at the real URL, add it to your iPhone home screen, it launches standalone —
and a push to `main` rebuilds and redeploys it.
**Phase 0 — Scaffolding.** ✅ **Done, with two deliberate deviations from this original description —
both recorded in `docs/DECISIONS.md`, not silent drift.** Monorepo, `uv`/`ruff`/`mypy --strict`,
FastAPI skeleton with `/healthz` and OpenAPI, Alembic baseline (users/invites/sessions), SvelteKit
static SPA shell with login, manifest + service worker, Caddy, Gitea Actions green, image in the
registry, deployed to a real Unraid host behind real HTTPS.
- **SQLite, not Postgres+RLS.** D15 reversed D4 mid-Phase-0, after the RLS version was already
shipped and merged. Isolation is now enforced entirely at the repository layer (`db.py`'s
`Scope`), not database RLS. See D15 for the full cost/benefit record.
- **CI builds and pushes on every push to `main`, but does not auto-redeploy the running
container.** D16/D17 made this deliberate: the runner shares this Unraid host's own `dockerd`
(DooD), and an unattended redeploy of a container on a personal server with no human gate was
judged the wrong default. A push to `main` gets you a new image in the registry within minutes;
getting it onto the running container is still a manual step (`deploy/README.md`). An
auto-updater (Watchtower or similar) was attempted and deferred — see "Deliberately deferred"
in `docs/DECISIONS.md`.
- **VAPID/push notifications were never started.** Correctly so — per this doc's own PWA-decision
table, that's gated behind standalone-mode detection and belongs to a later phase, not Phase 0.
*Done when — status:* Logging in at the real URL (`https://bike.bbergle.com`) and seeing an
authenticated view of your own account is **verified**, including the session actually persisting
(`scripts/smoke-test.sh`, added after a real Secure-cookie-over-HTTP bug on the first deploy — see
that script's header comment). **Not yet tried:** adding it to an iPhone home screen and confirming
a standalone launch — nobody has actually done this yet, so it isn't checked off, even though the
manifest and service worker are in place.
**Phase 1 — Zero-touch ride history (68 weeks).** Ingestion core (all three dedupe layers, course
discrimination, quarantine); **the Bryton cloud poller as the primary path**, polling every 15 min with
@@ -654,8 +672,13 @@ because if the project stalls right after it, it has still succeeded.
activity reaches Bryton's cloud with the phone switched off. Then plug it in over USB and `ls -R` the
mounted volume to confirm the actual `.fit` path.
**Phase 0:** `curl https://host/healthz` returns 200; push to `main` produces a new registry image and
a redeployed container; `docker compose logs` shows migrations applied.
**Phase 0 — done, verified for real, not just assumed from CI going green:** `curl https://host/healthz`
returns 200; a push to `main` produces a new registry image (`docs/DECISIONS.md` D17's release
workflow) — redeploying the running container from it is a manual step (D16/D17), not automatic;
`docker exec velodrome velodrome create-admin` bootstraps the first user (D18); logging in at the
real HTTPS URL and staying logged in on the next request is checked by `scripts/smoke-test.sh`, not
eyeballed in a browser, after that exact failure mode (a `Secure` cookie silently dropped when
tested over plain HTTP) actually happened on the first deploy.
**Phase 1 — ingestion:**
- Upload a real Rider 650 `.fit` → activity appears with correct distance, elevation, and map track.
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Post-deploy smoke test: proves a login -> authenticated request round trip actually works
# against a REAL deployed instance, over the network, the way a browser sees it.
#
# Exists because pytest (real SQLite, no mocks — see CLAUDE.md) proves the API logic is
# correct in isolation, but can't catch topology-specific failures. Concretely: a session
# cookie is set with `Secure` in production (velodrome/api/v1/auth.py), which browsers
# silently refuse to store unless the request was actually served over HTTPS. Hit the app via
# a plain-HTTP address (an IP, a port, skipping the reverse proxy) and `/auth/login` still
# returns 200 with valid credentials, and the cookie header is still sent — it's just quietly
# dropped, so the very next request looks unauthenticated. From a browser this looks exactly
# like "I logged in and it bounced me straight back to the login screen," with no error
# anywhere. Caught for real the first time this got deployed; this script exists so it's
# caught by running a command, not by refreshing a browser tab.
#
# Usage:
# scripts/smoke-test.sh <base_url> <email> <password>
# scripts/smoke-test.sh https://bike.bbergle.com you@example.com yourpassword
#
# Doesn't create the account — bootstrap one first with
# `docker exec -it velodrome velodrome create-admin --email you@example.com`, then reuse
# those credentials here (or keep a small dedicated account around just for this).
set -euo pipefail
BASE_URL="${1:?usage: smoke-test.sh <base_url> <email> <password>}"
EMAIL="${2:?usage: smoke-test.sh <base_url> <email> <password>}"
PASSWORD="${3:?usage: smoke-test.sh <base_url> <email> <password>}"
BASE_URL="${BASE_URL%/}"
COOKIEJAR="$(mktemp)"
LOGIN_BODY="$(mktemp)"
ME_BODY="$(mktemp)"
trap 'rm -f "$COOKIEJAR" "$LOGIN_BODY" "$ME_BODY"' EXIT
echo "-> logging in as $EMAIL at $BASE_URL"
LOGIN_STATUS=$(curl -s -o "$LOGIN_BODY" -w '%{http_code}' \
-c "$COOKIEJAR" \
-X POST "$BASE_URL/api/v1/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}")
if [ "$LOGIN_STATUS" != "200" ]; then
echo "FAIL: login returned $LOGIN_STATUS, expected 200"
cat "$LOGIN_BODY"
exit 1
fi
echo " login: 200 OK"
if ! grep -q "_session" "$COOKIEJAR" 2>/dev/null; then
echo "FAIL: login succeeded but no session cookie was actually stored by the client."
echo " Almost certainly a Secure-cookie-over-HTTP mismatch — see the comment at the"
echo " top of this script. Are you testing via HTTPS through the real reverse proxy,"
echo " or a plain-HTTP address (an IP, a bare port)?"
exit 1
fi
echo " session cookie: stored"
echo "-> confirming the session actually authenticates a follow-up request"
ME_STATUS=$(curl -s -o "$ME_BODY" -w '%{http_code}' -b "$COOKIEJAR" "$BASE_URL/api/v1/auth/me")
if [ "$ME_STATUS" != "200" ]; then
echo "FAIL: /auth/me returned $ME_STATUS after a successful login — the session isn't"
echo " persisting. This is exactly the 'logs in, bounces back to the login screen'"
echo " symptom a browser would show."
cat "$ME_BODY"
exit 1
fi
ME_EMAIL=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['email'])" "$ME_BODY" 2>/dev/null || echo "?")
if [ "$ME_EMAIL" != "$EMAIL" ]; then
echo "FAIL: /auth/me returned a different account ($ME_EMAIL) than the one that logged in ($EMAIL)."
exit 1
fi
echo " /auth/me: 200 OK, confirmed as $ME_EMAIL"
echo "PASS: login -> authenticated request round trip works end to end at $BASE_URL"