Compare commits

..
Author SHA1 Message Date
woggioni 565e927a9b Fix hand-end timeouts after partial acknowledgements 2026-10-25 13:22:17 +00:00
woggioni 71493f720b Fix OIDC subject collisions by introducing canonical provider-plus-subject principals
- Add canonical_principal(provider, sub) => 'oidc:v1:<provider>:<sub>'
- Preserve provider identity across HTTP sessions and WebSockets
- Apply principals throughout: game creation/joining/seating, WebSocket views/actions,
  Redis engine state, match history, leaderboard focus, ratings, /api/me, OpenAPI,
  and telemetry
- Widen identity columns to 1024 chars with schema-only aerich migration (5_20261024221522_canonical_principals.py)
- Document provider names as stable security identifiers
- Add regression tests: same subject at different providers cannot share games, private hands,
  join codes, moves, history, or ratings; distinct seat and rating ownership verified
- All platform, integration, game, and frontend tests pass; mypy clean
2026-10-24 22:25:44 +00:00
woggioni 4d940add1c Migrate the backend from pip to uv
Replace requirements.txt with a uv workspace and lockfile, install the five tavolo packages from packages/* in development and non-editable in the image, and build the Docker image with uv. Move dev tools to a PEP 735 dependency group and refresh the locked dependency versions.
2026-10-19 18:19:20 +00:00
woggioni 7d67eccca2 Notify when another player plays a card
Add an opt-in bell to the game HUD: the first click asks for the
browser's notification permission, and the choice is remembered per
device. A play by any other seat then raises a desktop notification
with the player's name, the card and the next player, while the page
is in the background and the viewer is actually seated in the game.

Detect the newest play across the game families: scopone and scopa
report it in last_move, while briscola and briscola chiamata only
expose the trick, which mid-trick or once completed ends with the
card just played. The first snapshot after every (re)connection is a
resync baseline, so reconnects and the initial load never replay old
moves; so are the viewer's own plays and the server's auto-plays.

Deliver through the service worker registration (installed PWAs on
Android reject page-context notifications), falling back to a page
notification when no worker is active, under a per-game tag that
replaces the previous move. sw.js now focuses the game window when a
notification is clicked.
2026-10-15 10:34:14 +00:00
woggioni 8c6ad10bf6 Make the frontend an installable PWA
Add a web app manifest and an install-only service worker (no offline
caching: live state always comes from the server and hashed asset names
change every build), registered from the WASM bundle. The worker must
live at the site root for its scope to cover the app, and Granian only
serves /static and /assets, so tavolo/static.py answers
/manifest.webmanifest and /sw.js from STATIC_DIR with the content types
browsers require; index.html gains the manifest link, icons, theme-color
and Apple standalone meta tags.

Icons are committed under web/assets/icons/ with vector sources and a
make-icons.sh regeneration script, mirroring the card art workflow.
2026-10-14 22:14:43 +00:00
woggioni 532cb16f94 Show the OIDC profile picture with an identicon fallback
GET /api/me now carries the profile picture URL when the provider
supplies an absolute http(s) one; the SPA shows it in the lobby navbar
and draws a deterministic identicon from the sub otherwise, falling back
to the identicon when the image fails to load.

The identicon is a 5x5 mirrored grid seeded by an inline FNV-1a hash so
it stays stable across Rust versions and is unit-testable on the host.
The dev mock IdP hands alice and bob a picture claim.
2026-10-14 16:13:10 +00:00
woggioni 3fe29ba740 Stop requesting the user email from the OIDC provider
OIDCConfig now asks for the openid and profile scopes only: kaya-oidc's
default would also request email, which the platform never stores or
exposes. The display-name fallback drops the email claim too
(preferred_username, name, then the sub).

Tests pin the configured scopes and cover an IdP that still returns an
email claim: the claim is ignored and absent from /api/me.
2026-10-14 15:56:35 +00:00
woggioni 25af0bf3fe Document every API response body in the OpenAPI spec
kaya-openapi 0.0.8 supports named component schemas, so the routes'
description-only responses become $refs: schemas for the core payloads
(auth providers, lobby, game view, ongoing games, match history,
leaderboard, ratings) live in tavolo.platform.openapi and are registered
through the mixin's components. Engine-defined parts (options echo, game
views, match results) stay open objects on purpose. Error responses now
carry the error envelope schema, and a new suite sweeps the document to
keep every /api response typed.
2026-10-14 14:05:11 +00:00
woggioni 551c4c9c28 Paginate the leaderboard from materialized player ratings
Store each player's leaderboard totals (wins, accumulated points and the
latest display name) on player_rating alongside the Elo rating, updated
in the same transaction as the match, so the board is a single indexed
table query instead of aggregating every participation on each request.

GET /api/leaderboard is now page-number paginated (page/limit) and
returns the page, page count and total, plus the caller's own rank and
page when authenticated; focus=me returns the caller's page directly.
The migration adds the columns, backfills them from the match history
(inserting rows for players from pre-Elo matches at the initial rating)
and replaces the index. backfill_elo rebuilds the aggregates too.

The web page opens on the page containing the signed-in player, marks
their row and adds a numbered footer pager.
2026-10-09 08:35:03 +00:00
woggioni bf613eb1c4 Set scopa targets to their traditional defaults
Partnership games go to 21: scopone scientifico (the tournament target)
and scopa a quattro, while two-player scopa keeps its usual 11. The scopa
package resolves the default from the player count and exposes a per
variant options_schema default, so the lobby creation form shows the
right value without frontend changes.
2026-10-09 06:36:06 +00:00
woggioni d4d437c1a1 Add scopa: classic two-player and scopa a quattro
New tavolo-scopa package with a pure rules engine and the platform
adapters for two-player scopa and four-player fixed-partnership scopa a
quattro: three cards each and four on the table, capture by rank or sum,
sweep for scope and score carte/denara/settebello/primiera.

The optional asso pigliatutto creation option turns either variant into
scopa d'assi (an ace sweeps the whole table and scores no scopa), reusing
scopone's rule semantics.

Wire both engines into the app and the Docker install, add the scopa
table view to the SPA (duo and four-seat layouts, deck count,
captured/scope stats, hand-summary and final screens, player names in the
two-player summary), and cover the rules, the platform contract, the
REST/WebSocket flows, match persistence and Elo.
2026-10-09 05:39:00 +00:00
woggioni 7883d0c458 Give the page background a casino felt texture
Replace the flat radial green with a layered body background: a soft
overhead light and an edge vignette over a fine grain tiled across the
green gradient.

The grain is a tiny seamless SVG tile (feTurbulence fractal noise mapped
to semi-transparent black speckles, stitchTiles so it repeats without
seams) committed under web/assets, so it rides the existing asset
pipeline and works offline; the palette and panel/card colors are
unchanged.
2026-10-09 01:26:31 +00:00
woggioni 637b711fa8 Give the match history table fixed columns
Rows rendered only their own match's team labels while the header was
the union across all loaded matches. Scopone and briscola have two team
labels and briscola chiamata has three, so a mixed history produced a
five-column header above rows with two or three team cells - every cell
after them (score, winner, you, elo) landed under the wrong header, and
the columns changed with the set of game types loaded.

Replace the variable team columns with a single "Teams" column: the
table is now Finished | Game | Teams | Score | Winner | You | Elo for
every game. The cell lists each team or role on its own line, ordered
Chiamante/Socio/Difensori for chiamata and alphabetically (Team A,
Team B, ...) otherwise, with team-less players last; names read
"Bob, Carol & Dave".
2026-10-09 01:18:05 +00:00
woggioni e0030ee94a Make an absent chiamata bidder pass instead of bidding randomly
The auction timeout bid a random legal rank, which could hand the
auction (and the call) to a disconnected player. An absent bidder now
always passes; only the last player standing before any bid cannot
pass, and is made to bid the ace - its suit is picked at random by the
existing call timeout. This keeps a deal from stalling without a caller
while never letting an absent player outbid someone.

The deadline log line now says which of the two happened.
2026-10-08 22:47:32 +00:00
woggioni c746a88cfb Stop the chiamata call picker from clipping on short windows
The call picker stacked the four 120px cards vertically, making the
dialog about 780px tall, and the overlay centered its content with no
scroll fallback. On typical laptop window heights that pushed the title
and options off-screen (the heading sat at y=-110 on a 560px viewport),
leaving what looked like a free-floating selection with no explanation
and no way to scroll.

Make dialogs scroll instead of clipping: the overlay scrolls and the
panel centers with `margin: auto`, which keeps every modal (the capture
picker and the scoring summaries too) centered when it fits and
reachable when it does not. Lay the call options out in a compact grid
(four across, 2x2 on phones) with 84px cards, name the won rank in the
title ("You won with the Re - name the card") and label each option
"Partner" or "Plays alone" so the choice reads at a glance.
2026-10-08 22:30:06 +00:00
woggioni 99aa81d682 Redirect to login on 401 and return afterwards
The SPA now sends the user to a new /login page whenever an API call
answers 401, remembering the page they were on. The OIDC callback always
lands back on the configured post-login redirect, so the saved page is
restored client-side once a session exists.

- auth: save/restore the return path in sessionStorage; defer the
  navigation so the failing request can finish first
- api: centralize 401 handling (me() still treats 401 as logged out)
- login page + shared login card, extracted from the lobby
- guard async signal writes so an in-flight request cannot touch a page
  unmounted by the redirect
2026-10-08 21:52:51 +00:00
woggioni 43d5a6e2f9 Add briscola chiamata
Five-player briscola with a descending calling auction and a secret
partner. Starting with the player after the dealer, each player bids a
lower rank (A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2) or passes; once the
standing bid is the two the auction continues on points (62, 64, ...).
The last player standing must bid, and the winner names any card of the
won rank: its holder is the secret partner, or the caller plays alone
when it is one of their own cards, and its suit becomes the trump. The
calling side must reach 61 (or the raised target); a 60-60 deal with the
plain target is a patta and is replayed. A match is a single deal, so
the caller and partner (or the lone caller) against the other players
map onto the platform's two-team result, Elo and match history.

The rules live in tavolo.briscola.chiamata with the
BriscolaChiamataEngine adapter registered alongside the other briscola
variants; the partner's role and the side totals stay hidden until the
called card is played. The web client gets a five-seat table, the
auction panel with legal bids and point raises, the call picker, the
reveal bands and a chiamata-aware final summary.
2026-10-08 04:27:15 +00:00
48 changed files with 2659 additions and 484 deletions
+5 -5
View File
@@ -7,11 +7,11 @@
.env.example
**/.venv/
**/__pycache__/
*.pyc
*.pyo
*.egg-info/
.mypy_cache/
.pytest_cache/
**/*.pyc
**/*.pyo
**/*.egg-info/
**/.mypy_cache/
**/.pytest_cache/
web/target/
web/dist/
server/tests/
+12 -10
View File
@@ -110,20 +110,22 @@ live-play websocket lives.
## Development
Backend (from `server/`):
Backend (from `server/`), using [uv](https://docs.astral.sh/uv/):
```sh
cd server
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-scopa -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]'
.venv/bin/python -m unittest discover -s tests -t .
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
.venv/bin/python -m unittest discover -s packages/tavolo-scopa/tests
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
uv sync --package tavolo-app --extra otel
uv run python -m unittest discover -s tests -t .
uv run python -m unittest discover -s packages/tavolo-platform/tests
uv run python -m unittest discover -s packages/tavolo-scopone/tests
uv run python -m unittest discover -s packages/tavolo-scopa/tests
uv run python -m unittest discover -s packages/tavolo-briscola/tests
```
The `otel` extra is optional; without it the telemetry integration tests
skip. Development tools (`mypy`, `httpx-ws`) are installed by default from
the workspace's `dev` dependency group.
Frontend (from `web/`), with the backend running on :8080:
```sh
@@ -142,7 +144,7 @@ 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/ \
.venv/bin/granian --host 127.0.0.1 --port 8080 tavolo.app:app
uv run granian --host 127.0.0.1 --port 8080 tavolo.app:app
```
Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh`
+1 -1
View File
@@ -128,7 +128,7 @@ services:
# (HTTP/WebSocket, Postgres, Redis), metrics and logs via OTLP/HTTP,
# disabled unless OTEL_ENABLED is set.
# The image bundles the otel extra (see packages/tavolo-app/pyproject.toml
# and server/requirements.txt).
# and server/uv.lock).
OTEL_ENABLED: ${OTEL_ENABLED:-}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
+1
View File
@@ -0,0 +1 @@
3.14
+32 -18
View File
@@ -7,7 +7,7 @@
# builder alpine python -> python venv with the backend + deps
# runtime alpine python + the venv + the compiled frontend
#
# apk and pip both use BuildKit cache mounts (type=cache): the package
# apk and uv both use BuildKit cache mounts (type=cache): the package
# caches persist in the builder's cache across builds instead of being
# re-downloaded, and never land in the image layers.
@@ -49,29 +49,43 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
# --- Python builder ----------------------------------------------------------
FROM alpine:3.24 AS builder
# The official uv image ships a statically linked musl binary, so it works
# on Alpine. Pin the version so image builds do not change implicitly.
COPY --from=ghcr.io/astral-sh/uv:0.13.1 /uv /uvx /bin/
RUN --mount=type=cache,target=/var/cache/apk \
apk add python3 py3-pip build-base python3-dev
apk add python3 build-base python3-dev
# Use Alpine's Python instead of downloading a managed interpreter, install
# the venv at the same path the runtime stage uses, and copy rather than
# hardlink from the cache mount (cache and target are different filesystems).
ENV UV_PYTHON_DOWNLOADS=0 \
UV_PROJECT_ENVIRONMENT=/opt/venv \
UV_LINK_MODE=copy
WORKDIR /build
# uv needs the workspace root plus the member pyprojects to know which
# package to sync. Copying only the manifests lets the third-party
# dependency layer stay cached while application sources change.
# server/pyproject.toml also carries [tool.aerich], read by the db-migrate
# compose service with working_dir=/app, next to ./migrations.
COPY server/uv.lock server/pyproject.toml ./
COPY server/packages/tavolo-platform/pyproject.toml packages/tavolo-platform/
COPY server/packages/tavolo-scopone/pyproject.toml packages/tavolo-scopone/
COPY server/packages/tavolo-scopa/pyproject.toml packages/tavolo-scopa/
COPY server/packages/tavolo-briscola/pyproject.toml packages/tavolo-briscola/
COPY server/packages/tavolo-app/pyproject.toml packages/tavolo-app/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-workspace --package tavolo-app --extra otel --no-dev
COPY server/requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \
python3 -m venv /opt/venv \
&& /opt/venv/bin/pip install --upgrade pip \
&& /opt/venv/bin/pip install -r requirements.txt
# The five sibling distributions, installed from the local tree (PEP 420
# namespace packages sharing the `tavolo` namespace). --no-deps: every
# third-party dependency — including the tavolo-app `otel` extra — is
# already pinned in requirements.txt.
# The five sibling distributions are workspace members (PEP 420 namespace
# packages sharing the `tavolo` namespace). --no-editable installs them as
# wheels, so the runtime image does not need their source tree.
COPY server/packages/ ./packages/
RUN --mount=type=cache,target=/root/.cache/pip \
/opt/venv/bin/pip install --no-deps ./packages/tavolo-platform ./packages/tavolo-scopone ./packages/tavolo-scopa ./packages/tavolo-briscola ./packages/tavolo-app
# server/pyproject.toml only carries the [tool.aerich] section (the
# db-migrate compose service runs `aerich upgrade` with working_dir=/app,
# next to ./migrations).
COPY server/pyproject.toml ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-editable --package tavolo-app --extra otel --no-dev
# aerich migration files are a release artifact: the db-migrate compose
# service runs `aerich upgrade` from this image before the app starts.
COPY server/migrations/ ./migrations/
+36 -22
View File
@@ -99,7 +99,7 @@ All configuration comes from environment variables (see `.env.example`):
| `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_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces, metrics and logs (the Docker image bundles the `otel` extra; non-image installs need `uv sync --package tavolo-app --extra 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) |
@@ -138,9 +138,12 @@ 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
replaces the previous one. Accounts are keyed by the canonical principal
`oidc:v1:<provider>:<sub>`, not by `sub` alone, so equal subjects at two
providers cannot share games, history, or ratings (someone using two
providers has two accounts). Provider names are permanent security
identifiers: do not reuse a name for a different issuer, and treat renaming
one as an identity migration. 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
@@ -249,8 +252,9 @@ aggregate with `max`, not `sum`.
per-deal audit; for briscola chiamata: the calling side's points
against the defenders', the declared target, the called card and
whether the caller played alone).
- `match_player` — one row per participant: the OIDC `sub`, display name,
seat, game-defined team label (nullable), whether they won, the points
- `match_player` — one row per participant: the canonical provider-qualified
principal (`user_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 and leaderboard totals per
@@ -276,7 +280,7 @@ rebuild every rating and aggregate from the recorded match history (e.g.
to backfill matches recorded before ratings existed):
```sh
.venv/bin/python -m tavolo.platform.backfill_elo \
uv run python -m tavolo.platform.backfill_elo \
--database-url postgres://tavolo:tavolo@localhost:5432/tavolo
```
@@ -511,24 +515,33 @@ rejected with an `illegal_move` error.
## Development
The backend is a [uv](https://docs.astral.sh/uv/) workspace: `pyproject.toml`
declares the five packages under `packages/` as members, and `uv.lock` pins
their full dependency closure. The checked-in `.python-version` selects
Python 3.14, matching the Docker image. Install uv, then from `server/`:
```sh
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-scopa -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]'
uv sync --package tavolo-app --extra otel
# app integration suite:
.venv/bin/python -m unittest discover -s tests -t .
uv run 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
.venv/bin/python -m unittest discover -s packages/tavolo-scopa/tests
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
uv run python -m unittest discover -s packages/tavolo-platform/tests
uv run python -m unittest discover -s packages/tavolo-scopone/tests
uv run python -m unittest discover -s packages/tavolo-scopa/tests
uv run python -m unittest discover -s packages/tavolo-briscola/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
.venv/bin/python -m mypy -p tavolo.scopa
.venv/bin/python -m mypy -p tavolo.briscola
uv run mypy -p tavolo.platform
uv run mypy -p tavolo.scopone
uv run mypy -p tavolo.scopa
uv run mypy -p tavolo.briscola
```
`uv sync` installs the workspace's `dev` dependency group (`mypy`,
`httpx-ws`) automatically; pass `--no-dev` for a runtime-only environment.
The `otel` extra is optional, but without it the telemetry integration tests
skip. The lockfile is committed: use `uv lock --upgrade` (or
`uv lock --upgrade-package <name>`) to update dependencies deliberately.
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
@@ -543,8 +556,8 @@ To add a migration after changing
`packages/tavolo-platform/src/tavolo/platform/models.py`:
```sh
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich migrate
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich upgrade
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo uv run aerich migrate
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo uv run aerich upgrade
```
(`aerich init-db` produces sqlite-flavored DDL when pointed at sqlite;
@@ -605,7 +618,8 @@ packages/tavolo-app/ # deployable app: composition root (own pyproject)
├── 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
pyproject.toml # uv workspace root + [tool.aerich] / [tool.mypy]
uv.lock # locked dependency set for the whole workspace
migrations/ # aerich migrations for the platform models
tests/ # app integration suite (platform + games wired together)
```
@@ -0,0 +1,47 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "match_player" ALTER COLUMN "user_sub" TYPE VARCHAR(1024) USING "user_sub"::VARCHAR(1024);
ALTER TABLE "player_rating" ALTER COLUMN "user_sub" TYPE VARCHAR(1024) USING "user_sub"::VARCHAR(1024);"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "match_player" ALTER COLUMN "user_sub" TYPE VARCHAR(255) USING "user_sub"::VARCHAR(255);
ALTER TABLE "player_rating" ALTER COLUMN "user_sub" TYPE VARCHAR(255) USING "user_sub"::VARCHAR(255);"""
MODELS_STATE = (
"eJztmutv4jgQwP+VKJ96Uq8q6Wt1Wp0UKNVyC6UCunfaahWZxFCrxs46zrGo1//9bOf94t"
"EChYoP2yVjj+P8Jh7PjPOsT6gDsXfSAdx+1P/QnnUCJlD8yDYcazpw3UQsBRwMcdAz7jL0"
"OAM2F8IRwB4UIgd6NkMuR5TIrqZm04mLIYeOptQ0OtIogfI//gg1BsfI45CJ5rGYh8ZnLv"
"RO5NgOtcXgiIzfNoxP0E8fWpyOoejIxGAPP4QYEQf+gl506T5ZIwSxkwGCHDmAkltyQCm7"
"v29d36iecopDy6bYn5Cktzvjj5TE3X0fOSdSR7aNIYEMiEdI4SI+xiHWSBTMWAg482E8VS"
"cROHAEfCyh659HPrEla03dSf45/zOcWqqbZd12B1a/ObAsvWAjOYUc71BkUyLtiwiXoJ5f"
"gnETIEqqyxs0vpi9o7PL3xQC6vExU40Kl/6iFAEHgaqCnlCW9gp4FWA3HgErh51RyjEXU3"
"4N7UgwB3dEagNsxZr6ZWFIxlwuyjNjDutvZi/AbSjcVCzAYFnehi2GapLUE8oeB0ywsAAv"
"Yr4WlDiawHLUWc0caydUPYl+rId84ky2gH4O6kGr0+wPzM6dHH7ieT+x4mUOmrLFUNJZTn"
"p0mTNLPIj2d2vwRZOX2vfubTO/VuJ+g++6nBPwObUInVrASTOJxJEoY+YRIsh7fJWdc6oH"
"Q++0ocVQkk3Bxn/1u7fl9k008qZFNtf+07DYQDdl1NQ+NfQR5oh4J/K277FVSUIZK0du86"
"hj/pP3qI12t543nxygLtyrjBtGT6k9TQqGwH6aAuZYmZbEbi4GM8i8ouHqoeLN1x7EQD1v"
"0VTpEO1OjbS/yzCRhlutAkoNWkW02DQxJnkJIGCsHkneW96pBFlV0JsQXRD6Wm7Sc2EEfC"
"e2T2QjV9k0Clt9DzINEfVbDVkMeVfQK4lxH5IgXepYnj/UfxwC310KfGO7rBD3pnU+Xthb"
"OzXOlwh8ZbfK0DdozAa/DvLkirXU9Qq083qbIr5V95tFbpyeLkFc9KoErtpyyQYsCz/7E4"
"Bxi/CKNAOWxp1ye9hDxmM5id/PjKvLT6JVzVFeXM1h3e+Y7XbrdpBjySGYrPLORv1f9a6G"
"S39nMG44KZ4GQVYuEKMUQ0DK6U5Lw7KhUNnDt3QOznq3284EyfXWIMf1vlNvCl+scItOiC"
"tx8QX2bMpKvO4NpqDKFUQaOcwjqbIpzqfbh3zdva+3m9pdr9lo9VthVhJnmaoxC7fXNNs5"
"uhBTMWfMwar+NqP4Kqe7a85ifT7XgRwgXJKkVWfXKZVDer2W9DptkCDrWi0tSeusMznZXa"
"e9IBcp1CtyfEt8tPDDaEy+wplC3BITAcQuc835s6P9hFooSQgxA9M4Q868U+LpxTPDwDM3"
"zH7DvG7qL9U1oE1WN4LKRQ/w4CEL5Y1M+/G8+kZQ2bBY0nVhgaPhMwYJ15qYaoGeBoijiS"
"DKgWxIxcNpnHKhGxUxgntorvgnz3GKpY+1jFhaFEnnz8kZUr4w8pA9X0pgTIVnDVaXenOO"
"wzcCekFFyCmWWw71lUN9ZXdSqE0VWHb3DPdD5auJI8oyrgzyE4XtlVVqF6ebyqXCEN+onV"
"+dfzq7PI/j/FgyL9gvxvlF570k16Li9vjuC9xor1wSaby1HkDmQCbBxtIVlETlUEJZWELZ"
"v9MBfVOB2BbOBnxXfkHymg9Uspof8PsUnYkEp0vwTA8PxKvh7/j3KvFx/pLfR2wyNzYhQ+"
"VfuoYtx/PyYZD0WZQIV9t5zd+gVu6hpZliyQ4apntvSxE3X8p94w5anRn+C5mHys5+qp19"
"SuUDpirGxcUynv7iotrTy7asp5eLagXCYfcPSLe21D5am7OP1or7qLgjh2SlLwBTKts/o3"
"ifkvh7fOy3/s3s5X9zkOhU"
)
+1 -1
View File
@@ -29,7 +29,7 @@ The deployable tavolo application: the composition root wiring
## Development (from `packages/tavolo-app/`)
```sh
.venv/bin/python -m mypy --namespace-packages --explicit-package-bases \
uv run mypy --namespace-packages --explicit-package-bases \
src/tavolo/app.py \
src/tavolo/config.py \
src/tavolo/telemetry.py \
+6 -4
View File
@@ -31,10 +31,6 @@ dependencies = [
]
[project.optional-dependencies]
dev = [
"mypy",
"httpx-ws",
]
otel = [
"kaya-otel>=0.0.7",
"opentelemetry-instrumentation-asyncpg>=0.66b0",
@@ -42,6 +38,12 @@ otel = [
"opentelemetry-instrumentation-system-metrics>=0.66b0",
]
[dependency-groups]
dev = [
"mypy",
"httpx-ws",
]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
@@ -72,9 +72,10 @@ _DEFAULT_OIDC_CLIENT_ID = "tavolo"
class OidcProviderSettings:
"""One OIDC provider, parsed from the ``OIDC_<n>_*`` variables.
``provider`` names the provider in URLs (``/auth/<provider>/login``)
and namespaces its session state (``oidc:<provider>:...``); it must
match kaya-oidc's ``[A-Za-z0-9_-]+`` pattern.
``provider`` names the provider in URLs (``/auth/<provider>/login``),
namespaces its session state (``oidc:<provider>:...``), and is part of
the canonical user principal. It must match kaya-oidc's
``[A-Za-z0-9_-]+`` pattern and remain stable across deployments.
"""
provider: str
@@ -95,10 +95,11 @@ def session_tag_hook(span: Any, carrier: Any) -> None:
The session id is the session cookie, i.e. a credential, so only a
truncated SHA-256 of it is exported; session ids are
``secrets.token_urlsafe(32)`` (256 bits), which makes the digest
irreversible in practice. The OIDC ``sub`` is a pseudonymous
identifier already persisted in Postgres and served by the API, so it
is exported verbatim as ``user.id`` (OpenTelemetry's current end-user
attribute; ``enduser.id`` is PII-flagged and being superseded).
irreversible in practice. The canonical provider-qualified principal
is a pseudonymous identifier already persisted in Postgres and served
by the API, so it is exported verbatim as ``user.id`` (OpenTelemetry's
current end-user attribute; ``enduser.id`` is PII-flagged and being
superseded).
"""
session = getattr(carrier, "session", None)
if session is None:
@@ -111,9 +112,7 @@ def session_tag_hook(span: Any, carrier: Any) -> None:
)
user = session_user(session)
if user is not None:
sub = user.get("sub")
if sub:
span.set_attribute("user.id", str(sub))
span.set_attribute("user.id", user.principal)
class _NoOtelLogs(logging.Filter):
+2 -2
View File
@@ -92,8 +92,8 @@ the match), so the hand-ack timeout is accepted but unused.
## Development (from `server/`)
```sh
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
.venv/bin/python -m mypy -p tavolo.briscola
uv run python -m unittest discover -s packages/tavolo-briscola/tests
uv run mypy -p tavolo.briscola
```
`test_engine.py`/`test_plugin.py` cover the two classic variants;
@@ -220,6 +220,8 @@ class _BriscolaBase(GameEngine):
)
elif kind == "hand_end":
for player in state.players:
if state.phase != PHASE_HAND_END:
break
engine.acknowledge_hand(state, player.sub)
log.info(
"deal %d auto-advanced after the acknowledgement timeout",
+2 -2
View File
@@ -58,8 +58,8 @@ examples are [`tavolo-scopone`](../tavolo-scopone/README.md),
## Development (from `server/`)
```sh
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
.venv/bin/python -m mypy -p tavolo.platform
uv run python -m unittest discover -s packages/tavolo-platform/tests
uv run mypy -p tavolo.platform
```
Tests run fully in-process against a `DummyEngine` (a two-player toy game
@@ -2,10 +2,14 @@
The platform has no application roles: every authenticated user may
create and join games. Authorization beyond login is game membership,
checked against the seats of the live session.
checked against the seats of the live session. Seats and persisted stats
use a canonical provider-plus-subject principal because a bare OIDC ``sub``
is not unique across providers.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any, Callable, Mapping, Optional
from kaya.core import HttpContext, WebSocket
@@ -17,25 +21,62 @@ from kaya.session import Session
# fixed key or provider name.
_OIDC_PREFIX = "oidc:"
_USER_SUFFIX = ":user"
_PRINCIPAL_PREFIX = "oidc:v1:"
_PROVIDER_NAME = re.compile(r"^[A-Za-z0-9_-]+$")
def session_user(session: Session) -> Optional[OIDCUser]:
"""Return the authenticated user stored in a session, if any.
@dataclass(frozen=True)
class AuthenticatedUser:
"""OIDC claims together with their stable, provider-qualified identity."""
provider: str
claims: OIDCUser
principal: str
def canonical_principal(provider: str, sub: str) -> str:
"""Return the global principal for one provider's OIDC subject.
Provider names are restricted to characters that cannot include ``:``,
so the tuple remains unambiguous even when the subject contains colons.
They are persisted security identifiers and must not be reused for a
different issuer.
"""
if not isinstance(provider, str) or _PROVIDER_NAME.fullmatch(provider) is None:
raise ValueError("invalid OIDC provider")
if not isinstance(sub, str) or not sub:
raise ValueError("invalid OIDC subject")
return f"{_PRINCIPAL_PREFIX}{provider}:{sub}"
def _authenticated_user(
provider: str, claims: OIDCUser
) -> Optional[AuthenticatedUser]:
try:
principal = canonical_principal(provider, claims.sub)
except (KeyError, TypeError, ValueError):
return None
return AuthenticatedUser(provider=provider, claims=claims, principal=principal)
def session_user(session: Session) -> Optional[AuthenticatedUser]:
"""Return the authenticated identity stored in a session, if any.
The kaya-oidc mixin writes the userinfo at login under a per-provider
key, so any provider's user is accepted (the configured provider is
``default`` unless the app says otherwise).
key. Retaining that provider is essential: an OIDC subject is only
unique within one provider, not across all of them.
"""
for key in session:
if key.startswith(_OIDC_PREFIX) and key.endswith(_USER_SUFFIX):
claims = session.get(key)
if isinstance(claims, Mapping):
return OIDCUser(claims)
provider = key.removeprefix(_OIDC_PREFIX).removesuffix(_USER_SUFFIX)
return _authenticated_user(provider, OIDCUser(claims))
return None
def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
"""Return the authenticated user of a WebSocket connection, if any.
def get_ws_user(ws: WebSocket) -> Optional[AuthenticatedUser]:
"""Return the authenticated identity of a WebSocket connection, if any.
The session mixin injects ``session`` into the websocket wrapper; the
OIDC mixin stores the userinfo there at login. Patched in tests.
@@ -73,13 +114,19 @@ def picture(user: OIDCUser) -> Optional[str]:
return None
def current_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[OIDCUser]:
"""Return the authenticated user of an HTTP request, if any.
def current_user(
oidc: OIDCProviders, ctx: HttpContext
) -> Optional[AuthenticatedUser]:
"""Return the authenticated identity of an HTTP request, if any.
Sessions are single-identity by default, so at most one provider has
a user; if several do, the first configured provider wins.
"""
return next(iter(oidc.get_users(ctx).values()), None)
users = oidc.get_users(ctx)
if not users:
return None
provider, claims = next(iter(users.items()))
return _authenticated_user(provider, claims)
def require_auth(oidc: OIDCProviders) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
@@ -40,6 +40,7 @@ class Seat:
persisting participations.
"""
# Canonical provider-plus-subject principal, despite the legacy field name.
user_sub: str
display_name: str
# Game-defined team label ("A"/"B", ...); ``None`` for games without
@@ -73,6 +74,7 @@ class GameSession:
id: str
game_type: str
join_code: str
# Canonical principal of the creator.
creator_sub: str
players: List[Seat] = field(default_factory=list)
created_at: Optional[datetime] = None
@@ -81,13 +83,13 @@ class GameSession:
stats_saved: bool = False
state: Any = None
def seated(self, sub: str) -> bool:
"""Whether ``sub`` occupies a seat in this game."""
return self.seat_of(sub) is not None
def seated(self, principal: str) -> bool:
"""Whether ``principal`` occupies a seat in this game."""
return self.seat_of(principal) is not None
def seat_of(self, sub: str) -> Optional[Seat]:
def seat_of(self, principal: str) -> Optional[Seat]:
for seat in self.players:
if seat.user_sub == sub:
if seat.user_sub == principal:
return seat
return None
@@ -112,6 +114,7 @@ class Deadline:
class PlayerResult:
"""The outcome of a finished match for one participant."""
# Canonical provider-plus-subject principal, despite the legacy field name.
user_sub: str
seat: int
won: bool
@@ -126,7 +129,7 @@ class PlayerResult:
class MatchResult:
"""The game-independent outcome of a finished match.
``teams`` groups the participants' subs into the sides the Elo rating
``teams`` groups the participants' principals into the sides the Elo rating
treats as opponents (exactly two for now); ``winner_team`` is the
index of the winning side. ``summary`` is persisted verbatim as the
match's JSON ``result`` column.
@@ -145,6 +148,9 @@ class GameEngine(ABC):
logging: all persistence, locking and transport concerns belong to
the platform. Rule violations are reported by raising
:class:`~tavolo.platform.errors.GameError` subclasses.
Every ``user_sub`` value in this contract is an opaque, canonical
provider-plus-subject principal, never a bare cross-provider OIDC ``sub``.
"""
#: Unique id carried by sessions and persisted matches.
@@ -6,11 +6,11 @@ completed matches are written here. The schema is game-independent:
* :class:`Match` — one row per finished match; everything game-specific
(scores, hands played, options, per-hand audit trail) lives in the
JSON ``result`` column produced by the game's engine.
* :class:`MatchPlayer` — one row per participant, linking an OIDC
``sub`` to a seat, an optional team label, whether they won, the
points they scored and their Elo delta.
* :class:`MatchPlayer` — one row per participant, linking a canonical
provider-qualified principal to a seat, an optional team label, whether
they won, the points they scored and their Elo delta.
* :class:`PlayerRating` — current chess-style Elo rating and leaderboard
totals (wins, points, latest display name) of a player for one game
totals (wins, points, latest display name) of a principal for one game
type, updated transactionally with every finished match (see
:mod:`tavolo.platform.elo` and :mod:`tavolo.platform.stats`).
"""
@@ -48,8 +48,8 @@ class MatchPlayer(Model):
match: fields.ForeignKeyRelation[Match] = fields.ForeignKeyField(
"models.Match", related_name="players", on_delete=fields.CASCADE
)
# OIDC subject of the player; no local users table.
user_sub = fields.CharField(max_length=255, db_index=True)
# Canonical provider-plus-subject principal; no local users table.
user_sub = fields.CharField(max_length=1024, db_index=True)
display_name = fields.CharField(max_length=200)
seat = fields.SmallIntField()
# Game-defined team label; null for games without fixed teams.
@@ -73,8 +73,8 @@ class PlayerRating(Model):
"""Current Elo rating and leaderboard totals of one player per game."""
id = fields.UUIDField(pk=True)
# OIDC subject of the player; no local users table.
user_sub = fields.CharField(max_length=255)
# Canonical provider-plus-subject principal; no local users table.
user_sub = fields.CharField(max_length=1024)
# Which game the rating applies to (an id from the game registry).
game_type = fields.CharField(max_length=32)
rating = fields.IntField(default=INITIAL_RATING)
@@ -90,6 +90,14 @@ def _array(items: Dict[str, Any]) -> Dict[str, Any]:
return {"type": "array", "items": items}
def _principal() -> Dict[str, Any]:
return {
"type": "string",
"description": "Canonical provider-qualified principal: "
"oidc:v1:<provider>:<sub>.",
}
def _nullable(schema: Dict[str, Any]) -> Dict[str, Any]:
"""A copy of ``schema`` that also accepts ``null`` (OpenAPI 3.1 types)."""
nullable = dict(schema)
@@ -124,7 +132,7 @@ HEALTH_SCHEMA = _object(
ME_SCHEMA = _object(
{
"sub": {"type": "string"},
"sub": _principal(),
"name": {"type": "string"},
"picture": _nullable({"type": "string"}),
},
@@ -164,7 +172,7 @@ GAME_TYPES_PAGE_SCHEMA = _object(
LOBBY_PLAYER_SCHEMA = _object(
{
"sub": {"type": "string"},
"sub": _principal(),
"name": {"type": "string"},
"seat": {"type": "integer"},
"team": _nullable({
@@ -258,7 +266,7 @@ ONGOING_GAMES_PAGE_SCHEMA = _object(
MATCH_PLAYER_SCHEMA = _object(
{
"user_sub": {"type": "string"},
"user_sub": _principal(),
"display_name": {"type": "string"},
"seat": {"type": "integer"},
"team": _nullable({"type": "string"}),
@@ -314,7 +322,7 @@ MATCHES_PAGE_SCHEMA = _object(
LEADERBOARD_ENTRY_SCHEMA = _object(
{
"user_sub": {"type": "string"},
"user_sub": _principal(),
"display_name": {"type": "string"},
"elo": {"type": "integer"},
"matches": {"type": "integer"},
@@ -225,12 +225,13 @@ def register(app: KayaApp, platform: "Platform") -> None:
user = current_user(platform.oidc, ctx)
assert user is not None # enforced by @require_auth
principal = user.principal
session = GameSession(
id=str(uuid.uuid4()),
game_type=engine.id,
join_code=await _unique_code(platform),
creator_sub=user.sub,
players=[Seat(user_sub=user.sub, display_name=display_name(user))],
creator_sub=principal,
players=[Seat(user_sub=principal, display_name=display_name(user.claims))],
created_at=datetime.now(timezone.utc),
)
try:
@@ -242,7 +243,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
log.info(
"game %s created by %s (%s, options %r)",
session.id,
user.sub,
principal,
engine.id,
options,
)
@@ -290,9 +291,10 @@ def register(app: KayaApp, platform: "Platform") -> None:
user = current_user(platform.oidc, ctx)
assert user is not None
principal = user.principal
existing = await platform.game_store.find_by_code(code)
if existing is None:
log.debug("join rejected for %s: unknown code %r", user.sub, code)
log.debug("join rejected for %s: unknown code %r", principal, code)
await send_error(ctx, 404, "unknown join code")
return
@@ -303,9 +305,9 @@ def register(app: KayaApp, platform: "Platform") -> None:
return
engine = platform.registry.require(session.game_type)
try:
engine.join(session, user.sub, display_name(user))
engine.join(session, principal, display_name(user.claims))
except GameError as exc:
log.debug("join rejected for %s in game %s: %s", user.sub, session.id, exc)
log.debug("join rejected for %s in game %s: %s", principal, session.id, exc)
await send_error(ctx, 409, str(exc))
return
await platform.game_store.save(session)
@@ -314,16 +316,16 @@ def register(app: KayaApp, platform: "Platform") -> None:
# armed a deadline; queue it so it fires even if nobody ever
# connects.
await platform.scheduler.sync_deadline(session)
seat = next(i for i, s in enumerate(session.players) if s.user_sub == user.sub)
seat = next(i for i, s in enumerate(session.players) if s.user_sub == principal)
if engine.in_lobby(session):
log.info(
"%s joined game %s (seat %d, %d/%d players)",
user.sub, session.id, seat, len(session.players), engine.max_players,
principal, session.id, seat, len(session.players), engine.max_players,
)
await send_json(ctx, 200, _lobby_payload(platform, session))
return
log.info("%s joined game %s (seat %d); match started", user.sub, session.id, seat)
await send_json(ctx, 200, _view_payload(platform, session, user.sub))
log.info("%s joined game %s (seat %d); match started", principal, session.id, seat)
await send_json(ctx, 200, _view_payload(platform, session, principal))
@app.GET("/api/games/preview")
@operation(summary="Preview a game by join code",
@@ -376,7 +378,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
return
user = current_user(platform.oidc, ctx)
await send_json(
ctx, 200, _view_payload(platform, session, user.sub if user else None)
ctx, 200, _view_payload(platform, session, user.principal if user else None)
)
@app.GET("/api/me/games")
@@ -395,11 +397,12 @@ def register(app: KayaApp, platform: "Platform") -> None:
async def my_games(ctx: HttpContext) -> None:
user = current_user(platform.oidc, ctx)
assert user is not None # enforced by @require_auth
principal = user.principal
sessions = await platform.game_store.list_ongoing()
await send_json(ctx, 200, {
"results": [
_ongoing_game_payload(platform, session, user.sub)
_ongoing_game_payload(platform, session, principal)
for session in sessions
if session.seated(user.sub)
if session.seated(principal)
]
})
@@ -34,8 +34,8 @@ def register(app: KayaApp, platform: "Platform") -> None:
ctx,
200,
{
"sub": user.sub,
"name": display_name(user),
"picture": picture(user),
"sub": user.principal,
"name": display_name(user.claims),
"picture": picture(user.claims),
},
)
@@ -154,7 +154,8 @@ def register(app: KayaApp, platform: "Platform") -> None:
return
user = current_user(platform.oidc, ctx)
assert user is not None
queryset = Match.filter(players__user_sub=user.sub).distinct()
principal = user.principal
queryset = Match.filter(players__user_sub=principal).distinct()
if game_type is not None:
queryset = queryset.filter(game_type=game_type)
matches, next_cursor = await paginate(
@@ -173,7 +174,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
# declare it on the model class, hence the getattr.
by_match[getattr(player, "match_id")].append(player)
results = [
_serialize_match(m, user.sub, by_match.get(m.id, [])) for m in matches
_serialize_match(m, principal, by_match.get(m.id, [])) for m in matches
]
await send_json(ctx, 200, {"results": results, "next_cursor": next_cursor})
@@ -226,7 +227,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
yours: Optional[PlayerRating] = None
if user is not None:
yours = await PlayerRating.get_or_none(
user_sub=user.sub, game_type=rating_game
user_sub=user.principal, game_type=rating_game
)
your_rank: Optional[int] = None
your_page: Optional[int] = None
@@ -276,7 +277,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
async def my_ratings(ctx: HttpContext) -> None:
user = current_user(platform.oidc, ctx)
assert user is not None
rows = await PlayerRating.filter(user_sub=user.sub).order_by("game_type")
rows = await PlayerRating.filter(user_sub=user.principal).order_by("game_type")
await send_json(ctx, 200, {
"results": [
{
@@ -29,6 +29,7 @@ log = getLogger(__name__)
class PlayerStats:
"""One participant's data feeding a ``player_rating`` row update."""
# Canonical provider-plus-subject principal, despite the legacy field name.
user_sub: str
won: bool
score: float
@@ -43,9 +44,9 @@ async def apply_match_stats(
) -> Dict[str, int]:
"""Update the ratings and leaderboard aggregates of ``teams``.
``teams`` groups the participants' subs into the two opposing sides;
``winner_team`` is the index of the winning side. Ratings are read
from (and written back to) the ``player_rating`` table; unrated
``teams`` groups the participants' canonical principals into the two
opposing sides; ``winner_team`` is the index of the winning side. Ratings
are read from (and written back to) the ``player_rating`` table; unrated
players start at the initial rating. Alongside the Elo delta the
transaction records each participant's wins, accumulated points and
latest display name, so the leaderboard is a single-table query.
@@ -121,7 +121,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
# Anonymous and non-seated viewers are welcome as read-only
# spectators; only an unknown game id is refused.
user = auth.get_ws_user(ws)
sub: Optional[str] = user.sub if user is not None else None
sub: Optional[str] = user.principal if user is not None else None
session = await platform.game_store.load(game_id)
if session is None:
@@ -43,7 +43,11 @@ from tavolo.platform import (
PlayerResult,
Seat,
)
from tavolo.platform.auth import get_ws_user # noqa: F401 (re-exported for patching)
from tavolo.platform.auth import (
AuthenticatedUser,
canonical_principal,
get_ws_user, # noqa: F401 (re-exported for patching)
)
from tavolo.platform.deadlines import DeadlineScheduler
from tavolo.platform.openapi import SCHEMAS
from tavolo.platform.registry import GameRegistry
@@ -326,13 +330,32 @@ def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
return OIDCUser({"sub": sub, "preferred_username": name or sub})
def make_principal(sub: str, provider: str = "default") -> str:
return canonical_principal(provider, sub)
def make_identity(
sub: str, name: Optional[str] = None, provider: str = "default"
) -> AuthenticatedUser:
claims = make_user(sub, name)
return AuthenticatedUser(
provider=provider,
claims=claims,
principal=canonical_principal(provider, sub),
)
@contextlib.contextmanager
def oidc_user(oidc: OIDCProviders, sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
"""Context manager: make ``oidc.get_users`` return this user for the
first configured provider."""
def oidc_user(
oidc: OIDCProviders,
sub: str,
name: Optional[str] = None,
provider: Optional[str] = None,
) -> Iterator[OIDCUser]:
"""Context manager: fake a login, using the first provider by default."""
user = make_user(sub, name)
provider = next(iter(oidc.providers))
patcher = _mock.patch.object(oidc, "get_users", return_value={provider: user})
selected = provider or next(iter(oidc.providers))
patcher = _mock.patch.object(oidc, "get_users", return_value={selected: user})
patcher.start()
try:
yield user
@@ -341,13 +364,20 @@ def oidc_user(oidc: OIDCProviders, sub: str, name: Optional[str] = None) -> Iter
@contextlib.contextmanager
def ws_users(users: Sequence[OIDCUser]) -> Iterator[None]:
"""Context manager: patch ``auth.get_ws_user`` to hand out ``users``
one per websocket connection, in order. Once exhausted it keeps
returning the last user."""
def ws_users(
users: Sequence[OIDCUser | AuthenticatedUser], provider: str = "default"
) -> Iterator[None]:
"""Patch ``auth.get_ws_user`` to hand out identities in sequence."""
from tavolo.platform import auth
remaining = list(users)
remaining = [
user if isinstance(user, AuthenticatedUser) else AuthenticatedUser(
provider=provider,
claims=user,
principal=canonical_principal(provider, user.sub),
)
for user in users
]
last = remaining[-1] if remaining else None
def _next(_ws):
@@ -16,6 +16,7 @@ from kaya.session import InMemorySessionStore, Session, SessionMixin
from helpers import async_test
from tavolo.platform.auth import (
canonical_principal,
current_user,
display_name,
get_ws_user,
@@ -53,12 +54,16 @@ class SessionUserTest(unittest.TestCase):
def test_finds_default_provider_user(self) -> None:
user = session_user(Session("sid", {"oidc:default:user": {"sub": "alice"}}))
assert user is not None
self.assertEqual("alice", user.sub)
self.assertEqual("alice", user.claims.sub)
self.assertEqual("default", user.provider)
self.assertEqual(canonical_principal("default", "alice"), user.principal)
def test_finds_user_of_any_provider(self) -> None:
user = session_user(Session("sid", {"oidc:google:user": {"sub": "alice"}}))
assert user is not None
self.assertEqual("alice", user.sub)
self.assertEqual("alice", user.claims.sub)
self.assertEqual("google", user.provider)
self.assertEqual(canonical_principal("google", "alice"), user.principal)
def test_ignores_flow_state_and_tokens(self) -> None:
session = Session(
@@ -79,6 +84,12 @@ class SessionUserTest(unittest.TestCase):
session = Session("sid", {"oidc:default:user": "alice"})
self.assertIsNone(session_user(session))
def test_ignores_missing_or_invalid_subjects(self) -> None:
for claims in ({}, {"sub": ""}, {"sub": 42}):
with self.subTest(claims=claims):
session = Session("sid", {"oidc:default:user": claims})
self.assertIsNone(session_user(session))
class GetWsUserTest(unittest.TestCase):
def test_returns_user_from_provider_namespaced_session(self) -> None:
@@ -88,12 +99,33 @@ class GetWsUserTest(unittest.TestCase):
)
user = get_ws_user(_ws(session))
assert user is not None
self.assertEqual("alice", user.sub)
self.assertEqual("alice", user.claims.sub)
def test_missing_session_returns_none(self) -> None:
self.assertIsNone(get_ws_user(SimpleNamespace()))
class CanonicalPrincipalTest(unittest.TestCase):
def test_same_subject_at_different_providers_is_distinct(self) -> None:
google = canonical_principal("google", "shared")
github = canonical_principal("github", "shared")
self.assertNotEqual(google, github)
self.assertEqual("oidc:v1:google:shared", google)
self.assertEqual("oidc:v1:github:shared", github)
def test_subject_may_contain_colons(self) -> None:
self.assertEqual(
"oidc:v1:google:tenant:alice",
canonical_principal("google", "tenant:alice"),
)
def test_invalid_components_are_rejected(self) -> None:
for provider, sub in (("", "alice"), ("bad:provider", "alice"), ("google", "")):
with self.subTest(provider=provider, sub=sub):
with self.assertRaises(ValueError):
canonical_principal(provider, sub)
class DisplayNameTest(unittest.TestCase):
"""The claim priority used for the name shown at the table."""
@@ -149,7 +181,9 @@ class CurrentUserTest(unittest.TestCase):
_ctx({"oidc:google:user": {"sub": "alice"}}),
)
assert user is not None
self.assertEqual("alice", user.sub)
self.assertEqual("alice", user.claims.sub)
self.assertEqual("google", user.provider)
self.assertEqual(canonical_principal("google", "alice"), user.principal)
def test_first_configured_provider_wins(self) -> None:
# Single-identity sessions never hold two users; this only pins the
@@ -164,7 +198,8 @@ class CurrentUserTest(unittest.TestCase):
),
)
assert user is not None
self.assertEqual("alice", user.sub)
self.assertEqual("alice", user.claims.sub)
self.assertEqual(canonical_principal("default", "alice"), user.principal)
class RequireAuthTest(unittest.TestCase):
@@ -198,7 +233,7 @@ class RequireAuthTest(unittest.TestCase):
async def handler(request_ctx: Any) -> None:
user = current_user(oidc, request_ctx)
assert user is not None
handled.append(user.sub)
handled.append(user.principal)
await handler(ctx)
self.assertEqual(["alice"], handled)
self.assertEqual([canonical_principal("default", "alice")], handled)
@@ -5,7 +5,7 @@ import unittest
from httpx import ASGITransport, AsyncClient
from helpers import async_test, make_platform, oidc_user
from helpers import async_test, make_platform, make_principal, oidc_user
class GamesRouteTest(unittest.TestCase):
@@ -118,7 +118,7 @@ class GamesRouteTest(unittest.TestCase):
self.assertEqual("lobby", body["phase"])
self.assertEqual(5, body["target"])
self.assertEqual(1, body["seats_open"])
self.assertEqual("alice", body["players"][0]["sub"])
self.assertEqual(make_principal("alice"), body["players"][0]["sub"])
# Join codes are case-insensitive.
with oidc_user(platform.oidc, "mallory"):
@@ -219,7 +219,7 @@ class GamesRouteTest(unittest.TestCase):
self.assertEqual(1, entry["seats_open"])
self.assertEqual(0, entry["your_seat"])
self.assertNotIn("join_code", entry)
self.assertEqual("alice", entry["players"][0]["sub"])
self.assertEqual(make_principal("alice"), entry["players"][0]["sub"])
# Bob's own game is the only one he sees; Alice's is not his.
with oidc_user(platform.oidc, "bob"):
@@ -246,6 +246,57 @@ class GamesRouteTest(unittest.TestCase):
self.assertEqual(0, by_id[mine.json()["id"]]["seats_open"])
class CrossProviderGamesTest(unittest.TestCase):
@async_test
async def test_same_sub_other_provider_does_not_inherit_player_access(self) -> None:
app, platform, _ = make_platform(oidc_providers=("default", "google"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post("/api/games", json={})
code = created.json()["join_code"]
game_id = created.json()["id"]
with oidc_user(platform.oidc, "bob"):
await client.post("/api/games/join", json={"code": code})
with oidc_user(platform.oidc, "alice", provider="google"):
listed = await client.get("/api/me/games")
watched = await client.get(f"/api/games/{game_id}")
self.assertEqual([], listed.json()["results"])
self.assertTrue(watched.json()["spectator"])
self.assertFalse(watched.json()["viewer_seated"])
self.assertNotIn("join_code", watched.json())
# The original provider's identical subject remains seated.
with oidc_user(platform.oidc, "alice"):
owned = await client.get(f"/api/games/{game_id}")
self.assertFalse(owned.json()["spectator"])
self.assertTrue(owned.json()["viewer_seated"])
self.assertIn("join_code", owned.json())
@async_test
async def test_same_sub_at_two_providers_occupies_distinct_seats(self) -> None:
app, platform, _ = make_platform(oidc_providers=("default", "google"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post("/api/games", json={})
code = created.json()["join_code"]
with oidc_user(platform.oidc, "alice", provider="google"):
joined = await client.post("/api/games/join", json={"code": code})
self.assertEqual(200, joined.status_code)
self.assertTrue(joined.json()["viewer_seated"])
session = await platform.game_store.load(created.json()["id"])
assert session is not None
self.assertEqual(
{
make_principal("alice"),
make_principal("alice", "google"),
},
{seat.user_sub for seat in session.players},
)
class GameTypesRouteTest(unittest.TestCase):
@async_test
async def test_lists_available_game_types(self) -> None:
@@ -311,7 +362,7 @@ class MeRouteTest(unittest.TestCase):
response = await client.get("/api/me")
self.assertEqual(200, response.status_code)
self.assertEqual(
{"sub": "alice", "name": "alice", "picture": None},
{"sub": make_principal("alice"), "name": "alice", "picture": None},
response.json(),
)
@@ -12,7 +12,10 @@ from httpx import ASGITransport, AsyncClient
from tavolo.platform.elo import INITIAL_RATING
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
from tavolo.platform.stats import save_match_result
from helpers import DummyEngine, async_test, make_platform, oidc_user, use_db
from helpers import DummyEngine, async_test, make_platform, make_principal, oidc_user, use_db
ALICE = make_principal("alice")
BOB = make_principal("bob")
def _finished_session(target: int = 2):
@@ -24,12 +27,12 @@ def _finished_session(target: int = 2):
id="stats-game",
game_type=engine.id,
join_code="STATS1",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="alice", team="A")],
creator_sub=ALICE,
players=[Seat(user_sub=ALICE, display_name="alice", team="A")],
)
engine.create(session, {"target": target})
engine.join(session, "bob", "bob")
engine.handle_action(session, "alice", "play", {})
engine.join(session, BOB, "bob")
engine.handle_action(session, ALICE, "play", {})
return engine, session
@@ -39,7 +42,7 @@ class SaveMatchResultTest(unittest.TestCase):
_, _, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
engine.handle_action(session, ALICE, "play", {})
self.assertTrue(engine.is_finished(session))
with ctx:
@@ -53,19 +56,19 @@ class SaveMatchResultTest(unittest.TestCase):
# The game type travels from the session onto the row; the
# engine's summary is stored verbatim as the result.
self.assertEqual("dummy", match.game_type)
self.assertEqual("alice", match.result["winner"])
self.assertEqual(ALICE, match.result["winner"])
self.assertEqual(2, match.result["plays"])
winners = await MatchPlayer.filter(won=True)
self.assertEqual({"alice"}, {p.user_sub for p in winners})
self.assertEqual({ALICE}, {p.user_sub for p in winners})
scores = {p.user_sub: p.score for p in await MatchPlayer.all()}
self.assertEqual({"alice": 2.0, "bob": 0.0}, scores)
self.assertEqual({ALICE: 2.0, BOB: 0.0}, scores)
@async_test
async def test_finished_match_updates_elo_ratings(self) -> None:
_, _, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
engine.handle_action(session, ALICE, "play", {})
with ctx:
await save_match_result(session, engine)
@@ -75,29 +78,59 @@ class SaveMatchResultTest(unittest.TestCase):
}
self.assertEqual(2, len(ratings))
# Two players at 1500: winner gains K/2, loser loses it.
self.assertEqual(INITIAL_RATING + 16, ratings["alice"].rating)
self.assertEqual(1, ratings["alice"].matches_played)
self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating)
self.assertEqual(1, ratings["bob"].matches_played)
self.assertEqual(INITIAL_RATING + 16, ratings[ALICE].rating)
self.assertEqual(1, ratings[ALICE].matches_played)
self.assertEqual(INITIAL_RATING - 16, ratings[BOB].rating)
self.assertEqual(1, ratings[BOB].matches_played)
# The leaderboard aggregates ride along in the same transaction.
self.assertEqual(1, ratings["alice"].wins)
self.assertEqual(0, ratings["bob"].wins)
self.assertEqual(2.0, ratings["alice"].points)
self.assertEqual(0.0, ratings["bob"].points)
self.assertEqual("alice", ratings["alice"].display_name)
self.assertEqual(1, ratings[ALICE].wins)
self.assertEqual(0, ratings[BOB].wins)
self.assertEqual(2.0, ratings[ALICE].points)
self.assertEqual(0.0, ratings[BOB].points)
self.assertEqual("alice", ratings[ALICE].display_name)
# The per-match delta is recorded on each participation row.
deltas = {
p.user_sub: p.elo_delta for p in await MatchPlayer.all()
}
self.assertEqual({"alice": 16, "bob": -16}, deltas)
self.assertEqual({ALICE: 16, BOB: -16}, deltas)
@async_test
async def test_same_sub_at_two_providers_has_independent_stats(self) -> None:
from tavolo.platform import GameSession, Seat
_, _, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine = DummyEngine()
google_alice = make_principal("alice", "google")
session = GameSession(
id="cross-provider-stats",
game_type=engine.id,
join_code="XPROV1",
creator_sub=ALICE,
players=[Seat(user_sub=ALICE, display_name="Alice", team="A")],
)
engine.create(session, {"target": 1})
engine.join(session, google_alice, "Alice")
engine.handle_action(session, ALICE, "play", {})
with ctx:
await save_match_result(session, engine)
self.assertEqual(
{ALICE, google_alice},
{row.user_sub for row in await MatchPlayer.all()},
)
self.assertEqual(
{ALICE, google_alice},
{row.user_sub for row in await PlayerRating.all()},
)
@async_test
async def test_elo_ratings_accumulate_across_matches(self) -> None:
_, _, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
engine.handle_action(session, ALICE, "play", {})
from tavolo.platform import GameSession, Seat
@@ -105,14 +138,14 @@ class SaveMatchResultTest(unittest.TestCase):
id="stats-game-2",
game_type="dummy",
join_code="STATS2",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="alice", team="A")],
creator_sub=ALICE,
players=[Seat(user_sub=ALICE, display_name="alice", team="A")],
)
engine2.create(session2, {"target": 2})
engine2.join(session2, "bob", "bob")
engine2.join(session2, BOB, "bob")
# Bob wins the second match.
engine2.handle_action(session2, "bob", "play", {})
engine2.handle_action(session2, "bob", "play", {})
engine2.handle_action(session2, BOB, "play", {})
engine2.handle_action(session2, BOB, "play", {})
with ctx:
await save_match_result(session, engine)
@@ -123,9 +156,9 @@ class SaveMatchResultTest(unittest.TestCase):
}
# Match 1: even teams, alice wins (+16/-16). Match 2: alice
# is now the favourite (1516 vs 1484), so losing costs 17.
self.assertEqual(INITIAL_RATING - 1, ratings["alice"])
self.assertEqual(INITIAL_RATING + 1, ratings["bob"])
bob = await PlayerRating.get(user_sub="bob")
self.assertEqual(INITIAL_RATING - 1, ratings[ALICE])
self.assertEqual(INITIAL_RATING + 1, ratings[BOB])
bob = await PlayerRating.get(user_sub=BOB)
self.assertEqual(2, bob.matches_played)
@async_test
@@ -160,11 +193,12 @@ async def _seed_two_matches(
result={"winner": winner, "plays": 3 + index},
)
for seat, sub in enumerate(("alice", "bob")):
principal = make_principal(sub)
score = 2.0 + index if sub == winner else 1.0
await MatchPlayer.create(
id=uuid.uuid4(),
match=match,
user_sub=sub,
user_sub=principal,
display_name=sub,
seat=seat,
team="A" if seat == 0 else "B",
@@ -172,22 +206,22 @@ async def _seed_two_matches(
score=score,
)
aggregate = aggregates.setdefault(
(sub, game_types[index]),
{"matches": 0, "wins": 0, "points": 0.0},
(principal, game_types[index]),
{"matches": 0, "wins": 0, "points": 0.0, "display_name": sub},
)
aggregate["matches"] += 1
aggregate["wins"] += 1 if sub == winner else 0
aggregate["points"] += score
for (sub, game_type), aggregate in aggregates.items():
for (principal, game_type), aggregate in aggregates.items():
await PlayerRating.create(
id=uuid.uuid4(),
user_sub=sub,
user_sub=principal,
game_type=game_type,
rating=INITIAL_RATING,
matches_played=aggregate["matches"],
wins=aggregate["wins"],
points=aggregate["points"],
display_name=sub,
display_name=aggregate["display_name"],
)
@@ -266,13 +300,13 @@ class StatsRouteTest(unittest.TestCase):
response = await client.get("/api/leaderboard")
self.assertEqual(200, response.status_code)
by_sub = {row["user_sub"]: row for row in response.json()["results"]}
self.assertEqual(2, by_sub["alice"]["matches"])
self.assertEqual(1, by_sub["alice"]["wins"]) # alice won match 1
self.assertEqual(3.0, by_sub["alice"]["points"]) # 2.0 + 1.0
self.assertEqual(1, by_sub["bob"]["wins"]) # bob won match 2
self.assertEqual(4.0, by_sub["bob"]["points"]) # 1.0 + 3.0
self.assertEqual(2, by_sub[ALICE]["matches"])
self.assertEqual(1, by_sub[ALICE]["wins"]) # alice won match 1
self.assertEqual(3.0, by_sub[ALICE]["points"]) # 2.0 + 1.0
self.assertEqual(1, by_sub[BOB]["wins"]) # bob won match 2
self.assertEqual(4.0, by_sub[BOB]["points"]) # 1.0 + 3.0
# Bob leads on points after tying Alice on wins.
self.assertEqual("bob", response.json()["results"][0]["user_sub"])
self.assertEqual(BOB, response.json()["results"][0]["user_sub"])
@async_test
async def test_leaderboard_includes_elo_and_sorts_by_it(self) -> None:
@@ -282,7 +316,7 @@ class StatsRouteTest(unittest.TestCase):
with ctx:
# Alice outranks everyone despite Bob leading on points.
await PlayerRating.filter(
user_sub="alice", game_type="dummy"
user_sub=ALICE, game_type="dummy"
).update(rating=1600)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
@@ -290,10 +324,10 @@ class StatsRouteTest(unittest.TestCase):
self.assertEqual(200, response.status_code)
results = response.json()["results"]
by_sub = {row["user_sub"]: row for row in results}
self.assertEqual(1600, by_sub["alice"]["elo"])
self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"])
self.assertEqual(1600, by_sub[ALICE]["elo"])
self.assertEqual(INITIAL_RATING, by_sub[BOB]["elo"])
# Elo outranks wins/points.
self.assertEqual("alice", results[0]["user_sub"])
self.assertEqual(ALICE, results[0]["user_sub"])
@async_test
async def test_leaderboard_elo_scoped_by_game_type(self) -> None:
@@ -304,13 +338,13 @@ class StatsRouteTest(unittest.TestCase):
ctx = await use_db(tortoise_mixin)
with ctx:
await PlayerRating.filter(
user_sub="bob", game_type="dummy"
user_sub=BOB, game_type="dummy"
).update(rating=1516)
# Bob's rating in another game must not leak into the
# dummy leaderboard.
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="bob",
user_sub=BOB,
game_type="second",
rating=1800,
matches_played=1,
@@ -321,8 +355,8 @@ class StatsRouteTest(unittest.TestCase):
self.assertEqual(200, response.status_code)
results = response.json()["results"]
by_sub = {row["user_sub"]: row for row in results}
self.assertEqual(1516, by_sub["bob"]["elo"])
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
self.assertEqual(1516, by_sub[BOB]["elo"])
self.assertEqual(INITIAL_RATING, by_sub[ALICE]["elo"])
self.assertEqual("second", platform.registry.all()[1].id)
@async_test
@@ -334,7 +368,7 @@ class StatsRouteTest(unittest.TestCase):
for index, sub in enumerate(("carol", "dave", "erin", "frank")):
await PlayerRating.create(
id=uuid.uuid4(),
user_sub=sub,
user_sub=make_principal(sub),
game_type="dummy",
rating=1600 + index,
matches_played=1,
@@ -354,20 +388,21 @@ class StatsRouteTest(unittest.TestCase):
self.assertEqual(6, body["total"])
self.assertEqual(2, len(body["results"]))
self.assertEqual(
["frank", "erin"], [row["user_sub"] for row in body["results"]]
[make_principal("frank"), make_principal("erin")],
[row["user_sub"] for row in body["results"]],
)
self.assertEqual(
["dave", "carol"],
[make_principal("dave"), make_principal("carol")],
[row["user_sub"] for row in second.json()["results"]],
)
self.assertEqual(
["bob", "alice"],
[BOB, ALICE],
[row["user_sub"] for row in third.json()["results"]],
)
# Out-of-range pages clamp to the last page.
self.assertEqual(3, beyond.json()["page"])
self.assertEqual(
["bob", "alice"],
[BOB, ALICE],
[row["user_sub"] for row in beyond.json()["results"]],
)
@@ -380,7 +415,7 @@ class StatsRouteTest(unittest.TestCase):
for index, sub in enumerate(("carol", "dave")):
await PlayerRating.create(
id=uuid.uuid4(),
user_sub=sub,
user_sub=make_principal(sub),
game_type="dummy",
rating=1600 + index,
matches_played=1,
@@ -398,7 +433,7 @@ class StatsRouteTest(unittest.TestCase):
self.assertEqual(4, body["page"])
self.assertEqual(4, body["your_rank"])
self.assertEqual(4, body["your_page"])
self.assertEqual("alice", body["results"][0]["user_sub"])
self.assertEqual(ALICE, body["results"][0]["user_sub"])
anonymous_body = anonymous.json()
self.assertEqual(1, anonymous_body["page"])
self.assertIsNone(anonymous_body["your_rank"])
@@ -421,7 +456,7 @@ class StatsRouteTest(unittest.TestCase):
app, platform, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
engine.handle_action(session, ALICE, "play", {})
with ctx:
await save_match_result(session, engine)
transport = ASGITransport(app=app)
@@ -433,10 +468,37 @@ class StatsRouteTest(unittest.TestCase):
p["user_sub"]: p
for p in response.json()["results"][0]["players"]
}
self.assertEqual(16, players["alice"]["elo_delta"])
self.assertEqual(-16, players["bob"]["elo_delta"])
self.assertEqual(16, players[ALICE]["elo_delta"])
self.assertEqual(-16, players[BOB]["elo_delta"])
self.assertEqual(16, response.json()["results"][0]["your_elo_delta"])
@async_test
async def test_same_sub_other_provider_cannot_read_history_or_ratings(self) -> None:
app, platform, tortoise_mixin = make_platform(
oidc_providers=("default", "google")
)
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, ALICE, "play", {})
with ctx:
await save_match_result(session, engine)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice", provider="google"):
matches = await client.get("/api/me/matches")
ratings = await client.get("/api/me/ratings")
board = await client.get("/api/leaderboard?focus=me")
self.assertEqual([], matches.json()["results"])
self.assertEqual([], ratings.json()["results"])
self.assertIsNone(board.json()["your_rank"])
with oidc_user(platform.oidc, "alice"):
owned_matches = await client.get("/api/me/matches")
owned_ratings = await client.get("/api/me/ratings")
self.assertEqual(1, len(owned_matches.json()["results"]))
self.assertEqual(1, len(owned_ratings.json()["results"]))
@async_test
async def test_my_ratings_requires_auth(self) -> None:
app, _, _ = make_platform()
@@ -452,14 +514,14 @@ class StatsRouteTest(unittest.TestCase):
with ctx:
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="alice",
user_sub=ALICE,
game_type="dummy",
rating=1516,
matches_played=1,
)
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="bob",
user_sub=BOB,
game_type="dummy",
rating=1484,
matches_played=1,
@@ -516,9 +578,9 @@ class GameTypeFilterTest(unittest.TestCase):
self.assertEqual(200, scoped.status_code)
by_sub = {row["user_sub"]: row for row in scoped.json()["results"]}
# Only the first match counts: one match per player, alice won.
self.assertEqual(1, by_sub["alice"]["matches"])
self.assertEqual(1, by_sub["alice"]["wins"])
self.assertEqual(0, by_sub["bob"]["wins"])
self.assertEqual(1, by_sub[ALICE]["matches"])
self.assertEqual(1, by_sub[ALICE]["wins"])
self.assertEqual(0, by_sub[BOB]["wins"])
self.assertEqual(400, unknown.status_code)
@@ -10,7 +10,7 @@ from httpx_ws import WebSocketDisconnect as HttpxWebSocketDisconnect, aconnect_w
from httpx_ws.transport import ASGIWebSocketTransport
from kaya.core import WebSocket, WebSocketDisconnect, WebSocketMessage
from helpers import async_test, make_platform, make_user, oidc_user, ws_users
from helpers import async_test, make_identity, make_platform, make_user, oidc_user, ws_users
class AbruptCloseSocket(WebSocket):
@@ -288,6 +288,27 @@ class WebSocketTest(unittest.TestCase):
self.assertEqual("error", error["type"])
self.assertEqual("forbidden", error["code"])
@async_test
async def test_same_sub_other_provider_is_a_spectator(self) -> None:
app, platform, _ = make_platform(oidc_providers=("default", "google"))
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_identity("alice", provider="google")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertTrue(first["game"]["spectator"])
self.assertFalse(first["game"]["viewer_seated"])
self.assertNotIn("join_code", first["game"])
await ws.send_json({"action": "play"})
error = await ws.receive_json()
self.assertEqual("error", error["type"])
self.assertEqual("forbidden", error["code"])
@async_test
async def test_anonymous_user_watches_as_spectator(self) -> None:
app, platform, _ = make_platform()
+2 -2
View File
@@ -36,8 +36,8 @@ application composition root.
## Development (from `server/`)
```sh
.venv/bin/python -m unittest discover -s packages/tavolo-scopa/tests
.venv/bin/python -m mypy -p tavolo.scopa
uv run python -m unittest discover -s packages/tavolo-scopa/tests
uv run mypy -p tavolo.scopa
```
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
@@ -224,6 +224,8 @@ class _ScopaBase(GameEngine):
)
elif kind == "hand_end":
for player in state.players:
if state.phase != PHASE_HAND_END:
break
engine.acknowledge_hand(state, player.sub)
log.info(
"hand %d auto-advanced after the acknowledgement timeout",
+2 -2
View File
@@ -31,8 +31,8 @@ application composition root.
## Development (from `server/`)
```sh
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
.venv/bin/python -m mypy -p tavolo.scopone
uv run python -m unittest discover -s packages/tavolo-scopone/tests
uv run mypy -p tavolo.scopone
```
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
@@ -234,6 +234,8 @@ class ScoponeEngine(GameEngine):
)
elif kind == "hand_end":
for player in state.players:
if state.phase != PHASE_HAND_END:
break
engine.acknowledge_hand(state, player.sub)
log.info(
"hand %d auto-advanced after the acknowledgement timeout",
+30 -4
View File
@@ -1,11 +1,37 @@
# NOTE: the tavolo-app distribution lives in packages/tavolo-app/ (same
# kaya-style layout as tavolo-platform and tavolo-scopone). This file
# exists only for tooling that reads configuration from the working
# directory:
# NOTE: the five tavolo distributions live in packages/ (same kaya-style
# layout as tavolo-platform and tavolo-scopone). This file is the root of
# the uv workspace and also holds tooling configuration that is read from
# the working directory:
# - uv locks and syncs the whole workspace from here;
# - aerich reads [tool.aerich] from here (server/ locally, /app in the
# db-migrate compose service), next to ./migrations;
# - mypy reads [tool.mypy] from here when run from server/.
# See the Migrations section in README.md.
[tool.uv]
# The old pip-compile setup used --index-url for the private Gitea index
# plus --extra-index-url for PyPI, so the best version across both indexes
# won. Match that behavior with uv.
index-strategy = "unsafe-best-match"
[[tool.uv.index]]
name = "gitea"
url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/simple"
default = true
[[tool.uv.index]]
name = "pypi"
url = "https://pypi.org/simple"
[tool.uv.workspace]
members = ["packages/*"]
[tool.uv.sources]
tavolo-platform = { workspace = true }
tavolo-scopone = { workspace = true }
tavolo-scopa = { workspace = true }
tavolo-briscola = { workspace = true }
[tool.aerich]
tortoise_orm = "tavolo.aerich_config.TORTOISE_ORM"
location = "./migrations"
-184
View File
@@ -1,184 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.14
# by the following command (run from server/packages/tavolo-app/):
#
# pip-compile --allow-unsafe --extra otel --extra-index-url=https://pypi.org/simple --index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --output-file=../../requirements.txt pyproject.toml
#
# NOTE: the tavolo-platform, tavolo-scopone, tavolo-scopa, tavolo-briscola
# and tavolo-app sibling packages are NOT pinned here: they are installed
# from ./packages in
# the Dockerfile and in development (pip install -e ./packages/tavolo-platform
# -e ./packages/tavolo-scopone -e ./packages/tavolo-scopa
# -e ./packages/tavolo-briscola
# -e ./packages/tavolo-app). All of their
# third-party dependencies are direct dependencies of tavolo-app, so this
# lock covers the full closure. The otel extra (kaya-otel and its
# OpenTelemetry dependencies) IS included, so OTEL_ENABLED works in the
# Docker image; pip-compile cannot resolve the unpublishable sibling
# packages, so regeneration drops them from a copy of the input file (the
# existing pins constrain the rest).
#
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
--extra-index-url https://pypi.org/simple
aerich==0.10.1
# via tavolo-app (pyproject.toml)
aiosqlite==0.22.1
# via tortoise-orm
anyio==4.15.1
# via
# aerich
# httpx
# tortoise-orm
asyncclick==8.4.2.1
# via aerich
asyncpg==0.31.0
# via tavolo-app (pyproject.toml)
certifi==2026.7.22
# via
# httpcore
# httpx
cffi==2.1.1
# via cryptography
click==8.5.0
# via granian
cryptography==50.0.1
# via pyjwt
dictdiffer==0.10.0
# via aerich
googleapis-common-protos==1.75.4
# via opentelemetry-exporter-otlp-proto-http
granian==2.8.3
# via
# kaya-rsgi
# tavolo-app (pyproject.toml)
h11==0.16.0
# via httpcore
httpcore==1.0.9
# via httpx
httpx==0.28.1
# via
# kaya-oidc
# tavolo-app (pyproject.toml)
idna==3.20
# via
# anyio
# httpx
iso8601==2.1.0
# via tortoise-orm
kaya-core==0.0.7
# via
# kaya-cors
# kaya-oidc
# kaya-openapi
# kaya-otel
# kaya-rsgi
# kaya-session
# tavolo-app (pyproject.toml)
kaya-cors==0.0.7
# via tavolo-app (pyproject.toml)
kaya-oidc==0.0.7
# via tavolo-app (pyproject.toml)
kaya-openapi==0.0.8
# via tavolo-app (pyproject.toml)
kaya-otel==0.0.7
# via tavolo-app (pyproject.toml)
kaya-rsgi==0.0.7
# via tavolo-app (pyproject.toml)
kaya-session==0.0.7
# via
# kaya-oidc
# kaya-session-redis
# tavolo-app (pyproject.toml)
kaya-session-redis==0.0.7
# via tavolo-app (pyproject.toml)
opentelemetry-api==1.45.0
# via
# opentelemetry-exporter-http-transport
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-instrumentation
# opentelemetry-instrumentation-asyncpg
# opentelemetry-instrumentation-redis
# opentelemetry-instrumentation-system-metrics
# opentelemetry-sdk
# opentelemetry-semantic-conventions
opentelemetry-exporter-http-transport[urllib3]==0.66b0
# via opentelemetry-exporter-otlp-proto-http
opentelemetry-exporter-otlp-common==0.66b0
# via opentelemetry-exporter-otlp-proto-http
opentelemetry-exporter-otlp-proto-common==1.45.0
# via opentelemetry-exporter-otlp-proto-http
opentelemetry-exporter-otlp-proto-http==1.45.0
# via kaya-otel
opentelemetry-instrumentation==0.66b0
# via
# opentelemetry-instrumentation-asyncpg
# opentelemetry-instrumentation-redis
# opentelemetry-instrumentation-system-metrics
opentelemetry-instrumentation-asyncpg==0.66b0
# via tavolo-app (pyproject.toml)
opentelemetry-instrumentation-redis==0.66b0
# via tavolo-app (pyproject.toml)
opentelemetry-instrumentation-system-metrics==0.66b0
# via tavolo-app (pyproject.toml)
opentelemetry-proto==1.45.0
# via
# opentelemetry-exporter-otlp-proto-common
# opentelemetry-exporter-otlp-proto-http
opentelemetry-sdk==1.45.0
# via
# kaya-otel
# opentelemetry-exporter-otlp-common
# opentelemetry-exporter-otlp-proto-http
opentelemetry-semantic-conventions==0.66b0
# via
# opentelemetry-instrumentation
# opentelemetry-instrumentation-asyncpg
# opentelemetry-instrumentation-redis
# opentelemetry-instrumentation-system-metrics
# opentelemetry-sdk
packaging==26.3
# via opentelemetry-instrumentation
protobuf==7.36.2
# via
# googleapis-common-protos
# opentelemetry-proto
psutil==7.2.2
# via opentelemetry-instrumentation-system-metrics
pwo==0.1.2
# via
# kaya-core
# kaya-rsgi
# kaya-session
# tavolo-app (pyproject.toml)
pycparser==3.0
# via cffi
pyjwt[crypto]==2.15.1
# via
# kaya-oidc
# tavolo-app (pyproject.toml)
pypika-tortoise==0.6.5
# via tortoise-orm
pyyaml==6.0.3
# via tavolo-app (pyproject.toml)
redis==8.1.0
# via
# kaya-session-redis
# tavolo-app (pyproject.toml)
tortoise-orm==1.1.8
# via aerich
typing-extensions==4.16.0
# via
# anyio
# kaya-core
# opentelemetry-api
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-sdk
# opentelemetry-semantic-conventions
# pwo
urllib3==2.8.0
# via opentelemetry-exporter-http-transport
wrapt==2.5.0
# via
# opentelemetry-instrumentation
# opentelemetry-instrumentation-redis
+2 -2
View File
@@ -1,5 +1,5 @@
"""Test helpers package for the tavolo-app integration suite."""
from .asynctest import async_test
from .oidc import make_user, oidc_user, ws_users
from .oidc import make_principal, make_user, oidc_user, ws_users
__all__ = ["async_test", "make_user", "oidc_user", "ws_users"]
__all__ = ["async_test", "make_principal", "make_user", "oidc_user", "ws_users"]
+16 -2
View File
@@ -17,6 +17,7 @@ from kaya.oidc import OIDCUser
# ``tavolo.platform.auth`` themselves.
from tavolo.app import oidc
from tavolo.platform import auth
from tavolo.platform.auth import AuthenticatedUser, canonical_principal
# The app's first configured provider (the test environment configures one
# named "default").
@@ -37,12 +38,17 @@ def make_user(
return OIDCUser(claims)
def make_principal(sub: str, provider: str = _PROVIDER) -> str:
return canonical_principal(provider, sub)
@contextlib.contextmanager
def oidc_user(
sub: str,
name: Optional[str] = None,
email: Optional[str] = None,
picture: Optional[str] = None,
provider: Optional[str] = None,
) -> Iterator[OIDCUser]:
"""Context manager: make ``oidc.get_users`` return this user.
@@ -52,7 +58,8 @@ def oidc_user(
scope's avatar claim.
"""
user = make_user(sub, name, email, picture)
patcher = _mock.patch.object(oidc, "get_users", return_value={_PROVIDER: user})
selected = provider or _PROVIDER
patcher = _mock.patch.object(oidc, "get_users", return_value={selected: user})
patcher.start()
try:
yield user
@@ -65,7 +72,14 @@ def ws_users(users: Sequence[OIDCUser]) -> Iterator[None]:
"""Context manager: patch ``auth.get_ws_user`` to hand out ``users``
one per websocket connection, in order. Once exhausted it keeps
returning the last user."""
remaining = list(users)
remaining = [
AuthenticatedUser(
provider=_PROVIDER,
claims=user,
principal=canonical_principal(_PROVIDER, user.sub),
)
for user in users
]
last = remaining[-1] if remaining else None
def _next(_ws):
+9 -9
View File
@@ -23,7 +23,7 @@ from tavolo.briscola.state import (
PlayerState,
)
from tavolo.platform import GameSession, Seat
from tests.helpers import async_test, make_user, oidc_user, ws_users
from tests.helpers import async_test, make_principal, make_user, oidc_user, ws_users
class BriscolaRoutesTest(unittest.TestCase):
@@ -235,14 +235,14 @@ class BriscolaTurnTimeoutTest(unittest.TestCase):
id="briscola-turn-timeout-1",
game_type=engine.id,
join_code="BT0001",
creator_sub="alice",
creator_sub=make_principal("alice"),
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
],
)
engine.create(session, {})
session.state.turn_timeout = 1
engine.join(session, "bob", "Bob")
engine.join(session, make_principal("bob"), "Bob")
await game_store.save(session)
ws_transport = ASGIWebSocketTransport(app=app)
@@ -283,10 +283,10 @@ async def _seed_hand_end_state() -> str:
id="briscola-hand-end-1",
game_type=engine.id,
join_code="BH0001",
creator_sub="alice",
creator_sub=make_principal("alice"),
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub="bob", display_name="Bob", team="B"),
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
],
)
session.state = BriscolaState(
@@ -298,11 +298,11 @@ async def _seed_hand_end_state() -> str:
briscola_suit="S",
players=[
PlayerState(
sub="alice", name="Alice", seat=0,
sub=make_principal("alice"), name="Alice", seat=0,
hand=[Card.parse("02D")], captured=[Card.parse("01C")],
),
PlayerState(
sub="bob", name="Bob", seat=1,
sub=make_principal("bob"), name="Bob", seat=1,
hand=[Card.parse("05D")], captured=[Card.parse("10C")],
),
],
+8 -6
View File
@@ -23,7 +23,7 @@ from tavolo.briscola.chiamata import (
Bid,
)
from tavolo.platform import GameSession, Seat
from tests.helpers import async_test, make_user, oidc_user, ws_users
from tests.helpers import async_test, make_principal, make_user, oidc_user, ws_users
PLAYERS = ("alice", "bob", "carol", "dave", "erin")
@@ -163,7 +163,7 @@ class ChiamataWebSocketTest(unittest.TestCase):
session = await game_store.load(game_id)
assert session is not None
for sub in ("carol", "dave", "erin", "alice"):
engine.handle_action(session, sub, "pass", {})
engine.handle_action(session, make_principal(sub), "pass", {})
await game_store.save(session)
self.assertEqual(PHASE_CALLING, session.state.phase)
self.assertEqual(1, session.state.caller)
@@ -227,15 +227,17 @@ class ChiamataTimeoutTest(unittest.TestCase):
id=game_id,
game_type="briscola_chiamata",
join_code=join_code,
creator_sub=PLAYERS[0],
creator_sub=make_principal(PLAYERS[0]),
players=[
Seat(user_sub=name, display_name=name.capitalize())
Seat(user_sub=make_principal(name), display_name=name.capitalize())
for name in PLAYERS
],
)
session.state = rules.create_game(PLAYERS[0], "Alice", turn_timeout=1)
session.state = rules.create_game(
make_principal(PLAYERS[0]), "Alice", turn_timeout=1
)
for name in PLAYERS[1:]:
rules.join_game(session.state, name, name.capitalize())
rules.join_game(session.state, make_principal(name), name.capitalize())
state_mutator(session.state)
await game_store.save(session)
return session.id
+87 -4
View File
@@ -1,4 +1,4 @@
"""Deadline-queue timeout tests (scopone game, full platform stack).
"""Deadline-queue timeout tests (full platform stack).
Timeouts must be driven by the persisted deadlines and the shared queue,
not by connected sockets: these tests seed sessions, queue their
@@ -10,12 +10,15 @@ from __future__ import annotations
import asyncio
import unittest
from datetime import datetime, timedelta, timezone
from itertools import permutations
from typing import Optional
from tavolo.app import game_store, platform, scheduler, tortoise_mixin
from tavolo.platform import GameSession, Seat
from tavolo.platform.deadlines import encode
from tavolo.platform.deadlines import DeadlineScheduler, encode
from tavolo.platform.models import Match
from tavolo.platform.registry import GameRegistry
from tavolo.platform.store import InMemoryGameStore
from tavolo.scopone.state import Card, PlayerState, ScoponeState
from tests.helpers import async_test
@@ -27,8 +30,9 @@ def _started_session(
code: str,
turn_timeout: int = 3600,
hand_ack_timeout: int = 3600,
game_type: str = "scopone_scientifico",
) -> GameSession:
engine = platform.registry.require("scopone_scientifico")
engine = platform.registry.require(game_type)
session = GameSession(
id=game_id,
game_type=engine.id,
@@ -39,7 +43,7 @@ def _started_session(
engine.create(session, {})
session.state.hand_ack_timeout = hand_ack_timeout
session.state.turn_timeout = turn_timeout
for name in PLAYERS[1:]:
for name in PLAYERS[1:engine.max_players]:
engine.join(session, name, name.capitalize())
return session
@@ -171,6 +175,85 @@ async def _phase_is(game_id: str, phase: str) -> Optional[GameSession]:
return session if session is not None and session.state.phase == phase else None
class PartialHandEndAckTest(unittest.TestCase):
@async_test
async def test_every_partial_ack_ordering_advances_persisted_game(self) -> None:
# Use an isolated store so the exhaustive matrix cannot affect other
# integration tests through the app's ongoing-game list or queue.
registry = GameRegistry(platform.registry.all())
store = InMemoryGameStore(registry)
local_scheduler = DeadlineScheduler(store, registry, heartbeat_ms=60_000)
loop = asyncio.get_running_loop()
case_count = 0
try:
for game_type in (
"scopa",
"scopa_a_quattro",
"scopone_scientifico",
"briscola",
"briscola_a_quattro",
):
engine = registry.require(game_type)
for ack_count in range(1, engine.max_players):
for ack_order in permutations(
range(engine.max_players), ack_count
):
case_count += 1
with self.subTest(game_type=game_type, ack_order=ack_order):
session = _started_session(
f"dl-partial-{case_count}",
f"DLP{case_count:03d}",
game_type=game_type,
)
state = session.state
state.phase = "hand_end"
state.turn_deadline = None
state.hand_end_deadline = (
datetime.now(timezone.utc) - timedelta(seconds=1)
).isoformat()
for seat in ack_order:
engine.handle_action(
session, state.players[seat].sub, "ack", {}
)
self.assertEqual(list(ack_order), state.acked)
await store.save(session)
current = engine.next_deadline(session)
assert current is not None
member = encode({
"game_id": session.id,
"kind": current.kind,
"token": current.token,
})
# Avoid racing the background consumer with this
# direct, deterministic process_due call.
await store.add_deadline(
member,
due_at=datetime.now(timezone.utc).timestamp() + 3600,
)
await local_scheduler.process_due(member)
result = await store.load(session.id)
assert result is not None
self.assertEqual("playing", result.state.phase)
self.assertEqual(2, result.state.hand_number)
self.assertEqual([], result.state.acked)
self.assertIsNone(result.state.hand_end_deadline)
following = engine.next_deadline(result)
assert following is not None
self.assertEqual("turn", following.kind)
self.assertNotIn(
member,
await store.due_deadlines(
float("inf"), limit=1000
),
)
finally:
local_scheduler.stop_consumer(loop)
self.assertEqual(124, case_count)
class ProcessDueTest(unittest.TestCase):
"""Direct ``process_due`` behaviour: engine revalidation and idempotency."""
+29 -3
View File
@@ -6,7 +6,7 @@ import unittest
from httpx import ASGITransport, AsyncClient
from tavolo.app import app
from tests.helpers import async_test, oidc_user
from tests.helpers import async_test, make_principal, oidc_user
class GamesRouteTest(unittest.TestCase):
@@ -37,7 +37,7 @@ class GamesRouteTest(unittest.TestCase):
with oidc_user("alice"):
snapshot = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, snapshot.status_code)
self.assertEqual("alice", snapshot.json()["players"][0]["sub"])
self.assertEqual(make_principal("alice"), snapshot.json()["players"][0]["sub"])
# A logged-in non-player is a read-only spectator: no join
# code, and the view is marked as a spectator view.
@@ -98,6 +98,32 @@ class GamesRouteTest(unittest.TestCase):
self.assertEqual(0, participant["captured_count"])
self.assertEqual(0, participant["scope"])
@async_test
async def test_same_sub_other_provider_cannot_read_private_hand(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
created = await client.post("/api/games", json={})
code = created.json()["join_code"]
game_id = created.json()["id"]
for player in ("bob", "carol", "dave"):
with oidc_user(player):
await client.post("/api/games/join", json={"code": code})
with oidc_user("alice", provider="google"):
watched = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, watched.status_code)
spectator = watched.json()
self.assertTrue(spectator["spectator"])
self.assertNotIn("join_code", spectator)
for participant in spectator["players"]:
self.assertNotIn("hand", participant)
with oidc_user("alice"):
owned = await client.get(f"/api/games/{game_id}")
self.assertFalse(owned.json()["spectator"])
self.assertIn("hand", owned.json()["players"][0])
@async_test
async def test_create_napola_option(self) -> None:
transport = ASGITransport(app=app)
@@ -180,7 +206,7 @@ class GamesRouteTest(unittest.TestCase):
self.assertFalse(body["napola"])
self.assertTrue(body["asso_pigliatutto"])
self.assertEqual(3, body["seats_open"])
self.assertEqual(["alice"], [p["sub"] for p in body["players"]])
self.assertEqual([make_principal("alice")], [p["sub"] for p in body["players"]])
for player in ("bob", "carol", "dave"):
with oidc_user(player):
+2 -2
View File
@@ -11,7 +11,7 @@ from httpx import ASGITransport, AsyncClient
from tavolo.app import app
from tavolo.config import settings
from tests.helpers import async_test, oidc_user
from tests.helpers import async_test, make_principal, oidc_user
class MeRouteTest(unittest.TestCase):
@@ -25,7 +25,7 @@ class MeRouteTest(unittest.TestCase):
# The email claim the IdP returns is neither stored nor exposed;
# no picture claim in this request.
self.assertEqual(
{"sub": "alice", "name": "alice", "picture": None},
{"sub": make_principal("alice"), "name": "alice", "picture": None},
response.json(),
)
+14 -14
View File
@@ -23,7 +23,7 @@ from tavolo.scopa.state import (
ScopaState,
)
from tavolo.platform import GameSession, Seat
from tests.helpers import async_test, make_user, oidc_user, ws_users
from tests.helpers import async_test, make_principal, make_user, oidc_user, ws_users
class ScopaRoutesTest(unittest.TestCase):
@@ -249,11 +249,11 @@ class ScopaWebSocketTest(unittest.TestCase):
turn=0,
players=[
PlayerState(
sub="alice", name="Alice", seat=0,
sub=make_principal("alice"), name="Alice", seat=0,
hand=[Card.parse("01D"), Card.parse("09C")],
),
PlayerState(
sub="bob", name="Bob", seat=1,
sub=make_principal("bob"), name="Bob", seat=1,
hand=[Card.parse("04D")],
),
],
@@ -264,10 +264,10 @@ class ScopaWebSocketTest(unittest.TestCase):
id="scopa-asso-pigliatutto-1",
game_type="scopa",
join_code="SA0001",
creator_sub="alice",
creator_sub=make_principal("alice"),
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub="bob", display_name="Bob", team="B"),
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
],
)
session.state = state
@@ -304,10 +304,10 @@ class ScopaWebSocketTest(unittest.TestCase):
turn=0,
players=[
PlayerState(
sub="alice", name="Alice", seat=0,
sub=make_principal("alice"), name="Alice", seat=0,
hand=[Card.parse("02D")],
),
PlayerState(sub="bob", name="Bob", seat=1),
PlayerState(sub=make_principal("bob"), name="Bob", seat=1),
],
table=[Card.parse("02C")],
)
@@ -315,10 +315,10 @@ class ScopaWebSocketTest(unittest.TestCase):
id="scopa-hand-end-1",
game_type="scopa",
join_code="SH0001",
creator_sub="alice",
creator_sub=make_principal("alice"),
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub="bob", display_name="Bob", team="B"),
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
],
)
session.state = state
@@ -374,14 +374,14 @@ class ScopaTurnTimeoutTest(unittest.TestCase):
id="scopa-turn-timeout-1",
game_type=engine.id,
join_code="ST0001",
creator_sub="alice",
creator_sub=make_principal("alice"),
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
],
)
engine.create(session, {})
session.state.turn_timeout = 1
engine.join(session, "bob", "Bob")
engine.join(session, make_principal("bob"), "Bob")
await game_store.save(session)
ws_transport = ASGIWebSocketTransport(app=app)
+13 -13
View File
@@ -21,7 +21,7 @@ from tavolo.platform.models import Match, MatchPlayer, PlayerRating
from tavolo.platform.stats import save_match_result
from tavolo.scopa import engine as scopa_rules
from tavolo.scopone import engine as rules
from tests.helpers import async_test, oidc_user
from tests.helpers import async_test, make_principal, oidc_user
PLAYERS = ("alice", "bob", "carol", "dave")
@@ -32,12 +32,12 @@ async def _finished_session(target_score: int = 1) -> GameSession:
id="stats-scope-1",
game_type=engine.id,
join_code="SS0001",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
creator_sub=make_principal("alice"),
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
)
engine.create(session, {"target_score": target_score})
for name in PLAYERS[1:]:
engine.join(session, name, name.capitalize())
engine.join(session, make_principal(name), name.capitalize())
moves = 0
while not engine.is_finished(session) and moves < 200000:
state = session.state
@@ -160,11 +160,11 @@ async def _finished_briscola_session() -> GameSession:
id="stats-briscola-1",
game_type=engine.id,
join_code="BS0001",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
creator_sub=make_principal("alice"),
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
)
engine.create(session, {})
engine.join(session, "bob", "Bob")
engine.join(session, make_principal("bob"), "Bob")
moves = 0
while not engine.is_finished(session) and moves < 200000:
state = session.state
@@ -246,15 +246,15 @@ async def _finished_chiamata_session() -> GameSession:
id="stats-chiamata-1",
game_type=engine.id,
join_code="BC0001",
creator_sub="alice",
creator_sub=make_principal("alice"),
players=[
Seat(user_sub=name, display_name=name.capitalize())
Seat(user_sub=make_principal(name), display_name=name.capitalize())
for name in ("alice", "bob", "carol", "dave", "erin")
],
)
engine.create(session, {})
for name in ("bob", "carol", "dave", "erin"):
engine.join(session, name, name.capitalize())
engine.join(session, make_principal(name), name.capitalize())
moves = 0
while not engine.is_finished(session) and moves < 200000:
state = session.state
@@ -344,15 +344,15 @@ class ScopaStatsTest(unittest.TestCase):
id=f"stats-scopa-{player_count}",
game_type=engine.id,
join_code="SC0001",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
creator_sub=make_principal("alice"),
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
)
engine.create(
session,
{"target_score": 1, "asso_pigliatutto": asso_pigliatutto},
)
for name in PLAYERS[1:player_count]:
engine.join(session, name, name.capitalize())
engine.join(session, make_principal(name), name.capitalize())
moves = 0
while not engine.is_finished(session) and moves < 200000:
state = session.state
+5 -5
View File
@@ -25,7 +25,7 @@ from tavolo.telemetry import (
)
from tavolo.config import Settings
from tests.helpers import async_test
from tests.helpers import async_test, make_principal
try:
from opentelemetry.sdk._logs import LoggingHandler, LoggerProvider
@@ -158,7 +158,7 @@ class SessionTagHookTest(unittest.TestCase):
hashlib.sha256(b"sid").hexdigest()[:16],
span.attributes["session.id"],
)
self.assertEqual("alice", span.attributes["user.id"])
self.assertEqual(make_principal("alice"), span.attributes["user.id"])
def test_tags_user_of_any_provider(self) -> None:
span = _RecordedSpan()
@@ -166,7 +166,7 @@ class SessionTagHookTest(unittest.TestCase):
session=Session("sid", {"oidc:google:user": {"sub": "alice"}})
)
session_tag_hook(span, carrier)
self.assertEqual("alice", span.attributes["user.id"])
self.assertEqual(make_principal("alice", "google"), span.attributes["user.id"])
def test_ignores_legacy_unprefixed_user_key(self) -> None:
span = _RecordedSpan()
@@ -190,7 +190,7 @@ class SessionTagHookTest(unittest.TestCase):
)
session_tag_hook(span, carrier)
self.assertNotIn("session.id", span.attributes)
self.assertEqual("alice", span.attributes["user.id"])
self.assertEqual(make_principal("alice"), span.attributes["user.id"])
def test_missing_session_is_a_noop(self) -> None:
span = _RecordedSpan()
@@ -266,7 +266,7 @@ class SessionTagIntegrationTest(unittest.TestCase):
attributes = span.attributes
assert attributes is not None
self.assertEqual(digest, attributes["session.id"], name)
self.assertEqual("alice", attributes["user.id"], name)
self.assertEqual(make_principal("alice"), attributes["user.id"], name)
def _metric_points(reader) -> dict:
+14 -14
View File
@@ -11,7 +11,7 @@ from httpx_ws.transport import ASGIWebSocketTransport
from tavolo.app import app, game_store, platform
from tavolo.platform import GameSession, Seat
from tavolo.scopone.state import Card, PlayerState, ScoponeState
from tests.helpers import async_test, make_user, oidc_user, ws_users
from tests.helpers import async_test, make_principal, make_user, oidc_user, ws_users
PLAYERS = ("alice", "bob", "carol", "dave")
@@ -27,8 +27,8 @@ def _started_session(
id=game_id,
game_type=engine.id,
join_code=code,
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
creator_sub=make_principal("alice"),
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
)
engine.create(
session,
@@ -42,7 +42,7 @@ def _started_session(
session.state.hand_ack_timeout = hand_ack_timeout
session.state.turn_timeout = turn_timeout
for name in PLAYERS[1:]:
engine.join(session, name, name.capitalize())
engine.join(session, make_principal(name), name.capitalize())
return session
@@ -83,7 +83,7 @@ class WebSocketTest(unittest.TestCase):
alice_first = await alice_ws.receive_json()
self.assertEqual("state", alice_first["type"])
self.assertEqual(
"alice", alice_first["game"]["players"][0]["sub"]
make_principal("alice"), alice_first["game"]["players"][0]["sub"]
)
self.assertNotIn("hand", alice_first["game"]["players"][1])
@@ -189,12 +189,12 @@ async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
id="hand-end-1",
game_type=engine.id,
join_code="HEND01",
creator_sub="alice",
creator_sub=make_principal("alice"),
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub="bob", display_name="Bob", team="B"),
Seat(user_sub="carol", display_name="Carol", team="A"),
Seat(user_sub="dave", display_name="Dave", team="B"),
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
Seat(user_sub=make_principal("carol"), display_name="Carol", team="A"),
Seat(user_sub=make_principal("dave"), display_name="Dave", team="B"),
],
)
session.state = ScoponeState(
@@ -203,10 +203,10 @@ async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
turn=0,
table=[Card.parse("02C")],
players=[
PlayerState(sub="alice", name="Alice", seat=0, hand=[Card.parse("02D")]),
PlayerState(sub="bob", name="Bob", seat=1),
PlayerState(sub="carol", name="Carol", seat=2),
PlayerState(sub="dave", name="Dave", seat=3),
PlayerState(sub=make_principal("alice"), name="Alice", seat=0, hand=[Card.parse("02D")]),
PlayerState(sub=make_principal("bob"), name="Bob", seat=1),
PlayerState(sub=make_principal("carol"), name="Carol", seat=2),
PlayerState(sub=make_principal("dave"), name="Dave", seat=3),
],
hand_ack_timeout=hand_ack_timeout,
)
+1852
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -1,5 +1,6 @@
//! User avatar: the provider's `picture` claim when available, a
//! deterministic identicon generated from the OIDC `sub` otherwise.
//! deterministic identicon generated from the canonical user principal
//! otherwise.
//!
//! The identicon is a 5x5 mirrored pixel grid derived from an inline
//! FNV-1a hash. The hash is implemented here (instead of
@@ -62,8 +63,8 @@ fn safe_picture_url(url: &str) -> Option<String> {
}
/// The user's avatar: their profile picture, or an identicon generated
/// from the `sub`. A picture that fails to load falls back to the
/// identicon as well.
/// from the canonical principal. A picture that fails to load falls back to
/// the identicon as well.
pub fn avatar(sub: &str, picture: Option<&str>) -> View {
let Some(url) = picture.and_then(safe_picture_url) else {
return identicon(sub);
+4
View File
@@ -9,6 +9,7 @@ fn default_true() -> bool {
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct User {
/// Canonical provider-qualified principal (`oidc:v1:<provider>:<sub>`).
pub sub: String,
pub name: String,
#[serde(default)]
@@ -18,6 +19,7 @@ pub struct User {
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct PlayerView {
/// Canonical provider-qualified principal.
pub sub: String,
pub name: String,
pub seat: usize,
@@ -386,6 +388,7 @@ pub enum ServerMessage {
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct MatchPlayer {
/// Canonical provider-qualified principal (legacy API field name).
pub user_sub: String,
pub display_name: String,
pub seat: usize,
@@ -475,6 +478,7 @@ fn default_page() -> u32 {
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct LeaderboardEntry {
/// Canonical provider-qualified principal (legacy API field name).
pub user_sub: String,
pub display_name: String,
/// Chess-style Elo rating for the requested game type.