@nodefony/devkit 10.0.0-beta.2 → 10.0.0-beta.4

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 (65) hide show
  1. package/README.md +151 -5
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
  4. package/dist/index.js +24 -11
  5. package/dist/nodefony/config/config.js +16 -137
  6. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  7. package/dist/nodefony/controllers/DevkitController.js +7 -14
  8. package/dist/nodefony/controllers/McpController.js +27 -163
  9. package/dist/nodefony/controllers/ProfileSessionController.js +47 -0
  10. package/dist/nodefony/mcp/observe.js +235 -0
  11. package/dist/nodefony/mcp/profileReport.js +242 -0
  12. package/dist/nodefony/mcp/profileRoute.js +521 -0
  13. package/dist/nodefony/mcp/shotClient.js +84 -0
  14. package/dist/nodefony/mcp/shutdown.js +44 -0
  15. package/dist/nodefony/mcp/tools.js +696 -0
  16. package/dist/nodefony/service/DevkitService.js +93 -29
  17. package/dist/nodefony/src/card.js +1 -2
  18. package/dist/nodefony/src/errors/DevkitError.js +1 -2
  19. package/dist/nodefony/src/profileSession.js +14 -0
  20. package/dist/types/index.d.ts +15 -1
  21. package/dist/types/nodefony/config/config.d.ts +1 -1
  22. package/dist/types/nodefony/controllers/McpController.d.ts +13 -17
  23. package/dist/types/nodefony/controllers/ProfileSessionController.d.ts +31 -0
  24. package/dist/types/nodefony/interfaces/IDevkitService.d.ts +22 -1
  25. package/dist/types/nodefony/mcp/observe.d.ts +83 -0
  26. package/dist/types/nodefony/mcp/profileReport.d.ts +138 -0
  27. package/dist/types/nodefony/mcp/profileRoute.d.ts +87 -0
  28. package/dist/types/nodefony/mcp/shotClient.d.ts +53 -0
  29. package/dist/types/nodefony/mcp/shutdown.d.ts +25 -0
  30. package/dist/types/nodefony/mcp/tools.d.ts +109 -0
  31. package/dist/types/nodefony/service/DevkitService.d.ts +41 -4
  32. package/dist/types/nodefony/src/profileSession.d.ts +12 -0
  33. package/docs/index.md +217 -20
  34. package/package.json +17 -12
  35. package/skills/nodefony-add-crud/SKILL.md +5 -0
  36. package/skills/nodefony-add-realtime-channel/SKILL.md +66 -9
  37. package/skills/nodefony-add-service/SKILL.md +14 -3
  38. package/skills/nodefony-authentication/SKILL.md +234 -0
  39. package/skills/nodefony-browser/SKILL.md +65 -21
  40. package/skills/nodefony-browser/references/socket.md +13 -4
  41. package/skills/nodefony-browser/references/sondes.md +13 -0
  42. package/skills/nodefony-browser/scripts/audit.mjs +59 -10
  43. package/skills/nodefony-browser/scripts/inspect.mjs +123 -5
  44. package/skills/nodefony-browser/scripts/lib/browser.mjs +112 -6
  45. package/skills/nodefony-browser/scripts/lib/probes.mjs +219 -7
  46. package/skills/nodefony-browser/scripts/socket.mjs +70 -4
  47. package/skills/nodefony-browser/scripts/watch.mjs +101 -0
  48. package/skills/nodefony-dev/SKILL.md +78 -82
  49. package/skills/nodefony-dev/scripts/docs.mjs +24 -8
  50. package/skills/nodefony-devops/SKILL.md +98 -74
  51. package/skills/nodefony-devops/references/compose.md +8 -0
  52. package/skills/nodefony-devops/references/dimensionnement.md +122 -0
  53. package/skills/nodefony-devops/references/frontal.md +282 -48
  54. package/skills/nodefony-devops/references/kubernetes.md +41 -14
  55. package/skills/nodefony-devops/references/secrets.md +30 -5
  56. package/skills/nodefony-devops/references/variables.md +66 -31
  57. package/skills/nodefony-migrate-schema/SKILL.md +5 -2
  58. package/skills/nodefony-migrate-schema/references/verdicts.md +20 -19
  59. package/skills/nodefony-protect-route/SKILL.md +124 -21
  60. package/skills/nodefony-security-review/SKILL.md +127 -0
  61. package/skills/nodefony-security-review/scripts/review-diff.mjs +1090 -0
  62. package/skills/nodefony-troubleshoot/SKILL.md +225 -0
  63. package/skills/nodefony-troubleshoot/scripts/log-errors.mjs +465 -0
  64. package/skills/nodefony-upgrade/SKILL.md +130 -0
  65. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
@@ -5,7 +5,7 @@
5
5
  ## 1. La commande qui répond, et à laquelle rien ne ment
6
6
 
7
7
  ```bash
8
- npx nodefony env # la cascade des .env, la valeur EFFECTIVE de chaque variable, et sa PROVENANCE
8
+ npx nodefony env # la valeur EFFECTIVE de chaque variable, et sa PROVENANCE (.env ou process)
9
9
  npx nodefony env --json # le même rapport, pour un script
10
10
  ```
11
11
 
@@ -19,8 +19,11 @@ npx nodefony inspect schema <module> # les clés qui EXISTENT, leur type,
19
19
  npx nodefony doctor --env production # ce qui manquerait là-bas, depuis ici
20
20
  ```
21
21
 
22
- > 🔴 **`inspect schema` avant d'écrire une clé.** Une clé inconnue n'est pas refusée : elle est
23
- > **retirée en silence** à la validation. Tu poses le réglage, rien ne change, rien ne le dit.
22
+ > 🔴 **`inspect schema` avant d'écrire une clé.** Une clé inconnue **arrête le démarrage**
23
+ > (code 78) en la nommant — y compris une clé qu'une version du framework a retirée, que tu
24
+ > découvres alors au premier déploiement après un `npm update`. Ce qui reste muet, c'est une
25
+ > **variable** mal orthographiée (`NF_PROT` au lieu de `NF_PORT`) : inconnue, donc ignorée, et
26
+ > seul `npx nodefony env` la montre.
24
27
 
25
28
  ## 2. Deux grammaires, et elles ne font pas la même chose
26
29
 
@@ -54,10 +57,33 @@ booléen, un nombre devient un nombre, `[…]` et `{…}` sont lus en JSON, une
54
57
  devient un tableau, le reste reste une chaîne. C'est ce qui évite le piège classique où la chaîne
55
58
  `"false"` est convertie en booléen **vrai**.
56
59
 
60
+ ### `NF_<MODULE>_<RÉGLAGE>` — les noms courts des modules
61
+
62
+ Un module déclare sur un champ de son schéma le nom court qui le règle (`NF_REDIS_HOST`,
63
+ `NF_REALTIME_DRIVER`…). `npx nodefony env --example` les écrit dans `.env.example`, une section
64
+ par module. Trois règles, toutes au démarrage :
65
+
66
+ - La valeur est **validée par le schéma** : `NF_REDIS_PORT=abc` arrête le démarrage (78) en
67
+ nommant la VARIABLE, pas le seul champ.
68
+ - **Nom court ET `NF__…` sur le même champ** : refus du démarrage en production (78) ; en
69
+ développement, `NF__…` l'emporte avec un avertissement qui nomme les deux.
70
+ - Chaque nom court accepte `<NOM>_FILE` ; une variable vide vaut absente.
71
+
72
+ ### Lire la notice `.env.example`
73
+
74
+ Chaque variable y porte ses métadonnées : `@required` / `@optional`, `@required=forEnv(production)`,
75
+ `@requiredWhen="…"` (exigée sous condition — `NF_JWT_KEYSET` : « en production, si l'application
76
+ émet des jetons »), `@sensitive`, `@default`, et **`@placement`**, qui dit où la poser en
77
+ production : `platform` (variables du déploiement), `secrets` (gestionnaire de secrets, ou
78
+ `<NOM>_FILE`), `workstation` (le poste seulement — ne la pose pas en production).
79
+ `npm run env:check` échoue (78) quand la notice a dérivé d'`env.ts`.
80
+
57
81
  ## 3. Le suffixe `_FILE` — les secrets montés
58
82
 
59
- **Toute** variable du catalogue accepte un `_FILE`. Le contenu du fichier est lu, retour à la ligne
60
- final retiré.
83
+ Les variables du catalogue acceptent un `_FILE`, **URL d'infrastructure comprises**
84
+ (`NF_DATABASE_URL_FILE`, `NF_REDIS_URL_FILE`, et leurs alias de plateforme). Le contenu du fichier
85
+ est lu, retour à la ligne final retiré. Une seule règle les tranche toutes : le tableau ci-dessous
86
+ vaut pour l'URL de la base comme pour un secret de session.
61
87
 
62
88
  ```yaml
63
89
  env:
@@ -93,32 +119,41 @@ Les seules qui te concernent au déploiement. Le catalogue complet, c'est `npx n
93
119
 
94
120
  ### Derrière un frontal — **les plus importantes**
95
121
 
96
- | Variable | Rôle | Absente ⇒ |
97
- | ---------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------ |
98
- | `NF__HTTP__TRUSTPROXY` | Quels intermédiaires sont crus sur leurs en-têtes | **Le schéma constaté est `http`** → cookie `__Host-` non émis, adresse client fausse |
99
- | `NF__HTTP__TRUSTEDHOSTS` | Les hôtes servis | Un `Host` étranger n'est pas refusé |
100
- | `NF__APP__DOMAINCHECK` | Contrôle du domaine | Pas de contrôle |
101
- | `NF__HTTP__STATICS__ENABLED` | L'application sert-elle les fichiers statiques | Elle les sert — donc en double avec ton frontal |
122
+ | Variable | Rôle | Absente ⇒ |
123
+ | ---------------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
124
+ | `NF__HTTP__TRUSTPROXY` | Quels intermédiaires sont crus sur leurs en-têtes | **Le schéma constaté est `http`** → cookie de session sans `__Host-`, mot de passe refusé `403`, adresse client fausse |
125
+ | `NF__HTTP__TRUSTEDHOSTS` | Les hôtes servis | Un `Host` étranger n'est pas refusé |
126
+ | `NF__APP__DOMAINCHECK` | Contrôle du domaine | Pas de contrôle |
127
+ | `NF__HTTP__STATICS__ENABLED` | L'application sert-elle les fichiers statiques | Elle les sert — donc en double avec ton frontal |
102
128
 
103
129
  ### Données
104
130
 
105
- | Variable | Rôle | Absente ⇒ |
106
- | ----------------- | ---------------------- | ---------------------------------------------------- |
107
- | `NF_DATABASE_URL` | L'URL de la base | **Repli sur SQLite local**, dans `var/` — silencieux |
108
- | `NF_REDIS_URL` | L'URL du cache partagé | Le cache n'est pas chargé |
131
+ | Variable | Rôle | Absente ⇒ |
132
+ | ----------------- | ---------------------- | ------------------------------------------------- |
133
+ | `NF_DATABASE_URL` | L'URL de la base | **Repli sur SQLite local**, dans `var/` — annoncé |
134
+ | `NF_REDIS_URL` | L'URL du cache partagé | Le cache n'est pas chargé |
109
135
 
110
136
  > Le repli SQLite est le piège d'exploitation classique : l'application **démarre**, sert des
111
- > requêtes, écrit dans un fichier — et chaque exemplaire a sa propre base. Rien n'est en erreur.
112
- > `npx nodefony env` le montre en une ligne.
113
-
114
- ### Secrets — obligatoires en production
115
-
116
- | Variable | Ce qu'il protège |
117
- | ------------------- | ------------------------------------------------ |
118
- | `NF_CSRF_SECRET` | Les jetons anti-CSRF, partagés entre exemplaires |
119
- | `NF_TOTP_KEY` | Les secrets de double authentification au repos |
120
- | `NF_WEBHOOK_KEY` | Les signatures de webhooks |
121
- | `NF_ADMIN_PASSWORD` | Le compte d'administration |
137
+ > requêtes, écrit dans un fichier — et chaque exemplaire a sa propre base. Il est ANNONCÉ
138
+ > (avertissement au démarrage, point `DB_SQLITE_FALLBACK` dans `npx nodefony status --json`),
139
+ > mais rien n'échoue : lis le bilan du démarrage. Seule exception : si une entité est écrite pour
140
+ > un autre moteur (PostgreSQL, MySQL), le repli est **refusé** et le démarrage s'arrête.
141
+
142
+ ### Secrets — ce qui se passe en production sans eux
143
+
144
+ | Variable | Ce qu'il protège | Absente en production ⇒ |
145
+ | ------------------- | ------------------------------------------------ | --------------------------------------------------- |
146
+ | `NF_CSRF_SECRET` | Les jetons anti-CSRF, partagés entre exemplaires | **Refus de démarrer** (78) |
147
+ | `NF_JWT_KEYSET` | La clé de signature des jetons, commune à tous | **Refus de démarrer** si l'app émet des jetons |
148
+ | `NF_TOTP_KEY` | Les secrets de double authentification au repos | Double authentification désactivée (journal CRITIC) |
149
+ | `NF_WEBHOOK_KEY` | Les signatures de webhooks | Webhooks désactivés (journal CRITIC) |
150
+ | `NF_ADMIN_PASSWORD` | Le compte d'administration, au PREMIER démarrage | Aucun compte créé ; inutile une fois le compte créé |
151
+
152
+ `NF_JWT_KEYSET` manque presque toujours au premier déploiement : sans elle, un exemplaire qui
153
+ émet des jetons **refuse de démarrer** en production (78) — et deux exemplaires à clés
154
+ différentes refuseraient chacun les jetons de l'autre. `npx nodefony security:secrets --jwt-keyset`
155
+ la produit (une ligne JSON) ; `--env` rend les autres secrets en lignes `CLÉ=valeur`, prêtes pour
156
+ `docker run --env-file` ou un gestionnaire de secrets.
122
157
 
123
158
  Tous en `_FILE` en production. Détail et rotation → [`secrets.md`](secrets.md).
124
159
 
@@ -129,7 +164,7 @@ Tous en `_FILE` en production. Détail et rotation → [`secrets.md`](secrets.md
129
164
  | `NF_POD_NAME` | Identité de l'exemplaire — **dérive l'identité d'origine du bus temps réel** |
130
165
  | `NF_INSTANCE_ID` | Identifiant d'exemplaire |
131
166
  | `NF_REALTIME_DRIVER` | Le transport du bus entre exemplaires |
132
- | `NF_REALTIME_BACKPLANE_SECRET` | Scelle les enveloppes échangées sur le bus |
167
+ | `NF_REALTIME_BACKPLANE_SECRET` | Scelle les enveloppes du bus — moins de 32 caractères arrête le démarrage |
133
168
  | `NF_REALTIME_BACKPLANE_NAMESPACE` | Cloisonne deux applications qui partagent le même bus |
134
169
 
135
170
  > 🔴 **Deux applications sur un même cache, sans espace de noms distinct, se parlent.** Le symptôme
@@ -137,10 +172,10 @@ Tous en `_FILE` en production. Détail et rotation → [`secrets.md`](secrets.md
137
172
 
138
173
  ### Multi-process sur une seule machine
139
174
 
140
- | Variable | Rôle |
141
- | ------------ | -------------------------------------------------------- |
142
- | `NF_CLUSTER` | Active le mode multi-process |
143
- | `NF_WORKERS` | Nombre d'exécutants — sinon dérivé des limites du cgroup |
175
+ | Variable | Rôle |
176
+ | ------------ | ------------------------------------------------------------------------------ |
177
+ | `NF_CLUSTER` | Posée par `nodefony cluster` dans chaque worker — **ne pas la poser soi-même** |
178
+ | `NF_WORKERS` | Nombre d'exécutants — sinon dérivé des limites du cgroup |
144
179
 
145
180
  En Kubernetes, **laisse ça tranquille** : un exemplaire = un processus = un pod, et c'est
146
181
  l'ordonnanceur qui multiplie. Le mode multi-process sert sur une machine nue.
@@ -259,10 +259,13 @@ neuve ne prouve RIEN de tout ça — elle est vide.
259
259
  ```bash
260
260
  # sqlite : une copie du fichier. Le chemin par défaut du connecteur `default` est
261
261
  # `var/databases/nodefony-drizzle.db` — `orm:migrate:status --json` le confirme.
262
- cp var/databases/nodefony-drizzle.db /tmp/essai.sqlite
262
+ # La copie va sous `tmp/scratch/` de l'application, pas dans le /tmp du système :
263
+ # c'est un résultat qu'on relit, et il doit disparaître avec le reste de `tmp/`.
264
+ mkdir -p tmp/scratch
265
+ cp var/databases/nodefony-drizzle.db tmp/scratch/essai.sqlite
263
266
  # PostgreSQL : CREATE DATABASE app_essai TEMPLATE app; (ou une restauration de sauvegarde)
264
267
 
265
- export NF_MIGRATE_DATABASE_URL="sqlite:/tmp/essai.sqlite"
268
+ export NF_MIGRATE_DATABASE_URL="sqlite:tmp/scratch/essai.sqlite"
266
269
  npx nodefony orm:migrate --json
267
270
  ```
268
271
 
@@ -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
@@ -93,10 +135,12 @@ n'en envoie aucun, et la défense de provenance le laisse alors passer — c'est
93
135
  distingue les sites, pas les intentions. Résultat mesuré : un `POST /api/cart/items` sans jeton rend
94
136
  `201`, et l'application croit avoir une défense.
95
137
 
96
- Toute action qui ÉCRIT porte donc `@CsrfProtect` explicitement. Le jeton ne se demande à AUCUN
97
- endpoint : une requête sûre (`GET`) vers la route protégée sème le cookie lisible `csrf-token`, et
98
- la mutation le rejoue dans l'en-tête `x-csrf-token` — c'est le double-submit, sinon `403`. La
99
- provenance et le jeton se **cumulent** ; l'une ne remplace jamais l'autre.
138
+ Toute action qui ÉCRIT porte donc `@CsrfProtect` explicitement. N'écris AUCUNE route pour distribuer
139
+ le jeton : `GET /nodefony/security/api/auth/me` le sème déjà (cookie lisible `csrf-token`), comme
140
+ toute requête sûre vers une route décorée — et il vaut pour TOUTES les routes protégées. La
141
+ mutation le rejoue dans l'en-tête `x-csrf-token` — c'est le double-submit, sinon `403`. Un test le
142
+ récupère par `csrfHeaders(base, cookie)` de `nodefony/testing`. La provenance et le jeton se
143
+ **cumulent** ; l'une ne remplace jamais l'autre.
100
144
 
101
145
  ## 🔴 Ce qu'il ne faut jamais écrire
102
146
 
@@ -171,9 +215,16 @@ connecter — ou pire, un mot de passe stocké en clair.
171
215
 
172
216
  ## Lire l'utilisateur courant
173
217
 
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é.
218
+ Dans un controller, **`this.user`** : l'utilisateur CONNECTÉ, ou `null` (anonyme, ou route hors
219
+ de toute zone). Le paramètre décoré `@CurrentUser()` rend le même, `undefined` pour l'anonyme.
220
+ Hors controller (un service), `RequestContext.getAuthenticatedUser()` — `null` hors requête ou
221
+ pour l'anonyme — et `RequestContext.getUserId()` (`string | null`). `RequestContext.getUser()`
222
+ rend l'utilisateur BRUT, anonyme compris : ne t'en sers pas pour décider « qui est connecté ».
223
+
224
+ L'identité est **ré-résolue à chaque requête** : les rôles sont frais, et une révocation prend
225
+ effet tout de suite. N'écris pas ton propre lecteur de session — le tien lira un instantané. Une
226
+ page PUBLIQUE qui veut savoir qui la visite se place sous une zone
227
+ `authenticators: ["session", "anonymous"]` : hors zone, `this.user` vaut toujours `null`.
177
228
 
178
229
  ## Un droit métier qui ne se réduit pas à un rôle
179
230
 
@@ -190,8 +241,16 @@ d'appartenance à écrire dans l'action.
190
241
 
191
242
  ## Une API pour un PROGRAMME, pas pour un navigateur
192
243
 
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é :
244
+ Un service partenaire, un script, un agent ne stockent aucun cookie. Le
245
+ `nodefony/config/security.ts` généré (preset `complete`) pose **deux zones, une par sorte de
246
+ preuve** — choisis selon ce que l'appelant détient, n'en ajoute pas une troisième :
247
+
248
+ | L'appelant présente | Zone | Préfixe | Obtenir la preuve |
249
+ | ---------------------------------------------- | --------- | -------------- | --------------------------------------- |
250
+ | une **clé d'API** opaque (`nf_…`) | `machine` | `/api/machine` | `POST /nodefony/security/api/keys` |
251
+ | un **jeton d'accès** (de l'app ou de Keycloak) | `partner` | `/api/partner` | `npx nodefony app:partner:call` le fait |
252
+
253
+ ### Clé d'API : la zone `machine`
195
254
 
196
255
  ```ts
197
256
  machine: {
@@ -216,6 +275,48 @@ qui ne stocke pas de cookie ne doit rien recevoir qu'il faille stocker.**
216
275
 
217
276
  Les clés s'émettent par `POST /nodefony/security/api/keys`.
218
277
 
278
+ ### Jeton d'accès : la zone `partner`, et la commande qui l'éprouve
279
+
280
+ La zone accepte un jeton signé par l'application (`jwt`) **et** un jeton de Keycloak
281
+ (`external-jwt`) ; les deux s'aiguillent sur l'émetteur, leur ordre ne décide de rien. Une route
282
+ qui tombe sous `/api/partner` est protégée sans rien ajouter. L'exemple qui marche est déjà dans
283
+ l'application — **lis-le avant d'écrire un appelant** :
284
+
285
+ ```bash
286
+ npx nodefony app:partner:call # jeton signé par l'app pour `admin` (--as <compte>)
287
+ npx nodefony app:partner:call --issuer keycloak # client <app>-machine, flux client_credentials
288
+ ```
289
+
290
+ Source : `nodefony/command/PartnerCallCommand.ts` (la commande) et
291
+ `nodefony/controllers/PartnerController.ts` (`GET /api/partner/whoami`, qui rend l'identité ET
292
+ l'émetteur). Un jeton Keycloak s'obtient par `requestClientCredentialsToken` de
293
+ `@nodefony/security` — découverte du point de jeton comprise —, jamais par un `fetch` recopié.
294
+
295
+ 🔴 **L'audience vit en UN endroit.** Un jeton n'ouvre que la zone dont il porte la ressource
296
+ (RFC 8707). `partnerResource(ctx.env)` est lue par la zone (`resource`), par `jwt.audiences` et par
297
+ la commande. Une ressource recopiée à la main finit par différer, et le seul symptôme est un refus
298
+ sans explication. Ouvrir une AUTRE ressource à des jetons, c'est trois gestes, pas un :
299
+
300
+ | Oublié | Symptôme |
301
+ | ----------------------------------------- | ------------------------------------------------------------- |
302
+ | la ressource dans `jwt.audiences` | **400 `invalid_target`** à l'émission du jeton de l'app |
303
+ | `security:keycloak:realm --write` relancé | **401** du jeton Keycloak : le realm n'inscrit pas l'audience |
304
+ | la `resource` sur la zone | l'application **refuse de démarrer** en nommant la zone |
305
+
306
+ 🔴 **`subjectPolicy: "ephemeral"` s'écrit SUR LA ZONE, jamais dans `resourceServer`.** Le client
307
+ machine de Keycloak n'a pas de compte ici : `ephemeral` l'accepte le temps de la requête, avec les
308
+ seuls `ephemeralRoles` de la zone (`ROLE_PARTNER`). Posé dans `resourceServer.subjectPolicy`
309
+ (global), il ferait accepter un appelant sans compte par **toutes** les zones `external-jwt`. Et
310
+ un rôle fort dans `ephemeralRoles` est accordé à **quiconque** détient un jeton valide du realm.
311
+
312
+ `whoami` n'exige pas `ROLE_PARTNER` — un compte de l'app ne l'a pas. Pour réserver une action aux
313
+ partenaires : `@IsGranted("ROLE_PARTNER")`.
314
+
315
+ **Prouver** : `--issuer nodefony` rend 200 sans Keycloak ; `--issuer keycloak` rend 200 avec le
316
+ profil `keycloak` du compose allumé ; et le MÊME jeton machine est refusé (401) sur une zone qui
317
+ garde `subjectPolicy` par défaut (`require`). Chaque échec de la commande sort en code 1 avec le
318
+ geste qui répare — lis-le avant de chercher ailleurs.
319
+
219
320
  ## Les gestes qui affaiblissent en silence
220
321
 
221
322
  Bloqué par une garde en résolvant autre chose, le réflexe est de la retirer. La fonctionnalité
@@ -244,7 +345,7 @@ Le refus d'un anonyme est gratuit : n'importe quelle zone le donne. Ce qui prouv
244
345
 
245
346
  ```bash
246
347
  npx nodefony security:user:add # un témoin SANS le rôle
247
- # 1. anonyme → refusé (401 ou 403 : les deux sont justes)
348
+ # 1. anonyme → refusé (401 ou 403 ; 302 vers /login si la zone a un entryPoint)
248
349
  # 2. connecté SANS rôle → refusé ← celui-ci porte l'information
249
350
  # 3. administrateur → servi
250
351
  npx nodefony inspect routes --json # la garde est-elle sur la route qu'on croit ?
@@ -256,6 +357,7 @@ Chaque geste ci-dessus a sa référence INSTALLÉE — une recherche ordinaire n
256
357
  ne descendant pas dans `node_modules` :
257
358
 
258
359
  - **Zones, authentificateurs, CSRF, CORS, clés d'API** — `node_modules/@nodefony/security/docs/firewall.md`
360
+ - **Jeton d'un tiers, `subjectPolicy`, `client_credentials`** — `node_modules/@nodefony/security/docs/external-jwt.md`
259
361
  - **`@IsGranted`, voters, hiérarchie de rôles** — `node_modules/@nodefony/security/docs/authorization.md`
260
362
  - **Contrat `IUser`, `UserService`, mot de passe** — `node_modules/@nodefony/user/docs/index.md`
261
363
  - **Politique de contenu, nonce, HSTS** — `node_modules/@nodefony/security/docs/headers.md`
@@ -271,3 +373,4 @@ framework.
271
373
  | Une ressource complète stockée | `nodefony-add-crud` |
272
374
  | Un service métier injectable | `nodefony-add-service` |
273
375
  | Un flux temps réel réservé | `nodefony-add-realtime-channel` |
376
+ | 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.