diff --git a/CLAUDE.md b/CLAUDE.md index b19e26f..63ac929 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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é). diff --git a/src/app/icare/icare-view.tsx b/src/app/icare/icare-view.tsx index f962614..3ff80e0 100644 --- a/src/app/icare/icare-view.tsx +++ b/src/app/icare/icare-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Avatar } from "@/components/avatar"; import { Podium } from "@/components/podium"; import { IconClose, IconTrophy } from "@/components/icons"; @@ -21,6 +21,12 @@ export function IcareView({ const [leaderboard, setLeaderboard] = useState(initialLeaderboard); const [ownBestScore, setOwnBestScore] = useState(initialOwnBestScore); const [showRanking, setShowRanking] = useState(false); + // Promesse plutôt qu'une simple valeur : évite une course si la partie se + // termine avant que start_icarus_run() n'ait eu le temps de répondre — + // handleFinish attend cette même promesse (déjà résolue dans l'immense + // majorité des cas, une partie dure toujours largement plus longtemps que + // cet aller-retour réseau). + const runIdPromiseRef = useRef>(Promise.resolve(null)); const refetch = useCallback(async () => { const supabase = createClient(); @@ -67,9 +73,26 @@ export function IcareView({ }; }, [refetch]); + // Démarre le suivi serveur du temps de jeu (icarus_runs) dès le premier + // battement d'aile — soumis avec le score à la fin pour rejeter un score + // incohérent avec le temps réellement écoulé (voir submit_icarus_score, + // supabase/schema.sql). + function handleStart() { + const supabase = createClient(); + runIdPromiseRef.current = (async () => { + try { + const { data } = await supabase.rpc("start_icarus_run"); + return (data as string | null) ?? null; + } catch { + return null; + } + })(); + } + async function handleFinish(score: number) { const supabase = createClient(); - const { data, error } = await supabase.rpc("submit_icarus_score", { p_score: score }); + const runId = await runIdPromiseRef.current; + const { data, error } = await supabase.rpc("submit_icarus_score", { p_score: score, p_run_id: runId }); if (!error && data) { setOwnBestScore(data.best_score); refetch(); @@ -98,7 +121,7 @@ export function IcareView({ - + {showRanking && (
diff --git a/src/app/icare/icarus-game.tsx b/src/app/icare/icarus-game.tsx index b4af61e..e510ca3 100644 --- a/src/app/icare/icarus-game.tsx +++ b/src/app/icare/icarus-game.tsx @@ -271,12 +271,23 @@ function drawSpeedLines(ctx: CanvasRenderingContext2D, centerX: number, centerY: ctx.restore(); } -export function IcarusGame({ onFinish }: { onFinish: (score: number) => void }) { +export function IcarusGame({ + onStart, + onFinish, +}: { + onStart: () => void; + onFinish: (score: number) => void; +}) { const canvasRef = useRef(null); const containerRef = useRef(null); const [status, setStatus] = useState("idle"); const [score, setScore] = useState(0); + const onStartRef = useRef(onStart); + useEffect(() => { + onStartRef.current = onStart; + }, [onStart]); + const onFinishRef = useRef(onFinish); useEffect(() => { onFinishRef.current = onFinish; @@ -318,6 +329,7 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void }) if (statusRef.current === "idle") { statusRef.current = "flying"; setStatus("flying"); + onStartRef.current(); } else if (statusRef.current === "dead") { resetGame(); } diff --git a/src/app/urne/urne-view.tsx b/src/app/urne/urne-view.tsx index 053eb3b..b98e7ac 100644 --- a/src/app/urne/urne-view.tsx +++ b/src/app/urne/urne-view.tsx @@ -254,7 +254,7 @@ export function UrneView({ {citizen.pseudo} - + {count > 0 ? `×${count}` : "—"} diff --git a/src/lib/icarus/constants.ts b/src/lib/icarus/constants.ts index 915f157..3f00f35 100644 --- a/src/lib/icarus/constants.ts +++ b/src/lib/icarus/constants.ts @@ -80,4 +80,4 @@ export const MIN_SCORE = 0; // Doit rester aligné avec la contrainte icarus_scores_best_score_check // (supabase/schema.sql) — celle-ci est la vraie garantie, cette constante // n'est qu'une valeur de référence côté client. -export const MAX_SCORE = 5000; +export const MAX_SCORE = 500; diff --git a/src/lib/melon/constants.ts b/src/lib/melon/constants.ts index 363ce90..61f39df 100644 --- a/src/lib/melon/constants.ts +++ b/src/lib/melon/constants.ts @@ -45,7 +45,7 @@ export const MIN_SCORE = 0; // Doit rester aligné avec la contrainte melon_scores_best_score_check // (supabase/schema.sql) — celle-ci est la vraie garantie, cette constante // n'est qu'une valeur de référence côté client. -export const MAX_SCORE = 5000; +export const MAX_SCORE = 2000; // Positions figées à la main pour certains Citoyens (id de profil → index // de palier, 0 = le plus petit) — les Citoyens restants remplissent les diff --git a/supabase/schema.sql b/supabase/schema.sql index 318884a..19e0868 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -552,10 +552,15 @@ create table if not exists public.icarus_scores ( -- score inventé directement via l'API pour se faire attribuer des gloires -- au Tribunal — voir la contrainte identique sur melon_scores. La -- contrainte inline de create table ne s'applique qu'à la création ; sur --- une table déjà existante il faut explicitement la remplacer. +-- une table déjà existante il faut explicitement la remplacer. Encore +-- resserré de 5000 à 500 (toujours généreux — largement au-dessus de ce +-- qu'une partie sans faute atteint en pratique, voir le calcul détaillé +-- au niveau de submit_icarus_score) après un deuxième passage d'audit : +-- 5000 restait un ordre de grandeur trop haut pour être vraiment un +-- plafond réaliste plutôt qu'une simple borne anti-débordement. alter table public.icarus_scores drop constraint if exists icarus_scores_best_score_check; alter table public.icarus_scores add constraint icarus_scores_best_score_check - check (best_score between 0 and 5000); + check (best_score between 0 and 500); alter table public.icarus_scores enable row level security; @@ -574,10 +579,53 @@ create policy "icarus_scores readable by authenticated" -- événement, pas de notion de jour comme pour la Course du Char). alter table public.settings add column if not exists icarus_points_awarded boolean not null default false; +-- Deuxième couche, orthogonale à la borne fixe ci-dessus (audit de +-- sécurité) : le jeu n'a aucune vérification de gameplay (génération +-- procédurale entièrement côté client, voir V6/§3bis) — un score +-- jusqu'à 500 reste soumettable tel quel sans avoir vraiment joué. Cette +-- table fait tenir un compte du temps RÉEL écoulé (horloge du serveur, +-- jamais une durée envoyée par le client — sinon aussi falsifiable que le +-- score lui-même) entre le début d'une partie et sa soumission, pour +-- rejeter un score incohérent avec le temps réellement passé. Aucun accès +-- direct côté client (pas de grant) : seules les deux RPC ci-dessous +-- (SECURITY DEFINER) la lisent/écrivent. +create table if not exists public.icarus_runs ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references public.profiles (id) on delete cascade, + started_at timestamptz not null default now() +); + +alter table public.icarus_runs enable row level security; + +create or replace function public.start_icarus_run() +returns uuid +language plpgsql +security definer +set search_path = public +as $$ +declare + v_id uuid; +begin + if auth.uid() is null then + raise exception 'authentication required'; + end if; + + insert into public.icarus_runs (user_id) values (auth.uid()) returning id into v_id; + return v_id; +end; +$$; + +grant execute on function public.start_icarus_run() to authenticated; + +-- Signature changée (ajout de p_run_id) : l'ancienne (integer) est +-- explicitement supprimée, sinon create or replace créerait une 2e +-- surcharge au lieu de remplacer (même remarque que post_wall_note). +drop function if exists public.submit_icarus_score(integer); + -- Seul point d'entrée pour soumettre un score. Une fois la date du Tribunal -- atteinte, les scores sont figés : la RPC ne fait plus rien (retourne le -- record existant sans le modifier) plutôt que d'échouer bruyamment. -create or replace function public.submit_icarus_score(p_score integer) +create or replace function public.submit_icarus_score(p_score integer, p_run_id uuid) returns public.icarus_scores language plpgsql security definer @@ -586,16 +634,30 @@ as $$ declare v_tribunal_date timestamptz; v_row public.icarus_scores; + v_started_at timestamptz; + v_elapsed_seconds double precision; + -- Temps minimal réel pour passer une colonne, au rythme le plus rapide + -- jamais atteignable dans le jeu : vitesse de défilement pleinement + -- montée en difficulté (FORWARD_SPEED × SPEED_MAX_MULTIPLIER) ET + -- bouclier du boost actif en permanence (× BOOST_SPEED_MULTIPLIER en + -- plus) — un cas déjà irréaliste en soi (le bouclier n'est ni continu + -- ni permanent), donc une marge de sécurité généreuse avant même le + -- ×0.9 ci-dessous. Doit rester aligné avec COLUMN_SPACING/FORWARD_SPEED/ + -- SPEED_MAX_MULTIPLIER/BOOST_SPEED_MULTIPLIER (src/lib/icarus/constants.ts). + -- 210 / (130 × 1.6 × 1.7) ≈ 0.594s, encore réduit de 10% (marge contre + -- le jitter d'arrondi de la boucle de jeu) : jamais assez strict pour + -- rejeter un score légitime, seulement pour rejeter l'impossible. + c_min_seconds_per_column constant double precision := 0.53; begin if auth.uid() is null then raise exception 'authentication required'; end if; - -- Plafond réaliste (5000, largement au-delà de ce qu'une vraie partie - -- peut atteindre), pas juste borné à 1 000 000 — voir la contrainte de - -- table associée (best_score_check), la vraie garantie ; cette - -- vérification donne juste un message d'erreur clair côté client. - if p_score is null or p_score < 0 or p_score > 5000 then + -- Plafond réaliste (500, largement au-delà de ce qu'une vraie partie + -- sans faute atteint), pas une simple borne anti-débordement — voir la + -- contrainte de table associée (best_score_check), la vraie garantie ; + -- cette vérification donne juste un message d'erreur clair côté client. + if p_score is null or p_score < 0 or p_score > 500 then raise exception 'invalid score'; end if; @@ -606,6 +668,23 @@ begin return v_row; end if; + if p_score > 0 then + select started_at into v_started_at + from public.icarus_runs + where id = p_run_id and user_id = auth.uid(); + + if v_started_at is null then + raise exception 'invalid run'; + end if; + + v_elapsed_seconds := extract(epoch from (now() - v_started_at)); + if p_score > floor(v_elapsed_seconds / c_min_seconds_per_column) then + raise exception 'score incohérent avec le temps de jeu écoulé'; + end if; + + delete from public.icarus_runs where id = p_run_id; + end if; + insert into public.icarus_scores (user_id, best_score, updated_at) values (auth.uid(), p_score, now()) on conflict (user_id) do update @@ -618,7 +697,7 @@ begin end; $$; -grant execute on function public.submit_icarus_score(integer) to authenticated; +grant execute on function public.submit_icarus_score(integer, uuid) to authenticated; do $$ begin @@ -1509,10 +1588,18 @@ create table if not exists public.melon_scores ( ); -- Plafond abaissé (audit de sécurité) : voir la remarque identique sur --- icarus_scores. +-- icarus_scores. Resserré une deuxième fois à 2000 (au lieu de 500 comme +-- Icare) : l'économie de score est différente ici (pas de vitesse de +-- défilement fixe à borner dans le temps — le score dépend du nombre de +-- fusions enchaînées, beaucoup plus dur à borner analytiquement sans +-- données de vraies parties) donc une estimation plus prudente plutôt +-- qu'un calcul aussi précis que pour Icare ; pas de deuxième couche par +-- temps réel écoulé pour Corne dans cette passe, contrairement à Icare +-- (icarus_runs) — à ajouter plus tard si des scores encore trop hauts sont +-- observés en pratique. alter table public.melon_scores drop constraint if exists melon_scores_best_score_check; alter table public.melon_scores add constraint melon_scores_best_score_check - check (best_score between 0 and 5000); + check (best_score between 0 and 2000); alter table public.melon_scores enable row level security; @@ -1548,11 +1635,11 @@ begin raise exception 'authentication required'; end if; - -- Plafond réaliste (5000, largement au-delà de ce qu'une vraie partie + -- Plafond réaliste (2000, largement au-delà de ce qu'une vraie partie -- peut atteindre), pas juste borné à 1 000 000 — voir la contrainte de -- table associée (best_score_check), la vraie garantie ; cette -- vérification donne juste un message d'erreur clair côté client. - if p_score is null or p_score < 0 or p_score > 5000 then + if p_score is null or p_score < 0 or p_score > 2000 then raise exception 'invalid score'; end if;