pdf-quiz-generator/docs/api.md
Daniel e72cdd6716 feat: a versioned API, refresh tokens, and an end-to-end stack that found four bugs
**The API.** Every route now lives under `/api/v1`, with `/api/...` rewritten
onto it — one route, two spellings, so they cannot drift and the OpenAPI
document describes each endpoint once. Errors carry an `error` object with a
stable code, one human sentence and, for a validation failure, the fields that
were wrong; `detail` is untouched so nothing that reads it breaks. The whole
surface — 320 routes, their parameters and their status codes — is checked in
as `backend/tests/api-contract.json`, and a test fails on any difference,
naming the routes that moved. `docs/api.md` is the contract in prose.

**Refresh tokens**, so an app can stay signed in without keeping a password.
Rows rather than signatures: listable, withdrawable, stored as hashes, rotated
on every use. A spent token coming back ends the whole session, because a theft
and a replay look identical from the server and the safe reading is the unsafe
one. A browser is not given one — it has nowhere to put it and a person to ask.

**An end-to-end stack**: `docker-compose.test.yml` with its own Postgres and
Redis, `e2e/seed.py` for the smallest world the tests name, and Playwright with
five projects — desktop, iPhone, Pixel, iPad and a browserless API project.
Devices because every bug reported this week was a phone bug found by a person
looking at a screenshot; a desktop-only suite would have passed through all of
them. Forty tests, five clean runs.

It found four things in its first hour:

- **A fresh deploy could not start.** `create_all()` ran before
  `CREATE EXTENSION vector`, so any database that had never had pgvector
  installed died on the first table with a vector column. Invisible here
  because this one has had the extension for a year.
- **A figure in a published article was a 404 for everyone but an admin.**
  Media in the library is nobody's to read by default, and nothing made an
  exception for a drawing an article actually shows — so every illustration
  added this week was an empty box for every real user.
- **Every rate limit was one bucket for the whole site.** The backend saw
  nginx's address for every request, so ten bad passwords from anybody locked
  out everybody, and no log line could say who. nginx now takes the real
  address from the proxy and overwrites the header on the way in; uvicorn runs
  with --proxy-headers.
- **The reading page's breakpoints disagreed** — 1150px in the component,
  820px in the stylesheet. Between them the menu button claimed the contents
  drawer and then toggled a class on a rail that was still in the layout: the
  contents did not open and the site menu did not either. The button was dead
  on every tablet.

And two smaller ones: the login limiter counted successful sign-ins, so eleven
people behind one hospital NAT locked each other out — it is cleared by a
correct password now; and `/uploads/{path}` served GET and HEAD from one route
with one operation id, which makes every OpenAPI client generator refuse the
document.

The first admin's password is generated and printed once at first start when
`DEFAULT_ADMIN_PASSWORD` is blank, rather than the account not existing:
`docker compose logs backend | grep -A3 "FIRST ADMIN"`.

CI (`.forgejo/workflows/tests.yml`) runs the backend suite, the contract, the
frontend suite and the build on every push to dev, main or master, and the
end-to-end stack on those branches and on pull requests into them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 01:23:38 +02:00

146 lines
5.8 KiB
Markdown

# The API as a contract
`api-reference.md` beside this file lists the endpoints. This one is about the
rules that hold across all of them — the things a client has to be able to rely
on, and what happens when they change.
## Addresses
Everything is under `/api/v1`. `/api/...` reaches the same route and always
will: it is the address the web app in this repository was written against, and
an address that has shipped is a promise.
It is a rewrite, not a second mount (`app/api/versioning.py`). One route, two
spellings — so the two cannot drift, and the OpenAPI document describes each
endpoint once rather than twice.
A client written today should say the version. The day `/api/v2` exists, `/api`
will still mean v1, and anything pinned to `/api/v1` keeps the behaviour it was
built on.
Two addresses are deliberately outside the scheme:
| Path | Why |
|---|---|
| `/api/health` | Monitoring points at it and nobody edits that for a year. Also answers at `/api/v1/health`. |
| `/uploads/...` | An image's URL gets written into markdown and shared. Those must not move. |
## The contract is published
- `GET /api/openapi.json` — the machine-readable document
- `/api/docs` — Swagger, `/api/redoc` — ReDoc
And it is checked. `backend/tests/api-contract.json` is the surface as it stood
when it was last accepted: every method and path, the parameters each takes and
the status codes it answers with. `test_api_contract.py` compares the live app
against it and fails on any difference, naming the routes that moved.
```bash
# after deliberately adding or changing a route
docker compose run --rm --no-deps -e DATABASE_URL=sqlite:// -e PYTHONPATH=/app \
-w /app backend python -m tests.test_api_contract --write
```
Regenerating is a diff to review. That is the point: removing a route should be
visible in a review as "this client is about to break", not discovered by the
client.
## Errors
Every failure carries an `error` object as well as the `detail` FastAPI has
always produced. `detail` is unchanged so nothing that reads it breaks; `error`
is what a client should switch on.
```json
{
"detail": "Quiz not found",
"error": { "code": "not_found", "message": "Quiz not found" }
}
```
A validation failure also says which inputs were wrong:
```json
{
"error": {
"code": "invalid_request",
"message": "email: value is not a valid email address",
"fields": [
{ "field": "email", "message": "value is not a valid email address", "type": "value_error" }
]
}
}
```
Codes: `bad_request`, `unauthenticated`, `forbidden`, `not_found`, `conflict`,
`too_large`, `invalid_request`, `rate_limited`, `upstream_failed`,
`unavailable`, `server_error`. The list lives in `app/api/errors.py`; switch on
the word, not the number.
## Signing in, and staying signed in
An access token is a signed statement good for a day. Nothing consults a table
before believing it, so it cannot be withdrawn — fine for a browser, which can
send the person back to a login form, and no use to an app, which would have to
keep the password to survive the night.
So a client that says so gets a refresh token:
```http
POST /api/v1/auth/login
{ "email": "...", "password": "...", "refresh": true, "device": "PedsHub for iPhone" }
→ { "access_token": "...", "expires_in": 86400, "refresh_token": "...", "token_type": "bearer" }
```
| Endpoint | What it does |
|---|---|
| `POST /api/v1/auth/refresh` | Trades a refresh token for a new pair. The old one is spent. |
| `POST /api/v1/auth/logout` | Ends this session (`refresh_token`) or all of them (`everywhere: true`). |
| `GET /api/v1/auth/sessions` | Where this account is signed in — one row per device, not per rotation. |
| `DELETE /api/v1/auth/sessions/{family}` | Ends one of them. |
Three properties worth knowing:
1. **Rotation.** Every refresh issues a new token and spends the old one.
2. **A spent token coming back ends the whole session.** Either it was copied or
a client is replaying, and from the server those look identical — so the safe
reading is the unsafe one. A thief who spends a token first makes the real
client's next attempt fail, which is the theft announcing itself.
3. **Only the hash is stored.** A database that leaks does not hand over live
sessions with it.
A browser is not given one. It has nowhere safe to put it and a person sitting
in front of it to ask again.
## Rate limits
Keyed on the caller's address, which means the deployment must let the caller's
address through: nginx sets it from `X-Forwarded-For` (overwriting, so nothing
a client sends for itself survives) and uvicorn runs with `--proxy-headers`.
Without both, every request looks like it came from the proxy and every limit
becomes one bucket for the whole site.
| What | Default | Setting |
|---|---|---|
| Password guesses | 10 per address per 15 minutes, **cleared by a correct password** | `LOGIN_MAX_ATTEMPTS`, `LOGIN_WINDOW_MINUTES` |
| Refresh | 600 per address per hour — flood protection, not security | `REFRESH_MAX_PER_HOUR` |
| The tutor, AI Mode, TTS | Per-user daily quotas | Admin settings |
Guesses are counted; sign-ins are not. Counting both would lock out a hospital:
one public address, a ward full of people, eleven of whom opened the app this
afternoon.
## What a client may see
Two rules that are not obvious from the endpoint list, and that a client should
not try to work around:
- **A stem is bank content; the answer beside it is not.** `correct_answer`,
`explanation`, `option_explanations`, `key_points` and `attending_tip` are
returned only to whoever writes that question — a moderator, its author, or
someone with an editorial grant covering where it is filed. Everyone else
earns the answer by sitting the question, which is what an attempt is.
- **Answer-side media needs the attempt in the URL.** `?attempt_id=` on an
`/uploads/...` request is how the server knows the person asking is the
person who sat it.