BeCoMe REST API¶
FastAPI-based REST API for the BeCoMe group decision-making method.
Contents¶
Quick start¶
uv sync --extra api
uv run uvicorn api.main:app --reload
The API runs at http://localhost:8000. Interactive documentation:
- Swagger UI: /docs
- ReDoc: /redoc
Module structure¶
api/
├── auth/ # Authentication & authorization
│ ├── jwt.py # Token creation/validation (rotation family / sid)
│ ├── password.py # Password hashing (bcrypt)
│ ├── cookies.py # HttpOnly session cookies + CSRF token helpers
│ ├── dependencies.py # CurrentUser dependency (cookie or Bearer)
│ ├── revocation_store.py # Token revocation store (in-memory / Redis)
│ ├── login_throttle.py # Per-account lockout after failed logins
│ ├── email_throttle.py # Per-address cooldown for reset and activation emails
│ └── logging.py # Auth event logging
├── db/ # Database layer
│ ├── models.py # SQLModel entities
│ ├── engine.py # Database engine setup
│ ├── session.py # Session dependency
│ └── utils.py # UTC helpers, email regex
├── middleware/ # Request processing
│ ├── rate_limit.py # SlowAPI rate limiting (logs violations)
│ ├── csrf.py # Session-bound CSRF check on cookie mutations
│ ├── body_size.py # Request body size limit (413)
│ ├── security_headers.py # Security response headers
│ ├── request_logging.py # Request/response logging + X-Request-ID
│ └── exception_handlers.py # Centralized errors + catch-all 500
├── routes/ # HTTP endpoints
│ ├── auth.py # /api/v1/auth/*
│ ├── users.py # /api/v1/users/*
│ ├── projects.py # /api/v1/projects/*
│ ├── opinions.py # /api/v1/projects/{id}/opinions
│ ├── invitations.py # /api/v1/invitations/*
│ ├── calculate.py # /api/v1/calculate
│ └── health.py # /api/v1/health
├── schemas/ # Pydantic DTOs
│ ├── auth.py # Login, register, tokens
│ ├── project.py # Project CRUD
│ ├── opinion.py # Expert opinions
│ ├── invitation.py # Project invitations
│ ├── calculation.py # BeCoMe calculation I/O
│ └── ...
├── services/ # Business logic
│ ├── user_service.py
│ ├── project_service.py # + membership / query services
│ ├── opinion_service.py
│ ├── invitation_service.py
│ ├── calculation_service.py
│ ├── password_reset_service.py
│ ├── data_export_service.py # GDPR export
│ ├── email/ # Email delivery (console / HTTP provider)
│ ├── export/ # Result export renderers
│ └── storage/ # File storage (Railway bucket, S3)
├── utils/ # Utilities
│ ├── sanitization.py # HTML sanitization
│ ├── client_ip.py # Real client IP behind trusted proxies
│ ├── upload.py # Size-capped streaming reads
│ └── photo_links.py # Photo proxy URL builder
├── config.py # Settings (Pydantic Settings) + deploy invariants
├── pagination.py # Shared limit/offset params (cap 100)
├── logging_config.py # Centralized logging + JSON formatter (test/prod)
├── logging_context.py # Request-scoped correlation (request_id/user_id) via contextvars
├── dependencies.py # DI factories + authorization
├── exceptions.py # API exception hierarchy
└── main.py # FastAPI application (+ setup_logging, Sentry init)
API endpoints¶
Authentication¶
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/auth/register |
Register new user, email an activation link |
| POST | /api/v1/auth/verify-email |
Confirm an address with an activation token and its password |
| POST | /api/v1/auth/resend-verification |
Request a fresh activation link for an address |
| POST | /api/v1/auth/login |
Login, get tokens |
| POST | /api/v1/auth/logout |
Revoke refresh token |
| POST | /api/v1/auth/refresh |
Refresh access token |
| POST | /api/v1/auth/forgot-password |
Request a password reset email |
| POST | /api/v1/auth/reset-password |
Reset password using a token |
| GET | /api/v1/auth/me |
Get current user profile, plus the session's CSRF token as a header |
Registration and activation. POST /auth/register always answers 202 with the same
body, whether the address is free, already registered but unverified, or already registered
and verified. The response never reveals which. The account it creates cannot log in until
someone redeems the emailed link through POST /auth/verify-email. Until then
POST /auth/login answers 403 with a distinct detail, so a client can offer a resend.
A submission takes effect only when someone follows its own link and restates its own
password. The password hash and names travel on the activation token, so registering an
unconfirmed address twice leaves two working links, and whichever one someone redeems
first decides
the credentials the account opens with. POST /auth/verify-email therefore takes
{token, password}: an unknown, spent, or expired token gets one opaque 400, while a
password that does not match the token gets a 403 with its own detail, so a client can
ask the user to retype instead of sending them off for a new link. Mismatches count against
their own per-token lockout, namespaced apart from login's. A run of failed logins can never
deny someone their own activation, and burning one token's budget can never lock a different,
freshly resent token for the same account. The two budgets are independent, so
they add up: 10 failures each per 15 minutes, and only a caller already holding a live
emailed token can spend the activation half. A mismatch also spends from the login lockout,
which costs the guesser rather than capping the pair. A completed password reset clears the
login lockout, and answers the same opaque 400 an unusable token gets when an activation
confirmed the account while the reset was in flight. POST /auth/resend-verification takes
{email, password} and answers 202 for any address. The link it mails carries the submitted
password like any other. See docs/security.md for why each branch behaves as it
does.
Session transport. Login and refresh set the access and refresh tokens as
Secure; HttpOnly; SameSite=Strict cookies (the refresh cookie stays scoped to
/api/v1/auth) plus a readable csrf_token cookie. The response body carries the same tokens,
so programmatic clients can keep using the Authorization: Bearer header.
A cookie-authenticated mutating request (POST/PUT/PATCH/DELETE) must send that value back
in an X-CSRF-Token header. Bearer-header requests are exempt. /auth/refresh reads the
refresh token from the cookie or the body, and logout revokes the session and clears the
cookies.
The token is derived from the session, not compared against the cookie: it is an HMAC
of the session's sid under SECRET_KEY, and the middleware recomputes the expected value
from the session cookie the request authenticates as. Anyone able to write cookies for this
host can plant a csrf_token they know, or one minted for a session they hold. That includes a
page on a sibling becomify.app subdomain, which SameSite counts as same-site. Neither
cookie matches what the server derives for the victim's session. The check also keys on the
session cookie, so omitting the CSRF cookie cannot waive it.
Login and refresh return the value in an X-CSRF-Token response header, and
GET /auth/me reports the token for the session it authenticates as, so a page reload
recovers it, and a request with no session cookie gets no header. The header is not redundant.
The cookie has no Domain attribute, so it belongs to the API host, and a browser app served
from any other host cannot read it out of document.cookie even though the browser keeps
sending it. The header is that app's only copy of the value, which is why
CORSMiddleware lists it under expose_headers as well as allow_headers. A refresh
stays in the same rotation family, so the token does not change across refreshes. A fresh login
starts a new family and a new token. See docs/security.md for why the cookie is not
widened with a Domain instead, and for what this does not cover.
Users¶
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/users/me |
Get profile |
| GET | /api/v1/users/me/export |
Export all personal data as JSON (GDPR Art. 20) |
| PUT | /api/v1/users/me |
Update profile |
| PUT | /api/v1/users/me/password |
Change password |
| POST | /api/v1/users/me/photo |
Upload photo (JPEG/PNG/GIF/WebP, max 5 MB and 4096x4096 px) |
| DELETE | /api/v1/users/me/photo |
Delete photo |
| GET | /api/v1/users/{id}/photo |
Serve a profile photo from the private bucket (public) |
| DELETE | /api/v1/users/me |
Delete account, handling each owned project (GDPR Art. 17) |
The photo proxy is public, because an <img> tag cannot send an auth header. It passes
the bucket's response straight through to the client instead of downloading the object
first, so the wait before the first byte is one bucket round trip rather than a full
download. The URL that reaches it carries a ?v= token taken from the stored object key, and
every upload mints a new key.
The route always serves whichever photo the account holds now, so the cache header depends on
whether the request named that photo. A v matching the current key describes bytes that
cannot change under it, so the route serves it with
Cache-Control: public, max-age=31536000, immutable. Anything else gets max-age=300: no
token, a stale one, or an invented one. Those URLs already resolve to different bytes than they
once did, and they will again. Pinning one for a
year would leave a shared cache handing out a replaced avatar to everyone who asked for it.
Deleting the account (DELETE /api/v1/users/me) accepts an optional body that says what
to do with every project the user still admins: transfer it to another member, or delete
it. Erasure then never silently drops other experts' contributions:
{ "project_dispositions": [
{ "project_id": "<uuid>", "action": "transfer", "new_admin_id": "<member-uuid>" },
{ "project_id": "<uuid>", "action": "delete" }
] }
An owned project left without a disposition returns 409, and a transfer to a non-member
returns 422. Erasure also removes the profile photo blob from object storage.
Projects¶
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/projects |
List user's projects |
| POST | /api/v1/projects |
Create project |
| GET | /api/v1/projects/{id} |
Get project details |
| PATCH | /api/v1/projects/{id} |
Update project (partial; every field except description is non-nullable, so an explicit null is a 422) |
| DELETE | /api/v1/projects/{id} |
Delete project |
| GET | /api/v1/projects/{id}/members |
List members |
| DELETE | /api/v1/projects/{id}/members/{user_id} |
Remove member (discards their opinion and recalculates) |
| POST | /api/v1/projects/{id}/transfer-ownership |
Transfer ownership to another member |
| POST | /api/v1/projects/{id}/invite |
Invite user |
| GET | /api/v1/projects/{id}/invitations |
List the project's pending invitations |
Opinions¶
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/projects/{id}/opinions |
List opinions |
| POST | /api/v1/projects/{id}/opinions |
Submit opinion |
| DELETE | /api/v1/projects/{id}/opinions |
Delete own opinion |
Invitations¶
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/invitations |
List pending invitations |
| POST | /api/v1/invitations/{id}/accept |
Accept invitation |
| POST | /api/v1/invitations/{id}/decline |
Decline invitation |
Calculation¶
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/calculate |
Calculate BeCoMe (standalone) |
| GET | /api/v1/projects/{id}/result |
Get project calculation result |
| GET | /api/v1/projects/{id}/result/export |
Download the result as PDF or CSV |
Health¶
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/health |
API health check |
Pagination. List endpoints (projects, opinions, members, invitations) accept optional
limit and offset query parameters. limit tops out at 100 and defaults to the first page,
so responses stay bounded. The GDPR export is the one exception: it always returns the full
dataset.
Configuration¶
Environment variables (a .env file works too):
| Variable | Default | Description |
|---|---|---|
APP_ENV |
dev |
Deployment profile: dev, test, or prod. Selects the .env.<APP_ENV> overlay; deployed profiles reject a weak secret, SQLite, a missing Redis, or localhost CORS at startup. See Environments. |
DATABASE_URL |
sqlite:///./become.db (fallback; local development uses the Docker PostgreSQL) |
Database connection string. On deployed environments this is the least-privilege become_app role. |
MIGRATION_DATABASE_URL |
required when deployed | Privileged connection used only by Alembic migrations (DDL); falls back to DATABASE_URL locally, but startup fails without it on a deployed service so the least-privilege split is never silently lost. |
SECRET_KEY |
required | JWT signing key (generate with openssl rand -hex 32) |
LOG_HASH_KEY |
optional | Key for the email tags in security logs; falls back to SECRET_KEY. Set it separately to keep tags comparable across a secret rotation. |
ACCESS_TOKEN_EXPIRE_MINUTES |
15 |
Access token TTL |
REFRESH_TOKEN_EXPIRE_DAYS |
7 |
Refresh token TTL |
DEBUG |
false |
Debug mode; must stay off on a deployed service (startup fails otherwise) |
API_VERSION |
1.0.0b1 |
API version (auto-read from pyproject.toml) |
CORS_ORIGINS |
http://localhost:3000,http://localhost:8080 |
Allowed CORS origins |
REDIS_URL |
required when deployed | Redis for rate limiting, token revocation, and auth throttles |
CLOUDFLARE_ORIGIN_SECRET |
required when deployed | Shared secret proving the request came through Cloudflare; every deployed environment sits behind it, so each needs its own value paired with a Transform Rule for that environment's API host |
EMAIL_PROVIDER |
console |
Password-reset email delivery: console (log) or http (Resend) |
EMAIL_API_KEY |
required when deployed | API key for the http email provider; startup fails without it on every deployed service, where the console fallback would print reset links to stdout instead of sending them |
API_PUBLIC_URL |
http://localhost:8000 |
Public base URL of this API, used to build profile photo proxy links |
BUCKET_NAME |
optional | Railway Storage Bucket name (auto-injected when a bucket is attached) |
BUCKET_ENDPOINT |
optional | S3-compatible bucket endpoint |
BUCKET_ACCESS_KEY_ID |
optional | Bucket access key |
BUCKET_SECRET_ACCESS_KEY |
optional | Bucket secret key |
LOG_LEVEL |
per profile: DEBUG on dev, INFO on test and prod |
Log verbosity (DEBUG/INFO/WARNING/ERROR/CRITICAL); an explicit value always wins. A local shell emits text, every deployed service emits JSON |
LOG_FILE |
optional | Path for a rotating log file (console logging is always on) |
SENTRY_DSN |
optional | Sentry DSN for backend error tracking (disabled when unset) |
BETTERSTACK_SOURCE_TOKEN |
optional | Better Stack log source token (ships api.* logs when set together with the host below) |
BETTERSTACK_INGESTING_HOST |
optional | Better Stack ingesting host for log shipping (per-environment source) |
Profile photos live in a private Railway Storage Bucket (S3-compatible), served through the
GET /api/v1/users/{id}/photo proxy. When the bucket variables are absent, photo upload is
disabled and every other feature keeps working.
Migrations. Alembic owns the PostgreSQL schema (migrations/), and alembic upgrade head
runs before each Railway deploy. To apply it by hand against one database, run
ALEMBIC_DATABASE_URL=<url> uv run alembic upgrade head. Local development runs on the same
engine, the PostgreSQL in docker/docker-compose.yml (docker compose -f docker/docker-compose.yml up -d db), so a migration is exercised
before it reaches a deployment rather than after. SQLite still works as a fallback and for the
test suite, but it reaches the schema through create_all and skips migrations entirely.
Observability. Every request gets an X-Request-ID response header for log correlation,
either generated or echoed from the client's header. A ContextFilter binds that ID and the
acting user through contextvars, so every api.* record carries request_id and user_id,
service and security logs included, not just the request line.
Under the api.* loggers, the API logs:
- each request and its timing
- every mutating domain action (
api.service.*andapi.route.*) - every refusal, whether it is a CSRF rejection, an over-large body, a rejected token, a denied invitation, or a refused photo upload
- at
DEBUG, the reads and the outbound calls to Redis, S3, and the email provider, with their timings
Records carry structured extra fields under an event name rather than free text. Output is
JSON on every deployed service, the Railway dev service included, since it is a deploy and not
a laptop. A drain can then index event, request_id, status_code, and duration_ms.
Five third-party loggers share those handlers at pinned levels (_EXTERNAL_LOG_LEVELS in
api/logging_config.py). uvicorn.error sits at INFO, so a boot that never finished is visible
in the drain. httpx and botocore sit at WARNING, since email_sent and s3_upload already
cover their successful calls. uvicorn.access stays silent on purpose, because api.request
logs the same requests with more fields. sqlalchemy.engine is pinned at WARNING and must
stay there: its DEBUG level prints bound query parameters, which on this schema means password
hashes, addresses, and reset-token hashes going to the drain in the clear. The read services log
query shape and timing instead. If drain volume from dev's DEBUG stream becomes a problem, call
logtail_handler.setLevel(logging.INFO) in _build_handlers, where the handler is built. The
console keeps DEBUG, the drain does not.
Unhandled exceptions return an opaque 500 and reach Sentry when SENTRY_DSN carries a value.
With the BETTERSTACK_* variables in place, the API also ships its logs to Better Stack through
logtail-python, one source per environment.
Testing¶
The test suite includes:
- Unit tests: auth, middleware, schemas, services, utilities (tests/unit/api/)
- Integration tests: auth flows, database, route handlers (tests/integration/api/)
- End-to-end tests: full API workflows (tests/e2e/)
# Run all API tests
uv run pytest tests/unit/api/ tests/integration/api/ -v
# Run with coverage
uv run pytest tests/unit/api/ tests/integration/api/ --cov=api --cov-report=term-missing
Related documentation¶
- Main README: project overview
- docs/security.md: application and database security posture
- Environments: the dev, test, and prod profiles, Railway deployment, and database topology