-- Le Tribunal — schéma V2 + V3 (auth par email, rôles, verrou de pseudo, -- points/journal/podium/indicateurs de progression) -- À exécuter dans l'éditeur SQL du projet Supabase. -- ============================================================ -- Migration depuis V1 (authentification pseudo + email interne) -- ============================================================ -- V1 utilisait un email fictif dérivé du pseudo (slug@letribunal.test). -- V2 exige un vrai email par utilisateur (unique, confirmé). Les comptes -- créés sous V1 sont donc invalides pour V2. Comme il ne s'agit que de -- données de test, on repart de zéro : -- -- 1. Dashboard Supabase → Authentication → Providers → Email -- → activer "Confirm email". -- 2. SQL Editor → exécuter la ligne ci-dessous pour supprimer tous les -- comptes de test (cascade automatiquement sur public.profiles) : -- -- delete from auth.users; -- -- 3. Exécuter tout le script ci-dessous. -- 4. Storage → bucket "avatars" → vider les fichiers de test existants -- (les anciens noms de fichiers restent valides, ils ne gênent pas, -- mais autant repartir propre). -- ============================================================ -- 1. Table profiles ---------------------------------------------------- create table if not exists public.profiles ( id uuid primary key references auth.users (id) on delete cascade, pseudo text not null, avatar_url text, points integer not null default 0, role text not null default 'public' check (role in ('public', 'judge')), pseudo_locked boolean not null default false, previous_rank integer, created_at timestamptz not null default now() ); -- Colonnes ajoutées si la table existait déjà depuis une version antérieure -- (no-op si déjà présentes). alter table public.profiles add column if not exists role text not null default 'public'; alter table public.profiles add column if not exists pseudo_locked boolean not null default false; alter table public.profiles add column if not exists previous_rank integer; alter table public.profiles drop column if exists slug; alter table public.profiles drop constraint if exists profiles_role_check; alter table public.profiles add constraint profiles_role_check check (role in ('public', 'judge')); -- Unicité du pseudo affiché (insensible à la casse). drop index if exists profiles_slug_key; create unique index if not exists profiles_pseudo_lower_key on public.profiles (lower(pseudo)); alter table public.profiles enable row level security; -- Pas de grant insert : la création du profil passe exclusivement par le -- trigger handle_new_user() (SECURITY DEFINER, contourne la RLS). grant select, update on public.profiles to authenticated; -- 1bis. Durcissement de sécurité (audit) -------------------------------------- -- PostgreSQL accorde EXECUTE à PUBLIC par défaut sur toute fonction créée, -- sauf révocation explicite. Plusieurs RPC de ce fichier n'étaient -- "protégées" que par un commentaire ("pas de grant à authenticated") sans -- REVOKE réel — donc en réalité appelables par n'importe quel utilisateur -- authentifié via l'API REST, malgré l'intention. Cette ligne change le -- comportement par défaut pour TOUTE fonction créée après elle dans ce -- script (donc pour toutes les fonctions ci-dessous) : plus aucune RPC -- n'est exécutable sans un `grant execute` explicite. Les RPC déjà -- existantes avant cette ligne conservent leurs anciens privilèges tant -- qu'on ne les révoque pas explicitement — voir les REVOKE ciblés plus bas -- pour award_icarus_points_if_due()/award_melon_points_if_due(), qui en -- avaient besoin rétroactivement. alter default privileges in schema public revoke execute on functions from public; -- 2. Fonctions & triggers ------------------------------------------------- -- La RLS est au niveau ligne : elle ne peut pas exprimer "cette colonne -- seulement si tel rôle". On verrouille donc les colonnes sensibles -- (role, points, pseudo_locked, pseudo figé) via des triggers, qui ne -- font jamais confiance à ce que le client envoie. -- À la création d'un profil : on ignore ce que le client a pu envoyer -- pour role/pseudo_locked/points et on force les valeurs par défaut. -- Le pseudo initial est repris depuis auth.users si non fourni explicitement. create or replace function public.enforce_profile_insert() returns trigger language plpgsql as $$ begin new.role := 'public'; new.pseudo_locked := false; new.points := 0; return new; end; $$; drop trigger if exists profiles_before_insert on public.profiles; create trigger profiles_before_insert before insert on public.profiles for each row execute function public.enforce_profile_insert(); -- À la mise à jour : selon le rôle de l'appelant (auth.uid()), on -- autorise ou rejette les changements de colonnes sensibles. create or replace function public.enforce_profile_update() returns trigger language plpgsql as $$ declare caller_role text; points_bypass boolean := coalesce(current_setting('app.bypass_points_lock', true), 'off') = 'on'; begin -- auth.uid() est NULL hors contexte d'une requête utilisateur (SQL Editor, -- migrations, clé service_role). Ces accès sont déjà pleinement fiables -- par construction (accès direct à la base), donc on les laisse passer. if auth.uid() is null then return new; end if; -- `points` ne peut être modifié que via la RPC award_points(), jamais -- directement par le client — même un juge ne peut pas l'écrire à la main. if new.points is distinct from old.points and not points_bypass then raise exception 'points must be modified via award_points()'; end if; select role into caller_role from public.profiles where id = auth.uid(); if caller_role = 'judge' then -- Un juge peut changer pseudo / pseudo_locked / role / avatar_url / -- previous_rank de n'importe qui. return new; end if; -- Appelant non-juge : ne peut modifier que sa propre ligne (déjà imposé -- par la RLS), et seulement pseudo / avatar_url, sous conditions. if new.role is distinct from old.role then raise exception 'only judges can change role'; end if; if new.previous_rank is distinct from old.previous_rank then raise exception 'only judges can reset the rank reference'; end if; if new.pseudo_locked is distinct from old.pseudo_locked and new.pseudo_locked = false then raise exception 'only judges can unlock a pseudo'; end if; if new.pseudo is distinct from old.pseudo then if old.pseudo_locked then raise exception 'pseudo is locked, ask a judge'; end if; -- Premier changement de pseudo : on le fige automatiquement. new.pseudo_locked := true; end if; return new; end; $$; drop trigger if exists profiles_before_update on public.profiles; create trigger profiles_before_update before update on public.profiles for each row execute function public.enforce_profile_update(); -- Création automatique du profil à l'inscription (auth.users → profiles), -- indépendamment de l'état de la session (fonctionne même si l'email -- n'est pas encore confirmé, contrairement à un insert fait depuis le client). -- Le pseudo temporaire inclut les 8 premiers caractères de l'uuid : un -- littéral fixe "Nouveau membre" pour tout le monde entrait en collision -- avec profiles_pseudo_lower_key (unique) dès qu'une 2e personne était -- invitée avant que la 1re ait fini son inscription — ce qui n'était -- jamais arrivé avant d'inviter plusieurs personnes d'affilée. create or replace function public.handle_new_user() returns trigger language plpgsql security definer set search_path = public as $$ begin insert into public.profiles (id, pseudo) values ( new.id, coalesce(new.raw_user_meta_data ->> 'pseudo', 'Nouveau membre ' || substr(new.id::text, 1, 8)) ); return new; end; $$; drop trigger if exists on_auth_user_created on auth.users; create trigger on_auth_user_created after insert on auth.users for each row execute function public.handle_new_user(); -- 3. Policies RLS ---------------------------------------------------------- drop policy if exists "Profiles are viewable by authenticated users" on public.profiles; create policy "Profiles are viewable by authenticated users" on public.profiles for select to authenticated using (true); -- Plus de policy INSERT côté client : la création du profil passe -- exclusivement par le trigger handle_new_user() (SECURITY DEFINER). drop policy if exists "Users can insert their own profile" on public.profiles; drop policy if exists "Users can update their own profile" on public.profiles; create policy "Users can update their own profile" on public.profiles for update to authenticated using (auth.uid() = id) with check (auth.uid() = id); drop policy if exists "Judges can update any profile" on public.profiles; create policy "Judges can update any profile" on public.profiles for update to authenticated using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')) with check (true); -- Aucune policy delete => suppression interdite en V2. -- 4. Bucket avatars ------------------------------------------------------- -- file_size_limit/allowed_mime_types ajoutés (audit de sécurité) : sans -- ça, un appel direct à l'API de Storage (hors de l'app, où l'upload est -- toujours compressé côté client via cropImageToBlob) pouvait uploader un -- fichier arbitrairement gros ou non-image comme "avatar" — même -- principe que sur le bucket wall-images. insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) values ('avatars', 'avatars', true, 3145728, array['image/webp', 'image/jpeg', 'image/png']) on conflict (id) do update set public = excluded.public, file_size_limit = excluded.file_size_limit, allowed_mime_types = excluded.allowed_mime_types; -- Le bucket public sert la lecture via l'URL publique (hors RLS), mais une -- policy SELECT reste nécessaire : pour un upload en upsert (remplacement -- d'une photo existante), Postgres doit pouvoir lire la ligne existante en -- interne pour évaluer la clause USING de l'UPDATE — sans SELECT, l'upsert -- échoue avec "new row violates row-level security policy" même si les -- policies INSERT/UPDATE sont correctes. drop policy if exists "Avatars are publicly viewable" on storage.objects; create policy "Avatars are publicly viewable" on storage.objects for select using (bucket_id = 'avatars'); drop policy if exists "Users can upload their own avatar" on storage.objects; create policy "Users can upload their own avatar" on storage.objects for insert to authenticated with check ( bucket_id = 'avatars' and split_part(name, '.', 1) = auth.uid()::text ); drop policy if exists "Users can update their own avatar" on storage.objects; create policy "Users can update their own avatar" on storage.objects for update to authenticated using ( bucket_id = 'avatars' and split_part(name, '.', 1) = auth.uid()::text ) with check ( bucket_id = 'avatars' and split_part(name, '.', 1) = auth.uid()::text ); -- 5. Vérification du pseudo avant inscription ----------------------------- -- Un visiteur non connecté (rôle anon) n'a pas le droit de lire `profiles` -- (RLS réservée aux authentifiés). Pour afficher "ce pseudo est déjà pris" -- avant même de créer le compte, on expose une RPC qui ne renvoie qu'un -- booléen, sans jamais exposer le contenu de la table aux anonymes. create or replace function public.is_pseudo_taken(p_pseudo text) returns boolean language sql security definer set search_path = public as $$ select exists (select 1 from public.profiles where lower(pseudo) = lower(p_pseudo)); $$; grant execute on function public.is_pseudo_taken(text) to anon, authenticated; -- 6. Points, journal ("le crieur") ------------------------------------------ create table if not exists public.points_log ( id bigint generated always as identity primary key, target_id uuid not null references public.profiles (id) on delete cascade, judge_id uuid not null references public.profiles (id) on delete cascade, delta integer not null, reason text, created_at timestamptz not null default now() ); alter table public.points_log enable row level security; -- Lecture ouverte à tous les authentifiés (transparence du journal). -- Aucune policy insert/update/delete : seule la RPC award_points() -- (SECURITY DEFINER, propriétaire de la table) peut écrire. revoke insert, update, delete on public.points_log from authenticated, anon; grant select on public.points_log to authenticated; drop policy if exists "points_log readable by authenticated" on public.points_log; create policy "points_log readable by authenticated" on public.points_log for select to authenticated using (true); -- Seul point d'entrée pour modifier les points : vérifie le rôle juge -- côté serveur, applique le delta (négatif autorisé, pas de plancher), -- et trace l'opération dans points_log. Le flag app.bypass_points_lock -- autorise le trigger enforce_profile_update() à laisser passer CETTE -- écriture précise sur la colonne points. create or replace function public.award_points(p_target_id uuid, p_delta int, p_reason text default null) returns void language plpgsql security definer set search_path = public as $$ declare caller_role text; begin select role into caller_role from public.profiles where id = auth.uid(); if caller_role is distinct from 'judge' then raise exception 'only judges can award points'; end if; if not exists (select 1 from public.profiles where id = p_target_id) then raise exception 'target member not found'; end if; perform set_config('app.bypass_points_lock', 'on', true); update public.profiles set points = points + p_delta where id = p_target_id; perform set_config('app.bypass_points_lock', 'off', true); insert into public.points_log (target_id, judge_id, delta, reason) values (p_target_id, auth.uid(), p_delta, p_reason); end; $$; grant execute on function public.award_points(uuid, int, text) to authenticated; -- 7. Repère de classement (flèches de progression) -------------------------- -- Fige le rang actuel de chacun dans previous_rank ; les flèches côté -- client comparent le rang courant à cette référence. create or replace function public.reset_rank_reference() returns void language plpgsql security definer set search_path = public as $$ declare caller_role text; begin select role into caller_role from public.profiles where id = auth.uid(); if caller_role is distinct from 'judge' then raise exception 'only judges can reset the rank reference'; end if; with ranked as ( select id, rank() over (order by points desc) as r from public.profiles ) update public.profiles p set previous_rank = ranked.r from ranked where p.id = ranked.id; end; $$; grant execute on function public.reset_rank_reference() to authenticated; -- 8. Realtime ---------------------------------------------------------------- -- Permet au leaderboard et au journal de s'actualiser en direct pour tout -- le monde (Dashboard → Database → Replication fait la même chose). do $$ begin alter publication supabase_realtime add table public.profiles; exception when duplicate_object then null; end $$; do $$ begin alter publication supabase_realtime add table public.points_log; exception when duplicate_object then null; end $$; -- 9. Le Calendrier des Dieux -------------------------------------------------- -- Agenda de la semaine : chaque journée est placée sous le patronage d'une -- divinité (nom/domaine en texte libre, l'Archonte choisit un préréglage -- côté client ou saisit le sien). Lecture ouverte à tous, écriture réservée -- aux juges (Archontes), vérifiée côté serveur par RLS — jamais par le -- masquage des boutons en front. create table if not exists public.days ( id uuid primary key default gen_random_uuid(), date date not null unique, god_name text not null, god_domain text not null, description text, created_at timestamptz not null default now() ); create table if not exists public.events ( id uuid primary key default gen_random_uuid(), day_id uuid not null references public.days (id) on delete cascade, title text not null, description text, type text not null default 'activite' check (type in ('activite', 'defi', 'epreuve', 'tribunal')), start_time time, location text, created_by uuid references public.profiles (id) on delete set null, created_at timestamptz not null default now() ); -- Ligne de configuration unique (id toujours = true) : date du Tribunal. create table if not exists public.settings ( id boolean primary key default true, tribunal_date timestamptz, constraint settings_singleton check (id) ); insert into public.settings (id) values (true) on conflict (id) do nothing; alter table public.days enable row level security; alter table public.events enable row level security; alter table public.settings enable row level security; grant select, insert, update, delete on public.days, public.events to authenticated; grant select, update on public.settings to authenticated; -- Lecture : tout utilisateur authentifié. drop policy if exists "days viewable by authenticated" on public.days; create policy "days viewable by authenticated" on public.days for select to authenticated using (true); drop policy if exists "events viewable by authenticated" on public.events; create policy "events viewable by authenticated" on public.events for select to authenticated using (true); drop policy if exists "settings viewable by authenticated" on public.settings; create policy "settings viewable by authenticated" on public.settings for select to authenticated using (true); -- Écriture : réservée aux juges. drop policy if exists "days insert by judges" on public.days; create policy "days insert by judges" on public.days for insert to authenticated with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); drop policy if exists "days update by judges" on public.days; create policy "days update by judges" on public.days for update to authenticated using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')) with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); drop policy if exists "days delete by judges" on public.days; create policy "days delete by judges" on public.days for delete to authenticated using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); drop policy if exists "events insert by judges" on public.events; create policy "events insert by judges" on public.events for insert to authenticated with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); drop policy if exists "events update by judges" on public.events; create policy "events update by judges" on public.events for update to authenticated using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')) with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); drop policy if exists "events delete by judges" on public.events; create policy "events delete by judges" on public.events for delete to authenticated using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); drop policy if exists "settings update by judges" on public.settings; create policy "settings update by judges" on public.settings for update to authenticated using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')) with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); -- created_by ne fait jamais confiance au client : toujours l'auteur réel. create or replace function public.enforce_event_insert() returns trigger language plpgsql as $$ begin new.created_by := auth.uid(); return new; end; $$; drop trigger if exists events_before_insert on public.events; create trigger events_before_insert before insert on public.events for each row execute function public.enforce_event_insert(); do $$ begin alter publication supabase_realtime add table public.days; exception when duplicate_object then null; end $$; do $$ begin alter publication supabase_realtime add table public.events; exception when duplicate_object then null; end $$; do $$ begin alter publication supabase_realtime add table public.settings; exception when duplicate_object then null; end $$; -- 10. Désigner les premiers juges --------------------------------------------- -- À faire une fois les comptes créés (SQL Editor) : -- -- update public.profiles set role = 'judge' where id = ''; -- -- (Récupérer l'uuid via Authentication → Users, ou : -- select id, pseudo from public.profiles;) -- 11. Le Vol d'Icare ----------------------------------------------------------- -- Mini-jeu : remplace la Course du Char (abandonnée — voir nettoyage -- ci-dessous). Flappy Bird grec : Icare vole entre des colonnes de temple, -- un seul record personnel all-time (pas de piste/jour, générée librement à -- chaque partie côté client). Les scores sont figés dès que la date du -- Tribunal (settings.tribunal_date) est atteinte, puis les gloires du top 3 -- sont attribuées automatiquement (pas d'Archonte impliqué) par une tâche -- planifiée pg_cron. -- Nettoyage de la Course du Char (schéma déjà appliqué en prod avant cet -- abandon — ces DROP sont nécessaires, pas juste cosmétiques). do $$ begin perform cron.unschedule('close-daily-chariot-race'); exception when others then null; end $$; drop function if exists public.close_daily_chariot_race(); drop table if exists public.chariot_race_closes; drop function if exists public.submit_chariot_run(integer, jsonb); drop table if exists public.chariot_runs; -- Un point attribué automatiquement n'a pas de juge : judge_id reste -- nullable (déjà appliqué précédemment — le Crieur affiche "Le Tribunal"). create table if not exists public.icarus_scores ( user_id uuid primary key references public.profiles (id) on delete cascade, best_score integer not null check (best_score between 0 and 1000000), updated_at timestamptz not null default now() ); -- Plafond abaissé (audit de sécurité) : 1 000 000 était un score -- absurdement irréaliste, ce qui rendait triviale la soumission d'un -- 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. 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 500); alter table public.icarus_scores enable row level security; -- Même modèle que points_log/chariot_runs : verrouillée en écriture, seule -- la RPC submit_icarus_score() (SECURITY DEFINER) peut écrire. revoke insert, update, delete on public.icarus_scores from authenticated, anon; grant select on public.icarus_scores to authenticated; drop policy if exists "icarus_scores readable by authenticated" on public.icarus_scores; create policy "icarus_scores readable by authenticated" on public.icarus_scores for select to authenticated using (true); -- Marqueur d'idempotence pour l'attribution automatique des gloires (un seul -- é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; -- Anti-triche par signalement (Alexandre, appliqué directement en base — -- récupéré ici via pg_get_functiondef le 2026-08-25 pour que ce fichier -- redevienne la vraie source de vérité, voir game_cheat_flags en section -- 17). Remplace la couche par rejet serveur strict qui vivait ici -- (icarus_runs/start_icarus_run(), voir §3bis/V12 dans CLAUDE.md) : jamais -- de blocage, un score dont la télémétrie de partie (durée, nombre de -- battements d'aile — envoyée par le client dans p_run, voir -- IcarusRunTelemetry) est manquante, invalide, ou incohérente avec le -- rythme maximal du jeu déclenche seulement un signalement dans -- game_cheat_flags pour vérification manuelle par un Archonte sur /admin. -- Nettoyage de l'ancienne couche, devenue orpheline (plus aucun code -- client ne l'appelle) : drop function if exists public.submit_icarus_score(integer, uuid); drop function if exists public.start_icarus_run(); drop table if exists public.icarus_runs; -- Marqueur d'idempotence pour l'attribution automatique des gloires (un seul -- é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; -- Surcharge à un seul paramètre : conservée pour compat (ancien client, -- appel RPC direct sans télémétrie), délègue à la version avec p_run à -- null — systématiquement signalée comme suspecte ci-dessous, mais jamais -- bloquée. create or replace function public.submit_icarus_score(p_score integer) returns public.icarus_scores language plpgsql security definer set search_path = public as $$ declare v_row public.icarus_scores; begin select * into v_row from public.submit_icarus_score(p_score, null::jsonb); return v_row; end; $$; grant execute on function public.submit_icarus_score(integer) to authenticated; -- Seul vrai point d'entrée utilisé par le client actuel. 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 — mais le signalement, lui, a déjà eu lieu avant ce point. create or replace function public.submit_icarus_score(p_score integer, p_run jsonb) returns public.icarus_scores language plpgsql security definer set search_path = public as $$ declare v_tribunal_date timestamptz; v_row public.icarus_scores; v_previous_best integer; v_duration_ms numeric; v_actions numeric; v_details jsonb; -- Ping régulier envoyé par le client PENDANT la partie (ping_game_run(), -- section 18) : preuve horodatée par le SERVEUR, contrairement à -- duration_ms/actions ci-dessus qui restent entièrement déclaratifs -- (calculés et envoyés par le client en une seule fois à la fin, donc -- falsifiables par un appel RPC direct sans avoir vraiment joué). v_session_token uuid; v_ping_count integer; v_first_ping_at timestamptz; v_last_ping_at timestamptz; begin if auth.uid() is null then raise exception 'authentication required'; end if; -- 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; select best_score into v_previous_best from public.icarus_scores where user_id = auth.uid(); if p_run is null or jsonb_typeof(p_run) is distinct from 'object' or (p_run ->> 'v') is distinct from '1' then insert into public.game_cheat_flags (user_id, game, score, severity, trigger_code, reason, details) values ( auth.uid(), 'icarus', p_score, 'high', 'missing_run_telemetry', 'Score soumis sans télémétrie de partie (appel RPC direct ou ancien client).', jsonb_build_object('previous_best', v_previous_best) ); else if jsonb_typeof(p_run -> 'duration_ms') = 'number' then v_duration_ms := (p_run ->> 'duration_ms')::numeric; end if; if jsonb_typeof(p_run -> 'actions') = 'number' then v_actions := (p_run ->> 'actions')::numeric; end if; -- Extraction protégée (begin/exception imbriqué) : une valeur -- malformée ne doit jamais faire échouer toute la soumission, juste -- être traitée comme une absence de token (donc 0 ping trouvé, -- signalé plus bas comme les autres incohérences). begin v_session_token := nullif(p_run ->> 'session_token', '')::uuid; exception when invalid_text_representation then v_session_token := null; end; v_details := jsonb_build_object( 'duration_ms', v_duration_ms, 'actions', v_actions, 'previous_best', v_previous_best ); if v_duration_ms is null or v_actions is null or v_duration_ms < 0 or v_actions < 0 then insert into public.game_cheat_flags (user_id, game, score, severity, trigger_code, reason, details) values ( auth.uid(), 'icarus', p_score, 'high', 'invalid_run_telemetry', 'La télémétrie de la partie est absente ou invalide.', v_details ); else if p_score >= 3 and v_duration_ms < p_score * 450 then insert into public.game_cheat_flags (user_id, game, score, severity, trigger_code, reason, details) values ( auth.uid(), 'icarus', p_score, 'high', 'impossible_score_pace', 'Le score a progressé plus vite que la vitesse maximale du jeu ne le permet.', v_details ); end if; if p_score > 0 and v_actions < 1 then insert into public.game_cheat_flags (user_id, game, score, severity, trigger_code, reason, details) values ( auth.uid(), 'icarus', p_score, 'high', 'score_without_input', 'Un score positif a été soumis sans aucun battement d''aile enregistré.', v_details ); end if; if v_duration_ms > 0 and v_actions > (v_duration_ms / 1000) * 20 + 10 then insert into public.game_cheat_flags (user_id, game, score, severity, trigger_code, reason, details) values ( auth.uid(), 'icarus', p_score, 'warning', 'extreme_input_rate', 'Cadence de battements anormalement élevée sur la partie.', v_details ); end if; -- En dessous de 15s, aucun ping n'est attendu (le premier n'arrive -- qu'après un cycle d'intervalle côté client, ~5s) : pas de -- signalement pour une partie courte, y compris un échec immédiat. if v_duration_ms >= 15000 then select count(*), min(created_at), max(created_at) into v_ping_count, v_first_ping_at, v_last_ping_at from public.game_run_pings where user_id = auth.uid() and game = 'icarus' and session_token = v_session_token; if v_ping_count = 0 then insert into public.game_cheat_flags (user_id, game, score, severity, trigger_code, reason, details) values ( auth.uid(), 'icarus', p_score, 'high', 'missing_pings', 'Aucun ping serveur reçu pendant une partie annoncée assez longue pour en attendre.', v_details || jsonb_build_object('ping_count', v_ping_count) ); elsif extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000 < v_duration_ms * 0.4 then insert into public.game_cheat_flags (user_id, game, score, severity, trigger_code, reason, details) values ( auth.uid(), 'icarus', p_score, 'high', 'ping_span_mismatch', 'L''écart réel entre les pings reçus est bien plus court que la durée de partie annoncée.', v_details || jsonb_build_object( 'ping_count', v_ping_count, 'ping_span_ms', extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000 ) ); end if; end if; end if; -- Nettoyage : les pings de cette partie ont été consommés (signalés ou -- non), inutile de les garder — évite aussi qu'un même session_token -- réutilisé par erreur ne corrèle avec une partie précédente. if v_session_token is not null then delete from public.game_run_pings where user_id = auth.uid() and session_token = v_session_token; end if; end if; select tribunal_date into v_tribunal_date from public.settings where id = true; if v_tribunal_date is not null and now() >= v_tribunal_date then select * into v_row from public.icarus_scores where user_id = auth.uid(); return v_row; 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 set best_score = excluded.best_score, updated_at = now() where excluded.best_score > public.icarus_scores.best_score; select * into v_row from public.icarus_scores where user_id = auth.uid(); return v_row; end; $$; grant execute on function public.submit_icarus_score(integer, jsonb) to authenticated; do $$ begin alter publication supabase_realtime add table public.icarus_scores; exception when duplicate_object then null; end $$; -- Attribue les gloires du top 3 (ex-aequo inclus au même rang) dès que la -- date du Tribunal est atteinte ; no-op tant qu'elle n'est pas encore -- passée, et no-op définitif une fois déjà fait (icarus_points_awarded). -- Uniquement appelée par pg_cron ou depuis le SQL Editor : REVOKE explicite -- ci-dessous (ne pas se contenter de "ne pas accorder à authenticated" — -- PostgreSQL accorde EXECUTE à PUBLIC par défaut, donc sans révocation -- explicite cette fonction restait appelable par n'importe qui via l'API -- REST malgré l'intention). create or replace function public.award_icarus_points_if_due() returns void language plpgsql security definer set search_path = public as $$ declare v_tribunal_date timestamptz; v_already_awarded boolean; r record; v_points int; begin select tribunal_date, icarus_points_awarded into v_tribunal_date, v_already_awarded from public.settings where id = true; if v_tribunal_date is null or now() < v_tribunal_date or v_already_awarded then return; end if; for r in with ranked as ( select user_id, best_score, rank() over (order by best_score desc) as rnk from public.icarus_scores ) select * from ranked where rnk <= 3 loop v_points := case r.rnk when 1 then 3 when 2 then 2 when 3 then 1 else 0 end; update public.profiles set points = points + v_points where id = r.user_id; insert into public.points_log (target_id, judge_id, delta, reason) values (r.user_id, null, v_points, 'Le Vol d''Icare — rang ' || r.rnk || ' au Tribunal'); end loop; update public.settings set icarus_points_awarded = true where id = true; end; $$; revoke execute on function public.award_icarus_points_if_due() from public, anon, authenticated; -- ⚠️ pg_cron doit être activé une fois pour toutes via le Dashboard Supabase -- (Database → Extensions → "pg_cron" → Enable) — pas scriptable depuis ce -- fichier, et pas garanti self-service selon le plan/la région du projet. -- Les deux blocs ci-dessous n'échouent jamais bruyamment si l'extension n'est -- pas encore activée (schéma "cron" inexistant) : le reste de ce script, -- ré-exécuté en entier à chaque changement, doit toujours pouvoir passer. do $$ begin perform cron.unschedule('award-icarus-points'); exception when others then null; -- la tâche n'existe pas encore, ou pg_cron pas activé end $$; do $$ begin perform cron.schedule( 'award-icarus-points', '*/15 * * * *', $cron$select public.award_icarus_points_if_due();$cron$ ); exception when others then raise notice 'pg_cron indisponible : active l''extension via le Dashboard Supabase (Database → Extensions → pg_cron), puis ré-exécute ce script pour planifier l''attribution automatique des gloires du Vol d''Icare.'; end $$; -- 12. Le Jeu de l'Agora — Le Char et Le Gardien du Silence --------------------- -- Outils pour la soirée du Tribunal elle-même (jeu physique/social, pas un -- mini-jeu numérique) : l'app affiche/synchronise un état que les Archontes -- pilotent à la main, elle ne calcule/arbitre jamais le déroulé du jeu. -- Préfixe chariot_* (pas char_*) : sans rapport avec l'ancienne Course du -- Char digitale déjà supprimée (section 11), qui utilisait déjà ce préfixe — -- le reprendre ici évite de laisser croire à une résurrection de ce mini-jeu. -- La route reste /char. create table if not exists public.chariot_questions ( id uuid primary key default gen_random_uuid(), text text not null, position integer not null default 0, created_at timestamptz not null default now() ); alter table public.chariot_questions enable row level security; grant select, insert, update, delete on public.chariot_questions to authenticated; -- Lecture réservée aux juges (audit de sécurité) : la banque complète est un -- spoiler du jeu en cours pour les Citoyens tant qu'une question n'a pas été -- révélée par un Archonte. /char (accessible à tous) n'a plus le droit de -- lire cette table directement — il passe par la RPC -- chariot_revealed_question() ci-dessous, qui ne renvoie que la question -- actuellement révélée. Seul /char/questions (déjà réservé aux juges) lit -- encore cette table directement. drop policy if exists "chariot_questions viewable by authenticated" on public.chariot_questions; drop policy if exists "chariot_questions viewable by judges" on public.chariot_questions; create policy "chariot_questions viewable by judges" on public.chariot_questions for select to authenticated using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); drop policy if exists "chariot_questions insert by judges" on public.chariot_questions; create policy "chariot_questions insert by judges" on public.chariot_questions for insert to authenticated with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); drop policy if exists "chariot_questions update by judges" on public.chariot_questions; create policy "chariot_questions update by judges" on public.chariot_questions for update to authenticated using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')) with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); drop policy if exists "chariot_questions delete by judges" on public.chariot_questions; create policy "chariot_questions delete by judges" on public.chariot_questions for delete to authenticated using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); do $$ begin alter publication supabase_realtime add table public.chariot_questions; exception when duplicate_object then null; end $$; -- Seul moyen pour un non-juge de savoir quelle question est actuellement -- révélée sans jamais lire la banque complète (RLS ci-dessus, juges -- uniquement) : renvoie zéro ligne tant qu'aucune question n'est révélée. -- question_number est calculé sur l'ordre complet (position, created_at) -- pour afficher "Question N" sur /char sans exposer les autres lignes. create or replace function public.chariot_revealed_question() returns table (id uuid, text text, question_number integer) language sql security definer set search_path = public as $$ select numbered.id, numbered.text, numbered.question_number from ( select q.id, q.text, row_number() over (order by q.position, q.created_at)::integer as question_number from public.chariot_questions q ) numbered where numbered.id = (select s.chariot_revealed_question_id from public.settings s where s.id = true); $$; grant execute on function public.chariot_revealed_question() to authenticated; -- Nettoyage de l'ancien modèle par statut (team_a/team_b/out), remplacé par -- un modèle par emplacement fixe ci-dessous — schéma jamais appliqué en -- prod (retour utilisateur avant la première vraie migration), DROP direct. drop table if exists public.chariot_roster; -- Char : 6 emplacements fixes (1-3 = Colonne A, 4-6 = Colonne B), vides -- (user_id null) ou occupés. Pré-remplis une fois pour toutes ci-dessous — -- on ne fait jamais que les UPDATE, jamais d'insert/delete côté client. create table if not exists public.chariot_slots ( slot integer primary key check (slot between 1 and 6), user_id uuid references public.profiles (id) on delete set null ); insert into public.chariot_slots (slot) select generate_series(1, 6) on conflict (slot) do nothing; alter table public.chariot_slots enable row level security; grant select, update on public.chariot_slots to authenticated; drop policy if exists "chariot_slots viewable by authenticated" on public.chariot_slots; create policy "chariot_slots viewable by authenticated" on public.chariot_slots for select to authenticated using (true); drop policy if exists "chariot_slots update by judges" on public.chariot_slots; create policy "chariot_slots update by judges" on public.chariot_slots for update to authenticated using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')) with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); do $$ begin alter publication supabase_realtime add table public.chariot_slots; exception when duplicate_object then null; end $$; -- Journal d'entrées dans le char (append-only, comme points_log) : sert -- uniquement à compter combien de fois chaque personne est montée — -- une ligne ajoutée à chaque affectation d'un emplacement, jamais modifiée. create table if not exists public.chariot_entries ( id bigint generated always as identity primary key, user_id uuid not null references public.profiles (id) on delete cascade, created_at timestamptz not null default now() ); alter table public.chariot_entries enable row level security; grant select, insert on public.chariot_entries to authenticated; revoke update, delete on public.chariot_entries from authenticated, anon; drop policy if exists "chariot_entries viewable by authenticated" on public.chariot_entries; create policy "chariot_entries viewable by authenticated" on public.chariot_entries for select to authenticated using (true); drop policy if exists "chariot_entries insert by judges" on public.chariot_entries; create policy "chariot_entries insert by judges" on public.chariot_entries for insert to authenticated with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); do $$ begin alter publication supabase_realtime add table public.chariot_entries; exception when duplicate_object then null; end $$; -- Question actuellement révélée sur la page publique (bascule afficher/ -- masquer), et état du Gardien du Silence. alter table public.settings add column if not exists chariot_revealed_question_id uuid references public.chariot_questions (id) on delete set null; alter table public.settings add column if not exists gardien_holder_id uuid references public.profiles (id) on delete set null; alter table public.settings add column if not exists gardien_expires_at timestamptz; -- Verrou de colonne pour le Gardien (même pattern que profiles.points) : -- gardien_holder_id/gardien_expires_at ne doivent changer que via un vrai -- tirage aléatoire (reroll_gardien), jamais par une écriture directe même -- par un Archonte — chariot_revealed_question_id reste, lui, directement -- modifiable par les juges (rien à protéger dans cette colonne-là). create or replace function public.enforce_settings_update() returns trigger language plpgsql as $$ declare bypass boolean := coalesce(current_setting('app.bypass_gardien_lock', true), 'off') = 'on'; begin if auth.uid() is null then return new; end if; if not bypass and ( new.gardien_holder_id is distinct from old.gardien_holder_id or new.gardien_expires_at is distinct from old.gardien_expires_at ) then raise exception 'gardien_holder_id/gardien_expires_at can only change via reroll_gardien()'; end if; return new; end; $$; drop trigger if exists settings_before_update on public.settings; create trigger settings_before_update before update on public.settings for each row execute function public.enforce_settings_update(); -- Tire un nouveau Gardien (uniquement parmi les Citoyens, jamais le -- détenteur actuel si possible) et repousse l'expiration de 5 minutes. -- p_force=true (réservé aux juges) reroll immédiatement ; p_force=false -- (utilisé par le minuteur côté client, 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 séparés) : -- avec plusieurs téléphones ouverts, tous les minuteurs locaux arrivent à -- zéro dans la même seconde et appellent cette RPC en même temps — la garde -- dans le WHERE fait que Postgres sérialise naturellement, un seul appel -- modifie réellement la ligne, les autres ne touchent aucune ligne. create or replace function public.reroll_gardien(p_force boolean default false) returns void language plpgsql security definer set search_path = public as $$ declare caller_role text; begin if p_force then select role into caller_role from public.profiles where id = auth.uid(); if caller_role is distinct from 'judge' then raise exception 'only judges can force a reroll'; end if; end if; perform set_config('app.bypass_gardien_lock', 'on', true); update public.settings set gardien_holder_id = coalesce( (select id from public.profiles where role = 'public' and id is distinct from settings.gardien_holder_id order by random() limit 1), (select id from public.profiles where role = 'public' order by random() limit 1) ), gardien_expires_at = now() + interval '5 minutes' where id = true and (p_force or gardien_expires_at is null or now() >= gardien_expires_at); perform set_config('app.bypass_gardien_lock', 'off', true); end; $$; grant execute on function public.reroll_gardien(boolean) to authenticated; -- ⚠️ Même remarque que pour pg_cron plus haut : à activer une fois via le -- Dashboard Supabase si ce n'est pas déjà fait. Cadence 1 minute : filet -- large par rapport au minuteur de 5 minutes, le déclenchement réel se fait -- côté client dès qu'un onglet est ouvert. do $$ begin perform cron.unschedule('reroll-gardien'); exception when others then null; end $$; do $$ begin perform cron.schedule( 'reroll-gardien', '*/1 * * * *', $cron$select public.reroll_gardien();$cron$ ); exception when others then raise notice 'pg_cron indisponible : active l''extension via le Dashboard Supabase (Database → Extensions → pg_cron), puis ré-exécute ce script pour planifier le reroll automatique du Gardien.'; end $$; -- 13. Suggestions de questions par les Citoyens + emails sur Admin -- ----------------------------------------------------------------- -- Une proposition par Citoyen (une ligne par user_id, comme icarus_scores). -- Verrouillée en écriture comme points_log/icarus_scores : seule la RPC -- submit_chariot_question() (SECURITY DEFINER) peut insert/update ; les -- juges gardent un accès direct en lecture (modération) et en suppression -- (rejet), sans passer par une RPC pour ce cas simple. create table if not exists public.chariot_submissions ( user_id uuid primary key references public.profiles (id) on delete cascade, text text not null, updated_at timestamptz not null default now() ); alter table public.chariot_submissions enable row level security; revoke insert, update on public.chariot_submissions from authenticated, anon; grant select, delete on public.chariot_submissions to authenticated; drop policy if exists "chariot_submissions readable by owner or judges" on public.chariot_submissions; create policy "chariot_submissions readable by owner or judges" on public.chariot_submissions for select to authenticated using ( user_id = auth.uid() or exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge') ); drop policy if exists "chariot_submissions delete by judges" on public.chariot_submissions; create policy "chariot_submissions delete by judges" on public.chariot_submissions for delete to authenticated using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); do $$ begin alter publication supabase_realtime add table public.chariot_submissions; exception when duplicate_object then null; end $$; -- Historique append-only (comme points_log) des versions successives d'une -- proposition : chariot_submissions ne garde que la valeur courante (upsert), -- donc sans ce journal les Archontes ne verraient jamais les versions -- précédentes d'une question modifiée plusieurs fois. Écrit uniquement par -- submit_chariot_question() (aucune policy insert, comme points_log). create table if not exists public.chariot_submission_history ( id bigint generated always as identity primary key, user_id uuid not null references public.profiles (id) on delete cascade, text text not null, created_at timestamptz not null default now() ); alter table public.chariot_submission_history enable row level security; revoke insert, update, delete on public.chariot_submission_history from authenticated, anon; grant select on public.chariot_submission_history to authenticated; drop policy if exists "chariot_submission_history readable by owner or judges" on public.chariot_submission_history; create policy "chariot_submission_history readable by owner or judges" on public.chariot_submission_history for select to authenticated using ( user_id = auth.uid() or exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge') ); do $$ begin alter publication supabase_realtime add table public.chariot_submission_history; exception when duplicate_object then null; end $$; -- Seul point d'entrée pour proposer/modifier sa question. Réutilise -- settings.tribunal_date comme déclencheur de gel — même philosophie que -- submit_icarus_score() : une fois l'Agora commencée, no-op silencieux -- (retourne la ligne existante sans la modifier) plutôt qu'une erreur. create or replace function public.submit_chariot_question(p_text text) returns public.chariot_submissions language plpgsql security definer set search_path = public as $$ declare v_caller_role text; v_tribunal_date timestamptz; v_row public.chariot_submissions; begin if auth.uid() is null then raise exception 'authentication required'; end if; select role into v_caller_role from public.profiles where id = auth.uid(); if v_caller_role = 'judge' then raise exception 'only citizens can submit a chariot question'; end if; if p_text is null or length(trim(p_text)) = 0 then raise exception 'question vide'; end if; if length(p_text) > 300 then raise exception 'question trop longue'; end if; select tribunal_date into v_tribunal_date from public.settings where id = true; if v_tribunal_date is not null and now() >= v_tribunal_date then select * into v_row from public.chariot_submissions where user_id = auth.uid(); return v_row; end if; insert into public.chariot_submissions (user_id, text, updated_at) values (auth.uid(), trim(p_text), now()) on conflict (user_id) do update set text = excluded.text, updated_at = now(); insert into public.chariot_submission_history (user_id, text) values (auth.uid(), trim(p_text)); select * into v_row from public.chariot_submissions where user_id = auth.uid(); return v_row; end; $$; grant execute on function public.submit_chariot_question(text) to authenticated; -- Émails des membres pour /admin : jamais stockés dans profiles (voir §4 du -- doc — le pseudo est le seul nom montré aux autres), donc invisibles côté -- client sans passer par auth.users. Plutôt que d'exposer la service_role -- key côté client, une RPC SECURITY DEFINER fait la jointure, gardée par la -- même vérification de rôle que les autres RPC juge-only du projet. create or replace function public.admin_list_members() returns table ( id uuid, pseudo text, avatar_url text, role text, pseudo_locked boolean, email text ) language plpgsql security definer set search_path = public as $$ begin -- Colonnes de sortie qualifiées par un alias partout : returns table(...) -- déclare id/role comme variables PL/pgSQL, qui masqueraient sinon les -- colonnes de même nom dans profiles (erreur 42702 "ambiguous"). if not exists (select 1 from public.profiles pr where pr.id = auth.uid() and pr.role = 'judge') then raise exception 'only judges can list members'; end if; return query select p.id, p.pseudo, p.avatar_url, p.role, p.pseudo_locked, u.email::text from public.profiles p join auth.users u on u.id = p.id order by p.pseudo asc; end; $$; grant execute on function public.admin_list_members() to authenticated; -- 14. Le Mur de la Honte ------------------------------------------------------- -- Une note anonyme par Citoyen par jour, max 10 notes visibles par jour -- (remise à zéro chaque jour — filtrage par date, pas de suppression), pour -- donner une raison de rouvrir l'app tous les jours de la semaine, pas -- seulement pour le classement. L'anonymat n'est qu'à moitié réel : les -- Archontes voient l'historique complet avec l'auteur de chaque note. -- Chaque note peut porter une image facultative (bucket wall-images, -- compressée côté client, limite de taille côté serveur) et des réactions -- emoji, elles aussi anonymes (voir wall_note_reactions plus bas). create table if not exists public.wall_notes ( id uuid primary key default gen_random_uuid(), user_id uuid not null references public.profiles (id) on delete cascade, text text not null, created_at timestamptz not null default now() ); -- URL publique dans le bucket wall-images (voir plus bas), facultative. alter table public.wall_notes add column if not exists image_url text; alter table public.wall_notes enable row level security; revoke insert, update, delete on public.wall_notes from authenticated, anon; grant select on public.wall_notes to authenticated; drop policy if exists "wall_notes readable by owner or judges" on public.wall_notes; create policy "wall_notes readable by owner or judges" on public.wall_notes for select to authenticated using ( user_id = auth.uid() or exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge') ); do $$ begin alter publication supabase_realtime add table public.wall_notes; exception when duplicate_object then null; end $$; -- Seul moyen pour un Citoyen de voir les notes des autres sans jamais -- exposer qui les a écrites côté client : la RLS ne peut pas masquer une -- colonne pour certaines lignes, donc cette RPC (SECURITY DEFINER) fait le -- filtre elle-même et ne renvoie jamais user_id. Le type de retour change -- (ajout d'image_url) : drop explicite requis, create or replace ne peut -- pas changer le type de retour d'une fonction existante. drop function if exists public.wall_notes_today(); create or replace function public.wall_notes_today() returns table (id uuid, text text, image_url text, created_at timestamptz) language sql security definer set search_path = public as $$ select w.id, w.text, w.image_url, w.created_at from public.wall_notes w where (w.created_at at time zone 'Europe/Paris')::date = (now() at time zone 'Europe/Paris')::date order by w.created_at asc; $$; grant execute on function public.wall_notes_today() to authenticated; -- Seul point d'entrée pour publier une note. Pas de gel via tribunal_date -- (contrairement au Char) : contrairement à la soirée de clôture, c'est une -- mécanique de toute la semaine. Petite fenêtre de course possible si deux -- personnes postent la même seconde alors qu'il reste 1 place (double -- insertion à 13) — acceptée sciemment, comme d'autres arbitrages similaires -- déjà faits dans le projet pour un groupe de ~12 amis. Ouvert aux Archontes -- en plus des Citoyens (plus de vérification de rôle) : plafond relevé à 12 -- (au lieu de 10) pour couvrir tout le groupe, pas seulement les Citoyens. -- La signature change (ajout de p_image_url) : l'ancienne (text) est -- explicitement supprimée, sinon create or replace créerait une 2e -- surcharge au lieu de remplacer. drop function if exists public.post_wall_note(text); create or replace function public.post_wall_note(p_text text, p_image_url text default null) returns public.wall_notes language plpgsql security definer set search_path = public as $$ declare v_today date := (now() at time zone 'Europe/Paris')::date; v_already_posted boolean; v_count_today int; v_image_url text := nullif(trim(coalesce(p_image_url, '')), ''); v_row public.wall_notes; begin if auth.uid() is null then raise exception 'authentication required'; end if; -- Les Archontes peuvent désormais publier eux aussi (plus réservé aux -- Citoyens) : plus de vérification de rôle ici, tout authentifié peut -- poster une note anonyme — la limite de 12 (au lieu de 10) plus bas -- suit ce changement pour couvrir tout le groupe. if p_text is null or length(trim(p_text)) = 0 then raise exception 'note vide'; end if; if length(p_text) > 200 then raise exception 'note trop longue'; end if; -- Doit venir du bucket wall-images géré par l'app, jamais une URL -- arbitraire (le bucket a lui-même une limite de taille/mime, voir plus -- bas — cette vérification empêche seulement d'y stocker n'importe quoi). if v_image_url is not null and v_image_url not like '%/wall-images/%' then raise exception 'image invalide'; end if; select exists ( select 1 from public.wall_notes where user_id = auth.uid() and (created_at at time zone 'Europe/Paris')::date = v_today ) into v_already_posted; if v_already_posted then raise exception 'already posted today'; end if; select count(*) into v_count_today from public.wall_notes where (created_at at time zone 'Europe/Paris')::date = v_today; if v_count_today >= 12 then raise exception 'wall full today'; end if; insert into public.wall_notes (user_id, text, image_url) values (auth.uid(), trim(p_text), v_image_url) returning * into v_row; return v_row; end; $$; grant execute on function public.post_wall_note(text, text) to authenticated; -- Bucket dédié aux images du Mur (pas "avatars") : chemin d'objet -- volontairement SANS le user_id (contrairement aux avatars) pour ne -- jamais faire fuiter l'auteur via l'URL publique de l'image — cohérent -- avec l'anonymat de wall_notes_today. file_size_limit + allowed_mime_types -- bornent le stockage côté Supabase (coût) ; le client compresse déjà -- l'image bien en dessous de cette limite avant l'upload -- (resizeImageToBlob), qui n'est qu'un filet de sécurité, pas la seule -- garantie. insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) values ('wall-images', 'wall-images', true, 3145728, array['image/webp', 'image/jpeg', 'image/png']) on conflict (id) do update set public = excluded.public, file_size_limit = excluded.file_size_limit, allowed_mime_types = excluded.allowed_mime_types; drop policy if exists "Wall images are publicly viewable" on storage.objects; create policy "Wall images are publicly viewable" on storage.objects for select using (bucket_id = 'wall-images'); -- Les Archontes peuvent désormais publier sur le Mur eux aussi (voir -- post_wall_note plus haut, qui n'a plus de restriction de rôle) : plus de -- filtre par rôle ici non plus, sinon un Archonte pourrait poster une note -- mais pas y joindre d'image. drop policy if exists "Citizens can upload wall images" on storage.objects; drop policy if exists "Members can upload wall images" on storage.objects; create policy "Members can upload wall images" on storage.objects for insert to authenticated with check (bucket_id = 'wall-images'); -- Réactions sur les notes ------------------------------------------------- -- Même principe d'anonymat que les notes elles-mêmes : chacun ne voit que -- ses propres réactions (pour savoir lesquelles sont déjà activées), le -- compte agrégé par note et par emoji est exposé séparément par -- wall_note_reaction_counts(), sans jamais révéler qui a réagi. create table if not exists public.wall_note_reactions ( id bigint generated always as identity primary key, note_id uuid not null references public.wall_notes (id) on delete cascade, user_id uuid not null references public.profiles (id) on delete cascade, emoji text not null check (emoji in ('👍', '😂', '😱', '❤️', '🔥')), created_at timestamptz not null default now(), unique (note_id, user_id, emoji) ); alter table public.wall_note_reactions enable row level security; revoke insert, update, delete on public.wall_note_reactions from authenticated, anon; grant select on public.wall_note_reactions to authenticated; drop policy if exists "wall_note_reactions readable by reactor only" on public.wall_note_reactions; create policy "wall_note_reactions readable by reactor only" on public.wall_note_reactions for select to authenticated using (user_id = auth.uid()); do $$ begin alter publication supabase_realtime add table public.wall_note_reactions; exception when duplicate_object then null; end $$; create or replace function public.wall_note_reaction_counts() returns table (note_id uuid, emoji text, count bigint) language sql security definer set search_path = public as $$ select r.note_id, r.emoji, count(*) as count from public.wall_note_reactions r join public.wall_notes w on w.id = r.note_id where (w.created_at at time zone 'Europe/Paris')::date = (now() at time zone 'Europe/Paris')::date group by r.note_id, r.emoji; $$; grant execute on function public.wall_note_reaction_counts() to authenticated; -- Bascule une réaction (ajoute si absente, retire si déjà posée) en un seul -- aller-retour : un delete puis, si rien n'a été supprimé, un insert — -- évite une lecture préalable puis un choix côté client entre 2 RPC. create or replace function public.toggle_wall_note_reaction(p_note_id uuid, p_emoji text) returns boolean language plpgsql security definer set search_path = public as $$ declare v_allowed constant text[] := array['👍', '😂', '😱', '❤️', '🔥']; v_deleted int; begin if auth.uid() is null then raise exception 'authentication required'; end if; if not (p_emoji = any(v_allowed)) then raise exception 'emoji non autorisé'; end if; if not exists (select 1 from public.wall_notes where id = p_note_id) then raise exception 'note introuvable'; end if; delete from public.wall_note_reactions where note_id = p_note_id and user_id = auth.uid() and emoji = p_emoji; get diagnostics v_deleted = row_count; if v_deleted > 0 then return false; end if; insert into public.wall_note_reactions (note_id, user_id, emoji) values (p_note_id, auth.uid(), p_emoji); return true; end; $$; grant execute on function public.toggle_wall_note_reaction(uuid, text) to authenticated; -- 15. L'Urne de l'Agora ------------------------------------------------------- -- Vote quotidien inspiré du vote à l'urne de l'Athènes antique : chaque jour -- chaque Citoyen dépose un jeton sur une personne de son choix. Contrairement -- au Mur de la Honte, les jetons s'accumulent sur toute la semaine dans un -- classement public et cumulatif — identique pour tout le monde, Archontes -- compris : contrairement à chariot_submissions/wall_notes, les Archontes -- n'ont ici AUCUNE vision privilégiée sur qui a voté pour qui, seul le total -- par personne est public. Les Archontes ne peuvent ni voter ni recevoir de -- jetons. Chaque vote peut porter une justification facultative, exposée de -- façon anonyme (urn_vote_reasons) : le contenu est public, jamais l'auteur. create table if not exists public.urn_votes ( id bigint generated always as identity primary key, voter_id uuid not null references public.profiles (id) on delete cascade, target_id uuid not null references public.profiles (id) on delete cascade, created_at timestamptz not null default now() ); -- Justification facultative laissée par le votant. Exposée ensuite de façon -- anonyme via urn_vote_reasons() (voir plus bas) : le contenu est public, -- jamais son auteur — cohérent avec le reste de la table. alter table public.urn_votes add column if not exists reason text; alter table public.urn_votes enable row level security; revoke insert, update, delete on public.urn_votes from authenticated, anon; grant select on public.urn_votes to authenticated; -- Pas d'exception juge ici (contrairement à chariot_submissions/wall_notes) : -- même les Archontes ne voient que leur propre ligne (pour savoir "j'ai déjà -- voté aujourd'hui") — qui a voté pour qui ne regarde personne d'autre. drop policy if exists "urn_votes readable by voter only" on public.urn_votes; create policy "urn_votes readable by voter only" on public.urn_votes for select to authenticated using (voter_id = auth.uid()); do $$ begin alter publication supabase_realtime add table public.urn_votes; exception when duplicate_object then null; end $$; -- Seul moyen de calculer un total public par personne sans jamais exposer -- une ligne individuelle (qui a voté pour qui) à qui que ce soit. Agrégé sur -- tout l'historique (pas de filtre de date, contrairement à wall_notes_today -- qui ne montre que le jour courant) : les jetons s'accumulent toute la -- semaine. create or replace function public.urn_vote_counts() returns table (target_id uuid, votes bigint) language sql security definer set search_path = public as $$ select v.target_id, count(*) as votes from public.urn_votes v group by v.target_id; $$; grant execute on function public.urn_vote_counts() to authenticated; -- Permet de comprendre pourquoi les gens ont voté pour quelqu'un sans jamais -- révéler qui a voté quoi (même principe que wall_notes_today : le contenu -- est exposé, l'identité du votant jamais) — visible par tous depuis le -- classement, Archontes compris (même vision que pour urn_vote_counts). create or replace function public.urn_vote_reasons(p_target_id uuid) returns table (reason text, created_at timestamptz) language sql security definer set search_path = public as $$ select v.reason, v.created_at from public.urn_votes v where v.target_id = p_target_id and v.reason is not null order by v.created_at desc; $$; grant execute on function public.urn_vote_reasons(uuid) to authenticated; -- Seul point d'entrée pour voter. Un vote par Citoyen par jour, définitif -- (aucune RPC de modification/suppression). La signature change (ajout de -- p_reason) : l'ancienne (uuid) est explicitement supprimée, sinon -- create or replace créerait une 2e surcharge au lieu de remplacer celle-ci. drop function if exists public.cast_urn_vote(uuid); create or replace function public.cast_urn_vote(p_target_id uuid, p_reason text default null) returns public.urn_votes language plpgsql security definer set search_path = public as $$ declare v_caller_role text; v_target_role text; v_today date := (now() at time zone 'Europe/Paris')::date; v_already_voted boolean; v_reason text := nullif(trim(coalesce(p_reason, '')), ''); v_row public.urn_votes; begin if auth.uid() is null then raise exception 'authentication required'; end if; select role into v_caller_role from public.profiles where id = auth.uid(); if v_caller_role = 'judge' then raise exception 'only citizens can vote'; end if; if p_target_id = auth.uid() then raise exception 'cannot vote for yourself'; end if; select role into v_target_role from public.profiles where id = p_target_id; if v_target_role is null then raise exception 'target member not found'; end if; if v_target_role = 'judge' then raise exception 'cannot vote for a judge'; end if; if v_reason is not null and length(v_reason) > 200 then raise exception 'justification trop longue'; end if; select exists ( select 1 from public.urn_votes where voter_id = auth.uid() and (created_at at time zone 'Europe/Paris')::date = v_today ) into v_already_voted; if v_already_voted then raise exception 'already voted today'; end if; insert into public.urn_votes (voter_id, target_id, reason) values (auth.uid(), p_target_id, v_reason) returning * into v_row; return v_row; end; $$; grant execute on function public.cast_urn_vote(uuid, text) to authenticated; -- 16. La Corne d'Abondance ------------------------------------------------------- -- Mini-jeu de fusion façon "Watermelon Game" : des cercles tombent dans un -- bac, deux cercles de même palier qui se touchent fusionnent en un cercle -- plus gros, jusqu'à débordement. Au lieu de fruits, chaque cercle affiche -- l'avatar d'un membre tiré au hasard (décoratif, indépendant du palier). -- Même mécanique de gloires que Le Vol d'Icare : record personnel all-time, -- scores figés à la date du Tribunal, gloires du top 3 attribuées -- automatiquement (pg_cron, sans intervention d'un Archonte). create table if not exists public.melon_scores ( user_id uuid primary key references public.profiles (id) on delete cascade, best_score integer not null check (best_score between 0 and 1000000), updated_at timestamptz not null default now() ); -- Plafond abaissé (audit de sécurité) : voir la remarque identique sur -- 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 2000); alter table public.melon_scores enable row level security; -- Même modèle que icarus_scores : verrouillée en écriture, seule la RPC -- submit_melon_score() (SECURITY DEFINER) peut écrire. revoke insert, update, delete on public.melon_scores from authenticated, anon; grant select on public.melon_scores to authenticated; drop policy if exists "melon_scores readable by authenticated" on public.melon_scores; create policy "melon_scores readable by authenticated" on public.melon_scores for select to authenticated using (true); -- Marqueur d'idempotence pour l'attribution automatique des gloires (un seul -- événement, comme icarus_points_awarded). alter table public.settings add column if not exists melon_points_awarded boolean not null default false; -- Anti-triche par signalement (Alexandre, appliqué directement en base — -- récupéré ici via pg_get_functiondef le 2026-08-25, même mécanisme que -- submit_icarus_score plus haut, voir §3bis/V12 dans CLAUDE.md et -- game_cheat_flags en section 17) : jamais de blocage, une partie dont la -- télémétrie (durée, nombre de lâchers, nombre de fusions — envoyée par le -- client dans p_run, voir MelonRunTelemetry) est manquante, invalide, ou -- incohérente avec ce que la physique du jeu permet (cooldown de lâcher, -- nombre de fusions borné par le nombre de lâchers, score borné par le -- gain maximal théorique par fusion) déclenche un signalement dans -- game_cheat_flags pour vérification manuelle par un Archonte sur /admin. -- -- Surcharge à un seul paramètre : conservée pour compat, délègue à la -- version avec p_run à null — systématiquement signalée comme suspecte -- ci-dessous, mais jamais bloquée. create or replace function public.submit_melon_score(p_score integer) returns public.melon_scores language plpgsql security definer set search_path = public as $$ declare v_row public.melon_scores; begin select * into v_row from public.submit_melon_score(p_score, null::jsonb); return v_row; end; $$; grant execute on function public.submit_melon_score(integer) to authenticated; -- Seul vrai point d'entrée utilisé par le client actuel. 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 — mais le signalement, lui, a déjà eu lieu avant ce point. create or replace function public.submit_melon_score(p_score integer, p_run jsonb) returns public.melon_scores language plpgsql security definer set search_path = public as $$ declare v_tribunal_date timestamptz; v_row public.melon_scores; v_previous_best integer; v_duration_ms numeric; v_actions numeric; v_merges numeric; v_citizen_count integer; v_max_tier integer; v_details jsonb; -- Ping régulier envoyé par le client PENDANT la partie (ping_game_run(), -- section 18) : preuve horodatée par le SERVEUR, contrairement à -- duration_ms/actions/merges ci-dessus qui restent entièrement -- déclaratifs — voir la même remarque sur submit_icarus_score. v_session_token uuid; v_ping_count integer; v_first_ping_at timestamptz; v_last_ping_at timestamptz; begin if auth.uid() is null then raise exception 'authentication required'; end if; -- 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 > 2000 then raise exception 'invalid score'; end if; select best_score into v_previous_best from public.melon_scores where user_id = auth.uid(); select count(*)::integer into v_citizen_count from public.profiles where role = 'public'; v_max_tier := greatest(v_citizen_count - 1, 0); if p_run is null or jsonb_typeof(p_run) is distinct from 'object' or (p_run ->> 'v') is distinct from '1' then insert into public.game_cheat_flags (user_id, game, score, severity, trigger_code, reason, details) values ( auth.uid(), 'melon', p_score, 'high', 'missing_run_telemetry', 'Score soumis sans télémétrie de partie (appel RPC direct ou ancien client).', jsonb_build_object('previous_best', v_previous_best) ); else if jsonb_typeof(p_run -> 'duration_ms') = 'number' then v_duration_ms := (p_run ->> 'duration_ms')::numeric; end if; if jsonb_typeof(p_run -> 'actions') = 'number' then v_actions := (p_run ->> 'actions')::numeric; end if; if jsonb_typeof(p_run -> 'merges') = 'number' then v_merges := (p_run ->> 'merges')::numeric; end if; -- Extraction protégée (begin/exception imbriqué) : une valeur -- malformée ne doit jamais faire échouer toute la soumission, juste -- être traitée comme une absence de token (donc 0 ping trouvé, -- signalé plus bas comme les autres incohérences). begin v_session_token := nullif(p_run ->> 'session_token', '')::uuid; exception when invalid_text_representation then v_session_token := null; end; v_details := jsonb_build_object( 'duration_ms', v_duration_ms, 'actions', v_actions, 'merges', v_merges, 'previous_best', v_previous_best ); if v_duration_ms is null or v_actions is null or v_merges is null or v_duration_ms < 0 or v_actions < 0 or v_merges < 0 then insert into public.game_cheat_flags (user_id, game, score, severity, trigger_code, reason, details) values ( auth.uid(), 'melon', p_score, 'high', 'invalid_run_telemetry', 'La télémétrie de la partie est absente ou invalide.', v_details ); else if p_score > 0 and (v_actions < 2 or v_merges < 1) then insert into public.game_cheat_flags (user_id, game, score, severity, trigger_code, reason, details) values ( auth.uid(), 'melon', p_score, 'high', 'score_without_required_moves', 'Un score positif nécessite au moins deux lâchers et une fusion.', v_details ); end if; if v_actions >= 2 and v_duration_ms < (v_actions - 1) * 400 then insert into public.game_cheat_flags (user_id, game, score, severity, trigger_code, reason, details) values ( auth.uid(), 'melon', p_score, 'high', 'impossible_drop_rate', 'Les pièces ont été lâchées plus vite que le délai minimal du jeu ne le permet.', v_details ); end if; if v_merges > greatest(v_actions - 1, 0) then insert into public.game_cheat_flags (user_id, game, score, severity, trigger_code, reason, details) values ( auth.uid(), 'melon', p_score, 'high', 'impossible_merge_count', 'Le nombre de fusions dépasse ce que le nombre de pièces lâchées permet.', v_details ); end if; if v_merges > 0 and p_score > v_merges * greatest(v_max_tier * 2, 2) then insert into public.game_cheat_flags (user_id, game, score, severity, trigger_code, reason, details) values ( auth.uid(), 'melon', p_score, 'high', 'impossible_score_from_merges', 'Le score dépasse le maximum théorique permis par les fusions enregistrées.', v_details ); end if; -- En dessous de 15s, aucun ping n'est attendu (le premier n'arrive -- qu'après un cycle d'intervalle côté client, ~5s) : pas de -- signalement pour une partie courte, y compris un échec immédiat. if v_duration_ms >= 15000 then select count(*), min(created_at), max(created_at) into v_ping_count, v_first_ping_at, v_last_ping_at from public.game_run_pings where user_id = auth.uid() and game = 'melon' and session_token = v_session_token; if v_ping_count = 0 then insert into public.game_cheat_flags (user_id, game, score, severity, trigger_code, reason, details) values ( auth.uid(), 'melon', p_score, 'high', 'missing_pings', 'Aucun ping serveur reçu pendant une partie annoncée assez longue pour en attendre.', v_details || jsonb_build_object('ping_count', v_ping_count) ); elsif extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000 < v_duration_ms * 0.4 then insert into public.game_cheat_flags (user_id, game, score, severity, trigger_code, reason, details) values ( auth.uid(), 'melon', p_score, 'high', 'ping_span_mismatch', 'L''écart réel entre les pings reçus est bien plus court que la durée de partie annoncée.', v_details || jsonb_build_object( 'ping_count', v_ping_count, 'ping_span_ms', extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000 ) ); end if; end if; end if; -- Nettoyage : les pings de cette partie ont été consommés (signalés ou -- non), inutile de les garder — évite aussi qu'un même session_token -- réutilisé par erreur ne corrèle avec une partie précédente. if v_session_token is not null then delete from public.game_run_pings where user_id = auth.uid() and session_token = v_session_token; end if; end if; select tribunal_date into v_tribunal_date from public.settings where id = true; if v_tribunal_date is not null and now() >= v_tribunal_date then select * into v_row from public.melon_scores where user_id = auth.uid(); return v_row; end if; insert into public.melon_scores (user_id, best_score, updated_at) values (auth.uid(), p_score, now()) on conflict (user_id) do update set best_score = excluded.best_score, updated_at = now() where excluded.best_score > public.melon_scores.best_score; select * into v_row from public.melon_scores where user_id = auth.uid(); return v_row; end; $$; grant execute on function public.submit_melon_score(integer, jsonb) to authenticated; do $$ begin alter publication supabase_realtime add table public.melon_scores; exception when duplicate_object then null; end $$; -- Attribue les gloires du top 3 (ex-aequo inclus au même rang) dès que la -- date du Tribunal est atteinte ; no-op tant qu'elle n'est pas encore -- passée, et no-op définitif une fois déjà fait (melon_points_awarded). -- Uniquement appelée par pg_cron ou depuis le SQL Editor : REVOKE explicite -- ci-dessous (voir la même remarque pour award_icarus_points_if_due — -- PostgreSQL accorde EXECUTE à PUBLIC par défaut sans révocation explicite). create or replace function public.award_melon_points_if_due() returns void language plpgsql security definer set search_path = public as $$ declare v_tribunal_date timestamptz; v_already_awarded boolean; r record; v_points int; begin select tribunal_date, melon_points_awarded into v_tribunal_date, v_already_awarded from public.settings where id = true; if v_tribunal_date is null or now() < v_tribunal_date or v_already_awarded then return; end if; for r in with ranked as ( select user_id, best_score, rank() over (order by best_score desc) as rnk from public.melon_scores ) select * from ranked where rnk <= 3 loop v_points := case r.rnk when 1 then 3 when 2 then 2 when 3 then 1 else 0 end; update public.profiles set points = points + v_points where id = r.user_id; insert into public.points_log (target_id, judge_id, delta, reason) values (r.user_id, null, v_points, 'La Corne d''Abondance — rang ' || r.rnk || ' au Tribunal'); end loop; update public.settings set melon_points_awarded = true where id = true; end; $$; revoke execute on function public.award_melon_points_if_due() from public, anon, authenticated; -- ⚠️ pg_cron doit être activé une fois pour toutes via le Dashboard Supabase -- (Database → Extensions → "pg_cron" → Enable) — voir la même remarque à la -- section Icare. Les deux blocs ci-dessous n'échouent jamais bruyamment si -- l'extension n'est pas encore activée. do $$ begin perform cron.unschedule('award-melon-points'); exception when others then null; -- la tâche n'existe pas encore, ou pg_cron pas activé end $$; do $$ begin perform cron.schedule( 'award-melon-points', '*/15 * * * *', $cron$select public.award_melon_points_if_due();$cron$ ); exception when others then raise notice 'pg_cron indisponible : active l''extension via le Dashboard Supabase (Database → Extensions → pg_cron), puis ré-exécute ce script pour planifier l''attribution automatique des gloires de La Corne d''Abondance.'; end $$; -- 17. Anti-triche par signalement (Alexandre) -------------------------------- -- Remplace icarus_runs (ancienne section 11, supprimée) côté client : plutôt -- qu'un rejet serveur strict basé sur le temps réellement écoulé, cette -- table journalise les parties suspectes (durée/nombre d'actions/fusions -- incohérents avec le score, calculés côté client et envoyés dans p_run à -- submit_icarus_score/submit_melon_score, section 11/16) pour qu'un -- Archonte les vérifie à la main sur /admin (AdminCheatFlags) — les scores -- concernés restent acceptés, rien n'est jamais bloqué automatiquement. -- -- Table, policy RLS et logique d'écriture (submit_icarus_score/ -- submit_melon_score) toutes récupérées directement depuis Supabase -- (dump de schéma + pg_policies + pg_get_functiondef, 2026-08-25) — ce -- fichier est de nouveau la vraie source de vérité pour cette -- fonctionnalité, contrairement à l'état précédent. create table if not exists public.game_cheat_flags ( id bigint generated always as identity primary key, user_id uuid not null references public.profiles (id), game text not null check (game in ('icarus', 'melon')), score integer not null, severity text not null check (severity in ('warning', 'high')), trigger_code text not null, reason text not null, details jsonb not null default '{}'::jsonb, created_at timestamptz not null default now() ); alter table public.game_cheat_flags enable row level security; -- Pas de grant insert/update/delete à authenticated : comme points_log/ -- icarus_scores, seules les fonctions SECURITY DEFINER submit_icarus_score/ -- submit_melon_score (section 11/16) peuvent y écrire. revoke insert, update, delete on public.game_cheat_flags from authenticated, anon; grant select on public.game_cheat_flags to authenticated; drop policy if exists "game_cheat_flags readable by judges" on public.game_cheat_flags; create policy "game_cheat_flags readable by judges" on public.game_cheat_flags for select to authenticated using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); -- 18. Anti-triche : ping régulier pendant la partie -------------------------- -- Complète le signalement (section 17) : duration_ms/actions/merges dans -- p_run sont entièrement déclaratifs (calculés et envoyés par le client en -- une seule fois à la fin), donc falsifiables par quelqu'un qui appelle -- submit_icarus_score/submit_melon_score directement sans avoir vraiment -- joué. Le client envoie un ping régulier PENDANT la partie (toutes les -- ~5s tant qu'elle est en cours), horodaté ici par l'horloge du SERVEUR — -- submit_icarus_score/submit_melon_score (section 11/16) corrèlent ensuite -- le nombre de pings et l'écart réel entre le premier et le dernier avec -- la durée annoncée, et signalent (jamais ne bloquent, même philosophie -- que le reste de l'anti-triche) toute incohérence. create table if not exists public.game_run_pings ( id bigint generated always as identity primary key, user_id uuid not null references public.profiles (id) on delete cascade, game text not null check (game in ('icarus', 'melon')), session_token uuid not null, created_at timestamptz not null default now() ); alter table public.game_run_pings enable row level security; -- Aucun grant à authenticated/anon (même principe que l'ancien icarus_runs, -- section 11) : seules ping_game_run() (insert) et submit_icarus_score/ -- submit_melon_score (lecture + nettoyage), toutes SECURITY DEFINER, -- contournent la RLS en tant que propriétaires des fonctions. create or replace function public.ping_game_run(p_game text, p_session_token uuid) returns void language plpgsql security definer set search_path = public as $$ begin if auth.uid() is null then raise exception 'authentication required'; end if; if p_game not in ('icarus', 'melon') then raise exception 'invalid game'; end if; insert into public.game_run_pings (user_id, game, session_token) values (auth.uid(), p_game, p_session_token); end; $$; grant execute on function public.ping_game_run(text, uuid) to authenticated;