pdf-quiz-generator/backend/.env.example
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

110 lines
5.2 KiB
Text

# Every setting the backend reads, with the default it falls back to. Blank
# means "off" or "not configured" throughout — nothing here has a secret in it,
# and nothing here is required except the database, the secret key and a model.
# ── Database and sessions ──────────────────────────────────────────────
DATABASE_URL=postgresql://quiz:quiz@postgres:5432/quiz
SECRET_KEY=change-me-to-a-random-secret-key-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440
# The first admin, created only when the database has none. Leave the email
# blank too and the first person to register becomes the admin instead.
# Set the email and leave the password blank, and one is generated and printed
# to the log at first start: docker compose logs backend | grep -A3 "FIRST ADMIN"
DEFAULT_ADMIN_EMAIL=
DEFAULT_ADMIN_PASSWORD=
# Redis: Celery's broker, the rate limiters, job progress and site settings.
REDIS_URL=redis://redis:6379/0
# ── Models ─────────────────────────────────────────────────────────────
# Everything goes through one LiteLLM proxy. Per-job models (extraction, the
# tutor, TTS, and so on) are chosen by an administrator in Settings → AI
# models; this is the fallback when a job has no model of its own.
LITELLM_MODEL=gpt-4o-mini
LITELLM_API_KEY=your-api-key-here
LITELLM_API_BASE=
# The embedding model, and the one model setting that is NOT editable in the
# interface. Every stored vector was produced by it, and vectors from different
# models are not comparable — change this and search returns noise until every
# question, article and card has been re-embedded. That is a deployment, so it
# lives here. `EMBEDDING_DIMENSIONS` must match what the model returns.
LITELLM_EMBEDDING_MODEL=
EMBEDDING_DIMENSIONS=1024
# The cross-encoder that reorders search results. Unset, unreachable or
# malformed and the fused ranking is returned untouched — never fewer results.
LITELLM_RERANK_MODEL=cohere-rerank-v4.0-pro
# Direct provider keys, used only where the proxy does not carry the service.
OPENAI_API_KEY=
ELEVENLABS_API_KEY=
GOOGLE_TTS_API_KEY=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=us-east-1
AWS_BEDROCK_REGION=us-east-1
# Self-hosted speech, for TTS and dictation without leaving the machine.
LOCAL_SPEECH_GATEWAY_URL=http://local-speech-gateway:8110
# ── Where the site lives ───────────────────────────────────────────────
# Used in the links inside emails, so a wrong value sends people nowhere.
APP_URL=https://pedshub.com
LOG_LEVEL=INFO
# ── Storage ────────────────────────────────────────────────────────────
# `local` keeps uploads on the volume at UPLOAD_DIR; `s3` puts them in a
# bucket and reads them back through it. Thumbnails live beside the original
# either way.
STORAGE_BACKEND=local
UPLOAD_DIR=/app/uploads
MAX_UPLOAD_SIZE=524288000
S3_ENDPOINT_URL=http://minio:9000
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_BUCKET=pedshub-media
S3_REGION=us-east-1
# Page chunks for extraction context.
CHROMA_PERSIST_DIR=/app/chroma_data
# ── Email ──────────────────────────────────────────────────────────────
# With MAIL_USERNAME or MAIL_FROM blank, mail is logged instead of sent — which
# also means sign-in codes and verification links go nowhere.
MAIL_USERNAME=your-email@example.com
MAIL_PASSWORD=your-app-password
MAIL_FROM=your-email@example.com
MAIL_PORT=587
MAIL_SERVER=smtp.gmail.com
MAIL_STARTTLS=true
MAIL_SSL_TLS=false
# Where the contact form's messages land.
ADMIN_EMAIL=
# ── Bot protection ─────────────────────────────────────────────────────
# Cap, self-hosted beside the app. Leave the secret blank to disable the
# challenge; sign-up then accepts submissions without one.
CAP_SECRET_KEY=
CAP_SITE_KEY=
CAP_API_URL=http://cap:3000
# ── Single sign-on ─────────────────────────────────────────────────────
# Any OIDC provider. Blank means the site uses its own accounts only.
OIDC_PROVIDER_URL=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_SCOPES=openid email profile
OIDC_PROVIDER_NAME=SSO
# ── Live sessions ──────────────────────────────────────────────────────
BBB_SERVER_URL=
BBB_SECRET=
# ── Clinical corpus (read-only) ────────────────────────────────────────
# A separate Milvus collection the assistant may query but never write to.
CLINICAL_MILVUS_URI=
CLINICAL_MILVUS_TOKEN=
CLINICAL_MILVUS_COLLECTION=mcp_bge_m3_1024