# AGENTS.md Guidance for working in this repo. ## What this is `bike-admin` is a small single-user web app for logging bike service/repair "events" (type, date, mileage, cost, notes) and seeing a per-bike **service-due** hint derived from the newest mileage and the most recent `service` event. Stack: FastAPI (ASGI) + stdlib `sqlite3` (WAL) + Jinja2 + HTMX. Server-rendered pages with HTMX partial swaps on the bike page. No build step. Dependencies via `uv` (see `pyproject.toml`). ## Commands ```bash uv sync --all-extras # install all deps (fastapi, dev tools) uv run uvicorn app.main:app --reload # run dev server (http://127.0.0.1:8000) uv run pytest -q # run the test suite uv run ruff check app tests # lint uv run ruff format --check app tests # format check uv run ruff format app tests # apply formatting ``` There is **no `main.py` at the repo root** — the app is the `app` package, entry point `app.main:app`. ## Configuration All settings come from env vars (or a `.env` file) via `pydantic-settings`, `env_prefix="BIKE_ADMIN_"` (see `app/config.py`). - **Required:** `BIKE_ADMIN_USERNAME`, `BIKE_ADMIN_PASSWORD`. The app **raises at startup** if `BIKE_ADMIN_PASSWORD` is not set (no insecure default). - `BIKE_ADMIN_SECRET_KEY` — used to derive the per-session CSRF token. - `BIKE_ADMIN_IS_PRODUCTION=true` — adds `Secure` to the session cookie (set when behind a TLS reverse proxy over HTTPS). - `BIKE_ADMIN_BIND_HOST` / `BIND_PORT` — bind address/port. - `BIKE_ADMIN_DATA_DIR` — directory for the SQLite file (`bike_admin.db`). ## Architecture / layout - `app/config.py` – `Settings` (pydantic-settings) + `get_settings()` (cached). - `app/db.py` – SQLite schema + typed DAO helpers + `get_conn()` context manager. - `app/security.py`– Argon2 password hashing, DB session tokens, CSRF, login throttle. - `app/services.py`– Business logic: `compute_service_due`, `summarize_bike`, `all_summaries`. - `app/main.py` – App factory, lifespan (schema init + user seed), security-header middleware, all routes, HTMX partial rendering. - `app/templates/` – Jinja2 templates (login, dashboard, bike detail, event partial). - `app/static/` – `main.css` and vendored `htmx.min.js`. - `tests/` – `conftest.py` (Client helper + temp-data-dir env) and per-area tests. ## Conventions & gotchas - **Starlette's `TemplateResponse(request, name, context)` signature is required.** This `starlette` version (1.6.0) uses the new signature — calling `TemplateResponse(name, context)` raises `TypeError: unhashable type: 'dict'`. Always pass `request` as the first arg (see all call sites in `app/main.py`). - **Templates must not contain single `-` inside `{{ }}` as a bare token**, and especially avoid `else '- '` — Jinja will mis-lex it (unbalanced braces → `TemplateSyntaxError: unexpected char '?' at ...`). Use `{% if ... %}{{ x }}{% else %}-{% endif %}` instead. - **CSRF:** every mutating form must carry the `csrf` hidden input (token = HMAC of `SECRET_KEY` over the session cookie). `_csrf(request)` in `app/main.py` returns it for the current session. - **Throttling is per-IP and stored in the `login_attempts` table** for the last `LOGIN_WINDOW_SECONDS`. - **Single user** model; `bikes` and `events` are global (no per-user scoping). ### Testing - Tests use `pythonpath = ["."]` (set in `pyproject.toml`) so `import app.*` works. - `conftest.py` has an autouse `env` fixture that points `BIKE_ADMIN_DATA_DIR`, `BIKE_ADMIN_SECRET_KEY`, `BIKE_ADMIN_USERNAME`, and `BIKE_ADMIN_PASSWORD` to a fresh temp dir + known values per test, and clears the `get_settings()` cache. - The `Client` helper wraps `TestClient` with `.login()`, `.csrf()`, `.create_bike()`, `.create_event()` helpers. - `TestClient` is instantiated with `follow_redirects=False` so 303s are observed (otherwise successful logins redirect to `/` and you lose the `set-cookie`). ### Security headers Applied via an HTTP middleware in `app/main.py`: `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy`, and a strict CSP (`default-src 'self'`, `script-src 'self'`, `frame-ancestors 'none'`).