4f4ca345ca019c29a41469682c852d97868fb779
11
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4f4ca345ca |
fix(deploy): healthcheck must hit the real host IP, not localhost
The deploy job runs inside its own ephemeral DooD job container, which is a separate container from `caddy` — caddy's -p 8090:80 publishes onto the real host's network namespace, not this job container's own loopback. A `localhost:8090` curl here would fail with connection-refused regardless of whether the deploy actually succeeded, misreporting a working deploy as a failed workflow. Point it at the same host IP deploy/.env.example's VELODROME_PUBLIC_URL already uses. Caught during review, not left as the open caveat the PR description flagged it as. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5d4d76203f |
feat(deploy): compose stack, Caddy, and the release/deploy pipeline
Phase 0 deployment: three-service docker-compose.yml (caddy, api, db), a Caddyfile that proxies /api/* to the api service and serves the SPA with index.html fallback, and two Gitea Actions workflows (release.yml builds and pushes both images on a v* tag or manual dispatch; deploy.yml is manual-only and rolls them out to the Unraid host). The non-obvious part is the Docker-outside-of-Docker constraint on this act_runner setup: job containers share the host's Docker daemon over the socket but do NOT share its filesystem, so any command whose correctness depends on a client-side local path (docker cp to a host path, mv/rm -rf on a host path, a bind-mount source path on a `docker run` command line issued from inside a job) silently operates on the ephemeral job container's own throwaway filesystem instead. Two things are safe: a bind mount declared in a compose file's `volumes:` block (resolved by the daemon when `docker compose up` creates the service — this is why db's pgdata bind mount is fine), and a named volume populated by a one-shot `docker run` whose *command* does the copying (this is why the web image's static build output goes into a `web_build` named volume via `docker run -v ... sh -c 'cp -a ...'` in deploy.yml, rather than any `docker cp`). Local verification (see PR description for full detail) caught a real bug: `docker compose run api alembic upgrade head` needs VELODROME_DB_APP_PASSWORD/VELODROME_DB_AUTH_PASSWORD as container env vars to create the two runtime roles, but --env-file alone doesn't inject them since the api service's permanent environment block deliberately omits them (least-privilege — the long-running app should never need role-creation passwords). Fixed by passing them as explicit -e overrides on the migration step, same as VELODROME_DATABASE_URL_MIGRATE. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
1832059e03 |
Merge pull request 'feat(web): SvelteKit PWA shell with login' (#3) from feat/web-shell into main
Reviewed-on: #3 |
||
|
|
ea6e17d39f |
Merge pull request 'feat(api): FastAPI skeleton with two-role RLS auth foundation' (#2) from feat/api-skeleton into main
Reviewed-on: #2 |
||
|
|
65d9829e27 |
fix(web): pin packageManager, drop unused @vite-pwa/sveltekit dep
CI's web job runs bare `corepack enable && pnpm install --frozen-lockfile` with no explicit pnpm version, so without a `packageManager` field corepack can resolve a different pnpm than the one that generated the lockfile (lockfileVersion 9, requires pnpm 9+) — that's what broke the first CI run. Also drops @vite-pwa/sveltekit, left over from an earlier approach abandoned in favor of the base vite-plugin-pwa plugin (see vite.config.ts) and no longer imported anywhere. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
7bef79fae5 |
feat(web): SvelteKit PWA shell with login
Phase 0 scaffolding for the frontend: SvelteKit + adapter-static in SPA mode
(fallback index.html, ssr disabled in the root layout — no Node process in
production, Caddy serves build/ directly per docs/PLAN.md), a login page and
auth store backed by /api/v1/auth/{login,me,logout}, an installable-PWA shell
(hand-written manifest, iOS meta/safe-area handling, an install-onboarding
banner), and a Dockerfile whose only job is to produce a buildable /app/build
artifact for deploy/ to consume.
Service worker notes, since the wiring isn't obvious from the diff:
- injectManifest, not generateSW: the caching policy needs to say "never
cache /api/*", which generateSW's declarative config can't express as
precisely as hand-written Workbox routes can.
- Uses the base `vite-plugin-pwa` plugin, not `@vite-pwa/sveltekit`'s
SvelteKit-specific wrapper. That wrapper's injectManifest build expects
SvelteKit's own built-in src/service-worker.{js,ts} convention to have
already transpiled the file — but that native build only permits importing
SvelteKit's own three virtual modules and hard-rejects `workbox-*` imports,
which our SW needs. The base plugin bundles src/service-worker.ts directly
instead, which works. SvelteKit's native service-worker convention is
explicitly disabled (`kit.files.serviceWorker` pointed at a path that
doesn't exist) so the two builds can't collide and silently clobber each
other's output — they do, if both are left enabled, and the failure mode is
silent (the SW builds fine, just precaches nothing).
- workbox-core/precaching/routing/strategies had to be added as direct
devDependencies even though workbox-build depends on them — pnpm doesn't
hoist transitive deps into the top-level node_modules, so the SW bundle
step couldn't resolve them otherwise.
- The SPA fallback index.html doesn't exist yet at service-worker-build time
(adapter-static writes it after all Vite plugins finish), so it can't be
glob-hashed into the precache manifest normally. It gets a synthetic
manifest entry instead, revisioned by a per-build-invocation timestamp
(see the swIndexRevision comment in vite.config.ts) so the cached shell
still invalidates correctly on every deploy.
Full rationale for each choice is inline as comments in vite.config.ts and
apps/web/README.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
||
|
|
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>
|