Resserre les plafonds de score, ajoute une vérif par temps réel écoulé sur Icare, corrige le contraste de l'Urne
Build and deploy / deploy (push) Successful in 36s
Build and deploy / deploy (push) Successful in 36s
5000 restait trop haut pour être un vrai plafond réaliste : Icare abaissé à 500 (calculé sur le rythme le plus rapide théoriquement atteignable dans le jeu), Corne à 2000 (estimation plus prudente, économie de score plus dure à borner). Ajoute icarus_runs/start_icarus_run() : le serveur enregistre l'instant réel de début de partie et rejette un score incohérent avec le temps écoulé, sans jamais faire confiance à une durée envoyée par le client. Corrige aussi le nombre de jetons de /urne, peu lisible en gold-bright sur fond marbre (passe à text-sea, même convention qu'Icare/Corne). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -66,15 +66,16 @@ Le script est **idempotent** : toujours le ré-exécuter en entier après une mo
|
||||
### `public.icarus_scores` (Le Vol d'Icare)
|
||||
|
||||
- `user_id, best_score, updated_at` — clé primaire `user_id` : un seul record personnel all-time par joueur (pas de notion de jour). Verrouillée comme `points_log` : lecture ouverte aux authentifiés, écriture uniquement via la RPC `submit_icarus_score`.
|
||||
- Contrainte `check (best_score between 0 and 5000)` sur `best_score` — la vraie garantie contre un score falsifié envoyé directement à la RPC (voir §3bis) ; `MAX_SCORE` dans `src/lib/icarus/constants.ts` n'est qu'une valeur de référence côté client, à garder alignée.
|
||||
- Contrainte `check (best_score between 0 and 500)` sur `best_score` — la vraie garantie contre un score falsifié envoyé directement à la RPC (voir §3bis) ; `MAX_SCORE` dans `src/lib/icarus/constants.ts` n'est qu'une valeur de référence côté client, à garder alignée.
|
||||
- `public.icarus_runs` (`id, user_id, started_at`) : deuxième couche anti-triche, orthogonale à la borne fixe — voir §3bis et `submit_icarus_score`.
|
||||
- `settings.icarus_points_awarded` : marqueur d'idempotence (un seul événement — l'attribution au début du Tribunal — pas une clôture quotidienne comme l'ancienne Course du Char).
|
||||
- Génération des colonnes/obstacles entièrement côté client, sans graine partagée (pas de piste identique pour tout le monde à reproduire ici — chaque partie est procédurale).
|
||||
|
||||
### `public.melon_scores` (La Corne d'Abondance)
|
||||
|
||||
- `user_id, best_score, updated_at` — même gabarit exact que `icarus_scores` (clé primaire `user_id`, verrouillée en écriture, uniquement via `submit_melon_score`), y compris la contrainte `check (best_score between 0 and 5000)`.
|
||||
- `user_id, best_score, updated_at` — même gabarit exact que `icarus_scores` (clé primaire `user_id`, verrouillée en écriture, uniquement via `submit_melon_score`), avec sa propre contrainte `check (best_score between 0 and 2000)` — pas la même valeur qu'Icare, l'économie de score est différente (voir §3bis) — et **pas** de deuxième couche par temps réel écoulé comme `icarus_runs` pour l'instant (formule de score plus dure à borner analytiquement pour Corne, calibrage basé sur une estimation plutôt qu'un calcul précis).
|
||||
- `settings.melon_points_awarded` : marqueur d'idempotence, même principe que `icarus_points_awarded`.
|
||||
- `submit_melon_score(p_score)` / `award_melon_points_if_due()` : copies conformes de `submit_icarus_score`/`award_icarus_points_if_due` (gel à `tribunal_date`, top 3 ex-aequo inclus, `judge_id = null` dans `points_log`, tâche `pg_cron` `award-melon-points`, même borne de score et même `execute` révoqué sur la fonction cron-only — voir §3bis).
|
||||
- `submit_melon_score(p_score)` / `award_melon_points_if_due()` : copies conformes de `submit_icarus_score`/`award_icarus_points_if_due` (gel à `tribunal_date`, top 3 ex-aequo inclus, `judge_id = null` dans `points_log`, tâche `pg_cron` `award-melon-points`, et même `execute` révoqué sur la fonction cron-only — voir §3bis) — sauf la borne de score elle-même (2000, pas 500) et l'absence de vérification par temps écoulé.
|
||||
- Le pool d'avatars des pièces (Citoyens uniquement, filtré côté serveur dans `corne/page.tsx` — un palier de taille par Citoyen) et les paliers/rayons du jeu vivent côté client (`src/lib/melon/constants.ts`), pas en base. N'importe qui peut jouer et apparaître dans `melon_scores`/le classement (Archontes compris) — seul le skin des pièces est restreint aux Citoyens.
|
||||
|
||||
### `public.chariot_questions` / `public.chariot_slots` / `public.chariot_entries` (Le Jeu de l'Agora — Le Char)
|
||||
@@ -109,7 +110,9 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè
|
||||
|
||||
PostgreSQL accorde `EXECUTE` à `PUBLIC` par défaut sur toute fonction créée, sauf révocation explicite — un commentaire disant « pas de grant à authenticated » **n'est pas** une vraie restriction d'accès si le `REVOKE` correspondant n'est pas écrit, la fonction reste appelable via l'endpoint REST RPC de Supabase. `schema.sql` ouvre donc avec `alter default privileges in schema public revoke execute on functions from public;` (s'applique aux fonctions créées après cette ligne dans le script, pas rétroactivement) et chaque fonction cron-only (`award_icarus_points_if_due`, `award_melon_points_if_due`) porte en plus un `revoke execute ... from public, anon, authenticated;` explicite juste après sa définition.
|
||||
|
||||
Incident réel ayant motivé cet audit : les scores d'Icare/Corne n'ont **aucune vérification de gameplay côté serveur** (génération procédurale entièrement côté client, comme documenté depuis V6/V11) — c'est un choix assumé pour un classement amical, mais l'ancien plafond (1 000 000) rendait un score falsifié, envoyé directement à `submit_icarus_score`/`submit_melon_score` via l'API, capable de se convertir en **vraies gloires** au Tribunal (via l'attribution automatique du top 3). Plafond ramené à une valeur généreuse mais réaliste (5000), appliqué à deux niveaux : la contrainte `check` sur `best_score` (la vraie garantie, `alter table ... add constraint` — un `check` inline sur `create table if not exists` ne s'appliquerait pas rétroactivement à une table déjà créée) et, en défense en profondeur, la validation dans la RPC elle-même.
|
||||
Incident réel ayant motivé cet audit : les scores d'Icare/Corne n'ont **aucune vérification de gameplay côté serveur** (génération procédurale entièrement côté client, comme documenté depuis V6/V11) — c'est un choix assumé pour un classement amical, mais l'ancien plafond (1 000 000) rendait un score falsifié, envoyé directement à `submit_icarus_score`/`submit_melon_score` via l'API, capable de se convertir en **vraies gloires** au Tribunal (via l'attribution automatique du top 3). Plafond d'abord ramené à 5000 puis, après un deuxième passage d'audit jugeant encore ça trop haut pour être un vrai plafond réaliste, resserré à 500 pour Icare / 2000 pour Corne (valeurs différentes : l'économie de score de Corne, basée sur des fusions enchaînées plutôt qu'une vitesse de défilement fixe, est plus dure à borner précisément — voir juste en dessous). Appliqué à deux niveaux dans les deux cas : la contrainte `check` sur `best_score` (la vraie garantie, `alter table ... add constraint` — un `check` inline sur `create table if not exists` ne s'appliquerait pas rétroactivement à une table déjà créée) et, en défense en profondeur, la validation dans la RPC elle-même.
|
||||
|
||||
Pour Icare spécifiquement, une deuxième couche orthogonale à la borne fixe : `public.icarus_runs` (`id, user_id, started_at`) enregistre le vrai instant de début de partie côté serveur (horloge Postgres, `started_at default now()`) au premier battement d'aile (`start_icarus_run()`, appelée depuis `IcarusGame`'s nouveau prop `onStart`) ; `submit_icarus_score(p_score, p_run_id)` calcule ensuite `now() - started_at` et rejette un score qui excéderait le rythme le plus rapide théoriquement atteignable dans le jeu (vitesse de défilement pleinement montée en difficulté **et** bouclier du boost actif en continu — un cas déjà irréaliste en soi — avec une marge de sécurité supplémentaire de 10 % contre le jitter de la boucle de jeu). Toujours le temps **réel** écoulé mesuré par le serveur, jamais une durée envoyée par le client (qui serait aussi falsifiable que le score lui-même). `icarus_runs` n'a aucun grant/policy pour `authenticated` : seules `start_icarus_run()`/`submit_icarus_score()` (SECURITY DEFINER) y touchent. Pas encore appliqué à Corne (`melon_scores`) dans cette passe — la formule de score par fusions enchaînées est plus dure à borner analytiquement sans données de vraies parties ; son plafond fixe (2000) reste donc, pour l'instant, une estimation plus prudente plutôt qu'un calcul aussi précis que pour Icare.
|
||||
|
||||
Bucket Storage `avatars` : `file_size_limit`/`allowed_mime_types` ajoutés (`on conflict (id) do update`, pas `do nothing`, pour que ré-exécuter `schema.sql` applique bien la limite à un bucket déjà existant) — sans ça, un upload direct à l'API de Storage (hors de l'app, où c'est toujours `cropImageToBlob` qui compresse) pouvait déposer un fichier arbitrairement gros ou non-image comme avatar. Même limite que `wall-images` (3 Mo, image/webp+jpeg+png).
|
||||
|
||||
@@ -120,7 +123,8 @@ Deuxième volet de l'audit (revue complète de tout le code applicatif, pas seul
|
||||
- `is_pseudo_taken(p_pseudo)` — anon + authenticated, ne renvoie qu'un booléen (l'anon ne peut pas lire `profiles`).
|
||||
- `award_points(p_target_id, p_delta, p_reason)` — authenticated, vérifie `role = 'judge'` côté serveur, jamais côté client.
|
||||
- `reset_rank_reference()` — authenticated, vérifie `role = 'judge'`, fige le classement courant dans `previous_rank`.
|
||||
- `submit_icarus_score(p_score)` — authenticated, vérifie côté serveur si la date du Tribunal est déjà passée (scores figés : no-op silencieux plutôt qu'une erreur), n'écrase le record que s'il est strictement battu, rejette un score hors bornes (défense en profondeur — la contrainte `check` sur `icarus_scores.best_score` est la garantie réelle, voir §3bis).
|
||||
- `start_icarus_run()` — authenticated, enregistre l'instant réel de début de partie (`icarus_runs.started_at`) pour la vérification par temps écoulé de `submit_icarus_score` — voir §3bis.
|
||||
- `submit_icarus_score(p_score, p_run_id)` — authenticated, vérifie côté serveur si la date du Tribunal est déjà passée (scores figés : no-op silencieux plutôt qu'une erreur), n'écrase le record que s'il est strictement battu, rejette un score hors bornes (défense en profondeur — la contrainte `check` sur `icarus_scores.best_score` est la garantie réelle) et un score incohérent avec le temps réellement écoulé depuis `p_run_id` (voir §3bis).
|
||||
- `award_icarus_points_if_due()` — **`execute` explicitement révoqué de `public`/`anon`/`authenticated`** (voir §3bis), appelée uniquement par la tâche planifiée `pg_cron` (ou depuis le SQL Editor) : dès que la date du Tribunal est atteinte, attribue les gloires du top 3 automatiquement (`judge_id = null` dans `points_log`, affiché comme « Le Tribunal » dans Le Crieur), une seule fois (`settings.icarus_points_awarded`).
|
||||
- `reroll_gardien(p_force default false)` — authenticated. Tire un nouveau Gardien parmi les Citoyens (jamais le détenteur actuel si possible) et repousse `gardien_expires_at` de 5 minutes. `p_force=true` (vérifie `role = 'judge'`) reroll immédiatement ; `p_force=false` (appelée par le minuteur côté client à expiration, et par `pg_cron` en filet) ne fait rien tant que `gardien_expires_at` n'est pas atteint — un seul `UPDATE ... WHERE` atomique (pas de `SELECT` puis `UPDATE`), pour qu'un reroll naturel avec plusieurs téléphones ouverts au même moment ne reroll qu'une seule fois.
|
||||
- `submit_chariot_question(p_text)` — authenticated, rejette les juges (seuls les Citoyens proposent), valide un texte non vide (≤ 300 caractères), et applique le même gel que `submit_icarus_score` via `settings.tribunal_date` (no-op silencieux une fois l'Agora commencée). `insert ... on conflict (user_id) do update` : une ligne par personne dans `chariot_submissions`, plus une ligne append-only dans `chariot_submission_history` à chaque écriture réelle (pas lors du no-op figé).
|
||||
|
||||
Reference in New Issue
Block a user