Phase 1 (2/3) : route publique /jouer/<slug> + identité visiteur
Build and deploy / test (push) Failing after 8s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Ajoute la vraie route hébergée multi-joueurs qui manquait totalement
(voir le constat d'exploration : /game/<slug>/play est réservé au
créateur connecté, "Publier" ne génère qu'un exécutable mono-joueur) —
un visiteur anonyme peut maintenant jouer un jeu explicitement publié en
ligne, avec sa propre partie (variables/objets per_player posés dans le
commit précédent).

core/player_identity.py : identité visiteur via un cookie NON SIGNÉ
(forge_player_id, secrets.token_urlsafe(16), 1 an) — une simple clé de
partition, jamais un jeton d'autorisation.

routes/public_play/ : 4 routes, miroirs des routes existantes de l'aperçu
créateur mais threadées avec le vrai player_id du cookie au lieu du
sentinel PLAYER_SHARED :
- GET /jouer/<slug> (game_play_public.py)
- GET /jouer/<slug>/runtime-payload
- POST /jouer/<slug>/flow/nodes/<id>/run-data
- POST /jouer/<slug>/flow/nodes/<id>/run-variable
Chacune vérifie elle-même db.is_public_played(slug) (404 sinon) — un jeu
n'est exposé publiquement que si le créateur l'a explicitement basculé
"Publier en ligne" (nouveau db/games/is_public_played.py, réutilise la
table générique _meta, comme game_meta.py pour 'name').

core/auth_guard.py : les 4 endpoints publics ajoutés à _PUBLIC_ENDPOINTS
— la garde générique de connexion les laisse passer sans session, mais
chaque vue vérifie quand même is_public_played elle-même (défense en
profondeur, pas seulement une liste d'exceptions). CSRF (core/csrf_guard.py)
n'a besoin d'AUCUN changement : le jeton est déjà lié à la session Flask,
qui existe pour n'importe quel visiteur (connecté ou non).

templates/play.html : FORGE_PLAY_URLS (posé en Phase -1) ne construit
plus ses URLs via des noms de endpoint fixes (url_for('runtime_payload',
...)) mais reçoit des URLs déjà résolues par la route elle-même
(runtime_payload_url/flow_node_run_data_url/flow_node_run_variable_url)
— nécessaire puisque ce même template sert maintenant DEUX familles de
routes (aperçu créateur ET partie publique), chacune avec ses propres
noms de endpoint. routes/play/game_play.py (aperçu créateur, INCHANGÉ
comportement) et game_play_public.py passent chacun ses propres URLs.

templates/base.html : bascule "🌐 Publier en ligne" dans la barre de
navigation du jeu, à côté de "📦 Publier" (export .zip) — deux
fonctionnalités distinctes. db/games/game_meta.py expose maintenant
is_public_played, disponible partout où `game` est dans le contexte.

Vérifié : 237 tests passent (5 nouveaux dans test_public_play.py, dont un
bout-en-bout via HTTP avec deux VRAIS clients de test anonymes — deux
cookies forge_player_id différents — qui obtiennent des valeurs de
variable indépendantes, et un qui verrouille que l'aperçu créateur reste
inchangé). Syntaxe JS validée sur les deux variantes de play.html rendu
(aperçu créateur et partie publique).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-30 15:46:00 +02:00
co-authored by Claude Sonnet 5
parent 236d6b4b46
commit 3c39f1a249
18 changed files with 362 additions and 9 deletions
+35
View File
@@ -0,0 +1,35 @@
"""Identité d'un visiteur anonyme de la route publique /jouer/<slug> (voir
routes/public_play/) — état par joueur, Phase 1. Un cookie NON SIGNÉ (pas
besoin de itsdangerous/app.secret_key ici) : c'est une simple clé de
PARTITION des données (quelles lignes/valeurs appartiennent à ce visiteur),
jamais un jeton d'autorisation — la contrefaçon la plus grave possible
serait de se faire passer pour un AUTRE joueur anonyme du même jeu, pas de
franchir une frontière de sécurité (l'aperçu créateur, lui, reste protégé
par core/auth_guard.py, complètement indépendant de ce mécanisme)."""
import secrets
from flask import request
PLAYER_ID_COOKIE = "forge_player_id"
_PLAYER_ID_MAX_AGE = 60 * 60 * 24 * 365 # 1 an
def get_or_create_player_id():
"""Renvoie (player_id, is_new) — is_new indique s'il faut poser le
cookie sur la réponse avant de la renvoyer (voir
set_player_id_cookie ci-dessous) : à utiliser aussi bien depuis une
route qui rend une page (GET /jouer/<slug>) qu'une route API JSON
(runtime-payload, run-data, run-variable), dont la réponse n'est
construite qu'APRÈS avoir déjà besoin du player_id pour agir."""
existing = request.cookies.get(PLAYER_ID_COOKIE)
if existing:
return existing, False
return secrets.token_urlsafe(16), True
def set_player_id_cookie(response, player_id):
response.set_cookie(
PLAYER_ID_COOKIE, player_id,
max_age=_PLAYER_ID_MAX_AGE, httponly=True, samesite="Lax",
)
return response