個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證[prod] - makeup artist freelance platform
翻譯準備中
取自儲存庫 README
正在載入完整 README。
檢視完整 README ↗Annuaire de maquilleuses professionnelles indépendantes : my-makeup.fr.
v3Cette branche porte la réécriture du site : une seule application Next.js et Payload, en TypeScript strict.
main tant que la bascule n'a pas eu lieu.main et dans l'historique.public/ est conservé : il contient le fichier de vérification de Bing et les images du site, qui doivent continuer à répondre à la racine. La clé IndexNow n'en fait plus partie : elle vient de la variable INDEXNOW_KEY (ticket 219).dev-assets/ est conservé : ce sont les sources de design.Les documents, sous docs/migration/ :
CHANGEMENTS-VOULUS.md : le registre de tout ce que la v3 fait autrement que le site actuel, par décision. À la répétition de la bascule, seuls ces écarts sont acceptés.JOURNAL.md : les faits datés de la migration.SCHEMA.md : le schéma gelé. Il dit où va chaque champ de l'ancienne API dans la v3, ou pourquoi il n'est pas repris.URLS.md : les URL gelées. Il dit ce que répond chaque famille d'adresses après la bascule.GEL.md : le gel de l'ancien site. Il dit quand il commence (au départ de la répétition de la bascule), ce qui reste permis après (sécurité, bandeau, lecture seule, contenus), et la commande bun run migration:freeze qui le contrôle.IMPORT.md : l'import des données de l'ancien site. Il dit ce que l'import garantit, comment le lancer, à blanc ou pour de vrai, et ce que dit son rapport.LECTURE-SEULE.md : le gel de l'ancien site par un réglage (LEGACY_READ_ONLY), son retrait en une minute et les correctifs prêts à appliquer à l'ancienne API et à l'ancien site.RETOUR-ARRIERE.md : le retour arrière après la bascule sans perdre de comptes. Il dit comment télécharger l'export privé de ce que la v3 a reçu depuis la bascule (/api/cutover/delta, admins du site seulement) et quoi rejouer dans l'ancien site.BASCULE.md : le pas-à-pas du jour de la bascule. Une liste de feu vert, puis pour chaque étape qui la fait, le geste, le contrôle chiffré, la durée et le critère d'arrêt, et le retour arrière sur une page. Il est gardé par tests/unit/cutover-steps-doc.test.ts.Trois documents disent comment travailler ici, à une personne comme à un agent :
CLAUDE.md : les commandes, l'architecture, les interdits et les pièges.AGENTS.md : à quel rythme lancer les tests, et quand un travail est fini.docs/qualite.md : les règles des tests, les sept règles des listes éditées en place, ce qu'on regarde sur un écran à 390 px, puis la CI, ses garde-fous et le canari, et les règles des mises à jour (Renovate).Un test les garde : une commande qu'ils citent existe, un lien qu'ils donnent mène quelque part (bun --no-env-file test tests/unit/guides-agents.test.ts).
Une application, trois zones, toutes sous src/app/ :
| Zone | Dossier | Adresse | État |
|---|---|---|---|
| Pages publiques | (front)/(site)/ | /, /search, /profil/<slug> | Accueil du site en ligne (recherche en tête, talents lus par src/services/public/talents.ts, données structurées) ; recherche par ville, en sections par lieu, par mot-clé (seul ou avec une ville) et par pages de 20 ; page publique d'un profil, rendue par le serveur : en-tête (nom en titre, spécialité, zone, disponibilité, photo), description, localisation, réseaux et coordonnées publiées, compétences, langues, formations, portfolio, prestations et prix, expériences |
| Espace maquilleuse | (front)/(dashboard)/auth/ | /auth | Page d'attente |
| Administration | (payload)/ | /admin et /api | Générée par Payload : les comptes admins, aux rôles admin et editor, les comptes des maquilleuses, pros, leurs profils, profiles, les photos, media, les talents, talents, et les articles du blog, articles |
Les noms entre parenthèses rangent les fichiers et n'apparaissent pas dans les adresses.
Les pages publiques et l'espace maquilleuse partagent une seule coquille HTML, (front)/layout.tsx : passer de l'une à l'autre se fait sans recharger la page, comme sur le site actuel. L'administration a la sienne, générée par Payload, et ne charge pas la feuille de style du site.
Le reste :
src/payload.config.ts : la configuration de Payload (base PostgreSQL, collections admins, pros, profiles, media, talents, articles, auth-events, contact-requests et deleted-accounts, stockage d'objets des photos).src/lib/payload-language.ts : Payload parle français, et seulement français : l'enveloppe d'un refus (« Le champ suivant n’est pas valide : Téléphone »), ses refus et ses messages traduits, et l'administration, même quand la requête demande l'anglais ; restent en anglais quelques messages codés en dur dans Payload (« Route not found », QueryError, certains APIError), que l'espace ne montre jamais. Un refus construit avec les erreurs de Payload leur passe req.t, sinon il sort en anglais.src/collections/ : les collections de Payload. src/lib/access.ts : qui peut lire, créer, modifier ou supprimer quoi (voir « Accès »).src/services/public/ : la seule lecture publique des données. Une page publique y lit un profil, champ par champ, avec les règles d'accès d'une visiteuse : jamais ce qu'ont écrit l'import ou un admin. Elle donne aussi la liste des profils (listé : ni masqué, ni indisponible, ni entièrement vide ; règle dans src/lib/profile/visibility.ts), dit si un profil est indexable (publiable), et ne rend ses coordonnées que si la maquilleuse les publie (publicContact). Un test unitaire échoue si une page de (front) lit une collection sans passer par ce dossier, ou si une lecture en liste de ce dossier n'a pas de limit.scripts/migration/ : les outils de la migration, dont l'import des données de l'ancien site (voir docs/migration/IMPORT.md).src/components/profile/ : la page publique d'un profil (tickets 101 à 103). ProfileView compose l'en-tête et les cartes à partir d'un PublicProfile et rien d'autre ; une carte sans contenu n'est pas rendue, titre compris. Tout est composant serveur, sauf le portfolio (ProfileGallery) : une rangée qui défile au doigt (accrochage CSS) et se parcourt au clavier (flèches, début, fin, deux boutons), qui annonce « Photo 2 sur 6 » et ne reçoit que les adresses et les tailles des photos. Ce que la page montre se calcule dans src/lib/profile/public-view.ts (listes, dates, liens, coordonnées) et src/lib/image-size.ts (la largeur où une photo est demandée). Tests : tests/unit/public-profile.test.tsx (les douze profils types), tests/unit/profile-gallery.test.tsx, (E43, E44, E46, E47, E66). Ce que mène l'adresse (ticket 104) se décide dans : le slug d'un profil non masqué donne sa page (200, en et sans adresse canonique tant qu'il n'est pas publiable, brouillon compris) ; l'ancien nom d'utilisateur () ou une variante du slug (majuscules, accents, espaces) donne un seul 308 vers le slug ; un slug inconnu, un profil masqué et les 13 profils du spam, que l'import ne reprend pas, donnent la page 404 en français. Cette lecture lève les règles d'accès de la collection (elles ne laissent lire que les profils publiés) : champs choisis un par un, masqué écarté par la règle.Les versions sont figées dans package.json, et bun.lock est le seul fichier de verrouillage.
Une seule règle dit où en est un profil : src/lib/profile/completeness.ts. Elle est portée de l'ancien site, puis allégée par décision du 27/10/2026 (voir publication-allegee dans CHANGEMENTS-VOULUS.md) : seuls 15 profils sur 100 remplissaient les six conditions de l'ancien site, le prix chiffré et la description de 200 caractères étant ce qui bloquait les autres. Le schéma, l'espace maquilleuse, la recherche, le sitemap, les rappels et le courriel aux membres passent tous par elle, sans refaire le calcul de leur côté.
Elle donne :
conditions) : photo principale, ville exploitable, spécialité, moyen de contact. Le moyen de contact est la seule exigence « sociale » gardée : une page sans aucun moyen de joindre la maquilleuse ne sert à rien à la visiteuse ;richness), qui ne décide que de l'indexation par Google : une description d'au moins 100 caractères (DESCRIPTION_MIN), une prestation avec un prix chiffré, au moins une photo de galerie ;active : photo principale, ville exploitable et spécialité ;publishable : les quatre conditions, sauf pour un compte interne, qui ne l'est jamais. Un profil publiable est en ligne, listé dans la recherche et lisible à son adresse ;indexable : publiable et une description d'au moins 100 caractères et (une prestation avec un prix chiffré ou au moins une photo de galerie). Seul un profil indexable est dans le sitemap, sans noindex, avec une adresse canonique et ses données structurées. Un profil publiable mais mince reste visible, en noindex, hors du sitemap et sans données structurées ;fullyEmpty : aucune des quatre conditions, ni prestation chiffrée, ni description de 100 caractères. Un profil qui n'a qu'un nom est entièrement vide ; une seule de ces choses suffit à ne plus l'être (la recherche ne liste pas un profil entièrement vide).| Avant (ancien site, jusqu'au 27/10/2026) | Maintenant | |
|---|---|---|
| Photo principale | exigée | exigée |
| Ville | exploitable : commune ou code postal, six mots au plus | exploitable : toute commune, tout code postal, toute ville étrangère, sans limite de mots ; refusés : le vide, null, -, ., undefined, un pays seul ou une zone (« France », « Europe », « Toute la France »), une adresse sans commune ni code postal |
| Spécialité | exigée | exigée |
| Moyen de contact | exigé | exigé (la seule exigence sociale gardée) |
| Prestation avec un prix chiffré | exigée | non exigée : avec la description, elle (ou une photo de galerie) rend le profil indexable |
| Description | 200 caractères au moins | non exigée : 100 caractères au moins pour être indexable |
| Photo de galerie | non | non exigée : remplace le prix pour être indexable |
Le formulaire de devis ne compte pas comme canal de contact.
Le prix d'une prestation est lu par src/lib/price.ts : le premier montant du texte, une durée n'en étant pas un (« 2 h : 150 € » donne 150, « 45 min, 60 € » donne 60 ; l'ancien site lisait 2 et 45).
La règle lit les champs sous leurs noms de l'ancien site (type ProfileFields) : le code qui l'appelle fait la correspondance depuis son propre document. Une photo se passe comme un objet ou une liste, jamais comme un simple identifiant, qui compterait comme « pas de photo ».
Les tests sont dans tests/unit/completeness.test.ts et se lancent seuls avec bun --no-env-file test tests/unit/completeness.test.ts. Deux fichiers de tests/unit/fixtures/ servent de référence :
completeness-cases.json : les cas partagés avec l'ancien site (score, manques, actif, interne comme lui ; publiable et indexable de la v3), et, sous frontieres, les cas des frontières de la publication allégée (villes, 99 et 100 caractères, prix, galerie, rien) ;completeness-old-site.json : les réponses du code de l'ancien site sur ces cas et sur ceux de son API. Ces valeurs ne se corrigent jamais à la main : un écart est un défaut du port.Un profil porte cinq listes : les prestations (serviceOffers, chacune avec ses options), les compétences (skills), les langues (languages), les formations (courses) et les expériences (experiences). Les champs sont dans src/collections/profile-lists.ts, leurs noms dans docs/migration/SCHEMA.md, §6. Les règles sont dans src/lib/profile/lists.ts, que les formulaires de l'espace maquilleuse appelleront aussi.
src/lib/price.ts, ou rien pour un prix « sur devis » ; la fin d'une expérience pas avant son début ; une formation obtenue au plus un an après la date du jour.Les tests : tests/unit/profile-lists.test.ts pour les règles, tests/integration/profile-lists.test.ts pour la collection, sur le banc de test. Une date de l'an 0 (« 0000-01-01 »), que PostgreSQL n'a pas, n'est pas une date : refus en 400 comme « 2024-02-30 », jamais un 500 (ticket 224).
La zone d'un profil est une commune de la liste officielle, un rayon d'action et « à domicile », partout en France. Elle n'a aucun champ pour une adresse postale. Les règles et leurs messages en français sont dans src/lib/profile/zone.ts, les champs dans src/collections/profiles/zone.ts :
city : son nom et son code INSEE, jamais l'un sans l'autre, et les coordonnées de son centre si on les connaît. Elle s'envoie entière : ce qu'une requête n'en donne pas est vidé, jamais repris de la commune déjà enregistrée. Le serveur refuse une commune sans code INSEE, un code mal formé, une ville tapée en texte libre sans code, une commune envoyée en liste, un nom qui ressemble à une adresse (un numéro, un code postal, une virgule, « rue des Lilas »), et un nom fait d'autres caractères que des lettres, des espaces, des traits d'union, des apostrophes et des parenthèses (un email, un site, « St. »). Aucun nom de la liste officielle n'est refusé par la règle des adresses : elle a été jouée sur toute la liste du 14/10/2026. Les coordonnées sont gardées à deux décimales : le centre de la commune à environ 1 km près, jamais un point qui situe un domicile. Le code peut être celui d'un des 101 départements ou d'une des collectivités d'outre-mer de la liste officielle (975, 977, 978, 984, 986 à 989). Le serveur calcule lui-même le département depuis le code (« 74010 » donne « 74 », « 2A004 » donne « 2A », « 97701 » donne « 977 ») : une valeur envoyée pour lui est remplacée. null vide la commune.radiusKm : de 0 à 200 km, en kilomètres entiers, ou vide. L'affichage garde sa propre borne de 1 000 km (src/lib/format-zone.ts), pour les valeurs de l'ancien site.homeService : vide tant que la maquilleuse ne l'a pas dit.La zone suit la lecture de son profil, sans règle d'accès propre : qui lit un profil lit toute sa zone, code INSEE et coordonnées du centre de la commune compris, qui sont des données publiques de référence. Le public la lit donc avec les profils publiés. Que la commune existe vraiment dans la liste officielle, et que son nom soit celui de son code, le serveur ne le vérifie pas : il ne vérifie que la forme du nom. Un nom bien formé à côté d'un code bien formé est donc accepté, même s'il n'est pas celui de ce code. L'espace maquilleuse et l'import choisiront la commune dans cette liste, nom et code ensemble (tickets 123 et 063). Tests : tests/unit/profile-zone.test.ts, et sur le banc tests/integration/profile-zone.test.ts.
Choisir sa commune dans l'espace (ticket 123). La carte « Localisation » de /auth/profil ouvre un panneau : une commune à choisir dans la liste officielle, un rayon de 0 à 200 km (vide permis) et « à domicile ». La commune se cherche par son nom ou son code postal ; un texte qui ressemble à une adresse n'est jamais envoyé à la liste, et le panneau le dit.
geo.api.gouv.fr (la politique de sécurité ne laisse parler qu'au site lui-même). La recherche et l'enregistrement sont deux Server Actions (src/app/(front)/(dashboard)/auth/profil/location-actions.ts) : session relue, origine vérifiée, une maquilleuse connectée seulement, 120 recherches par minute et par compte. Le client de la liste est src/lib/dashboard/city-reference.ts, appelé par fetch (donc simulable, voir docs/tests/http-sortant.md), avec son propre délai de 5 s.strictLocationSchema, src/lib/dashboard/location.ts) : un nom, des coordonnées, un département ou un champ réservé refusent la requête entière. Le serveur demande la commune de ce code à la liste, et enregistre son nom et son centre à elle : un nom ne peut pas se retrouver à côté du code d'une autre commune. Un texte libre n'est pas un code : refusé ici, et par la collection derrière.src/components/dashboard/CityField.tsx) est un « combobox » ARIA : flèches, Entrée pour choisir (sans envoyer le formulaire), Échap pour fermer la liste seulement ; chaque suggestion est un bouton de 44 px au moins, qui ne prend pas le focus du champ. Retaper après un choix reprend la commune. Les réponses qui arrivent dans le désordre sont jetées.tests/unit/dashboard-location.test.ts (U14, U15, U16, les règles), tests/unit/dashboard-city-reference.test.ts (la liste simulée : 200, 404, 500, réseau coupé, corps inattendu, service muet), tests/unit/direct-edit-zone-form.test.tsx (clavier, doigt, texte libre, panne), et sur le banc tests/integration/dashboard-location.test.ts. Pas d'essai de bout en bout : l'application du banc appellerait la vraie liste, qu'aucun test ne simule hors de son processus.Code postal, zone à revoir et étranger dans l'espace (tickets 265 et 266, PLAN-ZONES.md §3.3 et §3.4). La section « Ta zone » de /auth/profil et l'étape 2 de l'accueil emploient le même composant (src/components/dashboard/ZoneFields.tsx) : la question « Où travailles-tu ? », un champ unique et le lien « Je travaille à l'étranger ou dans plusieurs pays ».
findCities demande aussi codesPostaux) : cinq chiffres qui ne donnent qu'une commune la choisissent sans clic et l'annoncent dans role="status" ; un code partagé ouvre la liste (« Annequin (62) · 62149 ») sans rien choisir seul. La liste en panne garde le message d'avant et la commune enregistrée.cityFromCode), jamais ceux d'une requête : le schéma strict refuse un champ postalCodes. Une commune enregistrée sans codes postaux les reprend à l'enregistrement suivant.zoneReview, ticket 259) : un bandeau (ZoneReviewBanner, visible de la seule propriétaire) propose les communes du code postal partagé en boutons ; un appui enregistre et le serveur vide la revue. Sans code postal, il dit que la zone n'a pas pu être reprise et demande une commune ou un pays. Aucun texte de l'ancien site n'est affiché.strictLocationSchema est l'une des deux formes, { kind: 'city', codeInsee, … } (sans kind, une commune) ou { kind: 'countries', countries, town, … }. Une requête qui mêle codeInsee et countries, ou qui porte un champ réservé, est refusée entière. Le champ propose aussi les pays de la liste embarquée (filtre dans le navigateur, sans requête) ; les pays se choisissent en puces, 5 au plus, avec « Ville (facultatif) » quand un seul pays étranger est choisi, et chaque puce se retire par « Retirer Belgique ». La carte montre formatZone. saveLocation écrit l'une ou l'autre forme, et le profil devient publiable comme avec une commune.tests/unit/dashboard-city-reference.test.ts, dashboard-location.test.ts, dashboard-zone-choice.test.ts, dashboard-location-panel.test.tsx, sur le banc tests/integration/dashboard-location.test.ts, et de bout en bout tests/e2e/dashboard-zone.spec.ts (la fausse liste des communes du banc, aucune requête ne sort du poste).À chaque enregistrement d'un profil, quel qu'en soit l'auteur (la maquilleuse, un admin, l'import), le serveur calcule où il en est par la règle unique de la complétude, puis l'enregistre avec lui. Les règles sont dans src/lib/profile/state.ts, les champs dans src/collections/profiles/state.ts :
completeness : le score sur 13 ; active, publishable, indexable et fullyEmpty : les drapeaux de la règle ;activeSince, publishableSince, indexableSince et fullyEmptySince : pour chaque drapeau, la date à laquelle il a pris sa valeur. Elle ne bouge pas tant que le drapeau garde la sienne.La règle lit le profil comme la v3 le tient : la ville est le nom de la commune, qui a toujours son code INSEE (la règle n'a plus de limite de mots : « Pont de Montvert - Sud Mont Lozère » est lue telle qu'elle est écrite) ; une photo compte par son seul identifiant ; le prix chiffré d'une offre est son montant (priceFrom), qu'un texte sans montant (« Sur devis ») n'a pas ; le téléphone et l'email de contact ne comptent que si la maquilleuse les montre (publicContact).
Aucune API n'écrit ces champs : ce qu'une requête envoie dessus est ignoré, et le serveur les recalcule. Le score et les quatre drapeaux sont lus par tous ceux qui lisent le profil : ils ne disent rien qu'on ne voie déjà, et les pages publiques en ont besoin (noindex, sitemap, recherche). Les dates ne sont lues que par la propriétaire et un admin.
Le statut (status) est calculé lui aussi : aucune API ne l'écrit, pas même un admin. Il suit le choix de l'admin (chosenStatus, écrit par un admin seulement, lu aussi par la propriétaire) :
automatic, le choix d'un profil neuf : un profil entièrement vide est en brouillon, et passe à publié dès qu'il remplit une des quatre conditions, une prestation chiffrée ou une description de 100 caractères ; il repasse en brouillon s'il les perd toutes ;draft, published ou hidden : le statut reste celui-là, quoi qu'on enregistre. Un profil masqué ne redevient visible que par un admin. L'admin rend le statut à la règle en choisissant automatic ; la règle s'applique dès cet enregistrement.Seul un choix différent de celui qui est enregistré change le choix. Le formulaire de l'administration renvoie tous ses champs : chargé avant un enregistrement de la maquilleuse, il renvoie l'ancien statut, ignoré, et le choix qu'il a lu, inchangé ; il ne fixe donc rien. Le code serveur qui écrit status lui-même, règles d'accès levées (l'import, un jeu d'essai, un test), en fait le choix de l'admin.
Tests : tests/unit/profile-state.test.ts, et sur le banc tests/integration/profile-completeness.test.ts, qui écrit chaque cas partagé de la complétude comme l'import l'écrira (tests/support/completeness-cases.ts) et compare l'état enregistré à la règle et aux réponses de l'ancien site.
La liste de profiles montre, dans l'ordre : l'adresse, la commune, la complétude, « publiable », le statut et la dernière modification, la plus récente d'abord (defaultSort: '-updatedAt'). La dernière activité d'un profil est son dernier enregistrement (updatedAt) : derniereActivite est prévue sur le compte, pas sur le profil (docs/migration/SCHEMA.md, §9). La recherche du panneau lit l'adresse, le prénom, le nom, le nom d'artiste et la commune. Les filtres du panneau (publiable, indexable, entièrement vide, statut, statut choisi) lisent les drapeaux stockés à chaque enregistrement : ils ne calculent rien.
Une valeur qui n'est pas un choix est refusée avec une phrase, jamais ignorée ni répondue par une erreur de la base : un admin qui envoie un chosenStatus inconnu reçoit un 400 « Ce statut n'existe pas », et un filtre sur un statut inconnu (where[status][equals]=archive) un 400 « Ce filtre nomme un statut qui n'existe pas ». Ce que la maquilleuse envoie sur ces champs reste ignoré.
Masquer un profil (ticket 166) : l'admin ouvre la fiche, choisit « Masqué » dans « Statut choisi par l'admin » et sauvegarde ; la page publique répond 404 et le profil sort de la recherche, du plan du site et du sitemap en moins d'une minute (les pages gardent leur lecture sous l'étiquette du profil, qu'un enregistrement rafraîchit). Le choix « Automatique » le remet en ligne, dans le sitemap s'il est publiable. La rédaction ne change aucun statut. Le motif et le journal de la modération viennent avec le ticket S13, après la bascule.
Tests : tests/unit/profiles-status-filter.test.ts, sur le banc tests/integration/admin-profile-list.test.ts (les 12 profils du banc, le tri, les refus, le masquage), et tests/e2e/admin-profiles.spec.ts (E53, E56 sans journal).
La liste de pros montre, dans l'ordre : l'adresse, la création, « Adresse confirmée », « Bloqué », la dernière connexion et la présence d'un profil, le compte le plus récent d'abord (defaultSort: '-createdAt'). La dernière activité d'un compte est pour l'instant sa dernière connexion (lastSignInAt) : derniereActivite du plan n'existe pas encore (docs/migration/SCHEMA.md, §9). La colonne « Profil » lit le champ profil, un champ de jointure vers profils.pro : il n'a ni colonne ni migration, et seul un admin le lit.
Au-dessus du tableau, trois liens donnent le nombre de comptes « non confirmés », « sans profil » et « bloqués » et filtrent la liste (src/components/admin/AccountFilters.tsx, la règle dans src/lib/account/admin-filters.ts). Ce sont des filtres du panneau : ils se combinent avec les autres. Le panneau ne sait pas filtrer sur un champ de jointure (il plante), donc « sans profil » nomme les propriétaires de profils (where[id][not_in]), lus à chaque affichage ; l'API REST accepte aussi where[profil][exists]=false. Au-delà de quelques centaines de profils, l'adresse deviendrait longue : il faudrait alors une case stockée.
Bloquer (ticket 168) : l'admin ouvre la fiche du compte, coche « Bloqué » dans la colonne de droite et sauvegarde. Les sessions du compte sont fermées et sa connexion est refusée (ticket 089) ; décocher rend l'accès. La rédaction ne voit pas la collection et ne change aucun blocage.
Tests : tests/unit/admin-account-filters.test.ts, sur le banc tests/integration/admin-account-list.test.ts (les 20 comptes du banc, les trois filtres, le blocage par l'API) et tests/e2e/admin-accounts.spec.ts (la liste, le filtre, le blocage depuis la fiche, une capture à 390 px).
Une photo qui ne sert plus part à la corbeille du stockage, jamais à la suppression directe : l'application n'efface jamais un objet. La règle du déplacement est dans src/lib/trash.ts, le moment où une photo ne sert plus dans src/collections/trash.ts, et le détail au §8 de docs/migration/SCHEMA.md.
deleted/, à la racine du stockage : media/photo.webp y devient deleted/media/photo.webp, avec chacune de ses tailles dérivées. L'objet y est copié, la copie est vérifiée, puis l'original est retiré : c'est le seul retrait de l'application (src/lib/storage.ts), et un test échoue si un autre fichier en fait un.withTrash, dans src/payload.config.ts).where, est refusée en 403 avant toute écriture, par toutes les API, l'API locale sans contrôle d'accès comprise (oneAtATime) : Payload l'enregistrerait même si le stockage tombait pour l'un d'eux. La liste de l'administration n'offre donc plus de supprimer ni de modifier plusieurs lignes de ces deux collections. Seul l'import y échappe.S3_ENDPOINT absent), le ménage d'un profil ne supprime aucun média : leur objet ne pourrait pas bouger.restore (src/lib/trash.ts) remet un objet à sa place.[corbeille] par déplacement : combien d'objets, jamais leurs clés.Tests : tests/unit/trash.test.ts, et sur le banc tests/integration/media-trash.test.ts, où chaque requête de l'application au stockage est espionnée (tests/support/s3-spy.ts, voir docs/tests/banc.md).
Quand un profil, un article ou un talent est créé, modifié ou supprimé, le serveur dit à Next quelles pages publiques ne sont plus à jour : il les nomme par leur adresse (revalidatePath) et par leurs étiquettes (revalidateTag). La règle est dans src/lib/revalidate.ts, les crochets de Payload dans src/collections/revalidate.ts :
| Document | Pages rafraîchies | Étiquettes |
|---|---|---|
| Profil | /profil/<slug>, /search, /site-map, /sitemap.xml | profil:<slug>, profils |
| Article | /blog/<slug>, /blog, /site-map, /sitemap.xml | article:<slug>, articles |
| Talent | /talent/<slug>, / (l'accueil liste les talents), /site-map, /sitemap.xml | talent:<slug>, talents |
published, un article ou un talent publié. Un brouillon qui reste brouillon, un profil masqué qui le reste ne rafraîchissent rien. Quand l'adresse d'un document publié change, l'ancienne page et la nouvelle le sont toutes les deux.{ expire: 0 }), sans être servie périmée une dernière fois : un profil masqué ou des coordonnées retirées ne sont plus servis dès la visite suivante.TAGS) : celle de la liste pour plusieurs documents (la recherche, le sitemap, le plan du site, l'accueil), celle du document pour un seul. Depuis le ticket 117, la page d'un profil, celle d'un talent et celle d'un article gardent aussi leur lecture dans le cache (cachedProfileUrl, cachedTalent, cachedArticle, dans src/services/public/cached.ts), sous l'étiquette de leur document : une page qui n'a pas changé ne demande rien à la base, et un enregistrement la rafraîchit à la visite suivante, donc en moins d'une minute. Seul un document que le public voit est gardé : une adresse inconnue, un brouillon, un profil masqué ou une redirection sont relus à chaque visite, pour qu'une adresse inventée ne remplisse pas le cache. Depuis le ticket 269, le HTML de ces pages sort lui aussi du cache de Next (plus de force-dynamic, rien rendu au build, qui n'a pas de base : voir docs/seo/cache-des-pages.md). Essai : tests/e2e/page-freshness.spec.ts.docs/migration/IMPORT.md).Tests : tests/unit/revalidate.test.ts, et sur le banc tests/integration/revalidate.test.ts (I21), qui écoute Next par l'espion du cache (tests/support/cache-spy.ts, voir docs/tests/banc.md).
Deux cartes de /auth/profil s'éditent dans le panneau commun (tickets 125 et 126). Chacune a une règle pure, lue par le formulaire et par l'action serveur, et une fonction d'enregistrement qui suit le contrat de l'identité : session relue, origine contrôlée, profil trouvé par le compte, écriture avec les règles d'accès, valeurs rendues telles que la base les a gardées, message en français sinon.
src/lib/dashboard/networks.ts, save-networks.ts) : Instagram, Facebook, YouTube, LinkedIn et site web. normalizeNetwork range « @pseudo », un pseudo seul ou une adresse en une seule adresse https:// : @studio et https://www.instagram.com/studio/ donnent la même valeur (U28). L'adresse d'un autre site dans le champ d'un réseau est refusée sous le champ (E30) ; le site web accepte toute adresse web, jamais un @pseudo. Un champ vidé retire le réseau. Un réseau compte comme canal de contact pour la complétude. Une clé hors des cinq champs refuse la requête entière.src/lib/dashboard/contact-details.ts, save-contact-details.ts) : téléphone (20 caractères au plus, de 9 à 15 chiffres), email de contact, et son choix d'afficher les deux sur sa page. La date et la source du choix sont posées par le serveur (consentTrace) : la requête ne les porte pas, et le schéma strict refuse consentedAt. Choix coupé : la date est effacée, la page publique ne lit plus rien. Coordonnées masquées et aucun réseau : le profil n'est pas publiable. La carte dit « Oui, depuis le … » quand le choix est daté.src/lib/dashboard/bullets.ts, save-bullets.ts, src/components/direct-edit/blocks/BulletsBlock.tsx, tickets 127 et 128) : une seule liste de puces pour les deux. Le nom est rogné, jamais vide, 70 caractères au plus ; un doublon (même nom sans les espaces autour ni la casse) est refusé avec un message près du champ ; un « ; » termine un nom. Chaque puce a son identifiant (celui du serveur, ou un identifiant local jamais envoyé) : deux puces de même nom se retirent une par une. Après un retrait, le focus va sur la puce qui prend la place, sinon celle du dessus, sinon le champ (clavier) ou le titre du panneau (clic, doigt) ; chaque retrait est dit dans une zone role="status". Entrée maintenue n'agit qu'une fois. Le serveur refuse une puce neuve qui double une autre, mais garde les doublons déjà stockés (import) ; la description d'une compétence, que les puces ne montrent pas, est reprise de la ligne stockée. Le composant reçoit le nom de la liste (languages ou skills), pas ses messages : une page serveur ne peut pas passer de fonction au navigateur. Tests : tests/unit/dashboard-bullets.test.ts, tests/unit/direct-edit-about.test.tsx (L1 à L7, E30, E31), sur le banc tests/integration/dashboard-bullets.test.ts, et tests/e2e/dashboard-bullets.spec.ts.artistProfile lit aussi pour la règle d'accès des champs fermés au public (sans lui, Payload retirerait , et de sa propre page), puis le retire avant de rendre le profil.Tests : tests/unit/dashboard-networks.test.ts, tests/unit/dashboard-contact-details.test.ts, tests/unit/direct-edit-networks.test.tsx, tests/unit/direct-edit-contacts.test.tsx, sur le banc tests/integration/dashboard-networks-contact-details.test.ts, et tests/e2e/dashboard-networks-contact-details.spec.ts. Un groupe networks ou contactDetails envoyé à null vaut le groupe vidé, comme la commune (emptyGroupIfNull, src/lib/profile/contact-details.ts) : Payload répondait 500 (ticket 224, tests/integration/profile-contact.test.ts).
Trois cartes de /auth/profil s'éditent ligne par ligne dans un panneau commun (tickets 129, 130 et 131) : une liste de lignes, avec « Modifier » et « Retirer » sur chacune, un formulaire qui ajoute ou change une ligne, et « Enregistrer » qui envoie la liste entière. Les trois partagent un éditeur (src/components/dashboard/ListEditor.tsx) et une description par section (ListDefinition, src/lib/dashboard/list.ts) : experiences.ts, courses.ts et service-offers.ts disent les champs, ce qui est obligatoire, les bornes et la façon de lire et d'écrire une ligne stockée. Le formulaire et le serveur lisent ces mêmes règles, qui appellent celles de la collection (src/lib/profile/lists.ts) et du prix (src/lib/price.ts).
docs/qualite.md, §3) sont tenues par l'éditeur : clé d'affichage propre à chaque ligne (L1, L2), retrait de la ligne en cours (L3), focus après un retrait (L4), zone role="status" (L5), doublon refusé avec un message (L6), Entrée maintenue une fois (L7). Entrée maintenue garde ses retours à la ligne dans un texte long : EditingPanel ne retient plus les répétitions d'Entrée dans un textarea.src/lib/dashboard/save-list.ts, action dans lists-actions.ts) : schéma strict (les lignes, chacune avec ses textes, aucune autre clé : une requête ne peut pas porter un montant de prix), profil trouvé par le compte, liste écrite entière dans l'ordre envoyé, identifiants gardés par la collection. Les bornes valent pour chaque ligne ; l'obligation (entreprise, poste et début ; diplôme et école ; nom) ne vaut que pour une ligne neuve ou changée, pour qu'une ligne importée avec un champ vide ne bloque jamais les autres. Un échec rend un message en français et laisse la base telle quelle (E31).parsePrice et garde le texte ; le formulaire dit le montant retenu. Une prestation chiffrée remplit le critère « publiable » (U04). Les options d'une prestation (ticket 132) se saisissent dans le même panneau, sous la prestation ouverte par « Modifier » : chacune a son nom (requis), sa description et son prix, lus par les mêmes règles ; la clé d'affichage de chaque option est tirée à sa création, jamais de son contenu, donc taper dans ses champs, y compris juste après un enregistrement, ne déplace pas le focus (L2). Une option identique à une autre de la même prestation est refusée (L6), un retrait est annoncé et garde le focus (L4, L5), Entrée maintenue n'ajoute ni ne retire qu'une option (L7). Le serveur écrit les options reçues dans l'ordre, chacune gardant son identifiant (L1) ; une requête sans options garde celles qui sont stockées. Code : src/components/dashboard/OptionsEditor.tsx, src/lib/dashboard/service-offers.ts (SERVICE_OFFER_OPTIONS).Tests : tests/unit/dashboard-lists.test.ts, tests/unit/direct-edit-about.test.tsx, tests/unit/direct-edit-experiences.test.tsx, tests/unit/direct-edit-services.test.tsx (L1 à L7 jouées sur les trois sections) et, sur le banc, tests/integration/dashboard-lists.test.ts.
Les cartes « Photo principale » et « Portfolio » de /auth/profil s'éditent dans le panneau commun (tickets 134 à 136). Une photo ne passe jamais par une action serveur : le navigateur l'envoie à la route de médias de Payload (POST /api/media, où le ticket 133 contrôle le type, le poids et le nom), puis une action serveur nomme les photos sur le profil.
src/lib/dashboard/photo.ts pour les décisions, browser-photo.ts pour le canvas) : JPEG, PNG ou WebP lus dans les octets, 25 Mo au plus au choix, puis 2 000 px au plus sur le grand côté, WebP (JPEG si le navigateur ne sait pas encoder le WebP), 1 Mo au plus, par paliers de qualité. Les messages sont ceux du site en ligne, au mot près. Le réencodage retire l'EXIF.src/lib/dashboard/photos-upload.ts, avec ses trois issues séparées. Un fichier refusé ou une coupure du réseau pendant l'envoi : rien n'est enregistré, les fichiers déjà envoyés dans le geste sont supprimés (ils vont à la corbeille). Un « non » explicite du serveur : de même. Un appel au serveur qui se perd (résultat inconnu) : rien n'est supprimé, car l'enregistrement a pu aboutir ; la maquilleuse est invitée à recharger la page, et le rapprochement du ticket 137 voit les éventuels objets laissés. Session expirée : rien n'est supprimé non plus (elle n'en aurait pas le droit), le fichier envoyé est gardé pour le nouvel essai.save-photo.ts, action saveMyMainPicture) : le schéma strict ne prend qu'un identifiant. L'ancienne photo part à la corbeille par les crochets du profil (ticket 045), dans la même transaction.portfolio.ts, save-portfolio.ts, action saveMyPortfolio) : le serveur reçoit la liste entière des identifiants, dans l'ordre voulu, 10 au plus (U80), aucun deux fois. Les photos ajoutées se placent après celles qu'elle avait. Le panneau choisit plusieurs fichiers d'un coup ; un fichier refusé est écarté avec son nom et sa cause, sans perdre les autres ; le même fichier choisi deux fois est refusé (règle L6). Chaque photo est envoyée avec le texte alternatif « Photo du portfolio », et l'aperçu d'une ligne dit son rang (« Photo 2 sur 5 de ton portfolio »).role="status" par retrait, renouvelée quand la phrase se répète (L5), Entrée maintenue ne retire qu'une photo (L7). Sans objet pour une photo : L2 (aucun champ de saisie dans une ligne) et L3 (aucun formulaire de modification).Tests : tests/unit/dashboard-photo.test.ts, tests/unit/direct-edit-header.test.tsx (la photo principale), tests/unit/direct-edit-gallery.test.tsx et tests/unit/direct-edit-gallery-limits.test.tsx (le portfolio), sur le banc tests/integration/dashboard-photo.test.ts et tests/integration/dashboard-portfolio.test.ts (qui rejoue le rapprochement du ticket 137 : 0 orphelin), et tests/e2e/direct-edit-photo.spec.ts, tests/e2e/direct-edit-portfolio.spec.ts (une photo de 8 Mo faite pour l'occasion : tests/support/photo-8-mb.ts).
En tête de « Mon espace » (/auth/profil, issue 1073), OverviewStatus (src/components/dashboard/overview/) dit où en est le profil : le statut (visible de toutes, en brouillon, masquée par l'équipe avec son motif, en pause), « Ton profil est rempli à N % » (gaugePercent : le pourcentage des 13 critères, plafonné à 99 % tant qu'un point indispensable manque), et la liste des manques, chacun un lien vers son bloc de l'édition directe (editBlockHref(CRITERION_BLOCK[critère]), src/lib/dashboard/edit-blocks.ts), les points requis pour être publiable d'abord, puis ceux qui aident Google (« Pour apparaître sur Google » : description, prix ou galerie, facultatifs). profileProgress (src/lib/dashboard/progress.ts) ne calcule rien : il formule la définition unique (src/lib/profile/completeness.ts) lue par le même passage de champs que la sauvegarde du serveur ; un essai échoue si un critère est recompté ailleurs. Le bloc est rendu côté serveur.
Tests : tests/unit/dashboard-progress.test.tsx (U01, U02, U04 à U08), tests/unit/dashboard-overview.test.tsx et tests/unit/edit-blocks.test.tsx, sur le banc tests/integration/dashboard-progress.test.ts (I20 : le bloc égale l'état stocké) et tests/e2e/dashboard-overview.spec.ts.
tests/e2e/direct-edit-acceptance.spec.ts, jouée aux deux tailles : axe sur chacun des 15 blocs de l'édition directe, sur l'accueil en cinq étapes (E67) ; Échap ferme chaque bloc et rend le focus à son bouton (E68) ; la photo principale changée suit jusqu'au profil public et à son og:image (E37) ; zoom à 200 % (largeur de moitié, 320 px au plus bas) sans défilement de côté ni éléments qui se recouvrent. Les orphelins du stockage après la suite sont comptés par l'étape bench:reconcile --outside-import de la CI (ticket 137). Voir docs/tests/e2e.md.src/lib/dashboard/lock.ts) : Payload ne contrôle pas les versions et l'API locale ignore le verrou d'édition (overrideLock vaut vrai par défaut, vérifié dans Payload 3.90.2). Chaque enregistrement de l'espace passe donc par profileIdToWrite, qui refuse (LockedProfile) tant qu'un compte de l'administration tient un verrou de moins de 5 minutes sur le profil : la maquilleuse lit « Ton profil est en cours de modification par l'équipe, réessaie dans quelques minutes. », rien n'est écrit, sa saisie reste dans le formulaire. Le contrôle est le nôtre et non overrideLock: false, qui ne compare que le numéro du compte (une maquilleuse du même numéro que l'admin passerait). Test : tests/integration/dashboard-lock.test.ts.Il faut bun 1.3.14, Node 24 (.nvmrc), Docker avec Compose et openssl.
bun install --frozen-lockfile
.env (une seule fois)test -e .env || printf 'DATABASE_URI=postgres://mymakeup:[email protected]:5442/mymakeup_dev\nPAYLOAD_SECRET=%s\n' "$(openssl rand -hex 32)" > .env
Cette commande écrit les deux variables obligatoires de .env.exemple :
DATABASE_URI : l'adresse de la base locale de docker-compose.yml. Ces identifiants sont jetables et ne servent qu'à cette base.PAYLOAD_SECRET : un secret tiré au hasard par openssl, propre à ce poste.Elle ne touche pas à un fichier .env déjà présent. Sans ce fichier, l'application refuse de démarrer : elle nomme les variables qui manquent et s'arrête.
Le fichier .env n'est jamais versionné. Il ne doit jamais contenir l'adresse d'une base distante. Garde-fou : Payload ne pousse son schéma que dans une base de ce poste (voir « Configuration durcie »).
docker compose up -d --wait
PostgreSQL écoute sur 127.0.0.1:5442, donc sur ce poste seulement. Le port 5432 est laissé libre pour les autres projets.
Sans démon Docker, avec Podman : ajouter DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock devant chaque commande docker compose.
bun run dev
bun run admin:create <ton adresse> (voir « Créer un compte admin »).Le serveur de développement n'écoute que sur 127.0.0.1 : il n'est pas joignable depuis le réseau local.
Si le port 3000 est pris : PORT=3100 bun run dev.
docker compose down # arrête la base, garde les données
docker compose down -v # arrête la base et efface les données
L'application vérifie ses variables à chaque démarrage (bun run dev, bun run start, image Docker). S'il en manque une, ou si une valeur est refusée, elle s'arrête aussitôt et liste les variables à corriger. Le message ne montre jamais une valeur.
| Variable | Règle |
|---|---|
DATABASE_URI | Obligatoire. Une adresse postgres:// avec un hôte et un nom de base. |
DATABASE_POOL_MAX, DATABASE_POOL_TIMEOUT_MS | Facultatives (ticket 268). La taille du pool de connexions à la base (un entier de 1 à 50, 10 par défaut) et l'attente la plus longue d'une connexion libre (en millisecondes, de 1000 à 30000, 5000 par défaut). Au-delà de l'attente, la requête échoue avec une erreur franche et le pool sert les suivantes : jamais un blocage. Une valeur hors bornes, ou 0, arrête le démarrage. Pour un seul conteneur, les défauts suffisent ; la somme des pools de tous les conteneurs doit rester sous max_connections de PostgreSQL (100 par défaut). Mesures et cause de l'échec de la CI : docs/pool-connexions.md. |
PAYLOAD_SECRET | Obligatoire. 32 caractères au moins. |
PHOTO_UPLOAD_MAX_MB, PHOTO_PROFILE_MAX_COUNT, PHOTO_PROFILE_MAX_MB, PHOTO_WEBP_QUALITY, PHOTO_SIDE_MAX_PX | Facultatives (ticket 309). Le poids d'un envoi de photo avant conversion (1 à 50 Mo, 12 par défaut), le nombre de photos (1 à 500, 45) et le volume (1 à 2000 Mo, 60) qu'une maquilleuse garde, la qualité du WebP (40 à 95, 82) et le grand côté d'une photo rangée (800 à 4000 px, 2000). Toute photo acceptée est convertie en WebP, sans métadonnées ; les limites sont contrôlées côté serveur ; l'admin n'y est pas soumise. Une valeur hors bornes arrête le démarrage. Détail et raisons : docs/photos-limites.md. |
NEXT_PUBLIC_SITE_URL | Obligatoire en production (bun run start, image Docker), inutile pour bun run dev. Sans / final, et l'une de ces adresses seulement : https://my-makeup.fr, ou une adresse de ce poste comme http://localhost:3000. Aucun domaine temporaire n'entre dans le build : la production se construit directement pour https://my-makeup.fr. |
bun run build est la seule exception : il ne vérifie rien, sauf R2_PUBLIC_URL, et passe sans secret ni base.bun run payload, payload:types, payload:importmap, payload:migrate…) charge la même configuration : sans DATABASE_URI et PAYLOAD_SECRET valides, il s'arrête avec le même message. Le .env de l'étape 2 de « Démarrer en local » suffit.payload:types et payload:importmap n'ouvrent aucune connexion : la base peut être arrêtée. Sur un clone sans .env (la CI, par exemple), deux valeurs de remplissage passées dans l'environnement suffisent : une adresse postgres:// bien formée, sans base derrière, et 32 caractères quelconques.NEXT_PUBLIC_SITE_URL est publique. Présente au moment du build, même vide, elle est figée dans le build. Absente du build, elle est lue au démarrage.https://v3.my-makeup.fr, l'adresse provisoire de la prod avant la bascule, reste refusée dans NEXT_PUBLIC_SITE_URL : la prod est construite directement pour https://my-makeup.fr, aucun ticket n'ajoute d'adresse temporaire à DEPLOYED_ORIGINS (src/lib/env.ts).R2_PUBLIC_URL est publique. Elle est lue plus tôt que les autres, quand Next charge next.config.ts : une valeur refusée arrête aussi le build. Voir « Images ».NEXT_PUBLIC_APP_VERSION et SOURCE_COMMIT sont facultatives. Elles sont lues dans src/lib/env.ts sans être vérifiées : aucune valeur n'arrête le démarrage (voir « Route de santé »). next.config.ts lit aussi SOURCE_COMMIT, au moment du build, pour le figer dans le build : il le lit lui-même, par son nom. De src/lib/env.ts, il n'appelle que mediaOrigin, pour R2_PUBLIC_URL.SMTP_HOST, SMTP_PORT, EMAIL_FROM, SMTP_USER et SMTP_PASSWORD règlent l'adaptateur d'email de Payload. Elles ne se donnent jamais au build. Aucune connexion n'est ouverte au démarrage : un relais injoignable fait échouer l'envoi du mail, pas le démarrage. Sans identifiant, le relais est utilisé tel quel, sans chiffrement : cela ne convient qu'au capteur de mails du banc de test, où .env.test règle les trois premières.src/lib/emails/, ticket 073), passé comme dépendance : rend un . Le gabarit commun () écrit le texte et le HTML en français, avec un lien absolu construit depuis un chemin du site ; un lien qui sort du site, un sujet sur deux lignes ou un destinataire illisible sont refusés. Avec le contexte d'import (), rien ne part. Sans relais, rien ne part non plus et une ligne de journal le dit. Un relais en échec donne une à l'appelant, une phrase qu'on peut montrer ; le journal garde la nature de la panne, jamais l'adresse, le sujet, le lien ni le jeton. Un test d'intégration lit l'email dans le capteur du banc ().localhost, 127.0.0.1 ou ::1. Un nom de service Docker ou une adresse privée ne compte pas.src/migrations/, que Payload joue quand il démarre en production. bun run payload:migrate:create en écrit une, à relire avant de la versionner.init porte tout le schéma (ticket 049) : démarrée en production sur une base vide, l'application crée ses tables à son premier appel (voir « Image Docker »). Tout changement de collection vient ensuite avec sa propre migration, dans le même commit : la CI le vérifie (bun run schema:drift, voir docs/qualite.md, « La dérive de schéma »). Après bun run payload:migrate:create, passer bun run check : Biome remet en forme les fichiers écrits par Payload.docs/tests/banc.md, « Préparer la base du banc »).src/lib/pool-base.ts, ticket 222).listenToPool, src/lib/pool-base.ts, posé au démarrage de Payload) qui écrit une ligne courte avec le code de l'erreur, jamais son message ni l'adresse de la base. Pendant la panne /api/health répond 503, puis 200 au retour de la base, sans redémarrage (ticket 223).src/app/(front)/(dashboard)/auth/signin/actions.ts). Le jeton de session n'existe que dans le cookie payload-token (HttpOnly, SameSite=Lax, Secure en production, 14 jours) : aucune réponse de l'API, aucune page ne le porte.<RefreshSession />, src/components/dashboard/) la prolonge de 14 jours par POST /api/pros/refresh-token quand il en reste moins de 7, une fois par chargement de page, jamais depuis une page publique. Elle ne dépasse jamais 90 jours depuis son ouverture : le nouveau jeton est raccourci près du plafond (hook refresh de la collection pros) et un renouvellement à 90 jours est refusé en 403 (beforeOperation). Le cookie garde, lui, la date de fin de 14 jours : au-delà du plafond, c'est le jeton qui ne vaut plus rien. Tests : tests/unit/sliding-session.test.tsx (U67, U68), tests/integration/auth-sliding-session.test.ts (I16, horloge simulée), tests/e2e/sliding-session.spec.ts (E14, @lent)./auth/profil et /auth/apercu-profil sans cookie : une seule redirection 307 vers /auth/signin?callbackUrl=<page> (src/proxy.ts). /auth/init-account (l'accueil, supprimé le 27/10/2026) répond une 308 en un saut : vers /auth/profil avec un cookie, vers /auth/signin?callbackUrl=%2Fauth%2Fprofil sans. Avec un cookie qui ne vaut plus rien, la page elle-même fait la même redirection, et la connexion affiche son formulaire : une seule règle (signedInArtist) décide dans les deux sens, donc aucune boucle. La session d'un compte admin n'ouvre pas l'espace.closeSession, src/lib/auth/sign-out.ts). « Me déconnecter » ferme la session de l'appareil ; « Me déconnecter de tous les appareils » ferme toutes celles du compte, après une confirmation dans la page (/auth/profil), et jamais celles d'un autre compte. Les deux effacent le cookie, laissent une ligne au journal et mènent à l'accueil. Sans session, rien ne casse. La session d'un admin n'est pas touchée : le cookie porte le même nom, mais ce n'est pas une session de l'espace.pros.bloque, que seul un admin change) ne se connecte plus. Avec le bon mot de passe, la connexion le dit (code compte-bloque, « Ce compte est bloqué. Écris-nous depuis la page contact pour en savoir plus. », 403 par POST /api/pros/login) ; avec un mauvais mot de passe, c'est le message générique, comme pour une adresse inconnue. Bloquer vide les sessions du compte dans la même écriture : tous ses jetons sont refusés, sur tous les appareils, et les comptes des autres ne sont pas touchés. Débloquer rend l'accès. Le journal garde une ligne / par refus, et une ligne ou par changement de l'indicateur (). Tests : , . Le chemin de l'ancien mot de passe (ticket 087) et la connexion Google (090) devront aussi refuser un compte bloqué./auth/signup, ticket 082) demande un nom (libre, il peut être celui d'une autre maquilleuse), un email, un mot de passe de 8 caractères au moins (aucune règle de composition) et les conditions générales cochées. Les règles sont dans src/lib/auth/sign-up.ts, jouées par la page et par le serveur, avec un message en français par champ.src/lib/auth/sign-up-payload.ts) : le compte, son profil et l'adresse du profil, que le serveur fait à partir du nom, puis le mail de confirmation par le point d'envoi unique (src/lib/emails/). Si le mail ne peut pas partir, rien n'est gardé : elle réessaie avec la même adresse. Une adresse déjà utilisée ne révèle rien (ticket 083, ci-dessous)./auth/verification-wall) dit de regarder la boîte mail. Le lien du mail (/auth/verification-complete?code=…) confirme le compte : une fois. Le jeton est alors remplacé par la trace de son usage (un hachage), si bien que le même lien, suivi de nouveau, dit « déjà confirmé » et ne change rien ; un lien que personne ne connaît dit qu'il n'est pas valide (src/lib/auth/confirmation.ts). Un compte créé par un admin dans le panneau reçoit le même mail, avec le même lien.email-non-confirme) ; avec un mauvais mot de passe ou une adresse inconnue, elle répond comme toujours « Email ou mot de passe incorrect. », sans rien dire du compte./auth/profil, décision du 27/10/2026 : plus d'onboarding). Sa jauge, sa liste de ce qui manque et le bouton vers l'édition directe de sa page lui servent d'aide. Un compte venu sans profil en reçoit un vide, créé par le serveur (ensureProfile) à sa première visite de l'espace. L'ancienne adresse /auth/init-account répond une 308, en un saut, vers /auth/profil.AUTH_VERIFY est décrit dans « Variables d'environnement ». Le schéma de pros ne change pas avec lui : auth.verify reste actif, et off crée le compte déjà confirmé.Deux pages, aux chemins de l'ancien site : /auth/mot-de-passe-oublie (l'adresse) et /auth/reinitialiser (le nouveau mot de passe). Le lien « Mot de passe oublié ? » est sur la page de connexion, toujours visible.
pros.auth.forgotPassword, 24 heures, réglage MAIL_LINK_WINDOW_MS) mais n'envoie pas le mail : il part par le point d'envoi unique du site (payloadSendEmail), texte et HTML, avec le contenu de src/lib/emails/password-reset.ts. Comme Payload coupe son délai de 15 secondes par compte quand son envoi est coupé, la demande le garde elle-même (une écriture atomique sur resetPasswordRequestedAt) ; si le mail ne part pas, la place est rendue. Le lien vaut 24 heures et ne sert qu'une fois : deux envois du même lien au même instant, un seul passe (le lien est réclamé dans la transaction, la ligne verrouillée).after de Next). Elle est freinée par adresse cliente (10 par 15 minutes, chaque demande compte). Les portes REST natives de Payload (/api/pros/forgot-password, /api/pros/reset-password) répondent 403.src/proxy.ts répond une redirection vers /auth/reinitialiser et le garde dans le cookie mm-reset (HttpOnly, propre à cette page, 24 heures). La page dit « Ce lien n'est plus valable » pour un lien périmé, déjà servi ou venu de l'ancien site.auth-events) a les types forgot-password et password-reset.PasswordlessHelp), affichée à tout le monde sur la page de connexion, dit aux anciennes inscrites Facebook de passer par « Mot de passe oublié ».L'écran « créer le premier compte » de l'administration est fermé (POST /api/admins/first-register répond 403, même sur une base vide). Tant qu'aucun admin n'existe, et pour chaque nouveau compte, on passe par la commande ; une fois connecté, un admin peut aussi créer des comptes admin ou editor dans l'administration (le rôle par défaut est editor) :
bun run admin:create <email> # un admin, sur la base de DATABASE_URI
bun run admin:create <email> --role=editor # un compte rédaction
bun run admin:create <email> --remote-target # quand DATABASE_URI n'est pas sur ce poste
Le script porte --no-env-file (ticket 233) : il ne lit jamais le .env de travail, pour qu'un .env qui nommerait une autre base ne l'y envoie pas sans qu'on le voie. DATABASE_URI et PAYLOAD_SECRET viennent de l'environnement du terminal : en local, set -a; . ./.env; set +a (shell POSIX) les y met avant la commande.
Le mot de passe se tape au clavier, deux fois, sans s'afficher. Il n'existe aucune option, aucune variable d'environnement ni aucun tube pour le donner : hors d'un terminal, la commande refuse. Douze caractères au moins, aucune règle de composition. Elle n'affiche que l'identifiant et le rôle du compte créé, jamais l'adresse ni le mot de passe.
Elle écrit par l'API locale dans la base de DATABASE_URI, sans toucher au schéma : les tables sont celles que l'application a créées. Une base qui n'est pas sur ce poste est refusée sauf --remote-target, avant tout chargement. Elle refuse une adresse qui a déjà un compte, et deux saisies différentes.
Aucun compte admin de l'ancien site n'est importé : les trois comptes de l'ancien Strapi ne sont pas repris, ils se recréent ici. Le rôle admin est celui du propriétaire ; editor sert aux comptes qui n'écrivent que des articles et des talents.
Quand la base n'est pas publique (production, ticket 185), l'image déployée ne contient ni bun ni scripts/ : la commande ne peut pas s'y lancer. Elle se lance depuis un poste, par une ouverture temporaire de la base : dans Coolify, ressource de la base, « Make it publicly available » sur un port, ou un tunnel vers elle. Dans l'ordre : ouvrir, lancer DATABASE_URI=… bun run admin:create <email> --remote-target (la variable posée sur cette seule ligne, jamais dans un fichier), puis refermer l'ouverture aussitôt. La base est sensible : ne jamais laisser le port ouvert, et le pare-feu du serveur reste la seule autre barrière. Aucun accès SSH n'est configuré. Le ticket 185 reprend cette procédure pour la production.
Mot de passe admin oublié (ticket 272). Les portes REST POST /api/admins/forgot-password et /api/admins/reset-password sont fermées (403, comme pour pros) : aucun email ne part, quelle que soit l'adresse, et une adresse admin connue ne peut pas vider le quota d'envoi. Le lien « mot de passe oublié » de l'écran de connexion de l'administration ne marche donc plus. Pour remplacer un mot de passe perdu : un autre admin ouvre le compte dans l'administration et en saisit un nouveau ; s'il n'y a pas d'autre admin, on crée un nouveau compte par bun run admin:create <autre adresse> (même procédure, base non publique comprise), on s'y connecte, puis on supprime l'ancien compte. Chaque demande refusée laisse une ligne dans le journal auth-events (type et code, jamais l'adresse).
: , et en production, 8 heures (comme le cookie des maquilleuses).
Les tests : tests/unit/create-admin.test.ts (les règles et la commande), tests/integration/admins-roles.test.ts (l'écran du premier compte fermé, le frein), tests/integration/payload-locks.test.ts et la matrice d'accès (les verrous), et tests/e2e/admin-refuse-artist.spec.ts (E54 : une maquilleuse connectée n'ouvre pas /admin, et les verrous marchent pour les comptes du panneau).
/api/graphql et /api/graphql-playground répondent 404.NEXT_PUBLIC_SITE_URL et elle seule. Ailleurs, c'est ce poste : localhost et 127.0.0.1, sur le port du serveur.curl, un test) doit envoyer l'en-tête Origin d'une origine de confiance avec son cookie de session. Sans cet en-tête, le cookie est ignoré.Origin et avec Sec-Fetch-Site: cross-site : le cookie de session est ignoré pour cette page, et la personne y paraît déconnectée. Une adresse tapée, un favori et un lien du site lui-même ne sont pas concernés. Une page privée de l'espace (/auth/profil) traite cette personne comme n'ayant pas de session : une redirection vers /auth/signin?callbackUrl=…, où elle se reconnecte une fois (ticket 075)./api/og, l'image de partage de l'administration, est fermée.admin peut tout ; editor ne lit et ne modifie que son propre compte, sans pouvoir changer son rôle. Une session de l'administration dure 8 heures. Aucun visiteur ne crée de compte de l'administration : l'écran du premier compte (POST /api/admins/first-register) répond 403, sur une base vide comme sur une base pleine, et pour une maquilleuse connectée comme pour un visiteur. Le premier compte se crée par bun run admin:create (voir « Créer un compte admin »), jamais importés de l'ancien site. La connexion de l'administration (POST /api/admins/login) a le même frein par adresse que celle des maquilleuses ; comme pour les maquilleuses, le compte n'est jamais verrouillé (maxLoginAttempts: 0), pour qu'on ne puisse pas enfermer dehors le propriétaire en frappant à son adresse.payload-locked-documents) sont réservés aux comptes admin et editor : une maquilleuse et un visiteur reçoivent 403 en lecture, en pose, en changement et en retrait. Un verrou porte toujours le compte de la session, quel que soit le user du corps de la requête : la reprise en main d'un document par un second compte du panneau passe par là. Les règles sont posées au démarrage (src/lib/payload-locks.ts).Chaque réponse du site part avec sept en-têtes, sur toutes les adresses : pages, API, administration, fichiers, erreurs. Une seule sorte de réponse en est privée : la redirection 308 par laquelle Next répond lui-même à une adresse avec // (//blog), avant que le proxy ne la lise. Les autres redirections, celle du / final comprise, passent par le proxy et portent les sept en-têtes (relevé du 16/10/2026, ticket 228).
| En-tête | Valeur | Effet |
|---|---|---|
Strict-Transport-Security | max-age=31536000 | Pendant un an, le navigateur ne demande plus le site qu'en https. Ce domaine seulement : ni includeSubDomains, ni preload. Sans effet sur une adresse en http, donc sur ce poste. |
X-Content-Type-Options | nosniff | Un fichier est lu selon le type que le serveur annonce, jamais selon son contenu. |
Referrer-Policy | strict-origin-when-cross-origin | Un autre site apprend de quel site vient la visite, sans le chemin ni les paramètres. |
Permissions-Policy | caméra, micro, position, paiement et USB coupés | Le site ne s'en sert pas : ni une page ni un cadre inclus ne peut les demander. |
Content-Security-Policy-Report-Only | la politique de contenu | En mode rapport : voir ci-dessous. |
X-Frame-Options | SAMEORIGIN | Aucun autre site ne peut afficher une page du site dans un cadre (détournement de clic). L'aperçu de l'administration, de même origine, marche (ticket 271). |
Content-Security-Policy | frame-ancestors 'self' | La même interdiction, pour les navigateurs qui lisent la politique. Aucune autre directive n'est appliquée. |
X-Powered-By, mais Next ajoute encore ses en-têtes x-nextjs-* (x-nextjs-cache, x-nextjs-prerender, x-nextjs-stale-time) aux pages prérendues, aux fichiers de robots.txt et à l'optimiseur d'images : ils nomment Next. Les retirer est une décision de sécurité qui reste à Andy ; aucun test ne les interdit (relevé du 16/10/2026, ticket 228).frame-ancestors, ticket 271 ; sa copie en mode rapport garde cette directive, sans effet). Le navigateur écrit dans sa console ce qu'elle refuserait, et ne bloque rien. Aucun rapport n'est envoyé, ni au site ni ailleurs.bun run dev autorise en plus eval, dont React a besoin en développement.src/lib/security-headers.ts, et next.config.ts les pose sur /:path*. Un en-tête propre à certaines adresses s'ajoute dans next.config.ts, après cette entrée./_next/image ne va chercher une image distante que sur l'hôte des photos, en https. Toute autre adresse reçoit une erreur 400, sans qu'aucune requête ne parte.R2_PUBLIC_URL. Sans cette variable, c'est celui des photos du site en ligne.R2_PUBLIC_URL se donne au build et à bun run start, avec la même valeur : la politique de contenu garde l'hôte du build, et /_next/image relit la variable au démarrage.--build-arg R2_PUBLIC_URL=…). Dans Coolify, la variable doit être cochée « Build Variable » en plus de l'être pour l'exécution : sinon l'image porte la règle du site en ligne, et le navigateur comme l'optimiseur refusent les photos d'un autre compartiment. Vide, l'argument est ignoré : ce sont les photos du site en ligne./_next/image porte en plus la politique de Next pour les images, appliquée celle-là : ni script, ni cadre.curl -sI http://localhost:3000/ | grep -i -E 'strict-transport|x-content-type|referrer-policy|permissions-policy|content-security|x-powered'
bun --no-env-file test tests/unit/security-headers.test.ts
GET /api/health dit si l'application peut servir. C'est l'adresse que lisent Coolify, le contrôle de santé de l'image et la surveillance du site. Le chemin et les clés sont ceux du site en ligne.
| Cas | Réponse |
|---|---|
| La base répond | 200 et {"ok":true,"version":"…"} |
| La base ne répond pas dans les 2 secondes | 503 et {"ok":false}, sans aucun détail |
Cache-Control: no-store) ni indexée (X-Robots-Tag: noindex).[health] check failed: … qui se termine par un code : ECONNREFUSED (base arrêtée), timeout (pas de réponse en 2 secondes), PAYLOAD_INIT (Payload n'a pas démarré).version est le commit déployé, réduit à ses 7 premiers caractères. Il est cherché dans cet ordre :
NEXT_PUBLIC_APP_VERSION, lue au build : un libellé est affiché tel quel, un commit complet (40 caractères) est raccourci ;SOURCE_COMMIT donné au build (dans Coolify : « Include Source Commit in Build ») ;SOURCE_COMMIT donné au conteneur qui tourne, si le build n'en avait pas ;package.json.En local, sans ces variables, version est donc la version de package.json.
curl -i http://localhost:3000/api/health
bun --no-env-file test tests/unit/health.test.ts tests/unit/version.test.ts
Le Dockerfile construit l'image que l'hébergeur déploie : le serveur autonome de Next.js (output: 'standalone') sur Node 24, sans bun, sans sources et sans dépendances de développement.
L'image lance ce serveur autonome (node server.js). Sur ton poste, bun run start sert le même build avec next start : Next affiche alors « "next start" does not work with "output: standalone" configuration ». Cet avertissement est attendu : le site est servi normalement. bun run start reste la commande de ton poste, avec ses options -p et -H.
bun run image:build # construit l'image my-makeup:local
bun run image:check # la démarre contre une base jetable et vérifie le tableau ci-dessous
| Ce que l'image garantit | Comment |
|---|---|
| Elle se construit sans base ni secret | Le build ne reçoit que des variables publiques (liste ci-dessous). Les noms des variables d'exécution n'apparaissent ni dans l'historique de l'image ni dans sa configuration. |
| Elle tourne sans droits d'administration | Le serveur tourne sous l'utilisateur node (uid 1000), jamais root. |
| Elle joue les migrations une fois | Sur une base vide, le premier appel reçu démarre Payload, qui joue les migrations de src/migrations/. Aux démarrages suivants, rien n'est rejoué. |
| Elle dit si elle est saine | Son contrôle de santé interroge /api/health toutes les 30 secondes : « healthy » quand la base répond, « unhealthy » après 3 échecs de suite. Pendant les 60 premières secondes, un échec ne compte pas. |
| Elle redimensionne les images | sharp est dans l'image : /_next/image rend une image à la largeur demandée. |
| Variable | Donnée | Rôle |
|---|---|---|
NEXT_PUBLIC_SITE_URL | Au build (--build-arg, « Build Variable » dans Coolify) | L'adresse du site, figée dans le build. Une image construite sans elle est une image de test : le conteneur doit alors la recevoir au démarrage. |
SOURCE_COMMIT | Au build | Le commit construit : c'est la version de /api/health. |
R2_PUBLIC_URL | Au build (--build-arg, « Build Variable » dans Coolify) et au conteneur, avec la même valeur | L'adresse publique des photos : elle fixe les hôtes que l'optimiseur d'images accepte et la politique de contenu (voir « Images »). Absente, ce sont les photos du site en ligne. |
NEXT_PUBLIC_APP_VERSION | Au build, facultative | Un libellé de version, à la place du commit. |
DATABASE_URI, PAYLOAD_SECRET, S3_* | Au conteneur seulement | Jamais au build : dans Coolify, la case « Build Variable » reste décochée. |
ARG du Dockerfile, la liste de scripts/image/rules.ts, et .env.exemple..env.exemple (et à src/lib/env.ts). La garde de tests/ci/ refuse alors tout Dockerfile qui la nomme, et bun run image:check toute image qui la porte..dockerignore refuse tout, puis laisse passer ce dont le build a besoin. Un .env local, un dump de .migration/ ou l'historique git n'atteignent jamais le build. La liste est figée par un test de tests/ci/.Dockerfile sont celles de .nvmrc et de packageManager, chaque image étant figée par son digest. La garde C04 de tests/ci/ échoue si l'une d'elles s'écarte.bun run image:check [image] a besoin de la commande docker, et de rien d'autre. Il crée un réseau, une base PostgreSQL en mémoire sans port publié, démarre l'image deux fois, puis arrête la base. Il supprime tout à la fin, même en cas d'échec. Il ne lit aucun fichier .env.
| Variable | Effet |
|---|---|
IMAGE_CHECK_NAME | Préfixe des conteneurs et du réseau (défaut : mm-controle-image). À changer pour lancer deux contrôles en même temps. |
IMAGE_CHECK_PORT | Port de l'application sur 127.0.0.1 (défaut : un port libre). |
IMAGE_CHECK_COMMIT | Le commit donné au build en SOURCE_COMMIT : la version de /api/health doit en être les 7 premiers caractères. |
IMAGE_CHECK_PHOTOS | Le R2_PUBLIC_URL donné au build (défaut : aucun, donc les photos du site en ligne) : la politique de contenu doit nommer cet hôte et lui seul, et l'optimiseur doit refuser l'autre. |
Pour rendre son verdict vite, le contrôle demande au moteur de jouer le contrôle de santé de l'image toutes les 2 secondes. Podman refuse ce réglage : le contrôle suit alors le rythme de l'image, et dure un peu plus de 2 minutes.
Avec Podman, l'image se construit au format Docker, sinon Podman retire le contrôle de santé :
podman build --format docker --tag my-makeup:local .
DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock bun run image:check
Chaque pull request et chaque poussée sur v3 lancent .github/workflows/ci.yml : cinq jobs en parallèle, puis ci-ok, le seul check à exiger pour fusionner.
| Job | Ce qu'il lance |
|---|---|
qualite | Fichiers générés par Payload à jour, bun run typecheck, bun run ci:biome, bun run test:ci-guard |
unit | bun run test:unit |
build | bun run image:build sans secret ni base, puis bun run image:check |
integration | En trois lots côte à côte : bun run bench:up, puis bun run test:integration --shard=N/3 sur un tiers des fichiers : le banc de test, en CI comme sur un poste |
e2e | En six lots côte à côte : le Chromium de Playwright, bun run bench:up, puis bun run test:e2e --shard=N/6 sur un sixième des essais |
ci-ok | Rouge si l'un des cinq a échoué, a été annulé ou a été sauté |
Avant de pousser, les mêmes commandes se lancent en local : bun run check, bun run typecheck, bun run test, bun run test:ci-guard, et, banc démarré, bun run test:integration et bun run test:e2e.
La CI ne lit aucun secret, n'a aucun filtre de chemins et ne tolère aucun échec. Ces règles sont tenues par les garde-fous de tests/ci/ : voir docs/qualite.md, qui dit aussi comment ajouter un job ou une action.
renovate.json porte les règles que Renovate suivra pour proposer les nouvelles versions : versions exactes, 7 jours d'attente, majeures sur approbation, et une fusion automatique réservée aux petites mises à jour, derrière le check requis ci-ok. Payload, Next, Biome et Playwright ne sont jamais fusionnés seuls.
renovate.json de la branche par défaut : tant que main porte l'ancien site, c'est le sien qui s'applique, en sécurité seule.tests/ci/ fige les règles, et bun run renovate:validate demande à Renovate s'il sait lire le fichier.Le détail est dans docs/qualite.md, « Renovate » : ce que chaque règle décide, comment en changer une, et ce qui doit être réglé sur main avant la bascule.
Un changement qu'une visiteuse ou une maquilleuse peut remarquer a sa note de version, ajoutée dans le même commit : bun run notes:add ajout "Tu peux ajouter une vidéo à ta galerie.". Elle attend dans src/content/changelog.json.
Quand la CI d'une poussée sur main est verte, ci-ok compris, le workflow .github/workflows/release.yml réserve le numéro suivant (3.0.0, puis 3.0.1…), range les notes en attente dessous et pousse .release/version.json avec l'étiquette du numéro. Coolify surveille ce fichier pour déployer la prod, puis le job deployment attend que la prod serve la version (rouge au bout de 20 minutes). Personne ne publie à la main ; bun run release:publish --dry-run montre ce que ferait la prochaine publication, sans rien écrire.
Rien ne se publie avant la fusion de v3 dans main. Le détail, et ce qu'il reste à régler dans GitHub et Coolify : docs/publication.md.
| Commande | Rôle |
|---|---|
bun run dev | Serveur de développement, sur 127.0.0.1 seulement |
bun run build | Build de production |
bun run start | Sert le build de production avec next start. L'avertissement de Next sur output: standalone est attendu (voir « Image Docker ») |
bun run check | Biome : format, lint et tri des imports, avec écriture des corrections sûres |
bun run lint | Biome : lint seul, sans écriture |
bun run ci:biome | Biome en lecture seule, pour la CI |
bun run ci:canary | Écrit les quatre fautes du canari de la CI, sur une branche canari-… seulement (voir docs/qualite.md) |
bun run renovate:validate | Fait valider renovate.json par Renovate, en mode strict. Il lui faut docker (voir docs/qualite.md, « Renovate ») |
bun run typecheck | Génère les types de routes de Next, puis contrôle les types (tsc --noEmit) |
bun run payload:types | Régénère src/payload-types.ts après un changement de collection |
bun run payload:importmap | Régénère importMap.js après l'ajout d'un composant d'administration |
bun run payload:migrate | Joue les migrations de src/migrations/ qui ne l'ont pas encore été |
bun run payload:migrate:create | Écrit une migration à partir des collections, à relire avant de la versionner |
bun run schema:drift | Dit si les collections du code et la dernière migration décrivent le même schéma, sans base ni secret (voir docs/qualite.md, « La dérive de schéma ») |
bun run payload | Le CLI de Payload |
bun run test | Les tests qui tournent sans le banc de test : les tests unitaires |
bun run test:unit |
Biome se lance par ces scripts ou par bunx @biomejs/biome, jamais par npx biome : c'est un paquet homonyme.
bun run payload et les scripts payload:* ont besoin des deux variables obligatoires, donc du .env de l'étape 2 de « Démarrer en local » : voir « Configuration durcie ».
Les tests tournent avec bun test, lancé par les scripts ci-dessus. Pour un seul fichier : bun --no-env-file test tests/unit/test-guard.test.ts.
| Dossier | Contenu |
|---|---|
tests/unit/ | Tests unitaires, en *.test.ts, et tests de composants React, en *.test.tsx : ni base, ni réseau |
tests/integration/ | Tests d'intégration, en *.test.ts, contre la pile de test locale |
tests/e2e/ | Essais de bout en bout, en *.spec.ts |
tests/probes/ | Sondes du site en ligne, en lecture seule |
tests/ci/ | Garde-fous de la CI et de l'image Docker : chacun est joué sur le dépôt, puis sur un exemple fautif |
tests/fixtures/ | Données fictives et fichiers témoins |
tests/support/ | Outils partagés par les tests |
Tous ces dossiers existent. tests/probes/ ne contient aucun fichier *.test.ts ni *.spec.ts : les sondes se lancent par bun run probes, et leurs tests sont dans tests/unit/probes.test.ts.
bun test, lancé depuis la racine du dépôt, charge tests/support/environment-guard.ts avant tout fichier de test (bunfig.toml, section [test]). Si l'environnement permet de toucher autre chose que le banc de test local, la garde arrête tout avant le premier test. Son message nomme la variable en cause et ne recopie jamais une adresse complète.
Lancé depuis un sous-dossier, bun test ne charge pas la garde : bun lit le bunfig.toml du dossier où il est lancé, pas celui d'un dossier parent. Un outil de test qui ouvre lui-même une base appelle donc checkBase sur l'adresse qu'il va ouvrir, sans compter sur ce chargement.
| Variable | Règle |
|---|---|
APP_ENV | test (ou rien). prod est toujours refusé, toute autre valeur aussi (preprod comprise : il n'y a pas de préprod) |
DATABASE_URI | Hôte local, nom de base fini par _test, et aucun paramètre host ni hostaddr : il remplacerait l'hôte de l'adresse |
STRAPI_SOURCE_URI | La même règle que DATABASE_URI : la fausse base Strapi du banc, jamais la vraie |
BASE_URL, NEXT_PUBLIC_SITE_URL | Adresse locale. Jamais la prod, ni aucun sous-domaine de my-makeup.fr |
SMTP_HOST | Hôte local : le capteur de mails |
MAILPIT_URL | Hôte local : l'API du capteur de mails |
SMTP_*, EMAIL_*, MAILGUN_*, RESEND_* | Aucun domaine en my-makeup.fr : un expéditeur de test écrit depuis @example.test |
S3_ENDPOINT | Hôte local |
S3_BUCKET | Nom fini par -test |
GOOGLE_OIDC_ISSUER | Hôte local : le faux Google du banc, jamais Google |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | Une valeur qui porte le mot « factice », et jamais sans GOOGLE_OIDC_ISSUER : la connexion viserait Google lui-même |
Un hôte local est localhost, 127.0.0.1 ou ::1, ou le nom du service dans le réseau de la pile de test : postgres, mailpit, s3mock, fake-google. Une variable absente n'est pas vérifiée : les tests unitaires tournent sans aucune variable.
bun test lit tout seul le fichier .env, puis .env.test, qui l'emporte. Lancé sans --no-env-file sur un poste de développement, il tourne donc sous l'environnement du banc de test, et tout ce que .env.test ne règle pas vient du .env de travail : la garde vérifie aussi ces variables-là. Les tests ne dépendent jamais du .env de travail : les scripts passent --no-env-file, et c'est par eux qu'on lance les tests.
Une nouvelle variable qui désigne un service extérieur s'ajoute à la garde, avec son cas dans tests/unit/test-guard.test.ts.
Les tests d'intégration tournent contre six conteneurs jetables : PostgreSQL en mémoire, un capteur de mails, un stockage d'objets avec l'API S3, un faux Google, un faux Umami et une fausse liste des communes. Rien n'y est réel, et rien n'est gardé à l'arrêt.
Pour travailler dans le site local, sans écrire de test, quatre gestes (l'agent n'a plus besoin d'un compte en ligne) :
bun run bench:full # 1. monter : pile, base, jeu fictif, application sur http://localhost:3100 (premier plan)
bun run bench:session admin # 2. une session : fichiers dans .banc/sessions/<banc>/, le jeton n'est pas affiché
bun run bench:reset # 3. remettre à l'état de départ, en une dizaine de secondes (reprendre une session ensuite)
bun run bench:down # 4. tout arrêter et tout effacer
Le jeu : 12 profils types (comptes jeu-profil-*@example.test), 3 talents, 3 articles dont un brouillon, et un compte de chaque type du site ([email protected]…). L'administration en compte 19 profils : les 12 types et les 7 profils vides que le serveur crée avec les comptes types. Détail : docs/tests/banc.md, « Le banc complet ».
bun run bench:up # démarre le banc, rend la main quand les quatre services répondent
bun run test:integration # lance les tests d'intégration
bun run test:integration tests/integration/bench-base.test.ts # un seul fichier
bun run bench:down # arrête tout et efface les données
.env.test décrit le banc. Il est versionné, parce que toutes ses valeurs sont factices.bun run test:integration s'arrête avant le premier test et dit quoi démarrer.BENCH_INSTANCE=mon-banc BENCH_OFFSET=20 bun run bench:up.await createAccount('admin'), 'redaction', 'maquilleuse' (avec son profil vide), 'maquilleuse-sans-profil', ou 'maquilleuse-non-verifiee' (sans session).'maquilleuse-bloquee', 'reprise-avec-hash' (hash bcrypt d'un mot de passe fictif connu), 'reprise-google' et 'reprise-google-avec-hash' (session ouverte sans mot de passe, comme par Google), 'reprise-facebook'. Le jeu du banc en pose un exemplaire fixe de chaque type, sans doublon quand on le rejoue : await setDatasetAccounts() (tests/fixtures/accounts.ts).await placeDemoProfiles(payload) (src/demo-data/). Hors du banc, il refuse de tourner avant de rien lire ni écrire.tests/integration/payload-access.test.ts.await waitMail({ pour: adresse }).await dropObject(cle, contenu), await readObject(cle).bun run bench:source la crée, à l'identique à chaque fois. bun run bench:import --real y lance l'import ; un test le lance par importOnBench() (tests/support/import.ts).Le détail est dans docs/tests/banc.md : les services et leurs adresses, les deux variables d'instance, l'écriture d'un test d'intégration, la remise à zéro, le capteur de mails, le stockage d'objets, la fabrique de comptes et ses types, le jeu de comptes fixes, les 12 profils types, le faux Google et ses quatre scénarios, les images épinglées, la fausse base Strapi.
Un essai de bout en bout ouvre l'application dans un vrai navigateur, à 1440 px et à 390 px. L'application est construite pour la production, démarrée sur le port du banc et branchée sur lui ; elle ne lit aucun fichier .env.
bunx playwright install chromium # une fois par poste : le navigateur de Playwright
bun run bench:up
bun run test:e2e # tous les essais
bun run test:e2e tests/e2e/shell.spec.ts # un seul fichier
createAccount('admin'), puis context.addCookies(compte.session.cookies).violationsSevere(page) (tests/support/axe.ts) rend les violations d'accessibilité « serious » et « critical » de la page.test-results/essais/.my-makeup.fr arrête tout avant le premier essai.Le détail est dans docs/tests/e2e.md : écrire un essai, l'accessibilité, lire un échec, les garde-fous, les pièges.
Les sondes lisent le site en ligne comme une visiteuse anonyme, sans jamais rien y écrire : c'est la seule exception à « rien vers le site en ligne ». Les adresses ne changent pas avec la v3 : elles lisent aujourd'hui le site actuel.
bun run probes # https://my-makeup.fr
PROBES_TARGET=http://localhost:3100 bun run probes # une autre cible : le banc, un domaine temporaire
PROBES_STATS_TOKEN=… bun run probes # avec le jeton du service, R02 compare
PROBES_API=aucune bun run probes # Strapi est arrêté : plus de partie Strapi dans R05
/u) ou vers un autre hôte. Elle se vérifie avant la première requête.h1 dans le HTML du serveur, sans « undefined » ni « null ».PROBES_TARGET n'est lue que par les sondes : la garde des essais refuse toujours la prod..github/workflows/sondes.yml les lance sans aucun secret et écrit le rapport dans le résumé de l'exécution ; une sonde rouge fait échouer l'exécution, que le rapport de 07:30 lit par GitHub.Le détail est dans docs/tests/sondes.md : chaque sonde, la garde, lire le rapport, retrouver un profil rouge.
Aucun test n'appelle Internet. bun test, lancé depuis la racine du dépôt, charge tests/support/preload-outgoing-http.ts avant tout fichier de test (bunfig.toml, section [test]) : dès lors, aucune requête HTTP ne quitte le poste.
Un service extérieur se simule en une déclaration, dans le test :
simuler(http.get('https://referentiel.example.test/communes', () => HttpResponse.json([{ nom: 'Annecy', code: '74010' }])))
Une requête vers un hôte que rien ne déclare est refusée, et le test échoue avec un message qui nomme l'hôte, même si le code testé attrape l'erreur.
Les services du banc de test ne sont pas interceptés : ce poste, et les services de compose.test.yml.
Le code appelle un service extérieur par fetch : sous bun, c'est la seule sortie qui se simule.
Le détail est dans docs/tests/http-sortant.md : les gestes (panne, réseau coupé, service muet), ce qui passe, les deux couches et leurs limites.
Aucune licence n'est accordée pour l'instant : tous droits réservés.
tests/e2e/public-profile.spec.ts/profil/<segment>src/lib/profile/url.tsnoindexlegacyUsernamesrc/lib/revalidate.ts) ; le build n'en rend aucun. L'accueil vit sous src/app/(front)/(site)/home-cached/[key]/, où next.config.ts réécrit / (src/lib/urls/home-page.ts ; le proxy répond 404 à cette adresse interne). La cause du rendu à chaque visite, page par page, et la mesure en ligne : docs/seo/cache-des-pages.md. Tests : tests/unit/page-cache.test.ts, tests/e2e/page-cache.spec.ts.src/app/(front)/(site)/blog/ et src/components/blog/ : la liste du blog (ticket 107), tous les articles publiés du plus récent au plus ancien (jamais un brouillon), lus par src/services/public/article-list.ts sous l'étiquette articles ; /toutes-les-news redirige vers elle. Tests : tests/unit/blog-list.test.tsx, tests/integration/blog-list.test.ts, tests/e2e/blog-list.spec.ts (E64, U62).src/components/layout/ : le gabarit des pages publiques (Nav et menu mobile, pied de page, pages 404 et d'erreur), posé par (front)/(site)/layout.tsx. Il ne lit ni cookie ni session : les pages restent statiques. src/app/global-not-found.tsx répond la 404 d'une adresse qu'aucune route ne connaît, dans ce même gabarit.src/lib/markdown.ts : la seule conversion du markdown de la rédaction en HTML (markdownToHtml, remark puis rehype-sanitize) : le HTML écrit à la main dans le texte est jeté, un script ou un gestionnaire d'événement ne sort jamais, les titres # deviennent des h2 (le titre de la page reste le seul h1) et les images se chargent à la demande. Les articles (/blog/<slug>, ticket 106, lus par src/services/public/articles.ts, composants dans src/components/content/) et les talents l'appellent. Un brouillon ou un slug inconnu répond 404 en français. Tests : tests/unit/markdown.test.ts, tests/unit/blog-article.test.ts, tests/e2e/blog-article.spec.ts (E59, E65)./talent/<slug>, ticket 108, lue par readTalent dans src/services/public/talents.ts) : le titre en haut de la page (seo_title, sinon title) est le seul h1, puis la présentation, le contenu (même markdownToHtml que les articles), un lien vers la recherche et le fil d'Ariane en données structurées. L'onglet et les résultats de recherche prennent pageTitle, champ de la rédaction de 50 à 60 caractères et différent du h1 ; vide, ils gardent le titre court suivi du nom du site. Tests : tests/unit/talent-page.test.ts, tests/integration/talents.test.ts, tests/e2e/talent.spec.ts (E59, E65), I45 (tests/e2e/url-parity.spec.ts). Sur la copie importée, I45 exige aussi que l'onglet de chaque talent porte le titre écrit par la rédaction (50 à 60 caractères, différent du h1) : les 14 titres sont dans src/content/talent-titles.ts et l'import les copie dans pageTitle quand le talent n'en a pas (jamais par-dessus un titre saisi dans l'administration) ; un talent importé sans titre fait échouer I45./pourquoi-utiliser-my-makeup-en-tant-que-particulier, /solutions/pour-les-particuliers et les trois guides /particulier/trouver-une-maquilleuse, /particulier/centraliser-ses-recherches, /particulier/explorer-les-profils, plus la page de remerciement /demande-envoyee (en noindex, sans mesure). Les textes sont dans src/content/static-pages/client-guides.tsx et src/components/static-pages/ClientBenefits.tsx, mis en page par ClientGuide autour des blocs Hero et Cta de l'accueil : le titre de l'en-tête est le seul h1. Tests : tests/unit/client-pages.test.tsx, tests/e2e/client-pages.spec.ts, I45 (tests/e2e/url-parity.spec.ts)./a-propos, /maquilleuse/partenariats, /pourquoi-rejoindre-my-makeup-en-tant-que-maquilleuse et /solutions/pour-les-maquilleuses, sous src/app/(front)/(site)/. Les textes du jour du portage sont dans src/components/static-pages/artist-texts.ts, posés par PageArticle sous l'en-tête et le bloc de fin de l'accueil (Hero, Cta) ; l'en-tête porte le seul h1. Tests : tests/unit/artist-pages.test.tsx, tests/e2e/artist-pages.spec.ts (E64, I45)./sitemap.xml et /site-map (tickets 113 et 114) : le sitemap et le plan du site lisent les mêmes pages. Les pages fixes viennent d'une table explicite, STATIC_PAGES dans src/lib/seo/sitemap.ts (une page « à venir », comme /contact jusqu'au ticket 158, n'est ni dans le sitemap ni dans le plan) ; les profils indexables (publiables, avec une description et un prix ou une photo de galerie), les talents et les articles publiés viennent de src/services/public/indexable-pages.ts, lu par cachedIndexablePages sous les étiquettes profils, talents et articles. Le sitemap (src/app/sitemap.xml/route.ts) écrit une adresse absolue par page, sans //, avec la date réelle de dernière modification et sans changefreq ni priority ; une base en erreur donne un 500, jamais un sitemap raccourci. Le plan du site (src/app/(front)/(site)/site-map/page.tsx) a un seul h1, et lie les profils publiables par leur nom. Un test (tests/unit/sitemap.test.ts) échoue si une page de l'application n'est ni dans la table ni dans les exclusions (EXCLUDED_PAGES, EXCLUDED_PREFIXES), ou si une page de la table n'existe pas. Tests : tests/unit/sitemap.test.ts (U38 à U43), tests/e2e/sitemap.spec.ts (E62), I45.src/lib/search/ : ce que la page de recherche lit de l'adresse (input.ts, dont page et specialite, lu comme search quand search est vide), le classement par mot-clé (relevance.ts, porté de l'ancienne API : accents ignorés, une faute pour cinq lettres, tous les mots requis, mots vides et mots de la demande ignorés, poids normalisés), son assemblage avec la ville (results.ts), les pages de 20 (pagination.ts), la lecture bornée à 8 secondes (read.ts : un message et « Réessayer » plutôt qu'un écran blanc) et le frein (rate-limit.ts). Le texte tapé n'est ni journalisé, ni mesuré, ni dans une clé de cache : la recherche se fait en mémoire sur l'annuaire en cache (cachedDirectoryWithTexts). La mise en sections par lieu est dans place.ts (la ville, puis le département tapé, puis le même département, ceux qui se déplacent, le reste) ; un code postal tapé classe d'abord les profils dont la commune le porte (postalCodes, un code partagé trouve toutes ses communes), et la phrase des communes sous le titre vient des profils trouvés, sans appel au référentiel (ticket 263). Un profil de pays a pour place son libellé sans drapeau (directoryPlace, src/services/public/profiles.ts), n'a ni département ni code postal, et se déplace s'il a plusieurs pays ou un rayon. Le drapeau n'est rendu que par src/components/profile/ZoneText.tsx (aria-hidden), jamais dans un titre, une description, une donnée structurée ni la place cherchée (ticket 264). src/services/public/cached.ts : les lectures publiques gardées dans le cache de Next, sous l'étiquette de la liste qu'elles lisent (talents, profils). src/lib/urls/shape.ts : la forme d'une adresse (barre finale, //, /toutes-les-news), répondue par src/proxy.ts en un seul saut ; Next ne répond plus lui-même à la barre finale (skipTrailingSlashRedirect).src/lib/seo/ et src/proxy.ts : le socle SEO. Titre (60 caractères au plus), description (70 à 155), adresse canonique absolue sur https://my-makeup.fr sans barre finale, et consigne robots. src/proxy.ts pose X-Robots-Tag à chaque requête : noindex sur /auth et /search, qui ne sont pas bloquées dans robots.txt. Il n'y a plus de noindex sur tout un site selon APP_ENV : le noindex d'un hôte temporaire vient de l'hôte lui-même (réglage de l'hébergeur), pas du code. Une page noindex n'a pas d'adresse canonique. Jamais noarchive, nocache, nosnippet ni max-snippet : les assistants citent le site. /robots.txt vient d'une seule source, src/app/robots.ts (règles dans src/lib/seo/robots.ts) : tout est ouvert sauf /api/, avec l'adresse absolue du sitemap ; pas de fichier public/robots.txt.notFound() dans une page n'envoie au navigateur qu'une coquille vide (<html id="__next_error__">) et le flux de la page, sans le h1 de la 404 dans le HTML du serveur. Pour une adresse à un seul segment sous /profil, /talent ou /blog (GET ou HEAD), src/proxy.ts demande donc d'abord à src/services/public/exists.ts si la page existe, avec les mêmes lectures que la page ; le cache de Next ne garde rien quand il est lu depuis le proxy, donc le processus retient une minute les pages qui existent (jamais une adresse inconnue, existingPagesMemory) : une page qui existe n'ajoute aucune lecture de la base à ses visites suivantes ; si elle n'existe pas, il la réécrit vers la page 404 du site, rendue en entier, avec son h1, son titre, son statut 404 et noindex. Un profil dont l'adresse mène à une redirection (ancien nom, variante) passe jusqu'à la page. Une lecture qui échoue (base muette) laisse la page répondre. Les pages gardent leur notFound() pour ce que le proxy ne pouvait pas savoir. Règle des adresses vérifiées : src/lib/urls/unknown-page.ts. Tests : tests/unit/unknown-page.test.ts, tests/unit/proxy-unknown-page.test.ts, tests/e2e/page-404-h1.spec.ts.src/lib/seo/profile.ts : l'en-tête de la page d'un profil (ticket 105) : titre « Nom – spécialité à ville | My-Makeup » (60 caractères au plus), description, canonique ou noindex selon qu'il est indexable (publiable et assez riche pour Google), et les données structurées (ProfilePage → Person, offres en euros, fil d'Ariane), sans coordonnée, adresse postale ni note. Tests : tests/unit/profile-seo.test.ts (U47 à U50), tests/e2e/profile-seo.spec.ts (E64, E65).src/lib/seo/share.ts : l'aperçu d'un lien partagé (ticket 116). pageMetadata() en tire, pour toute page, og:title, og:description, og:url, og:type (profile pour un profil, article pour un article, website ailleurs), og:image absolue, et la carte Twitter. Une page en noindex les garde, sans adresse canonique. L'image est celle du site (/assets/og-my-makeup.jpg, 1 200 × 630) ; pour un profil, c'est sa photo principale passée par l'optimiseur d'images de Next (/_next/image?url=…&w=1200&q=75), jamais agrandie, de 300 Ko au plus. Tests : tests/unit/share-open-graph.test.ts (U51), tests/e2e/share-open-graph.spec.ts (E64 sur 12 adresses, poids et largeur de l'image).AboutBlock, issue 1016) enregistre par l'action saveMyDescription (src/lib/dashboard/save-description.ts). src/lib/dashboard/description.ts est la seule règle, lue par le formulaire et par le serveur : 2 000 caractères au plus (U27), tout HTML retiré (balises, scripts, styles, commentaires), sauts de ligne gardés, clé autre que description refusée (U32). Le compteur sous le champ compte par descriptionLength de la complétude (100 caractères pour apparaître sur Google, U05). Tests : tests/unit/dashboard-description.test.ts, tests/unit/direct-edit-about.test.tsx, tests/integration/dashboard-description.test.ts, tests/e2e/dashboard-description.spec.ts (E19-E29 description, E31, U05, U27).src/app/u/ : la mesure d'audience servie par le site (/u/script.js, /u/api/send), relayée vers Umami sans cookie ni Referer ; tout autre chemin sous /u répond 404. src/lib/analytics/ : le catalogue fermé des événements (events.ts), track(), seul chemin d'une page vers Umami, et le relais. Voir docs/mesure/umami.md.src/lib/providers.ts : la liste des prestataires de la politique de confidentialité (ticket 189), seule source de la page /politique-de-confidentialite. tests/unit/providers-policy.test.tsx la compare aux services configurés (hébergeur, instance Umami, stockage S3, relais SMTP, Google) : changer de service sans éditer la liste fait échouer la CI. La page donne aussi les délais que le code applique (7 jours pour un compte jamais confirmé, 30 jours avant l'effacement d'une photo retirée).signup_start (premier envoi du formulaire, ou clic sur le bouton Google de l'inscription), login_result (refus par email dans le formulaire ; réussite, email ou Google, sur la première page d'arrivée, d'après un cookie mm-sign-in-mark de deux minutes qui ne contient que « email » ou « google » et que la page dépense, voir src/lib/analytics/sign-in-mark.ts ; échec de Google sur la page de connexion avec le code affiché), auth_error (refus d'inscription, retour de Google en échec) et session_expired (une fois, à l'arrivée sur la connexion avec ?error=session-expiree, where = jwt_expire). Aucun email, nom ni jeton dans les propriétés. Test : tests/unit/analytics-auth.test.tsx.src/lib/dashboard/analytics.ts) : profile_save (une fois par réponse du serveur à un enregistrement de section, avec son résultat ok ; les verrous des panneaux font qu'une touche Entrée maintenue n'en donne qu'un), profile_publiable (sans propriété, seulement quand le serveur dit que cet enregistrement a fait passer le profil à publiable : withPublishableTransition dans src/lib/dashboard/publishable-transition.ts compare l'état avant et après, donc jamais dans l'autre sens ni pour un profil qui l'était déjà), upload_error (size, type ou server, la vraie cause du refus d'une photo, avant l'envoi ou par le serveur ; une session finie n'est pas un refus), profile_visibility (au clic sur « Voir mon profil public » ou « Modifier mon profil », ViewLink, jamais au chargement) et account_delete (seulement une fois le compte supprimé). Aucune propriété ne porte un texte saisi : le catalogue (src/lib/analytics/events.ts) n'accepte que des mots de listes fermées. Tests : tests/unit/analytics-dashboard.test.tsx (U82).onboarding_step et onboarding_source ne sont plus émis depuis le 27/10/2026 (le parcours guidé est supprimé) ; ils restent au catalogue, sans changer de nom. account_arrival dit qu'un compte ouvre son espace avec un profil encore entièrement vide (AccountArrivalEvent, sans propriété).src/lib/seo/indexnow.ts, src/app/api/indexnow/[key]/route.ts et scripts/seo/indexnow-all.ts : IndexNow (ticket 219), le fichier de clé servi à la racine par une réécriture de next.config.ts, l'envoi des adresses qui changent depuis src/collections/revalidate.ts, et bun run indexnow:all pour tout le sitemap (voir docs/seo/indexnow.md). Tests : tests/unit/indexnow.test.ts.src/app/api/stats/weekly/route.ts : les statistiques du rapport du lundi, des agrégats seulement, derrière le jeton STATS_TOKEN (voir docs/mesure/statistiques-hebdomadaires.md)./contact (src/app/(front)/(site)/contact/) garde les cinq champs du site en ligne, tous requis. L'action serveur vérifie le message (src/lib/contact/message.ts, les phrases et les limites du site en ligne), l'écrit dans la collection contact-requests (type contact, statut new, email pending), répond, puis envoie le mail à l'équipe après la réponse (src/lib/contact/payload-send.ts) par la porte d'envoi unique, avec l'adresse saisie en Reply-To. Le mail a un délai de 8 secondes : au-delà, ou si le fournisseur échoue, la ligne passe en echec et la visiteuse voit quand même la confirmation ; si l'enregistrement échoue, rien ne part et elle voit l'erreur, sa saisie gardée. Un message invalide ne crée aucune ligne. Seul un admin lit contact-requests ; aucune API ne la crée ni ne la supprime (l'écriture est celle du serveur, comme le journal d'authentification), et un admin n'en change que le statut (ticket 163). Aucun journal ne garde un champ saisi. Le formulaire envoie une seule fois (double clic, Entrée maintenue), annonce ses erreurs sous les champs et envoie platform_contact_submit une fois par réponse (ok : message enregistré). Tests : tests/unit/contact-message.test.ts, tests/unit/contact-send.test.ts, tests/unit/contact-form.test.tsx, tests/integration/contact.test.ts, tests/e2e/contact.spec.ts (I32, I33, E50, E51, E69, U76 adaptés). Ticket 161 : un champ piège (commentaire, hors de vue, hors du clavier, caché aux lecteurs d'écran) rempli fait répondre la confirmation habituelle sans rien écrire ni envoyer. Le serveur limite à 5 messages par heure et par adresse cliente (ipClient) et à 20 par jour et par boîte (forme canonique hachée, src/lib/contact/rate-limit.ts) ; seuls les messages qui passent la validation comptent. Passé les 5 de l’heure par adresse ou les 20 du jour par boîte, src/proxy.ts réécrit le POST vers /contact/throttled, qui répond un vrai 429 en français avec Retry-After ; le formulaire garde la saisie et dit la phrase. Pour la boîte, le proxy lit une copie du corps (champ email, avec ou sans le préfixe de React, 64 Ko au plus) sans rien prendre ; l’action reste la vérification qui compte les places, et elle refuse aussi avec la même phrase si le proxy n’a rien vu. Une requête sans X-Forwarded-For ou venue d’une adresse locale n’est jamais comptée par adresse (développement, banc), comme la recherche : cela suppose que l’application n’est joignable que par Traefik, et la limite par boîte reste seule à l’arrêter sinon ; les compteurs sont en mémoire du processus. Tests : tests/unit/contact-rate-limit.test.ts (U77), tests/integration/contact.test.ts (I34), tests/e2e/contact.spec.ts. La purge à 12 mois est le ticket 162 ; messages dans l'administration (ticket 163) : un admin lit contact-requests, marque un message answered ou spam (cycle new → forwarded → answered, tenu par la collection ; les autres champs sont figés, la date de réception comprise), voit au-dessus de la liste le nombre d'emails en échec avec le lien du filtre mailStatus = failed, et renvoie l'email d'un message en échec par le bouton « Renvoyer l'email » de sa fiche (POST /api/contact-requests/:id/resend, src/lib/contact/resend.ts) : le clic prend le message avant d'envoyer, donc un double clic n'envoie qu'un mail. La rédaction ne voit pas la collection. Tests : tests/unit/contact-statuses.test.ts (U78), tests/unit/contact-resend.test.ts, tests/integration/contact-admin.test.ts, tests/e2e/admin-messages-contact.spec.ts (E53, E55, E58). Ticket 224 : sans relais d'envoi (SMTP_HOST absent : un poste de développement, jamais le site), la porte d'envoi rend NO_RELAY au lieu de se taire, et la ligne reste echec au lieu de passer à transmise ; l'admin la renvoie une fois le relais posé. Un message resté en-attente plus de 15 minutes (le processus s'est arrêté entre l'enregistrement et l'envoi) passe à echec par la tâche quotidienne reprise-messages-contact-bloques (src/lib/tasks/stuck-messages.ts, âge compté depuis la dernière écriture de la ligne) et devient renvoyable. Tests : tests/unit/contact-send.test.ts, tests/unit/purge-messages-contact.test.ts, tests/integration/contact-retry.test.ts (horloge simulée).QUOTE_REQUESTS=on) : sur la fiche d'une maquilleuse publiée, qui montre un moyen de la joindre et n'est pas en pause, un formulaire à six champs (date, lieu, type de prestation, message, prénom, email, téléphone facultatif) et une case de consentement obligatoire. L'action serveur de la fiche (src/app/(front)/(site)/profil/[slug]/actions.ts, src/lib/quote/save.ts) vérifie tout, relit le profil (la page peut dater d'une heure), passe le même contrôle Turnstile que le formulaire de contact (passTurnstileGate, échec ouvert limité à 2 par heure et par adresse), un frein de 5 demandes par heure et par adresse et de 5 par jour et par boîte, et le champ piège. La demande est enregistrée avant l'envoi dans quote-requests (collection fermée à toute API : un admin la lit, le serveur l'écrit) ; le courriel part après la réponse à l'adresse du compte de la maquilleuse (objet fixe sans donnée de la cliente, réponse directe à la cliente) puis un accusé à la cliente qui ne reprend rien de ce qu'elle a écrit. Un envoi en échec ne perd rien : la ligne dit failed et la demande est dans l'espace. La maquilleuse la voit dans la carte « Demandes de devis » de /auth/profil (Nouvelle / Répondue, un bouton pour changer), lue par le profil de sa session. Conservation 6 mois, purge quotidienne, suppression à celle du compte. Le formulaire ne compte pas comme moyen de contact pour qu'un profil soit publiable. Tests : tests/unit/quote-request.test.ts, tests/unit/quote-form.test.tsx, tests/integration/quote-requests.test.ts, tests/e2e/quote-requests.spec.ts.src/lib/env.ts : les variables d'environnement, vérifiées au démarrage par src/instrumentation.ts.src/migrations/ : les migrations versionnées de la base.src/lib/auth/ : la connexion des maquilleuses. sign-in.ts (email et mot de passe, un code d'erreur et jamais le compte), legacy.ts, legacy-hash-payload.ts et equal-time-payload.ts (le mot de passe d'un compte de l'ancien site et le temps égalisé des refus, voir « Connexion et frein »), rate-limit.ts et client-ip.ts (le frein par adresse cliente, voir « Connexion et frein »), return-to.ts (la page de retour, interne seulement), session.ts (la garde des pages privées), session-state.ts (session valable, anonyme, expirée ou invérifiable : une panne ne déconnecte personne, et un jeton arrivé à sa fin renvoie une seule fois à la connexion avec « Ta session a expiré, reconnecte-toi. »), session-duration.ts (les règles pures), sliding-session.ts (les hooks Payload) et extend.ts (la durée de la session, voir « Connexion et frein »), closed-dashboard.ts et src/proxy.ts (la porte rapide : sans cookie, ou avec un cookie expiré), legacy-cookies.ts (les cookies de l'ancienne authentification, expirés 90 jours après la bascule, voir CUTOVER_DATE), forgot-password.ts (les règles pures du mot de passe oublié : adresse, nouveau mot de passe, jeton de 40 caractères, plan du lien pour src/proxy.ts, textes), password-reset-payload.ts (la demande, qui répond pareil et dans le même temps pour toute adresse et laisse partir le mail après la réponse, puis le choix du nouveau mot de passe, qui ferme toutes les sessions, voir « Mot de passe oublié ») et forgot-payload.ts (les portes REST natives de Payload fermées).POST, c'est l'action qui répond « session expirée »). Le formulaire du bloc (BlockForm) garde la saisie à l'écran, dit la session finie et propose un lien « Te reconnecter » dans un nouvel onglet ; la saisie n'est ni perdue ni stockée (l'ancien garde-fou guarded-input, qui la rangeait dans le sessionStorage, est retiré avec les panneaux, issue 1074). Tests : tests/unit/direct-edit-header.test.tsx, tests/e2e/direct-edit-header.spec.ts, tests/e2e/expired-session.spec.ts./auth/profil (AvailabilitySwitch), enregistré par saveMyAvailability (src/lib/dashboard/save-availability.ts, schéma strict d'un seul booléen dans src/lib/dashboard/availability.ts). L'interrupteur ne change qu'après la réponse du serveur ; un échec le laisse sur l'état enregistré et le dit ; touche maintenue ou double clic : une seule bascule. Indisponible, le profil sort de la liste et de la recherche (LISTED_FILTER) et la carte le dit ; une valeur absente (profil importé) se lit « Disponible ». Tests : tests/unit/dashboard-availability.test.tsx, tests/integration/dashboard-availability.test.ts, tests/e2e/dashboard-availability.spec.ts (E19-E29)./auth/profil (AccountDeletion) ouvre une fenêtre qui demande le mot de passe, ou l'adresse email pour un compte venu de Google ou de Facebook (src/lib/dashboard/deletion-confirmation.ts, la part commune au navigateur et au serveur). L'action serveur (deletion-actions.ts) lit la session, vérifie l'origine, puis deleteMyAccount (src/lib/dashboard/delete-account.ts) freine les mauvaises saisies (10 par 15 minutes, par adresse et par compte) et appelle la cascade commune, deleteAccountAndDependents (src/lib/account/deletion.ts, reprise par l'admin au ticket 167) : le profil, puis le compte et ses sessions, dans une seule transaction et sous une seule retenue de la corbeille. Panne du stockage : rien n'est supprimé. L'API garde delete fermé à la maquilleuse. L'admin supprime un compte depuis sa fiche dans l'administration (menu ⋮, « Supprimer », avec confirmation) : DELETE /api/pros/:id (src/lib/account/deletion-route.ts, ticket 167, réservé au rôle admin) appelle la même cascade, et les photos d'un compte sans profil partent aussi à la corbeille ; la suppression de plusieurs comptes à la fois est fermée, et l'API locale refuse toujours un compte qui a encore un profil. Tests : tests/integration/admin-account-deletion.test.ts (I37), tests/e2e/admin-account-deletion.spec.ts (E57). Tests : tests/unit/dashboard-deletion.test.tsx, tests/integration/dashboard-deletion.test.ts (I36, I38), tests/e2e/dashboard-deletion.spec.ts (E33).src/payload-types.ts et src/app/(payload)/admin/importMap.js : fichiers générés par Payload, versionnés, jamais modifiés à la main.docker-compose.yml : la base PostgreSQL de développement.Dockerfile et .dockerignore : l'image de production (voir « Image Docker »)..github/workflows/ci.yml et ci/required-checks.json : la CI et les checks requis (voir « Intégration continue »).renovate.json : les règles des mises à jour des dépendances, sans effet avant la bascule (voir « Mises à jour des dépendances »)..env.exemple : la liste des variables d'environnement, sans aucune valeur.tests/ : les tests. Un composant React s'y teste seul, sans navigateur : voir docs/tests/composants.md. Un service extérieur s'y simule, et aucun test n'appelle Internet : voir docs/tests/http-sortant.md.compose.test.yml et .env.test : le banc de test local et son environnement factice, voir « Le banc de test ».src/demo-data/ : le jeu fictif du banc de test, qui ne se charge nulle part ailleurs : les 12 profils types, leurs maquilleuses fictives et leurs photos, trois talents et trois articles dont un brouillon, voir « Le banc de test ».playwright.config.ts : les essais de bout en bout, voir « Les essais de bout en bout ». Il ne règle rien lui-même : tout vient de tests/support/e2e.ts.src/content/changelog.json : les notes de version, en attente puis publiées ; .github/workflows/release.yml : la publication d'une version, qui écrit .release/version.json (voir « Notes de version et publication »).procontactDetailspublicContactconsentedAtreadDashboardPhotos (src/lib/dashboard/photos.ts) lit les médias que le profil désigne, par ses identifiants et jamais par la requête.APP_ENV | Facultative. Où l'application dit tourner : test (le banc de test, .env.test) ou prod (le site). Absente sur un poste de développement. Toute autre valeur est refusée, preprod comprise : l'application ne démarre pas, et le message nomme la variable sans jamais montrer sa valeur. |
CUTOVER_DATE | Facultative. Le jour de la bascule depuis l'ancien site, au format AAAA-MM-JJ. Pendant les 90 jours qui suivent, src/proxy.ts expire les cookies de l'ancienne authentification (next-auth.*) à la première requête, et la page de connexion dit une fois à la maquilleuse de se reconnecter (ticket 081, src/lib/auth/legacy-cookies.ts). Absente, ils sont expirés sans date de fin, jusqu'au ticket 207 qui retire ce code. Illisible, elle arrête le démarrage. Le back-office, l'API et les fichiers ne sont pas touchés. |
PAYLOAD_DROP_DATABASE | Refusée si la base n'est pas sur ce poste : avec true, Payload efface toutes les tables. |
R2_PUBLIC_URL | Facultative. L'adresse publique des photos : https://, un hôte, sans chemin ni / final. Absente, c'est celle des photos du site en ligne, sauf sur le banc de test (voir plus bas). |
S3_ENDPOINT | Facultative. L'API S3 du stockage d'objets des photos : https:// et un hôte, ou sur ce poste http://127.0.0.1:PORT, sans chemin ni / final. Absente, aucune photo n'est reçue. Refusée si elle est sur ce poste alors que le site est déployé. |
S3_BUCKET | Obligatoire dès que S3_ENDPOINT est réglée. L'espace des photos : 3 à 63 caractères, minuscules, chiffres, points et tirets. |
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY | Obligatoires dès que S3_ENDPOINT est réglée. La clé d'accès au stockage et son secret. |
SMTP_HOST | Le relais SMTP par lequel partent tous les mails : un nom d'hôte seul, sans protocole ni port (Resend : smtp.resend.com). Facultative sur un poste et sur le banc : absente, l'application n'envoie rien et écrit une ligne de journal, sans adresse (y compris pour les mails propres à Payload : confirmation, mot de passe oublié), à la place de chaque mail. Obligatoire sur le site (https://my-makeup.fr en production), et refusée si elle désigne ce poste : l'application ne démarre pas. |
SMTP_PORT | Obligatoire dès que SMTP_HOST est réglée. Le port du relais, de 1 à 65535. |
EMAIL_FROM | Obligatoire dès que SMTP_HOST est réglée. L'expéditeur de tous les mails : Nom <adresse@domaine>, ou une adresse seule. |
SMTP_USER, SMTP_PASSWORD | L'identifiant du relais et son mot de passe, toujours ensemble (Resend : resend et une clé d'envoi limitée au domaine d'envoi). Obligatoires sur le site. Le port 465 est chiffré d'emblée ; tout autre port doit passer en TLS avant l'envoi de l'identifiant. Le mot de passe ne figure dans aucun message d'erreur. |
AUTH_VERIFY | Facultative. L'interrupteur d'exploitation de la confirmation d'email (ticket 082). Absente, vide ou on : un compte qui s'inscrit doit confirmer son email avant de se connecter, et le mail de confirmation part avec l'inscription. off : le compte est confirmé sur le champ, connecté aussitôt, et aucun mail ne part (le jour où le relais d'envoi est en panne ; sur un poste sans SMTP_HOST, c'est aussi la seule façon de s'inscrire). Toute autre valeur arrête le démarrage. Le build ne la lit pas. |
CONTACT_TO | Facultative. Qui reçoit les messages du formulaire de contact (ticket 158) : une adresse seule. Absente, c'est l'adresse publique de l'équipe sur le site (https://my-makeup.fr) et une adresse du domaine réservé aux tests ([email protected]) partout ailleurs : un poste et le banc n'écrivent à aucune vraie boîte. Une valeur illisible arrête le démarrage. |
TURNSTILE_SECRET_KEY, NEXT_PUBLIC_TURNSTILE_SITE_KEY | Facultatives, toujours ensemble (ticket 1000). La clé secrète et la clé de site du widget Cloudflare Turnstile du formulaire de contact. Réglées, le serveur fait vérifier le jeton du widget avant d'enregistrer un message ; absentes, aucune vérification chez Cloudflare (le champ piège et les freins restent). Si Cloudflare est injoignable, le message passe sous un frein plus serré (2 par heure et par adresse, 30 par heure en tout) : jamais d'erreur 500. Lues quand le serveur tourne, pas figées dans le build. Sur le site, une clé de test de Cloudflare arrête le démarrage. Voir src/lib/contact/turnstile.ts. |
STATS_TOKEN | Facultative. Le jeton de service de GET /api/stats/weekly, que lit le rapport du lundi : 32 caractères au moins (openssl rand -hex 32). Absente, la route refuse tout le monde. Jamais donnée au build. |
REMINDERS_SEND | Facultative (issue 1006). L'interrupteur des relances aux maquilleuses dont le profil est à une ou deux des quatre conditions d'être publiable (photo, ville, spécialité, contact). Seule la valeur on permet l'envoi ; absente, vide ou autre, aucun courriel ne part et la tâche quotidienne se contente d'écrire son rapport interne (la liste à blanc : combien de profils seraient relancés, lesquels par numéro, combien de comptes sont inactifs depuis 24 mois). Une faute de frappe ne coupe pas le site : elle éteint l'envoi. Plafond de 20 envois par jour, une relance par profil et par période de 90 jours, lien de désinscription en un clic dans chaque courriel. Lue quand le serveur tourne. Voir src/lib/tasks/reminders.ts. |
MEMBERS_MAIL_SEND | Facultative (issue 998). Un des quatre verrous de bun run members:mail : seule la valeur on (avec --send, --limit et --send-log) permet d'envoyer le courriel unique aux membres ; absente ou autre, la commande n'écrit qu'une liste à blanc. Lue par la commande seule, jamais par le serveur. Voir docs/membres-courriel.md. |
QUOTE_REQUESTS | Facultative (issue 1003). L'interrupteur du formulaire « Demander un devis » des profils. Seule la valeur on l'affiche (sur un profil publié, joignable et qui n'est pas en pause) et laisse le serveur accepter les demandes ; absente, vide ou autre, aucun formulaire et le serveur refuse tout envoi. L'allumer fait écrire le site à de vraies maquilleuses : à ne faire qu'à la main du propriétaire. Une faute de frappe éteint le formulaire sans arrêter le site. Lue quand le serveur tourne ; la page d'un profil, gardée jusqu'à une heure en cache, la prend à son prochain rendu. Voir src/lib/quote/ et src/app/(front)/(site)/profil/[slug]/actions.ts. |
INDEXNOW_KEY | Facultative. La clé IndexNow (ticket 219, docs/seo/indexnow.md) : 32 caractères hexadécimaux en minuscules (openssl rand -hex 16). Le site la sert à /<clé>.txt et prévient Bing et les moteurs du protocole quand une page publique change. Absente, rien n'est servi et rien n'est envoyé. Rien ne part ailleurs que du site (https://my-makeup.fr), ni pendant l'import. Une valeur illisible arrête le démarrage. Jamais donnée au build. |
STRAPI_SOURCE_URI | Facultative. La base Strapi que lit l'import de l'ancien site : une adresse postgres:// d'un rôle en lecture seule, sur le réseau interne de Coolify, jamais une base ouverte sur Internet. Elle est lue par bun run migration:import et par l'import que le conteneur déployé lance sur demande (voir IMPORT_TOKEN). Un secret, posé au runtime. Jamais la base de Payload. |
IMPORT_TOKEN | Facultative, sur le site seulement (NODE_ENV=production, APP_ENV=prod, NEXT_PUBLIC_SITE_URL=https://my-makeup.fr). Un jeton de 8 à 64 caractères (minuscules, chiffres, tirets, comme 2026-11-02-essai-1) fait lancer l'import de l'ancien site par le conteneur, une seule fois par jeton, quel que soit le mode : un passage arrêté prend un jeton neuf. Exige STRAPI_SOURCE_URI et le stockage des photos. Voir docs/migration/IMPORT.md, « En production ». |
IMPORT_MODE, IMPORT_DECISIONS, IMPORT_CONSENT_DATE, IMPORT_CITIES, IMPORT_TARGET_IN_SERVICE | Facultatives, avec IMPORT_TOKEN, sur le site seulement : une de ces variables ailleurs arrête le démarrage. IMPORT_MODE : dry-run (par défaut), real ou check. IMPORT_DECISIONS : la table de décision en JSON, des identifiants seulement. IMPORT_CONSENT_DATE : la date de bascule. IMPORT_CITIES : les réponses gardées du référentiel des communes, en JSON. IMPORT_TARGET_IN_SERVICE : oui seulement, jamais avec check. |
UMAMI_ORIGIN | Facultative. L'instance Umami vers laquelle le site relaie sa mesure d'audience, sous son propre chemin /u : https://HÔTE (un chemin est permis, jamais de paramètres). Absente, c'est l'instance actuelle sur le site (https://my-makeup.fr), et rien ailleurs : /u répond 404. Une instance de ce poste est refusée sur toute adresse déployée. Sur ce poste seulement, le faux Umami du banc, comme http://127.0.0.1:4200. Jamais donnée au build. |
NEXT_PUBLIC_UMAMI_DOMAINS | Facultative. Les noms d'hôte sur lesquels Umami compte les visites, séparés par des virgules. Absente, my-makeup.fr et www.my-makeup.fr : un build local ou une copie du site n'envoie rien. Lue à la construction des pages : donne-la au build. |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | Facultatives, ensemble : le client OAuth de la console Google. Sans elles, le site n'a pas de bouton Google. Des secrets : réglés par Coolify au runtime, jamais dans un fichier ni au build. L'adresse de retour à déclarer dans la console est <NEXT_PUBLIC_SITE_URL>/auth/google/callback. Sur le banc de test, .env.test les règle sur le client factice du faux Google. |
GOOGLE_OIDC_ISSUER | Facultative. Le fournisseur auquel parle la connexion Google. Absente, c'est Google : https://accounts.google.com. Sur ce poste seulement, l'adresse du faux Google du banc de test, comme http://127.0.0.1:7080, sans / final. Refusée sur le site. |
payloadSendEmail(payload, env)SendEmailcomposerEmailIMPORT_CONTEXTEmailSendFailedtests/integration/emails.test.tsS3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID et S3_SECRET_ACCESS_KEY règlent le stockage des photos (@payloadcms/storage-s3) : Cloudflare R2 sur le site, le simulateur S3 sur le banc de test, où .env.test les règle. Elles ne se donnent jamais au build. Sans elles, la collection media garde ses champs (le schéma ne dépend pas de l'environnement), mais une photo envoyée est refusée : rien n'est jamais écrit sur le disque du serveur.media/ pour une photo envoyée dans la v3), puis son nom de fichier. La base est R2_PUBLIC_URL ; sans elle, sur le banc de test, c'est le simulateur et son espace (http://127.0.0.1:9090/mymakeup-test), sinon l'hôte des photos du site en ligne. La règle est dans src/lib/media.ts. La route de fichier de Payload, /api/media/file/<nom>, ne lit jamais le disque : elle renvoie par une redirection (302) vers cette adresse, pour qui peut lire le média.docs/migration/IMPORT.md, « Les photos »).GOOGLE_OIDC_ISSUER fait prendre le faux Google du banc de test pour Google (voir « Le banc de test »). Une adresse locale est refusée dès que NEXT_PUBLIC_SITE_URL est celle du site, quel que soit le mode : l'application s'arrête au démarrage. Un build de production servi sur ce poste, comme pour les essais de bout en bout, l'accepte. La connexion Google (ticket 090) lit cette variable par serverEnv().googleIssuer, avec le client et le secret de GOOGLE_CLIENT_ID et GOOGLE_CLIENT_SECRET : son code est dans src/lib/auth/google/, ses deux adresses sont /auth/google (le départ, derrière le bouton) et /auth/google/callback.src/lib/env.ts et dans .env.exemple, par le ticket qui commence à la lire.connexioncompte-bloqueblocagedeblocagesrc/lib/auth/blocked-account.tstests/unit/blocked-account.test.tstests/integration/auth-blocked-account.test.tsGET /api/dashboard/session (un booléen, jamais mis en cache, sans accès à la base quand le cookie manque). Tant que la réponse n'est pas venue, elle montre « Me connecter ».Sec-Fetch-Site: cross-site sans Origin, donc le proxy répond à cette seule navigation (GET, cookie valide) par la page de relais du retour Google (src/lib/auth/relay-page.ts), qui repart en navigation du site. Le cookie d'un renouvellement de session finit 90 jours après son jeton, comme celui de la connexion, et le jeton n'est pas dans la réponse (extendUnderCap, src/lib/auth/session-cookie.ts). Le retour Google prend une place du frein par adresse (10 échecs par 15 minutes, comme la connexion ; au-delà, retour à la connexion avec « Trop d'essais, réessaie dans quelques minutes. », code trop-de-tentatives), et ses lignes retour-invalide et trop-de-tentatives au journal sont bornées à 3 par heure et par adresse. Un compte bloqué (CompteBloque) est écrit au niveau info par Payload, comme les autres refus normaux (un mauvais mot de passe, une adresse non confirmée et « Missing ID » aussi : sur le build de production les classes d'erreur de Payload sont renommées par la minification, donc le patch de payload lit le niveau par le constructeur, patches/[email protected]) ; l'administration n'affiche plus d'avatar venu de gravatar.com (admin.avatar: 'default'). Une adresse au pourcentage mal formé (/profil/%E0%A4%A) répond la page 404 du site, par le proxy (sous /api/, un 400 en JSON) ; une réponse à laquelle le proxy ajoute un cookie d'effacement est private, no-store. Tests : tests/unit/proxy-auth-findings.test.ts, tests/unit/pool-base.test.ts, tests/integration/auth-google.test.ts, auth-sliding-session.test.ts, auth-log-levels.test.ts, pool-delay.test.ts, et tests/e2e/private-dashboard.spec.ts, public-profile.spec.ts, administration-french.spec.ts, legacy-cookies.spec.ts./auth/** partent avec Cache-Control: private, no-store et X-Robots-Tag: noindex, nofollow (next.config.ts).trop-de-tentatives pour le formulaire, 429 pour POST /api/pros/login). Jamais par compte : le compte visé n'est pas verrouillé (maxLoginAttempts: 0). L'adresse est la dernière entrée de X-Forwarded-For, celle que le proxy a écrite ; si un autre relais de confiance se tient devant (CDN avec proxy actif), TRUSTED_PROXY_CIDRS (adresses ou plages séparées par des virgules) liste ses plages : elles sont sautées en lisant de droite à gauche. À renseigner avant la bascule si Cloudflare proxifie le domaine de l'application, sinon tous les visiteurs partagent une adresse ; une IPv6 compte par bloc /64. Les compteurs sont en mémoire, par processus : une seule instance de l'application. Aucune adresse n'est journalisée.auth-events, ticket 077) garde une ligne par connexion, réussie ou refusée : un type et une issue pris dans deux listes fermées (src/lib/auth/auth-log.ts), une durée en millisecondes et une date. Aucun champ de texte : ni adresse IP, ni email, ni nom, ni jeton n'y entrent, même glissés dans la saisie. Seul un admin le lit, aucune API ne l'écrit (l'admin non plus) : c'est le serveur, hors de la transaction de la connexion, si bien qu'une écriture qui échoue ne fait jamais échouer la connexion. Le formulaire écrit sa ligne dans signInWithPassword, la porte REST (POST /api/pros/login) dans les hooks de pros (src/lib/auth/auth-log-payload.ts) : jamais deux lignes pour une tentative. Le compte garde aussi la date de sa dernière connexion (pros.lastSignInAt).src/lib/auth/legacy.ts). L'import copie le hash bcrypt de l'ancien site dans pros.legacyBcrypt (champ caché, qu'aucune API ne lit) et donne au compte un mot de passe Payload tiré au hasard. Quand Payload refuse la connexion du formulaire, signInWithPassword compare le mot de passe saisi à ce hash (bcryptjs) ; s'il correspond, le mot de passe est écrit au format de Payload, legacyBcrypt passe à null dans la même écriture, et la maquilleuse est connectée : le journal écrit le code heritee. Un mauvais mot de passe ne touche à rien. La porte REST native (POST /api/pros/login) ne connaît pas ce chemin : un compte hérité y reçoit un refus et passe par le formulaire. Un mot de passe choisi autrement (défini par un admin, lien de réinitialisation) efface ce hash (legacy-hash-payload.ts) : l'ancien mot de passe ne marche plus et ne remplace jamais le nouveau.REFUSAL_FLOOR_MS). La porte REST (POST /api/pros/login) fait de même par les hooks de pros (src/lib/auth/equal-time-payload.ts) : le travail de Payload pour une adresse sans compte, puis le plancher sur un refus. Une connexion réussie n'attend pas ; une saisie vide ou trop longue est refusée tout de suite, sans plancher (elle ne dit rien d'un compte). Le test de durée (U73) pilote une horloge simulée : aucune attente réelle. Un test (tests/unit/legacy-sign-in.test.ts) lit la source de Payload et échoue si ses paramètres PBKDF2 changent.src/lib/tasks/, ticket 077) : une liste de purges, DAILY_TASKS, lancée une fois par jour par le serveur de production (la première fois 5 minutes après le démarrage, jamais par un build, un script ni un test). La première purge supprime les lignes du journal de plus de 90 jours. La purge des messages de contact (ticket 162) supprime ceux reçus il y a plus de 12 mois de calendrier ; elle ne fait rien tant qu'un import tourne dans le même processus (importEnCours, src/lib/import-context.ts) et reprend le lendemain. Une tâche s'ajoute à la liste, reçoit l'horloge, est idempotente et ne compte que des lignes. Une seule instance de l'application les lance. La tâche relances-profils (issue 1006, src/lib/tasks/reminders.ts) dresse chaque jour la liste des profils publiés ou en brouillon qui manquent d'une ou deux des six conditions du profil publiable (règle unique de completeness.ts) et la liste des comptes sans activité depuis 24 mois de calendrier, puis écrit un rapport interne dans reminder-reports (des nombres et des numéros de profil, jamais un nom ni une adresse, lu par un admin seulement, supprimé après 92 jours). Elle n'envoie rien, ne masque rien et ne supprime rien tant que REMINDERS_SEND n'est pas on ; allumée, elle envoie au plus 20 courriels par jour, un par profil et par période de 90 jours, avec un lien de désinscription (GET /api/reminders/unsubscribe?token=…, jeton signé, sans connexion, un clic). Tests : tests/unit/reminders.test.ts, tests/integration/reminders.test.ts.src/lib/tasks/, ticket 077) : une liste de purges, DAILY_TASKS, lancée une fois par jour par le serveur de production (la première fois 5 minutes après le démarrage, jamais par un build, un script ni un test). La première purge supprime les lignes du journal de plus de 90 jours. La purge des messages de contact (ticket 162) supprime ceux reçus il y a plus de 12 mois de calendrier ; elle ne fait rien tant qu'un import tourne dans le même processus (importEnCours, src/lib/import-context.ts) et reprend le lendemain. Une tâche s'ajoute à la liste, reçoit l'horloge, est idempotente et ne compte que des lignes. Une seule instance de l'application les lance. La purge des demandes de devis (issue 1003, purge-demandes-devis, src/lib/tasks/quote-requests.ts) supprime celles reçues il y a plus de 6 mois de calendrier, et celles dont le profil n'existe plus. La purge du journal de modération (issue 1005, purge-moderation-events, src/lib/tasks/moderation-events.ts) supprime les lignes datées de plus de 60 mois de calendrier (5 ans) ; elle ne touche jamais une ligne plus récente.createRateLimit({ nom, limite, fenetreMs }) (src/lib/auth/rate-limit.ts) avec ipClient(headers). L'inscription, l'oubli de mot de passe, la recherche et le contact prennent chacun leur frein.docs/limite-memoire.md) : le lecteur de corps multipart de Payload coupe un fichier à PHOTO_SIZE_MAX (10 Mio, constante unique de src/lib/photo-upload.ts) et une requête à 11 Mio, en répondant 413 avec la phrase de taille du site ; src/proxy.ts (src/lib/upload-guard.ts) répond 403 à un envoi vers /api/media sans cookie de session, et 413 à une longueur annoncée trop grande, avant que Payload lise le corps. La limite de mémoire du conteneur (2 Gio proposés) se pose dans Coolify, par Andy.src/lib/search/rate-limit.ts, joué par src/proxy.ts : une page ne peut pas répondre 429) : 60 requêtes par minute et par adresse sur /search, puis un 429 en français avec Retry-After. Aucune autre page n'est freinée (accueil, profils, blog : pages en cache). Un préchargement n'est pas compté, ni une requête qui n'est pas passée par le proxy : sans X-Forwarded-For, ou avec l'adresse de bouclage (127.0.0.1, ::1) que Next y écrit quand personne ne l'a fait (développement, banc, essais de bout en bout). Comme celui de la connexion, il est en mémoire du processus.SIGN_UP_FLOOR_MSAUTH_VERIFY=offsrc/lib/auth/resend.ts) : un champ email, une réponse identique pour un compte en attente, un compte confirmé et une adresse inconnue. Un mail par minute et 5 par jour pour une boîte (la forme canonique de l'adresse, voir plus bas : les variantes Gmail comptent pour une), plus 10 demandes par heure et par adresse cliente. Un compte en attente reçoit son lien de confirmation, le même ; un compte confirmé reçoit « tu as déjà un compte » ; une adresse inconnue ne reçoit rien. Les freins sont dans src/lib/auth/sign-up-rate-limits.ts, en mémoire comme les autres.src/app/(front)/(dashboard)/auth/signup/throttled/route.ts, laquelle répond un vrai 429 avec Retry-After et la phrase en français (le formulaire la montre sous ses champs) ; le refus est écrit au journal, au plus 3 lignes par heure pour une adresse et un motif ; un champ piège (commentaire, hors de vue, hors du clavier, caché des lecteurs d'écran) dont le remplissage donne l'écran de réussite sans créer de compte ni envoyer de mail ; les variantes Gmail (points, +étiquette, googlemail.com) sont une seule boîte (src/lib/auth/canonical-email.ts, src/lib/auth/account-by-email.ts) et se traitent comme « déjà pris » ; et une purge quotidienne (src/lib/tasks/unconfirmed-accounts.ts) supprime au bout de 7 jours les comptes jamais confirmés et leur profil vide, jamais un compte importé (strapiId ou legacyProvider renseigné) ni un profil rempli. Les refus laissent une ligne au journal avec leur motif (champ-piege, trop-de-tentatives, email-deja-pris), sans adresse IP ni email. L'inscription d'une adresse neuve prend, comme le mail « tu as déjà un compte » d'une adresse prise, une place dans le frein par boîte (1 par minute, 5 par jour) : un renvoi demandé juste après répond pareil dans les deux cas. Un compte pas encore confirmé qui s'inscrit de nouveau reçoit son lien de confirmation, pas « connecte-toi ». Ticket 224 : seule l'erreur d'unicité de Payload (ses deux phrases, en français et en anglais) vaut « adresse déjà utilisée » ; une autre erreur de Payload sur l'email (sa forme) répond email-invalide, avec la phrase du champ sous le champ (SIGN_UP_MESSAGES.email.invalide) et le motif donnees-invalides au journal.SecureHttpOnlySameSite=LaxeditoradminhiddenExceptAdminsrc/lib/access.tseditor/admin/accounttests/unit/access.test.tstests/e2e/admin-editor.spec.tspros) n'entre jamais dans l'administration. Elle ne lit et ne modifie que son propre compte, sans pouvoir changer ses champs réservés (bloqué, vérifié, fournisseur d'origine, identifiant de l'ancien site). Aucun anonyme ne crée de compte par l'API REST, même sur une base vide. Le mot de passe, le hash hérité de l'ancien site et les sessions ne sortent jamais, pas même pour un admin : Payload rend la liste des sessions toujours vide. L'email d'un compte est rangé en minuscules : deux adresses qui ne diffèrent que par la casse sont le même compte.profiles), avec un slug unique calculé par le serveur à partir du nom du compte (src/lib/slug.ts) : -2, -3 pour les homonymes, maquilleuse-<identifiant> pour un nom qui ne donne rien. Le slug ne change plus ensuite, et aucune API ne l'écrit. Une création qui porte NO_PROFILE_CONTEXT (src/collections/Profiles.ts) laisse le compte sans profil : c'est ce que fera l'import pour les comptes qui n'en ont pas. Une maquilleuse ne modifie que son propre profil ; un compte sans profil ne modifie rien. Par l'API locale, un compte qui a encore un profil n'est pas supprimé (refus 409) : le profil part d'abord. Un admin qui supprime un compte (DELETE /api/pros/:id, ticket 167) supprime tout d'un bloc : le profil, le compte, ses sessions, et ses photos vont à la corbeille du stockage (src/lib/account/deletion.ts).draft (celui d'un profil neuf), published ou hidden. L'anonyme et le rôle editor ne lisent que les profils publiés ; une maquilleuse lit le sien, quel que soit son statut, et les profils publiés. Le serveur calcule lui-même le statut : brouillon pour un profil entièrement vide, publié sinon, à moins qu'un admin n'en ait choisi un autre (voir « État d'un profil »). Ce que lit le public d'un profil est son identité et le badge « Super maquilleuse », jamais le compte relié, ni ce qu'a écrit l'import (strapiId, legacyUsername), ni ce qu'a décidé un admin. Seul un admin écrit le choix du statut, le motif, la vérification et sa date, le SIRET, la note interne, le score et le badge : ce qu'une maquilleuse envoie sur ces champs est ignoré. Elle lit son statut, son motif, sa vérification et son SIRET, jamais la note interne ni le score.contactDetails : téléphone et email de contact) ne sont lues que par la propriétaire et un admin, jamais par le public ni par le rôle editor, même quand elle a choisi de les montrer (publicContact) : une page publique les lira par src/services/public/. Quand publicContact devient vrai, le serveur pose lui-même la date (consentedAt) et la source du consentement (consentSource : artist ou admin, selon le compte qui enregistre) ; il les vide quand il redevient faux. Aucune API ne les écrit. Un téléphone de plus de 20 caractères ou un email mal formé est refusé, avec le message de l'ancien site. Un lien de réseau qui nomme un protocole autre que http ou https (javascript:, data:) est refusé : la page publique en fera un lien.media) sont des objets du stockage, chacun sous son propre préfixe. Le public ne liste jamais les médias : il lit un média seulement s'il est la photo principale ou une photo de la galerie d'un profil publié ; un autre est introuvable (404). Une maquilleuse envoie ses photos, liste et lit les siennes, ne modifie et ne supprime que les siennes ; le serveur la pose comme propriétaire (owner), quel que soit le propriétaire que dit sa requête, et ce que dit une requête sur l'objet lui-même (nom, préfixe, adresse, type, taille) est ignoré. Un profil n'accepte que des photos de son compte : une photo principale et une galerie ordonnée de 10 photos au plus. Le propriétaire, l'identifiant et les formats de l'ancien site ne sont pas publics. Un document demandé par un identifiant qu'aucun document ne peut porter (/api/media/abc, /api/pros/99999999999) est refusé (403) avant toute requête à la base, dans toutes les collections.talents, les pages /talent/<slug>) sont écrits par la rédaction, c'est-à-dire les rôles admin et editor : créer, modifier, publier, dépublier, supprimer, lire les brouillons et les versions. Tout autre visiteur, maquilleuse connectée comprise, ne lit que les talents publiés, même en demandant les brouillons (?draft=true), et n'écrit rien. Deux talents n'ont jamais le même slug ; un slug s'écrit déjà comme une adresse (minuscules sans accent, mots séparés par un tiret), sinon il est refusé à la publication, jamais corrigé. L'identifiant de l'ancien site (strapiId) n'est écrit que par l'import et ne sort que pour la rédaction. Ces règles de la rédaction sont dans src/collections/editor.ts, pour les articles aussi.articles, les pages /blog/<slug>) suivent les mêmes règles que les talents : la rédaction les écrit, les publie et les dépublie ; tout autre visiteur ne lit que les articles publiés. Les champs sont ceux de l'ancien site (titre, adresse, auteur, extrait, titre et description pour les moteurs de recherche, 70 et 155 caractères au plus), sauf la galerie, gallery. Le contenu est du markdown, sans limite de longueur, relu à l'identique. La galerie ne prend que des images de la rédaction (JPEG, PNG, WebP, GIF ou AVIF), brouillon compris : un PDF, la photo d'une maquilleuse ou un identifiant qu'aucun média ne peut porter y sont refusés (400), pour la rédaction, un admin comme pour l'import. Ses photos n'appartiennent à personne : la rédaction les envoie, les liste, les modifie et les supprime, sans jamais toucher à une photo de maquilleuse. Le public lit une photo d'article quand elle est dans la galerie d'un article publié et n'appartient à personne ; celle d'un brouillon reste introuvable, y compris quand un brouillon l'ajoute à un article déjà publié, jusqu'à sa publication.src/lib/bounded-query.ts), dans chaque collection, celles que Payload ajoute lui-même comprises (verrous des documents, préférences) : un filtre de plus de 20 conditions, ou un filtre ou un tri sur l'email d'un compte, les coordonnées, la note interne ou le score d'un profil, est refusé (400) à tout autre qu'un admin, même par une relation (pro.email). Les secrets d'un compte (mot de passe, sel, sessions, hash hérité, jetons) ne servent jamais de filtre ni de tri, pas même à un admin. La réponse est la même phrase en français, que la valeur existe ou non. Payload refuse de lui-même un filtre ou un tri sur tout autre champ que la personne ne peut pas lire. Le code du serveur qui lit sans les règles (overrideAccess) n'est pas borné. Une lecture en liste par l'API REST est plafonnée pour tout compte autre que admin (ticket 071, défaut en attente de la parole d'Andy) : 100 documents au plus, 10 sans limit, pagination=false ignoré ; le plafond est posé par un endpoint GET / en tête de chaque collection, pas par l'API locale (pages, sitemap, relations).src/lib/access.ts. Chacune a ses lignes dans la matrice d'accès, jouée par l'API REST et par l'API locale (tests/integration/payload-access.test.ts, voir docs/tests/banc.md, « La matrice d'accès »).Les tests unitaires de tests/unit/, sans lire le fichier .env |
bun run test:ci-guard | Les garde-fous de tests/ci/, lancés à part de bun run test |
bun run bench:up | Démarre le banc de test : base, capteur de mails, stockage d'objets, faux Google et faux Umami, en conteneurs |
bun run bench:down | Arrête le banc de test et efface tout ce qu'il contenait |
bun run bench:full | Le banc complet en une commande : démarre la pile, la remet à son état de départ (base reconstruite par les migrations, comptes types, 12 profils types avec photos, 3 talents, 3 articles dont un brouillon), construit l'application et la lance sur le port du banc. --without-application s'arrête avant l'application (voir « Le banc de test ») |
bun run bench:reset | Remet le banc à son état de départ, application ou non : base, stockage, capteur de mails, jeu. Environ 10 secondes |
bun run bench:session <compte> | Ouvre la session d'un compte du banc (admin, redaction, maquilleuse, profil-reference… ou une adresse) et l'écrit dans .banc/sessions/, sans jamais afficher le jeton |
bun run bench:prepare | Reconstruit la base du banc par les migrations versionnées, vide, puis vérifie qu'elle décrit les collections du code |
bun run bench:source | Crée la fausse base Strapi que lit l'import, banc démarré : base, rôle en lecture seule, photos dans le stockage. Relancée, elle la recrée à l'identique et donne la même empreinte. --fingerprint la lit sans la toucher, --erase la supprime (voir docs/tests/source-strapi.md) |
bun run bench:import | L'import des données de l'ancien site sur le banc : il lit la fausse base Strapi et écrit dans la base du banc. À blanc par défaut, --real pour écrire (voir docs/migration/IMPORT.md) |
bun run bench:reconcile [--json] [--outside-import] | Le rapprochement à blanc du stockage et de la base du banc : objets sans média, médias sans objet, médias sans profil, avec la corbeille comptée à part. Ne supprime rien (aucune option ne le permet) ; code de sortie 1 s'il trouve un orphelin. La CI le lance après les essais de bout en bout (voir docs/tests/banc.md, « Le rapprochement du stockage et de la base »). |
with-secret cloudflare -- bun run media:backup [--apply] [--lifecycle] [--age] [--restore-check <clé> --into <compartiment>] | La copie quotidienne des photos vers my-makeup-media-backup et la règle de la corbeille (ticket 278). Sans --apply, rien n'est écrit. Ne supprime jamais rien. Voir docs/migration/COPIE-PHOTOS.md. |
bun run rehearsal --dump <sauvegarde> --decisions <fichier> --cities <fichier> --consent-date <date> | La répétition complète de la migration en une commande, sur un banc à elle (mm-rehearsal) : restaure hors réseau la sauvegarde de la nuit, l'importe deux fois, juge la parité contre l'instantané, chronomètre chaque étape et détruit la copie. Rapport sans donnée personnelle dans .migration/repetition-<horodatage>/ (voir docs/migration/IMPORT.md, « La répétition complète en une commande ») |
bun run migration:import | L'import des données de l'ancien site, avec DATABASE_URI et STRAPI_SOURCE_URI de l'environnement (le script porte --no-env-file : le .env de travail n'est pas lu). À blanc par défaut ; --real écrit ; une base qui n'est pas sur ce poste demande --remote-target --expected-database <nom> (voir docs/migration/IMPORT.md). En production, sans accès SSH, l'import se lance dans le conteneur par IMPORT_TOKEN |
bun run members:mail [options] | Le courriel unique aux membres (issue 998) : liste à blanc par défaut, rapport sans adresse ni nom ; l'envoi exige --send, MEMBERS_MAIL_SEND=on, --limit et --send-log (voir docs/membres-courriel.md) |
bun run bench:members-mail [options] | La même commande contre le banc ; --bench-sample écrit un courriel d'exemple dans la sonde de courrier du banc |
bun run admin:create <email> | Crée un compte de l'administration, et la seule façon d'avoir le premier. Le mot de passe se tape au clavier, deux fois (voir « Créer un compte admin ») |
bun run test:integration | Les tests d'intégration de tests/integration/, sur le banc. Avec un chemin : ce fichier seul |
bun run test:e2e | Les essais de bout en bout de tests/e2e/, dans un vrai navigateur, contre l'application construite et branchée sur le banc. Avec un chemin : ce fichier seul |
bun run cutover:check | Le contrôle d'un hôte (ticket 173) : lit en GET, 2 requêtes par seconde au plus, toutes les adresses de l'inventaire, le sitemap, le fichier robots, les anciennes routes d'authentification et le script de mesure, et écrit un rapport daté. L'hôte, de production seulement (https://my-makeup.fr, https://v3.my-makeup.fr, ou le banc de ce poste), par HOST_CHECK_TARGET (voir docs/tests/controle-hote.md) |
bun run cutover:ui-crawl | Le crawl des interfaces (ticket 177) : un vrai navigateur ouvre 40 adresses en largeur téléphone (390 px) et bureau (1440 px) et relève les pages en 5xx, les erreurs de console, un h1 ou une Nav qui n'est pas unique, un « undefined » ou « null » visible, un débordement, une image cassée. Lectures seules, 2 requêtes par seconde au plus. L'hôte, de production seulement ou le banc de ce poste, par UI_CRAWL_TARGET (voir docs/tests/crawl-interfaces.md) |
bun run probes | Les sondes de tests/probes/ : lisent le site en ligne, en GET seulement, 2 requêtes par seconde au plus. Une autre cible par PROBES_TARGET (voir « Les sondes du site en ligne ») |
bun run image:build | Construit l'image Docker my-makeup:local |
bun run image:check | Vérifie l'image construite contre une base jetable (voir « Image Docker ») |
bun run film:check | Vérifie les raccords du film de l'accueil sur ses fichiers de public/assets/accueil/ : la boucle revient à sa première image, l'intro finit sur la première image de la boucle, les deux images fixes sont celles du film, dans chaque format. Avant tout nouveau rendu ; il lui faut ffmpeg (voir docs/accueil.md, « Le film ») |
bun run notes:add | Ajoute une note de version en attente : un type (ajout, amelioration, correction), puis la phrase entre guillemets (voir « Notes de version et publication ») |
bun run perf:lighthouse | La vitesse des quatre pages de référence (P01 à P04) en mobile : 5 passages par page, médiane, budgets du plan 02 §7.5 et comparaison avec le site en ligne, rapport dans docs/mesure/lighthouse-v3.md. Sur le banc (bun run bench:full), seul et machine au repos : la commande refuse sinon, --indicative mesure quand même (voir docs/mesure/lighthouse.md) |
bun run perf:pool-load | La charge du pool de connexions à la base (ticket 268), sur le banc seulement, application lancée : --requests requêtes simultanées sur la page d'un profil, la recherche et /api/health, --rounds vagues, --writes créations de comptes en plus, ou --lock pour prendre toutes les connexions par un verrou extérieur. Dit les erreurs, la requête la plus lente et le pic de connexions à la base. Voir docs/pool-connexions.md |
bun run names:check [dossier…] | Liste les noms du code qui sont encore français (fichiers, dossiers, noms déclarés, scripts, variables d'environnement lues, options des scripts) et sort avec le code 1 s'il en trouve hors des exceptions nommées. --summary, --json (voir docs/noms.md). Tourne dans la CI (job qualite), sous ci-ok, sans liste d'attente |
bun run release:publish | Avec --dry-run : montre la prochaine publication sans rien écrire. Avec --push : publie, dans le workflow release.yml seulement |
bun run release:check-deployment | Attend que la prod serve le commit de version RELEASE_COMMIT : dans le job deployment de release.yml seulement |
GOOGLE_OIDC_ISSUERfakeGoogle().choisir({ scenario: 'refus' })