f6005a4fddbc927c73deb3027919a42b2cd31741
24
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f6005a4fdd |
Merge pull request 'test(deploy): add a real login smoke test, not just a health check' (#11) from test/deploy-login-smoke-test into main
CI / Repo hygiene (push) Successful in 3s
CI / Web (lint, typecheck, build) (push) Successful in 16s
CI / Migrations reversible (push) Successful in 7s
Release image / Build and push single-container image (push) Successful in 6s
CI / API (lint, types, tests) (push) Successful in 54s
Reviewed-on: #11 |
||
|
|
8c88748f50 |
test(deploy): add a real login smoke test, not just a health check
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 |
||
|
|
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 |
||
|
|
d0c0d98307 |
docs: record D18 (admin bootstrap) and the deploy bootstrap step
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
6b0f28cf74 |
docs(deploy): note the /data uid/gid-mismatch trap on first start
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 |
||
|
|
8278d96875 |
Merge pull request 'fix(deploy): default the Unraid template's host port off 8080' (#7) from fix/deploy-unraid-template-port into main
Reviewed-on: #7 |
||
|
|
45719f284c |
feat(deploy): build+push release image on every merge to main
Was tag-push-or-manual-dispatch only. Adds a push:main trigger so main stays continuously deployable without needing a version tag for every change. Also fixes a real bug this surfaced while testing the D17 registry-TLS fix: the old tag logic unconditionally retagged :latest on every run, including manual test dispatches off a feature branch — one such dispatch, done while verifying the previous commit, silently overwrote :latest with a feature-branch build. Tag resolution now only moves :latest on an actual main push or a version tag; a manual dispatch gets its own manual-<timestamp>-<sha> tag and leaves :latest alone. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG |
||
|
|
a114a7d3d8 |
fix(deploy): push through a TLS-terminating proxy, not raw Gitea HTTP
release.yml's first real run failed: docker/login-action against 192.168.0.3:3000 hit "server gave HTTP response to HTTPS client" — Docker refuses any non-localhost registry over plain HTTP by default, so this was never actually a workflow bug. Rejected insecure-registries in daemon.json after reading this Unraid host's own rc.docker script: applying it needs a full dockerd restart, and with Live Restore disabled here, that stops every one of the ~40 other containers on the box first. Also rejected a real Let's Encrypt cert on a public bbergle.com subdomain — this host's other subdomains are Cloudflare-proxied, which would terminate TLS at Cloudflare's edge and never reach our own cert at all. Chosen instead, scoped to touch nothing already working: a self-signed cert for registry.bbergle.com behind a new NPMplus proxy host (found its real HTTPS port, 9537, by reading `docker port NPMplus` rather than assuming 443, which is a different nginx process on this box entirely); an /etc/hosts entry on the Unraid host so only that host needs to resolve the name (no DNS record, no router/NAT dependency); and its CA dropped into /etc/docker/certs.d, which Docker's own docs confirm is read per-connection with no daemon restart required. Also pins buildx to driver: docker instead of setup-buildx-action's default docker-container driver, which runs an isolated builder that doesn't see /etc/docker/certs.d and would have quietly defeated all of the above. Full record, including what was rejected and why, in docs/DECISIONS.md D17. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG |
||
|
|
32037b1190 |
fix(deploy): default the Unraid template's host port off 8080
8080 is already bound by qBittorrent on the actual Unraid host this gets deployed to (found while placing the template for real) — defaulted to 8090 instead. Purely a template default; the container's own internal port is unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG |
||
|
|
70e0182177 |
Merge pull request 'chore(deploy): single-container Dockerfile, Caddy, and Unraid template' (#6) from chore/deploy-single-container into main
CI / Repo hygiene (push) Successful in 2s
CI / Web (lint, typecheck, build) (push) Successful in 14s
CI / Migrations reversible (push) Successful in 6s
CI / API (lint, types, tests) (push) Successful in 52s
Release image / Build and push single-container image (push) Failing after 1m42s
Reviewed-on: #6 |
||
|
|
6c48000d7b |
chore(deploy): single-container Dockerfile, Caddy, and Unraid template
Builds the container the "1 container" decision (D15) actually needs, which D15 itself deferred as follow-up work: Caddy + the FastAPI app + the static SvelteKit build in one image, SQLite on a mounted volume. See docs/DECISIONS.md D16 for the specific choices and why (entrypoint-run migrations instead of a separate deploy-pipeline step, tini + a small supervisor script instead of s6-overlay/supervisord, copying the Caddy binary out of its official image). Removes apps/api/Dockerfile and apps/web/Dockerfile from the old 4-container compose plan (PR #4, closed as superseded) — the root Dockerfile replaces both with one multi-stage build. deploy/unraid-template.xml turns VELODROME_PUBLIC_URL, VELODROME_SECRET_KEY, etc. into fillable Unraid Community Applications web UI fields, per the earlier decision to keep config there instead of a .env file. .gitea/workflows/release.yml builds and pushes the image to the Gitea registry on a version tag or manual dispatch; it does not touch the running container. Verified by actually running the built image, not just building it: the health endpoint responds through Caddy's proxy, the SPA serves with working client-route fallback, alembic ran and produced a real (non-empty) SQLite file under /data, the process runs as the non-root velodrome user, and killing the uvicorn process brings the whole container down (exit 143) rather than leaving Caddy serving alone — confirming the entrypoint's coupled-lifetime behavior actually holds, not just that it reads correctly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG |
||
|
|
9fa50cb2ea |
Merge pull request 'refactor(api): move from Postgres+RLS to single-engine SQLite' (#5) from refactor/sqlite-single-container into main
Reviewed-on: #5 |
||
|
|
e7392a5723 |
refactor(api): move from Postgres+RLS to single-engine SQLite
Reverses a shipped, tested, merged decision (D4/PR #2) rather than building on it — see docs/DECISIONS.md D15 for the full record: what was rejected (Postgres as a second container; Postgres+PostGIS bundled inside the single container via a supervisor), what this costs (no database-level RLS, no PostGIS, procrastinate needs replacing — all stated as a concern before this was decided, and reaffirmed anyway, which is the user's call to make about their own instance). The one invariant-critical consequence: isolation between users now rests entirely on the repository-layer scope (db.py's `Scope.select()`), not two layers. CLAUDE.md's invariant #4 is revised accordingly. This is not a downgrade-and-hope — `Scope` is built so an unfiltered query against a user-owned table is structurally harder to write than a scoped one (there is no method on `Scope` that returns one), and tests/test_auth.py::test_scoped_session_blocks_cross_user_reads replaces the old RLS proof with the same empirical standard: it doesn't trust the query builder filters correctly because the code reads correctly, it registers two real users and checks. test_unscoped_session_can_see_every_user_when_misused is the deliberately alarming companion — it demonstrates exactly what a reviewer must now catch, since nothing else will. Six real, non-obvious SQLite behaviours found and fixed by actually running this against a real file, not assumed from docs: - Foreign keys, ON DELETE CASCADE included, are OFF by default per connection — deleting a user silently left orphaned sessions/api_tokens, no error either way. Fixed with PRAGMA foreign_keys=ON on every connect. - Transactions default to DEFERRED, which only takes a write lock on the first actual write — a real check-then-act race for invite redemption (two concurrent redemptions could both read used_count < max_uses as true before either commits). Fixed by disabling the driver's implicit BEGIN and issuing BEGIN IMMEDIATE ourselves — SQLAlchemy's own documented recipe for this, not improvised. - DateTime(timezone=True) does NOT round-trip tzinfo on SQLite — a tz-aware datetime goes in, a naive one comes back out, and every `expires_at < datetime.now(UTC)` comparison in auth/service.py then raises TypeError. Fixed once at the Base level with a UTCDateTime TypeDecorator rather than per-column. - Uuid(as_uuid=True) stores as 32-char hex with NO hyphens on SQLite, not str(uuid)'s hyphenated form. A test fixture that raw-inserted the hyphenated form left rows the ORM's own later UPDATE (via invite.used_count += 1's autoflush) could never match by primary key, updating zero rows and raising StaleDataError. Fixed by using .hex to match exactly what the ORM itself writes. - BEGIN IMMEDIATE applies to every transaction, reads included — a long-lived test fixture that autobegins a transaction via a bare read and never explicitly closes it holds SQLite's exclusive write lock for the rest of the test, and a later scoped_session() call fails with "database is locked". Not an app-code bug (every real session block closes cleanly on exit), but real enough to document since the next person writing a test against the db_auth fixture will hit it too. - Python's sqlite3 module deprecates its own implicit datetime adapter as of 3.12 — silent today, warns on every raw-SQL datetime bind. Only ever hit test fixture code (the ORM path never uses it, confirmed by running the ORM-only health test with warnings promoted to errors and it stayed clean); fixed there with an explicit .isoformat() rather than left for a future Python version to turn into a real failure. Also, since with_for_update() silently no-ops on SQLite (confirmed — SQLAlchemy emits no SQL for it, no error either) rather than actually locking anything: removed it from register()'s invite-redemption query and corrected the comment to attribute the concurrency guarantee to BEGIN IMMEDIATE, where it now actually lives. One PR, not several, for the same reason PR #2 was: the migration, the models, db.py, and the docs recording why are five views of one decision — splitting them wouldn't make review easier, just disconnected. 552 insertions / 548 deletions across 17 files, most of it necessarily touching what PR #2 shipped rather than net-new code. Deliberately deferred, not solved here: PostGIS's replacement for spatial storage, procrastinate's replacement for background jobs, and the EXCLUDE USING gist constraint's replacement for component_installs — none of those tables exist yet (Phase 1-2), so none of it is broken, and docs/DECISIONS.md D15 records exactly what each future phase needs to decide before it can be built. .gitea/workflows/deploy pipeline (PR #4, built for the old 3-container Postgres compose stack) was closed as superseded rather than merged; the single-container image build is follow-up work, not part of this change. Verified: ruff check, ruff format --check, and mypy --strict all clean. 13/13 pytest passing against a real SQLite file, including with DeprecationWarning promoted to an error (confirms the sqlite3 adapter deprecation fix actually holds, not just that it's quiet by default). Full alembic upgrade -> downgrade -1 -> upgrade cycle run clean. alembic check clean with no include_object filter needed at all now (SQLite starts with nothing but what our own migrations create — no PostGIS/TIGER noise to filter out in the first place). CI's exact migration command sequence reproduced locally end to end before touching the workflow file. Co-Authored-By: Claude Opus 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>
|