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

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 (55) hide show
  1. package/README.md +146 -4
  2. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/_virtual/{_@oxc-project_runtime@0.153.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 +25 -226
  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/errors/DevkitError.js +1 -2
  18. package/dist/nodefony/src/profileSession.js +14 -0
  19. package/dist/types/index.d.ts +15 -1
  20. package/dist/types/nodefony/config/config.d.ts +1 -1
  21. package/dist/types/nodefony/controllers/McpController.d.ts +11 -26
  22. package/dist/types/nodefony/controllers/ProfileSessionController.d.ts +31 -0
  23. package/dist/types/nodefony/interfaces/IDevkitService.d.ts +22 -1
  24. package/dist/types/nodefony/mcp/observe.d.ts +83 -0
  25. package/dist/types/nodefony/mcp/profileReport.d.ts +138 -0
  26. package/dist/types/nodefony/mcp/profileRoute.d.ts +87 -0
  27. package/dist/types/nodefony/mcp/shotClient.d.ts +53 -0
  28. package/dist/types/nodefony/mcp/shutdown.d.ts +25 -0
  29. package/dist/types/nodefony/mcp/tools.d.ts +109 -0
  30. package/dist/types/nodefony/service/DevkitService.d.ts +41 -4
  31. package/dist/types/nodefony/src/profileSession.d.ts +12 -0
  32. package/docs/index.md +160 -11
  33. package/package.json +16 -11
  34. package/skills/nodefony-add-crud/SKILL.md +10 -1
  35. package/skills/nodefony-add-service/SKILL.md +3 -3
  36. package/skills/nodefony-authentication/SKILL.md +5 -5
  37. package/skills/nodefony-browser/SKILL.md +20 -0
  38. package/skills/nodefony-browser/references/socket.md +13 -4
  39. package/skills/nodefony-browser/references/sondes.md +13 -1
  40. package/skills/nodefony-browser/scripts/inspect.mjs +55 -8
  41. package/skills/nodefony-browser/scripts/lib/probes.mjs +53 -0
  42. package/skills/nodefony-browser/scripts/socket.mjs +70 -4
  43. package/skills/nodefony-browser/scripts/watch.mjs +101 -0
  44. package/skills/nodefony-dev/SKILL.md +31 -1
  45. package/skills/nodefony-dev/scripts/docs.mjs +3 -3
  46. package/skills/nodefony-devops/SKILL.md +85 -64
  47. package/skills/nodefony-devops/references/dimensionnement.md +122 -0
  48. package/skills/nodefony-devops/references/frontal.md +277 -47
  49. package/skills/nodefony-devops/references/kubernetes.md +41 -14
  50. package/skills/nodefony-devops/references/secrets.md +13 -2
  51. package/skills/nodefony-devops/references/variables.md +14 -12
  52. package/skills/nodefony-protect-route/SKILL.md +6 -4
  53. package/skills/nodefony-security-review/scripts/review-diff.mjs +4 -4
  54. package/skills/nodefony-troubleshoot/SKILL.md +86 -12
  55. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
@@ -1,28 +1,49 @@
1
- # Derrière un frontal — nginx, haproxy, Ingress
1
+ # Derrière un frontal — nginx, HAProxy, h2c, Ingress, Gateway API, Traefik
2
2
 
3
3
  > **Maintenance** : vérité courante. Éditer en place.
4
4
 
5
5
  ## 1. La configuration se DÉRIVE, elle ne s'écrit pas
6
6
 
7
7
  ```bash
8
- npx nodefony proxy:generate nginx [-o <fichier>] [-b <hôte>] [-l <port>] [--reencrypt]
9
- npx nodefony proxy:generate haproxy
8
+ npx nodefony proxy:generate nginx # sur la sortie standard, pour lire avant d'installer
9
+ npx nodefony proxy:generate haproxy -o haproxy.cfg
10
10
  ```
11
11
 
12
- La commande **démarre l'application sans ouvrir de port**, lit ses montages statiques réels, sa
13
- taille de corps maximale et son battement de cœur temps réel, puis écrit la configuration.
12
+ | Option | Rôle |
13
+ | ----------------------------------------- | ----------------------------------------------------------------------------- |
14
+ | `-o, --out <fichier>` | écrire dans un fichier plutôt que sur la sortie standard |
15
+ | `-b, --backend <hôte>` | l'adresse à laquelle le frontal joint l'application (défaut `127.0.0.1`) |
16
+ | `-l, --listen <port>` | le port d'écoute du frontal (défaut `80`) |
17
+ | `--reencrypt` | TLS jusqu'à l'application (port HTTPS) au lieu du clair sur le réseau interne |
18
+ | `--tls-cert`, `--tls-key`, `--tls-listen` | nginx seulement : le frontal termine le TLS (écoute `443` par défaut) |
19
+ | `--assets-root <dossier>` | servir tous les fichiers statiques depuis un seul arbre (`assets:publish`) |
20
+
21
+ La commande **démarre l'application sans ouvrir de port** et lit sa configuration **réelle** :
22
+ ports, domaines de confiance, dossiers statiques montés par chaque module, taille de corps
23
+ maximale, battement de cœur WebSocket, port h2c. 🔴 Elle lit la configuration **de l'environnement
24
+ où elle tourne** : lance-la avec les variables de production, sinon tu obtiens la configuration
25
+ d'un autre déploiement — par exemple sans h2c.
14
26
 
15
27
  **Pourquoi ça compte** : une configuration écrite à la main est juste le jour où on l'écrit. Au
16
28
  premier dossier statique ajouté, à la première route de téléversement, elle devient fausse — et
17
- elle ne le dit pas : elle sert un 404 ou un 413 que personne ne rattache à la configuration du
18
- frontal.
29
+ elle ne le dit pas : elle sert un 404 ou un 413 que personne ne rattache au frontal.
19
30
 
20
- Dans l'image, cette génération est un étage de construction : la configuration est produite **au
21
- moment où l'image est construite**, donc à partir du code exact qu'elle embarque.
31
+ ## 2. Ce que le générateur pose, et pourquoi
22
32
 
23
- ## 2. Le point qui casse tout si on l'oublie
33
+ | Ce qui est généré | Ce que ça évite |
34
+ | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
35
+ | `X-Forwarded-For` **écrasé** avec l'adresse vue du client | un client qui s'invente une adresse |
36
+ | `Upgrade` / `Connection` relayés | une WebSocket qui « ne se connecte pas », sans le moindre message |
37
+ | inactivité = **4 battements WebSocket, plancher 300 s** | un frontal qui coupe des sockets **vivantes** ; nginx `proxy_read_timeout`/`proxy_send_timeout`, HAProxy `timeout client`/`server` |
38
+ | HAProxy : `timeout tunnel` à la même valeur | une fois passée en WebSocket, la connexion n'obéit plus à `timeout server` : sans cette ligne, le défaut la coupe |
39
+ | `client_max_body_size` dérivé de la taille de corps de l'application | un `413` de nginx (1 Mo par défaut) que l'application n'a jamais vu passer |
40
+ | une chaîne de `try_files` sur **toutes** les racines statiques | les fichiers d'un module en `404` alors qu'ils sont bien montés |
41
+ | HAProxy : `Forwarded` entrant **effacé** avant le nôtre | un en-tête forgé conservé à côté du vrai |
42
+ | HAProxy : le schéma **constaté** sur la connexion entrante (`ssl_fc`) | annoncer `https` pour un client venu en clair — cookies `Secure` posés sur du clair |
43
+ | HAProxy : `option httpchk GET /readyz` dans chaque backend | un exemplaire qui draine gardé dans la table — un contrôle TCP le croit sain tant que son port accepte (§6) |
44
+ | nginx avec TLS : HTTP/2 côté navigateur (`http2 on`), HTTP/1.1 vers l'application | le plafond d'environ six connexions par origine est celui du navigateur envers le **frontal** : c'est là que le multiplexage compte |
24
45
 
25
- 🔴 **`NF__HTTP__TRUSTPROXY`**
46
+ ## 3. Le point qui casse tout si on l'oublie — `NF__HTTP__TRUSTPROXY`
26
47
 
27
48
  Quand ton frontal termine le TLS, l'application reçoit du **HTTP en clair**, plus un en-tête qui
28
49
  annonce le schéma d'origine. Sans réglage, elle ne croit pas cet en-tête — et c'est la bonne
@@ -30,10 +51,12 @@ position par défaut, parce qu'un en-tête est trivialement falsifiable par un c
30
51
 
31
52
  Conséquence si tu ne le poses pas : le schéma constaté est `http`, donc :
32
53
 
33
- - **le cookie `__Host-` n'est pas émis** — ce préfixe impose qu'il soit posé sur une origine
34
- sécurisée. Les sessions ne tiennent pas, **sans aucun message** ;
35
- - l'adresse cliente enregistrée est celle du frontal, donc l'audit et toute limitation par adresse
36
- portent sur une seule IP ;
54
+ - **le cookie de session part sans le préfixe `__Host-`** — ce préfixe n'est posé que sur une
55
+ origine que l'application sait sécurisée. La session marche, mais perd ce qu'il garantit (pas de
56
+ `Domain`, donc pas de fixation de session depuis un sous-domaine), **sans aucun message** ;
57
+ - l'adresse cliente enregistrée est celle du frontal : l'audit, la limitation de débit et le
58
+ plafond de connexions par adresse portent sur **une seule** IP — une vague de reconnexions
59
+ WebSocket se fait alors refuser en `429` pour tout le monde ;
37
60
  - les redirections peuvent pointer sur `http://` ;
38
61
  - **toute connexion par mot de passe répond `403`** (« doit passer par HTTPS ») : en production,
39
62
  un secret reçu sur une requête que l'application croit en clair est refusé avant toute
@@ -44,8 +67,12 @@ Conséquence si tu ne le poses pas : le schéma constaté est `http`, donc :
44
67
  NF__HTTP__TRUSTPROXY: uniquelocal # le frontal est sur un réseau privé
45
68
  ```
46
69
 
47
- **Le prouver, plutôt que le croire** — c'est un réglage dont l'effet est invisible tant qu'on ne
48
- regarde pas le bon endroit :
70
+ Jamais `true` si l'application est joignable par un autre chemin que le frontal : n'importe quel
71
+ client pourrait alors écrire sa propre adresse. Dans une **cascade** (équilibreur d'hébergeur →
72
+ frontal → application), chaque saut doit être de confiance : la remontée de l'adresse se fait de
73
+ droite à gauche et s'arrête au premier inconnu.
74
+
75
+ **Le prouver, plutôt que le croire** :
49
76
 
50
77
  ```bash
51
78
  curl -ksSI https://<hôte>/
@@ -55,19 +82,20 @@ curl -ksSI https://<hôte>/
55
82
  Puis **débranche-le** : retire la variable, redéploie, refais la requête. Le cookie doit tomber. Un
56
83
  réglage qu'on n'a jamais vu manquer n'est pas un réglage prouvé.
57
84
 
58
- ## 3. Les autres réglages du mode « derrière un frontal »
85
+ ## 4. Les autres réglages du mode « derrière un frontal »
59
86
 
60
- | Variable | À poser | Ce qu'elle fait |
61
- | ---------------------------- | --------------------------- | ---------------------------------------------------------------------------------------- |
62
- | `NF__HTTP__TRUSTEDHOSTS` | les hôtes réellement servis | Un `Host` étranger est refusé — coupe l'empoisonnement d'en-tête |
63
- | `NF__APP__DOMAINCHECK` | `"true"` | Contrôle du domaine actif |
64
- | `NF__HTTP__STATICS__ENABLED` | `"false"` | **Le frontal sert les fichiers statiques** ; l'application cesse de les servir en double |
87
+ | Variable | À poser | Ce qu'elle fait |
88
+ | ---------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
89
+ | `NF__HTTP__TRUSTEDHOSTS` | les hôtes réellement servis | Un `Host` étranger est refusé — coupe l'empoisonnement d'en-tête |
90
+ | `NF__APP__DOMAINCHECK` | `"true"` | Contrôle du domaine actif |
91
+ | `NF__HTTP__STATICS__ENABLED` | `"false"` | **Le frontal sert les fichiers statiques** ; l'application cesse de les servir en double |
92
+ | `NF_OAUTH_REDIRECT_BASE` | l'URL publique, sans chemin | Connexion par un fournisseur d'identité : sans elle, l'adresse de retour vaut `https://localhost:<port>` et le fournisseur refuse (`redirect_uri`) |
65
93
 
66
94
  Laisser l'application servir les statiques derrière un frontal qui les sert aussi n'est pas une
67
95
  erreur fonctionnelle — c'est un gaspillage silencieux : chaque fichier peut être servi par le
68
96
  mauvais des deux, avec des en-têtes de cache différents.
69
97
 
70
- ## 4. Ne publie aucun port de l'application
98
+ ## 5. Ne publie aucun port de l'application
71
99
 
72
100
  Derrière un frontal, l'application **ne doit publier aucun port**. C'est la définition d'être
73
101
  derrière quelque chose : le seul chemin doit passer par le frontal. C'est ce que fait le profil de
@@ -77,43 +105,228 @@ Si tu publies quand même un port « pour déboguer », tu as deux portes d'entr
77
105
  sans limitation et sans les en-têtes que ton frontal pose. Utilise `docker compose exec` ou un
78
106
  transfert de port temporaire.
79
107
 
80
- ## 5. Les sondes, vues du frontal
81
-
82
- Ton frontal a besoin de savoir quand retirer un exemplaire. Les deux sondes ne servent pas à la
83
- même chose :
108
+ ## 6. Les sondes, vues du frontal
84
109
 
85
110
  | Sonde | Pour quoi | Pendant l'arrêt |
86
111
  | --------- | ------------------------------- | --------------- |
87
112
  | `/livez` | « le process est-il vivant ? » | **reste `200`** |
88
113
  | `/readyz` | « peut-il prendre du trafic ? » | passe à `503` |
89
114
 
90
- **Ton frontal doit sonder `/readyz`**, jamais `/livez` : c'est `/readyz` qui bascule dès le début de
91
- l'arrêt, donc c'est lui qui te fait retirer l'exemplaire **avant** qu'il cesse de répondre. Un
92
- frontal qui sonde `/livez` garde l'exemplaire dans sa table pendant tout le drain et envoie du
93
- trafic à quelque chose qui refuse déjà.
115
+ Un frontal qui retire les exemplaires doit sonder **`/readyz`**, jamais `/livez` : c'est `/readyz`
116
+ qui bascule dès le début de l'arrêt. Les deux répondent **avant la limitation de débit** — un `429`
117
+ sur une sonde veut dire que tu n'interroges pas le chemin nu (une réécriture ajoute un préfixe).
118
+
119
+ 🔴 **Ce que les configurations générées font réellement** — à savoir dès que tu mets
120
+ **plusieurs** exemplaires derrière un même frontal :
121
+
122
+ | Frontal généré | Contrôle de santé |
123
+ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
124
+ | HAProxy | `option httpchk GET /readyz` dans **chaque** backend : l'exemplaire est retiré dès que `/readyz` répond `503`, alors que son port accepte encore. En h2c, la sonde part sur le port HTTP en HTTP/1.1 (`check port 5151 check-proto h1`) |
125
+ | nginx | nginx libre n'a **pas** de contrôle actif : il retire un amont après des échecs constatés, jamais sur `/readyz` |
126
+
127
+ Le chemin vient de la configuration (`health.readinessPath`) ; sondes désactivées, le générateur
128
+ retombe sur un contrôle de connexion, plutôt que d'interroger un chemin que plus rien ne sert.
129
+ Éprouvé par un HAProxy 3.0 réel sur la configuration générée : un exemplaire dont la sonde ne
130
+ répond pas `200` sort du répartiteur alors que son port accepte.
131
+
132
+ Ce que la sonde ne règle pas seule : le frontal ne la lit que toutes les deux secondes. Pose un
133
+ **délai avant drain** (`shutdownDelay`, en millisecondes) au moins égal à deux
134
+ intervalles de contrôle, pour que le frontal ait vu le `503` avant que l'exemplaire cesse
135
+ d'accepter.
136
+
137
+ ## 7. Les flux SSE derrière le frontal
138
+
139
+ Un flux SSE est une réponse HTTP qui ne finit pas. Trois réglages décident s'il est bien relayé :
140
+
141
+ | Réglage | Qui le pose | Pourquoi |
142
+ | ---------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------- |
143
+ | pas de mise en tampon | l'application envoie `X-Accel-Buffering: no` | nginx relaie chaque événement au lieu d'attendre de remplir son tampon. Ne filtre pas cet en-tête |
144
+ | inactivité ≥ 300 s | le générateur | un flux muet n'est tenu que par son battement de 15 s : vingt battements de marge |
145
+ | HTTP/2 côté navigateur | le générateur (nginx avec TLS) | au-delà de six flux par origine en HTTP/1.1, le navigateur met les suivants en attente |
146
+
147
+ Entre le frontal et l'application, chaque flux SSE occupe **une connexion** en HTTP/1.1. Beaucoup
148
+ de flux longs par pod : c'est le cas où h2c (§8) vaut la peine.
149
+
150
+ ## 8. HTTP/2 jusqu'à l'application — le port h2c
151
+
152
+ Par défaut le frontal parle HTTP/1.1 à l'application. Un frontal qui termine le TLS peut aussi lui
153
+ parler **HTTP/2 en clair** (h2c, connaissance préalable) sur un port **dédié**, désactivé par défaut :
154
+
155
+ ```bash
156
+ NF__APP__SERVERS__H2C__ENABLED=true # port 5153 ; NF__APP__SERVERS__H2C__PORT pour le changer
157
+ ```
158
+
159
+ **h2c n'est pas « plus rapide » par principe.** Mesuré sur une route triviale, un seul process :
160
+ débit h2c rapporté à HTTP/1.1 = **−48 %** à forme égale (64 connexions × 1 flux), **−18 %** dans la
161
+ forme d'un frontal (4 connexions × 16 flux). Le frontal garde déjà un pool de connexions HTTP/1.1
162
+ qui masque le blocage en tête de file, et HTTP/2 coûte du travail par requête (trames, compression
163
+ des en-têtes). Le choix se fait sur la **forme du trafic** :
164
+
165
+ | Forme du trafic | h2c ? | Pourquoi |
166
+ | -------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------- |
167
+ | gRPC | **requis** | gRPC n'existe qu'en HTTP/2 |
168
+ | Beaucoup de flux SSE longs par pod | **oui** | en HTTP/1.1 chaque flux tient une connexion du frontal ; en h2c ils partagent |
169
+ | En-têtes lourds et répétés (cookies, jetons) | probable | la compression HPACK les factorise d'une requête à l'autre |
170
+ | REST classique, petites réponses | **mesurer** | souvent neutre, parfois perdant |
171
+ | WebSocket | **non** | reste sur le port HTTP |
172
+ | Plusieurs workers dans un pod | **attention** | le cluster Node répartit des connexions : avec les rares connexions h2c, un worker prend tout |
173
+
174
+ **Trois règles valent pour TOUS les frontaux :**
175
+
176
+ | Règle | Pourquoi |
177
+ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
178
+ | les requêtes vont au port **h2c**, les **WebSocket** au port **HTTP** | le port h2c ne sert ni `Upgrade` ni le CONNECT étendu (RFC 8441), seul chemin d'une WebSocket en HTTP/2 |
179
+ | les **sondes** visent le port **HTTP** | le port h2c refuse HTTP/1.1, et une sonde de kubelet parle HTTP/1.1. Garde le port HTTP **actif** |
180
+ | le schéma passe par `X-Forwarded-Proto` **et** `NF__HTTP__TRUSTPROXY` | en clair, l'application ne croit jamais le `:scheme` que le frontal écrit |
181
+
182
+ Vérifier le port, puis comparer avec le même instrument :
183
+
184
+ ```bash
185
+ curl --http2-prior-knowledge http://127.0.0.1:5153/readyz
186
+ h2load --h1 -n 100000 -c 64 http://127.0.0.1:5151/<route> # HTTP/1.1
187
+ h2load -n 100000 -c 4 -m 16 http://127.0.0.1:5153/<route> # h2c, forme d'un frontal
188
+ ```
189
+
190
+ ### HAProxy — généré
191
+
192
+ Lancé avec h2c actif, `proxy:generate haproxy` envoie les requêtes au port h2c en `proto h2` et
193
+ garde les WebSocket sur le port HTTP. Rien à écrire :
194
+
195
+ ```text
196
+ use_backend be_nodefony if { hdr(upgrade) -i websocket }
197
+ default_backend be_nodefony_h2c
198
+
199
+ backend be_nodefony_h2c
200
+ option httpchk GET /readyz
201
+ http-check expect status 200
202
+ # la sonde vise le port HTTP en HTTP/1.1 : le port h2c refuse HTTP/1.1
203
+ server nodefony_h2c 10.0.0.12:5153 check port 5151 check-proto h1 proto h2
204
+ ```
94
205
 
95
- Les deux répondent **avant la limitation de débit**. Si tu obtiens un `429` sur une sonde, ce n'est
96
- pas la sonde que tu as appelée : vérifie que tu interroges bien le chemin nu, sans préfixe ajouté
97
- par une règle de réécriture.
206
+ Avec `--reencrypt`, h2c est ignoré : le port TLS négocie déjà HTTP/2. **C'est la seule cible jouée
207
+ contre un frontal réel** (HAProxy 3.0, configuration générée) : requête servie en h2c, sonde lue
208
+ sur le port HTTP, WebSocket ouverte sur le port HTTP.
98
209
 
99
- ## 6. En Kubernetes — l'Ingress, pas une configuration nginx
210
+ ### nginx — reste en HTTP/1.1 vers l'application
211
+
212
+ nginx ne parle pas HTTP/2 à ses amonts (hors gRPC) : `proxy:generate nginx` ignore h2c.
213
+
214
+ ### Envoy
215
+
216
+ Un cluster h2c pour les requêtes, un cluster HTTP/1.1 pour les WebSocket :
217
+
218
+ ```yaml
219
+ routes:
220
+ - match:
221
+ prefix: "/"
222
+ headers:
223
+ [
224
+ {
225
+ name: upgrade,
226
+ string_match: { exact: websocket, ignore_case: true },
227
+ },
228
+ ]
229
+ route:
230
+ { cluster: nodefony_http, upgrade_configs: [{ upgrade_type: websocket }] }
231
+ - match: { prefix: "/" }
232
+ route: { cluster: nodefony_h2c }
233
+ clusters:
234
+ - name: nodefony_h2c # port 5153
235
+ typed_extension_protocol_options:
236
+ envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
237
+ "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
238
+ explicit_http_config: { http2_protocol_options: {} }
239
+ - name: nodefony_http # port 5151, HTTP/1.1 par défaut
240
+ ```
241
+
242
+ ### Traefik
243
+
244
+ ```yaml
245
+ labels:
246
+ - "traefik.http.routers.app.rule=Host(`app.example.com`)"
247
+ - "traefik.http.routers.app.service=app-h2c"
248
+ - "traefik.http.services.app-h2c.loadbalancer.server.port=5153"
249
+ - "traefik.http.services.app-h2c.loadbalancer.server.scheme=h2c"
250
+ - "traefik.http.routers.app-ws.rule=Host(`app.example.com`) && HeaderRegexp(`Upgrade`, `(?i)^websocket$`)"
251
+ - "traefik.http.routers.app-ws.priority=100"
252
+ - "traefik.http.routers.app-ws.service=app-http"
253
+ - "traefik.http.services.app-http.loadbalancer.server.port=5151"
254
+ ```
255
+
256
+ > Envoy, Istio, Gateway API et Traefik : recettes tirées de leur documentation, **pas encore jouées
257
+ > contre un frontal réel**. Les éprouver avant de les déclarer justes.
258
+
259
+ ## 9. Traefik sans h2c
260
+
261
+ `proxy:generate` ne connaît que nginx et HAProxy. Avec Traefik, la déclaration vit avec les
262
+ conteneurs :
263
+
264
+ ```yaml
265
+ labels:
266
+ - "traefik.enable=true"
267
+ - "traefik.http.routers.app.rule=Host(`app.example.com`)"
268
+ - "traefik.http.routers.app.entrypoints=websecure"
269
+ - "traefik.http.routers.app.tls.certresolver=le"
270
+ - "traefik.http.services.app.loadbalancer.server.port=5151"
271
+ - "traefik.http.services.app.loadbalancer.responseforwarding.flushinterval=100ms"
272
+ - "traefik.http.services.app.loadbalancer.healthcheck.path=/readyz"
273
+ - "traefik.http.services.app.loadbalancer.healthcheck.interval=5s"
274
+ ```
275
+
276
+ Traefik relaie `Upgrade` de lui-même et ne croit les `X-Forwarded-*` entrants que des adresses
277
+ déclarées dans `entryPoints.<nom>.forwardedHeaders.trustedIPs` : derrière un équilibreur
278
+ d'hébergeur, déclarer cet amont, sinon l'adresse cliente est perdue.
279
+
280
+ ## 10. En Kubernetes — pas de configuration nginx générée
100
281
 
101
282
  Le frontal dérivé sert pour une machine, un compose, une machine virtuelle. **Dans un cluster, ce
102
- rôle appartient au contrôleur d'Ingress**, que tu as choisi et que l'application ne connaît pas.
283
+ rôle appartient au contrôleur d'entrée** (Ingress ou Gateway API), que l'application ne connaît pas.
103
284
 
104
- Donc : un `Ingress` standard — chemins, TLS, taille de corps en annotation — et non une
105
- configuration nginx générée, qui ne fonctionnerait qu'avec un seul contrôleur.
285
+ > 🔴 **ingress-nginx est retiré depuis mars 2026** (annonce du projet Kubernetes, novembre 2025) :
286
+ > plus de version, plus de correctif de sécurité. Les déploiements existants tournent encore, mais
287
+ > un nouveau déploiement vise la **Gateway API** ou un autre contrôleur.
106
288
 
107
- Ce qui reste vrai, et ce qui change :
289
+ | Point | Avec un frontal à toi | Avec un contrôleur d'entrée |
290
+ | ------------------------------- | ----------------------- | ---------------------------------------------------- |
291
+ | `NF__HTTP__TRUSTPROXY` | **obligatoire** | **obligatoire** — le CIDR des pods du contrôleur |
292
+ | `NF__HTTP__TRUSTEDHOSTS` | recommandé | recommandé |
293
+ | Configuration dérivée | oui | non — `Ingress`, `HTTPRoute` |
294
+ | Inactivité ≥ 300 s | posée par le générateur | **à poser toi-même** — sinon les WebSocket tombent |
295
+ | Taille de corps | dérivée | **à poser toi-même**, égale à celle de l'application |
296
+ | Qui sert les fichiers statiques | le frontal | l'application, ou un service dédié |
108
297
 
109
- | Point | Avec un frontal à toi | Avec un Ingress |
110
- | ------------------------------- | --------------------- | ---------------------------------- |
111
- | `NF__HTTP__TRUSTPROXY` | **obligatoire** | **obligatoire** |
112
- | `NF__HTTP__TRUSTEDHOSTS` | recommandé | recommandé |
113
- | Configuration dérivée | oui | non — un `Ingress` |
114
- | Qui sert les fichiers statiques | le frontal | l'application, ou un service dédié |
298
+ **Si tu restes sur ingress-nginx**, les réglages que le générateur aurait posés s'écrivent en
299
+ annotations (la WebSocket, elle, passe sans rien demander) :
300
+
301
+ ```yaml
302
+ metadata:
303
+ annotations:
304
+ nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
305
+ nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
306
+ nginx.ingress.kubernetes.io/proxy-buffering: "off" # flux SSE
307
+ nginx.ingress.kubernetes.io/proxy-body-size: "<taille de corps de l'application>"
308
+ ```
115
309
 
116
- ## 7. Sous Podman sans privilèges — ce qu'on ne peut pas réparer
310
+ **h2c sous Kubernetes** : c'est le `Service` qui déclare le protocole de chaque port, puis la route
311
+ envoie les requêtes au port h2c et `Upgrade: websocket` au port HTTP (`VirtualService` sous Istio,
312
+ `HTTPRoute` avec une règle sur l'en-tête `upgrade` sous Gateway API) :
313
+
314
+ ```yaml
315
+ apiVersion: v1
316
+ kind: Service
317
+ spec:
318
+ ports:
319
+ - name: http # sondes, WebSocket
320
+ port: 5151
321
+ appProtocol: http
322
+ - name: h2c
323
+ port: 5153
324
+ appProtocol: kubernetes.io/h2c # Gateway API ; Istio : http2
325
+ ```
326
+
327
+ Les sondes du `Deployment` visent le port **5151**, jamais 5153.
328
+
329
+ ## 11. Sous Podman sans privilèges — ce qu'on ne peut pas réparer
117
330
 
118
331
  L'adresse source du client **est perdue** : le trafic arrive depuis la passerelle de redirection.
119
332
  Derrière un frontal, l'en-tête d'adresse d'origine porte donc la passerelle, pas le client.
@@ -121,3 +334,20 @@ Derrière un frontal, l'en-tête d'adresse d'origine porte donc la passerelle, p
121
334
  Ce n'est pas un réglage à trouver : c'est une propriété du mode sans privilèges. **Énonce-le** —
122
335
  ton audit et ta limitation par adresse sont faux, sans aucune erreur pour le signaler. Voir
123
336
  [`podman.md`](podman.md).
337
+
338
+ ## 12. Symptôme → cause → correction
339
+
340
+ | Symptôme | Cause | Correction |
341
+ | -------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
342
+ | La WebSocket se ferme seule au bout d'une minute | l'inactivité du frontal est plus courte que le battement | régénérer ; sur un contrôleur d'entrée, `proxy-read-timeout` ≥ 300 s |
343
+ | La WebSocket ne s'ouvre pas, aucune erreur nulle part | `Upgrade`/`Connection` non relayés | régénérer ; sur une configuration écrite à la main, les ajouter |
344
+ | Depuis h2c, la WebSocket ne s'ouvre plus | le frontal l'envoie au port h2c | router `Upgrade: websocket` vers le port HTTP |
345
+ | Depuis h2c, le pod n'est jamais prêt | la sonde vise le port h2c, qui refuse HTTP/1.1 | sonder `/readyz` sur le port HTTP |
346
+ | Un flux SSE n'arrive qu'en bloc, ou à la fin | le frontal met la réponse en tampon | ne pas filtrer `X-Accel-Buffering` ; sur ingress-nginx, `proxy-buffering: "off"` |
347
+ | `413` sur un envoi que l'application accepte | limite de corps du frontal plus basse | régénérer ; sur un contrôleur d'entrée, `proxy-body-size` |
348
+ | Les statiques d'un module en `404` derrière nginx | une seule racine statique déclarée | régénérer : la chaîne de `try_files` vient des montages réels |
349
+ | Des `5xx` à chaque déploiement, plusieurs exemplaires | le frontal ne lit pas `/readyz` (§6), ou pas de délai avant drain | régénérer le frontal (sonde `/readyz`) + `shutdownDelay` |
350
+ | `429` en rafale juste après un déploiement | reconnexions comptées sur la seule adresse du frontal | `NF__HTTP__TRUSTPROXY` |
351
+ | Toutes les adresses des journaux sont celles du frontal | `NF__HTTP__TRUSTPROXY` absent — le défaut sûr | déclarer l'adresse ou le CIDR du frontal |
352
+ | Une adresse cliente **forgée** apparaît dans l'audit | confiance accordée alors que l'application est joignable directement | fermer l'accès direct, ne jamais poser `true` dans ce cas |
353
+ | `Invalid parameter: redirect_uri` en ligne, pas en local | `NF_OAUTH_REDIRECT_BASE` absente | la poser à l'URL publique de l'application |
@@ -21,14 +21,19 @@ qui n'existent pas.
21
21
 
22
22
  ## 2. Le contexte de sécurité — politique Restricted
23
23
 
24
- **Commence par lire `deploy/migrate-job.yaml` : son contexte de sécurité est déjà conforme.** Il
25
- est le seul modèle juste que tu aies sous la main, et il a été écrit pour être recopié.
26
-
27
- Six exigences, toutes portées par le **manifeste** — aucune n'est « héritée de l'image ». Ce que
28
- l'image apporte, c'est de les rendre satisfaisables sans contorsion : elle déclare
24
+ **Commence par lire `deploy/migrate-job.yaml` : il passe la politique Restricted.** Recopie-en le
25
+ contexte de sécurité — mais pas son `readOnlyRootFilesystem: false`, qui convient à un travail
26
+ éphémère et pas à un exemplaire qui sert du trafic.
27
+
28
+ Restricted exige **quatre** champs, tous portés par le **manifeste** — aucun n'est « hérité de
29
+ l'image » : `runAsNonRoot: true` (et un `runAsUser`, s'il est posé, différent de 0),
30
+ `allowPrivilegeEscalation: false`, `capabilities.drop: ["ALL"]`, `seccompProfile` `RuntimeDefault`.
31
+ Ce que l'image apporte, c'est de les rendre satisfaisables sans contorsion : elle déclare
29
32
  `USER 1000:1000`, donc l'identifiant numérique existe ; elle n'écoute qu'au-dessus de 1024, donc
30
- aucune capacité n'est nécessaire. Cinq ne coûtent rien à poser. **La sixième —
31
- `readOnlyRootFilesystem` — demande les deux volumes**, et c'est la seule qui se travaille.
33
+ aucune capacité n'est nécessaire. Aucun ne coûte rien.
34
+
35
+ **`readOnlyRootFilesystem: true` n'est PAS exigé par Restricted** : c'est un durcissement en plus,
36
+ recommandé, et le seul qui se travaille — il demande les deux volumes.
32
37
 
33
38
  ```yaml
34
39
  spec:
@@ -69,8 +74,10 @@ kubectl label ns <ns> pod-security.kubernetes.io/enforce=restricted
69
74
  kubectl apply -f <ton-deployment>.yaml # doit passer SANS avertissement
70
75
  ```
71
76
 
72
- Puis **débranche** : retire un `emptyDir`, réapplique. Le pod ne doit plus démarrer. Une politique
73
- qu'on n'a jamais vue refuser quelque chose n'est pas une politique.
77
+ Puis **débranche** : retire `seccompProfile`, réapplique. L'admission doit **refuser** le pod. Une
78
+ politique qu'on n'a jamais vue refuser quelque chose n'est pas une politique. (Retirer un
79
+ `emptyDir` fait échouer le **démarrage**, pas l'admission : c'est une autre preuve, celle des
80
+ volumes.)
74
81
 
75
82
  ## 3. Les sondes
76
83
 
@@ -93,6 +100,8 @@ livenessProbe: { httpGet: { path: /livez, port: 5151 }, periodSeconds: 10 }
93
100
  son arrêt propre — exactement ce qu'on cherche à éviter.
94
101
  - Les deux répondent **avant la limitation de débit** : un kubelet limité recevrait `429` et
95
102
  conclurait à un échec.
103
+ - **Toujours le port HTTP (5151)**, même si h2c est actif : le port h2c (5153) refuse HTTP/1.1,
104
+ la langue d'une sonde de kubelet. Un pod qui sonde 5153 n'est jamais prêt.
96
105
 
97
106
  ## 4. L'arrêt — ne perdre aucune requête
98
107
 
@@ -130,6 +139,11 @@ resources:
130
139
  processeur ne tue pas — elle **ralentit en permanence**, et ça ne ressemble pas à une limite, ça
131
140
  ressemble à une application lente. Les demandes suffisent à garantir la part.
132
141
 
142
+ Ces valeurs sont un **départ** pour un trafic modeste. Un pod qui doit tenir son plein débit
143
+ demande environ **un cœur** (`requests.cpu: "1"`) : ton code tourne sur une seule boucle. Combien
144
+ de pods, quelle mémoire par pod, et pourquoi c'est souvent le temps réel qui décide →
145
+ [`dimensionnement.md`](dimensionnement.md).
146
+
133
147
  Si tu bornes le tas de la machine virtuelle JavaScript face à une limite mémoire de cgroup,
134
148
  dimensionne-le avec une mesure, pas au jugé.
135
149
 
@@ -141,8 +155,10 @@ C'est la différence la plus structurante avec le compose de développement.
141
155
  2. **Ne reporte jamais l'hôte du compose.** `compose.yaml` pointe un **service du compose** ; le
142
156
  même nom dans un cluster désigne un Service qui n'existe pas. Rien ne casse au déploiement, tout
143
157
  casse au premier accès.
144
- 3. **L'URL passe par un Secret, en `_FILE`** — elle porte un mot de passe, elle n'a rien à faire
145
- dans un ConfigMap.
158
+ 3. **L'URL passe par un Secret** — elle porte un mot de passe, elle n'a rien à faire dans un
159
+ ConfigMap. Deux formes, au choix : `valueFrom.secretKeyRef` (ce que fait
160
+ `deploy/migrate-job.yaml`) ou le Secret monté en fichier et `NF_DATABASE_URL_FILE`. Ne pose
161
+ jamais les deux : `NF_DATABASE_URL` et `NF_DATABASE_URL_FILE` ensemble arrêtent le démarrage.
146
162
  4. **Le plafond de connexions.** Une base administrée les compte strictement. N exemplaires × le
147
163
  pool de chacun peuvent la saturer, et l'erreur sort de **son** côté, pas du tien. Reste prudent
148
164
  sur le nombre d'exemplaires.
@@ -164,14 +180,25 @@ env:
164
180
  - { name: NF_POD_NAME, valueFrom: { fieldRef: { fieldPath: metadata.name } } }
165
181
  - { name: NF__HTTP__TRUSTPROXY, value: uniquelocal }
166
182
  - { name: NF_CSRF_SECRET_FILE, value: /run/secrets/csrf }
167
- - { name: NF_DATABASE_URL_FILE, value: /run/secrets/db-url }
183
+ - name: NF_DATABASE_URL # ou NF_DATABASE_URL_FILE vers un Secret monté (§6)
184
+ valueFrom: { secretKeyRef: { name: <app>-db, key: url } }
185
+ # plusieurs exemplaires + temps réel : un bus partagé, sinon chaque pod parle seul
186
+ - { name: NF_REALTIME_DRIVER, value: redis }
187
+ - name: NF_REDIS_URL
188
+ valueFrom: { secretKeyRef: { name: <app>-redis, key: url } }
168
189
  ```
169
190
 
170
191
  `NF_POD_NAME` vient de l'API descendante. Elle dérive l'identité d'origine du bus temps réel : sans
171
192
  elle, plusieurs exemplaires se confondent et les messages s'écoutent eux-mêmes.
172
193
 
173
- `NF__HTTP__TRUSTPROXY` est **obligatoire** dès qu'un Ingress termine le TLS —
174
- [`frontal.md`](frontal.md) dit ce qui tombe sans lui, et comment le voir tomber.
194
+ `NF_REALTIME_DRIVER=redis` n'est utile qu'à partir de **deux** exemplaires : avec un seul, le bus
195
+ local suffit. Sans lui à deux exemplaires, un message n'atteint que les clients du pod qui l'émet,
196
+ **sans erreur** — détail dans [`dimensionnement.md`](dimensionnement.md) §4.
197
+
198
+ `NF__HTTP__TRUSTPROXY` est **obligatoire** dès qu'un contrôleur d'entrée termine le TLS —
199
+ [`frontal.md`](frontal.md) dit ce qui tombe sans lui, et comment le voir tomber. L'Ingress ou la
200
+ Gateway API, les annotations d'inactivité qui gardent les WebSocket, et le `Service` à deux ports
201
+ pour h2c : [`frontal.md`](frontal.md) §10.
175
202
 
176
203
  ## 8. Éprouver sans cluster de production
177
204
 
@@ -59,13 +59,23 @@ dès que l'application émet des jetons. Sans `NF_TOTP_KEY` ou `NF_WEBHOOK_KEY`,
59
59
  démarre mais la brique se désactive (double authentification, webhooks), avec un journal CRITIC.
60
60
 
61
61
  ```bash
62
- npx nodefony security:secrets --env # les secrets, en lignes CLÉ=valeur (--env-file, gestionnaire)
62
+ npx nodefony security:secrets --env # les secrets, en lignes CLÉ=valeur — pour un `docker --env-file` ou un gestionnaire
63
63
  npx nodefony security:secrets --jwt-keyset # la valeur de NF_JWT_KEYSET — une ligne JSON, clé PRIVÉE
64
64
  ```
65
65
 
66
66
  Une valeur par ENVIRONNEMENT (staging ≠ production), générée une fois, rangée dans le
67
67
  gestionnaire de secrets ; ne laisse traîner aucun fichier qui la contient.
68
68
 
69
+ **En intégration continue**, la CI générée (preset `complete`) tire ces clés à CHAQUE exécution,
70
+ juste avant `test:e2e` : `security:secrets --env >> "$GITHUB_ENV"` sous GitHub, une boucle
71
+ `export "$ligne"` sous GitLab. Tirées pour un run, elles ne protègent rien — elles n'ont rien à
72
+ faire dans les `secrets` du dépôt. Un `CRITIC … AUCUNE clé de chiffrement` dans le journal de
73
+ l'e2e veut dire que l'étape a sauté : la suite est verte sur une production amputée.
74
+
75
+ > 🔴 **Ne jamais charger ces lignes par `set -a && . fichier`.** Le shell interprète les
76
+ > guillemets du JSON de `NF_JWT_KEYSET` et le rend illisible. `docker run --env-file`, un
77
+ > `env_file:` de compose ou `export "$ligne"` ligne à ligne gardent la valeur telle quelle.
78
+
69
79
  `NF_ADMIN_PASSWORD` a un défaut en développement, aucun en production : sans lui, le premier
70
80
  démarrage ne crée **aucun** compte d'administration — plutôt qu'un compte au mot de passe connu de
71
81
  tout le monde. Il ne sert qu'à ce premier démarrage ; `npx nodefony security:user:add <id> --admin`
@@ -73,7 +83,8 @@ fait le même geste à la main.
73
83
 
74
84
  ## 5. Les monter, pas les poser en clair
75
85
 
76
- Le suffixe `_FILE` marche sur **toute** variable — détail complet dans
86
+ Le suffixe `_FILE` marche sur les variables du catalogue, URL de base et de cache comprises —
87
+ l'URL d'une base porte un mot de passe, c'est elle qu'on monte en premier. Détail complet dans
77
88
  [`variables.md`](variables.md). Ce qu'il faut retenir ici :
78
89
 
79
90
  ```yaml
@@ -80,8 +80,10 @@ production : `platform` (variables du déploiement), `secrets` (gestionnaire de
80
80
 
81
81
  ## 3. Le suffixe `_FILE` — les secrets montés
82
82
 
83
- **Toute** variable du catalogue accepte un `_FILE`. Le contenu du fichier est lu, retour à la ligne
84
- 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.
85
87
 
86
88
  ```yaml
87
89
  env:
@@ -117,12 +119,12 @@ Les seules qui te concernent au déploiement. Le catalogue complet, c'est `npx n
117
119
 
118
120
  ### Derrière un frontal — **les plus importantes**
119
121
 
120
- | Variable | Rôle | Absente ⇒ |
121
- | ---------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------ |
122
- | `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 |
123
- | `NF__HTTP__TRUSTEDHOSTS` | Les hôtes servis | Un `Host` étranger n'est pas refusé |
124
- | `NF__APP__DOMAINCHECK` | Contrôle du domaine | Pas de contrôle |
125
- | `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 |
126
128
 
127
129
  ### Données
128
130
 
@@ -170,10 +172,10 @@ Tous en `_FILE` en production. Détail et rotation → [`secrets.md`](secrets.md
170
172
 
171
173
  ### Multi-process sur une seule machine
172
174
 
173
- | Variable | Rôle |
174
- | ------------ | -------------------------------------------------------- |
175
- | `NF_CLUSTER` | Active le mode multi-process |
176
- | `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 |
177
179
 
178
180
  En Kubernetes, **laisse ça tranquille** : un exemplaire = un processus = un pod, et c'est
179
181
  l'ordonnanceur qui multiplie. Le mode multi-process sert sur une machine nue.
@@ -135,10 +135,12 @@ n'en envoie aucun, et la défense de provenance le laisse alors passer — c'est
135
135
  distingue les sites, pas les intentions. Résultat mesuré : un `POST /api/cart/items` sans jeton rend
136
136
  `201`, et l'application croit avoir une défense.
137
137
 
138
- Toute action qui ÉCRIT porte donc `@CsrfProtect` explicitement. Le jeton ne se demande à AUCUN
139
- endpoint : une requête sûre (`GET`) vers la route protégée sème le cookie lisible `csrf-token`, et
140
- la mutation le rejoue dans l'en-tête `x-csrf-token` — c'est le double-submit, sinon `403`. La
141
- 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.
142
144
 
143
145
  ## 🔴 Ce qu'il ne faut jamais écrire
144
146