@nodefony/devkit 10.0.0-beta.1 → 10.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +27 -12
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateParam.js +1 -1
  5. package/dist/index.js +2 -2
  6. package/dist/nodefony/controllers/DevkitController.js +2 -2
  7. package/dist/nodefony/controllers/McpController.js +71 -6
  8. package/dist/nodefony/service/DevkitService.js +2 -2
  9. package/dist/nodefony/src/card.js +1 -2
  10. package/dist/types/nodefony/controllers/McpController.d.ts +12 -1
  11. package/docs/index.md +63 -15
  12. package/package.json +9 -9
  13. package/skills/nodefony-add-realtime-channel/SKILL.md +66 -9
  14. package/skills/nodefony-add-service/SKILL.md +11 -0
  15. package/skills/nodefony-authentication/SKILL.md +234 -0
  16. package/skills/nodefony-browser/SKILL.md +45 -21
  17. package/skills/nodefony-browser/references/sondes.md +13 -0
  18. package/skills/nodefony-browser/scripts/audit.mjs +59 -10
  19. package/skills/nodefony-browser/scripts/inspect.mjs +122 -4
  20. package/skills/nodefony-browser/scripts/lib/browser.mjs +112 -6
  21. package/skills/nodefony-browser/scripts/lib/probes.mjs +166 -7
  22. package/skills/nodefony-dev/SKILL.md +56 -81
  23. package/skills/nodefony-dev/scripts/docs.mjs +21 -5
  24. package/skills/nodefony-devops/SKILL.md +16 -13
  25. package/skills/nodefony-devops/references/compose.md +8 -0
  26. package/skills/nodefony-devops/references/frontal.md +5 -1
  27. package/skills/nodefony-devops/references/image.md +1 -1
  28. package/skills/nodefony-devops/references/secrets.md +18 -4
  29. package/skills/nodefony-devops/references/variables.md +52 -19
  30. package/skills/nodefony-migrate-schema/SKILL.md +5 -2
  31. package/skills/nodefony-migrate-schema/references/verdicts.md +20 -19
  32. package/skills/nodefony-protect-route/SKILL.md +118 -17
  33. package/skills/nodefony-security-review/SKILL.md +127 -0
  34. package/skills/nodefony-security-review/scripts/review-diff.mjs +1090 -0
  35. package/skills/nodefony-troubleshoot/SKILL.md +151 -0
  36. package/skills/nodefony-troubleshoot/scripts/log-errors.mjs +465 -0
  37. package/skills/nodefony-upgrade/SKILL.md +130 -0
@@ -0,0 +1,234 @@
1
+ ---
2
+ name: nodefony-authentication
3
+ metadata:
4
+ version: 1.0.0
5
+ description: >
6
+ Établit QUI est l'utilisateur d'une application Nodefony par ce que le framework sert déjà —
7
+ page /login et son habillage, client NodefonyLogin, route me, second facteur, connexion
8
+ récente, retour sûr après connexion, fournisseurs (Keycloak, GitHub), jeton d'un programme —
9
+ au lieu de le réécrire. À charger AVANT d'écrire un formulaire de connexion, une redirection
10
+ après login, une lecture de session ou un appel d'API depuis un script. Ce qu'un utilisateur connecté a le DROIT de faire → nodefony-protect-route.
11
+ Déclencheurs : "ajoute une page de connexion", "formulaire de login", "se connecter", "second
12
+ facteur", "TOTP", "MFA", "exiger une connexion récente", "ramener l'utilisateur après la
13
+ connexion", "?from=", "qui est connecté", "déconnexion", "Keycloak", "se connecter avec
14
+ GitHub", "mon script doit appeler l'API sans formulaire", "jeton d'accès", "202 au login".
15
+ ---
16
+
17
+ # authentication — la connexion est servie, elle ne s'écrit pas
18
+
19
+ > 🧭 **Tu es arrivé ici directement ? Charge aussi `nodefony-dev`** — il porte la conduite
20
+ > commune (par où commencer, comment prouver que c'est fait). Cette page-ci ne couvre QUE la
21
+ > connexion.
22
+ >
23
+ > Une réponse te manque ? Elle est probablement INSTALLÉE — `rg` ne descend pas dans
24
+ > `node_modules`. `node node_modules/@nodefony/devkit/skills/nodefony-dev/scripts/docs.mjs <termes>`
25
+ > la trouve, avec la ligne exacte.
26
+
27
+ > **Maintenance** : vérité courante, jamais un journal. Éditer en place ; historique = `git log`.
28
+
29
+ ## 1. Quand m'utiliser / quand passer la main
30
+
31
+ | Besoin | Skill |
32
+ | --------------------------------------------------------------------- | ------------------------------- |
33
+ | Se connecter, se déconnecter, savoir qui est connecté, second facteur | ici |
34
+ | Réserver une route à un rôle, renvoyer l'anonyme vers `/login` (zone) | `nodefony-protect-route` |
35
+ | La ZONE qui reçoit un programme (`machine`, `partner`), un partenaire | `nodefony-protect-route` |
36
+ | Une page qui affiche l'état de connexion en temps réel | `nodefony-add-realtime-channel` |
37
+ | Vérifier à l'écran que la connexion aboutit | `nodefony-browser` |
38
+
39
+ ## 2. 🔴 Ce qui est DÉJÀ servi — n'écris ni page, ni controller de compte
40
+
41
+ Une application qui charge `@nodefony/security` sert tout ceci :
42
+
43
+ | Ce qui existe | Ce que ça fait |
44
+ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
45
+ | `GET /login` | la page : identifiant, mot de passe, second facteur, passkey, boutons des fournisseurs |
46
+ | `POST /nodefony/security/api/auth/login` | `{username, password}` → `200` + cookie ; **`202` = second facteur requis, PAS encore connecté** |
47
+ | `POST /nodefony/security/api/auth/login/totp` | `{code}` — le code à 6 chiffres (ou un code de récupération) qui achève une connexion en `202` |
48
+ | `GET /nodefony/security/api/auth/me` | **TOUJOURS `200`** : `{ user: null }` sans session, sinon `{ user: { id, identifier, roles } }` |
49
+ | `POST /nodefony/security/api/auth/logout` | idempotent ; rend l'adresse de déconnexion du fournisseur quand la session venait de lui |
50
+ | `/nodefony/security/api/totp/{enroll,confirm,disable,status}` | le titulaire connecté active ou retire SON second facteur |
51
+
52
+ - 🔴 **Qui est connecté, côté serveur — deux lignes à retenir, rien d'autre :**
53
+
54
+ ```ts
55
+ this.user; // dans un controller
56
+ RequestContext.getAuthenticatedUser(); // partout ailleurs (service, hook)
57
+ ```
58
+
59
+ Les deux rendent l'utilisateur connecté, ou `null`. C'est la règle UNIQUE du framework : elle
60
+ part du jeton que pose le pare-feu, et ses variantes en dérivent — `RequestContext.getUserId()`
61
+ pour l'`id` seul, `authenticatedUser()` de `@nodefony/user` pour le compte typé `IUser`. Ne lis
62
+ jamais la session à la main, et pas `RequestContext.getUser()`, qui rend aussi l'anonyme.
63
+
64
+ - `me` ne teste jamais une session par son code HTTP : il n'y a pas de `401` à attendre. Le
65
+ champ est `identifier`, pas `username`. Le rechargement d'une page (F5) se restaure avec `me`,
66
+ jamais avec un état gardé côté navigateur.
67
+ - Ces routes sont montées par `@nodefony/framework`, sous un chemin qui porte le nom de
68
+ `security` : c'est le piège qui fait conclure qu'elles n'existent pas.
69
+ - Aucune commande CLI n'active le second facteur d'un compte : c'est le titulaire, connecté, qui
70
+ l'enrôle (`enroll` rend `{secretBase32, otpauthUri}`, `confirm` le valide par un premier code).
71
+
72
+ ## 3. Habiller la page `/login` — sans la réécrire
73
+
74
+ Quatre niveaux, du plus léger au plus lourd ; prends le premier qui suffit. La section
75
+ `security.loginPage` est **stricte** : une clé mal orthographiée arrête le démarrage.
76
+
77
+ | Niveau | Geste |
78
+ | ------ | ---------------------------------------------------------------------------------------- |
79
+ | 1 | `skin` (neuf habillages) et `layout` |
80
+ | 2 | `title`, `heading`, `subtitle`, `logo`, `hero`, `footer` |
81
+ | 3 | `stylesheet` : TA feuille, qui redéfinit les variables `--nf-login-*` |
82
+ | 4 | `template` : un gabarit `.eta` à toi — dernier recours, il ne suit plus les mises à jour |
83
+
84
+ `path` (défaut `/login`) déplace la page ; **toute** redirection du framework la suit, y compris
85
+ l'échec d'un fournisseur quand `oauth2.failureRedirect` est omis. N'écris donc jamais `"/login"`
86
+ en dur dans ton code : `loginPagePath(from)` le compose. Détail :
87
+ `node_modules/@nodefony/security/docs/login-page.md`.
88
+
89
+ ❌ Recopier la feuille de style de la page, ou un nouveau `.eta` pour changer trois couleurs :
90
+ la page cesse de suivre le framework, et le niveau 1 ou 3 suffisait.
91
+
92
+ ## 4. Une connexion dans TON front — `NodefonyLogin`, jamais `fetch`
93
+
94
+ Le plus souvent, un **bouton** suffit : un lien vers `loginPagePath(from)` (`nodefony/client`),
95
+ qui compose `/login?from=<page>`. La page servie fait le reste.
96
+
97
+ Un formulaire à toi se bâtit sur `NodefonyLogin` (`nodefony/client`), qui conduit mot de passe,
98
+ second facteur, passkey, fournisseurs et blocage — par la liaison de TON moteur :
99
+
100
+ | Moteur | Liaison | Import |
101
+ | ------- | --------------------- | ------------------ |
102
+ | React | `useNodefonyLogin` | `nodefony/react` |
103
+ | Vue | `useNodefonyLogin` | `nodefony/vue` |
104
+ | Svelte | `nodefonyLogin` | `nodefony/svelte` |
105
+ | Angular | `injectNodefonyLogin` | `nodefony/angular` |
106
+
107
+ Elle expose l'état (`getState`, `subscribe`) et les gestes : `login`, `submitMfaCode`,
108
+ `loginWithPasskey`, `loadProviders`, `startProvider`, `me`, `logout`. **Suis l'adresse que rend
109
+ `logout()`** : sinon la session du fournisseur (Keycloak) reste ouverte. Détail :
110
+ `node_modules/nodefony/docs/client.md` (§ NodefonyLogin).
111
+
112
+ 🔴 **Jamais de `fetch` ni d'`axios` vers `/nodefony/security/api/auth/*`** : le `202`, le
113
+ blocage après échecs, le jeton anti-falsification et la passkey sont autant de cas qu'un
114
+ `fetch` écrit à la main oublie — et chacun est une faille ou une panne.
115
+
116
+ ## 5. Le `202` est un DÉFI, pas un succès
117
+
118
+ ```json
119
+ { "mfaRequired": true, "methods": ["totp"] }
120
+ ```
121
+
122
+ La session ne porte alors qu'un défi en attente : **l'utilisateur n'est pas connecté**. La liste
123
+ `methods` est **ouverte** — d'autres méthodes viendront ; teste l'appartenance
124
+ (`methods.includes(MFA_METHOD_TOTP)`), jamais l'égalité du tableau. Le code s'envoie à
125
+ `login/totp` (ou `submitMfaCode` avec la liaison).
126
+
127
+ ## 6. Ramener l'utilisateur après la connexion — `safeRedirectPath`
128
+
129
+ Le renvoi de l'anonyme vers la page se déclare sur la ZONE (`entryPoint`, skill
130
+ `nodefony-protect-route`) ; il arrive avec `?from=<page>`. Au retour :
131
+
132
+ ```ts
133
+ import { safeRedirectPath } from "nodefony"; // aussi dans nodefony/client
134
+ const destination = safeRedirectPath(from); // "/" par défaut
135
+ ```
136
+
137
+ 🔴 **Un `from` ne se suit JAMAIS sans `safeRedirectPath`.** C'est la seule garde contre la
138
+ redirection ouverte : `?from=https://site-piege.example` ou `?from=//site-piege.example`
139
+ enverrait ton utilisateur ailleurs, connecté. N'écris pas ta propre garde
140
+ (`startsWith("/")`, expression régulière d'URL) : `//hôte` commence par `/`, et c'est
141
+ exactement le contournement qu'elle laisse passer.
142
+
143
+ ## 7. Exiger une connexion RÉCENTE, ou deux facteurs, avant une action sensible
144
+
145
+ Une étiquette sur la route — n'écris PAS la vérification :
146
+
147
+ ```ts
148
+ import { Post, RequireAuthentication } from "@nodefony/framework";
149
+
150
+ @Post("/transfers")
151
+ @RequireAuthentication({ maxAge: 300, amr: ["otp"] }) // < 5 min, avec le code TOTP
152
+ transfer() { … }
153
+ ```
154
+
155
+ - `maxAge` en secondes, `amr` = moyens exigés (tous présents) ; l'un ou l'autre suffit.
156
+ - Refus = **401** dont le `WWW-Authenticate` porte `error="insufficient_user_authentication"`
157
+ (RFC 9470) : « reconnecte-toi », pas « interdit ». Évalué APRÈS `@IsGranted` : un rôle absent
158
+ reste un 403.
159
+ - Pour LIRE l'information (l'afficher, la journaliser) : `readSessionAuthentication(this.session)`
160
+ (`nodefony`) → `{ at, amr }` ou `null`. Jamais pour décider à la place de l'étiquette.
161
+
162
+ | Connexion par | `amr` |
163
+ | ------------------- | ---------------- |
164
+ | mot de passe | `["pwd"]` |
165
+ | mot de passe + TOTP | `["pwd", "otp"]` |
166
+ | passkey | `["pop"]` |
167
+ | fournisseur | `[]` |
168
+
169
+ - Session sans ces métadonnées (ouverte avant, ou altérée) : l'étiquette **refuse**, elle ne devine pas.
170
+ - ❌ `session.getMetaBag("amr")`, ❌ un `if (Date.now() - auth.at > …)` écrit dans l'action : tu
171
+ recopies une garde que le framework porte, avec ses refus normés.
172
+ - La route doit être sous une zone `session` : hors zone, aucune session n'est reprise et
173
+ l'étiquette refuse tout le monde.
174
+
175
+ ## 8. Les routes de connexion restent sous le contrôle anti-falsification
176
+
177
+ `bypassFirewall` sort une route de l'authentification, **pas** du contrôle CSRF : une route de
178
+ connexion à toi reste soumise au jeton. Seul `@CsrfExempt` l'en dispense — et il ouvrirait la
179
+ connexion à n'importe quel site (connexion forcée). Garde le jeton ; `NodefonyLogin` le porte.
180
+
181
+ ## 9. 🔴 En production, un mot de passe en HTTP clair est refusé (`403`)
182
+
183
+ Avant toute vérification, et la page affiche « doit passer par HTTPS ». Derrière un proxy qui
184
+ termine TLS, déclare-le dans `trustProxy` (config de `@nodefony/http`) — sinon **toutes** les
185
+ connexions échouent. `security.allowInsecureCredentials` n'est pas la correction : elle accepte
186
+ le clair.
187
+
188
+ ## 10. Se connecter par un fournisseur (Keycloak, GitHub…)
189
+
190
+ Le fournisseur se déclare dans `security.oauth2.providers` ; ses identifiants viennent de
191
+ l'environnement (`NF_KEYCLOAK_*`, `GITHUB_CLIENT_ID`…). Deux commandes, faute de quoi on
192
+ diagnostique un `401` à l'aveugle :
193
+
194
+ ```bash
195
+ npx nodefony security:oauth:doctor [-p keycloak] # découverte, URL de retour, secret
196
+ npx nodefony security:keycloak:realm [--write|--check|--apply] # realm dérivé de TA config
197
+ ```
198
+
199
+ `--write` écrit le realm (rôles, audiences) dans `docker/keycloak/import/`, `--check` sort en 1
200
+ s'il ne suit plus la configuration, `--apply` pose le thème de connexion sur le Keycloak de
201
+ développement. Un `401` avec un jeton Keycloak valide sur une API, c'est presque toujours
202
+ l'audience absente : `--write`, puis réimport. Détail :
203
+ `node_modules/@nodefony/security/docs/keycloak.md`.
204
+
205
+ ## 11. Un PROGRAMME ne remplit jamais le formulaire
206
+
207
+ Un script, une tâche planifiée, un agent n'ont ni navigateur ni cookie : ils présentent une
208
+ preuve à chaque appel, `Authorization: Bearer …`.
209
+
210
+ | Preuve | L'obtenir |
211
+ | ------------------------------- | --------------------------------------------------------------- |
212
+ | clé d'API opaque (`nf_…`) | `POST /nodefony/security/api/keys` (session d'administration) |
213
+ | jeton signé par l'application | `npx nodefony security:token [compte] --scope … --ttl … --json` |
214
+ | jeton Keycloak (client machine) | `requestClientCredentialsToken` de `@nodefony/security` |
215
+
216
+ ❌ Un `POST` vers la route de login depuis un script, ou un cookie de session recopié dans sa
217
+ configuration : le premier fait stocker un mot de passe au programme, le second expire et casse
218
+ en silence. La zone qui reçoit ces preuves (`machine`, `partner`, `stateless: true`) et la
219
+ commande qui l'éprouve (`app:partner:call`) → skill `nodefony-protect-route`. Détail :
220
+ `node_modules/@nodefony/security/docs/api-keys.md`, `obtenir-un-jeton.md`.
221
+
222
+ ## 12. Prouver que c'est fait
223
+
224
+ ```bash
225
+ # la page est servie, avec ton habillage (lis son <title>)
226
+ curl -s localhost:5151/login
227
+ # me répond 200 sans session — teste user, pas le code
228
+ curl -s localhost:5151/nodefony/security/api/auth/me
229
+ # le retour refuse une destination étrangère : les deux doivent rendre "/"
230
+ node -e 'import("nodefony").then(({safeRedirectPath:s})=>console.log(s("//evil.example"),s("https://evil.example")))'
231
+ ```
232
+
233
+ Une action « connexion récente » se prouve par trois sessions : mot de passe seul → refusée ;
234
+ mot de passe + TOTP → servie ; la même, ouverte depuis plus longtemps que le délai → refusée.
@@ -178,7 +178,7 @@ pour un élément contre son fond. La sonde ne connaît donc aucun sélecteur
178
178
  composants.
179
179
 
180
180
  Réglages par variables d'environnement : `NF_BROWSER_BASE`, `NF_BROWSER_PAGE`, `NF_BROWSER_EXPECT`,
181
- `NF_BROWSER_LOGIN`, `NF_BROWSER_USER`, `NF_BROWSER_PASSWORD`, `NF_BROWSER_PROBES`
181
+ `NF_BROWSER_LOGIN`, `NF_BROWSER_USER`, `NF_BROWSER_PASSWORD`, `NF_BROWSER_WHOAMI`, `NF_BROWSER_PROBES`
182
182
  (`libellé=sélecteur`, séparés par des virgules), `NF_BROWSER_FAMILIES`, `NF_BROWSER_WIDTHS`,
183
183
  `NF_BROWSER_SEUIL_LOURD`, `NF_BROWSER_SEUIL_LENT`, `NF_BROWSER_ACTIONS`, `NF_BROWSER_FULLPAGE`. Le détail vit dans l'en-tête de chaque script.
184
184
 
@@ -194,8 +194,9 @@ NF_BROWSER_ACTIONS="Nouvelle commande|voir:Moyen de paiement" node node_modules/
194
194
 
195
195
  Grammaire : `verbe:cible[=valeur]`, séquence séparée par `|`, verbe facultatif (`clic` par défaut),
196
196
  cible cherchée d'abord comme **texte visible** puis comme sélecteur CSS. Verbes : `clic` · `double`
197
- · `droit` (clic droit) · `survol` · `saisir:Nom=valeur` · `touche:Enter` · `voir` · `defiler:600`
198
- · `attendre`.
197
+ · `droit` (clic droit) · `survol` · `saisir:Nom=valeur` · `choisir:Liste=valeur` (une option d'un
198
+ `<select>`, par sa valeur ou son libellé ; la cible peut être le libellé qui l'enveloppe) ·
199
+ `touche:Enter` · `voir` · `defiler:600` · `attendre`.
199
200
 
200
201
  - 🔴 **`voir` plutôt qu'un clic pour amener dans la vue** : sur un formulaire, le texte d'une
201
202
  question est un `label` — cliquer dessus coche la case qu'il décrit, et tu observes alors un écran
@@ -207,9 +208,12 @@ cible cherchée d'abord comme **texte visible** puis comme sélecteur CSS. Verbe
207
208
  faite sur un écran qu'on n'a pas ouvert est pire qu'aucune mesure.
208
209
 
209
210
  **`NF_BROWSER_LOGIN` n'a pas de défaut** : c'est le chemin du formulaire de connexion de **ton**
210
- application. Il n'en existe pas d'universel, et deviner enverrait la sonde sur une page inexistante,
211
- où elle mesurerait un écran d'erreur en croyant s'être authentifiée. Sans lui, un identifiant posé
212
- fait s'arrêter la sonde avec un message — jamais une mesure fausse.
211
+ application. Une application qui charge `@nodefony/security` sert le sien à **`/login`** (sauf
212
+ `security.loginPage.path` ou `enabled: false`) : pose donc `NF_BROWSER_LOGIN=/login`. La sonde ne
213
+ le devine pas pour autant — deviner l'enverrait, ailleurs, sur une page inexistante, où elle
214
+ mesurerait un écran d'erreur en croyant s'être authentifiée. Sans lui, un identifiant posé fait
215
+ s'arrêter la sonde avec un message — jamais une mesure fausse. Une session reprise se prouve par
216
+ `/nodefony/security/api/auth/me` (`NF_BROWSER_WHOAMI`) : un `{ user: null }` la déclare périmée.
213
217
 
214
218
  Les sondes de couleur cherchent tes sélecteurs, pas ceux d'une bibliothèque : le thème est lu sur le
215
219
  `color-scheme` **calculé** (ce que le moteur applique) et sur `data-theme`. Si ton application marque
@@ -239,6 +243,20 @@ NF_BROWSER_STORAGE="ma-cle-de-theme=light" node .../scripts/inspect.mjs /
239
243
  Le champ `theme` de la sortie dit ce qui a été RÉELLEMENT appliqué. Vérifie-le : c'est ainsi qu'on
240
244
  sait qu'on a mesuré le bon écran.
241
245
 
246
+ ### Mesurer sur l'écran que tu veux — téléphone, bureau, grand écran
247
+
248
+ La sonde mesure en 1 440 × 900 par défaut. Une page juste sur un bureau peut obliger à défiler pour
249
+ atteindre son bouton sur un téléphone, ou flotter perdue sur un écran de 2 560 px :
250
+
251
+ ```bash
252
+ NF_BROWSER_VIEWPORT=mobile-court NF_BROWSER_FAMILIES=pli node .../scripts/inspect.mjs /connexion "Se connecter"
253
+ ```
254
+
255
+ Écrans nommés : `mobile` (390 × 844), `mobile-court` (375 × 667), `tablette` (820 × 1180),
256
+ `bureau` (défaut), `large` (1 920 × 1080), `tres-large` (2 560 × 1440) — ou `LARGEURxHAUTEUR`.
257
+ Sous 600 px de large, l'écran est émulé en **téléphone** (tactile, densité 2), pas seulement rétréci.
258
+ Un nom inconnu est **refusé** (code 64). Le champ `viewport` de la sortie dit l'écran mesuré.
259
+
242
260
  ## Les familles de sondes — activables, jamais un mur de JSON
243
261
 
244
262
  Le socle ci-dessus sort toujours. Le reste s'active par famille, chacune rendant un **verdict**
@@ -248,15 +266,16 @@ Le socle ci-dessus sort toujours. Le reste s'active par famille, chacune rendant
248
266
  NF_BROWSER_FAMILIES=axe,perf,reseau node .../scripts/inspect.mjs /tableau-de-bord "Chiffre d affaires"
249
267
  ```
250
268
 
251
- | Famille | Question à laquelle elle répond |
252
- | ------------ | -------------------------------------------------------------------------------------------------- |
253
- | **`axe`** | **Audit WCAG complet par `axe-core` — une centaine de règles, dont le contraste de TOUT le texte** |
254
- | `a11y` | Étiquettes, noms accessibles, hiérarchie des titres, cibles < 24 px, arbre d'accessibilité |
255
- | `rendu` | Débordement horizontal, éléments hors viewport, polices RÉELLEMENT chargées |
256
- | `reseau` | Requêtes, échecs, ressources lourdes et lentes, octets réellement transférés |
257
- | `perf` | TTFB, FCP, LCP, CLS, tâches longues — verdict sur les seuils Web Vitals |
258
- | `stockage` | Attributs des cookies et inventaire du Web Storage — **jamais les valeurs** |
259
- | `responsive` | Le débordement horizontal rejoué à plusieurs largeurs (`NF_BROWSER_WIDTHS`) |
269
+ | Famille | Question à laquelle elle répond |
270
+ | ------------ | ----------------------------------------------------------------------------------------------------- |
271
+ | **`axe`** | **Audit WCAG complet par `axe-core` — une centaine de règles, dont le contraste de TOUT le texte** |
272
+ | `a11y` | Étiquettes, noms accessibles, hiérarchie des titres, cibles < 24 px, arbre d'accessibilité |
273
+ | `rendu` | Débordement horizontal, éléments hors viewport, polices RÉELLEMENT chargées |
274
+ | `reseau` | Requêtes, échecs, ressources lourdes et lentes, octets réellement transférés |
275
+ | `perf` | TTFB, FCP, LCP, CLS, tâches longues — verdict sur les seuils Web Vitals |
276
+ | `stockage` | Attributs des cookies et inventaire du Web Storage — **jamais les valeurs** |
277
+ | `responsive` | Le débordement horizontal rejoué à plusieurs largeurs (`NF_BROWSER_WIDTHS`) |
278
+ | `pli` | Faut-il défiler pour AGIR ? Le bouton principal face au bas de l'écran, la place du bloc qui le porte |
260
279
 
261
280
  `NF_BROWSER_FAMILIES=toutes` active tout ; un nom inconnu est **refusé** (code 64), jamais ignoré.
262
281
  Ce que chaque champ veut dire, comment lire un verdict, et **quand chaque famille se trompe** :
@@ -387,13 +406,12 @@ Le protocole du fil (les quatre formes de frame), la lecture de chaque verdict
387
406
  <!-- prettier-ignore -->
388
407
  | Contrainte | Ce qui arrive sinon |
389
408
  | --- | --- |
390
- | Joindre l'app par **`host.docker.internal`** | `localhost` désigne le CONTENEUR, pas ta machine. Et si tu as activé `domainCheck`, ajoute ce nom aux `trustedHosts` en développement : sinon la barrière répond **`421`** alors que le réseau passe. |
409
+ | Joindre l'app par **`host.docker.internal`** | `localhost` désigne le CONTENEUR, pas ta machine. Ce nom est dans les `trustedHosts` de développement d'une application générée (`nodefony.config.ts`, bloc http) ; sans lui, la page annonce ses assets en `127.0.0.1` — le conteneur — et, avec `domainCheck`, la barrière répond **`421`**. |
391
410
  | Passer par **HTTPS** | Le cookie de session est `secure` : sur une origine `http://` non-`localhost`, le navigateur le **jette**, et tout revient en **`401`** — ce qui se lit à tort comme un login qui rate. |
392
- | **Rien à poser** pour rendre Vite joignable | L'origine des assets se dérive du `Host` de ta requête : arriver par `host.docker.internal` suffit — l'allowlist Vite et le WebSocket du rechargement à chaud suivent le même nom, et ton poste reste servi sur `127.0.0.1` en même temps. |
411
+ | Rien à poser pour **Vite** | Nodefony relaie Vite sur l'origine de la page (`/_vite/…`) : scripts, images et WebSocket du rechargement à chaud suivent l'hôte par lequel tu arrives, en HTTPS si la page l'est — pas de contenu mixte, un seul certificat. |
393
412
 
394
- Si la page annonce quand même `127.0.0.1` depuis le conteneur, c'est que le nom ne franchit pas
395
- `trustedHosts`, ou qu'une `publicOrigin` explicite est configurée dans `nodefony.config.ts` — un
396
- réglage durable gagne toujours sur une déduction.
413
+ Si un script de la page vise encore `127.0.0.1:5173`, le framework installé précède le relais
414
+ (`@nodefony/frontend` à mettre à jour) : les balises d'une version courante sont relatives.
397
415
 
398
416
  ## Pièges — chacun a déjà fait conclure faux
399
417
 
@@ -418,7 +436,13 @@ réglage durable gagne toujours sur une déduction.
418
436
  - **Une capture ne s'écrase pas.** Réutiliser un nom laisse l'ancienne image en place pendant que
419
437
  l'appel répond « OK » : tu lis un écran périmé. Les sondes horodatent — ne le contourne pas.
420
438
  - **Un état d'authentification sauvegardé peut être périmé** (session expirée, serveur redémarré).
421
- Les sondes le constatent et refont le parcours plutôt que de mesurer l'écran de connexion.
439
+ Les sondes le PROUVENT avant de mesurer : `NF_BROWSER_WHOAMI` (défaut
440
+ `/nodefony/security/api/auth/me`) doit répondre 2xx SANS `{ user: null }` dans son corps (c'est
441
+ ainsi qu'il dit « anonyme »), sinon l'état est jeté, la reprise est
442
+ annoncée sur la sortie d'erreur et le parcours de connexion est rejoué. Attendre un renvoi vers le
443
+ formulaire ne suffisait pas : une page PUBLIQUE n'en fait jamais, et la sonde mesurait alors en
444
+ anonyme sans le dire. Application sans `@nodefony/security` : pointer `NF_BROWSER_WHOAMI` sur une
445
+ route qui rend 401/403 (ou `{ user: null }`) à un anonyme — ou le laisser vide, et la sonde se connecte à chaque fois.
422
446
  - **Chaque compte a SON état sauvegardé** (le fichier porte l'identifiant). C'est ce qui permet
423
447
  d'enchaîner deux sondes sous deux comptes — comparer ce que voit un administrateur et ce que voit
424
448
  un utilisateur ordinaire — sans que la seconde reprenne la session de la première. Sans cela on
@@ -164,6 +164,19 @@ pixels, ni tactile, ni `user-agent`. Une media query sur `pointer` ou `hover` ne
164
164
  capture PNG est prise AVANT cette famille, à la largeur d'origine : les débordements constatés ici
165
165
  ne s'y voient pas.
166
166
 
167
+ ## `pli` — les proportions, sur l'écran demandé
168
+
169
+ Sur l'écran de `NF_BROWSER_VIEWPORT` : la hauteur de la page et le défilement (`scrollPx`), le
170
+ débordement en largeur, l'**action principale** (`primary` : libellé, bas en pixels, `marginPx` =
171
+ marge avant le bas de l'écran) et le **bloc** qui la porte (`frame` : taille, part de la largeur et
172
+ de la hauteur de l'écran). `ALERTE` quand il faut défiler pour atteindre l'action, ou que la page
173
+ déborde en largeur — un pied de page sous le pli n'est PAS une alerte.
174
+
175
+ **Quand elle se trompe** : l'action principale est devinée — le plus GRAND bouton de validation
176
+ visible (un bouton « retour » est aussi un `submit`, jamais le plus large). Sur une page à plusieurs
177
+ formulaires, désigne-la : `NF_BROWSER_PRIMARY="#mon-bouton"`. Le bloc est le plus grand ancêtre de
178
+ l'action resté plus étroit que l'écran : sur une page sans carte, c'est une colonne, pas un cadre.
179
+
167
180
  ## Lire un verdict sans se faire piéger
168
181
 
169
182
  1. **`ALERTE` n'est pas « cassé »** — c'est « mérite un regard ». Le détail dit lequel.
@@ -19,6 +19,7 @@
19
19
  * `@env` NF_BROWSER_LOGIN chemin du formulaire de connexion — aucun défaut deviné
20
20
  * `@env` NF_BROWSER_USER identifiant ; sans lui, aucune authentification n'est tentée
21
21
  * `@env` NF_BROWSER_PASSWORD mot de passe associé
22
+ * `@env` NF_BROWSER_COLOR_SCHEME `light` ou `dark` — posé au MOTEUR, seul réglage que l'onglet de Lighthouse reçoit ; sans lui, le thème du système
22
23
  * `@env` NF_BROWSER_CATEGORIES catégories à jouer, séparées par des virgules (défaut : toutes celles que ce Lighthouse connaît)
23
24
  * `@env` NF_BROWSER_FORMFACTOR `desktop` (défaut) ou `mobile` — un score de performance ne veut RIEN dire sans son appareil
24
25
  * `@env` NF_BROWSER_SEUIL_AUDIT score en deçà duquel un audit est retenu, en pourcentage (défaut 90)
@@ -29,10 +30,22 @@
29
30
  import { createServer } from "node:net";
30
31
  import { mkdirSync, writeFileSync } from "node:fs";
31
32
  import path from "node:path";
32
- import { mkdtempSync } from "node:fs";
33
+ import { mkdtempSync, rmSync } from "node:fs";
33
34
  import { tmpdir } from "node:os";
34
- import { BASE, LOGIN, PASSWORD, OUTPUT, USER } from "./lib/browser.mjs";
35
- import { summarizeLighthouse } from "./lib/probes.mjs";
35
+ import {
36
+ BASE,
37
+ COLOR_SCHEME,
38
+ LOGIN,
39
+ PASSWORD,
40
+ OUTPUT,
41
+ USER,
42
+ } from "./lib/browser.mjs";
43
+ import {
44
+ browserOrder,
45
+ captureSlug,
46
+ colorSchemeLaunchArgs,
47
+ summarizeLighthouse,
48
+ } from "./lib/probes.mjs";
36
49
 
37
50
  const PAGE = process.argv[2] ?? process.env.NF_BROWSER_PAGE ?? "/";
38
51
  const THRESHOLD = Number(process.env.NF_BROWSER_SEUIL_AUDIT ?? 90) / 100;
@@ -85,11 +98,40 @@ const port = await freePort();
85
98
  // Profil PERSISTANT : c'est lui qui porte la session entre notre connexion et
86
99
  // l'onglet que Lighthouse ouvrira. Jetable — il vit le temps de l'audit.
87
100
  const profile = mkdtempSync(path.join(tmpdir(), "nf-audit-"));
88
- const ctx = await chromium.launchPersistentContext(profile, {
89
- channel: "chromium",
90
- ignoreHTTPSErrors: true,
91
- args: [`--remote-debugging-port=${port}`, "--no-sandbox"],
92
- });
101
+ // Le navigateur se CONSTATE, dans le même ordre que les autres sondes
102
+ // (`browserOrder` : celui du pilote, puis ceux DÉJÀ posés sur la machine). Un
103
+ // `channel: "chromium"` codé en dur faisait échouer l'audit sur un poste où
104
+ // seul Chrome est installé — là même où `inspect.mjs` passait.
105
+ let ctx = null;
106
+ const failures = [];
107
+ for (const channel of browserOrder(process.env.NF_BROWSER_ENGINE)) {
108
+ try {
109
+ ctx = await chromium.launchPersistentContext(profile, {
110
+ channel,
111
+ ignoreHTTPSErrors: true,
112
+ // `null` : le pilote n'émule rien — c'est le réglage du moteur qui
113
+ // décide, le même pour la connexion et pour l'onglet de Lighthouse.
114
+ colorScheme: null,
115
+ args: [
116
+ `--remote-debugging-port=${port}`,
117
+ "--no-sandbox",
118
+ ...colorSchemeLaunchArgs(COLOR_SCHEME),
119
+ ],
120
+ });
121
+ break;
122
+ } catch (e) {
123
+ failures.push(`${channel} : ${String(e).split("\n")[0]}`);
124
+ }
125
+ }
126
+ if (!ctx) {
127
+ console.error(
128
+ `Aucun navigateur n'a répondu :\n ${failures.join("\n ")}\n` +
129
+ "Installer celui du pilote (npx playwright install chromium) ou viser un\n" +
130
+ "navigateur présent : NF_BROWSER_ENGINE=chrome (ou msedge).",
131
+ );
132
+ rmSync(profile, { recursive: true, force: true });
133
+ process.exit(69); // EX_UNAVAILABLE
134
+ }
93
135
 
94
136
  try {
95
137
  const page = ctx.pages()[0] ?? (await ctx.newPage());
@@ -153,17 +195,24 @@ try {
153
195
  // vérifier — et à comparer dans le temps.
154
196
  mkdirSync(OUTPUT, { recursive: true });
155
197
  const stamp = new Date().toISOString().replace(/[:.]/gu, "-").slice(0, 19);
156
- const slug = PAGE.replace(/\//gu, "-").replace(/^-/u, "") || "racine";
198
+ const slug = captureSlug(PAGE);
157
199
  const fullPath = path.join(OUTPUT, `lighthouse-${slug}-${stamp}.json`);
158
200
  writeFileSync(fullPath, JSON.stringify(lhr), "utf8");
159
201
 
160
202
  console.log(
161
203
  JSON.stringify(
162
- { ...summarizeLighthouse(lhr, THRESHOLD), fullReport: fullPath },
204
+ {
205
+ ...summarizeLighthouse(lhr, THRESHOLD),
206
+ colorScheme: COLOR_SCHEME ?? "système",
207
+ fullReport: fullPath,
208
+ },
163
209
  null,
164
210
  2,
165
211
  ),
166
212
  );
167
213
  } finally {
168
214
  await ctx.close();
215
+ // Le profil est jetable : fermé, il ne sert plus à rien et resterait dans le
216
+ // dossier temporaire du système.
217
+ rmSync(profile, { recursive: true, force: true });
169
218
  }