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/
├── assistant/ # Local-only chat assistant (agent, RAG, model clients); see its README
├── 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
├── 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
│ ├── assistant.py # /api/v1/assistant/* (local-only, registered when ASSISTANT_ENABLED)
│ └── 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¶
The sections below cover the routes of a deployed service. The assistant's two routes,
GET /api/v1/assistant/config and POST /api/v1/assistant/chat, are not listed here. They exist
only when ASSISTANT_ENABLED=true, and a deployed profile refuses to start with that setting on,
so on a deployed service every request that reaches routing answers 404 for the whole
/api/v1/assistant prefix. Their behavior is in the
assistant README.
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 |
Bot check. Four of these endpoints are open to the internet and each one costs something
real: an account, a password guess, or an email sent from this domain. They therefore require
a Cloudflare Turnstile token in the X-Turnstile-Token request header — register, login,
forgot-password and resend-verification. The token is verified against Cloudflare's
siteverify, including the action that minted it and the hostname that served the widget, and
anything short of a confirmed pass answers 403: a missing header, a rejected token, and an
unreachable siteverify are one indistinguishable refusal, so nothing about the address or the
account behind it leaks through the refusal. That the check exists at all is not a secret — the
widget on the form says so — but the header is still optional in the schema deliberately, since a
required one would answer 422 and separate "no token" from "bad token". TURNSTILE_ENABLED=false removes the check entirely,
which is what keeps local development and the test suite offline; a deployed service in that
state still starts, and records turnstile_disabled at ERROR while it does.
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. For an
address that already has a confirmed account, the owner gets a notice instead of a link, in the
language the Accept-Language header names, like the activation email.
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. Three endpoints read the Accept-Language request header and write
their email in English or Czech: register (the activation link, or the notice to an existing
account's owner), resend-verification (the activation link) and forgot-password (the reset
link). The best-weighted cs or en range wins, and a missing or unusable header means English.
The header never changes the response, so a bad value is not an error. 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 |
An invitation is not emailed. The invitee finds it in their list of pending invitations.
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. On a developer machine the local assistant's hourly counter uses it too when it is set |
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 |
TURNSTILE_ENABLED |
false |
Bot check on the four open auth endpoints. A deployed service starts with it off, and records turnstile_disabled at ERROR: it is the way out of a Cloudflare siteverify outage, since the check is fail-closed |
TURNSTILE_SECRET_KEY |
required when the check is on | Cloudflare Turnstile secret, paired with the widget whose sitekey the frontend build carries |
TURNSTILE_HOSTNAMES |
[], required when the check is on |
JSON array of hostnames the widget may be served from; a token minted anywhere else is refused. Never list localhost on production |
EMAIL_PROVIDER |
console |
Sender for the activation, password-reset, and existing-account notice emails: http (Resend) delivers them, console delivers nothing. console logs each link, cutting any token to its first 8 characters, and prints the full link to stdout. http without EMAIL_API_KEY falls back to console. A deployed service refuses to start unless the provider is http and EMAIL_API_KEY is set |
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 |
EMAIL_API_URL |
https://api.resend.com/emails |
Endpoint the http sender posts to |
EMAIL_FROM |
[email protected] |
Sender address; with http it must be on a domain verified in Resend |
EMAIL_FROM_NAME |
BeCoMe |
Sender display name |
FRONTEND_BASE_URL |
http://localhost:5173, required when deployed |
Frontend origin every emailed link is built from. A deployed service refuses to start while its host is localhost, 127.0.0.1, ::1, or empty |
PASSWORD_RESET_TOKEN_TTL_MINUTES |
60 |
How long a password-reset link stays valid |
EMAIL_VERIFICATION_TOKEN_TTL_HOURS |
24 |
How long an activation link stays valid |
DISPOSABLE_EMAIL_BLOCKING_ENABLED |
true |
Registration rejects known disposable-mail domains; false turns the check off |
MX_CHECK_ENABLED |
true |
Registration rejects a domain that does not exist, has no MX, A, or AAAA record, or publishes a null MX (RFC 7505), even beside an A record. A timed-out or failed lookup lets the address through. false turns the check off |
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) |
ASSISTANT_ENABLED |
false |
Registers the local-only assistant routes. Startup fails when it is true on a deployed profile or on Railway. The other ASSISTANT_* variables are listed and commented in env/.env.example. The assistant README covers setup and limits |
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 --env-file .env -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 (needs `uv sync --extra dev --extra api --extra assistant`)
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