ea6e17d39f80d2c10c07f0c9441bccef35fda745
6
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ea6e17d39f |
Merge pull request 'feat(api): FastAPI skeleton with two-role RLS auth foundation' (#2) from feat/api-skeleton into main
Reviewed-on: #2 |
||
|
|
ddea750792 |
feat(api): FastAPI skeleton with two-role RLS auth foundation
Phase 0's API half: a working FastAPI app with register/login/me/logout, backed by a Postgres schema where row-level security is real and independently proven, not just declared. The core design decision, and the reason this lands as one PR instead of several: request-scoped queries run as `velodrome_app` (NOBYPASSRLS), but looking up identity in the first place — login by email, a session by its token hash — has to happen *before* app.user_id can be set, so those specific lookups run as a second role, `velodrome_auth` (BYPASSRLS), used nowhere else in the codebase. See velodrome/db.py's module docstring and apps/api/README.md for the full rationale. This is genuinely one reviewable unit: the migration, the models, and the auth service only make sense evaluated together, since they're three views of the same invariant. tests/test_auth.py::test_rls_blocks_cross_user_session_reads is the test worth reading first — it doesn't trust the RLS policy SQL because it reads correctly, it proves isolation by registering two users and confirming a scoped read of `sessions` for user A returns exactly one row, never two. Bugs found and fixed while actually running this against real Postgres (everything below was verified against a live postgis/postgis:16-3.4 container and a built Docker image, not just read for correctness): - CREATE ROLE's PASSWORD clause is DDL, not DML — it doesn't accept bind parameters (`PASSWORD $1` is a syntax error). Fixed with dollar-quoting. - Postgres roles are cluster-wide, not per-database — a second database in the same cluster hit "role already exists" on a plain CREATE ROLE. Fixed with a DO block catching duplicate_object. - A bare `Mapped[datetime]` on the ORM models infers a naive timestamp, silently disagreeing with the migration's correct `DateTime(timezone=True)` — asyncpg rejected the mismatch at insert time. Fixed once, at the declarative Base level via type_annotation_map, rather than per-column. - The session cookie's `secure` flag was gated on `!= "development"`, so anything else — including local testing and a real deploy running temporarily without TLS in front — got a Secure cookie no HTTP client will ever send back, breaking every authenticated request after login with no visible error. Gated on `== "production"` instead. - `alembic check` initially flagged every PostGIS/TIGER-installed table (dozens of them) as drift, because they're not in our metadata. A schema-based denylist doesn't work — reflected foreign tables come back with schema=None regardless of their real schema. Fixed with an allowlist keyed on target_metadata.tables instead, which is also more robust against future PostGIS versions adding more tables. - The migration itself was missing `nullable=False` on three timestamp columns that the ORM model assumed were never null — a genuine model/migration drift that alembic check caught once the PostGIS noise above was filtered out. Fixed in 0001 directly, since it's never shipped. - Two indexes the migration creates explicitly weren't declared on the ORM models, causing the same kind of drift. Added index=True to match. Deliberately deferred, not forgotten: per-IP/per-account login rate limiting (docs/PLAN.md mentions it; Phase 0's bar is a working skeleton, and this needs its own design pass) and the procrastinate job runner / worker container (nothing to run yet — arrives with the ingestion pipeline). ci.yml updated to match: the api and migrations jobs now provision the same two runtime roles this code actually needs, replacing the single placeholder DATABASE_URL from before any code existed. Verified: ruff check, ruff format --check, and mypy --strict all clean. 12/12 pytest passing against a real Postgres. Full alembic upgrade -> downgrade -1 -> upgrade cycle run twice (once standalone, once inside a two-database cluster to specifically catch the role-collision bug). alembic check clean. Docker image builds and serves real traffic — register and an authenticated GET /me both exercised against the actual built container, not just the test suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e7cb06a6bc |
Merge pull request 'chore: set up branching, CI, and PR workflow' (#1) from chore/repo-scaffolding into main
Reviewed-on: #1 |
||
|
|
e34c1d92ad |
chore(scripts): resolve Gitea token from keychain or config file
An exported GITEA_TOKEN only exists in the shell that exported it, so tooling invoked from elsewhere could not find it. Resolve in order: $GITEA_TOKEN, ~/.config/gitea/token, then the macOS Keychain — so the token can live somewhere durable and non-world-readable instead of a plaintext dotfile. Also factors the API call and repo coordinates into scripts/lib/gitea.sh so pr.sh and review.sh stop duplicating them, and adds `review.sh --list` for enumerating open PRs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d5e473959c |
chore: set up branching, CI, and PR workflow
Prepares the repo for parallel agent work. No application code. - CLAUDE.md: conventions, branch naming, and the six non-negotiable invariants from the design (immutable raw bytes, no stored odometers, SI integers, dual-layer user isolation, secret containment, single ingestion path). Also records a model-allocation policy: the orchestrator runs Opus 5, workers default to Sonnet, and Opus is reserved for review plus the areas where a mistake is silent and expensive (ingest, wear SQL, auth/RLS, the Bryton protocol client). And the Gitea Actions gotchas, so nobody rediscovers them: GITEA_TOKEN cannot push to the container registry, jobs.*.environment is ignored, and cron needs a workflow_dispatch pair. - CONTRIBUTING.md: day-to-day flow, worktrees for parallel branches, review expectations. - .gitea/workflows/ci.yml: repo hygiene (branch naming, secret scan, no ride data in git), plus API/web/migration jobs that guard on whether the code exists yet, so CI is meaningful now and grows into the real thing rather than being rewritten. - .gitea/PULL_REQUEST_TEMPLATE.md: forces an honest "how this was verified" and an invariant checklist. - scripts/pr.sh, scripts/review.sh: open and inspect PRs via the Gitea API. - Directory scaffold with placeholder READMEs. Agents open PRs; humans merge them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d3f5ed7e7b |
Add planning docs for self-hosted cycling app
Planning output only; no application code yet.
Key findings driving the design:
- The Bryton Rider 650 has on-device Wi-Fi (Main Menu -> Data Sync) and
uploads to Bryton's cloud with no phone and no Bryton Active app. Paired
with the reverse-engineered Bryton cloud API — which returns the original
unmodified FIT bytes — this makes ride sync fully hands-off, and higher
fidelity than the current Strava route (Strava's API cannot return the
original file, only smoothed streams).
- Build fresh rather than forking Endurain or FitTrackee; borrow Endurain's
gear/component structure and strava-gear's retroactive time-ranged wear
computation.
- PWA rather than a native iOS app: iOS 16.4+ gives home-screen PWAs real
push notifications, which was the only thing that used to force native.
Docs:
docs/PLAN.md stack, schema, ingestion, auth, notifications, roadmap,
CI/CD, risks, verification
docs/RESEARCH.md Bryton cloud protocol, FIT library comparison,
maintenance intervals, geo services, Gitea gotchas
docs/DECISIONS.md decisions taken, alternatives rejected, rationale
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|