5000 restait trop haut pour être un vrai plafond réaliste : Icare abaissé à 500 (calculé sur le rythme le plus rapide théoriquement atteignable dans le jeu), Corne à 2000 (estimation plus prudente, économie de score plus dure à borner). Ajoute icarus_runs/start_icarus_run() : le serveur enregistre l'instant réel de début de partie et rejette un score incohérent avec le temps écoulé, sans jamais faire confiance à une durée envoyée par le client. Corrige aussi le nombre de jetons de /urne, peu lisible en gold-bright sur fond marbre (passe à text-sea, même convention qu'Icare/Corne). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
43 KiB
@AGENTS.md
Le Tribunal
Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des olympiades pendant une semaine de vacances : comptes, rôles, leaderboard en direct, attribution de points par des juges, journal public des événements. Habillée sur le thème d'un tribunal de la Grèce antique (voir section 6).
1. État actuel
V1 à V11 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.
- V4 : direction artistique Grèce antique (marbre/or/mer de nuit, polices Cinzel/Cormorant Garamond/Manrope, vocabulaire Archontes/Citoyens/Agora/Crieur), header global avec chip utilisateur.
- V5 : navigation entièrement repliée dans le menu déroulant du chip (header épuré : logo + chip seulement), page La Roulette (tirage au sort animé — déplacée dans
/chardepuis, voir V7), page Le Calendrier des Dieux (agenda de la semaine, panthéon, date du Tribunal), boutons de validation des points unifiés (PointsConfirmControls) entre podium et classement. - V6 : premier mini-jeu compétitif, Le Vol d'Icare (
/icare) — Flappy Bird grec (Icare vole entre des colonnes de temple), record personnel all-time (pas de piste par jour), touche l'écran pour battre des ailes, échec net au premier contact (score remis à zéro, comme l'original). Les scores sont figés dès que la date du Tribunal est atteinte, puis les gloires du top 3 sont attribuées automatiquement (tâche planifiéepg_cron, sans intervention d'un Archonte) — seul mécanisme de points de tout le projet qui ne passe pas par une décision de juge. Un boost (« éclair de Zeus »), rare et exceptionnel (intervalle large de 15 à 25 colonnes, pas de plafond par partie — une partie moyenne, ~50 de score, n'en voit naturellement que 1 à 2), apparaît au centre du trou de la colonne qui le porte (donc toujours atteignable) et active le bouclier pour une distance aléatoire (3 à 8 colonnes, pas un budget de casses) : chaque colonne rencontrée pendant cette fenêtre compte dans la distance, qu'elle soit touchée (elle casse alors au lieu de tuer, comptée comme passée) ou simplement franchie sans contact (elle n'est pas cassée pour autant). Pendant que le bouclier est actif, Icare est immobile (gravité coupée, les taps/la barre d'espace n'ont plus d'effet — impossible d'agir sur la trajectoire) et le défilement est accéléré (×1,7) : il fonce tout droit à travers les colonnes jusqu'à épuisement de la distance, où le contrôle normal reprend. Cueillette animée d'un bond en avant d'Icare (traînée de vitesse), halo pulsant sur l'éclair, éclat de particules sur chaque colonne cassée et fin liseré doré autour d'Icare tant que le bouclier est actif — entièrement dessiné sur canvas (src/app/icare/icarus-game.tsx), sans state React par frame. - V7 : outils numériques pour le vrai jeu du Tribunal (soirée physique/sociale, pas un mini-jeu digital), page Le Jeu de l'Agora (
/char) — pensée format paysage 16:9 (projetée sur grand écran) plutôt que mobile-first comme le reste de l'app. Le Char : 2 colonnes fixes de 3 emplacements affichées côte à côte avec un « VS » et une illustration de char vu du dessus entre les deux, affectation d'un Citoyen à un emplacement vide en un clic (liste des Citoyens libres directement cliquables, pas de menu déroulant), bouton « Retirer », boutons « Colonne A/B remporte le duel » (vide la colonne gagnante après une brève animation — les vainqueurs sortent, les perdants restent), compteur du nombre de fois que chacun est monté dans le char, avec un petit classement récapitulatif en bas de page (tout le monde, y compris ceux qui n'y sont jamais monté). La banque de questions (ajout/édition/réordonnancement/bascule afficher-masquer) vit sur une page séparée réservée aux Archontes,/char/questions— pensée pour être pilotée depuis un téléphone pendant que/charest projetée depuis un ordinateur ;/charn'affiche que la question actuellement révélée, en lecture seule. Le Gardien du Silence : rôle tiré au sort parmi les Citoyens (jamais les Archontes), affiché de façon discrète (barre compacte, pas une grande carte), minuteur avec reroll automatique à expiration (RPC auto-protégée, filetpg_cron) et bouton de reroll manuel pour les Archontes. L'app n'arbitre jamais le jeu lui-même, elle affiche/synchronise un état piloté à la main. La Roulette (tirage au sort animé parmi les membres, ou les Citoyens uniquement), initialement livrée en V5 sur sa propre page/roulette, a été fusionnée dans/charcomme troisième section — la route et le lien de menu dédiés ont été retirés, tout l'outillage de la soirée du Tribunal vit maintenant au même endroit. - V8 : les Citoyens peuvent désormais proposer une question pour Le Char — une par personne (upsert), modifiable autant de fois que voulu depuis une nouvelle section sur
/profile, jusqu'à ce que la date du Tribunal (settings.tribunal_date) soit atteinte (même mécanisme de gel que les scores d'Icare, no-op silencieux plutôt qu'une erreur). Les Archontes modèrent ces suggestions depuis une nouvelle section « Suggestions des Citoyens » sur/char/questions: chaque proposition affiche l'auteur, avec un bouton « Ajouter à la banque » (la copie danschariot_questions, à la fin de l'ordre existant) et un bouton de rejet (suppression, confirmation à deux temps comme le reste de la page). Par ailleurs,/adminaffiche maintenant l'adresse email de chaque membre (RPCadmin_list_members(), seul moyen d'exposerauth.users.emailaux juges sans passer par laservice_role keycôté client). - V9 : Le Mur de la Honte (
/mur) — chaque Citoyen peut publier une note anonyme par jour (dix places maximum sur le mur, remis à zéro chaque jour — pas de suppression, juste un filtrage par date). L'anonymat n'est qu'à moitié réel : les Archontes voient une section « Historique complet » (filtrable par jour) avec l'auteur de chaque note jamais publiée, cohérent avec le thème du site (les Archontes ont un ascendant sur les Citoyens). Pas de gel lié à la date du Tribunal : contrairement au Char, c'est une mécanique de toute la semaine, pas de la seule soirée de clôture. - V10 : L'Urne de l'Agora (
/urne) — vote quotidien inspiré du vote à l'urne de l'Athènes antique : chaque Citoyen dépose un jeton par jour sur une personne de son choix (jamais lui-même), confirmation à deux temps avant l'envoi (vote définitif pour la journée, aucune RPC de modification). 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 : contraste volontaire avec le Mur, ici les Archontes n'ont aucune vision privilégiée sur qui a voté pour qui, seul le total par personne est public. Les Archontes ne votent pas et ne peuvent pas recevoir de jetons. - V11 : deuxième mini-jeu compétitif, La Corne d'Abondance (
/corne) — jeu de fusion façon « Watermelon Game » : des avatars de Citoyens tombent dans un bac (physique gérée parmatter-js, seule dépendance de ce type dans le projet — l'empilement stable de cercles est notoirement difficile à obtenir sans bugs de superposition/tremblement en physique artisanale), deux avatars de même taille qui se touchent fusionnent en un plus gros, jusqu'au débordement soutenu (5 secondes de délai de grâce, pour ne pas sanctionner un simple rebond) qui met fin à la partie — les pièces qui dépassent la ligne de danger clignotent (.animate-danger-blink) pour signaler que le compte à rebours est en cours. Un palier de taille par Citoyen (pas les Archontes) plutôt qu'un nombre de paliers arbitraire (buildTierRadii,src/lib/melon/constants.ts) :citizens[tier](indexation directe, sans tirage) donne l'association palier → Citoyen, fixe pour tout le monde (pas juste pour une partie) puisquecorne/page.tsxtrie déjà les Citoyens par pseudo, un ordre déterministe.TIER_OVERRIDES(src/lib/melon/constants.ts, id de profil → index de palier) permet de fixer certains Citoyens à une taille précise à la main ;applyTierOverrides()les place d'abord, puis remplit le reste dans l'ordre alphabétique. 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) — n'importe qui (Archontes compris) peut jouer, seul le pool d'avatars affichés est limité aux Citoyens. Rendu en DOM (pas canvas) pour réutiliser tel quel le composantAvatarexistant (src/components/avatar.tsx, étendu pour accepter une taille en pixels arbitraire en plus des tailles nommées) — la position et la rotation de chaque pièce sont appliquées en impératif (style.transform,translate(...) rotate(body.angle)) à 60 fps depuis la boucle physique matter-js, jamais viasetState. Le halo de l'animation d'apparition doit porterrounded-fulllui-même, sinon son ombre portée dessine un carré visible autour du rond ; l'apparition d'une pièce utilise un simple fondu (.animate-fade-in) plutôt que l'animation avec mise à l'échelle utilisée ailleurs dans l'app (.animate-reveal), pour que sa taille exacte soit lisible dès la première frame. Trait de visée vertical + preview flottante au-dessus du bac pour montrer où la prochaine pièce va tomber, plus un second aperçu, plus petit et fixe (pas de position à viser), pour voir venir deux coups à l'avance. La bordure du bac vit sur un conteneur externe non dimensionné (pas sur le conteneur aux coordonnées physiques 300×420) : la mettre directement dessus réduisait sa zone intérieure réelle (box-sizing) sans que matter-js le sache, laissant les pièces s'empiler visuellement en dehors du cadre à droite/en bas. Sous le bac, une échelle de repère (taille d'affichage réduite mais proportionnelle au vrai rayon du palier, une seule ligne défilante, chaque rond centré sur son propre milieu plutôt qu'aligné en bas — un alignement bas avec des tailles très variables décalait progressivement la flèche par rapport aux plus gros ronds) montre l'ordre complet de fusion du plus petit au plus grand Citoyen.
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, mode grand écran.
2. Stack technique
- Framework : Next.js 16 (App Router) + TypeScript
- Style : Tailwind CSS v4 (design tokens en variables CSS, voir
globals.css) - Auth + Base + Stockage + Temps réel : Supabase (Auth, Postgres, Storage, Realtime)
- Recadrage photo :
react-easy-crop+ canvas natif (resize/compression client, jamais d'upload de l'image brute) - Physique 2D :
matter-js(uniquement le moteur de simulation —Engine/Composite/Bodies/Body/Events, jamais son moduleRender), pour La Corne d'Abondance - Déploiement : self-hosted, Docker Compose + Traefik (reverse proxy), Supabase (managé) — CI via Gitea Actions (
.gitea/workflows/deploy.yml) qui build le standalone Next.js et le POST à un webhook (docker/webhook/receive.py) déclenchant un hot-swap sans downtime (docker/entrypoint.sh) ; domaine injecté parAPP_DOMAIN(.env.docker, gitignored — seul.env.docker.exampleest versionné) dans le label TraefikHost(). Domaine actuel :agora.alxczl.fr.
Next.js 16 a renommé middleware.ts en proxy.ts (fichier src/proxy.ts, fonction proxy). src/lib/supabase/middleware.ts est un nom de fichier utilitaire interne, sans rapport avec cette convention.
3. Modèle de données (supabase/schema.sql, source de vérité)
Le script est idempotent : toujours le ré-exécuter en entier après une modification, jamais de migration incrémentale séparée.
public.profiles (liée à auth.users, créée automatiquement par un trigger sur auth.users)
| Colonne | Notes |
|---|---|
id |
= auth.users.id |
pseudo |
affiché partout, unique (insensible à la casse) |
avatar_url |
URL publique Storage ({user_id}.webp) |
points |
jamais modifiable directement — uniquement via RPC award_points |
role |
public (défaut) ou judge |
pseudo_locked |
passe à true automatiquement au 1er changement de pseudo |
previous_rank |
repère de classement, mis à jour par reset_rank_reference() |
public.points_log (journal, lecture seule côté client)
id, target_id, judge_id, delta, reason, created_at — écrit uniquement par la RPC award_points.
public.days / public.events / public.settings (Calendrier des Dieux)
days:id, date (unique), god_name, god_domain, description, created_at.events:id, day_id (fk days), title, description, type ('activite'|'defi'|'epreuve'|'tribunal'), start_time, location, created_by (fk profiles, forcé par trigger), created_at.settings: ligne unique (id boolean primary key default true, contraintecheck (id)) —tribunal_date.- RLS :
SELECTouvert à tout authentifié ;INSERT/UPDATE/DELETEréservés au rôlejudge, vérifié par policy (exists (select 1 from profiles where id = auth.uid() and role = 'judge')) — pas de triggerBEFORE UPDATEici car il n'y a pas de colonne à protéger dans une ligne par ailleurs modifiable par tous (contrairement àprofiles) : toute la table est verrouillée en écriture aux juges.
public.icarus_scores (Le Vol d'Icare)
user_id, best_score, updated_at— clé primaireuser_id: un seul record personnel all-time par joueur (pas de notion de jour). Verrouillée commepoints_log: lecture ouverte aux authentifiés, écriture uniquement via la RPCsubmit_icarus_score.- Contrainte
check (best_score between 0 and 500)surbest_score— la vraie garantie contre un score falsifié envoyé directement à la RPC (voir §3bis) ;MAX_SCOREdanssrc/lib/icarus/constants.tsn'est qu'une valeur de référence côté client, à garder alignée. public.icarus_runs(id, user_id, started_at) : deuxième couche anti-triche, orthogonale à la borne fixe — voir §3bis etsubmit_icarus_score.settings.icarus_points_awarded: marqueur d'idempotence (un seul événement — l'attribution au début du Tribunal — pas une clôture quotidienne comme l'ancienne Course du Char).- Génération des colonnes/obstacles entièrement côté client, sans graine partagée (pas de piste identique pour tout le monde à reproduire ici — chaque partie est procédurale).
public.melon_scores (La Corne d'Abondance)
user_id, best_score, updated_at— même gabarit exact queicarus_scores(clé primaireuser_id, verrouillée en écriture, uniquement viasubmit_melon_score), avec sa propre contraintecheck (best_score between 0 and 2000)— pas la même valeur qu'Icare, l'économie de score est différente (voir §3bis) — et pas de deuxième couche par temps réel écoulé commeicarus_runspour l'instant (formule de score plus dure à borner analytiquement pour Corne, calibrage basé sur une estimation plutôt qu'un calcul précis).settings.melon_points_awarded: marqueur d'idempotence, même principe queicarus_points_awarded.submit_melon_score(p_score)/award_melon_points_if_due(): copies conformes desubmit_icarus_score/award_icarus_points_if_due(gel àtribunal_date, top 3 ex-aequo inclus,judge_id = nulldanspoints_log, tâchepg_cronaward-melon-points, et mêmeexecuterévoqué sur la fonction cron-only — voir §3bis) — sauf la borne de score elle-même (2000, pas 500) et l'absence de vérification par temps écoulé.- Le pool d'avatars des pièces (Citoyens uniquement, filtré côté serveur dans
corne/page.tsx— un palier de taille par Citoyen) et les paliers/rayons du jeu vivent côté client (src/lib/melon/constants.ts), pas en base. N'importe qui peut jouer et apparaître dansmelon_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 deposition(pas de contrainte unique dessus, deuxupdatenon transactionnels ; toujours trierorder 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 (commedays/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 RPCchariot_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 desupdatepour affecter/vider un emplacement. RLS : lecture ouverte à tout le monde (contrairement àchariot_questionsdepuis 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 (commepoints_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_*(paschar_*) volontaire : sans rapport avec l'ancienne Course du Char digitale supprimée, qui utilisait déjà ce préfixe.
public.chariot_submissions (suggestions de questions par les Citoyens, V8)
user_id (pk), text, updated_at— une ligne par Citoyen (upsert, commeicarus_scores). Verrouillée commeicarus_scores/points_log:insert/updaterévoqués pourauthenticated, seule la RPCsubmit_chariot_question()(SECURITY DEFINER) peut écrire.- RLS
select: le propriétaire voit sa propre ligne, les juges voient tout (modération) — pas de visibilité entre Citoyens. - RLS
delete: réservée aux juges (rejet direct depuis/char/questions, pas besoin de RPC pour une suppression simple). - Gel réutilisant
settings.tribunal_date(même déclencheur quesubmit_icarus_score) plutôt qu'une nouvelle colonne dédiée. chariot_submission_history: journal append-only (commepoints_log) des versions successives d'une proposition —chariot_submissionsn'upsertant que la valeur courante, sans ce journal les Archontes ne verraient jamais les versions précédentes d'une question modifiée plusieurs fois. Même RLS (propriétaire + juges), écrit uniquement parsubmit_chariot_question(). Affiché en repli sur/char/questions(« Historique (N) » par proposition, versions précédentes uniquement — la version actuelle est déjà affichée au-dessus).
Colonnes sensibles : RLS + trigger, jamais confiance au client
La RLS est au niveau ligne : elle ne peut pas exprimer « cette colonne seulement si tel rôle ». Le verrouillage fin des colonnes (role, points, pseudo_locked, pseudo figé, previous_rank) passe par le trigger enforce_profile_update() :
points: bloqué pour tout le monde, y compris les juges, en écriture directe. Seule la RPCaward_points()(SECURITY DEFINER) peut la modifier, via un flag de session (app.bypass_points_lock) qui autorise explicitement CETTE écriture précise.role,previous_rank: modifiables uniquement par unjudge.pseudo: modifiable une fois par le propriétaire (auto-lock ensuite), ou à tout moment par unjudge.auth.uid() is null(SQL Editor, migrations) bypass le trigger : ces accès sont déjà fiables par construction.
Même pattern sur public.settings depuis V7, via enforce_settings_update() : gardien_holder_id/gardien_expires_at sont bloqués en écriture directe pour tout le monde, y compris les juges — seule la RPC reroll_gardien() peut les modifier (flag app.bypass_gardien_lock), pour garantir que le Gardien est réellement tiré au sort et pas choisi à la main. tribunal_date, icarus_points_awarded et chariot_revealed_question_id restent, eux, directement modifiables par les juges (rien à protéger dans ces colonnes-là).
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 d'abord ramené à 5000 puis, après un deuxième passage d'audit jugeant encore ça trop haut pour être un vrai plafond réaliste, resserré à 500 pour Icare / 2000 pour Corne (valeurs différentes : l'économie de score de Corne, basée sur des fusions enchaînées plutôt qu'une vitesse de défilement fixe, est plus dure à borner précisément — voir juste en dessous). Appliqué à deux niveaux dans les deux cas : la contrainte check sur best_score (la vraie garantie, alter table ... add constraint — un check inline sur create table if not exists ne s'appliquerait pas rétroactivement à une table déjà créée) et, en défense en profondeur, la validation dans la RPC elle-même.
Pour Icare spécifiquement, une deuxième couche orthogonale à la borne fixe : public.icarus_runs (id, user_id, started_at) enregistre le vrai instant de début de partie côté serveur (horloge Postgres, started_at default now()) au premier battement d'aile (start_icarus_run(), appelée depuis IcarusGame's nouveau prop onStart) ; submit_icarus_score(p_score, p_run_id) calcule ensuite now() - started_at et rejette un score qui excéderait le rythme le plus rapide théoriquement atteignable dans le jeu (vitesse de défilement pleinement montée en difficulté et bouclier du boost actif en continu — un cas déjà irréaliste en soi — avec une marge de sécurité supplémentaire de 10 % contre le jitter de la boucle de jeu). Toujours le temps réel écoulé mesuré par le serveur, jamais une durée envoyée par le client (qui serait aussi falsifiable que le score lui-même). icarus_runs n'a aucun grant/policy pour authenticated : seules start_icarus_run()/submit_icarus_score() (SECURITY DEFINER) y touchent. Pas encore appliqué à Corne (melon_scores) dans cette passe — la formule de score par fusions enchaînées est plus dure à borner analytiquement sans données de vraies parties ; son plafond fixe (2000) reste donc, pour l'instant, une estimation plus prudente plutôt qu'un calcul aussi précis que pour Icare.
Bucket Storage avatars : file_size_limit/allowed_mime_types ajoutés (on conflict (id) do update, pas do nothing, pour que ré-exécuter schema.sql applique bien la limite à un bucket déjà existant) — sans ça, un upload direct à l'API de Storage (hors de l'app, où c'est toujours cropImageToBlob qui compresse) pouvait déposer un fichier arbitrairement gros ou non-image comme avatar. Même limite que wall-images (3 Mo, image/webp+jpeg+png).
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 lireprofiles).award_points(p_target_id, p_delta, p_reason)— authenticated, vérifierole = 'judge'côté serveur, jamais côté client.reset_rank_reference()— authenticated, vérifierole = 'judge', fige le classement courant dansprevious_rank.start_icarus_run()— authenticated, enregistre l'instant réel de début de partie (icarus_runs.started_at) pour la vérification par temps écoulé desubmit_icarus_score— voir §3bis.submit_icarus_score(p_score, p_run_id)— authenticated, vérifie côté serveur si la date du Tribunal est déjà passée (scores figés : no-op silencieux plutôt qu'une erreur), n'écrase le record que s'il est strictement battu, rejette un score hors bornes (défense en profondeur — la contraintechecksuricarus_scores.best_scoreest la garantie réelle) et un score incohérent avec le temps réellement écoulé depuisp_run_id(voir §3bis).award_icarus_points_if_due()—executeexplicitement révoqué depublic/anon/authenticated(voir §3bis), appelée uniquement par la tâche planifiéepg_cron(ou depuis le SQL Editor) : dès que la date du Tribunal est atteinte, attribue les gloires du top 3 automatiquement (judge_id = nulldanspoints_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 repoussegardien_expires_atde 5 minutes.p_force=true(vérifierole = 'judge') reroll immédiatement ;p_force=false(appelée par le minuteur côté client à expiration, et parpg_cronen filet) ne fait rien tant quegardien_expires_atn'est pas atteint — un seulUPDATE ... WHEREatomique (pas deSELECTpuisUPDATE), 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 quesubmit_icarus_scoreviasettings.tribunal_date(no-op silencieux une fois l'Agora commencée).insert ... on conflict (user_id) do update: une ligne par personne danschariot_submissions, plus une ligne append-only danschariot_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 lirechariot_questions(RLS juges uniquement, voir §3bis) — renvoie zéro ligne tant qu'aucune question n'est révélée, et calculequestion_number(position dans l'ordre complet) côté serveur pour afficher « Question N » sur/charsans exposer les autres lignes.admin_list_members()— authenticated, vérifierole = 'judge', seule façon d'exposerauth.users.email(jamais stocké dansprofiles) sur/adminsans passer par laservice_role keycôté client.wall_notes_today()— authenticated,language sql, renvoie les notes du jour (heure de Paris), y comprisimage_url, sansuser_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 (sinonraise exceptiondans les deux cas — pas de no-op silencieux ici, l'UI doit remonter l'erreur).p_image_urlest facultatif et doit venir du bucketwall-images(vérifié par unlike).wall_note_reaction_counts()— authenticated,language sql, renvoie(note_id, emoji, count)agrégé sur les notes du jour — même principe queurn_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 undeletepuis, si rien n'a été supprimé, uninsert).p_emojilimité à une liste fermée (contraintechecksur 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).urn_vote_reasons(p_target_id)— authenticated,language sql, renvoie les justifications (reason,created_at) laissées pour une personne, sansvoter_id— même principe quewall_notes_today: le contenu est public depuis le classement, l'auteur ne l'est jamais.cast_urn_vote(p_target_id, p_reason default null)— authenticated, rejette les juges (ni votants ni cibles), rejette le vote pour soi-même, vérifie que l'appelant n'a pas déjà voté aujourd'hui (heure de Paris) — vote définitif, aucune RPC de modification/suppression.p_reasonest une justification facultative (≤ 200 caractères,nullif(trim(...), '')pour normaliser une chaîne vide ennull).
public.wall_notes (Le Mur de la Honte, V9)
id, user_id, text, image_url, created_at— une ligne par note, jamais éditée/supprimée (commepoints_log) ; le « reset quotidien » n'est qu'un filtrage par date danswall_notes_today(), pas une suppression.image_urlest facultative, pointe vers le bucketwall-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 (embedprofiles(pseudo, avatar_url), section « Historique complet » sur/mur) — pas de visibilité entre Citoyens, c'est tout l'intérêt dewall_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_limit3 Mo,allowed_mime_typesimage/webp+jpeg+png — même limite que le bucketavatars, voir §3bis) : contrairement àavatars, le chemin d'objet est un UUID sans leuser_id— sinon l'auteur fuiterait via l'URL publique de l'image, ce quewall_notes_today()prend justement soin de ne jamais exposer. Compression côté client avant upload (resizeImageToBlob,src/lib/image.ts, contrairement àcropImageToBlobqui 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 — RLSselectlimitée àuser_id = auth.uid()(pour savoir lesquelles sont déjà activées), le total public passe uniquement parwall_note_reaction_counts().
public.urn_votes (L'Urne de l'Agora, V10)
id, voter_id, target_id, reason, created_at— journal append-only (commepoints_log/chariot_entries) d'un vote par ligne, jamais un upsert : les jetons s'accumulent d'un jour sur l'autre, contrairement àwall_notesqui ne montre que le jour courant.reasonest une justification facultative laissée par le votant.- RLS
select: pas d'exception juge (contrairement àchariot_submissions/wall_notes) — le votant ne voit que sa propre ligne (pour savoir « j'ai déjà voté aujourd'hui »), personne d'autre, Archontes compris, ne peut voir qui a voté pour qui ; seulurn_vote_counts()expose un total agrégé, eturn_vote_reasons()le contenu des justifications sans leur auteur, identiques pour tout le monde.
4. Authentification
Sur invitation uniquement, plus d'auto-inscription publique : "Allow new users to sign up" est désactivé côté Supabase. Un Archonte invite via Authentication → Users → Invite user ; le lien reçu établit une session directement sur /signup, qui sert alors à finaliser le compte (mot de passe, pseudo, photo) — sans session valide, la page affiche un message « invitation requise ». Le profil est créé dès l'invitation par le trigger handle_new_user (pseudo temporaire "Nouveau membre " + 8 premiers caractères de l'uuid jusqu'à finalisation — le suffixe garantit l'unicité quand plusieurs personnes sont invitées avant d'avoir fini leur inscription ; /signup détecte l'état « pas encore finalisé » par préfixe, jamais par égalité stricte). Nécessite un SMTP custom côté Supabase (le service intégré est trop limité en volume, voir README). Le pseudo est un simple nom d'affichage stocké dans profiles, jamais l'email n'est montré aux autres Citoyens — seuls les Archontes le voient, sur /admin (via admin_list_members(), voir §3).
5. Pages & navigation
| Route | Contenu |
|---|---|
/ |
Redirige vers /leaderboard si connecté, sinon /login |
/login |
Email + mot de passe |
/signup |
Email + mot de passe + pseudo + photo (recadrée) ; gère l'attente de confirmation email |
/leaderboard |
« L'Agora » — podium top 3 + liste, Realtime, contrôles de points pour les juges, date/décompte du Tribunal en en-tête (src/lib/tribunal-date.ts, partagé avec /calendrier) |
/icare |
« Le Vol d'Icare » — Flappy Bird grec, record personnel all-time, scores figés au Tribunal, points attribués automatiquement |
/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 |
/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 |
/profile |
Pseudo (verrouillable), photo, déconnexion, et pour les Citoyens : proposition d'une question pour Le Char (modifiable jusqu'au Tribunal) |
/admin |
« Admin » — juges uniquement, vérifié côté serveur (Server Component), habillée d'une police monospace (rupture volontaire avec le thème grec) — gestion des membres, reset du repère de classement |
/leaderboard, /profile, /admin, /journal, /calendrier, /icare, /corne, /char (et donc /char/questions, couverte par le même préfixe), /mur, /urne sont protégées par src/proxy.ts (redirection /login si non connecté). L'accès juge-only de /admin et /char/questions n'est pas géré par le proxy (il n'a pas facilement le rôle) — la page elle-même vérifie et redirige ; même principe pour les boutons d'édition du Calendrier (RLS + vérification serveur, jamais un simple masquage front).
Le header global (components/header.tsx) est rendu dans layout.tsx pour tout utilisateur connecté : logo (couronne de laurier + « Le Tribunal », jamais masqué même à 320px) à gauche, chip utilisateur (avatar, pseudo, points, jamais masqués) à droite. Aucun lien de navigation dans le header lui-même — le chip ouvre un menu déroulant qui contient toutes les pages (avec icônes), fermeture au clic extérieur/Échap, entrée courante surlignée.
6. Look & feel — Grèce antique
Thème fixe et codé en dur (pas de système de re-thématisation par édition — voir roadmap). Un tribunal égéen de nuit : marbre, or, mer sombre, sceaux de cire.
Tokens couleur (globals.css, exposés comme utilities Tailwind via @theme inline) :
--ink #0A1B33/--ink-2 #0F2748: fond de l'app (dégradés radiaux mer de nuit), header, boutons primaires.--marble #F4ECD8/--marble-2 #E9DEC2: surfaces claires (.marble-surface, classe utilitaire avec léger dégradé/grain).--gold #C9A227/--gold-bright #E7C560: accents, bordures, 1ʳᵉ place. Jamais pour du texte courant surmarble(contraste insuffisant) — utiliser--text-marbleà la place.--oxblood #A5342A: blâmes (points négatifs), sceaux, erreurs.--olive #5E6B3B: honneurs (points positifs), succès.--sea #2E6E7E: accent secondaire.--silver #C7CDD6/--bronze #B08D57: accents 2ᵉ/3ᵉ place du podium.--text-marble #2A2116/--text-mut #7A6A4C: texte sur surfaces claires.
components/points-confirm-controls.tsx : boutons ✓/✕ compacts (32px, icône 16px) de validation/annulation des points en attente — utilisés à l'identique par le podium et le reste du classement (JudgePointControls). Ne pas dupliquer cette UI ailleurs, toujours passer par ce composant.
Polices (next/font/google, variables CSS, exposées comme font-heading/font-serif/font-sans) : Cinzel (titres, noms, chiffres de score — majuscules, tracking-[0.1em]), Cormorant Garamond (citations/sous-titres, italique), Manrope (interface, corps de texte).
Ornements en SVG/CSS inline (jamais d'image bitmap) :
components/laurel-wreath.tsx— couronne de laurier calculée par trigonométrie (deux arcs symétriques de feuilles), utilisée en logo, sur le rang 1 du podium, etc. Le favicon (src/app/icon.svg) est une version statique des mêmes coordonnées..meander-divider(classe globale) — frise à méandre grecque enbackground-imageSVG data-URI, séparateur discret.components/diamond-divider.tsx— séparateur ◆ avec filets dégradés..marble-surface(classe globale) — texture marbre via dégradés (pas d'image de bruit).
Convention de carte : marble-surface rounded-2xl border border-gold/40 shadow-xl (formulaires, podium) ou rounded-lg border-gold/25 pour les lignes de liste. Bouton primaire : bg-ink-2 text-gold-bright font-heading uppercase hover:bg-ink. Bouton secondaire : border border-gold/30 text-text-marble hover:bg-gold/10.
Vocabulaire diégétique (toujours accompagné du terme fonctionnel dans le code/les commentaires, pour que ça reste maintenable) : juges/admins → Archontes, membres → Citoyens, leaderboard → L'Agora, journal des points → Le Crieur (décrets), points positifs/négatifs → Honneurs/Blâmes, points → gloires. Exception volontaire : la page /admin s'appelle simplement « Admin » (police monospace, rupture délibérée avec le vocabulaire diégétique et le thème grec, pour marquer que c'est un outil technique).
7. Conventions
- Toujours passer par les policies RLS + trigger ; ne jamais exposer la
service_role keycôté client. - Toute nouvelle colonne sensible sur
profiles(ou une future table) suit le pattern RLS+trigger de la section 3, pas ungrant/revokepar colonne (qui ne peut pas distinguer les rôles applicatifs). - Aperçus locaux d'image avant upload (crop, fichier choisi) =
blob:/data:URL → toujoursunoptimizedsurnext/imageou une balise<img>classique (next/imagene sait pas résoudre ces schémas côté serveur). - Commandes :
npm run dev,npm run build,npm run lint. - Le schéma SQL (
supabase/schema.sql) est la seule source de vérité pour la base — le tenir à jour à chaque changement de données/sécurité, et le garder idempotent.
8. Roadmap (pas encore codé)
- Éditions : table
editions(année, mood board) ; les points deviendront rattachables à une édition donnée. Le thème visuel (Grèce antique) reste fixe, ce n'est pas un système de re-thématisation par édition. - Mode grand écran : vue leaderboard optimisée pour vidéoprojecteur.