@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
@@ -113,25 +113,26 @@ n'a jamais les deux — c'est le discriminant à tester.
113
113
 
114
114
  ### Refus d'usage — la demande elle-même
115
115
 
116
- | Code | Ce qui s'est passé | Le geste |
117
- | -------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
118
- | `NF_MIGRATE_UNKNOWN_CONNECTOR` | aucun connecteur de ce nom | le message liste ceux que l'application déclare |
119
- | `NF_MIGRATE_UNKNOWN_TAG` | `--up-to` désigne une migration inconnue | relire `sources[].pendingTags` |
120
- | `NF_MIGRATE_UNKNOWN_SOURCE` | `--source` n'est pas déclarée par cette application | relire `sources[].name` |
121
- | `NF_MIGRATE_URL_MISMATCH` | la variable de migration désigne une base d'un AUTRE dialecte | corriger la variable, ou choisir le bon connecteur |
122
- | `NF_MIGRATE_NOT_CONFIGURED` | connecteur SQL non déclaré dans la configuration | le déclarer pour pouvoir le suivre |
123
- | `NF_MIGRATE_SECONDARY_CONNECTOR` | un connecteur secondaire porte le nom d'un dossier de dialecte (`sqlite`, `postgres`, `mysql`, `meta`) | renommer le connecteur dans `connectors` — un secondaire range ses migrations sous `migrations/<connecteur>/` |
124
- | `NF_MIGRATE_NO_MIGRATIONS` | ce connecteur est porté par une base qui ne se migre pas ainsi | rien à migrer ici — ce n'est pas une panne |
125
- | `NF_MIGRATE_NOT_DEVELOPMENT` | geste réservé au développement, demandé ailleurs | passer par le travail de déploiement |
126
- | `NF_MIGRATE_DESTRUCTIVE` | des migrations en attente SUPPRIMENT des données | relire le fichier produit, puis assumer explicitement |
127
- | `NF_MIGRATE_UNAVAILABLE` | la commande s'est arrêtée sans pouvoir nommer la cause | constater l'état (`orm:migrate:status`) avant de reprendre |
128
- | `NF_MIGRATE_CONFIRM_REQUIRED` | `orm:reset` demandé hors terminal, ou en sortie machine | relancer avec `--yes` si l'effacement est voulu |
129
- | `NF_GENERATE_NAME` | le nom de migration demandé n'est pas utilisable | reprendre la suggestion que le message donne |
130
- | `NF_GENERATE_MISSING_ENTITY` | une entité est enregistrée sans fichier qui la fournisse | `nodefony inspect entities --json` pour la situer |
131
- | `NF_GENERATE_FRAMEWORK_TABLE` | une entité de l'application usurpe une table du framework | la renommer, ou écrire une migration libre (`--custom`) |
132
- | `NF_GENERATE_DESTRUCTIVE` | la migration ÉCRITE supprime des données | relire le fichier, puis annuler (`git checkout`) ou appliquer — la mise en service a sa propre garde |
133
- | `NF_GENERATE_DATABASE_BEHIND` | rien à écrire, et pourtant la base ne porte pas le schéma | `repair --forget <source>/<tag>` : l'historique affirme une migration jamais exécutée |
134
- | `NF_GENERATE_TOOL_MISSING` | l'outil de génération n'est pas installé | l'installer en dépendance de développement |
116
+ | Code | Ce qui s'est passé | Le geste |
117
+ | -------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
118
+ | `NF_MIGRATE_UNKNOWN_CONNECTOR` | aucun connecteur de ce nom | le message liste ceux que l'application déclare |
119
+ | `NF_MIGRATE_UNKNOWN_TAG` | `--up-to` désigne une migration inconnue | relire `sources[].pendingTags` |
120
+ | `NF_MIGRATE_UNKNOWN_SOURCE` | `--source` n'est pas déclarée par cette application | relire `sources[].name` |
121
+ | `NF_MIGRATE_URL_MISMATCH` | la variable de migration désigne une base d'un AUTRE dialecte | corriger la variable, ou choisir le bon connecteur |
122
+ | `NF_MIGRATE_NOT_CONFIGURED` | connecteur SQL non déclaré dans la configuration | le déclarer pour pouvoir le suivre |
123
+ | `NF_MIGRATE_SECONDARY_CONNECTOR` | un connecteur secondaire porte le nom d'un dossier de dialecte (`sqlite`, `postgres`, `mysql`, `meta`) | renommer le connecteur dans `connectors` — un secondaire range ses migrations sous `migrations/<connecteur>/` |
124
+ | `NF_MIGRATE_NO_MIGRATIONS` | ce connecteur est porté par une base qui ne se migre pas ainsi | rien à migrer ici — ce n'est pas une panne |
125
+ | `NF_MIGRATE_NOT_DEVELOPMENT` | geste réservé au développement, demandé ailleurs | passer par le travail de déploiement |
126
+ | `NF_MIGRATE_DESTRUCTIVE` | des migrations en attente SUPPRIMENT des données | relire le fichier produit, puis assumer explicitement |
127
+ | `NF_MIGRATE_UNAVAILABLE` | la commande s'est arrêtée sans pouvoir nommer la cause | constater l'état (`orm:migrate:status`) avant de reprendre |
128
+ | `NF_MIGRATE_CONFIRM_REQUIRED` | `orm:reset` demandé hors terminal, ou en sortie machine | relancer avec `--yes` si l'effacement est voulu |
129
+ | `NF_GENERATE_NAME` | le nom de migration demandé n'est pas utilisable | reprendre la suggestion que le message donne |
130
+ | `NF_GENERATE_MISSING_ENTITY` | une entité est enregistrée sans fichier qui la fournisse | `nodefony inspect entities --json` pour la situer |
131
+ | `NF_GENERATE_UNREADABLE_ENTITY` | rien d'écrit, et un fichier d'entités ne s'importe pas : sa table n'est vue par personne | corriger la cause nommée (souvent un paquet absent des dépendances), ou sortir le fichier de `nodefony/entity/` |
132
+ | `NF_GENERATE_FRAMEWORK_TABLE` | une entité de l'application usurpe une table du framework | la renommer, ou écrire une migration libre (`--custom`) |
133
+ | `NF_GENERATE_DESTRUCTIVE` | la migration ÉCRITE supprime des données | relire le fichier, puis annuler (`git checkout`) ou appliquer — la mise en service a sa propre garde |
134
+ | `NF_GENERATE_DATABASE_BEHIND` | rien à écrire, et pourtant la base ne porte pas le schéma | `repair --forget <source>/<tag>` : l'historique affirme une migration jamais exécutée |
135
+ | `NF_GENERATE_TOOL_MISSING` | l'outil de génération n'est pas installé | l'installer en dépendance de développement |
135
136
 
136
137
  ## Un contrat qui ne bougera pas
137
138
 
@@ -1,17 +1,17 @@
1
1
  ---
2
2
  name: nodefony-protect-route
3
3
  description: >
4
- Réserve une route d'une application Nodefony aux personnes habilitées, par les briques du
5
- framework plutôt que par un contrôle écrit à la main dans l'action. Porte les deux étages
6
- (zone du pare-feu et garde par route), la hiérarchie de rôles qui évite d'attribuer un rôle de
7
- plus, la façon d'ouvrir une route à un partenaire sans démonter la défense anti-falsification,
8
- et les gestes qui affaiblissent l'application en silence. À charger AVANT de poser une garde,
9
- d'ouvrir une route à un tiers, ou de toucher à la configuration de sécurité.
10
- Déclencheurs : "protège cette route", "réserver aux administrateurs", "@IsGranted", "firewall",
11
- "zone protégée", "403", "401", "un rôle qui en implique un autre", "roleHierarchy",
12
- "un partenaire doit pouvoir poster", "erreur CSRF", "origine refusée", "@CsrfExempt",
13
- "@CsrfProtect", "mon POST passe sans jeton", "faut-il protéger une écriture",
14
- "API pour un programme", "clé d'API", "désactiver la sécurité pour tester".
4
+ Réserve une route ou une page d'une application Nodefony aux personnes habilitées, par les
5
+ briques du framework plutôt que par un contrôle écrit dans l'action. Porte les deux étages
6
+ (zone du pare-feu et garde par route), la hiérarchie de rôles, le renvoi de l'anonyme vers la
7
+ page de connexion, l'ouverture à un partenaire sans démonter la défense anti-falsification,
8
+ les zones des programmes (clé d'API, jeton), et les gestes qui affaiblissent l'application en
9
+ silence. À charger AVANT de poser une garde, d'ouvrir une route à un tiers, ou de toucher à la
10
+ sécurité. Se CONNECTER (page, second facteur, retour après connexion) → nodefony-authentication.
11
+ Déclencheurs : "protège cette route", "réserver aux administrateurs", "@IsGranted",
12
+ "firewall", "403", "401", "roleHierarchy", "un partenaire doit pouvoir poster", "erreur CSRF",
13
+ "@CsrfExempt", "mon POST passe sans jeton", "API pour un programme", "clé d'API",
14
+ "client_credentials", "désactiver la sécurité pour tester".
15
15
  ---
16
16
 
17
17
  # protect-route — la garde vient du framework, jamais de l'action
@@ -85,6 +85,48 @@ pattern: "^/api/account/(profile|invoices)"; // ❌ les routes d'aujourd'hui
85
85
  elle a l'air de couvrir l'espace, et l'introspection montre bien une route protégée à côté. Quand
86
86
  des routes partagent un préfixe, ne les protège pas une par une.
87
87
 
88
+ ## Protéger des PAGES — renvoyer l'anonyme vers la connexion
89
+
90
+ 🔴 **La connexion elle-même — page `/login`, son habillage, le client `NodefonyLogin`, le second
91
+ facteur, le retour après connexion, les fournisseurs — est le skill `nodefony-authentication`.**
92
+ N'écris ni page de connexion ni controller de compte : tout est servi. Ici, seulement ce que la
93
+ ZONE décide.
94
+
95
+ **Conduire l'anonyme à la page** se déclare sur la ZONE, jamais dans l'action :
96
+
97
+ ```ts
98
+ areas: {
99
+ pages: {
100
+ pattern: "^/compte(/|$)",
101
+ authenticators: ["session"],
102
+ entryPoint: "/login", // la page du framework (security.loginPage.path)
103
+ },
104
+ }
105
+ ```
106
+
107
+ | La requête refusée | Réponse |
108
+ | -------------------------------------------------------------------------- | ------------------------------- |
109
+ | navigation (`GET`/`HEAD`, `Accept: text/html`, `Sec-Fetch-Mode: navigate`) | `302` → `/login?from=%2Fcompte` |
110
+ | appel d'API (`Accept: application/json`, `fetch()`) | `401`, inchangé |
111
+
112
+ **L'éprouver** : la navigation se joue avec `navigate()` de `nodefony/testing`, JAMAIS avec
113
+ `fetch()` — Node y réécrit `Sec-Fetch-Mode` en `cors`, le pare-feu voit un appel d'API et répond
114
+ `401` sur un code juste. `fetch()` reste l'outil du côté API.
115
+
116
+ ```ts
117
+ import { navigate } from "nodefony/testing";
118
+ import { appBaseUrl } from "./e2e.setup"; // tests/e2e.setup.ts, généré
119
+
120
+ const res = await navigate(`${appBaseUrl()}/compte`); // 302 → /login?from=%2Fcompte
121
+ ```
122
+
123
+ Le `from` qui revient après la connexion ne se suit JAMAIS sans `safeRedirectPath` — skill
124
+ `nodefony-authentication`.
125
+
126
+ **Une zone `session` sans `roles`, avec un fournisseur qui crée les comptes à la première
127
+ connexion** (`allowSignup`), est ouverte à quiconque possède un compte chez ce fournisseur.
128
+ `npx nodefony doctor --live` la signale ; pose un rôle sur la zone.
129
+
88
130
  ## 🔴 La PROVENANCE n'est pas une PREUVE D'INTENTION — une mutation exige `@CsrfProtect`
89
131
 
90
132
  Le raisonnement qui vient, et qui est faux : « le pare-feu vérifie déjà `Sec-Fetch-Site`, donc une
@@ -171,9 +213,16 @@ connecter — ou pire, un mot de passe stocké en clair.
171
213
 
172
214
  ## Lire l'utilisateur courant
173
215
 
174
- Le paramètre décoré `@CurrentUser()` (typé `IUser` de `@nodefony/user`). L'identité est
175
- **ré-résolue à chaque requête** : les rôles sont frais, et une révocation prend effet tout de
176
- suite. N'écris pas ton propre lecteur de session — le tien lira un instantané.
216
+ Dans un controller, **`this.user`** : l'utilisateur CONNECTÉ, ou `null` (anonyme, ou route hors
217
+ de toute zone). Le paramètre décoré `@CurrentUser()` rend le même, `undefined` pour l'anonyme.
218
+ Hors controller (un service), `RequestContext.getAuthenticatedUser()` — `null` hors requête ou
219
+ pour l'anonyme — et `RequestContext.getUserId()` (`string | null`). `RequestContext.getUser()`
220
+ rend l'utilisateur BRUT, anonyme compris : ne t'en sers pas pour décider « qui est connecté ».
221
+
222
+ L'identité est **ré-résolue à chaque requête** : les rôles sont frais, et une révocation prend
223
+ effet tout de suite. N'écris pas ton propre lecteur de session — le tien lira un instantané. Une
224
+ page PUBLIQUE qui veut savoir qui la visite se place sous une zone
225
+ `authenticators: ["session", "anonymous"]` : hors zone, `this.user` vaut toujours `null`.
177
226
 
178
227
  ## Un droit métier qui ne se réduit pas à un rôle
179
228
 
@@ -190,8 +239,16 @@ d'appartenance à écrire dans l'action.
190
239
 
191
240
  ## Une API pour un PROGRAMME, pas pour un navigateur
192
241
 
193
- Un service partenaire, un script, un agent ne stockent aucun cookie. **La zone est déjà posée**
194
- dans le `nodefony/config/security.ts` généré :
242
+ Un service partenaire, un script, un agent ne stockent aucun cookie. Le
243
+ `nodefony/config/security.ts` généré (preset `complete`) pose **deux zones, une par sorte de
244
+ preuve** — choisis selon ce que l'appelant détient, n'en ajoute pas une troisième :
245
+
246
+ | L'appelant présente | Zone | Préfixe | Obtenir la preuve |
247
+ | ---------------------------------------------- | --------- | -------------- | --------------------------------------- |
248
+ | une **clé d'API** opaque (`nf_…`) | `machine` | `/api/machine` | `POST /nodefony/security/api/keys` |
249
+ | un **jeton d'accès** (de l'app ou de Keycloak) | `partner` | `/api/partner` | `npx nodefony app:partner:call` le fait |
250
+
251
+ ### Clé d'API : la zone `machine`
195
252
 
196
253
  ```ts
197
254
  machine: {
@@ -216,6 +273,48 @@ qui ne stocke pas de cookie ne doit rien recevoir qu'il faille stocker.**
216
273
 
217
274
  Les clés s'émettent par `POST /nodefony/security/api/keys`.
218
275
 
276
+ ### Jeton d'accès : la zone `partner`, et la commande qui l'éprouve
277
+
278
+ La zone accepte un jeton signé par l'application (`jwt`) **et** un jeton de Keycloak
279
+ (`external-jwt`) ; les deux s'aiguillent sur l'émetteur, leur ordre ne décide de rien. Une route
280
+ qui tombe sous `/api/partner` est protégée sans rien ajouter. L'exemple qui marche est déjà dans
281
+ l'application — **lis-le avant d'écrire un appelant** :
282
+
283
+ ```bash
284
+ npx nodefony app:partner:call # jeton signé par l'app pour `admin` (--as <compte>)
285
+ npx nodefony app:partner:call --issuer keycloak # client <app>-machine, flux client_credentials
286
+ ```
287
+
288
+ Source : `nodefony/command/PartnerCallCommand.ts` (la commande) et
289
+ `nodefony/controllers/PartnerController.ts` (`GET /api/partner/whoami`, qui rend l'identité ET
290
+ l'émetteur). Un jeton Keycloak s'obtient par `requestClientCredentialsToken` de
291
+ `@nodefony/security` — découverte du point de jeton comprise —, jamais par un `fetch` recopié.
292
+
293
+ 🔴 **L'audience vit en UN endroit.** Un jeton n'ouvre que la zone dont il porte la ressource
294
+ (RFC 8707). `partnerResource(ctx.env)` est lue par la zone (`resource`), par `jwt.audiences` et par
295
+ la commande. Une ressource recopiée à la main finit par différer, et le seul symptôme est un refus
296
+ sans explication. Ouvrir une AUTRE ressource à des jetons, c'est trois gestes, pas un :
297
+
298
+ | Oublié | Symptôme |
299
+ | ----------------------------------------- | ------------------------------------------------------------- |
300
+ | la ressource dans `jwt.audiences` | **400 `invalid_target`** à l'émission du jeton de l'app |
301
+ | `security:keycloak:realm --write` relancé | **401** du jeton Keycloak : le realm n'inscrit pas l'audience |
302
+ | la `resource` sur la zone | l'application **refuse de démarrer** en nommant la zone |
303
+
304
+ 🔴 **`subjectPolicy: "ephemeral"` s'écrit SUR LA ZONE, jamais dans `resourceServer`.** Le client
305
+ machine de Keycloak n'a pas de compte ici : `ephemeral` l'accepte le temps de la requête, avec les
306
+ seuls `ephemeralRoles` de la zone (`ROLE_PARTNER`). Posé dans `resourceServer.subjectPolicy`
307
+ (global), il ferait accepter un appelant sans compte par **toutes** les zones `external-jwt`. Et
308
+ un rôle fort dans `ephemeralRoles` est accordé à **quiconque** détient un jeton valide du realm.
309
+
310
+ `whoami` n'exige pas `ROLE_PARTNER` — un compte de l'app ne l'a pas. Pour réserver une action aux
311
+ partenaires : `@IsGranted("ROLE_PARTNER")`.
312
+
313
+ **Prouver** : `--issuer nodefony` rend 200 sans Keycloak ; `--issuer keycloak` rend 200 avec le
314
+ profil `keycloak` du compose allumé ; et le MÊME jeton machine est refusé (401) sur une zone qui
315
+ garde `subjectPolicy` par défaut (`require`). Chaque échec de la commande sort en code 1 avec le
316
+ geste qui répare — lis-le avant de chercher ailleurs.
317
+
219
318
  ## Les gestes qui affaiblissent en silence
220
319
 
221
320
  Bloqué par une garde en résolvant autre chose, le réflexe est de la retirer. La fonctionnalité
@@ -244,7 +343,7 @@ Le refus d'un anonyme est gratuit : n'importe quelle zone le donne. Ce qui prouv
244
343
 
245
344
  ```bash
246
345
  npx nodefony security:user:add # un témoin SANS le rôle
247
- # 1. anonyme → refusé (401 ou 403 : les deux sont justes)
346
+ # 1. anonyme → refusé (401 ou 403 ; 302 vers /login si la zone a un entryPoint)
248
347
  # 2. connecté SANS rôle → refusé ← celui-ci porte l'information
249
348
  # 3. administrateur → servi
250
349
  npx nodefony inspect routes --json # la garde est-elle sur la route qu'on croit ?
@@ -256,6 +355,7 @@ Chaque geste ci-dessus a sa référence INSTALLÉE — une recherche ordinaire n
256
355
  ne descendant pas dans `node_modules` :
257
356
 
258
357
  - **Zones, authentificateurs, CSRF, CORS, clés d'API** — `node_modules/@nodefony/security/docs/firewall.md`
358
+ - **Jeton d'un tiers, `subjectPolicy`, `client_credentials`** — `node_modules/@nodefony/security/docs/external-jwt.md`
259
359
  - **`@IsGranted`, voters, hiérarchie de rôles** — `node_modules/@nodefony/security/docs/authorization.md`
260
360
  - **Contrat `IUser`, `UserService`, mot de passe** — `node_modules/@nodefony/user/docs/index.md`
261
361
  - **Politique de contenu, nonce, HSTS** — `node_modules/@nodefony/security/docs/headers.md`
@@ -271,3 +371,4 @@ framework.
271
371
  | Une ressource complète stockée | `nodefony-add-crud` |
272
372
  | Un service métier injectable | `nodefony-add-service` |
273
373
  | Un flux temps réel réservé | `nodefony-add-realtime-channel` |
374
+ | Se connecter, second facteur | `nodefony-authentication` |
@@ -0,0 +1,127 @@
1
+ ---
2
+ name: nodefony-security-review
3
+ description: >
4
+ Relit la sécurité du code d'une application Nodefony avant le commit : un script sur le diff,
5
+ puis une grille de jugement. Le script nomme, fichier et ligne, ce
6
+ qu'un motif suffit à voir — route ouverte à l'anonyme ou hors de toute
7
+ zone du pare-feu, mutation sans jeton anti-falsification, SQL interpolé, secret écrit ou
8
+ journalisé, identité brute qui inclut l'anonyme, garde éteinte, redirection ouverte — avec le
9
+ geste qui corrige et le skill qui le porte ; la grille couvre le droit sur l'objet, les champs
10
+ renvoyés, les entrées non bornées. À charger AVANT de commiter une route,
11
+ une requête, un service qui touche des données ou de la configuration de sécurité. Écrire la
12
+ garde elle-même → `nodefony-protect-route`.
13
+ Déclencheurs : "c'est sûr ?", "est-ce sécurisé ?", "relis la sécurité avant que je commite", "ma route est-elle
14
+ protégée ?", "revue de sécurité", "relecture de sécurité", "audit de sécurité de mon code",
15
+ "y a-t-il une faille", "injection SQL", "secret dans le code", "avant de commiter".
16
+ metadata:
17
+ version: 1.0.0
18
+ ---
19
+
20
+ # nodefony-security-review — relire avant de commiter, faute par faute
21
+
22
+ > **Maintenance** : vérité courante, jamais un journal. Éditer en place ; l'historique vit dans
23
+ > `git log`. Pas de `references/`, comme `nodefony-dev` et pour la même raison mesurée : une page
24
+ > de `references/` n'est presque jamais ouverte par un agent, un script cité comme commande l'est.
25
+ > Ce qui doit atteindre un agent s'EXÉCUTE ou vit dans ce corps.
26
+
27
+ ## Passer la main
28
+
29
+ | Ce que tu fais en réalité | Le skill |
30
+ | ----------------------------------------------------------------------- | ------------------------- |
31
+ | Tu poses la garde : zone, rôle, jeton anti-falsification, voter | `nodefony-protect-route` |
32
+ | Tu touches à la connexion : identité, second facteur, jeton d'un script | `nodefony-authentication` |
33
+ | Tu codes la fonctionnalité elle-même | `nodefony-dev` |
34
+ | Tu sors un secret du source : `env.ts`, `.env`, `npx nodefony env` | `nodefony-dev` |
35
+ | Quelque chose qui marchait ne marche plus | `nodefony-troubleshoot` |
36
+ | Tu prépares l'image, les secrets du déploiement, le proxy TLS | `nodefony-devops` |
37
+
38
+ ## 1. La règle — nommer, trancher, prouver
39
+
40
+ **Une relecture NOMME chaque point**, avec son fichier et sa ligne : « ça me paraît sûr » n'est
41
+ pas une relecture. Puis elle **tranche** chacun : corrigé, ou justifié par écrit (« cette route est
42
+ publique : c'est la page d'accueil »). Un point ni corrigé ni justifié reste ouvert, et se dit.
43
+
44
+ **Ne corrige jamais en affaiblissant.** Retirer une garde qui gêne, couper le jeton
45
+ anti-falsification, élargir une zone à l'anonyme, déplacer une URL sous une autre zone : le diff
46
+ redevient silencieux et la faille est plus grande qu'avant. Une URL déjà publiée ne se déplace pas
47
+ — on adapte la ZONE à l'URL (skill `nodefony-protect-route`).
48
+
49
+ ## 2. Le script — ce qu'un motif suffit à voir
50
+
51
+ ```bash
52
+ node node_modules/@nodefony/devkit/skills/nodefony-security-review/scripts/review-diff.mjs
53
+ node node_modules/@nodefony/devkit/skills/nodefony-security-review/scripts/review-diff.mjs --base main
54
+ node node_modules/@nodefony/devkit/skills/nodefony-security-review/scripts/review-diff.mjs --staged
55
+ node node_modules/@nodefony/devkit/skills/nodefony-security-review/scripts/review-diff.mjs --json
56
+ ```
57
+
58
+ Sans option, il relit le **travail en cours** (non commité, fichiers non suivis compris) ; s'il n'y
59
+ en a pas, le **dernier commit** — la première ligne de sa sortie dit toujours ce qu'il a relu.
60
+ `--base main` relit toute une branche. Sorties : `0` rien à signaler · `1` au moins un point ·
61
+ `64` usage · `66` aucun diff lisible (pas un dépôt git : passe un patch avec `--diff`).
62
+
63
+ Chaque point sort avec son niveau, `fichier:ligne`, la raison et le geste :
64
+
65
+ - **`FAUTE`** — à corriger. Une faute qu'on garde exige une raison écrite, et elle est rare.
66
+ - **`À JUSTIFIER`** — légitime parfois, jamais par accident : une route publique voulue, une
67
+ variable lue hors de `env.ts` pour une bonne raison. Corrige, ou écris la raison.
68
+
69
+ Un secret signalé est **masqué** dans l'extrait (quatre caractères, puis `…`). Ne le recopie pas
70
+ non plus dans ta relecture : nomme sa constante et sa ligne. Une valeur citée dans un compte rendu
71
+ versionné est publiée une fois de plus.
72
+
73
+ ⚠️ **Il croise les routes NOUVELLES avec les zones du pare-feu** lues dans `nodefony/config/` — ce
74
+ que ni `doctor` ni `inspect` ne font. Dans une application générée, la zone `^/api` laisse passer
75
+ l'anonyme : une route ajoutée sous `/api` sans `@IsGranted` répond à n'importe qui, et rien
76
+ d'autre ne le dit. Il ne relit pas les tests (leurs secrets en dur sont des fixtures).
77
+
78
+ ⚠️ **Un outil MCP échappe aux zones.** Ce qu'un module publie par `getMcpTools()` est servi par
79
+ `/nodefony/mcp`, qu'aucune zone du pare-feu ne couvre : tant que `mcp.authorization` n'est pas
80
+ déclaré, seules l'adresse locale et l'origine le gardent. Un outil qui rend des données de compte
81
+ ou qui ÉCRIT se relit comme une route ouverte.
82
+
83
+ ## 3. La grille — ce qu'on cherche, ce qui le montre, ce qui le corrige
84
+
85
+ | Ce qu'on cherche | Ce qui le montre | Le geste juste |
86
+ | ------------------------------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
87
+ | Une route nouvelle que l'anonyme atteint (zone `anonymous`, ou hors zone) | le script (`route-open`) | `@IsGranted("ROLE_…")`, ou une zone qui exige une session → `nodefony-protect-route` |
88
+ | Une mutation sans `@CsrfProtect` sous une zone à session | le script | `@CsrfProtect()` ; le client sème le jeton par un `GET` → `nodefony-protect-route` |
89
+ | « Connecté ? » testé sur l'utilisateur brut | le script (`raw-identity`) | `this.user` dans un controller → `nodefony-authentication` |
90
+ | Une donnée reçue dans du SQL brut | le script | le gabarit `` sql`… ${valeur}` `` de drizzle lie la valeur ; jamais `sql.raw` sur une entrée |
91
+ | Un secret dans le source, un `.env` dans le diff | le script, puis `npx nodefony env` | variable `NF_…` déclarée dans `env.ts`, valeur dans le `.env` du poste, jamais commité ; `npx nodefony env` AVANT et APRÈS prouve qu'elle est lue → `nodefony-dev` |
92
+ | Un mot de passe, un jeton, un cookie dans le journal | le script | journaliser l'identifiant de l'objet, jamais la valeur |
93
+ | Une garde éteinte (`rateLimit`, `unsafe-inline`, secret en clair) | le script | régler un seuil, jamais éteindre → `nodefony-protect-route` |
94
+ | Une redirection vers une adresse reçue | le script | `safeRedirectPath(valeur)`, importé de `nodefony` |
95
+ | Un chemin de fichier, une commande composés avec une entrée | le script | vérifier que le chemin reste sous sa racine ; `execFile` avec un tableau d'arguments, sans shell |
96
+ | Un outil MCP publié (`getMcpTools()`), une porte MCP ouverte au réseau | le script | lecture seule et rien qu'on ne lirait sans compte ; sinon `scopes` + `mcp.authorization` (`node_modules/@nodefony/devkit/docs/index.md`) |
97
+ | Ce qui était DÉJÀ ouvert avant ce diff (`@Anonymous`, zone publique) | `npx nodefony doctor` | famille « Surface ouverte » : l'inventaire, à relire |
98
+ | Un secret suivi par git, une variable non déclarée | `npx nodefony doctor` | familles « Secrets hors de git » et « Variables déclarées » |
99
+ | Une dépendance à vulnérabilité connue | `npm audit --omit=dev` | monter la version corrigée ; jamais `--force` sans lire ce qu'il casse |
100
+
101
+ ## 4. Ce que seul un jugement tranche
102
+
103
+ Le script ne voit pas l'intention. Relis ces trois points toi-même, sur chaque route touchée :
104
+
105
+ 1. **Le droit sur l'OBJET.** Un rôle dit « peut éditer des documents », pas « peut éditer CE
106
+ document ». Une route qui lit un identifiant dans l'URL (`/api/documents/:id`) et renvoie
107
+ l'enregistrement sans vérifier qu'il appartient à l'appelant laisse chacun lire les données des
108
+ autres en changeant un nombre. Le geste : un voter, `@IsGranted("doc.edit", { subject: "id" })`
109
+ → `nodefony-protect-route`.
110
+ 2. **Les champs RENVOYÉS.** Rendre l'entité entière (`this.renderJson(user)`) envoie aussi ce
111
+ qu'on n'a pas choisi — l'empreinte du mot de passe (colonne `password` de `User`), les
112
+ métadonnées internes, les rôles d'un autre compte.
113
+ Rends un objet construit champ par champ.
114
+ 3. **Les entrées NON BORNÉES.** Une liste sans plafond, une taille de page lue telle quelle, un
115
+ fichier reçu sans limite de taille : une seule requête suffit à épuiser le serveur. Plafonne
116
+ (`Math.min(limite, 100)`), et refuse ce qui dépasse plutôt que de le traiter.
117
+
118
+ ## 5. Avant de dire « relu »
119
+
120
+ 1. **Le script est relancé APRÈS les corrections** : il rend `0`, ou chaque point restant porte sa
121
+ justification écrite dans ta réponse.
122
+ 2. **`npx nodefony doctor`** ne signale rien de neuf.
123
+ 3. **Une garde ajoutée est PROUVÉE** par trois appels — anonyme refusé, compte sans le rôle refusé,
124
+ compte avec le rôle servi (skill `nodefony-protect-route`). Le refus de l'anonyme seul ne prouve
125
+ rien : n'importe quelle zone le donne.
126
+ 4. **Nomme ce que tu n'as pas relu** : `npm audit` non lancé, une route dont tu ne sais pas si elle
127
+ doit être publique. Le dire coûte une ligne ; le taire, une faille.