# 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.