CI / Build and push docker image (push) Successful in 7m1s
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.
449 lines
24 KiB
Markdown
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`. |
|