Let anyone with the game URL watch as a read-only spectator
CI / Build and push docker image (push) Successful in 3m0s

Anonymous users and logged-in non-players can now open /game/<id> and
follow the match: they see the table, the score and each player's
captured cards and scope, but never a hand and never the join code.
Spectator actions are rejected with a forbidden error.

Backend (tavolo-platform / tavolo-scopone):
- /ws/games/{id} accepts anonymous and non-seated connections as
  spectators; only unknown games are refused. The state payload carries
  spectator: true and withholds join_code unless the viewer is seated.
- GET /api/games/{id} is public and returns the same personalized
  (player) or read-only (spectator) view.
- GameEngine.view_for/game_over_view take Optional[str] (None = spectator)
  and are documented to hide privileged information for non-seated
  viewers.

Frontend (Sycamore):
- Parse the spectator flag and render a spectator table: card backs plus
  captured/scope for every seat, no clickable hand, "Spectating" badge.
- A pre-start spectator sees a "game not started yet" waiting room with
  the watch link and no join code.
- "Copy watch link" buttons in the waiting room and the game HUD.
- The hand-end summary carries no acknowledge button for spectators and
  the game-over overlay hides "My matches".

Tests and docs updated.
This commit is contained in:
2026-10-06 12:51:53 +08:00
parent adc3d340b1
commit b5219062f2
22 changed files with 453 additions and 185 deletions
@@ -191,12 +191,17 @@ class GameEngine(ABC):
"""
@abstractmethod
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
"""The personalized game view for one player.
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
"""The personalized game view for one viewer.
Must hide information the player is not entitled to (e.g. other
players' hands). The platform merges in the envelope fields
(``id``, ``join_code``, ``game_type``) itself.
Must hide information the viewer is not entitled to (e.g. other
players' hands). ``user_sub`` is ``None`` for a spectator — an
anonymous user or an authenticated user who is not seated — and
the view must then contain no privileged information either (no
hand, no legal moves: only what everyone at the table can see).
The platform merges in the envelope fields (``id``,
``join_code`` for seated players, ``game_type``, ``spectator``)
itself.
"""
@abstractmethod
@@ -248,6 +253,13 @@ class GameEngine(ABC):
``kind``/``token`` no longer match the live state.
"""
def game_over_view(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
"""Extra fields merged into the ``game_over`` websocket message."""
def game_over_view(
self, session: GameSession, user_sub: Optional[str]
) -> Dict[str, Any]:
"""Extra fields merged into the ``game_over`` websocket message.
``user_sub`` is ``None`` for a spectator (see :meth:`view_for`);
the default fields are public, so the default implementation
ignores it.
"""
return {}
@@ -16,7 +16,7 @@ import secrets
import uuid
from datetime import datetime, timezone
from logging import getLogger
from typing import TYPE_CHECKING, Any, Dict
from typing import TYPE_CHECKING, Any, Dict, Optional
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
@@ -76,14 +76,21 @@ def _lobby_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]
}
def _view_payload(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
def _view_payload(
platform: "Platform", session: GameSession, sub: Optional[str]
) -> Dict[str, Any]:
engine = platform.registry.require(session.game_type)
return {
seated = sub is not None and session.seated(sub)
payload: Dict[str, Any] = {
"id": session.id,
"join_code": session.join_code,
"game_type": session.game_type,
**engine.view_for(session, sub),
"spectator": not seated,
}
if seated:
# The join code is a credential: only players get it.
payload["join_code"] = session.join_code
payload.update(engine.view_for(session, sub))
return payload
def _preview_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]:
@@ -310,24 +317,24 @@ def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/games/${game_id}")
@operation(summary="Get a game snapshot",
description="Only seated players may read a game; the view is "
"personalized by the engine (e.g. hidden hands).",
description="Anyone who knows the game id may read a game. "
"Seated players get the personalized view (their "
"own hand plus the join code); everyone else — "
"anonymous users and authenticated non-players — "
"gets the read-only spectator view: no hands, no "
"join code, just what is public at the table.",
tags=["games"],
responses={
200: {"description": "The personalized game state"},
401: {"description": "Authentication required"},
403: {"description": "Not a player in this game"},
200: {"description": "The personalized (player) or "
"read-only (spectator) game state"},
404: {"description": "Game not found"},
})
@require_auth(platform.oidc)
async def get_game(ctx: HttpContext, game_id: str) -> None:
session = await platform.game_store.load(game_id)
if session is None:
await send_error(ctx, 404, "game not found")
return
user = current_user(platform.oidc, ctx)
assert user is not None
if not session.seated(user.sub):
await send_error(ctx, 403, "forbidden")
return
await send_json(ctx, 200, _view_payload(platform, session, user.sub))
await send_json(
ctx, 200, _view_payload(platform, session, user.sub if user else None)
)
@@ -2,12 +2,21 @@
Clients connect to ``/ws/games/{game_id}`` using their session cookie
(the OIDC login stores the user in the session, which the session mixin
loads onto the websocket). Only seated players are accepted.
loads onto the websocket). Anyone who knows the game id is accepted:
seated players get their personalized view, everyone else — anonymous
users and authenticated users who are not seated — is a read-only
**spectator** receiving the view without privileged information (no
hands, no legal moves). The game view carries ``spectator: true`` for
them and never carries the join code. Spectator actions other than
``state``/``sync`` are rejected with a ``forbidden`` error; seating is
re-checked under the game lock so a viewer who joins mid-connection
immediately gains (and, if they somehow left, would lose) the right to
act.
The platform owns the connection lifecycle — authentication, seat
check, the subscription/publish fan-out and the message envelope — and
delegates the game-specific actions to the engine registered for the
session's ``game_type``.
The platform owns the connection lifecycle — authentication, the
subscription/publish fan-out and the message envelope — and delegates
the game-specific actions to the engine registered for the session's
``game_type``.
Protocol
--------
@@ -48,7 +57,7 @@ import asyncio
import json
from contextlib import suppress
from logging import getLogger
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Optional
from kaya.core import KayaApp, WebSocket, WebSocketDisconnect
@@ -83,17 +92,21 @@ async def _reject(ws: WebSocket, status: int) -> None:
await ws.close(status)
def _state_message(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
def _state_message(
platform: "Platform", session: GameSession, sub: Optional[str]
) -> Dict[str, Any]:
engine = platform.registry.require(session.game_type)
return {
"type": "state",
"game": {
"id": session.id,
"join_code": session.join_code,
"game_type": session.game_type,
**engine.view_for(session, sub),
},
seated = sub is not None and session.seated(sub)
payload: Dict[str, Any] = {
"id": session.id,
"game_type": session.game_type,
"spectator": not seated,
}
if seated:
# The join code is a credential: only players get it.
payload["join_code"] = session.join_code
payload.update(engine.view_for(session, sub))
return {"type": "state", "game": payload}
def register(app: KayaApp, platform: "Platform") -> None:
@@ -101,24 +114,19 @@ def register(app: KayaApp, platform: "Platform") -> None:
@app.websocket("/ws/games/${game_id}")
async def game_socket(ws: WebSocket, game_id: str) -> 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)
if user is None:
log.debug("websocket %s rejected: no authenticated user", game_id)
await _reject(ws, 401)
return
sub: Optional[str] = user.sub if user is not None else None
session = await platform.game_store.load(game_id)
if session is None:
log.debug("websocket rejected: unknown game %s", game_id)
await _reject(ws, 404)
return
if not session.seated(user.sub):
log.debug("websocket %s rejected: %s is not seated", game_id, user.sub)
await _reject(ws, 403)
return
await ws.accept()
log.info("%s connected to game %s", user.sub, game_id)
log.info("%s connected to game %s", sub or "spectator", game_id)
send_lock = asyncio.Lock()
@@ -132,7 +140,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
# ends momentarily.
pass
await send(_state_message(platform, session, user.sub))
await send(_state_message(platform, session, sub))
# Backstop: make sure the current deadline is queued even if its
# entry was lost (e.g. the queue was flushed while the game lived
# on thanks to its sliding TTL).
@@ -140,7 +148,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
async with platform.game_store.subscribe(game_id) as events:
forward = asyncio.create_task(
_forward(platform, events, game_id, user.sub, send)
_forward(platform, events, game_id, sub, send)
)
try:
async for message in ws:
@@ -149,19 +157,19 @@ def register(app: KayaApp, platform: "Platform") -> None:
if message.kind != "text" or not isinstance(message.data, str):
await send(_error("expected a text frame with a JSON object"))
continue
await _handle_message(platform, send, game_id, user.sub, message.data)
await _handle_message(platform, send, game_id, sub, message.data)
finally:
forward.cancel()
with suppress(asyncio.CancelledError):
await forward
log.debug("%s disconnected from game %s", user.sub, game_id)
log.debug("%s disconnected from game %s", sub or "spectator", game_id)
async def _forward(
platform: "Platform",
events,
game_id: str,
sub: str,
sub: Optional[str],
send: Send,
) -> None:
async for _ in events:
@@ -181,7 +189,7 @@ async def _forward(
async def _handle_message(
platform: "Platform", send: Send, game_id: str, sub: str, raw: str
platform: "Platform", send: Send, game_id: str, sub: Optional[str], raw: str
) -> None:
try:
data = json.loads(raw)
@@ -210,7 +218,7 @@ async def _handle_action(
platform: "Platform",
send: Send,
game_id: str,
sub: str,
sub: Optional[str],
action: str,
data: Dict[str, Any],
) -> None:
@@ -219,6 +227,13 @@ async def _handle_action(
if session is None:
await send(_error("game not found", code="not_found"))
return
# Re-check seating under the lock: an anonymous spectator stays a
# spectator forever, while a logged-in viewer who joins the game
# mid-connection becomes a player and may act from then on.
if sub is None or not session.seated(sub):
log.debug("game %s: rejected %r from a spectator", game_id, action)
await send(_error("spectators cannot act on the game", code="forbidden"))
return
engine = platform.registry.require(session.game_type)
payload = {key: value for key, value in data.items() if key != "action"}
try:
@@ -138,7 +138,7 @@ class DummyEngine(GameEngine):
state["finished"] = True
state["winner"] = user_sub
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
state = session.state
return {
"started": state["started"],
@@ -146,7 +146,7 @@ class DummyEngine(GameEngine):
"winner": state["winner"],
"plays": len(state["plays"]),
"target": state["target"],
"viewer_seated": session.seated(user_sub),
"viewer_seated": user_sub is not None and session.seated(user_sub),
}
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
@@ -44,10 +44,24 @@ class GamesRouteTest(unittest.TestCase):
self.assertEqual(game_id, snap["id"])
self.assertEqual("dummy", snap["game_type"])
self.assertTrue(snap["viewer_seated"])
self.assertFalse(snap["spectator"])
self.assertIn("join_code", snap)
# A logged-in non-player is a read-only spectator: the view
# marks it and the join code is withheld.
with oidc_user(platform.oidc, "mallory"):
forbidden = await client.get(f"/api/games/{game_id}")
self.assertEqual(403, forbidden.status_code)
watched = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, watched.status_code)
spect = watched.json()
self.assertFalse(spect["viewer_seated"])
self.assertTrue(spect["spectator"])
self.assertNotIn("join_code", spect)
# Anonymous users may watch too.
anonymous = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, anonymous.status_code)
self.assertTrue(anonymous.json()["spectator"])
self.assertNotIn("join_code", anonymous.json())
@async_test
async def test_join_starts_game(self) -> None:
@@ -11,7 +11,6 @@ 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 tavolo.platform import GameSession, Seat
class AbruptCloseSocket(WebSocket):
@@ -117,6 +116,9 @@ class WebSocketTest(unittest.TestCase):
self.assertEqual("dummy", first["game"]["game_type"])
self.assertEqual(0, first["game"]["plays"])
self.assertEqual(game_id, first["game"]["id"])
# Seated players get the join code; spectators don't.
self.assertIn("join_code", first["game"])
self.assertFalse(first["game"]["spectator"])
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
alice_first = await alice_ws.receive_json()
@@ -237,7 +239,7 @@ class WebSocketTest(unittest.TestCase):
self.assertEqual(404, caught.exception.code)
@async_test
async def test_non_player_is_closed(self) -> None:
async def test_non_player_watches_as_spectator(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
@@ -247,21 +249,38 @@ class WebSocketTest(unittest.TestCase):
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("mallory")]):
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
pass
self.assertEqual(403, caught.exception.code)
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual("state", first["type"])
self.assertTrue(first["game"]["spectator"])
self.assertFalse(first["game"]["viewer_seated"])
self.assertNotIn("join_code", first["game"])
# Spectators may not act.
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_unauthenticated_is_closed(self) -> None:
app, _, _ = make_platform()
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([]):
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
async with aconnect_ws("/ws/games/whatever", ws_client):
pass
self.assertEqual(401, caught.exception.code)
async def test_anonymous_user_watches_as_spectator(self) -> None:
app, platform, _ = make_platform()
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([]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual("state", first["type"])
self.assertTrue(first["game"]["spectator"])
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"])
class RejectionStatusTest(unittest.TestCase):
@@ -271,33 +290,19 @@ class RejectionStatusTest(unittest.TestCase):
``accept()`` as the HTTP status of the refused upgrade; a WebSocket
close code like 4401 is not a valid status and panics the worker
(``InvalidStatusCode``, and with ``panic = "abort"`` the whole app
dies). Drive the rejections through the app with a recording socket
so the invariant is checked without a real server.
dies). Drive the rejection through the app with a recording socket
so the invariant is checked without a real server. Spectators are
accepted, so the only pre-accept rejection left is an unknown game.
"""
@async_test
async def test_pre_accept_rejections_are_http_statuses(self) -> None:
app, platform, _ = make_platform()
session = GameSession(
id="seated-game",
game_type="dummy",
join_code="SEAT01",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine = platform.registry.require("dummy")
engine.create(session, {})
await platform.game_store.save(session)
cases = [
("unauthenticated", [], "/ws/games/whatever", 401),
("unknown game", [make_user("alice")], "/ws/games/no-such-game", 404),
("not seated", [make_user("mallory")], f"/ws/games/{session.id}", 403),
]
for label, users, path, expected in cases:
ws = RecordingSocket(path)
async def test_unknown_game_rejection_is_an_http_status(self) -> None:
app, _, _ = make_platform()
for label, users in (("anonymous", []), ("logged in", [make_user("alice")])):
ws = RecordingSocket("/ws/games/no-such-game")
with ws_users(users):
await app.handle_websocket(ws)
self.assertEqual([expected], ws.close_codes, label)
self.assertEqual([404], ws.close_codes, label)
for code in ws.close_codes:
self.assertTrue(100 <= code <= 599, f"{label}: {code} is not an HTTP status")
@@ -528,13 +528,14 @@ def hand_points(state: ScoponeState) -> Tuple[List[int], Dict[str, object]]:
return points, details
def state_for_player(state: ScoponeState, sub: str) -> Dict[str, object]:
def state_for_player(state: ScoponeState, sub: Optional[str]) -> Dict[str, object]:
"""Serialize ``state`` hiding other players' hands.
Hands are reduced to a count, except for the requesting player's own
hand. Non-seated viewers simply see no hand at all. The platform
merges the session envelope (``id``, ``join_code``, ``game_type``)
into the view itself.
hand. Non-seated viewers — including spectators, for whom ``sub`` is
``None`` — simply see no hand at all. The platform merges the session
envelope (``id``, ``join_code`` for seated players, ``game_type``,
``spectator``) into the view itself.
"""
viewer = state.player_for(sub)
players: List[Dict[str, object]] = []
@@ -171,13 +171,13 @@ class ScoponeEngine(GameEngine):
else:
raise IllegalMove(f"unknown action: {action!r}")
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
return engine.state_for_player(session.state, user_sub)
def is_finished(self, session: GameSession) -> bool:
return session.state.phase == PHASE_FINISHED
def game_over_view(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
state: ScoponeState = session.state
return {
"scores": {"A": state.scores[0], "B": state.scores[1]},
@@ -239,7 +239,7 @@ class ScoponeState:
# -- helpers ----------------------------------------------------------
def player_for(self, sub: str) -> Optional[PlayerState]:
def player_for(self, sub: Optional[str]) -> Optional[PlayerState]:
for player in self.players:
if player.sub == sub:
return player
@@ -574,6 +574,27 @@ class MatchFlowTest(unittest.TestCase):
self.assertEqual(["07C"], view["table"])
self.assertTrue(view.get("your_turn"))
def test_state_for_player_spectator_sees_public_info_only(self) -> None:
state = make_state(
[["02D", "03C"], ["04D"], ["05D"], ["06D"]],
table=["07C"],
captured=[["01D"], [], ["02C"], ["03B"]],
scope=[1, 0, 2, 0],
)
view = engine.state_for_player(state, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertNotIn("legal_moves", view)
# The public table state is still fully visible.
self.assertEqual(["07C"], view["table"])
self.assertEqual({"A": 0, "B": 0}, view["scores"])
players = {p["seat"]: p for p in view["players"]}
self.assertEqual(1, players[0]["captured_count"])
self.assertEqual(1, players[0]["scope"])
self.assertEqual(2, players[2]["scope"])
self.assertEqual(1, players[1]["cards_left"])
def test_legal_moves_only_for_player_on_turn(self) -> None:
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
table=["07C"])
@@ -160,6 +160,17 @@ class ViewTest(unittest.TestCase):
self.assertNotIn("join_code", view)
self.assertNotIn("game_type", view)
def test_view_for_spectator_hides_all_hands(self) -> None:
engine = ScoponeEngine()
session = _started(engine)
view = engine.view_for(session, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertNotIn("legal_moves", view)
self.assertIn("table", view)
self.assertEqual({"A": 0, "B": 0}, view["scores"])
def test_lobby_view(self) -> None:
engine = ScoponeEngine()
session = _session(engine, target_score=16)