尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證Saison d'aventure : épisodes, candidats, notes privées, partage par lien révocable.
翻譯準備中
取自儲存庫 README
正在載入完整 README。 以下為已儲存的摘錄。
mister-miss-koh — « Mister & Miss Koh » PWA de suivi des saisons d'aventure — en cours comme passées : candidats, épisodes, épreuves, conseils, votes et départs, avec notes personnelles, favoris et partage révocable. État : la saison en cours est publiée, et lue par le site. Les 25 migrations sont appliquées sur le projet Supabase hébergé, qui suit les 18 pages de saison déclarées par Wikipédia. Les quatre suites pgTAP passent contre cette base — isolation (34), publication (43), suivi du compte (21), partage éphémère (26) —, jouées le 07/09/2026.檢視完整 README ↗
PWA de suivi des saisons d'aventure — en cours comme passées : candidats, épisodes, épreuves, conseils, votes et départs, avec notes personnelles, favoris et partage révocable.
État : la saison en cours est publiée, et lue par le site. Les 38 migrations sont appliquées sur le projet Supabase hébergé, qui suit les 18 pages de saison déclarées par Wikipédia. Huit suites pgTAP, 233 assertions (isolation 34, publication 86, suivi du compte 21, partage éphémère 26, planification 40, structure de sécurité 2, vote double 8, détail des voix 16), passent sur une pile jetable à chaque PR qui touche aux migrations ou aux tests (
supabase-tests.yml, vertes le 30/09/2026) ; les mêmes se rejouent à la main contre cette base.Les migrations s'appliquent toutes seules depuis le 11/09/2026 (
.github/workflows/supabase-migrate.yml) : une poussée surmainqui touchesupabase/migrations/**ousupabase/functions/**jouedb push, déploie les fonctions Edge, puis pose les secrets de la planification. Ça n'a pas toujours été le cas, et ça s'est payé :0022et0023sont restées en attente pendant que le site déployé répondait « Could not find the function », CI verte pendant tout ce temps. Les suites*:remoterestent la seule mesure du RÉEL — elles interrogent la base hébergée, pas une pile jetable. La fonction Edge est déployée, un premier import réel a tourné, et son lot de 78 différences a été relu puis publié : le site affiche la vraie saison, avec sa provenance et son anti-spoiler. Le retour arrière a servi pour de vrai, à corriger trois défauts de la publication : voir docs/pipeline-wikipedia.md.
Un outil non officiel, sans lien avec les ayants droit de l'émission. Les données référentielles proviennent de Wikipédia, source collaborative : rien n'y est présenté comme officiel, et chaque valeur affichée porte sa provenance, sa révision et son statut de validation. Le pavé de provenance a sa carte « Source de vérité » dans les Réglages — on l'y consulte, il ne s'impose plus à chaque ouverture de l'accueil, où le sous-titre de la saison dit déjà s'il s'agit d'une démonstration.
L'identité visuelle ne doit rien à l'émission — terre, feu, océan, jungle. Aucun logo, totem, photographie, extrait ou élément graphique n'est reproduit.
Un seul logo, un seul fichier. public/favicon.svg — le K du nom,
creusé dans une pastille de braise posée sur une tuile vert sombre — est la
source unique : l'en-tête le sert tel quel, et npm run icons en tire tous les
PNG du manifeste. Quatre détails qui font tenir l'ensemble :
bg, à
18,32,28, la teinte exacte de la tuile, et non le défaut du socle
(12,18,34) qui posait un cadre bleu-noir autour d'un vert-noir. Une tuile
en dégradé rendrait ce raccord impossible.La pastille occupe 65 % de la tuile, sous les 80 % de la zone de sécurité du masque circulaire : rien n'est rogné à l'installation.
La flamme n'a pas disparu de l'application, seulement de sa signature. Elle reste là où elle veut dire « ceci s'éteint » — le partage d'un jour, sur une fiche et dans la tournée des portraits. L'en-tête et le repli de l'écran d'attente, eux, ne faisaient que signer : le second porte désormais une boussole, qui bat depuis son centre au lieu de vaciller depuis un pied qu'elle n'a pas.
Les portraits des candidats ne font pas exception. L'application n'en distribue aucun et n'en télécharge aucun : chaque candidat porte une vignette d'initiales, et vous pouvez y mettre votre image, qui reste sur votre appareil (IndexedDB, ré-encodée en vignette, métadonnées retirées). Les photos officielles sont des œuvres protégées représentant des personnes identifiables ; les republier ici contredirait la ligne ci-dessus.
Partager une de vos images, c'est la donner — pas la publier. L'image part par la feuille de partage du système ou par un enregistrement, d'appareil à appareil, sans toucher à un serveur. Le QR code, lui, porte le lien de la fiche, jamais l'image — un QR code contient au plus 2,9 ko, et l'écran affiche les deux tailles côte à côte plutôt que de le laisser croire.
Et vous pouvez les reprendre tous d'un coup. « Exporter les portraits », dans les Réglages, rassemble ceux que vous avez déposés dans une archive ZIP — le même geste qu'« Enregistrer l'image » sur une fiche, mais pour tous. L'archive ne quitte pas l'appareil : les octets viennent du dépôt local. Si vous avez accepté la mesure d'audience, seul le geste est compté, sans nom ni nombre de portraits. L'archive est « stored », sans compression : des images déjà encodées ne se compressent pas, et une bibliothèque de plus coûterait du bundle pour rien. Deux candidats homonymes reçoivent des noms distincts, sans quoi l'extraction n'en garderait qu'un — en silence.
Sauf si vous le demandez, une fois, et pour un jour. Le partage éphémère est la seule route de l'application qui dépose une image sur un serveur : elle y meurt à la première ouverture, ou au bout d'un jour — la première des deux échéances. Le bouton le dit avant le clic, la copie s'efface d'elle-même, et vous pouvez l'éteindre plus tôt. Le lien dit qui il montre, si bien que celui qui le reçoit pose la photo sur la bonne fiche d'un clic, sans passer par ses fichiers.
Et la fiche mène à ce qu'on vient y lire. Déposer une photo, la remplacer, la retirer, l'enregistrer, en confier une copie : cinq boutons qui occupaient le haut de la fiche et repoussaient plus bas les saisons précédentes, le binôme et la source. Ils se déplient d'un clic sous « Photo et partage ». La vignette, elle, s'ouvre en grand d'un clic — sur la fiche seulement : dans une liste, elle jouxte déjà un lien vers cette fiche.
Les Réglages rechargent l'application, ils ne décrivent plus le build. La carte « Version installée », qui donnait le commit, l'heure de compilation et les versions des bibliothèques, a été retirée le 17/09/2026. Reste « Recharger l'application », qui remplace la copie gardée sur l'appareil sans toucher aux données, aux notes ni aux réglages.
Un pseudonyme se choisit, il ne s'invente pas. Aucun profil n'est créé
automatiquement : pseudonym est obligatoire, et le fabriquer depuis une
adresse électronique reviendrait à vous attribuer un nom. Tant que vous n'en
choisissez pas un, une note partagée est signée « quelqu'un qui n'a pas choisi
de pseudonyme » — c'est l'état réel, pas une panne. L'identifiant public,
lui, est facultatif : c'est une adresse unique, et sa disponibilité se demande
au serveur, seul à voir les profils des autres.
Un profil est privé par défaut, et « Public » n'est offert qu'avec une
adresse. La page se rejoint par #/profil/<identifiant> : la proposer sans
identifiant promettrait une page injoignable, et retirer son identifiant
referme le profil. Un profil public montre le pseudonyme, l'adresse, quelques
mots — et, si son auteur le décide, ses notes publiques. Celles-là
seulement : une note « qui a le lien » n'y remonte jamais, parce que confier
n'est pas publier. Repasser en « Privé » ferme la page à la requête suivante.
Vos notes, elles, peuvent être données OU publiées — et l'écran ne confond pas les deux. Les envoyer en texte ou les enregistrer en Markdown ne publie rien. Un lien de lecture, lui, les ouvre à qui obtient l'adresse, sans compte : le bouton le dit avant, « Révoquer » est à côté du lien, et un lien révoqué referme la note dans la foulée. Le lien de collection montre les notes que vous avez marquées « partagée » — telles qu'elles sont à l'instant où on l'ouvre : en retirer une la fait disparaître aussitôt, sans révoquer le lien ni en refaire un.
L'accueil montre des chiffres, pas un menu. Trois tuiles (en jeu, épisodes vus, favoris), et une quatrième pour les notes quand elles sont déjà chargées, parce qu'une tuile qui ne porte qu'un nom ne vaut pas mieux que l'onglet du bas. Le tableau de bord a le sien, « Suivi », dans la barre basse. Et ces comptes respectent l'anti-spoiler : « 16 en jeu » s'arrête à la limite que vous avez réglée, ce que l'écran dit sous les tuiles plutôt que de laisser croire à une erreur de calcul. Sur un téléphone, il tient sans défiler : les trois tuiles sur une ligne, l'icône à côté du chiffre, et « Code source · M'offrir un café · Signaler un problème » sur une seule ligne. Mesuré le 30/09/2026 : 863 px de haut avant, 618 après, sur 393 px de large.
Les candidats se rangent comme la saison se joue, à l'épisode où vous en êtes. All Stars se joue en duos dans une tribu unique jusqu'au jour 9, puis en deux tribus, Taboga la jaune et Sebako la rouge. L'onglet Candidats suit ce changement : il regroupe par duo tant qu'une seule tribu existe à votre limite anti-spoiler, puis par tribu dès que deux se montrent. Chaque tribu porte la couleur de la légende Wikipédia et le nom de sa teinte (« tribu jaune »), parce que c'est ainsi qu'on en parle devant l'émission. La tribu d'un candidat se lit à votre limite, jamais dans l'absolu : à qui n'a vu que l'épisode 3, Taboga n'existe pas encore. La source date les séjours en jours et l'anti-spoiler raisonne en épisodes. Le jour du conseil de chaque soirée fait le lien, et s'il manque, l'application attend plutôt que de deviner. On revient, et l'on ressort : un éliminé que l'arène ramène dans une tribu redevient « en jeu », et sa fiche raconte sortie, retour et nouvelle sortie, chaque ligne derrière sa garde anti-spoiler.
L'anti-spoiler se voit, là où il agit. Sur l'écran des épisodes, un repère pleine largeur, « Vous en êtes là · épisode N », sépare ce qu'on a vu de ce qui reste masqué, et l'écran s'ouvre dessus. Cocher le 8 coche aussi du 3 au 7 : la règle agissait en silence, elle part maintenant en vague depuis l'épisode touché, se dit dans un message et s'annule d'un geste. Un épisode masqué garde la même silhouette givrée que tous les autres (Confort, Immunité, Conseil, des barres de largeur fixe) : jamais le vrai texte flouté, qui resterait lisible au lecteur d'écran, et jamais une forme qui trahirait un conseil absent ou une soirée à deux départs. Une pastille dit l'état du réglage et ouvre ses trois choix ; en « Tout voir », elle passe en avertissement. Chaque tour de conseil se détaille — voix par candidat, bulletins, voix barrées, « ≥ » quand la source est partielle —, derrière la garde de sa soirée. L'accueil compte les épisodes à rattraper (des épisodes, jamais des faits masqués, qui diraient qu'une soirée a eu deux départs), et le même nombre peut s'afficher sur l'icône de l'application installée, là où le système le permet. Une première ouverture en trois temps demande où l'on en est, ce qu'il faut masquer et qui l'on suit, sans rien montrer de la saison.
Les notes d'un autre se lisent à VOTRE limite. Une note partagée par lien, ou publique sur un profil, peut parler de tout ce qui était diffusé quand elle a été écrite : elle se masque chez le lecteur qui n'en est pas là, et l'écran lui demande où il en est. Il peut l'afficher quand même, pour cette note seulement. L'auteur, lui, lit toujours les siennes : il sait ce qu'il y a écrit.
L'application démarre sans configuration. Sans backend, elle tourne sur un
référentiel de démonstration explicitement marqué « Donnée fictive de
démonstration » : aucun de ses prénoms n'est réel. C'est le comportement d'une
copie lancée sans .env. La page publique, elle, lit la saison publiée sur
Supabase, et hors ligne la dernière version enregistrée sur l'appareil,
annoncée comme telle.
Deux services tiers, et un seul attend votre accord. Dès l'ouverture, et sans consentement, l'application signale sa session à Sentry (région européenne), qui reçoit ensuite un rapport quand une erreur survient : message, pile d'appels, navigateur, version de l'application. La mesure d'audience, PostHog sur son nuage européen, ne se charge qu'après « Accepter » dans le bandeau de consentement : des vues d'écran et quelques gestes comptés (installation, mise à jour, note écrite, lien créé, partage, export), jamais le texte d'une note, une image ni le nom d'un candidat, et aucun cookie.
npm install
npm run dev
npm run dev sert sur 5173, le port par défaut de Vite : vite.config.ts n'en
fixe aucun. Pour le port 5236, celui de la liste d'URL autorisées (voir
Comptes et notes), lancer
npm run dev -- --port 5236 --strictPort, comme le fait
.claude/launch.json. L'installation lit
@mister-guiiug/dev-pwa-config sur GitHub Packages : exporter
NODE_AUTH_TOKEN (un jeton avec read:packages) avant npm install.
| Commande | Ce qu'elle vérifie | État au 29/09/2026 |
|---|---|---|
npm run lint | ESLint (socle : react-hooks, jsx-a11y, react-refresh) | 0 erreur, 1 avertissement |
npm run type-check | TypeScript strict, tsc -b | propre |
npm test | Vitest : cœur métier, adaptateur, écrans, composants | 519 tests verts |
npm run test:edge | Deno : pipeline d'import, catalogue, lieu de tournage, garde du soir | 224 tests verts |
npm run test:rls:remote | pgTAP : RLS et partages, contre la base liée | 34 assertions vertes |
npm run test:publication:remote | pgTAP : publication, lieu et retour arrière | 86 assertions vertes |
npm run test:personnel:remote | pgTAP : suivi multi-appareils, suppression, annulation | 21 assertions vertes |
npm run test:photo:remote | pgTAP : partage éphémère, brûlure, péremption, quota | 26 assertions vertes |
npm run test:planification:remote | pgTAP : les deux tâches de l'import, la fenêtre à l'heure de Paris | 40 assertions vertes |
npm run build | tsc -b, Vite, budget (563 kB gzip, index ≤ 107 kB, préchargé ≤ 223 kB) | 519 kB gzip, index 100 kB |
npm run doctor | pwa-doctor du socle | 0 défaut, 0 dette, 1 info |
La colonne « État » vient des journaux de la CI de main : ci.yml (lint,
types, Vitest, Deno, build, docteur en --strict) et deploy.yml pour le
poids, au 29/09/2026 (257496f) ; supabase-tests.yml, qui joue les cinq suites
pgTAP sur une pile jetable, au 28/09/2026 (cae5bfe) — il ne tourne que lorsque
supabase/ change, et rien n'y a changé depuis. Leurs variantes :remote,
elles, se lancent à la main contre la base liée.
Le budget de bundle est enfin MESURÉ. Jusqu'au socle 4.5.0,
pwa-bundle-budgetsortait muet derrière le lien denode_modules/.binet rendait 0 : la borne existait, personne ne la contrôlait. C'est le jobdeployqui fait foi — il inline lesVITE_SUPABASE_*, ce qui lui coûte environ 1 Kio de plus qu'un build nu (104,04 contre 103,11 Kio, mesurés côte à côte le 06/09). La borne se dérive sur lui, jamais sur le build local.Et attention à ce que ce chiffre veut dire. Le chunk d'entrée est passé de 103,67 à 92,51 Kio en gagnant le partage éphémère — non parce qu'il a maigri, mais parce que
supabaseReferential(16,7 Kio), désormais partagé entre du code chargé d'emblée et une route paresseuse, a été détaché dans son propre fichier. Ce fichier est préchargé parindex.html: la charge du premier écran n'a pas diminué, elle s'est répartie.mainChunkKbborne un FICHIER, pas le premier écran.Les Réglages se chargent à la demande depuis le 29/09/2026, préchargés dès que le navigateur est au repos. L'anti-spoiler rendu visible portait l'index de 115 à 121 Kio (mesures locales), sous une borne de 117 ; sortir l'écran le moins visité — et ce que lui seul importe : file des portraits, export ZIP, carte, relecture de Wikipédia — l'a ramené à 100 Kio, en CI comme au déploiement de 257496f. La borne est redescendue à 107 : un cliquet qu'on ne redescend pas quand le poids baisse n'en est plus un.
Le dépôt est sous licence MIT — voir LICENSE — et cette licence couvre le code.
Le référentiel ne retient que des faits tabulaires : un prénom, un âge, une date, un nom de tribu, qui a voté pour qui, un décompte. Aucune prose de la source n'est copiée — ni résumé d'épisode, ni paragraphe rédigé. Le projet ne revendique donc aucune licence sur les données, et garde en revanche leur traçabilité entière : page, révision, date de lecture, pour chaque valeur. Voir docs/attribution.md.
L'application consomme @mister-guiiug/dev-pwa-config (version 6.18.0),
paquet de configuration et de composants de la famille miss-* / mister-*.
Jusqu'au 05/09/2026, ce paquet s'appelait
dev-wpa-config: une coquille typographique dans le nom publié, corrigée par la 4.0.0, qui ne change que le nom (dépôt, paquet, workflows@v4). L'audit du 04/09 (docs/audit-socle.md) est antérieur à ce renommage et n'est pas réécrit.
Ce qu'il apporte et qui est réellement branché : ThemeProvider,
AppHeader, BottomNav, AppFooter, PageContainer, Card, Badge,
Button, EmptyState, ErrorBoundary, LabelsProvider, IconsProvider
(lucide), react/rive derrière AppAnimation, versioned-store pour les
données personnelles, storage pour le cache, backend pour la sélection du
backend et sa couverture, image pour ré-encoder un portrait déposé, share
(partage natif et repli presse-papiers), download, qr (peer optionnelle
uqr, chargée seulement à l'ouverture d'un QR code), format (slugify,
formatBytes), react/observability (Sentry), react/consent-banner et
analytics (PostHog, après accord), vite-pwa / vite-csp / pwaSeoPlugin,
les workflows CI, déploiement, Lighthouse, nettoyage, migrations et tests
Supabase, anti-pause (tous en @v6), pwa-icons, pwa-bundle-budget,
pwa-doctor.
src/
domain/ métier PUR, sans React : référentiel (zod), anti-spoiler,
statistiques (zéro ≠ inconnu), chiffres de l'accueil
(`summary.ts` — comptés À LA LIMITE anti-spoiler), règles de
saison, duos
supposés (`pairing.ts` — la source prime toujours), tribus
(`tribes.ts` — la tribu À LA LIMITE, les retours, le nom de
la teinte), partage
(`sharing.ts` — nom de fichier, adresses, capacité d'un QR),
notes en document (`notesExport.ts` — Markdown et texte brut),
cibles d'une note (`noteTargets.ts` — nommer, ou le dire),
qui peut lire quoi (`visibility.ts` — les niveaux, et pourquoi
un profil n'en offre que deux),
pseudonyme et identifiant public (`profile.ts` — les règles du
schéma, dites avant que PostgreSQL ne les oppose)
backend/ sélection du backend, port du référentiel, démonstration,
portraits (IndexedDB, sur l'appareil — `photos.ts`), envoi
d'une image par la feuille du système (`sharePhoto.ts`), liens
de partage des notes (`sharing.ts` — créer, révoquer, lire),
profil (`profile.ts` — `upsert`, et la disponibilité demandée
au serveur et non à la table), suivi du compte
(`personal.ts` — favoris et épisodes vus, une union jamais un
écrasement)
store/ zustand — référentiel + données personnelles (magasin versionné :
anti-spoiler, animations, épisodes vus, favoris, duos supposés,
filtre des candidats — défaut « En jeu », calculé À LA LIMITE
anti-spoiler, donc il ne révèle aucun départ) ;
un rechargement en échec garde la dernière lecture réussie ;
notes du compte (useNotesStore), portraits (usePhotosStore :
URL d'objet, révoquées dès qu'elles sont remplacées)
animations/ registre de rôles → AppAnimation (socle react/rive, repli garanti),
niveaux de mouvement (`data-motion` sur <html>)
hooks/ useSpoilerLimit, useSession (compte courant), useProfile (le
pseudonyme, chargé à la connexion et oublié après), useNotes (les
notes du compte, une fois), usePersonalSync (le suivi qui suit
le compte, le magasin local en repli), useUndo (supprimer sans
confirmer, annuler pendant huit secondes), useHaptics,
useRefreshReferential (recharger et le dire — l'échec aussi)
components/ Layout, SpoilerGuard, Provenance, FavoriteButton, PullToRefresh,
HomeTiles (l'accueil en tuiles : un chiffre par destination),
NoteEditor + TargetNotes (une note là où la chose s'affiche),
PairBlock (le binôme : celui de la source, ou le vôtre),
Avatar + ContestantTraits + PhotoPicker (portrait, sexe, âge),
PhotoShare et NoteShare, tous deux sur ShareLinkPanel (le QR,
l'adresse en clair, et le partage natif d'un lien),
LocationMap (tuiles OpenStreetMap en SVG, géométrie dans mapTiles)
features/ accueil, tableau de bord, candidats, épisodes, notes, compte,
profil (pseudonyme, identifiant public, visibilité),
`profile/` — le profil de quelqu'un, à son adresse —,
réglages, hors-ligne, et `share/` — ce qu'un lien ouvre, pour
n'importe qui, sans session
supabase/
migrations/ 0001 référentiel · 0002 import · 0003 personnel · 0004 RLS
0005 amorçage · 0006 source · 0007 publication · 0008 catalogue
0009 correctifs · 0010 tribus et épreuves · 0011 version
d'extraction · 0012 format d'épreuve · 0013 colliers ·
0014 colonnes mortes · 0015-0018 duos révélés, ordre des
publications, photo des remplacements, cause extraite ·
0019 lieu de tournage · 0020 révision publiée ·
0021 partage des notes (lecteur de collection, et la
jointure de profil rendue EXTERNE) · … ·
0028 tribus colorées, jour du conseil, deux sorties
possibles pour un même candidat · 0029 rang de chaque
sortie dans sa soirée · 0030 sorties écrasées avant 0028
rendues · 0031 jour du conseil des épisodes déjà publiés ·
0032 révision lue notée même sans publication, relecture
chaque soir · 0033 publication d'un lot certain sans relecteur
· 0034 log_event fermée aux clients · 0035 la nuit lit
une saison à la fois · 0036 la soirée sondée toutes les
deux minutes · 0037 le poids d'un bulletin (vote
double) · 0038 un tour sait si le détail de ses
voix est complet
functions/ pipeline d'import (Deno, sans dépendance) + fonction Edge
tests/ isolation RLS, publication, suivi du compte, partage
éphémère, planification, structure de sécurité, vote
double, détail des voix (pgTAP)
Six choix qui structurent le code :
{ value, complete } ; un
départ de binôme vaut 0 certain, un détail de voix partiel vaut « ≥ n » ;season.rules, jamais présumé — une saison ordinaire n'en déduit rien ;data-motion sur <html> — full,
essential (« Animations » décochée : plus rien de décoratif, mais une case
qui se coche répond encore), none (« Réduire les mouvements ») — posé par
useMotionLevel ; le réglage système gagne toujours. Les cases restent des
<input> natifs redessinés : le clavier, le lecteur d'écran et le script de
captures les trouvent ;navigator.share parte du geste) ; la charge examinée par canShare est
exactement celle qui sera envoyée — vérifier { files } puis envoyer
fait dire oui au bouton et non au clic ; la
d'une note s'ouvre avant que son lien n'existe, sinon l'adresse promettrait
ce que le serveur refuse ; et un lien de collection nomme une , pas
une liste figée, ce qui rend le retrait immédiat. Ce qui ne peut pas passer
est dit avec le chiffre : le QR code porte le lien parce qu'un QR contient au
plus 2,9 ko.La seule route qui dépose une image sur un serveur. Elle meurt à la première des deux échéances : une ouverture, ou un jour.
bytea), pas dans un
bucket. Un bucket, ce sont deux durées de vie à tenir synchrones ; le jour où
l'une part sans l'autre, il reste des octets qui survivent à leur promesse.
Le portrait est déjà réduit à 120 Kio par le dépôt local : c'est ce qui rend
ce choix tenable.delete … returning, une seule instruction, donc
deux lecteurs simultanés ne peuvent pas obtenir la même image.rpc, c'est-à-dire un POST, derrière un geste.update n'est accordé, à personne : une péremption ne se repousse
pas, pas même par son propriétaire. Et bytes n'est lisible par personne —
c'est un droit de COLONNE, tenu par le moteur.expires_at > now() dans la
fonction de lecture est le contrôle d'accès, immédiat ; le balayage
(pg_cron, au quart d'heure) est la promesse — les octets s'en vont même si
plus personne n'ouvre le lien.| Document | Contenu |
|---|---|
| docs/audit-socle.md | Audit factuel du socle, et ce qu'il change au plan |
| docs/modele-de-donnees.md | Schéma, diagramme, et les six écarts assumés |
| docs/politiques-rls.md | Double verrou, partage par fonction, tests |
| docs/pipeline-wikipedia.md | Catalogue, extraction, diff, publication |
| docs/attribution.md | Source, traçabilité, accès responsable |
La base hébergée est le projet Supabase mister-miss-koh
(oqldfzrsandcguajyxbh, région eu-west-3, offre Free). Les trente-six
migrations y sont appliquées, par supabase-migrate.yml à chaque poussée qui
les touche. src/backend/database.types.ts en avait été généré, mais il
s'arrête à 0021 et n'a reçu depuis que des retouches à la main : il ignore
photo_shares et les fonctions qui suivent. Le régénérer avant de s'y fier.
supabase link --project-ref oqldfzrsandcguajyxbh
supabase db push
supabase gen types typescript --linked > src/backend/database.types.ts
supabase link lit le mot de passe de la base dans SUPABASE_DB_PASSWORD ; il
vit, avec la référence du projet, dans .env.supabase.local, ignoré par git.
L'URL et la clé anonyme vont dans .env.development.local sur le poste, et
dans les variables — pas les secrets — du dépôt GitHub pour le déploiement :
Vite les copie dans le bundle, et la clé anonyme est publique par construction
(rôle anon vérifié dans le jeton avant d'être traitée ainsi). La clé
service_role, elle, n'entre nulle part côté client.
Le projet Free se met en pause après sept jours sans requête. Le workflow
supabase-keepalive.yml fait un select anonyme chaque jour. Il
n'attend aucun secret : le réutilisable du socle déclare ses deux entrées
en secrets:, mais un appelant y passe l'expression qu'il veut — ici les
variables du dépôt, qui portent déjà l'URL et la clé anonyme pour le
déploiement. Une seule valeur, un seul endroit.
Les tables naissent en ENABLE ROW LEVEL SECURITY sans aucune politique :
un schéma poussé à moitié refuse tout au lieu d'exposer. 0004_rls.sql ouvre
ensuite le strict nécessaire, avec un double verrou — droits SQL retirés puis
redonnés un par un, en plus des politiques.
Les saisons suivies ne sont pas une liste écrite à la main : ce sont les pages
que l'API MediaWiki déclare dans Catégorie:Saison de Koh-Lanta — 18 le
05/09/2026. 0008_catalogue_saisons.sql les amorce ; l'action discover de
la fonction Edge relit la catégorie et ajoute ce qui manque, sans jamais rien
supprimer.
Chaque saison découverte naît en unknown / pending_review : on sait qu'une
page existe, pas ce qu'elle contient. C'est une publication qui la rend
visible : celle d'un relecteur (admin ou validator), ou, depuis le
28/09/2026, celle de la planification, quand le lot n'apporte que des faits
neufs et sans ambiguïté (au plus 80 différences, aucune suppression, rien de
rétroactif). Tout autre lot attend un relecteur.
Déployée sur le projet hébergé, sans Docker :
supabase functions deploy import-wikipedia --project-ref oqldfzrsandcguajyxbh --use-api
Elle s'ouvre par un secret de planification (IMPORT_CRON_SECRET, secret du
dépôt GitHub que supabase-migrate.yml pose à chaque passage sur la fonction
et dans le Vault du projet, où pg_cron le lit) ou par un compte portant le
rôle admin ou validator, vérifié en base. La
vérification de jeton de la plateforme restant active, un appel présente aussi
la clé anonyme en Authorization ; elle n'ouvre rien par elle-même.
Deux tâches pg_cron l'appellent (0026, 0032, 0035, 0036) : chaque nuit, de
4 h à 6 h UTC, une page connue toutes les deux minutes (les dix-huit entre
4 h 01 et 4 h 35), et chaque soir de 16 h à minuit, heure de Paris (jusqu'à 2 h
tant que la page bouge), une SONDE de la saison en diffusion toutes les deux
minutes : un appel à l'API, et un import seulement quand une révision nouvelle
s'est calmée depuis trois minutes (quinze au plus). Le site suit la page à
quelques minutes près, et l'application ouverte suit le site : au premier plan
et en ligne, elle demande toutes les deux minutes, et à chaque retour au
premier plan, le numéro de la dernière publication de la saison regardée ;
s'il a avancé, elle relit en silence et le dit par un toast neutre (« De
nouvelles données sont arrivées. »), jamais par le contenu, que la limite
anti-spoiler garde (useReferentialWatch).
Le premier import réel a tourné le 05/09/2026 : 78 différences proposées, aucune validée automatiquement, référentiel publié inchangé. Détail et comportements vérifiés dans docs/pipeline-wikipedia.md.
supabase test db exige Docker (pg_prove en conteneur), qui ne démarre pas
sur le poste de développement. Le même fichier se joue contre la base liée,
sans Docker, par un lanceur qui collecte chaque verdict pgTAP :
npm run test:rls:remote
Les suites comptent 34 assertions pour l'isolation et 86 pour la
publication (21 pour le suivi du compte, 26 pour le partage éphémère, 40 pour
la planification). Le rollback final ne laisse rien derrière lui. Sur une
pile jetable, supabase-tests.yml les joue toutes à chaque PR qui touche aux
migrations ou aux tests.
npm run test:publication:remote
Aucun mot de passe, nulle part. On entre par un lien à usage unique envoyé
à une adresse (signInWithOtp) : l'application ne voit passer aucun secret et
n'en conserve aucun. Il n'y a donc ni réinitialisation, ni stockage, ni fuite
possible par le bundle.
Deux réglages sans lesquels le lien ne ramène nulle part :
src/backend/supabaseReferential.ts). Le flux implicite
renvoie le jeton dans le fragment (#access_token=…), l'endroit exact où
HashRouter lit la route : le routeur y verrait une adresse inconnue et
remplacerait le tout par « / », emportant le jeton. PKCE passe par
?code=…, que le routeur ne touche pas ;https://mister-guiiug.github.io/mister-miss-koh/ (aussi site_url) et
http://localhost:5236/. Elle valait http://localhost:3000 par défaut,
c'est-à-dire nulle part.Les notes vivent sur le serveur : on relit une note ailleurs, et on ne veut
pas la perdre en vidant un cache. Une note vise exactement une chose
(saison, candidat, épisode…), garantie par une contrainte de cardinalité ; la
suppression est logique (deleted_at), pour qu'un lien partagé cesse
d'ouvrir la note au moment où son auteur la retire.
Et cette suppression s'annule. Supprimer une note ne demande plus de
confirmation : la notification offre huit secondes pour revenir en arrière. Une
boîte de dialogue avant chaque geste fatigue sans protéger — on répond « oui »
par réflexe —, alors qu'un retour en arrière protège vraiment, et il ne coûte
rien ici puisque la ligne n'a jamais été détruite : annuler retire une date.
Pas de corbeille pour autant, et la raison est dans la RLS : la politique de
lecture porte deleted_at is null, donc on ne peut pas lister ses notes
supprimées. Supprimer et restaurer passent par deux fonctions
security definer, delete_note et restore_note (0023), qui vérifient
elles-mêmes user_id = auth.uid() : une ligne supprimée n'étant visible sous
aucune politique de lecture, un update direct échouait dans les deux sens.
La publication d'une collection, elle, garde sa confirmation : on confirme ce
qui sort, on annule ce qui reste chez soi.
Les favoris et les épisodes vus suivent le compte, sans cesser d'être
locaux. L'anti-spoiler est la fonction centrale de l'application et il ne
franchissait pas l'appareil : un épisode marqué vu sur le téléphone laissait la
tablette afficher les éliminés. Il s'écrit maintenant aussi dans
user_favorites et watched_episodes — tables présentes depuis la migration
0003, fermées à autrui depuis 0004, sans aucune migration nouvelle —
pendant que le magasin local reste la référence de l'appareil : sans compte,
hors ligne, ou si le serveur refuse, l'application marche entière. À la
connexion, l'appareil et le compte sont réunis (une union, jamais un
écrasement) ; le prix, assumé et écrit, est qu'un décochage fait sur un
appareil pendant que l'autre l'ignorait revient une fois.
Le référentiel de démonstration ne synchronise rien : ses identifiants
(c-ael, e1) ne sont pas des uuid, les envoyer ferait échouer chaque
insertion et publierait un suivi qui ne veut rien dire.
Le profil (carte « Profil » de l'écran Compte, #/compte) donne un
pseudonyme à ce que l'on
partage, et un identifiant public facultatif. Ce sont deux choses : deux
personnes ont le droit de s'appeler « Tarzan », une seule peut être tarzan —
l'unicité porte sur l'adresse, pas sur le libellé, sans quoi chaque inscription
deviendrait une course au nom. Sa disponibilité se demande au serveur
(handle_is_available, security definer) : vérifiée côté client, un
identifiant déjà pris passerait pour libre, puisque l'appelant ne voit pas le
profil qui le détient. Tout y est privé par défaut, et la seule bascule
offerte, les notes publiques, est un consentement séparé :
rendre son profil visible n'ouvre rien d'autre. Aucune adresse électronique n'y
figure : elle n'est dans aucune colonne de profiles, elle reste dans
auth.users, hors de portée de l'API publique.
Enfin, la lecture filtre sur user_id, et ce n'est pas une précaution
superflue : les politiques RLS sont permissives, donc combinées par OU. À
côté de « je vois les miennes » vit « tout le monde voit les notes publiques ».
Le serveur a raison ; c'est à la requête de dire ce qu'elle cherche. Deux
assertions pgTAP (§ 2 bis de rls.test.sql) figent ce comportement, faute de
quoi la disparition du filtre ne casserait rien de visible.
Dans l'ordre :
show_favorites n'a rien à montrer à autrui : les favoris suivent le
compte (user_favorites), mais la RLS n'en ouvre la lecture qu'à leur
propriétaire, et afficher la bascule promettrait ce que la base refuse.
show_stats n'ajouterait qu'un compte de notes déjà visible. Aucune des
deux n'est offerte tant qu'elle ne gouverne rien. De même, le partage d'un
profil par lien (get_shared_profile, portées profile / favorites /
ranking de share_links) reste inutilisé : l'adresse d'un profil public
se donne telle quelle, sans jeton à révoquer ;export_my_data() existe en
base (0004, § 9) et aucun écran ne l'appelle — la portabilité est écrite,
pas offerte ;.riv n'est fourni, et aucun ne sera inventé. En
attendant, la boussole battante de l'écran d'attente est le repli CSS du
rôle referential-loading : un .riv la remplacerait sans toucher à
l'écran. Conséquence à connaître avant d'alléger le bundle : le runtime
Rive (~57 ko, environ un dixième des 513 kB du total) n'est téléchargé par
personne tant que animations/registry.ts garde tous ses src: null.{ files, url }