Files
tavolo/server
woggioni 915a294dc8 Support multiple OIDC providers
kaya-oidc 0.0.6 can run several providers in one app (OIDCProviders),
each with its own login route and namespaced session state. Adopt it:
providers are configured in indexed, contiguous OIDC_<n>_* blocks
(0-based) with OIDC_<n>_PROVIDER naming the provider in its login URL
and session keys. The old unindexed OIDC_ISSUER/OIDC_CLIENT_ID/... names
are no longer read; startup fails with a rename hint when they are set.
With no OIDC variables at all, the single dev mock provider named
"default" is used, as before.

The platform now authenticates through OIDCProviders: auth.current_user
returns the session's user (sessions stay single-identity, so at most one
provider is logged in) and require_auth/route handlers use it, while the
websocket path keeps scanning for any provider's user. New routes expose
the configuration to the SPA and keep old links working: GET
/api/auth/providers lists the login URLs, GET /auth/login redirects to
the first provider and GET /auth/logout to whichever provider the
session is logged in with. The lobby renders one login button per
provider from that endpoint.

Update the env examples, compose and k8s manifests to the indexed scheme,
document the multiple-provider setup and the migration, and cover it with
config, auth and route tests.
2026-10-01 12:38:29 +00:00
..
2026-10-01 12:38:29 +00:00
2026-10-01 12:38:29 +00:00
2026-10-01 12:38:29 +00:00
2026-10-01 12:38:29 +00:00
2026-10-01 12:38:29 +00:00
2026-10-01 01:18:05 +00:00

tavolo (backend)

The backend for a multiplayer card-game platform, built on the kaya framework. It is a monorepo of three distributions sharing the tavolo namespace (see packages/tavolo-platform/README.md and packages/tavolo-scopone/README.md):

  • tavolo-platform (packages/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.
  • tavolo-scopone (packages/tavolo-scopone/) — scopone scientifico, the four-player, fixed-partnership variant of the classic Italian card game: pure rules plus the platform adapter.
  • tavolo-app (packages/tavolo-app/) — the composition root wiring the platform to the scopone game, with the environment-driven settings and the SPA shell.

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.

Quick start (Docker Compose)

# 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, 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:

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).

Configuration

All configuration comes from environment variables (see .env.example):

Variable Default Description
DATABASE_ENGINE postgres Database DSN scheme/driver
DATABASE_HOST localhost Postgres host
DATABASE_PORT unset Postgres port; omitted from the DSN when empty (driver default, 5432 for Postgres)
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 (used for sqlite in tests and for 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 Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console
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 (the Docker image bundles the otel extra; non-image installs need pip install tavolo-app[otel])
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 the exporter default
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

¹ 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:

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. Each provider's sub is a separate account, so match history and Elo ratings are per provider identity (someone using two providers has two accounts). Migrating from the pre-0.0.6 unindexed variables: rename OIDC_ISSUER/OIDC_CLIENT_ID/... to OIDC_0_..., add OIDC_0_PROVIDER, and register the new callback path (/auth/<provider>/callback) at the provider. Sessions survive only when the provider is named default; otherwise users log in once more.

Logging

The application logs through the Python stdlib logging module, one getLogger(__name__) per module: lifecycle and business events at INFO (game created/joined, websocket connections, match results, auto-plays), per-move and store detail at DEBUG.

By default everything at DEBUG level goes to the console. Set LOGGING_CONFIG to the path of a YAML file to take over the configuration; the file follows the logging.config.dictConfig schema. Keep disable_existing_loggers: false — Granian configures its own loggers before importing the app, and disabling them would silence the server. Example for quieter production logs (WARNING for third parties, INFO for the application):

version: 1
disable_existing_loggers: false
formatters:
  default:
    format: "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}"
    style: "{"
handlers:
  console:
    class: logging.StreamHandler
    formatter: default
root:
  level: WARNING
  handlers: [console]
loggers:
  tavolo:
    level: INFO

OpenTelemetry

With OTEL_ENABLED set, the app exports over OTLP/HTTP (kaya-otel plus tavolo.telemetry):

  • Traces: HTTP server spans, one span per WebSocket connection, plus database spans for the Postgres (asyncpg, through Tortoise) and Redis clients. Redis command values are redacted by the instrumentation (GET ? ?) and asyncpg spans carry the parameterized SQL only — no parameter values. Each HTTP/WebSocket span is tagged with the request's session and user, so all traces of a session can be found together: 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.
  • Metrics: the HTTP server metrics from kaya-otel, plus tavolo.active_users (sessions in the session store), tavolo.ongoing_games (live matches not finished yet, maintained by the game store), and process/CPython metrics from opentelemetry-instrumentation-system-metrics: process.cpu.time, process.cpu.utilization, process.memory.usage, process.memory.virtual, cpython.gc.collections, cpython.gc.collected_objects and cpython.gc.uncollectable_objects.
  • Logs: forwarded from the root logger at the configured console level (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). Note that with multiple workers or replicas each process reports the same global counts: aggregate with max, not sum.

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).
  • 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 (e.g. 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.

Postgres (statistics, via Tortoise ORM + aerich migrations)

  • match — one row per finished match: the game played (game_type, one of the ids from GET /api/game-types, 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. Ratings update in the same transaction as the match result. To recompute every rating from the recorded match history (e.g. to backfill matches recorded before ratings existed):

.venv/bin/python -m tavolo.platform.backfill_elo \
  --database-url postgres://tavolo:tavolo@localhost:5432/tavolo

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:

{"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 (see above), 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

python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e './packages/tavolo-app[dev]'
# app integration suite:
.venv/bin/python -m unittest discover -s tests -t .
# per-package suites (each package is self-contained):
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
# type checks (see packages/tavolo-app/README.md for the app modules):
.venv/bin/python -m mypy -p tavolo.platform
.venv/bin/python -m mypy -p tavolo.scopone

Tests run fully in-process: in-memory sqlite for Postgres, in-memory stores for Redis, a fake OIDC user patched onto the mixins, and httpx / httpx-ws ASGI transports for HTTP and WebSocket coverage. The platform suite additionally runs against a DummyEngine toy game, so the game-independent machinery is covered without importing scopone.

Migrations

The Postgres schema is owned by aerich migrations in migrations/. The db-migrate compose service runs aerich upgrade before the app starts. To add a migration after changing packages/tavolo-platform/src/tavolo/platform/models.py:

DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich migrate
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich upgrade

(aerich init-db produces sqlite-flavored DDL when pointed at sqlite; adjust UUID/TIMESTAMPTZ/BOOL/JSONB for Postgres like the existing baseline.)

Layout

packages/tavolo-platform/     # game-independent platform (own pyproject + tests)
  src/tavolo/platform/
  ├── __init__.py             # public surface (GameEngine, GameSession, Platform, ...)
  ├── engine.py               # the platform<->game contract
  ├── registry.py             # game id -> engine lookup
  ├── mixin.py                # Platform + PlatformMixin (mounts everything on a kaya app)
  ├── errors.py               # shared GameError hierarchy
  ├── models.py               # Match / MatchPlayer / PlayerRating (Postgres)
  ├── elo.py                  # chess-style Elo math (1500 start, K=32)
  ├── stats.py                # MatchResult -> Postgres persistence + Elo update
  ├── backfill_elo.py         # recompute all ratings from the match history
  ├── store.py                # Redis / in-memory live-session store (+ deadline queue)
  ├── deadlines.py            # connection-independent timeout scheduler
  ├── ws.py                   # WebSocket live-play endpoint (generic envelope)
  ├── auth.py                 # auth helpers (HTTP + WebSocket)
  ├── http.py                 # JSON request/response helpers
  ├── pagination.py           # keyset (cursor) pagination
  ├── openapi.py              # shared OpenAPI parameter fragments
  ├── tortoise_mixin.py       # TortoiseORM lifecycle (HTTP + WebSocket)
  └── routes/
      ├── health.py           # GET /api/health
      ├── me.py               # GET /api/me
      ├── games.py            # lobby: game-types / create / join / snapshot
      └── stats.py            # match history + leaderboard + Elo ratings
packages/tavolo-scopone/      # scopone scientifico game (own pyproject + tests)
  src/tavolo/scopone/
  ├── state.py                # ScoponeState / PlayerState / Card, JSON (de)serialization
  ├── engine.py               # pure scopone scientifico rules
  ├── errors.py               # scopone-specific rule violations
  └── plugin.py               # ScoponeEngine: the GameEngine adapter
packages/tavolo-app/          # deployable app: composition root (own pyproject)
  src/tavolo/
  ├── app.py                  # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins
  ├── config.py               # env -> frozen Settings
  ├── telemetry.py            # OTLP metric/log providers, app gauges, count poller
  ├── aerich_config.py        # aerich CLI configuration
  ├── logging_config.py       # logging setup
  └── static.py               # SPA shell hosting
pyproject.toml                # tooling shim: [tool.aerich] + [tool.mypy] for server/ CWD
migrations/                   # aerich migrations for the platform models
tests/                        # app integration suite (platform + scopone wired together)