- User-facing brand "bike-admin" -> "volundr" (page titles, top bar, login header, FastAPI title, README/AGENTS). Directory, remote, BIKE_ADMIN_* env prefix and bike_admin.db filename are unchanged. - Add deploy/: systemd unit (bike-admin.service) and a production .env template (env.production.example).
96 lines
4.7 KiB
Markdown
96 lines
4.7 KiB
Markdown
# AGENTS.md
|
||
|
||
Guidance for working in this repo.
|
||
|
||
## What this is
|
||
|
||
**volundr** (packaged as `bike_admin`) is a small single-user web app for logging bike service/repair
|
||
"events" (type, date, mileage, cost, notes) and seeing per-bike hints: a
|
||
**service-due** status derived from the newest mileage and the most recent
|
||
`service` event, plus **MOT / insurance renewal** statuses derived from the most
|
||
recent `mot` / `insurance` events.
|
||
|
||
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`).
|
||
- Hints: `BIKE_ADMIN_SERVICE_DUE_SOON_MILES` (service "book soon" lead time,
|
||
default 200), `BIKE_ADMIN_RENEWAL_DUE_SOON_DAYS` (MOT/insurance "due soon"
|
||
lead time, default 60), `BIKE_ADMIN_RENEWAL_MONTHS` (MOT/insurance validity,
|
||
default 12).
|
||
|
||
## 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`, `compute_renewal_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`, `favicon.svg`, and vendored `htmx.min.js`.
|
||
- `tests/` – `conftest.py` (Client helper + temp-data-dir env) and per-area
|
||
tests (`test_auth`, `test_due`, `test_renewals`, `test_events`, `test_security`).
|
||
|
||
## 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'`).
|