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
+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 }
}