Files
tavolo/server/README.md
T
woggioni ed90a2a5a6
CI / Build and push docker image (push) Successful in 7m1s
Base the release image on scratch
Build the backend as a fully static musl binary (rust:1-alpine +
x86_64-unknown-linux-musl) and run it in a scratch image with only the
binary and the compiled frontend: 32 MB instead of ~110 MB, no shell,
package manager or libc, running as the numeric user 10001:10001.

The binary moves to /usr/local/bin/tavolo so `tavolo migrate` and
`tavolo serve` resolve on PATH in compose and Kubernetes. TLS for OIDC and
Postgres validates against Mozilla's roots embedded in the binary by
webpki-roots, so no certificate files are needed; the OTLP client is built
without a TLS backend, so OTEL_EXPORTER_OTLP_ENDPOINT must be http://
(documented). The Docker HEALTHCHECK is removed since scratch has no curl:
Kubernetes probes /api/health over HTTP and Compose gates on its dependency
checks. `strip = "symbols"` trims the release binary.

Validated with docker compose up --build: migrations, sessions, lobby
creation, the OIDC discovery redirect and the SPA all work, proving musl DNS
resolution for the postgres/redis/mockoauth service names.
2026-10-02 14:34:58 +00:00

449 lines
24 KiB
Markdown

# tavolo (backend)
The backend for a multiplayer card-game platform, written in Rust with
[Axum](https://github.com/tokio-rs/axum). It is a Cargo workspace of three
crates mirroring the original Python layout:
- **`crates/tavolo-platform/`** — everything game-independent: the lobby API,
the live-game store, the WebSocket endpoint, the deadline scheduler, match
persistence and the Elo leaderboards, plus the `GameEngine` contract games
implement.
- **`crates/tavolo-scopone/`** — scopone scientifico, the four-player,
fixed-partnership variant of the classic Italian card game: pure rules plus
the platform adapter.
- **`crates/tavolo-app/`** — the composition root: environment-driven
settings, OIDC login, sessions, CORS, OpenTelemetry, SPA hosting and the
`tavolo` binary (`serve` / `migrate` / `backfill-elo`).
Players authenticate with the configured **OIDC** provider. Live game state
is kept in **Redis** (with real-time play over WebSocket), and completed
matches — every participant and the game-specific result — are persisted to
**Postgres** for history and leaderboard statistics. The HTTP/WebSocket
contract is byte-compatible with the SPA in `../web/`.
## Quick start (Docker Compose)
```sh
# From the repository root (docker-compose.yml lives there):
docker compose up --build
```
The stack starts Postgres, Redis, a mock OIDC provider
(navikt/mock-oauth2-server) with four ready-made players
(`alice`, `bob`, `carol`, `dave`), a one-shot database migration service
(`tavolo migrate`) and the app itself on `http://127.0.0.1:8080`. The same
container also serves the Sycamore/WASM frontend (built from `../web/` by the
Docker image), so the UI is available at that address.
Because the browser and the app both talk to the OIDC issuer at
`http://mockoauth:8180/tavolo`, add a host entry once:
```sh
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
```
Then log in: `GET http://localhost:8080/auth/login` (a provider-agnostic
alias that redirects to the first configured provider), type any username
(e.g. `alice`) in the interactive login, and you are redirected back with a
session cookie. That cookie authenticates both the REST API and the
WebSocket endpoint.
OpenAPI documentation is served at `/api/docs` (`/api/openapi.json`).
The release image is a **scratch image** containing a fully static musl
binary and the compiled frontend: no shell, package manager or libc. It runs
as the numeric user `10001:10001` and has no Docker `HEALTHCHECK` (scratch has
no `curl`); Kubernetes probes `/api/health` over HTTP, and Compose gates on
the Postgres/Redis/OIDC dependency checks. To debug a running container use
`docker debug` (Docker Desktop) or `kubectl debug` with an ephemeral
container.
## Configuration
All configuration comes from environment variables (see `.env.example`):
| Variable | Default | Description |
|---|---|---|
| `DATABASE_ENGINE` | `postgres` | Database DSN scheme/driver (Postgres only) |
| `DATABASE_HOST` | `localhost` | Postgres host |
| `DATABASE_PORT` | unset | Postgres port; omitted from the DSN when empty (driver default, 5432) |
| `DATABASE_NAME` | `tavolo` | Postgres database name |
| `DATABASE_USER` | `tavolo` | Postgres user |
| `DATABASE_PASSWORD` | `password` | Postgres password |
| `DATABASE_OPTIONS` | unset | Extra DSN query parameters, e.g. `ssl=require` |
| `DATABASE_URL` | unset | Full-DSN override; when set, the `DATABASE_*` parts above are ignored (managed-DB URLs) |
| `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores |
| `OIDC_<n>_PROVIDER` | `default` ¹ | Provider name (required when OIDC is configured): used in the login URL `/auth/<provider>/login` and to namespace its session keys. Unique, `[A-Za-z0-9_-]+` |
| `OIDC_<n>_ISSUER` | `http://localhost:8180/tavolo` ¹ | OIDC issuer URL |
| `OIDC_<n>_CLIENT_ID` | `tavolo` ¹ | OIDC client id |
| `OIDC_<n>_CLIENT_SECRET` | unset | OIDC client secret; leave unset for public clients |
| `OIDC_<n>_REDIRECT_URI` | `http://localhost:8080/auth/<provider>/callback` | Login callback URL; must be registered at the provider |
| `OIDC_<n>_POST_LOGIN_REDIRECT` | `/` | Where the browser is sent after login |
| `OIDC_<n>_POST_LOGOUT_REDIRECT` | `/` | Where the browser is sent after logout |
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the scopone between-hands scoring summary waits for acknowledgements (wired into the `ScoponeEngine`) |
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a scopone player has to play before the server plays a random legal card for them (wired into the `ScoponeEngine`) |
| `DEADLINE_HEARTBEAT_MS` | `30000` | Backstop poll interval of the deadline consumer; enqueued deadlines are announced over pub/sub and wake consumers immediately, so this only bounds recovery from lost notifications or a dead worker |
| `LOGGING_CONFIG` | unset | Accepted for compatibility; **ignored** (the Rust server logs via `RUST_LOG`) |
| `RUST_LOG` | `debug` (app), INFO+ deps | tracing-subscriber env filter, e.g. `tavolo=info,sqlx=warn` |
| `CORS_ALLOW_ORIGINS` | unset | Comma-separated origins allowed for cross-origin requests, or `*` for any. CORS is disabled unless this or `CORS_ALLOW_ORIGIN_REGEX` is set |
| `CORS_ALLOW_ORIGIN_REGEX` | unset | Regex (fullmatch) additionally matched against request origins, e.g. `https://tavolo-[a-z0-9-]+\.vercel\.app` |
| `CORS_ALLOW_METHODS` | `GET` | Comma-separated methods allowed for cross-origin requests, or `*` for all |
| `CORS_ALLOW_HEADERS` | unset | Comma-separated request headers allowed in cross-origin requests, or `*` to mirror back the requested ones. The CORS-safelisted headers are always allowed |
| `CORS_ALLOW_CREDENTIALS` | `false` | `1`/`true`/`yes`/`on` allow cookies/credentials on cross-origin requests |
| `CORS_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser |
| `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response |
| `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces, metrics and logs (compiled in by default) |
| `OTEL_SERVICE_NAME` | `tavolo` | `service.name` resource attribute of the exported telemetry |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Base URL of an OTLP/HTTP collector (e.g. `http://localhost:4318`; unset uses that default). Must be `http://`: the OTLP client is built without a TLS backend |
| `OTEL_EXPORTER_OTLP_HEADERS` | unset | Comma-separated `key=value` headers sent to the collector (e.g. authentication) |
| `OTEL_EXCLUDED_PATHS` | `/api/health` | Comma-separated paths excluded from tracing and metrics (exact matches) |
| `OTEL_METRIC_EXPORT_INTERVAL_MILLIS` | `60000` | How often the OTLP metric reader exports |
| `OTEL_METRICS_POLL_INTERVAL_SECONDS` | `15` | How often the session/game count gauges are refreshed |
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
| `STATIC_DIR` | `web/dist` | Directory holding the compiled frontend (the Docker image sets `/app/web/dist`) |
¹ When no `OIDC_<n>_*` variable is set at all, a single provider named
`default` pointing at the dev mock IdP is used. Indices must be contiguous
from 0 (`OIDC_0_*`, `OIDC_1_*`, ...).
### Multiple OIDC providers
Providers are configured in indexed blocks; each becomes its own login route
`/auth/<provider>/login` with its own callback URL and session namespace:
```sh
OIDC_0_PROVIDER=google
OIDC_0_ISSUER=https://accounts.google.com
OIDC_0_CLIENT_ID=...
OIDC_0_CLIENT_SECRET=...
OIDC_0_REDIRECT_URI=https://tavolo.example.com/auth/google/callback
OIDC_1_PROVIDER=github
OIDC_1_ISSUER=https://github.com/login/oauth
OIDC_1_CLIENT_ID=...
OIDC_1_CLIENT_SECRET=...
OIDC_1_REDIRECT_URI=https://tavolo.example.com/auth/github/callback
```
The login page reads `GET /api/auth/providers` and shows one button per
provider. `GET /auth/login` redirects to the first provider (useful for
bookmarks and scripts), and `GET /auth/logout` signs out whichever provider
the session is logged in with. Sessions hold a single identity: logging in
with another provider replaces the previous one.
## Logging
The Rust server logs through `tracing`, configured with `RUST_LOG`
(`tracing-subscriber` env-filter syntax). The default filter is `debug` for
the application with noisy dependencies capped (`sqlx`, `redis`, `hyper`,
`reqwest` at WARN). `LOGGING_CONFIG` (a Python `logging.config.dictConfig`
YAML path) is accepted so existing deployments do not break, but its value is
ignored with a warning.
Lifecycle and business events are logged at INFO (game created/joined,
websocket connections, match results, auto-plays); per-move and store detail
at DEBUG.
## OpenTelemetry
With `OTEL_ENABLED` set, the app exports over OTLP/HTTP:
- **Traces**: one span per HTTP request and one per WebSocket connection,
tagged with the request's session and user: `session.id` is a truncated
SHA-256 of the session id (the cookie is a credential, so it is never
exported verbatim) and `user.id` is the OIDC `sub`, present once logged in.
Database-client auto-instrumentation is not included.
- **Metrics**: the `http.server.request.duration` histogram, plus
`tavolo.active_users` (sessions in the session store),
`tavolo.ongoing_games` (live matches not finished yet), and process metrics:
`process.cpu.time`, `process.cpu.utilization`, `process.memory.usage`,
`process.memory.virtual`. The Python build's CPython gc metrics have no
Rust equivalent.
- **Logs**: tracing events forwarded to OTLP (OpenTelemetry's own records are
filtered out to avoid export loops).
The session/game gauges are polled every
`OTEL_METRICS_POLL_INTERVAL_SECONDS` (Redis-backed stores report
cluster-wide counts, in-memory stores the process's own). With multiple
replicas each process reports the same global counts: aggregate with `max`,
not `sum`.
The OTLP client is built without a TLS backend, so
`OTEL_EXPORTER_OTLP_ENDPOINT` must use `http://`; terminate TLS in front of
the collector (or run a local sidecar) if the collector is remote. The
OIDC/Postgres clients do not use this client and validate HTTPS against
Mozilla's roots embedded in the binary (no certificate files are needed, and
private CAs are not trusted by them).
## Data model
### Redis (live games)
- `tavolo:game:<uuid>` — the whole session as JSON: the platform-owned
envelope (`id`, `game_type`, `join_code`, seats, timestamps) plus the
game-specific `state` blob, which only the registered engine interprets.
Sliding TTL (`GAME_TTL_SECONDS`). The layout is identical to the original
Python implementation, so in-flight games survive the backend swap.
- `tavolo:code:<JOINCODE>` — the 6-character join code → game id index.
- `tavolo:game:<uuid>:lock` — a short-lived lock serializing every mutation.
- `tavolo:game:<uuid>:events` — a pub/sub channel carrying "state changed"
signals; every open WebSocket reloads the session and pushes the
personalized view to its player.
- `tavolo:deadlines` — a sorted set (score = due timestamp) of pending
timeouts (scopone's turn auto-plays and hand-end auto-continues). Every
worker runs a consumer that fires due entries under the per-game lock by
calling the engine's `fire_deadline`, so timeouts do not depend on any
player being connected and survive the death of any worker (delivery is
at-least-once; engines revalidate the entry's token against the live state
before firing).
- `tavolo:deadlines:events` — a pub/sub channel announcing newly enqueued
deadlines; it wakes every worker's consumer immediately, while the
heartbeat poll remains as a backstop for lost signals.
- `tavolo:games:ongoing` — sorted set of running games for the
`tavolo.ongoing_games` gauge.
Sessions live under `tavolo:session:<id>` as JSON (the Python server used
pickled values under `kaya:session:`; existing sessions are invalidated once
on cutover, users log in again).
### Postgres (statistics, via sqlx migrations)
- `match` — one row per finished match: the game played (`game_type`,
indexed so statistics can be scoped per game), start/finish timestamps, and
the game-specific outcome as reported by the engine (`result` JSON — for
scopone: both teams' final scores, winner, target score, hands played and
the per-hand audit).
- `match_player` — one row per participant: the OIDC `sub`, display name,
seat, game-defined team label (nullable), whether they won, the points they
scored, the Elo change the match produced (`elo_delta`) and game-specific
extras (`details` JSON). Unique per `(match, user_sub)`.
- `player_rating` — current Elo rating per `(user_sub, game_type)`, with the
number of rated matches played.
When a match ends, the engine's result is written transactionally to
Postgres (once, guarded by a flag on the session); the finished session stays
in Redis until its TTL expires so clients can still fetch the final board.
### Elo ratings
Players carry a chess-style Elo rating per game type (`tavolo.platform.elo`):
everyone starts at 1500, a team's rating is the mean of its members, and the
standard formula `E = 1 / (1 + 10 ** ((R_opp - R_team) / 400))` with `K = 32`
decides how many points the match result moves — the same delta for every
member of a team, zero-sum between the two teams, with Python-compatible
banker's rounding. Ratings update in the same transaction as the match
result. To recompute every rating from the recorded match history:
```sh
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo \
cargo run -p tavolo-app -- backfill-elo
# or, in the container image:
docker compose run --rm tavolo tavolo backfill-elo
```
## REST API
All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
`/api/game-types` and `/api/leaderboard` require authentication.
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
| `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown |
| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) |
| `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
| `GET` | `/api/me/ratings` | The caller's Elo rating per game type |
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / points per player, sorted by Elo (`?game_type=`) |
## WebSocket protocol
Connect to `/ws/games/{game_id}` with your session cookie. Only seated
players are accepted; the upgrade is refused before the handshake completes
with an HTTP status mirroring `GET /api/games/{id}`: `401` unauthenticated,
`404` unknown game, `403` not seated. (Browsers report a refused handshake as
an abnormal close — there is no close code to read.)
Server → client messages are JSON objects with a `type`:
- `{"type": "state", "game": {...}}` — personalized game view. Your own hand
is included; other players expose only their card counts. `your_turn: true`
is present when it is your move.
- `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}`
- `{"type": "error", "code": "illegal_move", "message": "..."}`
Client → server messages are JSON objects with an `action`. The platform
handles `state` (resend the snapshot) itself; every other action is
dispatched to the session's game engine with the rest of the message as its
payload. For scopone scientifico:
```json
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
{"action": "play", "card": "07D"}
{"action": "ack"}
{"action": "state"}
```
- `card` is the card you play, rendered as `RRSUIT` (`01A`..`10D`; suits
`D` denari, `C` coppe, `S` spade, `B` bastoni — e.g. `07D` is the
settebello).
- `capture` lists the table cards to take. When a capture is legal it is
mandatory to provide one; when no capture exists it must be omitted.
- `ack` acknowledges the hand-end scoring summary (see below). The next hand
is dealt once all four players have acknowledged, or automatically after
`HAND_ACK_TIMEOUT_SECONDS`.
- `state` asks for a fresh snapshot.
After every accepted move the new state is broadcast to all four players.
### Turn timeout
The state carries a `turn_deadline` while a hand is being played. If the
player on turn does not move before it, the server plays a random legal card
for them (picking one of the legal captures at random when a capture is
required), so a disconnected or idle player cannot stall the match. The
timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
queue, not from timers tied to client connections, so the match keeps
progressing even with every player disconnected.
### Hand-end summary
When a hand finishes but the match continues, the game enters the
`hand_end` phase instead of dealing immediately: the state carries
`last_hand` (a full scoring breakdown with an `award` map naming the team
that won each category), the `acknowledged` seats and a `hand_end_deadline`.
The frontend renders this as a screen every player must dismiss. A play
attempted in this phase is rejected with an `illegal_move` error.
## Rules implemented
- 40-card Italian deck, ten cards per player, empty table at hand start.
- A card captures a **single card of equal rank** (mandatory when present)
or a **combination of table cards whose ranks sum to its own**.
- Emptying the table scores a *scopa* (+1), except on the last play of a
hand. Remaining table cards go to the last player who captured.
- Hand points: `carte` (most captured cards), `denara` (most diamonds),
`settebello` (7♦), `primiera` (best 7/6/5/4 per suit, all four suits
required), plus one point per scopa. Ties award nothing.
- Optional *napola* rule (per-game `napola` creation option, default on):
the longest run of consecutive denari starting from the ace scores one
point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4, …).
A team that captures the whole denari suit (ace to king) wins the match
instantly, regardless of the score.
- Optional *asso pigliatutto* rule (per-game `asso_pigliatutto` creation
option, default off): an ace played when no equal rank is on the table
sweeps every card on the table — or captures itself when the table is
empty. The capture never scores a scopa, but it counts as the last
capture, and the mandatory equal-rank capture still applies when another
ace is on the table.
- The match ends when a team reaches the target score (default 11,
configurable per game via the `target_score` creation option) with a
clear lead; a tie at or above the target is broken by another hand.
## Development
Backend (from `server/`):
```sh
cargo test --workspace # unit + integration suites
cargo run -p tavolo-app # = tavolo serve (needs the env below)
cargo run -p tavolo-app -- migrate
```
For a local run without Docker:
```sh
DATABASE_URL=postgres://tavolo:password@localhost:5432/tavolo \
REDIS_URL=redis://localhost:6379/0 \
cargo run -p tavolo-app
```
Tests run fully in-process: in-memory stores, a `DummyEngine` toy game for
the platform suite, a mock OIDC provider for the login flow, and ephemeral
TCP servers for WebSocket coverage. The Redis- and Postgres-backed tests are
gated on `TEST_REDIS_URL` / `TEST_DATABASE_URL` and skipped when unset.
Frontend (from `web/`), with the backend running on :8080:
```sh
cd web
trunk serve # SPA on http://localhost:8000, /api /auth /ws proxied
```
For the login redirect to land back on the dev server, run the backend with:
```sh
OIDC_0_PROVIDER=default \
OIDC_0_ISSUER=http://localhost:8180/tavolo \
OIDC_0_CLIENT_ID=tavolo \
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback \
OIDC_0_POST_LOGIN_REDIRECT=http://localhost:8000/ \
OIDC_0_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
cargo run -p tavolo-app
```
Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh`
re-downloads them if needed.
### Migrations
The Postgres schema is owned by embedded sqlx migrations in
`crates/tavolo-app/migrations/`, applied by `tavolo migrate` (the
`db-migrate` compose service and the Kubernetes init container). The
migration is idempotent (`CREATE TABLE IF NOT EXISTS`) and matches the
original aerich-created schema, so it can be applied to an existing database
without touching data; the `aerich` bookkeeping table is left alone. To
change the schema, add a new numbered `.sql` file next to the baseline and
run `cargo test -p tavolo-app` (the migration is embedded at compile time).
## Layout
```
crates/tavolo-platform/ # game-independent platform (own crate + tests)
src/
├── engine.rs # the platform<->game contract
├── registry.rs # game id -> engine lookup
├── errors.rs # GameError + platform Error
├── store.rs # Redis / in-memory live-session store (+ deadline queue)
├── deadlines.rs # connection-independent timeout scheduler
├── session.rs # HTTP sessions (in-memory / Redis) + middleware
├── oidc.rs # OIDC discovery, PKCE, JWKS validation, logout
├── auth.rs # session -> user helpers
├── stats.rs # match persistence + history/leaderboard queries + Elo
├── models.rs # sqlx row models
├── elo.rs # chess-style Elo math (1500 start, K=32)
├── pagination.rs # keyset (cursor) pagination
├── http.rs # JSON request/response helpers
├── platform.rs # the collaborators shared by all endpoints
├── routes/ # health / me / auth / games / stats / openapi
└── ws.rs # WebSocket live-play endpoint (generic envelope)
crates/tavolo-scopone/ # scopone scientifico game (own crate + tests)
src/{state,engine,plugin,errors}.rs
crates/tavolo-app/ # deployable app: composition root (own crate)
src/{main,app,config,cors,logging,static_files,telemetry}.rs
migrations/ # embedded sqlx migrations
dev/mockoauth/ # dev OIDC provider image (compose)
Dockerfile # web-builder + musl cargo + scratch runtime
.env.example
```
## Differences from the original Python server
The Python implementation that lived in this directory was replaced by the
Rust one; it remains available in git history. Behavioral compatibility is
intentional and tested (same JSON payloads, status codes, error messages,
Redis layout and Postgres schema), with these documented differences:
| Area | Behavior |
|---|---|
| Sessions | Stored as JSON under `tavolo:session:` (Python: pickle under `kaya:session:`). Existing sessions are invalidated once on cutover; users log in again. |
| Logging | `LOGGING_CONFIG` is accepted but ignored; use `RUST_LOG`. |
| Database | Postgres only (`DATABASE_URL=sqlite://...` is rejected). |
| OpenTelemetry | Rust process metrics replace the CPython gc metrics; no Postgres/Redis client auto-instrumentation. |
| Deadline members | Compact JSON instead of Python's spaced `json.dumps`; stale duplicates are harmless (engines revalidate tokens). |
| CORS | Hand-written middleware replicating kaya-cors / Starlette `CORSMiddleware` semantics, including `400 Disallowed CORS ...` preflight rejections. |
| CLI | `tavolo migrate` / `tavolo backfill-elo` replace `aerich upgrade` / `python -m tavolo.platform.backfill_elo`. |