Durcit la sécurité : plafond de score réaliste, REVOKE explicites, banque de questions du Char réservée aux juges
Build and deploy / deploy (push) Successful in 36s

Un score falsifié envoyé directement à submit_icarus_score/submit_melon_score pouvait se convertir en vraies gloires au Tribunal (plafond abaissé de 1 000 000 à 5000, contrainte + RPC). Les fonctions cron-only award_*_points_if_due() n'étaient protégées que par un commentaire, pas un vrai REVOKE. Le bucket avatars n'avait aucune limite de taille/type. La banque complète des questions du Char (spoiler du jeu) était lisible par tout Citoyen via /char ou un appel direct — RLS restreinte aux juges, /char passe désormais par la RPC chariot_revealed_question().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-08-24 04:41:39 +02:00
parent 3503dc2593
commit dc99af6bb3
6 changed files with 145 additions and 45 deletions
+19 -7
View File
@@ -66,20 +66,21 @@ 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.
- `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`).
- `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)`.
- `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`).
- `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).
- 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)
- `chariot_questions` : `id, text, position, created_at` — banque de questions ordonnée, réordonnée par swap de `position` (pas de contrainte unique dessus, deux `update` non transactionnels ; toujours trier `order by position, created_at`). RLS : lecture ouverte, écriture réservée aux juges (comme `days`/`events`). Gérée exclusivement depuis `/char/questions`, jamais depuis `/char`.
- `chariot_slots` : `slot (pk, 1 à 6), user_id nullable` — 6 emplacements fixes pré-remplis une fois pour toutes (1-3 = Colonne A, 4-6 = Colonne B), jamais insert/delete côté client, seulement des `update` pour affecter/vider un emplacement. Même RLS que `chariot_questions`.
- `chariot_questions` : `id, text, position, created_at` — banque de questions ordonnée, réordonnée par swap de `position` (pas de contrainte unique dessus, deux `update` non transactionnels ; toujours trier `order by position, created_at`). RLS : **lecture réservée aux juges** (audit de sécurité — la liste complète serait un spoiler du jeu en cours pour un Citoyen qui la lirait directement, RLS ouverte à tout le monde jusque-là), écriture réservée aux juges (comme `days`/`events`). Gérée exclusivement depuis `/char/questions`, jamais depuis `/char`. `/char` (accessible à tous) ne lit plus jamais cette table directement : il passe par la RPC `chariot_revealed_question()`, qui ne renvoie que la question actuellement révélée (avec son numéro d'ordre, calculé côté serveur pour ne jamais exposer la position des autres questions).
- `chariot_slots` : `slot (pk, 1 à 6), user_id nullable` — 6 emplacements fixes pré-remplis une fois pour toutes (1-3 = Colonne A, 4-6 = Colonne B), jamais insert/delete côté client, seulement des `update` pour affecter/vider un emplacement. RLS : lecture ouverte à tout le monde (contrairement à `chariot_questions` depuis l'audit — rien à cacher ici, l'occupation des emplacements est publique par nature), écriture réservée aux juges.
- `chariot_entries` : `id, user_id, created_at` — journal append-only (comme `points_log`) d'une ligne à chaque affectation d'un Citoyen à un emplacement, sert uniquement à compter combien de fois chacun est monté dans le char (`count(*) group by user_id`, calculé côté client). RLS : lecture ouverte, insertion réservée aux juges, aucune mise à jour/suppression possible.
- `settings.chariot_revealed_question_id` : question actuellement affichée sur `/char`, directement modifiable par les juges (rien à protéger dans cette colonne-là) — bascule effectuée depuis `/char/questions`.
- Préfixe `chariot_*` (pas `char_*`) volontaire : sans rapport avec l'ancienne Course du Char digitale supprimée, qui utilisait déjà ce préfixe.
@@ -104,15 +105,26 @@ Même pattern sur `public.settings` depuis V7, via `enforce_settings_update()` :
Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accès aux colonnes) est la convention du projet — le reproduire pour toute nouvelle colonne sensible plutôt que d'inventer autre chose.
### §3bis. Durcissement de sécurité (audit)
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.
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).
Deuxième volet de l'audit (revue complète de tout le code applicatif, pas seulement `schema.sql`) : la RLS `select` de `chariot_questions` était ouverte à tout authentifié (`using (true)`), et `/char/page.tsx` en lisait la table entière pour l'embarquer dans le payload RSC envoyé à tout le monde — un Citoyen pouvait donc lire la banque complète des questions à venir (spoiler du jeu de la soirée) simplement en inspectant la page ou en rejouant l'appel Supabase depuis la console, alors même que `/char` n'en affiche jamais qu'une seule à l'écran. Corrigé en deux temps : RLS `select` de `chariot_questions` restreinte aux juges (seul `/char/questions`, déjà réservé aux juges, en a encore besoin), et nouvelle RPC `chariot_revealed_question()` qui ne renvoie que la question révélée (avec son numéro d'ordre) pour que `/char` n'ait plus jamais besoin de lire la table directement.
### RPC exposées
- `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.
- `award_icarus_points_if_due()`**pas de grant à authenticated**, 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`).
- `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).
- `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é).
- `chariot_revealed_question()` — authenticated, `language sql`, seul moyen pour un non-juge de connaître la question actuellement révélée sans jamais lire `chariot_questions` (RLS juges uniquement, voir §3bis) — renvoie zéro ligne tant qu'aucune question n'est révélée, et calcule `question_number` (position dans l'ordre complet) côté serveur pour afficher « Question N » sur `/char` sans exposer les autres lignes.
- `admin_list_members()` — authenticated, vérifie `role = 'judge'`, seule façon d'exposer `auth.users.email` (jamais stocké dans `profiles`) sur `/admin` sans passer par la `service_role key` côté client.
- `wall_notes_today()` — authenticated, `language sql`, renvoie les notes du jour (heure de Paris), y compris `image_url`, **sans `user_id`** — seul moyen de laisser un Citoyen voir les notes des autres sans jamais exposer l'auteur côté client (la RLS ne peut pas masquer une colonne pour certaines lignes).
- `post_wall_note(p_text, p_image_url default null)` — authenticated, rejette les juges (seuls les Citoyens publient), valide un texte non vide (≤ 200 caractères), puis vérifie que l'appelant n'a pas déjà publié aujourd'hui et que le mur du jour a moins de 10 notes (sinon `raise exception` dans les deux cas — pas de no-op silencieux ici, l'UI doit remonter l'erreur). `p_image_url` est facultatif et doit venir du bucket `wall-images` (vérifié par un `like`).
@@ -127,7 +139,7 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè
- `id, user_id, text, image_url, created_at` — une ligne par note, jamais éditée/supprimée (comme `points_log`) ; le « reset quotidien » n'est qu'un filtrage par date dans `wall_notes_today()`, pas une suppression. `image_url` est facultative, pointe vers le bucket `wall-images`.
- RLS `select` : le propriétaire voit sa propre note (sert à afficher « tu as déjà publié aujourd'hui » et à réafficher son propre texte), les juges voient tout avec l'auteur (embed `profiles(pseudo, avatar_url)`, section « Historique complet » sur `/mur`) — pas de visibilité entre Citoyens, c'est tout l'intérêt de `wall_notes_today()`.
- Pas de gel via `settings.tribunal_date` : contrairement au Char, c'est une mécanique de toute la semaine.
- Bucket Storage `wall-images` (public, `file_size_limit` 3 Mo, `allowed_mime_types` image/webp+jpeg+png) : contrairement à `avatars`, le chemin d'objet est un UUID **sans le `user_id`** — sinon l'auteur fuiterait via l'URL publique de l'image, ce que `wall_notes_today()` prend justement soin de ne jamais exposer. Compression côté client avant upload (`resizeImageToBlob`, `src/lib/image.ts`, contrairement à `cropImageToBlob` qui force un carré pour les avatars) ; la limite du bucket n'est qu'un filet de sécurité, pas la seule garantie.
- Bucket Storage `wall-images` (public, `file_size_limit` 3 Mo, `allowed_mime_types` image/webp+jpeg+png — même limite que le bucket `avatars`, voir §3bis) : contrairement à `avatars`, le chemin d'objet est un UUID **sans le `user_id`** — sinon l'auteur fuiterait via l'URL publique de l'image, ce que `wall_notes_today()` prend justement soin de ne jamais exposer. Compression côté client avant upload (`resizeImageToBlob`, `src/lib/image.ts`, contrairement à `cropImageToBlob` qui force un carré pour les avatars) ; la limite du bucket n'est qu'un filet de sécurité, pas la seule garantie.
- `public.wall_note_reactions` (`id, note_id, user_id, emoji, created_at`, `unique (note_id, user_id, emoji)`) : réactions emoji sur une note, même principe d'anonymat que les notes elles-mêmes — RLS `select` limitée à `user_id = auth.uid()` (pour savoir lesquelles sont déjà activées), le total public passe uniquement par `wall_note_reaction_counts()`.
### `public.urn_votes` (L'Urne de l'Agora, V10)
+12 -15
View File
@@ -6,44 +6,42 @@ import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import { EntryRanking } from "./entry-ranking";
import { GardienBar } from "./gardien-bar";
import { RouletteSection } from "./roulette-section";
import type { ProfileRow, QuestionRow, SettingsRow, SlotRow } from "./page";
import type { ProfileRow, RevealedQuestion, SettingsRow, SlotRow } from "./page";
import { SlotBoard } from "./slot-board";
export function CharView({
isJudge,
initialSlots,
initialEntryCounts,
initialQuestions,
initialRevealedQuestion,
initialSettings,
initialProfiles,
}: {
isJudge: boolean;
initialSlots: SlotRow[];
initialEntryCounts: Record<string, number>;
initialQuestions: QuestionRow[];
initialRevealedQuestion: RevealedQuestion;
initialSettings: SettingsRow;
initialProfiles: ProfileRow[];
}) {
const [slots, setSlots] = useState(initialSlots);
const [entryCounts, setEntryCounts] = useState(initialEntryCounts);
const [questions, setQuestions] = useState(initialQuestions);
const [revealedQuestion, setRevealedQuestion] = useState(initialRevealedQuestion);
const [settings, setSettings] = useState(initialSettings);
const [profiles, setProfiles] = useState(initialProfiles);
// Une seule page projetée (16:9) : on ré-interroge tout à chaque
// changement, y compris chariot_questions/settings — la question révélée
// est désormais pilotée depuis /char/questions (un autre appareil), donc
// cette page doit réagir en direct à ces changements-là aussi.
// changement, y compris settings — la question révélée est désormais
// pilotée depuis /char/questions (un autre appareil), donc cette page
// doit réagir en direct à ces changements-là aussi. La question révélée
// passe par la RPC chariot_revealed_question() (jamais une lecture
// directe de chariot_questions, réservée aux juges — spoiler sinon).
const refetch = useCallback(async () => {
const supabase = createClient();
const [{ data: sl }, { data: en }, { data: q }, { data: s }, { data: p }] = await Promise.all([
supabase.from("chariot_slots").select("slot, user_id").order("slot", { ascending: true }),
supabase.from("chariot_entries").select("user_id"),
supabase
.from("chariot_questions")
.select("id, text, position, created_at")
.order("position", { ascending: true })
.order("created_at", { ascending: true }),
supabase.rpc("chariot_revealed_question"),
supabase
.from("settings")
.select("chariot_revealed_question_id, gardien_holder_id, gardien_expires_at")
@@ -57,7 +55,7 @@ export function CharView({
for (const entry of en) counts[entry.user_id] = (counts[entry.user_id] ?? 0) + 1;
setEntryCounts(counts);
}
if (q) setQuestions(q);
setRevealedQuestion((q?.[0] as RevealedQuestion) ?? null);
if (s) setSettings(s);
if (p) setProfiles(p);
}, []);
@@ -84,7 +82,6 @@ export function CharView({
};
}, [refetch]);
const revealedQuestion = questions.find((q) => q.id === settings.chariot_revealed_question_id) ?? null;
const gardienHolder = profiles.find((p) => p.id === settings.gardien_holder_id) ?? null;
return (
@@ -109,7 +106,7 @@ export function CharView({
className="marble-surface animate-reveal rounded-2xl border border-gold/40 px-6 py-6 text-center shadow-xl sm:py-8"
>
<p className="mb-1 font-heading text-xs tracking-[0.2em] text-text-mut uppercase sm:text-sm">
Question {questions.findIndex((q) => q.id === revealedQuestion.id) + 1}
Question {revealedQuestion.question_number}
</p>
<p className="font-heading text-xl text-text-marble sm:text-2xl lg:text-3xl">{revealedQuestion.text}</p>
</div>
+9 -10
View File
@@ -10,12 +10,14 @@ export type ProfileRow = {
points: number;
};
export type QuestionRow = {
// Uniquement la question révélée (jamais la banque complète, réservée aux
// juges — voir la RPC chariot_revealed_question() et la RLS de
// chariot_questions dans supabase/schema.sql, audit de sécurité).
export type RevealedQuestion = {
id: string;
text: string;
position: number;
created_at: string;
};
question_number: number;
} | null;
export type SlotRow = {
slot: number;
@@ -60,11 +62,8 @@ export default async function CharPage() {
const { data: entries } = await supabase.from("chariot_entries").select("user_id");
const { data: questions } = await supabase
.from("chariot_questions")
.select("id, text, position, created_at")
.order("position", { ascending: true })
.order("created_at", { ascending: true });
const { data: revealedRows } = await supabase.rpc("chariot_revealed_question");
const revealedQuestion: RevealedQuestion = (revealedRows?.[0] as RevealedQuestion) ?? null;
const { data: settings } = await supabase
.from("settings")
@@ -83,7 +82,7 @@ export default async function CharPage() {
isJudge={isJudge}
initialSlots={(slots ?? []) as SlotRow[]}
initialEntryCounts={buildEntryCounts(entries ?? [])}
initialQuestions={questions ?? []}
initialRevealedQuestion={revealedQuestion}
initialSettings={
settings ?? { chariot_revealed_question_id: null, gardien_holder_id: null, gardien_expires_at: null }
}
+4 -1
View File
@@ -77,4 +77,7 @@ export const BOOST_DASH_DISTANCE = 22;
export const SHATTER_ANIM_MS = 450;
export const MIN_SCORE = 0;
export const MAX_SCORE = 1000000;
// 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;
+4 -1
View File
@@ -42,7 +42,10 @@ export const FIXED_DT_MS = 1000 / 60;
export const MAX_FRAME_DT_MS = 250;
export const MIN_SCORE = 0;
export const MAX_SCORE = 1000000;
// 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;
// Positions figées à la main pour certains Citoyens (id de profil → index
// de palier, 0 = le plus petit) — les Citoyens restants remplissent les
+97 -11
View File
@@ -56,6 +56,21 @@ alter table public.profiles enable row level security;
-- 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
@@ -202,9 +217,17 @@ create policy "Judges can update any profile"
-- 4. Bucket avatars -------------------------------------------------------
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true)
on conflict (id) do nothing;
-- 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
@@ -524,6 +547,16 @@ create table if not exists public.icarus_scores (
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.
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);
alter table public.icarus_scores enable row level security;
-- Même modèle que points_log/chariot_runs : verrouillée en écriture, seule
@@ -558,7 +591,11 @@ begin
raise exception 'authentication required';
end if;
if p_score is null or p_score < 0 or p_score > 1000000 then
-- 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
raise exception 'invalid score';
end if;
@@ -593,8 +630,11 @@ 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).
-- Volontairement pas de grant execute à authenticated : uniquement appelée
-- par pg_cron ou depuis le SQL Editor.
-- 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
@@ -632,6 +672,8 @@ begin
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.
@@ -676,9 +718,18 @@ create table if not exists public.chariot_questions (
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;
create policy "chariot_questions viewable by authenticated"
on public.chariot_questions for select to authenticated using (true);
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
@@ -700,6 +751,28 @@ 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.
@@ -1435,6 +1508,12 @@ create table if not exists public.melon_scores (
updated_at timestamptz not null default now()
);
-- Plafond abaissé (audit de sécurité) : voir la remarque identique sur
-- icarus_scores.
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);
alter table public.melon_scores enable row level security;
-- Même modèle que icarus_scores : verrouillée en écriture, seule la RPC
@@ -1469,7 +1548,11 @@ begin
raise exception 'authentication required';
end if;
if p_score is null or p_score < 0 or p_score > 1000000 then
-- 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
raise exception 'invalid score';
end if;
@@ -1504,8 +1587,9 @@ 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).
-- Volontairement pas de grant execute à authenticated : uniquement appelée
-- par pg_cron ou depuis le SQL Editor.
-- 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
@@ -1543,6 +1627,8 @@ begin
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