Author SHA1 Message Date
BBergleandClaude Opus 5 7f33cb1593 docs: correct the false Wi-Fi premise; add UI and live-tracking phases
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 6s
CI / API (lint, types, tests) (pull_request) Successful in 53s
The plan's headline section claimed the Rider 650 has on-device Wi-Fi and a
Data Sync menu that uploads to Bryton's cloud with no phone involved. It
does not. The unit has ANT+ and Bluetooth only; its sole sync route is BLE
to the Bryton Active app. Confirmed on the physical device, corroborated by
BikeRadar's hands-on. Likely origin: conflation with the Rider 750 / S800,
which do have Wi-Fi.

Impact is narrower than it first appears and no built code is invalidated:
everything downstream of Bryton's cloud never depended on how a ride got
into that cloud, so the poller, ingestion, schema, wear engine and all of
Phase 0 stand. What was invalidated is the product promise — Phase 1 was
called "Zero-touch ride history" and claimed to fix the original complaint
(having to remember to open the Active app). It does not; it is one-tap.
Renamed accordingly rather than leaving the doc overclaiming.

Corrections propagated everywhere the premise had spread: PLAN.md's opening
sections, Phase 1, top risks (the chain is now longer and has a human link
that fails silently — earns a "nothing ingested in N days" nudge), and the
verification checklist; DECISIONS.md D3's justification; README.md, which
was additionally stale on nearly every other point (claimed no code written,
Postgres, compose, four containers); and RESEARCH.md, where the claim
originated under a "verified" header it had not earned. RESEARCH.md is
annotated rather than rewritten — it is a record of what was found, and the
correction is part of that record. USB path facts are marked unverified too,
since they came from the same unverified batch.

D20 records the process lesson: the plan contained the right check ("first
action before writing any code"), it was never run, and nothing downstream
required it to have been. Device capabilities get confirmed on the device
before being written as fact.

Also adds the two phases requested before this came up, both grounded in
feasibility research rather than assumption:
- Phase 1A, an open-ended UI pass done together, including the verbose field
  surface driven off activity_field_inventory.
- Phase 1B, live tracking. Constrained hard by reality: iOS suspends
  backgrounded PWAs and implements no Web Bluetooth, and Bryton's own Live
  Track needs the phone relaying over BLE, so the tracking client cannot be
  our PWA. Shape that works is OwnTracks POSTing to our API for position,
  with the server deriving distance/pace/elevation; HR and power need BLE and
  are explicitly a second-class opt-in, not a blocker. Two rules written in:
  live positions must never become activities (invariant #6), and "no privacy
  zones, ever" does not extend to a public live link.

Inserted as 1A/1B rather than renumbering Phases 2-5, whose numbers are
referenced from DECISIONS.md, deploy/README.md and code comments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 23:20:37 -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
BBergleandClaude Opus 5 d0c0d98307 docs: record D18 (admin bootstrap) and the deploy bootstrap step
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 19s
CI / Migrations reversible (pull_request) Successful in 6s
CI / API (lint, types, tests) (pull_request) Successful in 54s
The deploy README described how to start the container but not how to get
into it, which left the first-run experience at a login page nobody can get
past. Adds the actual command, both the interactive and the piped form, and
says why there is no --password flag.

D18 records the three decisions worth arguing with later rather than
rediscovering: why this is a CLI instead of a bootstrap HTTP endpoint or an
env var (both rejected, with reasons), why it refuses an existing email, why
it is not restricted to the first user, and why the admin role is recorded
but not yet enforced.

Numbered D18 because D17 was taken by the registry-TLS decision that merged
while this branch was in flight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 22:31:15 -04:00
BBergleandClaude Opus 5 b7b4c31296 feat(auth): add velodrome create-admin to bootstrap the first user
A fresh deployment could not be used. Registration requires a valid invite
code, invites can only be issued by an existing admin, and a newly migrated
database has neither -- so there was no way to create the first account.
docs/PLAN.md always called for this command; it was never built during
Phase 0, and D16 (single-container deploy) made the gap reachable.

Adds a console script -- `[project.scripts]` -> velodrome.cli:main -- which
installs into the same venv as alembic and uvicorn, so the deployed image
already has it on PATH:

    docker exec -it velodrome velodrome create-admin --email you@example.com

The account-creating logic is `auth.service.create_admin`, not something in
cli.py, so that `db.auth_session` stays confined to auth/service.py as its
docstring requires. Its lookup is an exact match on a unique key, which is
the pattern db.py documents as safe on that session.

Deliberate constraints, all covered by tests (see docs/DECISIONS.md D18):

- Refuses an email that already exists rather than updating the row. An
  operator re-running a months-old command from shell history means "create",
  never "reset the password"; silently accepting would make this an
  undocumented password-reset tool that any container-exec grants.
- Not restricted to "only when there are zero users". The restriction buys
  nothing -- reaching the command already requires process execution inside
  the container, which already permits rewriting the SQLite file directly --
  while removing the cases that do happen: a second admin, and recovering an
  instance whose only admin was lost.
- No --password flag. An argument lands in shell history, in ps output, and
  in the Docker daemon's record of the exec'd command. A TTY prompt (with
  confirmation) and --password-stdin are the two forms that avoid all three.
- Pydantic's ValidationError is never printed verbatim: its rendering embeds
  the offending value, which for a short password prints the password itself.
  Only loc and msg are shown (CLAUDE.md invariant #5).

role="admin" is recorded but nothing enforces it yet -- there is no
admin-only endpoint until invite management in Phase 1. ROLE_ADMIN/
ROLE_MEMBER become named constants, and AuthenticatedSession carries the
role for that future check. It is deliberately absent from UserOut, so no
HTTP response and no OpenAPI contract changes. The register endpoint's
password and display-name constraints move to named aliases in schemas/auth
so the CLI applies exactly the same rules rather than a drifting copy.

Verified: ruff check, ruff format --check, mypy --strict, and the full
pytest suite (28 passed) from apps/api/; `alembic check` reports no model
drift. Also smoke-tested end to end against a scratch database -- creation,
the duplicate-email refusal, and the no-TTY message all behave as described.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 22:30:42 -04:00
BBergle 3b80034f0e Merge pull request 'fix(deploy): push through a TLS-terminating proxy, not raw Gitea HTTP' (#9) from fix/deploy-registry-tls into main
CI / Repo hygiene (push) Successful in 1s
CI / Web (lint, typecheck, build) (push) Successful in 15s
CI / Migrations reversible (push) Successful in 5s
CI / API (lint, types, tests) (push) Successful in 54s
Release image / Build and push single-container image (push) Successful in 1m39s
Reviewed-on: #9
2026-09-21 22:15:04 -04:00
BBergleandClaude Sonnet 5 6b0f28cf74 docs(deploy): note the /data uid/gid-mismatch trap on first start
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 6s
CI / API (lint, types, tests) (pull_request) Successful in 54s
Hit this deploying to the real Unraid host: the container runs as a fixed
non-root uid/gid (999), not root and not Unraid's usual nobody:users
(99:100). A freshly-created appdata directory is owned by nobody:users with
no write access for anyone else, so the container starts but uvicorn fails
immediately with "unable to open database file" — not obvious from the
error alone, worth documenting once rather than re-debugging it later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 21:08:03 -04:00
BBergle 8278d96875 Merge pull request 'fix(deploy): default the Unraid template's host port off 8080' (#7) from fix/deploy-unraid-template-port into main
CI / Repo hygiene (push) Successful in 3s
CI / Web (lint, typecheck, build) (push) Successful in 22s
CI / Migrations reversible (push) Successful in 9s
CI / API (lint, types, tests) (push) Successful in 1m5s
Reviewed-on: #7
2026-09-21 20:58:36 -04:00
13 changed files with 1249 additions and 100 deletions
+39 -24
View File
@@ -1,48 +1,63 @@
# bike-app
A self-hosted cycling app: syncs rides from a **Bryton Rider 650**, tracks mileage like Strava, and
adds a **spare-parts inventory** and a **maintenance record** with mileage-milestone reminders.
A self-hosted cycling app ("Velodrome"): syncs rides from a **Bryton Rider 650**, tracks mileage
like Strava, and adds a **spare-parts inventory** and a **maintenance record** with
mileage-milestone reminders.
**Status: planning complete, no code written yet.**
**Status: Phase 0 complete and deployed.** Auth, the single-container image, CI/CD into a
self-hosted Gitea registry, and a real HTTPS deployment are live and verified. No ride ingestion
yet — that's Phase 1. See the roadmap in [`docs/PLAN.md`](docs/PLAN.md).
## Why
Today the Rider 650 syncs over Bluetooth to the Bryton Active phone app, which forwards to Strava —
but Active has no background sync, so you have to remember to open the app. Research found a better
path that removes the phone entirely:
The Rider 650 syncs over Bluetooth to the Bryton Active phone app, which forwards to Bryton's cloud
and on to Strava. Two problems: Active has no background sync, so you have to remember to open the
app; and Strava's API can only ever hand back decoded, smoothed streams — never the original file.
This app polls Bryton's cloud directly and takes the **original, unmodified FIT bytes**:
```
ride ends -> Rider 650 joins home Wi-Fi (Main Menu -> Data Sync)
-> uploads to Bryton cloud
ride ends -> BLE -> Bryton Active app -> Bryton cloud
-> this app's poller fetches the ORIGINAL FIT file
-> rides, wear tracking, and push reminders
```
That's also *higher fidelity* than the current route — Strava's API can only ever return smoothed
streams, never the original file.
**What that does and doesn't fix.** It does not remove the phone: you still open Active once after a
ride, and nothing in this app can reach across that gap (the Rider 650 has no Wi-Fi, and its BLE
sync protocol is undocumented — see `docs/PLAN.md`, "How rides actually reach the app"). What it
fixes is everything after that tap — full-resolution original bytes in your own database, every
field the head unit recorded, wear recalculated, reminders armed, and no third party able to change
the terms later.
## Docs
| File | What's in it |
|---|---|
| [`docs/PLAN.md`](docs/PLAN.md) | The full implementation plan: stack, schema, ingestion pipeline, auth, notifications, phased roadmap, CI/CD, risks, verification |
| [`docs/RESEARCH.md`](docs/RESEARCH.md) | Raw findings: the Bryton cloud protocol (endpoints, headers, auth), FIT library comparisons, maintenance interval tables, self-hostable geo services, Gitea Actions gotchas |
| [`docs/DECISIONS.md`](docs/DECISIONS.md) | Every decision taken, what was rejected, and why |
| [`docs/DECISIONS.md`](docs/DECISIONS.md) | Every decision taken, what was rejected, and why — including the ones later reversed, with the reasoning intact |
| [`docs/RESEARCH.md`](docs/RESEARCH.md) | Raw findings: the Bryton cloud protocol, FIT library comparisons, maintenance interval tables, self-hostable geo services, Gitea Actions gotchas |
| [`CLAUDE.md`](CLAUDE.md) | Conventions, non-negotiable invariants, branching and PR workflow |
| [`deploy/README.md`](deploy/README.md) | How to build, run, and bootstrap the deployed container |
## Planned stack
## Stack as built
Python 3.12 / FastAPI / SQLAlchemy async / PostgreSQL 16 + PostGIS, `procrastinate` for jobs,
SvelteKit static SPA as an installable PWA, MapLibre GL, all behind Caddy in Docker Compose.
Source control and CI in self-hosted Gitea with an act_runner on the same box.
Python 3.12 / FastAPI / SQLAlchemy 2.0 async / **SQLite** (D15 — reversed the original
Postgres+PostGIS choice mid-Phase-0), SvelteKit static SPA as an installable PWA, MapLibre GL to
come in Phase 1, all served by Caddy from a **single container** (D16). Source control and CI in
self-hosted Gitea with act_runner on the same host.
Four containers in v1, under 2GB RAM.
The two consequences of the SQLite decision worth knowing before reading any code: user isolation is
enforced entirely in the repository layer (`apps/api/velodrome/db.py`'s `Scope`), with no database
RLS behind it; and the original job-queue choice (`procrastinate`, Postgres-only) needs a
replacement before Phase 1's ingestion pipeline can be built.
## Next steps
1. **Verify on the Rider 650:** does `Main Menu -> Data Sync` upload *automatically* on joining
Wi-Fi, or only on manual trigger? This determines how completely the phone leaves the loop.
2. **Plug the 650 in over USB** and `ls -R` the mounted volume to confirm the real `.fit` path
(documented as `Bryton/Activities/`, but worth confirming).
3. **Grab a real `.fit` file** from it and run it through `fitdecode` — Bryton's encoder is not
Garmin's, and the schema should be checked against reality before it's written.
4. Then Phase 0: scaffolding and CI (see the roadmap in `docs/PLAN.md`).
1. **Pick the two Phase 1 blockers** deferred by D15: the background job queue, and how to store
ride tracks without PostGIS.
2. **Verify against the physical device before building on it** (the lesson of D20): the USB `.fit`
path layout, and that the `intervalssync` protocol still retrieves activities from a current
Bryton account.
3. **Grab a real `.fit` file** and run it through `fitdecode` — Bryton's encoder is not Garmin's,
and the schema should be checked against reality before it's written.
4. Then Phase 1: ingestion (see the roadmap in `docs/PLAN.md`).
+6
View File
@@ -15,6 +15,12 @@ dependencies = [
"uuid6>=2024.7.10",
]
[project.scripts]
# Installs into the venv's bin/ next to `alembic` and `uvicorn`, which deploy/entrypoint.sh
# already invokes by their installed script names — so `docker exec velodrome velodrome ...` works
# against the deployed image with no extra wiring (the Dockerfile puts /app/.venv/bin on PATH).
velodrome = "velodrome.cli:main"
[project.optional-dependencies]
dev = [
"ruff>=0.7",
+308
View File
@@ -0,0 +1,308 @@
"""Tests for the operator CLI (`velodrome create-admin`).
These drive `cli.run()` directly against the same real SQLite database and real Argon2id hashing
every other test uses — no mocks, per CLAUDE.md's test policy. That matters more than usual here:
this command is the only way to create the first account on a fresh deployment, it is run exactly
once by a human who has no way to debug it, and the failure mode of "it printed success but the
password doesn't actually work" is indistinguishable from a broken deployment. So the central test
below doesn't assert on a return code — it creates an admin through the CLI and then logs in as
that admin over HTTP, proving the hash the CLI wrote is one the login path accepts.
The other thing under test is what the CLI *refuses* to do: overwrite an existing account, and
echo a rejected password back to the terminal (CLAUDE.md invariant #5).
"""
import io
import sys
import tomllib
from pathlib import Path
import httpx
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from velodrome import cli
from velodrome.models import ROLE_ADMIN, ROLE_MEMBER
_PASSWORD = "correct horse battery staple"
_OTHER_PASSWORD = "an entirely different passphrase"
class _FakeTty:
"""Stands in for `sys.stdin` attached to a terminal, so `_read_password` takes the prompt
branch rather than the pipe branch. `readline` raises rather than returning a value: if the
prompt path ever silently starts reading stdin instead of calling getpass, that's a behaviour
change this should fail on, not absorb."""
def isatty(self) -> bool:
return True
def readline(self) -> str:
raise AssertionError("the prompt path must not read stdin directly")
def _pipe(password: str, *, newline: str = "\n") -> io.StringIO:
"""stdin as a pipe (isatty() is False on StringIO), carrying one line."""
return io.StringIO(f"{password}{newline}")
async def test_create_admin_creates_an_account_that_can_actually_log_in(
client: httpx.AsyncClient, db_auth: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The load-bearing test: bootstrap an admin through the CLI, then log in as them over HTTP.
This is deliberately an end-to-end assertion rather than "did a row appear with a hash in it".
The whole point of the command is to produce working credentials on a deployment where nobody
can yet log in to check, so the only assertion worth making is that the credentials work
through the same endpoint a real operator would use next.
"""
monkeypatch.setattr(sys, "stdin", _pipe(_PASSWORD))
code = await cli.run(
["create-admin", "--email", "boss@example.com", "--name", "Boss", "--password-stdin"]
)
assert code == 0
resp = await client.post(
"/api/v1/auth/login", json={"email": "boss@example.com", "password": _PASSWORD}
)
assert resp.status_code == 200, resp.text
assert "vd_session" in resp.cookies
me = await client.get("/api/v1/auth/me")
assert me.status_code == 200
assert me.json()["email"] == "boss@example.com"
assert me.json()["display_name"] == "Boss"
async def test_create_admin_records_the_admin_role(
db_auth: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The role is the one thing that distinguishes this from registration, and nothing enforces
it yet (docs/DECISIONS.md D17) — so nothing else in the suite would notice if it silently
wrote `member`. Asserted against the stored column directly, and against ROLE_MEMBER too, so
this fails loudly rather than passing vacuously if the default ever changes."""
monkeypatch.setattr(sys, "stdin", _pipe(_PASSWORD))
assert await cli.run(["create-admin", "--email", "boss@example.com", "--password-stdin"]) == 0
role = (
await db_auth.execute(
text("SELECT role FROM users WHERE email = :email"), {"email": "boss@example.com"}
)
).scalar_one()
await db_auth.commit()
assert role == ROLE_ADMIN
assert role != ROLE_MEMBER
async def test_create_admin_defaults_display_name_to_the_email_local_part(
db_auth: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(sys, "stdin", _pipe(_PASSWORD))
assert await cli.run(["create-admin", "--email", "benny@example.com", "--password-stdin"]) == 0
name = (
await db_auth.execute(
text("SELECT display_name FROM users WHERE email = :email"),
{"email": "benny@example.com"},
)
).scalar_one()
await db_auth.commit()
assert name == "benny"
async def test_create_admin_refuses_an_existing_email_without_touching_the_account(
client: httpx.AsyncClient, db_auth: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Re-running the bootstrap command must not become an undocumented password reset.
"Refused" is asserted three ways, because exit code 1 alone would also be satisfied by a
command that failed *after* corrupting the row: the original password must still work, the new
one must not, and the display name must be unchanged.
"""
monkeypatch.setattr(sys, "stdin", _pipe(_PASSWORD))
assert (
await cli.run(
["create-admin", "--email", "boss@example.com", "--name", "Boss", "--password-stdin"]
)
== 0
)
monkeypatch.setattr(sys, "stdin", _pipe(_OTHER_PASSWORD))
second = await cli.run(
["create-admin", "--email", "boss@example.com", "--name", "Impostor", "--password-stdin"]
)
assert second == 1
still_works = await client.post(
"/api/v1/auth/login", json={"email": "boss@example.com", "password": _PASSWORD}
)
assert still_works.status_code == 200, "the original password must survive a refused re-run"
rejected = await client.post(
"/api/v1/auth/login", json={"email": "boss@example.com", "password": _OTHER_PASSWORD}
)
assert rejected.status_code == 401, "the refused run's password must never become valid"
name = (
await db_auth.execute(
text("SELECT display_name FROM users WHERE email = :email"),
{"email": "boss@example.com"},
)
).scalar_one()
await db_auth.commit()
assert name == "Boss"
async def test_create_admin_error_message_never_echoes_the_password(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""CLAUDE.md invariant #5, in the one place it's easy to breach by accident.
Pydantic's default rendering of a ValidationError embeds the offending value — for a
too-short password that means printing the password itself to the operator's terminal, and
into whatever captured that output (a CI log, a `script` session, a scrollback buffer shared
in a bug report). `_validate` strips it deliberately; this proves it stays stripped.
"""
secret = "short"
monkeypatch.setattr(sys, "stdin", _pipe(secret))
code = await cli.run(["create-admin", "--email", "boss@example.com", "--password-stdin"])
assert code == 1
captured = capsys.readouterr()
assert secret not in captured.out
assert secret not in captured.err
# ...while still being a useful message: it must name the offending field and the rule.
assert "password" in captured.err
assert "at least 8" in captured.err
async def test_create_admin_rejects_a_malformed_email(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setattr(sys, "stdin", _pipe(_PASSWORD))
code = await cli.run(["create-admin", "--email", "not-an-email", "--password-stdin"])
assert code == 1
assert "email" in capsys.readouterr().err
async def test_password_stdin_rejects_an_empty_line(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""An empty pipe is nearly always `$PASSWORD` being unset in the operator's shell. Failing
loudly beats creating an account whose password is the empty string."""
monkeypatch.setattr(sys, "stdin", io.StringIO(""))
code = await cli.run(["create-admin", "--email", "boss@example.com", "--password-stdin"])
assert code == 1
assert "empty" in capsys.readouterr().err
async def test_password_stdin_preserves_a_trailing_space(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Only the line ending is stripped, not surrounding whitespace — a password with a trailing
space is legitimate, and trimming it would create an account whose password can never be typed
back in. Proven through a real login rather than by inspecting the hash."""
padded = f"{_PASSWORD} "
monkeypatch.setattr(sys, "stdin", _pipe(padded, newline="\r\n"))
assert await cli.run(["create-admin", "--email", "boss@example.com", "--password-stdin"]) == 0
resp = await client.post(
"/api/v1/auth/login", json={"email": "boss@example.com", "password": padded}
)
assert resp.status_code == 200, "the trailing space must be part of the stored password"
trimmed = await client.post(
"/api/v1/auth/login", json={"email": "boss@example.com", "password": _PASSWORD}
)
assert trimmed.status_code == 401
async def test_without_a_tty_or_password_stdin_it_explains_how_to_run_it(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""The most likely first-run mistake is `docker exec` without `-t`. getpass would otherwise
fail with a bare OSError, so the command catches it first and prints both working forms."""
monkeypatch.setattr(sys, "stdin", io.StringIO(""))
code = await cli.run(["create-admin", "--email", "boss@example.com"])
assert code == 1
err = capsys.readouterr().err
assert "docker exec -it" in err
assert "--password-stdin" in err
async def test_prompt_path_requires_the_confirmation_to_match(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""A typo in a password nobody can see, on the one account that can't be recovered by another
admin, is worth a second prompt."""
monkeypatch.setattr(sys, "stdin", _FakeTty())
answers = iter([_PASSWORD, _OTHER_PASSWORD])
monkeypatch.setattr(cli.getpass, "getpass", lambda prompt="": next(answers))
code = await cli.run(["create-admin", "--email", "boss@example.com"])
assert code == 1
assert "did not match" in capsys.readouterr().err
async def test_prompt_path_creates_the_account_when_both_entries_match(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The interactive path is the one the docs tell operators to use, so it gets the same
end-to-end login proof as the piped path."""
monkeypatch.setattr(sys, "stdin", _FakeTty())
answers = iter([_PASSWORD, _PASSWORD])
monkeypatch.setattr(cli.getpass, "getpass", lambda prompt="": next(answers))
assert await cli.run(["create-admin", "--email", "boss@example.com"]) == 0
resp = await client.post(
"/api/v1/auth/login", json={"email": "boss@example.com", "password": _PASSWORD}
)
assert resp.status_code == 200
async def test_success_output_never_contains_the_password(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""The failure path is covered above; the success path prints more, so it gets its own check.
The realistic leak here is a well-meaning "created with password: ..." confirmation line."""
monkeypatch.setattr(sys, "stdin", _pipe(_PASSWORD))
assert await cli.run(["create-admin", "--email", "boss@example.com", "--password-stdin"]) == 0
captured = capsys.readouterr()
assert _PASSWORD not in captured.out
assert _PASSWORD not in captured.err
assert "boss@example.com" in captured.out
assert ROLE_ADMIN in captured.out
async def test_a_missing_required_option_exits_two_not_one(
capsys: pytest.CaptureFixture[str],
) -> None:
"""argparse's usage errors exit 2; a refusal from the command itself exits 1. A script driving
this should be able to tell "you called it wrong" from "it ran and declined"."""
with pytest.raises(SystemExit) as exc:
await cli.run(["create-admin"])
assert exc.value.code == 2
capsys.readouterr()
async def test_no_subcommand_is_a_usage_error(capsys: pytest.CaptureFixture[str]) -> None:
with pytest.raises(SystemExit) as exc:
await cli.run([])
assert exc.value.code == 2
capsys.readouterr()
def test_the_console_script_is_registered_under_the_name_the_docs_use() -> None:
"""deploy/README.md and the CLI's own error messages tell operators to run
`docker exec -it velodrome velodrome create-admin`. That only works because pyproject declares
the console script, which nothing else in the test suite would exercise — an editable install
imports `velodrome.cli` fine whether or not the entry point exists. Asserted against the
manifest so renaming the module or the function fails here rather than on a deployment.
"""
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
manifest = tomllib.loads(pyproject.read_text(encoding="utf-8"))
assert manifest["project"]["scripts"]["velodrome"] == "velodrome.cli:main"
+69 -4
View File
@@ -23,7 +23,7 @@ from velodrome.auth.security import (
)
from velodrome.config import get_settings
from velodrome.db import auth_session
from velodrome.models import Invite, Session, User
from velodrome.models import ROLE_ADMIN, Invite, Session, User
class AuthError(Exception):
@@ -51,6 +51,14 @@ class AuthenticatedSession:
user_id: UUID
email: str
display_name: str
# Carried here so the role a user actually has is available wherever identity is — an
# authorization check on the first admin-only endpoint (realistically invite management) is
# then a comparison against a value already in hand, not another query bolted on later. This
# is data plumbing, not an authorization mechanism: nothing reads it yet, deliberately, since
# there is no admin-only endpoint to protect (docs/DECISIONS.md D18). It is not a secret and
# is not in any response model — `schemas.auth.UserOut` deliberately doesn't declare it, so
# adding it here changes no HTTP response and no OpenAPI contract.
role: str
async def register(
@@ -103,7 +111,58 @@ async def register(
invite.used_count += 1
return AuthenticatedSession(
user_id=user.id, email=user.email, display_name=user.display_name
user_id=user.id,
email=user.email,
display_name=user.display_name,
role=user.role,
)
async def create_admin(*, email: str, password: str, display_name: str) -> AuthenticatedSession:
"""Create a user with the admin role, with no invite. Operator path only — see velodrome.cli.
This is the one deliberate hole in "open signup does not exist": a fresh deployment has no
users and therefore nobody who can issue the first invite, so the first account has to come
from outside the HTTP API. It lives here rather than in cli.py because this is where
`auth_session` belongs (see db.py's docstring — nothing outside this module imports it), and
because the lookup below is exactly the pattern that module documents as safe: an exact match
on a unique key, never a scan.
It is not reachable over HTTP and never will be — nothing in `api/` calls it. Reaching it
requires the ability to run a process inside the container, which is already the ability to
read and rewrite the SQLite file directly, so it grants an operator-turned-attacker nothing
they did not already have.
Refuses outright if the email is taken, rather than updating the row — see docs/DECISIONS.md
D18. Creating an account and resetting an existing account's password are different operations
with different blast radii, and an operator re-running a bootstrap command they last ran
months ago means the first, never the second. The check-then-insert is safe against a
concurrent `register()` for the same email the same way invite redemption is: db.py issues
`BEGIN IMMEDIATE`, so the two transactions serialize instead of interleaving, with the unique
index on `users.email` as the backstop underneath that.
"""
async with auth_session() as db:
async with db.begin():
existing = (
await db.execute(select(User).where(User.email == email))
).scalar_one_or_none()
if existing is not None:
raise EmailAlreadyRegistered("an account with this email already exists")
user = User(
email=email,
display_name=display_name,
password_hash=hash_password(password),
role=ROLE_ADMIN,
)
db.add(user)
await db.flush() # populate user.id before we reference it below
return AuthenticatedSession(
user_id=user.id,
email=user.email,
display_name=user.display_name,
role=user.role,
)
@@ -140,7 +199,10 @@ async def login(
db.add(session_row)
return raw_token, AuthenticatedSession(
user_id=user.id, email=user.email, display_name=user.display_name
user_id=user.id,
email=user.email,
display_name=user.display_name,
role=user.role,
)
@@ -184,7 +246,10 @@ async def validate_session(raw_token: str) -> AuthenticatedSession:
)
return AuthenticatedSession(
user_id=user.id, email=user.email, display_name=user.display_name
user_id=user.id,
email=user.email,
display_name=user.display_name,
role=user.role,
)
+181
View File
@@ -0,0 +1,181 @@
"""Operator CLI — `velodrome <command>`.
Installed as a console script (`[project.scripts]` in pyproject.toml) into the same venv as
`alembic` and `uvicorn`, which `deploy/entrypoint.sh` already invokes by their installed names, so
the deployed container has this on PATH with no extra wiring:
docker exec -it velodrome velodrome create-admin --email you@example.com
It exists because there is otherwise **no way to create the first user**. Registration requires a
valid invite (docs/PLAN.md "Auth": open signup does not exist as a setting), invites are created by
an existing admin, and a fresh database has neither — so a new deployment is unusable without a
path in from outside the HTTP API. docs/PLAN.md always called for this command; it was simply
never built during Phase 0. See docs/DECISIONS.md D18 for the three decisions recorded here: why
it refuses to touch an existing account, why it is *not* restricted to the very first user, and
why `role="admin"` is recorded but not yet enforced anywhere.
argparse rather than Typer/Click: one command with three options does not justify a runtime
dependency the deployed image would have to carry, and the stdlib covers this case completely.
There is deliberately **no `--password` flag** — see `_read_password`.
"""
import argparse
import asyncio
import getpass
import sys
from collections.abc import Sequence
from pydantic import BaseModel, EmailStr, ValidationError
from velodrome.auth import service
from velodrome.config import get_settings
from velodrome.schemas.auth import DisplayName, Password
class CliError(Exception):
"""An operator-facing failure: printed as `error: <message>`, exit code 1.
Distinct from argparse's own usage errors, which exit 2 — so a script driving this can tell
"you called it wrong" apart from "it ran and refused".
"""
class _CreateAdminInput(BaseModel):
"""The same constraints the HTTP register endpoint applies, reused rather than restated — a
CLI-created account must not be able to hold a password the API would have rejected.
"""
email: EmailStr
password: Password
display_name: DisplayName
def _validate(*, email: str, password: str, display_name: str) -> _CreateAdminInput:
try:
return _CreateAdminInput(email=email, password=password, display_name=display_name)
except ValidationError as exc:
# Deliberately not `str(exc)`: pydantic's rendered message embeds the offending value
# ("... [type=string_too_short, input_value='hunter2', input_type=str]"), which for the
# password field prints the password to the operator's terminal and into whatever
# captures that output. CLAUDE.md invariant #5 — only `loc` and `msg` are safe to show.
details = "; ".join(
f"{'.'.join(str(part) for part in err['loc'])}: {err['msg']}"
for err in exc.errors(include_url=False, include_input=False)
)
raise CliError(f"invalid input — {details}") from exc
def _read_password(*, from_stdin: bool) -> str:
"""Prompt for a password, or read one line from stdin.
No `--password` flag exists on purpose: an argument lands in the operator's shell history, in
`ps` output for as long as the process runs, and — because the realistic invocation here is
`docker exec` — in the Docker daemon's own record of the exec'd command. A TTY prompt and a
pipe are the two forms that avoid all three, and they're the same two forms `docker login`
offers for exactly this reason.
"""
if from_stdin:
line = sys.stdin.readline()
# Strip only the line ending, not surrounding whitespace — a trailing space in a password
# is legitimate, and silently trimming it would create a password that can never be typed
# back in correctly.
password = line.rstrip("\r\n")
if not password:
raise CliError("--password-stdin was given but the first line of stdin was empty")
return password
if not sys.stdin.isatty():
raise CliError(
"no terminal available to prompt on. Either allocate one (note the -t):\n"
" docker exec -it velodrome velodrome create-admin --email you@example.com\n"
"or pipe the password in:\n"
" printf '%s' \"$PASSWORD\" | docker exec -i velodrome \\\n"
" velodrome create-admin --email you@example.com --password-stdin"
)
password = getpass.getpass("Password: ")
if password != getpass.getpass("Confirm password: "):
raise CliError("passwords did not match")
return password
async def _create_admin(args: argparse.Namespace) -> int:
email: str = args.email
# The local part is a reasonable default for a name nobody but the operator will see until
# they change it in the UI; it keeps the common invocation to a single flag.
display_name: str = args.name if args.name is not None else email.partition("@")[0]
password = _read_password(from_stdin=args.password_stdin)
validated = _validate(email=email, password=password, display_name=display_name)
try:
created = await service.create_admin(
email=str(validated.email),
password=validated.password,
display_name=validated.display_name,
)
except service.EmailAlreadyRegistered as exc:
raise CliError(
f"an account already exists for {email} — refusing to modify it. This command only "
"ever creates a new account; it will not reset an existing one's password (see "
"docs/DECISIONS.md D18). To add a different admin, re-run with another --email."
) from exc
print("Created admin user:")
print(f" id {created.user_id}")
print(f" email {created.email}")
print(f" display name {created.display_name}")
print(f" role {created.role}")
print()
print(f"Log in at {get_settings().public_url}")
print(
"Note: the admin role is recorded on the account but nothing enforces it yet — no "
"admin-only endpoint exists (docs/DECISIONS.md D18)."
)
return 0
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="velodrome",
description="Velodrome operator commands. Run inside the container, e.g. "
"`docker exec -it velodrome velodrome create-admin --email you@example.com`.",
)
subcommands = parser.add_subparsers(dest="command", required=True)
create_admin = subcommands.add_parser(
"create-admin",
help="create a user with the admin role, bypassing the invite requirement",
description="Create a user with the admin role, bypassing the invite requirement. This is "
"how the first account on a fresh deployment is made — registration needs an invite, and "
"a fresh database has none. Refuses to modify an account that already exists.",
)
create_admin.add_argument("--email", required=True, help="the account's email address")
create_admin.add_argument(
"--name",
default=None,
help="display name (default: the part of the email address before the @)",
)
create_admin.add_argument(
"--password-stdin",
action="store_true",
help="read the password from the first line of stdin instead of prompting for it",
)
return parser
async def run(argv: Sequence[str] | None = None) -> int:
"""The async entrypoint. `main` wraps this in `asyncio.run`; tests call it directly."""
args = _build_parser().parse_args(argv)
try:
if args.command == "create-admin":
return await _create_admin(args)
except CliError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
raise AssertionError(f"unhandled command {args.command!r}") # pragma: no cover
def main(argv: Sequence[str] | None = None) -> int:
return asyncio.run(run(argv))
+2 -2
View File
@@ -1,4 +1,4 @@
from velodrome.models.base import Base
from velodrome.models.identity import ApiToken, Invite, Session, User
from velodrome.models.identity import ROLE_ADMIN, ROLE_MEMBER, ApiToken, Invite, Session, User
__all__ = ["ApiToken", "Base", "Invite", "Session", "User"]
__all__ = ["ROLE_ADMIN", "ROLE_MEMBER", "ApiToken", "Base", "Invite", "Session", "User"]
+12 -2
View File
@@ -24,6 +24,16 @@ def _now_utc() -> datetime:
return datetime.now(UTC)
# The only two values `users.role` and `invites.role` are ever set to. Named constants so the set
# is discoverable from one place: `velodrome.cli`'s create-admin writes ROLE_ADMIN, and
# registration copies whatever role the redeemed invite carries. Nothing *enforces* a role yet —
# no admin-only endpoint exists (docs/DECISIONS.md D18) — and the column stays a plain string
# rather than a DB-level enum or CHECK constraint so adding a third role later is an application
# change, not a migration.
ROLE_MEMBER = "member"
ROLE_ADMIN = "admin"
class User(Base):
__tablename__ = "users"
@@ -31,7 +41,7 @@ class User(Base):
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False)
display_name: Mapped[str] = mapped_column(String(200), nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
role: Mapped[str] = mapped_column(String(20), nullable=False, default=ROLE_MEMBER)
timezone: Mapped[str] = mapped_column(String(64), nullable=False, default="UTC")
# Display-only, per CLAUDE.md invariant #3 — storage is always SI, this never touches a query.
unit_system: Mapped[str] = mapped_column(String(10), nullable=False, default="imperial")
@@ -53,7 +63,7 @@ class Invite(Base):
Uuid(as_uuid=True), ForeignKey("users.id"), nullable=False
)
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
role: Mapped[str] = mapped_column(String(20), nullable=False, default=ROLE_MEMBER)
expires_at: Mapped[datetime] = mapped_column(nullable=False)
max_uses: Mapped[int] = mapped_column(nullable=False, default=1)
used_count: Mapped[int] = mapped_column(nullable=False, default=0)
+10 -2
View File
@@ -6,15 +6,23 @@ simply not being listed here is what keeps password_hash/token_hash out of every
adding a new field, ask whether it belongs in a response before adding it, not after.
"""
from typing import Annotated
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field
# Named aliases rather than inline constraints, because these two rules are also applied outside
# the HTTP layer: `velodrome.cli` validates `create-admin`'s input against exactly the same ones,
# so an account created from the CLI can't hold a password the register endpoint would have
# rejected. Defined once here so the two can't drift apart.
Password = Annotated[str, Field(min_length=8, max_length=200)]
DisplayName = Annotated[str, Field(min_length=1, max_length=200)]
class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=200)
display_name: str = Field(min_length=1, max_length=200)
password: Password
display_name: DisplayName
invite_code: str = Field(min_length=1, max_length=200)
+81 -3
View File
@@ -43,6 +43,65 @@ front of port 8080 — this container only ever serves plain HTTP itself.
`GET http://<host>:8080/api/v1/healthz` should return `{"status": "ok"}` once it's up.
## Create the first admin user
**A fresh deployment has no users and you cannot sign up for one.** Registration requires an invite
code, invites are issued by an existing admin, and a new database has neither — so the first account
is created from inside the container (`docs/DECISIONS.md` D18 for why it's a CLI and not a
first-run web page):
```sh
docker exec -it velodrome velodrome create-admin --email you@example.com
```
That prompts for the password twice and prints the new account's id, email and role. Then log in at
`VELODROME_PUBLIC_URL`. Note the **`-t`** — without a TTY there's nothing to prompt on; the command
says so rather than hanging. Add `--name "Your Name"` to set a display name (it defaults to the part
of the email before the `@`); it's editable in the UI later either way.
For a non-interactive run (a provisioning script), pipe the password in instead — note `-i` rather
than `-it`:
```sh
printf '%s' "$ADMIN_PASSWORD" | docker exec -i velodrome \
velodrome create-admin --email you@example.com --password-stdin
```
There is deliberately no `--password` flag: an argument would land in your shell history, in `ps`
output, and in the Docker daemon's record of the exec'd command.
Re-run it with a different `--email` to add another admin. Re-running it with an email that already
exists **refuses and changes nothing** — it is not a password-reset tool, and there isn't one yet
(D18). Minimum password length is 8 characters, the same rule the register endpoint applies.
Nothing enforces the admin role yet — no admin-only endpoint exists — so today this differs from an
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
@@ -63,6 +122,13 @@ the same values, there's no separate migration-time config anymore (docs/DECISIO
|---|---|
| `/data` | The SQLite database file. Will also hold the content-addressed blob store once Phase 1 builds ingestion. This is the only thing that needs backing up. |
The container runs as a fixed non-root user (uid/gid `999`), not root and not Unraid's usual
`nobody:users` (99:100). If `/data`'s host directory doesn't already exist, Docker/Unraid creates
it owned by `nobody:users` with no write access for other users — the container starts, but
uvicorn fails immediately with `sqlite3.OperationalError: unable to open database file`, since it
can't create the SQLite file inside a directory it can't write to. Fix once, before first start:
`chown -R 999:999 <host path>` (e.g. `/mnt/user/appdata/velodrome` on Unraid).
## Unraid
Import `unraid-template.xml` from the Docker tab's "Add Container" template picker — it exposes
@@ -88,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).
+177 -5
View File
@@ -26,18 +26,31 @@ Bryton BLE is a dead end regardless, and the *server* does all syncing.
**Kept as insurance:** the backend stays strictly API-first with a CI-enforced OpenAPI contract, so
if Apple ever makes the PWA route untenable, a native client is a code-generation exercise.
### D3 — Bryton cloud poller is the primary ingestion path
### D3 — Bryton cloud poller is the primary ingestion path — **premise corrected, decision survives**
**Chosen:** server-side poller against the reverse-engineered Bryton Active API, every 15-20 min.
**Rejected:** Strava as a source (no `export_original` — decoded smoothed streams only; plus the
June 2026 tier restructure caps new apps at 10 users and requires a paid dev subscription);
BLE/ANT-FS direct (nobody has reverse-engineered Bryton's BLE — weeks of work, breaks on firmware
updates); depending on the Bryton Active phone app (the original complaint).
**Why:** the Rider 650 has on-device Wi-Fi (`Main Menu -> Data Sync`) and uploads to Bryton's cloud
with no phone involved, and the cloud API returns the **original unmodified FIT bytes**. That's both
zero-touch *and* higher fidelity than the current Strava route.
updates, and separately impossible from an iOS PWA, which has no Web Bluetooth at all).
**Why:** the cloud API returns the **original unmodified FIT bytes** — higher fidelity than the
Strava route, and everything downstream of the cloud is ours.
**Fallbacks, both built:** USB watch folder (also the historical-backfill mechanism, so it stays
exercised rather than bit-rotting) and manual upload.
> **Corrected 2026-09-22.** This entry originally justified itself with "the Rider 650 has on-device
> Wi-Fi (`Main Menu -> Data Sync`) and uploads to Bryton's cloud with no phone involved… That's both
> zero-touch *and* higher fidelity," and listed "depending on the Bryton Active phone app (the
> original complaint)" as *rejected*. **The Wi-Fi premise was false** — the Rider 650 has ANT+ and
> Bluetooth only, and its only sync route is BLE to the Active app (confirmed on the physical
> device; see `docs/PLAN.md`, "How rides actually reach the app"). So the rejected option is in fact
> the only one available, and the chain is
> `head unit → BLE → Active app → Bryton cloud → poller`.
>
> **The decision itself still stands** — polling Bryton's cloud for original FIT bytes remains the
> best available primary path, and nothing downstream of the cloud depended on how rides got into
> it. What changes is the *claim*: this is one-tap, not zero-touch, and it does not fix the original
> complaint. See D20 for the process lesson.
### D4 — Python / FastAPI / Postgres+PostGIS — **database choice superseded by D15**
**Chosen:** Python 3.12, FastAPI, Pydantic v2, SQLAlchemy 2.0 async, Alembic, PostgreSQL 16 + PostGIS 3.4.
**Rejected:** TypeScript full-stack, Go.
@@ -289,10 +302,169 @@ they're ready to make that call deliberately, not bundled into this fix.
and certs (`vaultwarden.bbergle.com` etc.) — untouched, new proxy host only. No other container on
the Unraid host was restarted, reconfigured, or otherwise touched to make this work.
### D18 — Admin bootstrap is a CLI command, not an HTTP endpoint or a first-run mode
**Chosen:** `velodrome create-admin`, a console script (`[project.scripts]` in
`apps/api/pyproject.toml``velodrome.cli:main`) installed into the same venv as `alembic` and
`uvicorn`, so the deployed image already has it on PATH:
```sh
docker exec -it velodrome velodrome create-admin --email you@example.com
```
**Why this exists at all:** registration requires a valid invite code (`docs/PLAN.md` "Auth" — open
signup does not exist, not even as a setting), invites can only be created by an existing admin,
and a freshly migrated database has neither. A new deployment was therefore unusable: there was no
way to create the first account. `docs/PLAN.md` always called for this command; it was simply never
built during Phase 0, and the gap only became visible once D16 made a real deployment possible.
**Why a CLI rather than the alternatives:**
- *A bootstrap HTTP endpoint that works only while the users table is empty* — rejected. It puts an
unauthenticated account-creating route on the public internet permanently, whose safety depends
entirely on a row count staying zero. The window is real (between first start and first login),
it's the exact window where the deployment is least watched, and the failure is silent: whoever
wins the race owns the instance.
- *An env var like `VELODROME_INITIAL_ADMIN_PASSWORD`* — rejected. A password in an env var is
visible in `docker inspect`, in the Unraid template's saved config on disk, and in the container's
own `/proc/1/environ` for the process's whole life. D16 deliberately moved configuration into
Unraid's UI, which would mean the bootstrap password sitting in that UI indefinitely.
- *Seeding a default account in a migration* — rejected outright. It would mean a known-credential
account existing on every deployment, and it contradicts the reason migrations are schema-only.
**Why it refuses an email that already exists, rather than updating it:** creating an account and
resetting an existing account's password are different operations with different blast radii, and
the realistic scenario — an operator re-running a command they last ran months ago, from shell
history — means the first, never the second. Silently accepting it would make this an undocumented
password-reset tool that any container-exec grants, and would make the command's behaviour depend
on state the operator can't see. It exits 1 and says what it refused. A genuine password reset is a
separate future command that should have to say so in its name.
**Why it is *not* restricted to "only when there are zero users":** that restriction sounds safer
and isn't. It buys nothing — the command already requires the ability to run a process inside the
container, which is already the ability to read and rewrite the SQLite file directly, so a
restriction only constrains the legitimate operator, never an attacker who is by definition already
past it. Meanwhile it removes the two cases that actually happen: a second admin for a family
member, and recovering an instance whose only admin account was lost. The invite system remains the
normal path for adding users; this stays the operator's escape hatch.
**Why `role="admin"` is recorded but nothing enforces it yet:** there is no admin-only endpoint to
protect. Invite management — the first thing that genuinely needs the distinction — is Phase 1.
Writing the column now means the first account is correctly marked when that check does arrive,
rather than needing a data fix-up later; writing an *enforcement* mechanism now would be guessing at
the shape of a check with no caller. `ROLE_ADMIN`/`ROLE_MEMBER` are named constants in
`models/identity.py`, and the column stays a plain string rather than a DB enum or CHECK constraint
so a third role later is an application change, not a migration. `AuthenticatedSession.role` carries
the value for that future check; it is deliberately absent from `schemas.auth.UserOut`, so this
changes no HTTP response and no OpenAPI contract.
**Why there is no `--password` flag:** an argument lands in shell history, in `ps` output for the
process's lifetime, and — because the realistic invocation is `docker exec` — in the Docker daemon's
record of the exec'd command. A TTY prompt (with confirmation) and `--password-stdin` are the two
forms that avoid all three, which is the same pair `docker login` offers for the same reason.
Pydantic's `ValidationError` rendering is also deliberately not printed verbatim: it embeds the
offending value, which for a too-short password prints the password to the terminal. Only `loc` and
`msg` are shown (CLAUDE.md invariant #5); `tests/test_cli.py` asserts this on both the failure and
success paths.
**argparse, not Typer/Click:** one command with three options doesn't justify a runtime dependency
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.
---
### D20 — The Rider 650 has no Wi-Fi; verify device capabilities on the device
**What happened:** the plan's headline section, "The sync breakthrough," asserted that the Rider 650
has on-device Wi-Fi and a `Main Menu → Data Sync` entry that uploads rides to Bryton's cloud with no
phone involved. It does not. The Rider 650 has ANT+ and Bluetooth only; its sole sync route is BLE to
the Bryton Active app. Confirmed on the physical device, and corroborated by BikeRadar's hands-on
("ANT+ and Bluetooth connectivity", syncing via "Bryton's Active App"). The most likely origin of
the error is conflation with the Rider 750 / S800, which do have Wi-Fi.
**Why it survived so long:** the plan *did* contain the right check — "First action before writing
any code: on the Rider 650, go to `Main Menu → Data Sync`… and confirm a test ride uploads without
the phone." It was never run, and nothing downstream required it to have been. An entire phase was
planned, and Phase 0 fully built and deployed, on top of an unverified device capability that was
written down in the declarative voice of a finding rather than the provisional voice of an
assumption.
**What it cost, and didn't:** less than it first appeared. Everything downstream of Bryton's cloud —
ingestion, dedupe, schema, wear engine, garage, notifications, all of Phase 0 — never depended on
how a ride reached that cloud, so no built code was invalidated. What was invalidated was the
*product promise*: Phase 1 was called "Zero-touch ride history" and claimed to fix the original
complaint (having to remember to open the Active app). It does not. It is one-tap, and the
complaint stands. That renaming, not a refactor, was the actual repair.
**The rule going forward:** a physical-device capability that a phase depends on is confirmed **on
the device** before it is written down as fact. Model-adjacent sources (a spec page for a different
unit in the same family, a review of a sibling model) do not count. Until confirmed, such a claim is
written as an open question in `docs/RESEARCH.md`, not as a premise in `docs/PLAN.md` — and any
phase resting on it carries the verification as its first task, not as a footnote. The same applies
to the remaining unverified device claims: the USB `.fit` path layout, and whether the
`intervalssync` protocol still retrieves activities from a current Bryton account.
---
## 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.
+263 -49
View File
@@ -9,8 +9,8 @@ self-hosted Gitea and an act_runner on the same box for automated builds.
The specific pain driving this: today your Rider 650 syncs over Bluetooth to the Bryton Active
phone app, which forwards to Strava — but Active has no background sync, so **you have to remember
to open the app**. Research turned up a better path that removes the phone from the loop entirely
(see "The sync breakthrough" below).
to open the app**. This app is not, on its own, able to fix that last part — see "How rides actually
reach the app" below for why, and what it does fix.
You also want **mileage-milestone notifications** — "every 200 miles, clean and lube the drivetrain" —
which makes the maintenance side push-based rather than something you have to remember to go look at.
@@ -24,35 +24,57 @@ Directory is empty; this is greenfield. Decisions already made:
---
## The sync breakthrough
## How rides actually reach the app
Two facts verified during research change the design:
> **Corrected 2026-09-22, after checking the actual device.** An earlier version of this plan opened
> with a "sync breakthrough": the claim that the Rider 650 has on-device Wi-Fi and a `Data Sync`
> menu that uploads to Bryton's cloud with no phone involved. **That is false.** The Rider 650 has
> ANT+ and Bluetooth only — no Wi-Fi — and the only sync route it offers is Bluetooth to the Bryton
> Active app. The unit's menu has no `Data Sync` entry, and BikeRadar's hands-on confirms
> connectivity is "ANT+ and Bluetooth" with syncing via "Bryton's Active App." The likely source of
> the error is conflation with the Rider 750 / S800, which *do* have Wi-Fi. This mattered: it was
> the headline premise of the whole plan and it survived into a written roadmap unverified. The
> lesson is recorded in `docs/DECISIONS.md` — verify device capabilities against the physical device
> before building a plan on them, not against model-adjacent sources.
1. **Your Rider 650 has on-device Wi-Fi.** Its main menu has a `Data Sync` entry where the head unit
itself joins a Wi-Fi hotspot and uploads tracks to Bryton's cloud — **no phone, no Active app**.
2. **Bryton's cloud API is fully reverse-engineered** and returns the **original, unmodified FIT
bytes**. Working MIT reference implementation: `github.com/jorge-huxley/intervalssync`
(Python, updated 2026-09-17).
So the pipeline becomes fully hands-off:
The real chain, which is what everything downstream is built on:
```
ride ends → Rider 650 joins home Wi-Fi → uploads to Bryton cloud
ride ends → BLE → Bryton Active app on your phone → Bryton cloud
→ your server's poller fetches the original FIT → app
```
**First action before writing any code:** on the Rider 650, go to `Main Menu → Data Sync`, join your
home Wi-Fi, and confirm a test ride uploads without the phone. If it only syncs on manual menu
trigger rather than automatically, the fallback is still good (USB, below) — but verify this, because
it determines whether Phase 2 fully solves your complaint.
**The one fact that still holds, and is the load-bearing one:** Bryton's cloud API is fully
reverse-engineered and returns the **original, unmodified FIT bytes**. Working MIT reference
implementation: `github.com/jorge-huxley/intervalssync` (Python, updated 2026-09-17). Everything
this app does downstream of Bryton's cloud — ingestion, dedupe, the schema, the wear engine, the
garage, notifications — never depended on *how* a ride got into that cloud, which is why losing the
Wi-Fi premise costs far less than it first appears.
**What this does and doesn't fix.** It does not fix the original complaint. You still have to open
the Active app for a ride to leave the head unit; nothing in this app can reach across that gap
(see "Why not Bluetooth direct" below). What it does fix is everything *after* that: one tap and
the ride is permanently yours — full-resolution, original bytes, in your own database, with wear
recalculated and maintenance reminders armed, and no third party able to change the terms later.
**Worth trying, costs nothing, no code:** an **iOS Shortcuts personal automation** to open Bryton
Active for you — triggered on the Rider 650's Bluetooth disconnecting, or on arriving home. If iOS
honours it reliably, most of the hands-free behaviour comes back without any architectural change.
Try this before concluding the one-tap step is permanent.
**Why not Strava as the source:** Strava's API has no `export_original` endpoint — you get decoded,
smoothed streams, never the original file. Its June 2026 tier restructure also caps new apps at 10
users and requires the *developer* to hold a paid Strava subscription. Dead end; skip it.
**Why not Bluetooth direct:** Bryton's BLE sync protocol is not reverse-engineered by anyone —
no Gadgetbridge support, no ANT-FS, no published UUIDs. No third-party app, native or otherwise,
can pull rides off the head unit. Explicitly out of scope.
**Why not Bluetooth direct:** Bryton's BLE sync protocol is not reverse-engineered by anyone — no
Gadgetbridge support, no ANT-FS, no published UUIDs. Independently of the protocol, **iOS gives web
apps no Bluetooth at all** (Web Bluetooth is unimplemented in WebKit, with no public Apple
position), so the installed PWA structurally cannot talk to the head unit even if the protocol were
known. Pulling rides off the unit directly would mean reverse-engineering an undocumented protocol
*and* running it on non-iOS hardware in the house (a Pi, an old Android phone). That's a research
project of unknown size, not a schedulable phase. Out of scope — but it is the only route that
would truly remove the phone, so it is the thing to revisit if the one-tap step ever becomes
intolerable.
---
@@ -180,6 +202,35 @@ hashes), `api_tokens` (scoped, for Home Assistant/Grafana).
`counts_for_wear` (user override).
- `fit_time_created` + `device_serial` → partial unique index. This is the natural dedupe key.
**Capture everything the head unit emits, not a whitelist.** A hard requirement, not a nice-to-have:
whatever fields a Rider 650 puts in a FIT file should end up queryable and displayable, including
fields that aren't in the standard FIT profile. `fitdecode` surfaces all of it — every message type
(including ones it doesn't recognise, by message number), every field (unrecognised ones as
`unknown_<n>`), and developer fields with their definition metadata. The parser must therefore be
**field-agnostic by construction**: iterate the messages that are actually present and persist what
is found, rather than reading a fixed list of known field names and silently dropping the rest.
Concretely, three things this implies beyond the tables above:
- **`activity_streams` takes any channel.** `channel` is already a free string, so a new or unknown
per-record field (`unknown_61`, a developer field, a Bryton-specific extension) becomes a stream
row with no schema change. No whitelist anywhere in the parse path.
- **`activity_fit_messages`** — the non-time-series long tail, which the current tables have nowhere
to put: `device_info` (firmware, battery, every paired sensor), `event` (start/stop/lap triggers,
battery and sensor warnings), `hrv`, `zones_target`, `workout`/`workout_step`, `sport`, plus any
message type we don't recognise. Stored as `(activity_id, message_type, message_index, fields
json)` — JSON is correct here precisely because the shape is unknown and variable, which is the
opposite of the streams case where it's uniform and huge.
- **`activity_field_inventory`** — per activity, which channels and message types actually turned up,
with units and value ranges. This is what lets the UI render *"everything we got from this ride"*
dynamically instead of hardcoding a field list that goes stale the moment Bryton's firmware adds
something. It also makes "what does this head unit actually record?" answerable without scanning
every stream.
None of this risks anything, because of invariant #1: the raw bytes are retained forever, so a
parser that learns to understand more fields later is a `parser_version` bump and a reparse, not a
migration or a data-loss event. Verbosity is a projection-widening exercise and can be iterated on
safely.
**Streams — columnar arrays, one row per channel** (`activity_streams`: `channel`, `n`, `scale`,
`values_i32[]`). Decision and justification:
- **Size.** A 3h ride at 1Hz × ~9 channels: normalized per-sample rows ≈ 1.2MB + 0.3MB index;
@@ -226,6 +277,18 @@ contains every chain you retired since 2019.
**Weather:** `weather_observations` keyed on a **0.05° grid cell + UTC hour**, so nearby rides reuse
cached data, plus a per-activity `activity_weather` rollup.
**Live tracking (Phase 1B):** `live_sessions` (`user_id`, `started_at`, `ended_at`,
`share_token_hash` — only the hash, same discipline as invites and sessions — `expires_at`,
`obfuscate_endpoints_m`, and a nullable `activity_id` reconciled after the real FIT file arrives)
plus `live_positions` (`session_id`, `ts`, lat/lon as int32 semicircles, `altitude_cm`,
`speed_mms`, `accuracy_m`, and a nullable JSON column for whatever optional sensor metrics a
screen-on client manages to send). `live_positions` is append-only and high-write relative to
everything else here; it is also the **only** table in the schema that is deliberately *not*
permanent — once a session is reconciled to its activity, the positions are redundant against the
FIT file's own record, and can be pruned on a retention window without losing anything. This is the
single exception to "every table is a rebuildable projection of raw bytes," and it is an exception
precisely because live telemetry has no raw file behind it.
### The wear engine
`service_rules` carries `metric` (`distance | ride_time | calendar`), `threshold`, `basis`
@@ -434,13 +497,22 @@ Other Bryton hardening: map nonstandard manufacturer/product IDs via serial pref
values; store laps verbatim but **never derive session totals by summing laps** (use the `session`
message); compute `moving_time` from records with speed > 0.5 m/s if absent.
**The parser reads what's there, not what it expects.** Per the "capture everything" requirement in
the Schema section: walk every message and every field `fitdecode` yields, persist unrecognised ones
under their raw identifiers (`unknown_<n>`, developer fields with their definition metadata) rather
than skipping them, and record what was found in `activity_field_inventory`. A field the parser
doesn't have a name for is still worth storing and still worth showing — Bryton's encoder is not
Garmin's, and the whole point is to see everything the head unit actually recorded. A parser change
that *narrows* what gets captured is a regression, and the golden-fixture corpus should catch it:
assert on the field inventory of a known file, not just on the handful of summary numbers.
**Sources** implement a common `ActivitySource` protocol:
- **Upload** — `POST /api/v1/uploads`, multipart, 50MB cap, accepts `.fit`, `.fit.gz`, `.gpx`, `.tcx`.
- **USB watcher** — 60s periodic scan of `/import/inbox/**/*.fit`. A host udev rule on volume label
`Bryton` mounts the device **read-only** and rsyncs into the inbox. **Discover the subfolder at
runtime by recursive glob** — sources disagree on whether it's `Activities/`, `Actives/`, or root, so
don't hardcode it (your 650 is documented as `Bryton/Activities/`, but verify).
- **Bryton cloud (Phase 2)** — Meteor DDP over SockJS to `m3.brytonactive.com`: `login` with the
- **Bryton cloud (Phase 1 — the primary path)** — Meteor DDP over SockJS to `m3.brytonactive.com`: `login` with the
SHA-256 digest → `subscribe("activityList")` → read `userActivities` (**filter `_deleted`
tombstones**) → diff against `raw_files.source_ref``GET /api/activity?id=<id>` with `X-User-Id`,
`X-Auth-Token`, `x-api-key`, `User-Agent: okhttp/4.12.0` → raw original FIT bytes. Poll every 20 min,
@@ -499,13 +571,25 @@ lacks** — the cookie is transport convenience only.
These are the payoff for self-hosting — things Strava structurally cannot do.
**Free, because they're schema properties:**
- **No privacy zones, ever.** No third party holds your data, so show real door-to-door routes.
- **No privacy zones on your own archive.** No third party holds your data, so show real
door-to-door routes. (This reasoning covers the *private* archive only — a publicly shareable
live-tracking link is a different risk and gets its own treatment; see the live tracking phase.)
- **Every field the head unit recorded, not the handful a platform chose to keep.** Strava's API
gives you decoded, smoothed streams for a fixed set of channels; upload a FIT file there and the
unrecognised and vendor-specific fields are simply gone. Here the original bytes are retained
forever *and* the parser stores unknown and developer fields under their raw identifiers, so the
UI can show everything the Rider 650 actually wrote — including fields nobody has named yet.
- **Unlimited full-resolution retention**, forever, of the original files.
- **Cost-per-km on every component**, and per-kind averages ("my chains cost £0.019/km").
- **Receipts and photos attached** to parts, service events, and bikes.
- **Wet-weighted wear** — rim pads genuinely wear ~4× faster in the rain, and you have the weather data.
**Cheap and high value:**
- **Live tracking on your own terms** (Phase 1B) — a share link your family opens with no Bryton
account, no third party holding the trace, that keeps working if Bryton's service dies, and whose
history lands in your own database next to the ride it belongs to. Note honestly that Bryton's own
Live Track already does the live-map part and the phone has to be present either way; what
self-hosting buys is ownership, not capability.
- **Mileage-milestone and maintenance pushes** straight to your phone — the reason the garage data is
worth keeping. Strava's gear tracking can't do interval reminders at all.
- **Grafana pointed straight at Postgres** — roughly an afternoon, the cheapest analytics in the plan.
@@ -529,23 +613,114 @@ 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.
**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
the full `integration_health` alerting stack; USB watcher for historical backfill and as the break-glass
path; manual upload; activity list/detail with MapLibre and stream charts; totals and trends by
week/month/year and per bike, in miles; bikes CRUD; **odometer milestone notifications** (they only need
activities, so they ship here); invites; nightly `pg_dump -Fc` + restic.
*Done when:* you finish a ride, tap Data Sync on the 650, put the bike away, and it's on your phone
within 15 minutes with **zero further interaction** — and you stop opening Strava to look at your own
data. **This phase fixes your original complaint; protect it from scope creep.**
- **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 — One-tap ride history (68 weeks).** Ingestion core (all three dedupe layers, course
discrimination, quarantine, and the capture-everything field handling from the Schema section); **the
Bryton cloud poller as the primary path**, polling every 15 min with the full `integration_health`
alerting stack; USB watcher for historical backfill and as the break-glass path; manual upload;
activity list/detail with MapLibre and stream charts; totals and trends by week/month/year and per
bike, in miles; bikes CRUD; **odometer milestone notifications** (they only need activities, so they
ship here); invites; nightly SQLite snapshot + restic (not `pg_dump` — see D15).
*Done when:* you finish a ride, open the Active app once, put the bike away, and within 15 minutes
it's in your app — full-resolution original bytes, every field the head unit recorded, wear
recalculated — with **no further interaction**, and you stop opening Strava to look at your own data.
> **Renamed from "Zero-touch" deliberately.** The original criterion said "tap Data Sync on the 650…
> zero further interaction," which the device cannot do (see "How rides actually reach the app").
> The honest bar is **one tap in the Active app**, not zero. This phase therefore does *not* fully
> fix the original complaint — it fixes everything downstream of it. Removing that last tap needs
> either the iOS Shortcuts automation trick (free, unproven, try it) or reverse-engineering
> Bryton's BLE on non-iOS hardware (unbounded, out of scope). Don't let this phase quietly grow to
> chase it. **Protect it from scope creep.**
**Phase 1A — Make it yours: the UI pass (open-ended, done together).** Phase 1 deliberately ships a
plain, functional UI — correctness of the *data* first, because a beautiful page over wrong numbers
is worse than an ugly page over right ones. This phase is the opposite: no new data, no new
pipeline, just making the thing feel like yours, working through it together rather than against a
spec written in advance.
What it covers: the ride list and ride detail layout; which numbers are hero numbers and which are
buried; chart design for the stream data; the **verbose field surface** — everything the head unit
recorded, driven off `activity_field_inventory` so unknown and vendor-specific fields appear rather
than being silently hidden; dark mode; the mobile layout, since the real reading device is a phone
on a home screen; and the empty/loading/error states that a plain Phase 1 will have done crudely.
*Why it's a phase and not a task:* UI taste isn't specifiable up front by either of us — it needs
real rides on a real screen and a few rounds of "no, bigger / not that / what if the map was the
whole page." Budgeting it as its own phase makes that iteration legitimate rather than scope creep
against Phase 1.
*Done when:* you'd rather open this than Strava to look at a ride you just did — and you can find
every field the Rider 650 recorded without asking where it went.
**Phase 1B — Live tracking (35 weeks).** A self-hosted equivalent of Bryton's Live Track: someone
at home opens a link and watches your position and live metrics move on a map.
**Read the constraints before designing anything here — they are hard, and they shape the feature:**
- **The phone must be in your pocket.** Bryton's own Live Track requires the Active app running and
relaying over BLE (Rider 650 manual, "LIVE TRACK"); the head unit has no independent uplink. No
self-hosted design changes that.
- **An installed iOS PWA cannot do this.** iOS suspends JS when backgrounded or screen-locked, so a
PWA can only track foreground with the screen on (Wake Lock, iOS 18.4+, helps but doesn't lift
the restriction). And WebKit implements **no Web Bluetooth at all**, so the PWA cannot read
HR/power/cadence sensors under any circumstances.
- Therefore: **the tracking client is not our PWA.** It is an existing, backgrounded app POSTing to
our API.
**The shape that actually works:** **OwnTracks** (free, open-source, App Store) in HTTP mode,
POSTing to an authenticated endpoint on our server — genuinely backgrounded, screen off, phone in
pocket, ~30sfew-minute fixes in "move" mode. That gives **position + GPS speed**, and the server
derives the rest from the position stream: distance, elapsed and moving time, current/average pace,
elevation gain (via the Phase 3 DEM), and progress against the route if one is loaded. That is a
genuinely useful live metric set with no BLE at all.
**What is *not* achievable backgrounded: heart rate, power, cadence.** Those need BLE, which means
either a screen-on phone mounted on the bars running a BLE-capable browser (a second-class,
opt-in mode — not the installed PWA), a companion Android device or LTE tracker in a jersey pocket,
or a native app and a $99/yr Apple Developer account. Ship the location-first version; treat sensor
metrics as a separate, explicitly optional follow-on, and don't let them block the useful 80%.
**Two design rules this phase must not break:**
1. **Live positions are telemetry, not a ride.** They must never become an `activity` — the real
activity still arrives as original FIT bytes via Bryton (invariant #6, one ingestion path). A
live session is linked to the activity it corresponds to after the fact; it is not a second,
lower-fidelity source of truth. Getting this wrong produces duplicate, worse rides.
2. **"No privacy zones, ever" does not extend to a public live link.** That stance is sound for
*your own archive on your own server*; it is not sound for a URL that shows strangers your
current location, or your home, in real time. This phase needs: expiring share tokens, explicit
start/stop (plus an auto-end on inactivity so a forgotten session doesn't broadcast indefinitely),
and the option to blur the first and last N metres.
*Done when:* your family can open a link while you're out, see where you are and how far you've
gone, and the link stops working when the ride does.
> **On the phase numbering:** 1A and 1B are inserted rather than renumbering Phases 25, because
> "Phase 2"/"Phase 3" are referenced from `docs/DECISIONS.md`, `deploy/README.md` and code comments,
> and silently shifting their meaning is exactly the kind of stale cross-reference D20 is about.
**Phase 2 — The garage (57 weeks).** Components and time-ranged installs; inventory with the stock
ledger and install-from-stock; service events with photo/receipt attachments; **the seeded service-rule
@@ -615,7 +790,9 @@ heatmap/segment recompute.
for a private single-maintainer instance — but never make this repo public or add untrusted
collaborators without disabling Actions on fork PRs.
**Backups are a systemd timer on the host, not a Gitea Action**`pg_dump -Fc` + restic to B2/S3 with
**Backups are a systemd timer on the host, not a Gitea Action**a consistent SQLite snapshot
(`sqlite3 .backup` or `VACUUM INTO`, **not** a raw file copy of a live database; `pg_dump` no longer
applies, see D15) + restic to B2/S3 with
**append-only repo credentials** (so a compromised app host can't delete history), plus a restic
snapshot of `blobstore/`. Backups must not depend on CI, because CI is the thing most likely to be
broken when you need a restore. Quarterly `restore-drill.sh` restores into a throwaway stack and
@@ -625,12 +802,19 @@ asserts activity counts match.
## Top risks
**1. The Bryton private API breaks silently.** Hardcoded API key, undocumented protocol, zero
stability guarantee — and the failure mode is *silence*. Mitigations: the poller is one
`ActivitySource` among several, and the USB path ships first in Phase 1 so the system is never
*dependent* on it; nightly canary; immediate alerting on `auth`/`protocol` errors; vendored protocol
pinned to an upstream SHA so fixes are a diff, not a re-derivation; dual-source dedupe on the FIT
natural key means you can fall back to USB mid-week and lose nothing and duplicate nothing.
**1. The Bryton chain breaks silently — and it is now a longer chain than originally planned.** With
the Wi-Fi premise gone, every automatically-ingested ride passes through
`head unit → BLE → Active app → Bryton cloud → poller`, and only the last link is ours. Two of those
links can fail quietly: the app not being opened (rides simply never leave the unit — invisible to
the server, which cannot distinguish "no rides uploaded" from "you didn't ride"), and Bryton's
private API itself (hardcoded key, undocumented protocol, zero stability guarantee). Mitigations:
the poller is one `ActivitySource` among several, and the USB path ships in Phase 1 so the system is
never *dependent* on the cloud; nightly canary; immediate alerting on `auth`/`protocol` errors;
vendored protocol pinned to an upstream SHA so fixes are a diff, not a re-derivation; dual-source
dedupe on the FIT natural key means you can fall back to USB mid-week and lose nothing and duplicate
nothing. **New mitigation the longer chain earns:** a "nothing ingested in N days" nudge, so the
silent failure mode of simply forgetting to open Active surfaces as a notification rather than as a
gap you notice months later.
**2. Losing or corrupting years of ride history.** The realistic threats are mundane — a parser bug
writes wrong elevation to 4,000 rides, a migration drops a column, a disk dies. The raw-bytes-are-truth
@@ -650,12 +834,28 @@ because if the project stalls right after it, it has still succeeded.
## Verification
**Before coding:** on the Rider 650, `Main Menu → Data Sync` → join home Wi-Fi → ride → confirm the
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.
**Before coding — and this section is the reason the Wi-Fi premise survived as long as it did, so
treat it as load-bearing, not boilerplate.** The original version of this checklist said to verify
`Main Menu → Data Sync` on the Rider 650. That check was never run, and the feature does not exist;
a false premise sat at the top of this plan through an entire phase of work. Any capability of a
physical device that a phase depends on gets confirmed **on the device** before it is written down
as a fact.
**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.
Still worth doing before Phase 1:
- Plug the Rider 650 in over USB and `ls -R` the mounted volume to confirm the actual `.fit` path
(sources disagree: `Activities/`, `Actives/`, or root).
- Ride, open the Active app, and confirm the activity reaches Bryton's cloud — then confirm the
`intervalssync` protocol actually retrieves it, before building a poller on the assumption.
- Test the iOS Shortcuts automation (open Active on Rider 650 BLE disconnect, or on arriving home)
and see whether it fires reliably. This determines whether the one-tap step is permanent.
**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.
@@ -671,6 +871,20 @@ a redeployed container; `docker compose logs` shows migrations applied.
user B, `SELECT * FROM activities`, expect zero of user A's rows.
- Cross 1,000 miles on a bike → exactly one milestone notification, naming the ride that crossed it.
**Phase 1A — UI:** open a ride you actually did on your actual phone and check you can find every
field the head unit recorded without hunting; confirm a file containing an unknown or developer
field still surfaces it (feed a golden fixture with a deliberately nonstandard field and check it
renders rather than vanishing).
**Phase 1B — live tracking:**
- Start a session, lock the phone, put it in a jersey pocket, ride — confirm positions keep arriving
with the screen off. This is the whole feature; if it only works screen-on, it has failed.
- Open the share link on a device that has never logged in → the map moves.
- Let the token expire (or end the ride) → the same link stops working.
- Confirm a live session **never** produces an `activity` row, and that once the real FIT file lands
via Bryton, the session reconciles to it rather than sitting alongside as a duplicate.
- Enable endpoint obfuscation, then check the public link genuinely does not reveal your house.
**Phase 2 — garage and notifications:**
- Create a bike, install a chain, import 3 rides → chain shows summed distance. Edit the install date
backwards → the number self-corrects with no manual recomputation.
+24 -9
View File
@@ -8,20 +8,31 @@ architecture design; Sonnet for the two breadth surveys). Confidence is flagged
## 1. Getting data off the Bryton Rider 650
### Device facts (verified 2026-09-20)
### Device facts (dated 2026-09-20 — item 2 was WRONG, see correction)
The Rider 650 is **modern generation**. Two independent extraction paths, both good:
> ⚠️ **Correction, 2026-09-22.** Item 2 below is false and was never verified on the device. The
> Rider 650 has **no Wi-Fi** — ANT+ and Bluetooth only — and no `Data Sync` menu entry. Its only
> sync route is BLE to the Bryton Active app. Confirmed on the physical unit, corroborated by
> BikeRadar's hands-on ("ANT+ and Bluetooth connectivity", syncing via "Bryton's Active App"). The
> likely origin is conflation with the Rider 750 / S800, which do have Wi-Fi. The header on this
> section originally read "verified 2026-09-20" — it was not verified; that word is the reason the
> claim propagated into `docs/PLAN.md` as a premise and survived an entire phase of work. See
> `docs/DECISIONS.md` D20. **Item 1 (USB) is still believed correct but is also unverified against
> the device — treat it as an open question, not a finding, until someone plugs the unit in.**
The Rider 650 is **modern generation**. Extraction paths:
1. **USB mass storage.** Mounts as a plain FAT volume labelled `Bryton`; activities are native
Garmin-format `.fit` files in `Bryton/Activities/`. No driver, no udev rule needed beyond
convenience — it is plain `usb-storage`. Filter on `ID_FS_LABEL=Bryton`.
*Caveat:* sources disagree across models about whether the folder is `Activities/`, `Actives/`,
or the volume root. Discover it at runtime with a recursive glob; don't hardcode.
2. **On-device Wi-Fi.** `Main Menu -> Data Sync` lets the head unit join a Wi-Fi hotspot directly
and upload tracks to Bryton's cloud **with no phone and no Bryton Active app**. This is the
key finding — it removes the phone from the pipeline entirely.
*Still to verify:* whether it uploads automatically on joining Wi-Fi, or only when you
manually trigger Data Sync from the menu. Two-minute test; do it first.
*Status:* **unverified on the device.**
2. ~~**On-device Wi-Fi.** `Main Menu -> Data Sync` lets the head unit join a Wi-Fi hotspot directly
and upload tracks to Bryton's cloud **with no phone and no Bryton Active app**.~~
**FALSE — the device has no Wi-Fi and no such menu.** See the correction above. The real path is
`head unit -> BLE -> Bryton Active app -> Bryton cloud`, so the phone cannot be removed from the
pipeline by any means available to this project.
### Bryton Active cloud API (reverse-engineered, working)
@@ -330,8 +341,12 @@ can't delete history. 3-2-1. **Test restores** — an untested backup is a hypot
## 5. Open questions to resolve before/while building
1. **Does Rider 650 Data Sync upload automatically on joining Wi-Fi, or only on manual trigger?**
Determines how completely the phone is removed from the loop. Two-minute test.
1. ~~**Does Rider 650 Data Sync upload automatically on joining Wi-Fi, or only on manual trigger?**~~
**RESOLVED 2026-09-22 — the question was malformed.** There is no Wi-Fi and no Data Sync on this
device; the phone cannot be removed from the loop at all. See the correction at the top of this
file and `docs/DECISIONS.md` D20. *This was the single most consequential open question in this
list, it was marked "two-minute test," and it went unanswered while an entire phase was planned
and built on the assumed answer.*
2. **Exact on-device `.fit` path** — documented as `Bryton/Activities/` for the 650, but sources
disagree across models. One `ls -R` settles it.
3. **Bryton's FIT encoder quirks** — untested against `fitdecode`. Get one real 650 file and run it
+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"