Ouvre Le Mur de la Honte aux Archontes, plafond quotidien relevé à 12
Build and deploy / deploy (push) Successful in 36s

post_wall_note() n'a plus de vérification de rôle : un Archonte peut désormais publier une note anonyme comme n'importe quel Citoyen. La policy Storage d'upload sur wall-images perd aussi son filtre par rôle (sinon possible de poster une note mais pas d'y joindre une image). Limite quotidienne relevée de 10 à 12 notes pour couvrir tout le groupe plutôt que les seuls Citoyens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-08-25 17:30:46 +02:00
parent 40a10170bb
commit 5e75939b94
4 changed files with 113 additions and 107 deletions
+5 -3
View File
@@ -6,7 +6,7 @@ Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des ol
## 1. État actuel
V1 à V13 sont livrées :
V1 à V14 sont livrées :
- **V1** : comptes, profils, leaderboard statique.
- **V2** : authentification par email réel (plus de pseudo+email interne), rôles `public`/`judge`, verrou de pseudo (modifiable une fois), section Admin.
- **V3** : attribution de points par les juges (RPC sécurisée, +/- avec confirmation explicite), journal public « le crieur », podium top 3, flèches de progression, recadrage/compression photo côté client.
@@ -23,6 +23,8 @@ V1 à V13 sont livrées :
- **V13** : troisième couche de l'anti-triche des mini-jeux (à la demande explicite de l'utilisateur, après qu'un premier signalement réel via V12 a confirmé que le mécanisme fonctionnait) — un ping régulier pendant la partie, en plus du signalement fondé sur la télémétrie déclarative de V12. `public.game_run_pings` (`id, user_id, game, session_token, created_at`) journalise chaque ping, horodaté par le **serveur** (jamais par le client, contrairement à `duration_ms`/`actions`/`merges` qui restent déclaratifs) ; `ping_game_run(p_game, p_session_token)` l'écrit. Côté client, `IcarusGame`/`MelonGame` retrouvent un prop `onStart` (le composant de jeu reste agnostique de Supabase, comme avant — c'est `IcareView`/`CorneView` qui gèrent le token/l'intervalle) déclenché au premier battement d'aile pour Icare, au montage et à chaque `resetGame()` pour Corne ; `IcareView`/`CorneView` y génèrent un `session_token` (`crypto.randomUUID()`), lancent un `setInterval` de 5s qui appelle `ping_game_run()` (best-effort, erreurs ignorées), l'arrêtent à la fin de la partie ou au démontage du composant, et l'incluent dans `p_run.session_token` envoyé à `submit_icarus_score`/`submit_melon_score`. Ces deux RPC corrèlent alors le nombre de pings et l'écart réel entre le premier et le dernier avec `duration_ms` annoncé (uniquement si celui-ci dépasse 15s, pour ne jamais signaler une partie courte/un échec immédiat) et ajoutent deux nouveaux `trigger_code` à `game_cheat_flags` : `missing_pings` (zéro ping reçu) et `ping_span_mismatch` (écart réel entre pings très inférieur à la durée annoncée) — toujours en signalement, jamais en blocage, même philosophie que V12. Décision produit assumée (l'utilisateur l'a explicitement refusée pour V12 aussi) : les signalements restent **volontairement invisibles** pour la personne concernée — le but est de savoir qui triche sans le prévenir, y compris si un score signalé finit par recevoir de vraies gloires automatiquement au Tribunal.
- **V14** : Le Mur de la Honte (V9) s'ouvre aux Archontes — ils peuvent désormais publier une note anonyme comme n'importe quel Citoyen (`post_wall_note` n'a plus de vérification de rôle), et la limite quotidienne passe de 10 à 12 notes pour couvrir tout le groupe plutôt que les seuls Citoyens. La policy Storage d'upload sur `wall-images` perd elle aussi son filtre par rôle (sinon un Archonte aurait pu poster une note mais pas y joindre d'image). Contraste assumé avec V10 (l'Urne, où les Archontes ne votent ni ne reçoivent de jetons) : ici rien ne les distingue plus des Citoyens côté publication — ils gardent seulement, comme avant, leur visibilité privilégiée sur l'auteur de chaque note via « Historique complet ».
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, mode grand écran.
---
@@ -139,7 +141,7 @@ Deuxième volet de l'audit (revue complète de tout le code applicatif, pas seul
- `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`).
- `post_wall_note(p_text, p_image_url default null)` — authenticated, ouvert à tout le monde y compris les juges depuis V14 (plus de vérification de rôle), 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 12 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`).
- `wall_note_reaction_counts()` — authenticated, `language sql`, renvoie `(note_id, emoji, count)` agrégé sur les notes du jour — même principe que `urn_vote_counts` : jamais qui a réagi, seulement le total.
- `toggle_wall_note_reaction(p_note_id, p_emoji)` — authenticated, bascule une réaction (ajoute si absente, retire si déjà posée en un `delete` puis, si rien n'a été supprimé, un `insert`). `p_emoji` limité à une liste fermée (contrainte `check` sur la colonne, revérifiée dans la RPC pour un message d'erreur clair).
- `urn_vote_counts()` — authenticated, `language sql`, renvoie `(target_id, votes)` agrégé sur tout l'historique (pas de filtre de date, contrairement à `wall_notes_today`) — seul moyen de calculer un total public sans jamais exposer une ligne individuelle (qui a voté pour qui).
@@ -179,7 +181,7 @@ Deuxième volet de l'audit (revue complète de tout le code applicatif, pas seul
| `/corne` | « La Corne d'Abondance » — jeu de fusion façon Watermelon Game (avatars aléatoires, physique `matter-js`), même mécanique de gloires que Le Vol d'Icare |
| `/char` | « Le Jeu de l'Agora » — format paysage (projeté) : Le Char (2 colonnes de 3, boutons de duel), Le Gardien du Silence (tirage aléatoire, minuteur discret) et La Roulette (tirage au sort animé) |
| `/char/questions` | Banque de questions du Char (ajout/édition/réordonnancement/afficher-masquer) + modération des suggestions des Citoyens (ajouter à la banque/rejeter) — juges uniquement, pilotée depuis un téléphone pendant que `/char` est projetée |
| `/mur` | « Le Mur de la Honte » — une note anonyme par Citoyen par jour (dix places, remis à zéro chaque jour) ; les Archontes y voient en plus l'historique complet avec l'auteur de chaque note |
| `/mur` | « Le Mur de la Honte » — une note anonyme par personne par jour, Archontes compris depuis V14 (douze places, remis à zéro chaque jour) ; les Archontes y voient en plus l'historique complet avec l'auteur de chaque note |
| `/urne` | « L'Urne de l'Agora » — un jeton par Citoyen par jour sur une autre personne, classement cumulatif public identique pour tous, Archontes compris (aucune vision privilégiée ici) |
| `/calendrier` | « Le Calendrier des Dieux » — agenda de la semaine, panthéon, date du Tribunal, édition réservée aux juges. Retirée du menu de navigation (la date du Tribunal, seule info largement utile au quotidien, est reprise sur `/leaderboard`) mais la route reste fonctionnelle — les juges y accèdent directement par l'URL pour gérer le calendrier et changer la date |
| `/journal` | « Le Crieur » — fil live des décrets (attributions de points), lecture pour tous |
+80 -78
View File
@@ -9,7 +9,11 @@ import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import type { MyReactionRow, ReactionCountRow, WallHistoryRow, WallNoteRow } from "./page";
const MAX_LENGTH = 200;
const MAX_NOTES_PER_DAY = 10;
// Les Archontes peuvent désormais publier aussi (plus réservé aux
// Citoyens) : 12 places pour couvrir tout le groupe, pas seulement les
// Citoyens — doit rester aligné avec la borne côté serveur dans
// post_wall_note (supabase/schema.sql).
const MAX_NOTES_PER_DAY = 12;
const MAX_IMAGE_SOURCE_BYTES = 20 * 1024 * 1024;
const REACTION_EMOJIS = ["👍", "😂", "😱", "❤️", "🔥"];
@@ -362,85 +366,83 @@ export function MurView({
)}
</div>
{!isJudge && (
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
{myNoteToday ? (
<div>
<p className="text-sm text-olive">Ta note d&apos;aujourd&apos;hui est publiée.</p>
{myNoteToday.image_url && (
<Image
src={myNoteToday.image_url}
alt=""
width={300}
height={200}
className="mt-2 max-h-40 w-full rounded-md object-cover"
/>
)}
<p className="mt-2 rounded-md border border-gold/20 bg-white/40 px-3 py-2 text-sm text-text-marble">
{myNoteToday.text}
</p>
<p className="mt-2 text-xs text-text-mut">Reviens demain pour en publier une nouvelle.</p>
</div>
) : wallFull ? (
<p className="text-sm text-text-mut">Le mur est complet pour aujourd&apos;hui, reviens demain !</p>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
maxLength={MAX_LENGTH}
rows={2}
placeholder="Écrire une note anonyme…"
className="rounded-md border border-gold/30 bg-white/50 px-3 py-2 text-sm text-text-marble outline-none focus:border-gold"
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
{myNoteToday ? (
<div>
<p className="text-sm text-olive">Ta note d&apos;aujourd&apos;hui est publiée.</p>
{myNoteToday.image_url && (
<Image
src={myNoteToday.image_url}
alt=""
width={300}
height={200}
className="mt-2 max-h-40 w-full rounded-md object-cover"
/>
<p className="text-right text-[0.65rem] text-text-mut">
{text.length}/{MAX_LENGTH}
)}
<p className="mt-2 rounded-md border border-gold/20 bg-white/40 px-3 py-2 text-sm text-text-marble">
{myNoteToday.text}
</p>
<p className="mt-2 text-xs text-text-mut">Reviens demain pour en publier une nouvelle.</p>
</div>
) : wallFull ? (
<p className="text-sm text-text-mut">Le mur est complet pour aujourd&apos;hui, reviens demain !</p>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
maxLength={MAX_LENGTH}
rows={2}
placeholder="Écrire une note anonyme…"
className="rounded-md border border-gold/30 bg-white/50 px-3 py-2 text-sm text-text-marble outline-none focus:border-gold"
/>
<p className="text-right text-[0.65rem] text-text-mut">
{text.length}/{MAX_LENGTH}
</p>
{imagePreview ? (
<div className="relative w-32">
<Image
src={imagePreview}
alt=""
width={128}
height={96}
unoptimized
className="h-24 w-32 rounded-md object-cover"
/>
<button
type="button"
onClick={clearImage}
className="absolute -top-2 -right-2 rounded-full bg-ink-2 p-1 text-marble"
aria-label="Retirer l'image"
>
<IconClose className="h-3 w-3" />
</button>
</div>
) : (
<label className="inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-md border border-gold/30 px-3 py-1.5 text-xs font-medium text-text-marble hover:bg-gold/10">
<IconCamera className="h-4 w-4" />
Ajouter une image (optionnel)
<input type="file" accept="image/*" onChange={handleImageChange} className="hidden" />
</label>
)}
{imageError && <p className="text-xs text-oxblood">{imageError}</p>}
{error && (
<p role="alert" className="text-sm text-oxblood">
{error}
</p>
{imagePreview ? (
<div className="relative w-32">
<Image
src={imagePreview}
alt=""
width={128}
height={96}
unoptimized
className="h-24 w-32 rounded-md object-cover"
/>
<button
type="button"
onClick={clearImage}
className="absolute -top-2 -right-2 rounded-full bg-ink-2 p-1 text-marble"
aria-label="Retirer l'image"
>
<IconClose className="h-3 w-3" />
</button>
</div>
) : (
<label className="inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-md border border-gold/30 px-3 py-1.5 text-xs font-medium text-text-marble hover:bg-gold/10">
<IconCamera className="h-4 w-4" />
Ajouter une image (optionnel)
<input type="file" accept="image/*" onChange={handleImageChange} className="hidden" />
</label>
)}
{imageError && <p className="text-xs text-oxblood">{imageError}</p>}
{error && (
<p role="alert" className="text-sm text-oxblood">
{error}
</p>
)}
<button
type="submit"
disabled={posting}
className="rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
>
{posting ? "Publication…" : "Publier anonymement"}
</button>
</form>
)}
</div>
)}
)}
<button
type="submit"
disabled={posting}
className="rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
>
{posting ? "Publication…" : "Publier anonymement"}
</button>
</form>
)}
</div>
{isJudge && (
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
+9 -10
View File
@@ -50,15 +50,14 @@ export default async function MurPage() {
supabase.from("wall_note_reactions").select("note_id, emoji").eq("user_id", user.id),
]);
let myNoteToday: WallNoteRow | null = null;
if (!isJudge) {
const { data: myNotes } = await supabase
.from("wall_notes")
.select("id, text, image_url, created_at")
.eq("user_id", user.id)
.order("created_at", { ascending: false });
myNoteToday = (myNotes ?? []).find((n) => isToday(n.created_at)) ?? null;
}
// Les Archontes peuvent désormais publier aussi (comme les Citoyens) :
// besoin de savoir s'ils ont déjà posté aujourd'hui, peu importe le rôle.
const { data: myNotes } = await supabase
.from("wall_notes")
.select("id, text, image_url, created_at")
.eq("user_id", user.id)
.order("created_at", { ascending: false });
const myNoteToday: WallNoteRow | null = (myNotes ?? []).find((n) => isToday(n.created_at)) ?? null;
let history: WallHistoryRow[] = [];
if (isJudge) {
@@ -75,7 +74,7 @@ export default async function MurPage() {
<div className="mb-6 text-center">
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">Le Mur de la Honte</h1>
<p className="font-serif text-sm text-marble/60 italic">
Une note anonyme par jour, dix places sur le mur la journée passée, tout s&apos;efface.
Une note anonyme par jour, douze places sur le mur la journée passée, tout s&apos;efface.
</p>
</div>
<MurView
+19 -16
View File
@@ -1392,10 +1392,13 @@ grant execute on function public.wall_notes_today() to authenticated;
-- (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 à 11) — acceptée sciemment, comme d'autres arbitrages similaires
-- déjà faits dans le projet pour un groupe de ~12 amis. 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.
-- 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)
@@ -1405,7 +1408,6 @@ security definer
set search_path = public
as $$
declare
v_caller_role text;
v_today date := (now() at time zone 'Europe/Paris')::date;
v_already_posted boolean;
v_count_today int;
@@ -1416,11 +1418,10 @@ begin
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 post on the wall';
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;
@@ -1447,7 +1448,7 @@ begin
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 >= 10 then
if v_count_today >= 12 then
raise exception 'wall full today';
end if;
@@ -1481,14 +1482,16 @@ 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;
create policy "Citizens can upload wall images"
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'
and exists (select 1 from public.profiles p where p.id = auth.uid() and p.role <> 'judge')
);
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