- README: expand the production section with a full systemd recipe (useradd, chown, uv sync --project, install -d for ReadWritePaths, .env perms 640), a Caddy block, and backup note. - AGENTS.md: add the Arch python-uv package-name note and the root-rooted venv caveat, plus a new "Deploy / production" section documenting the volundr unit, data location, .env perms, and forward-proxy expectations.
6.3 KiB
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
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.
Arch Linux note: uv ships as python-uv (not uv or python3-uv) so
sudo pacman -S --needed python-uv is the install command. If you create the
venv with sudo, the interpreter will provision under /root/.local/share/uv
and a non-root service user can never execute it; build the venv as the
service user (or against a system Python — see README "systemd recipe").
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 ifBIKE_ADMIN_PASSWORDis not set (no insecure default). BIKE_ADMIN_SECRET_KEY— used to derive the per-session CSRF token.BIKE_ADMIN_IS_PRODUCTION=true— addsSecureto 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).
Deploy / production
deploy/bike_admin.service is the systemd unit for the production server
(volundr). Key points:
- Runs as the unprivileged
bikesystem user (no home dir needed beyond the uv-provisioned interpreter in/home/bike.local/share/uv/...). - App data lives next to the checkout:
/opt/bike_admin/data. The app createsdata/bike_admin.dbon startup, but systemd requires theReadWritePaths=target to exist before start — hence the one-timesudo install -d -o bike -g bike /opt/bike_admin/datastep (see README). .envat/opt/bike_admin/.env,chown bike:bikeandchmod 640. (systemd reads it at ExecStart.)- Hardening kept:
NoNewPrivileges,PrivateTmp,ProtectSystem=full,ProtectHome=true. Do not removeProtectHomeunless the interpreter path moves back to/home;StateDirectory=is the cleaner alternative for data, but we choseReadWritePaths=to keep data next to the app.
Caddy (hermodr, on a separate host) reverse-proxies
volundr.local.jakes-asgard.uk to the app's IP:8000. The app trusts
X-Forwarded-For (BIKE_ADMIN_TRUST_PROXY defaults true) and uvicorn's
--forwarded-allow-ips whitelist in the unit governs which proxies it'll
accept.
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 vendoredhtmx.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. Thisstarletteversion (1.6.0) uses the new signature — callingTemplateResponse(name, context)raisesTypeError: unhashable type: 'dict'. Always passrequestas the first arg (see all call sites inapp/main.py). - Templates must not contain single
-inside{{ }}as a bare token, and especially avoidelse '- '— Jinja will mis-lex it (unbalanced braces →TemplateSyntaxError: unexpected char '?' at ...). Use{% if ... %}{{ x }}{% else %}-{% endif %}instead. - CSRF: every mutating form must carry the
csrfhidden input (token = HMAC ofSECRET_KEYover the session cookie)._csrf(request)inapp/main.pyreturns it for the current session. - Throttling is per-IP and stored in the
login_attemptstable for the lastLOGIN_WINDOW_SECONDS. - Single user model;
bikesandeventsare global (no per-user scoping).
Testing
- Tests use
pythonpath = ["."](set inpyproject.toml) soimport app.*works. conftest.pyhas an autouseenvfixture that pointsBIKE_ADMIN_DATA_DIR,BIKE_ADMIN_SECRET_KEY,BIKE_ADMIN_USERNAME, andBIKE_ADMIN_PASSWORDto a fresh temp dir + known values per test, and clears theget_settings()cache.- The
Clienthelper wrapsTestClientwith.login(),.csrf(),.create_bike(),.create_event()helpers. TestClientis instantiated withfollow_redirects=Falseso 303s are observed (otherwise successful logins redirect to/and you lose theset-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').