Add Sycamore/WASM frontend and restructure into server/ + web/
Repo is now a monorepo:
- server/: the kaya backend, unchanged in behaviour, plus:
- GET /api/me for SPA session detection
- last_move recorded on every play and broadcast in the game state, so
clients can show who played which card the moment they play it
- legal_moves per hand card for the player on turn (rules stay
server-side)
- static catch-all route serving the compiled SPA with index.html
fallback; Tortoise context now bound only for /api/* requests
- configurable OIDC post-login/logout redirects for dev against trunk
- web/: Sycamore 0.9 + WASM frontend (trunk): login via the OIDC flow,
lobby (create match / join by code), live game page over websocket with
card images (CC0 woodcut napoletane deck), capture picker, move banner,
game-over overlay, match history and leaderboard pages
- server/Dockerfile gains a rust+trunk stage building web/dist; the single
app image serves the SPA; compose builds from the repo root with
overridable ports/OIDC env
Verified end-to-end against the compose stack: four OIDC logins, game
creation, three joins by code, websocket play with broadcasts to all
players and out-of-turn rejection. 51 backend tests, mypy and cargo tests
all green.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
"""Authentication helpers on top of the kaya-oidc mixin.
|
||||
|
||||
Scopa has no application roles: every authenticated user may create and
|
||||
join games. Authorization beyond login is game membership, checked against
|
||||
the live game state in Redis.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Mapping, Optional
|
||||
|
||||
from kaya.core import HttpContext, WebSocket
|
||||
from kaya.oidc import OIDCUser
|
||||
|
||||
from .app import oidc_mixin
|
||||
|
||||
|
||||
def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
|
||||
"""Return the authenticated user 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.
|
||||
"""
|
||||
session = getattr(ws, "session", None)
|
||||
if session is None:
|
||||
return None
|
||||
claims = session.get("oidc_user")
|
||||
if not isinstance(claims, Mapping):
|
||||
return None
|
||||
return OIDCUser(claims)
|
||||
|
||||
|
||||
def display_name(user: OIDCUser) -> str:
|
||||
"""Best-effort human-readable name for a user."""
|
||||
for key in ("name", "preferred_username", "email"):
|
||||
value = user.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return user.sub
|
||||
|
||||
|
||||
def require_auth(handler: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Decorator: gate a handler on being authenticated (any OIDC user).
|
||||
|
||||
Responds ``401`` with a JSON error envelope when unauthenticated —
|
||||
unlike kaya's built-in ``OIDCMixin.require_auth`` which redirects to
|
||||
the login page (wrong for a JSON API).
|
||||
"""
|
||||
|
||||
async def guarded(ctx: HttpContext, *args: Any, **kwargs: Any) -> None:
|
||||
if oidc_mixin.get_user(ctx) is None:
|
||||
await ctx.send_bytes(
|
||||
401,
|
||||
b'{"error":"unauthenticated"}',
|
||||
{"content-type": ("application/json",)},
|
||||
)
|
||||
return
|
||||
await handler(ctx, *args, **kwargs)
|
||||
|
||||
return guarded
|
||||
Reference in New Issue
Block a user