ACritiqueExécutéSécurité
La clé d'administration de la base est livrée à chaque visiteur
src/lib/supabaseAdmin.ts construit un client Supabase avec la clé service_role, écrite en dur en repli d'une variable d'environnement. Le commentaire raconte l'histoire : « avec la clé anon la page admin renvoyait des tableaux vides (problème de RLS ?) donc on utilise la clé service_role qui contourne les policies, ça marche ». Les 11 requêtes de l'administration l'utilisent. Après construction, la clé est dans le fichier JavaScript servi à tous les visiteurs : son contenu décodé dit "role":"service_role". La page admin elle-même n'est protégée que par une redirection dans l'écran.
Impact. La clé service_role ignore toutes les règles d'accès. Quiconque ouvre les outils de développement de son navigateur peut lire, modifier ou effacer tous les profils, toutes les réservations, tous les paiements, et créer des administrateurs.
Coût de ne rien faire. Une fuite de données clients à notifier à la CNIL sous 72 heures, et une base qu'un inconnu peut vider un dimanche soir.
Correction. Supprimer le client admin du navigateur ; changer la clé chez Supabase, puisqu'elle est publique ; servir l'administration par des fonctions serveur qui vérifient le rôle de l'appelant, ou par des règles d'accès is_admin() (la fonction existe déjà en base). Effort : une journée.
CCritiqueExécutéDonnées, sécurité
Les règles « allow all » du premier jour n'ont jamais été retirées
La première migration ouvre profiles et bookings à tout le monde, « pour démarrer, on affinera les règles plus tard ». Un mois après, une migration ajoute les règles fines (« chacun voit ses réservations »), mais ne supprime pas les anciennes. Dans Postgres, les règles s'additionnent : la plus large gagne. Rejouées dans une base jetable, les migrations donnent ceci : un visiteur sans compte, avec la seule clé publique, lit l'e-mail et le téléphone de tous les clients et toutes les réservations ; une cliente connectée annule la réservation d'un autre client ; et elle passe son propre profil en role = 'admin'. L'écran « Mes réservations » masque le problème en filtrant dans le navigateur.
Impact. Toutes les données personnelles des clients sont publiques, et n'importe qui peut devenir administrateur en une requête. Les règles fines donnent une fausse impression de sécurité à qui relit les migrations récentes.
Coût de ne rien faire. Le même que A, sans même avoir besoin de la clé d'administration.
Correction. Supprimer les deux règles « allow all » ; interdire la modification de role par l'utilisateur (règle ou colonne protégée) ; filtrer par user_id dans les requêtes. Puis rejouer le test de l'annexe, qui doit tout refuser. Effort : une demi-journée, test compris.
BCritiqueExécutéLogique
Le paiement est déclaré réussi par la page, pas par Stripe
Au retour de Stripe, PaymentReturn.tsx (ligne 45) appelle confirmPayment avec les paramètres de l'adresse. confirmPayment insère un paiement succeeded et confirme la réservation, depuis le navigateur, parce que « Stripe nous a redirigé sur success_url donc le paiement est passé ». La base l'autorise : une règle laisse chaque client insérer et modifier ses propres paiements. Exécuté dans la base jetable : une cliente enregistre un paiement réussi de 18 € avec stripe_session_id = 'n_importe_quoi', accepté. Côté serveur, le webhook Stripe lit l'événement tel quel, la vérification de signature est commentée (« faisait planter en local »), et la fonction est déployée sans vérification de l'appelant.
Impact. Une séance se réserve sans payer en tapant l'adresse de retour à la main. N'importe qui peut envoyer au webhook un faux événement « paiement complété » et confirmer n'importe quelle réservation. La base ne peut plus servir de rapprochement avec Stripe : elle contient des paiements que Stripe n'a jamais vus.
Coût de ne rien faire. Des séances données, des places prises par des non-payeurs, et une comptabilité qui ne correspond plus aux encaissements.
Correction. Le webhook signé devient la seule source de vérité : vérification de signature, idempotence par identifiant d'événement, écriture des paiements par le serveur uniquement. Supprimer les règles d'insertion et de modification des paiements côté client. La page de retour se contente d'attendre la confirmation. Effort : deux jours, tests compris.
GCritiqueLuhors de la liste des défauts placésLogique, sécurité
N'importe quel client peut rembourser n'importe quelle réservation
refund-payment exige un utilisateur connecté, puis rembourse chez Stripe la réservation dont on lui donne l'identifiant, sans vérifier qu'elle appartient à l'appelant. Les identifiants de toutes les réservations sont lisibles par tous (constat C). En parallèle, l'annulation côté navigateur marque le paiement « remboursé » en base avant même d'appeler Stripe, sans attendre ni vérifier la réponse.
Impact. Un client mécontent, ou un concurrent avec un compte gratuit, peut rembourser toutes les séances payées du studio, une par une. Et quand un remboursement échoue chez Stripe, la base dit qu'il a eu lieu.
Coût de ne rien faire. Le chiffre d'affaires de la période, rendu à des clients qui n'ont rien demandé, et des réclamations que personne ne pourra expliquer avec la base.
Correction. Vérifier dans la fonction que l'appelant est le propriétaire ou un administrateur, et que la séance est encore annulable ; écrire le statut « remboursé » seulement après la réponse de Stripe, ou par le webhook charge.refunded. Même vérification à ajouter dans create-checkout. Effort : une demi-journée.
JÉlevéExécutéQualité et performance, logique
« Mes réservations » télécharge les réservations de tous les clients et, au-delà de 1 000, en oublie
Pour afficher les réservations d'une cliente, listBookings demande à la base toutes les réservations de tous les clients, avec leur créneau, leur profil et leur paiement, puis l'écran ne garde que les siennes. La page d'un créneau et les notifications font de même. Chronométrage dans une base jetable, sur des données fictives, comparé à la même requête filtrée sur la cliente :
| Réservations en base |
Requête de l'application |
Données envoyées |
Requête filtrée |
Données envoyées |
| 100 |
7 ms |
131 ko |
1 ms |
1 ko |
| 1 000 |
25 ms |
1,3 Mo |
1 ms |
9 ko |
| 10 000 |
314 ms |
13 Mo |
4 ms |
28 ko |
| 50 000 |
1 638 ms |
64 Mo |
8 ms |
141 ko |
Ces mesures sont faites sans le plafond de Supabase, pour montrer la pente. Car la configuration du projet plafonne chaque réponse à 1 000 lignes (max_rows = 1000, la valeur par défaut de Supabase). Au-delà de 1 000 réservations, la base ne renvoie que les 1 000 plus récentes, sans erreur.
Impact. Vers mille réservations, chaque affichage de « Mes réservations » télécharge plus d'un mégaoctet sur le téléphone de la cliente. Au-delà de 1 000, une cliente fidèle ne voit plus ses anciennes réservations, et la page d'un créneau ne détecte plus qu'elle l'a déjà réservé. Le problème grandit avec le succès du studio, et il est invisible tant qu'il est petit.
Coût de ne rien faire. Des clientes qui croient leurs réservations perdues et appellent le studio ; des doublons qui passent (voir E) ; une application qui ralentit chaque mois sans que personne ne sache pourquoi.
Correction. Filtrer sur la cliente dans la requête elle-même, ce que la correction de C impose de toute façon ; paginer l'historique ; une requête dédiée pour « ai-je déjà réservé ce créneau ». Effort : une demi-journée, avec C.
HMoyenLuhors de la liste des défauts placésLogique
Le code promo est affiché, jamais facturé
L'écran de paiement accepte LUNE10 et affiche un total réduit de 10 %. Le commentaire dit « les codes promo sont gérés côté Stripe », mais la session Stripe est créée avec le prix plein et sans option de code promo.
Impact. Pour une séance à 18 €, le client voit 16,20 € et paie 18 €. Pour lui, c'est une erreur de facturation ; pour le studio, une pratique commerciale trompeuse sur le prix affiché.
Correction. Créer le code chez Stripe et l'appliquer dans la session côté serveur, ou retirer le champ. Effort : deux heures.
EMoyenExécutéLogique, données
Deux clients peuvent prendre la dernière place
createBooking vérifie la place restante, puis insère, en deux requêtes séparées, sans transaction. La base n'a aucune contrainte de capacité, ni d'unicité sur le couple client et créneau. Exécuté : sur un créneau d'une place, déjà réservé, la base accepte deux réservations de plus, dont un doublon de la même cliente. L'écran avertit d'un doublon mais ne l'empêche pas.
Impact. Des cours en surnombre, un client refusé à l'entrée alors qu'il a payé, un remboursement à faire à la main. Plus le studio a du succès, plus ça arrive : c'est aux heures de pointe que deux personnes cliquent en même temps.
Correction. Une fonction en base qui vérifie la capacité et insère dans la même transaction, avec verrou sur le créneau ; une contrainte d'unicité client et créneau sur les réservations actives. Effort : une demi-journée.
DMoyenLuExploitation
Les e-mails partent d'un domaine qui n'est pas celui du studio, et personne ne lit les rebonds
send-confirmation envoie depuis onboarding@resend.dev, le domaine de démonstration de Resend, en attendant de « configurer SPF / DKIM sur studio-kerelune.example plus tard ». Les événements de Resend, rebonds compris, sont enregistrés dans email_events, que rien ne lit jamais. Si l'envoi échoue, l'écran affiche « l'e-mail de confirmation est en cours d'envoi ». La fonction est aussi ouverte sans vérification de l'appelant : n'importe qui peut déclencher un envoi au nom du studio vers l'adresse d'une réservation.
Impact. Les confirmations arrivent en spam, ou n'arrivent pas, sans que personne le sache. Le client appelle le studio, qui ne voit rien.
Correction. Domaine du studio authentifié chez Resend (SPF, DKIM, DMARC) ; suppression des adresses en rebond avant chaque envoi ; vérification de l'appelant ; message d'écran honnête en cas d'échec. Effort : une demi-journée, plus le délai DNS.
IMoyenLuExploitation
Quand quelque chose casse, personne ne le sait
121 console.error dans l'interface, qui s'affichent dans le navigateur du client et nulle part ailleurs. Une variable VITE_SENTRY_DSN est lue, jamais utilisée. Aucun délai maximal sur les appels réseau. Le déploiement publie directement en production à chaque envoi sur main, avec npm install plutôt que npm ci, sans aucune étape de test et sans environnement d'essai.
Impact. Un paiement qui échoue, un e-mail qui ne part pas, un remboursement refusé par Stripe : le studio l'apprend par un client, ou ne l'apprend pas.
Correction. Un collecteur d'erreurs côté interface et serveur, avec alerte ; une préproduction ; npm ci et une étape de test bloquante. Effort : une journée.
KMoyenExécutéQualité et performance
Sur mobile, le planning met 5 secondes à s'afficher
Mesuré avec Lighthouse, l'outil de mesure de Google, sur la page du planning servie compressée comme le ferait l'hébergeur, trois passages par mode : sur mobile, score de 67 à 72 sur 100 et 5,2 secondes avant l'affichage du contenu principal (Google considère « bon » en dessous de 2,5 s) ; sur ordinateur, 97 sur 100 et 1,2 s. La page transfère 521 ko, dont 265 ko de Stripe.js : le module de paiement est importé d'une façon qui charge Stripe sur toutes les pages, planning compris, alors qu'il ne sert qu'au paiement. Le reste de l'application tient en un seul fichier de 681 ko (197 ko compressé), sans aucun découpage par page : l'administration est livrée à tous les visiteurs, et 299 ko de JavaScript ne servent pas à afficher le planning. Les polices viennent de Google Fonts, ce qui transmet l'adresse IP de chaque visiteur à Google sans son consentement.
Impact. Sur un téléphone en 4G, la cliente attend cinq secondes avant de voir les cours ; une partie part avant. La transmission des adresses IP à Google est un sujet RGPD connu, déjà sanctionné par un tribunal allemand en 2022.
Correction. Importer Stripe dans sa version qui ne charge rien d'avance (@stripe/stripe-js/pure) et ne le charger qu'au paiement ; découper l'application par page, administration à part ; héberger les polices avec le site. Puis remesurer. Effort : une journée.