Skip to content

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.* and api.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