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

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.

Status: planning complete, no code written yet.

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:

ride ends -> Rider 650 joins home Wi-Fi (Main Menu -> Data Sync)
          -> uploads to 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.

Docs

File What's in it
docs/PLAN.md The full implementation plan: stack, schema, ingestion pipeline, auth, notifications, phased roadmap, CI/CD, risks, verification
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 Every decision taken, what was rejected, and why

Planned stack

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.

Four containers in v1, under 2GB RAM.

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).
S
Description
No description provided
Readme
742 KiB
Languages
Python 70.1%
TypeScript 8.4%
Shell 8.2%
Svelte 7.6%
Dockerfile 2.6%
Other 3.1%