@nodefony/http 10.0.0-alpha.9 → 10.0.0-beta.2

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 (100) hide show
  1. package/README.md +12 -3
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorateParam.js +1 -1
  5. package/dist/index.js +6 -4
  6. package/dist/nodefony/command/assetsPublishCommand.js +1 -1
  7. package/dist/nodefony/command/certificatesCommand.js +1 -2
  8. package/dist/nodefony/command/networkCommand.js +1 -1
  9. package/dist/nodefony/command/proxyGenerateCommand.js +1 -1
  10. package/dist/nodefony/config/config.js +26 -4
  11. package/dist/nodefony/interfaces/IRouting.js +1 -0
  12. package/dist/nodefony/interfaces/ISecurity.js +1 -0
  13. package/dist/nodefony/service/ProfilerAdminApi.js +2 -1
  14. package/dist/nodefony/service/audit-logger.js +16 -13
  15. package/dist/nodefony/service/certificates.js +87 -144
  16. package/dist/nodefony/service/error-renderer.js +11 -8
  17. package/dist/nodefony/service/http-kernel.js +342 -165
  18. package/dist/nodefony/service/pretty-request-logger.js +3 -10
  19. package/dist/nodefony/service/servers/clientError.js +2 -2
  20. package/dist/nodefony/service/servers/server-http.js +23 -23
  21. package/dist/nodefony/service/servers/server-https.js +41 -42
  22. package/dist/nodefony/service/servers/server-static.js +13 -17
  23. package/dist/nodefony/service/servers/server-websocket-secure.js +11 -10
  24. package/dist/nodefony/service/servers/server-websocket.js +11 -10
  25. package/dist/nodefony/service/servers/wsHeartbeat.js +2 -2
  26. package/dist/nodefony/service/sessions/sessions-service.js +22 -15
  27. package/dist/nodefony/service/trace.js +3 -2
  28. package/dist/nodefony/service/upload/upload-service.js +11 -10
  29. package/dist/nodefony/service/x509.js +324 -0
  30. package/dist/nodefony/src/assets/prebuiltUi.js +1 -1
  31. package/dist/nodefony/src/context/Context.js +34 -19
  32. package/dist/nodefony/src/context/forwarded.js +4 -4
  33. package/dist/nodefony/src/context/http/HttpContext.js +133 -88
  34. package/dist/nodefony/src/context/http/Request.js +108 -46
  35. package/dist/nodefony/src/context/http/Response.js +134 -54
  36. package/dist/nodefony/src/context/http/parser.js +9 -10
  37. package/dist/nodefony/src/context/http/urlFastPath.js +35 -11
  38. package/dist/nodefony/src/context/http2/Response.js +53 -25
  39. package/dist/nodefony/src/context/metaData.js +1 -1
  40. package/dist/nodefony/src/context/trustProxy.js +4 -3
  41. package/dist/nodefony/src/context/websocket/Response.js +13 -6
  42. package/dist/nodefony/src/context/websocket/WebsocketContext.js +59 -26
  43. package/dist/nodefony/src/context/websocket/wsCloseReason.js +41 -0
  44. package/dist/nodefony/src/cookies/cookie.js +3 -3
  45. package/dist/nodefony/src/errors/httpError.js +6 -6
  46. package/dist/nodefony/src/errors/thrownMessage.js +27 -0
  47. package/dist/nodefony/src/profiler/FrameProfile.js +4 -3
  48. package/dist/nodefony/src/profiler/Profiler.js +4 -4
  49. package/dist/nodefony/src/servers/kernelServers.js +29 -0
  50. package/dist/nodefony/src/servers/portBinder.js +65 -23
  51. package/dist/nodefony/src/session/session.js +18 -10
  52. package/dist/nodefony/src/session/sessionStoreFailure.js +2 -1
  53. package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +24 -9
  54. package/dist/types/index.d.ts +16 -0
  55. package/dist/types/nodefony/config/config.d.ts +23 -0
  56. package/dist/types/nodefony/interfaces/IContext.d.ts +24 -7
  57. package/dist/types/nodefony/interfaces/ICookie.d.ts +18 -18
  58. package/dist/types/nodefony/interfaces/IHttpKernel.d.ts +10 -4
  59. package/dist/types/nodefony/interfaces/IResponse.d.ts +1 -1
  60. package/dist/types/nodefony/interfaces/IRouting.d.ts +87 -0
  61. package/dist/types/nodefony/interfaces/ISecurity.d.ts +45 -0
  62. package/dist/types/nodefony/interfaces/ISession.d.ts +8 -8
  63. package/dist/types/nodefony/interfaces/IUpload.d.ts +9 -9
  64. package/dist/types/nodefony/interfaces/index.d.ts +2 -0
  65. package/dist/types/nodefony/service/audit-logger.d.ts +14 -10
  66. package/dist/types/nodefony/service/certificates.d.ts +42 -57
  67. package/dist/types/nodefony/service/http-kernel.d.ts +192 -42
  68. package/dist/types/nodefony/service/servers/server-http.d.ts +4 -1
  69. package/dist/types/nodefony/service/servers/server-https.d.ts +4 -1
  70. package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +4 -1
  71. package/dist/types/nodefony/service/servers/server-websocket.d.ts +4 -1
  72. package/dist/types/nodefony/service/sessions/sessions-service.d.ts +6 -2
  73. package/dist/types/nodefony/service/upload/upload-service.d.ts +1 -1
  74. package/dist/types/nodefony/service/x509.d.ts +86 -0
  75. package/dist/types/nodefony/src/context/Context.d.ts +21 -10
  76. package/dist/types/nodefony/src/context/forwarded.d.ts +3 -3
  77. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +75 -18
  78. package/dist/types/nodefony/src/context/http/Request.d.ts +39 -2
  79. package/dist/types/nodefony/src/context/http/Response.d.ts +47 -7
  80. package/dist/types/nodefony/src/context/http/parser.d.ts +1 -1
  81. package/dist/types/nodefony/src/context/http2/Response.d.ts +32 -2
  82. package/dist/types/nodefony/src/context/websocket/Response.d.ts +4 -4
  83. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +11 -2
  84. package/dist/types/nodefony/src/context/websocket/wsCloseReason.d.ts +21 -0
  85. package/dist/types/nodefony/src/cookies/cookie.d.ts +9 -9
  86. package/dist/types/nodefony/src/errors/httpError.d.ts +7 -7
  87. package/dist/types/nodefony/src/errors/thrownMessage.d.ts +12 -0
  88. package/dist/types/nodefony/src/profiler/Profiler.d.ts +28 -28
  89. package/dist/types/nodefony/src/servers/kernelServers.d.ts +20 -0
  90. package/dist/types/nodefony/src/servers/portBinder.d.ts +16 -65
  91. package/dist/types/nodefony/src/session/session.d.ts +12 -12
  92. package/dist/types/nodefony/src/session/storage/RevocationGuardStorage.d.ts +1 -1
  93. package/docs/cookies.md +25 -25
  94. package/docs/index.md +4 -2
  95. package/docs/observabilite.md +27 -27
  96. package/docs/rate-limit.md +20 -20
  97. package/docs/servers.md +82 -79
  98. package/docs/session.md +74 -69
  99. package/docs/upload.md +49 -43
  100. package/package.json +9 -12
package/docs/session.md CHANGED
@@ -69,8 +69,8 @@ flowchart TD
69
69
  ```
70
70
 
71
71
  Le point d'activation est **unique et commun aux deux transports** : `HttpKernel.startSession()`
72
- (`http-kernel.ts:1152`). Il commence par la garde paresseuse `if (!intent && !context.hasSession())`
73
- (`http-kernel.ts:1137`) — sans intent de route ni cookie entrant, **aucune session n'est ouverte**.
72
+ (`http-kernel.ts:1246`). Il commence par la garde paresseuse `if (!intent && !context.hasSession())`
73
+ (`http-kernel.ts:1283`) — sans intent de route ni cookie entrant, **aucune session n'est ouverte**.
74
74
 
75
75
  ## 📖 Lexique
76
76
 
@@ -114,15 +114,15 @@ défaut** dans Nodefony :
114
114
 
115
115
  - **Vol du cookie (hijacking).** Un script injecté (XSS) ou un réseau en clair capte le cookie et
116
116
  rejoue la session. → `HttpOnly` et `Secure` sont à `true` par défaut (`sessionCookieSchema`,
117
- `config.ts:748-755`), et le nom du cookie prend le préfixe `__Host-` dès que le transport est TLS
118
- (`Context.getSessionCookieName()`, `Context.ts:714`).
117
+ `config.ts:752-755`), et le nom du cookie prend le préfixe `__Host-` dès que le transport est TLS
118
+ (`Context.getSessionCookieName()`, `Context.ts:733`).
119
119
  - **Fixation.** L'attaquant pose lui-même un identifiant dans le navigateur de la victime, attend
120
120
  qu'elle se connecte, puis réutilise **le même** identifiant. → double défense : `strictMode` rejette
121
121
  tout identifiant inconnu du store (`Session.resume()`, `session.ts:177`), et le login **régénère**
122
122
  l'identifiant (`AuthFlow` — voir plus bas).
123
123
  - **Exploitation prolongée d'un identifiant volé.** Une session maintenue artificiellement vivante
124
124
  resterait exploitable indéfiniment. → l'**absolute timeout** borne l'âge depuis la création et n'est
125
- **jamais** prolongé (`Session.isValidSession()`, `session.ts:366`), en plus de l'idle timeout.
125
+ **jamais** prolongé (`Session.isValidSession()`, `session.ts:371`), en plus de l'idle timeout.
126
126
 
127
127
  > [!IMPORTANT]
128
128
  > Le cookie **ne chiffre rien** et n'a pas à le faire : il ne porte qu'un numéro. La sécurité repose
@@ -135,16 +135,16 @@ Quatre partis pris, chacun vérifiable dans le code.
135
135
 
136
136
  **1. Le cookie ne transporte que l'identifiant.** `Session.getSession()` lit la **valeur brute** du
137
137
  cookie, sans déchiffrement (`session.ts:160`) ; l'identifiant vient de `Session.generateId()`
138
- (`session.ts:226`). Modèle BFF : le web reste sur un cookie opaque, le JWT est réservé aux API et aux
138
+ (`session.ts:231`). Modèle BFF : le web reste sur un cookie opaque, le JWT est réservé aux API et aux
139
139
  agents (voir [Firewall](../../security/docs/firewall.md)).
140
140
 
141
141
  **2. La session est paresseuse.** Elle n'existe que si une route la demande — `@UseSession`, ou la
142
142
  seule présence d'un paramètre `@Session` — ou si un cookie arrive déjà : c'est la garde de
143
- `HttpKernel.startSession()` (`http-kernel.ts:1152`). Une route publique ne paie **ni lecture de store,
143
+ `HttpKernel.startSession()` (`http-kernel.ts:1246`). Une route publique ne paie **ni lecture de store,
144
144
  ni `Set-Cookie`**.
145
145
 
146
146
  **3. Un seul modèle d'état pour le web et le temps réel.** Le même `startSession()` sert
147
- `HttpKernel.onRequestEnd()` (`http-kernel.ts:1443`) et `HttpKernel.onConnect()` (`http-kernel.ts:1711`) ;
147
+ `HttpKernel.onRequestEnd()` (`http-kernel.ts:1697`) et `HttpKernel.onConnect()` (`http-kernel.ts:2008`) ;
148
148
  l'activité HTTP **ou** WS prolonge la même session (`Session.touchIfNeeded()`, `session.ts:421`).
149
149
 
150
150
  **4. L'administration ne voit jamais un identifiant.** Un opérateur manipule une `ref`, HMAC tronqué
@@ -274,31 +274,31 @@ curl -sk -b /tmp/jar https://localhost:5152/panier
274
274
  Le cookie obtenu porte `__Host-`, `HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/` et **aucun** `Domain` :
275
275
  c'est exactement ce qu'assert le banc d'intégration `session-runtime` (« Set-Cookie de session sur TLS »).
276
276
  Sur un transport en clair (port 5151), le préfixe `__Host-` est omis — le navigateur le rejetterait
277
- faute de `Secure` (`Context.getSessionCookieName()`, `Context.ts:714`).
277
+ faute de `Secure` (`Context.getSessionCookieName()`, `Context.ts:733`).
278
278
 
279
279
  ## ⚙️ Configuration
280
280
 
281
- Source unique des défauts : le schéma Zod `sessionSchema` (`config.ts:782`) et son sous-schéma
282
- `sessionCookieSchema` (`config.ts:748`).
283
-
284
- | Option | Type | Défaut | Effet |
285
- | ------------------- | ------- | ------------ | --------------------------------------------------------------------------------- |
286
- | `store` | string | `"auto"` | Backend de persistance — voir la résolution ci-dessous (`config.ts:795`). |
287
- | `name` | string | `"nodefony"` | Nom du cookie, préfixé `__Host-` selon `cookie.hostPrefix` (`config.ts:770`). |
288
- | `strictMode` | bool | `true` | Un identifiant inconnu du store est rejeté → session neuve (anti-fixation). |
289
- | `idleTimeoutS` | int ≥ 0 | `1800` | Inactivité max (30 min). `0` = pas d'expiration par inactivité (`config.ts:823`). |
290
- | `absoluteTimeoutS` | int ≥ 0 | `43200` | Âge max depuis la création (12 h), **jamais** prolongé. `0` = désactivé. |
291
- | `gcIntervalS` | int ≥ 0 | `600` | Période de purge des sessions expirées, hors requête. `0` = timer désarmé. |
292
- | `gcJitter` | bool | `true` | Décale le départ du GC par process (anti _thundering herd_ sur un store partagé). |
293
- | `refererCheck` | bool | `false` | Lie la session à l'hôte de création (défense en profondeur, `session.ts:366`). |
294
- | `cookie.maxAge` | int ≥ 0 | `0` | `0` = cookie de session (effacé à la fermeture du navigateur). |
295
- | `cookie.httpOnly` | bool | `true` | Inaccessible depuis JavaScript — anti-XSS. |
296
- | `cookie.secure` | bool | `true` | Envoyé sur TLS uniquement. |
297
- | `cookie.signed` | bool | `false` | Signe le cookie avec le secret HMAC du kernel. |
298
- | `cookie.hostPrefix` | enum | `"auto"` | `__Host-` : `auto` (sur TLS) \| `true` (toujours) \| `false` (jamais). |
281
+ Source unique des défauts : le schéma Zod `sessionSchema` (`config.ts:786`) et son sous-schéma
282
+ `sessionCookieSchema` (`config.ts:752`).
283
+
284
+ | Option | Type | Défaut | Effet |
285
+ | ------------------- | ------- | ------------ | ---------------------------------------------------------------------------------------- |
286
+ | `store` | string | `"auto"` | Backend de persistance — voir la résolution ci-dessous (`config.ts:795`). |
287
+ | `name` | string | `"nodefony"` | Nom du cookie, préfixé `__Host-` selon `cookie.hostPrefix` (`config.ts:770`). |
288
+ | `strictMode` | bool | `true` | Un identifiant inconnu du store est rejeté → session neuve (anti-fixation). |
289
+ | `idleTimeoutS` | int ≥ 0 | `1800` | Inactivité max (30 min). `0` = pas d'expiration par inactivité (`config.ts:823`). |
290
+ | `absoluteTimeoutS` | int ≥ 0 | `43200` | Âge max depuis la création (12 h), **jamais** prolongé. `0` = désactivé. |
291
+ | `gcIntervalS` | int ≥ 0 | `600` | Période de purge des sessions expirées, hors requête. `0` = timer désarmé. |
292
+ | `gcJitter` | bool | `true` | Décale le départ du GC par process (anti _thundering herd_ sur un store partagé). |
293
+ | `refererCheck` | bool | `false` | Lie la session à l'hôte de création — ⚠️ court-circuite les expirations (voir plus bas). |
294
+ | `cookie.maxAge` | int ≥ 0 | `0` | `0` = cookie de session (effacé à la fermeture du navigateur). |
295
+ | `cookie.httpOnly` | bool | `true` | Inaccessible depuis JavaScript — anti-XSS. |
296
+ | `cookie.secure` | bool | `true` | Envoyé sur TLS uniquement. |
297
+ | `cookie.signed` | bool | `false` | Signe le cookie avec le secret HMAC du kernel. |
298
+ | `cookie.hostPrefix` | enum | `"auto"` | `__Host-` : `auto` (sur TLS) \| `true` (toujours) \| `false` (jamais). |
299
299
 
300
300
  `SameSite` n'est pas dans ce bloc : il vient des options de cookie génériques, dont le défaut est
301
- `Lax` (`defaultCookieOptions`, `cookie.ts:48`).
301
+ `Lax` (`cookieDefaultSettings`, `cookie.ts:39`).
302
302
 
303
303
  > [!WARNING]
304
304
  > `idleTimeoutS: 0` **et** `absoluteTimeoutS: 0` désactivent les deux bornes : une session ne meurt
@@ -310,7 +310,7 @@ Source unique des défauts : le schéma Zod `sessionSchema` (`config.ts:782`) et
310
310
 
311
311
  `auto` n'est pas un store : c'est une sentinelle résolue une fois, au boot, par `resolveAutoStore()`
312
312
  (`config/infra.ts:297`), puis journalisée. Elle suit **l'infra que tu as déclarée**, bornée aux stores
313
- réellement enregistrés (`SessionsService.initializeStorage()`, `sessions-service.ts:241`).
313
+ réellement enregistrés (`SessionsService.initializeStorage()`, `sessions-service.ts:258`).
314
314
 
315
315
  ```mermaid
316
316
  flowchart TD
@@ -351,7 +351,7 @@ Ce qui change, c'est la topologie et la façon d'expirer.
351
351
 
352
352
  ### `memory` — l'implémentation de référence
353
353
 
354
- Store built-in de `@nodefony/http`, enregistré d'office (`sessions-service.ts:860`). Les sessions vivent
354
+ Store built-in de `@nodefony/http`, enregistré d'office (`sessions-service.ts:913`). Les sessions vivent
355
355
  dans une `Map` du process : elles **disparaissent au redémarrage** et ne sont **pas partagées** entre
356
356
  pods — c'est un choix (mesurer le framework sans le goulot disque/SQL), pas une limite.
357
357
 
@@ -385,7 +385,7 @@ Ici l'expiration **idle** est portée par Redis lui-même : `SET … EX` pose le
385
385
  (`@nodefony/redis/nodefony/src/SessionStorage.ts:168`) — aucun balayage périodique.
386
386
 
387
387
  L'absolute timeout, lui, n'est pas exprimable par un TTL glissant : il reste honoré **à la lecture**
388
- par `Session.isValidSession()` (`session.ts:366`). Une entrée trop vieille peut donc survivre côté
388
+ par `Session.isValidSession()` (`session.ts:371`). Une entrée trop vieille peut donc survivre côté
389
389
  Redis jusqu'à son TTL idle, mais elle est **refusée à la reprise**.
390
390
 
391
391
  Capacités réduites, annoncées et non simulées : la pagination est **par curseur** (pas de `total`, pas
@@ -439,12 +439,17 @@ sequenceDiagram
439
439
  → `resume()` (`session.ts:177`), sinon `create()` (`session.ts:204`) qui tire un identifiant CSPRNG,
440
440
  pose le cookie et marque la session à persister.
441
441
 
442
- **Validation à la reprise.** `Session.isValidSession()` (`session.ts:365`) applique dans l'ordre le
443
- `refererCheck` (si activé), l'**absolute** (âge depuis `created`, `session.ts:381`), puis l'**idle**
444
- (depuis `updated`, `session.ts:394`). Échec → `invalidate()` (`session.ts:284`) détruit l'entrée et
445
- recrée une session vierge.
442
+ **Validation à la reprise.** `Session.isValidSession()` (`session.ts:371`) applique l'**absolute**
443
+ (âge depuis `created`) puis l'**idle** (depuis `updated`). Échec → `invalidate()` (`session.ts:290`)
444
+ détruit l'entrée et recrée une session vierge.
446
445
 
447
- **Écriture minimale.** `SessionsService.saveSession()` (`sessions-service.ts:429`) n'écrit **que** si la
446
+ > [!WARNING]
447
+ > Avec `refererCheck: true`, `isValidSession()` rend son verdict sur la **seule** correspondance
448
+ > d'hôte (`Session.checkSecureReferer()`) et retourne aussitôt : les bornes **absolute** et
449
+ > **idle** ne sont alors **pas** évaluées. Tant que ce comportement n'est pas corrigé, ne pas
450
+ > activer `refererCheck` quand l'expiration des sessions compte — c'est-à-dire presque toujours.
451
+
452
+ **Écriture minimale.** `SessionsService.saveSession()` (`sessions-service.ts:463`) n'écrit **que** si la
448
453
  session est `dirty` et non `readOnly` ; sinon il appelle `Session.touchIfNeeded()` (`session.ts:421`),
449
454
  qui prolonge l'idle **sans réécrire le blob**, et seulement au-delà d'une demi-vie d'idle
450
455
  (`session.ts:445`). Une requête de lecture coûte donc au pire un `UPDATE` d'horodatage toutes les
@@ -452,14 +457,14 @@ qui prolonge l'idle **sans réécrire le blob**, et seulement au-delà d'une dem
452
457
 
453
458
  **Anti-résurrection**, sur deux niveaux. `Session.destroy()` remet `mutated = false` (`session.ts:307`)
454
459
  pour que la sauvegarde de fin de requête ne réécrive pas ce qu'on vient de supprimer. Surtout, **tout**
455
- store est décoré par `RevocationGuardStorage` (`sessions-service.ts:279`) : `destroy()` pose une
456
- **pierre tombale** de 5 minutes (`RevocationGuardStorage.ts:144`) qui refuse ensuite tout `write`
457
- (`RevocationGuardStorage.ts:151`) **et tout `touch`** (`RevocationGuardStorage.ts:151`) du même
460
+ store est décoré par `RevocationGuardStorage` (`sessions-service.ts:309`) : `destroy()` pose une
461
+ **pierre tombale** de 5 minutes (`RevocationGuardStorage.ts:156`) qui refuse ensuite tout `write`
462
+ (`RevocationGuardStorage.ts:163`) **et tout `touch`** (`RevocationGuardStorage.ts:130`) du même
458
463
  identifiant — ce qui couvre la requête « en vol » d'un autre client.
459
464
 
460
465
  **Purge hors requête.** Un `GcScheduler` est armé au `onReady`, désarmé au `onTerminate`
461
466
  (`sessions-service.ts:284-313`). La passe métier nue, `SessionsService.runGc()`
462
- (`sessions-service.ts:470`), est publique exprès : un CronJob Kubernetes peut l'appeler à la place du
467
+ (`sessions-service.ts:496`), est publique exprès : un CronJob Kubernetes peut l'appeler à la place du
463
468
  timer (`gcIntervalS: 0`).
464
469
 
465
470
  ## Entités de persistance
@@ -498,17 +503,17 @@ C'est le différenciateur du framework appliqué à l'état de session : un seul
498
503
  <!-- prettier-ignore -->
499
504
  | Aspect | HTTP | WebSocket |
500
505
  | --- | --- | --- |
501
- | Ouverture | à chaque requête — `startSession()` dans `onRequestEnd()` (`http-kernel.ts:1443`) | **une fois** au handshake — `startSession()` dans `onConnect()` (`http-kernel.ts:1711`) |
506
+ | Ouverture | à chaque requête — `startSession()` dans `onRequestEnd()` (`http-kernel.ts:1697`) | **une fois** au handshake — `startSession()` dans `onConnect()` (`http-kernel.ts:2008`) |
502
507
  | Lecture du cookie | constructeur du contexte | constructeur, même nom effectif (`WebsocketContext.ts:172`) |
503
508
  | Sauvegarde | fin de requête | après **chaque frame** traitée (`WebsocketContext.ts:302`) |
504
- | Filet de fermeture | — | `once("onFinish")` sauve si non déjà fait (`http-kernel.ts:1518`) |
509
+ | Filet de fermeture | — | `once("onFinish")` sauve si non déjà fait (`http-kernel.ts:1801`) |
505
510
  | Portée ALS | une requête | **handshake + toutes les frames** (`http-kernel.ts:1495`) |
506
511
 
507
512
  La conséquence pratique la plus utile : côté WebSocket, la bulle `AsyncLocalStorage` ouverte au
508
- handshake par `RequestContext.run()` **enveloppe aussi les messages** (`http-kernel.ts:436`). L'identité résolue une fois est donc
513
+ handshake par `RequestContext.run()` **enveloppe aussi les messages** (`http-kernel.ts:1929`). L'identité résolue une fois est donc
509
514
  disponible à chaque frame sans relire la base — c'est ce dont profite
510
515
  `FirewallRealtimeAuthenticator.supports()` (`FirewallRealtimeAuthenticator.ts:80`), câblé automatiquement
511
- par le firewall sur les zones temps réel protégées (`firewall.ts:300`).
516
+ par le firewall sur les zones temps réel protégées (`firewall.ts:297`).
512
517
 
513
518
  > [!WARNING]
514
519
  > Rien à écrire dans `initialize()` : il n'existe **pas** de `Controller.startSession()`. La session WS
@@ -535,20 +540,20 @@ affiche.
535
540
 
536
541
  | Surface | Méthode | Portée |
537
542
  | ------------------------ | ----------------------------------------------- | ------------------------------------------- |
538
- | Déconnexion locale | `Session.destroy()` (`session.ts:300`) | la session courante + pierre tombale |
539
- | Révocation par un admin | `destroyByRef()` (`sessions-service.ts:707`) | une session désignée par sa `ref` publique |
540
- | « Déconnecter partout » | `destroyByUser()` (`sessions-service.ts:737`) | toutes les sessions d'un utilisateur |
541
- | « Mes appareils » (self) | `destroyOwnByRef()` (`sessions-service.ts:834`) | une session, **restreinte au propriétaire** |
543
+ | Déconnexion locale | `Session.destroy()` (`session.ts:306`) | la session courante + pierre tombale |
544
+ | Révocation par un admin | `destroyByRef()` (`sessions-service.ts:745`) | une session désignée par sa `ref` publique |
545
+ | « Déconnecter partout » | `destroyByUser()` (`sessions-service.ts:775`) | toutes les sessions d'un utilisateur |
546
+ | « Mes appareils » (self) | `destroyOwnByRef()` (`sessions-service.ts:872`) | une session, **restreinte au propriétaire** |
542
547
 
543
548
  Deux finesses valent d'être connues.
544
549
 
545
550
  `destroyByUser()` ne fait pas un seul passage : il **repasse jusqu'à ce qu'un passage complet ne
546
- détruise plus rien** (`sessions-service.ts:737`), car supprimer en parcourant décale les rangs sous un
551
+ détruise plus rien** (`sessions-service.ts:775`), car supprimer en parcourant décale les rangs sous un
547
552
  curseur offset. Une révocation « partout » qui en laisserait une n'est pas une imprécision, c'est une
548
553
  faille — on rend donc la main avec la preuve, pas l'espoir.
549
554
 
550
555
  `destroyOwnByRef()` ferme l'IDOR **par construction** : parcours restreint aux sessions du demandeur,
551
- et appartenance **re-vérifiée** avant même de comparer la `ref` (`sessions-service.ts:840`). Une
556
+ et appartenance **re-vérifiée** avant même de comparer la `ref` (`sessions-service.ts:863`). Une
552
557
  `ref` d'autrui est structurellement introuvable.
553
558
 
554
559
  ### Redaction — l'identifiant ne sort jamais du process
@@ -561,28 +566,28 @@ Trois barrières superposées :
561
566
  **liste blanche** : `ref`, `user`, `authenticated`, `ip`, `ua`, dates. Jamais un `delete` après coup.
562
567
  3. La `ref` elle-même est un HMAC tronqué non réversible (`computeSessionRef()`,
563
568
  `sessions-service.ts:100`) ; la clé est dérivée du certificat au boot et n'est jamais sérialisée
564
- (`SessionsService.sessionRef()`, `sessions-service.ts:528`).
569
+ (`SessionsService.sessionRef()`, `sessions-service.ts:564`).
565
570
 
566
571
  ### Récapitulatif des défenses actives par défaut
567
572
 
568
573
  | Menace | Défense | Ancrage |
569
574
  | --------------------------------- | ------------------------------------------------- | -------------------------------------------------- |
570
- | Vol par script injecté (XSS) | `HttpOnly` | `sessionCookieSchema` (`config.ts:748`) |
571
- | Interception réseau | `Secure` + `__Host-` sur TLS | `getSessionCookieName()` (`Context.ts:714`) |
572
- | Requête inter-sites | `SameSite=Lax` par défaut | `defaultCookieOptions` (`cookie.ts:48`) |
575
+ | Vol par script injecté (XSS) | `HttpOnly` | `sessionCookieSchema` (`config.ts:752`) |
576
+ | Interception réseau | `Secure` + `__Host-` sur TLS | `getSessionCookieName()` (`Context.ts:733`) |
577
+ | Requête inter-sites | `SameSite=Lax` par défaut | `cookieDefaultSettings` (`cookie.ts:39`) |
573
578
  | Fixation (cookie pré-posé) | `strictMode` + régénération au login | `Session.resume()` (`session.ts:177`) |
574
- | Identifiant deviné | 32 octets CSPRNG (43 caractères base64url) | `Session.generateId()` (`session.ts:226`) |
575
- | Session volée exploitée longtemps | absolute timeout, jamais prolongé | `absoluteTimeoutS` à la reprise (`session.ts:381`) |
576
- | Session oubliée ouverte | idle timeout glissant | `idleTimeoutS` à la reprise (`session.ts:394`) |
577
- | Résurrection après révocation | pierre tombale 5 min sur `write` **et** `touch` | `RevocationGuardStorage.ts:121` |
579
+ | Identifiant deviné | 32 octets CSPRNG (43 caractères base64url) | `Session.generateId()` (`session.ts:231`) |
580
+ | Session volée exploitée longtemps | absolute timeout, jamais prolongé | `absoluteTimeoutS` à la reprise (`session.ts:388`) |
581
+ | Session oubliée ouverte | idle timeout glissant | `idleTimeoutS` à la reprise (`session.ts:401`) |
582
+ | Résurrection après révocation | pierre tombale 5 min sur `write` **et** `touch` | `RevocationGuardStorage.ts:127-171` |
578
583
  | Fuite d'identifiant en admin | `ref` HMAC + projection en liste blanche | `toSessionSummary()` (`sessions-service.ts:112`) |
579
- | IDOR sur « mes sessions » | périmètre depuis l'identité ALS, jamais du client | `destroyOwnByRef()` (`sessions-service.ts:834`) |
584
+ | IDOR sur « mes sessions » | périmètre depuis l'identité ALS, jamais du client | `destroyOwnByRef()` (`sessions-service.ts:872`) |
580
585
 
581
586
  ## 🧰 API publique
582
587
 
583
588
  Les signatures vivent dans `.ai/symbols.json` (jamais recopiées ici). Voici les usages réels.
584
589
 
585
- **Depuis un contrôleur** — `this.session` est un getter sur le contexte (`Controller.ts:279`) ; un
590
+ **Depuis un contrôleur** — `this.session` est un getter sur le contexte (`Controller.ts:384`) ; un
586
591
  paramètre `@Session()` suffit à déclarer l'intent.
587
592
 
588
593
  | Besoin | Appel | Effet |
@@ -590,7 +595,7 @@ paramètre `@Session()` suffit à déclarer l'intent.
590
595
  | Lire une valeur | `session.get("panier")` | `null` si absente — jamais `undefined`. |
591
596
  | Écrire une valeur | `session.set("panier", items)` | Marque la session `dirty` → écriture en fin de requête. |
592
597
  | Message « une seule lecture » | `session.setFlashBag("notice", "…")` | Consommé (et effacé) au premier `getFlashBag`. |
593
- | Lire ce message | `session.getFlashBag("notice")` | Rend la valeur puis la supprime (`session.ts:518`). |
598
+ | Lire ce message | `session.getFlashBag("notice")` | Rend la valeur puis la supprime (`session.ts:530`). |
594
599
  | Métadonnée technique | `session.getMetaBag("ip")` | ip / ua / host / remoteAddress posés à la création. |
595
600
  | Se déconnecter | `await session.destroy(true)` | Détruit l'entrée store **et** efface le cookie. |
596
601
  | Renouveler l'identifiant | `session.regenerateId()` | Nouvel identifiant, état conservé (`session.ts:236`). |
@@ -598,12 +603,12 @@ paramètre `@Session()` suffit à déclarer l'intent.
598
603
 
599
604
  **Intent de route** — `UseSession(options)` (`routerDecorators.ts:800`) s'applique à une classe **ou** à
600
605
  une méthode ; la méthode l'emporte, par fusion et non par remplacement
601
- (`resolveSessionIntent()`, `routerDecorators.ts:833`). **Une seule** option (`SessionIntent`,
606
+ (`resolveSessionIntent()`, `routerDecorators.ts:858`). **Une seule** option (`SessionIntent`,
602
607
  `ISession.ts:17`) :
603
608
 
604
609
  - `readOnly: true` — la session est reprise et lue mais **jamais** persistée ; une mutation tentée est
605
610
  journalisée en WARNING sans écriture (`Session.save()`, `session.ts:255-264`). C'est le seul champ
606
- propagé par le kernel (`http-kernel.ts:1194`).
611
+ propagé par le kernel (`http-kernel.ts:1210`).
607
612
 
608
613
  En décorateur de **classe**, `@UseSession` se place **sous** `@controller` (`routerDecorators.ts:189`).
609
614
 
@@ -611,7 +616,7 @@ En décorateur de **classe**, `@UseSession` se place **sous** `@controller` (`ro
611
616
 
612
617
  Le registre est une inversion de contrôle complète : `@nodefony/http` ne connaît **aucun** backend.
613
618
  Chaque module fournisseur se déclare lui-même au chargement, par
614
- `SessionsService.registerStorage(nom, ctor)` (`sessions-service.ts:184`) — exactement ce que fait la
619
+ `SessionsService.registerStorage(nom, ctor)` (`sessions-service.ts:196`) — exactement ce que fait la
615
620
  dernière ligne de chaque adaptateur (`@nodefony/redis/nodefony/src/SessionStorage.ts:323`).
616
621
 
617
622
  Pour ajouter un backend :
@@ -638,7 +643,7 @@ Trois règles de conception se dégagent du contrat, et méritent d'être respec
638
643
  | Domaine | Norme | Comment le code s'y conforme |
639
644
  | ----------------------------- | ------------------------- | ----------------------------------------------------------------------------- |
640
645
  | Attributs et préfixes cookie | RFC 6265bis §4.1.3 | `__Host-` impose `Secure` + `Path=/`, interdit `Domain` (`cookie.ts:386-403`) |
641
- | Nom du cookie selon transport | RFC 6265bis / OWASP | `getSessionCookieName()` (`Context.ts:714`) |
646
+ | Nom du cookie selon transport | RFC 6265bis / OWASP | `getSessionCookieName()` (`Context.ts:733`) |
642
647
  | Idle timeout | NIST SP 800-63B-4 / OWASP | défaut 1800 s, glissant par `touch` (`config.ts:831`) |
643
648
  | Absolute timeout | NIST SP 800-63B-4 / OWASP | défaut 43200 s, jamais prolongé (`config.ts:808`) |
644
649
  | Identifiant de session | OWASP Session Management | 32 octets CSPRNG, opaque (`session.ts:226`) |
@@ -651,7 +656,7 @@ Trois règles de conception se dégagent du contrat, et méritent d'être respec
651
656
  Le coût d'une session est **payé seulement quand elle sert** :
652
657
 
653
658
  - **Zéro par défaut** — sans intent ni cookie, `startSession()` sort immédiatement
654
- (`http-kernel.ts:1152`) : ni objet `Session`, ni lecture de store.
659
+ (`http-kernel.ts:1246`) : ni objet `Session`, ni lecture de store.
655
660
  - **Objet léger** — trois sacs `{}` à plat, pas de container DI par session (`session.ts:100-104`).
656
661
  - **Zéro écriture en lecture** — le dirty-tracking court-circuite `save()` (`session.ts:255`) ; le
657
662
  `touch` est throttlé à une écriture par demi-vie d'idle (`session.ts:445`).
@@ -659,7 +664,7 @@ Le coût d'une session est **payé seulement quand elle sert** :
659
664
  probabiliste hérité de PHP (`sessions-service.ts:306-312`).
660
665
  - **Révocation quasi gratuite** — la `Map` de pierres tombales est **paresseuse** : sans révocation,
661
666
  `write` ne paie qu'une comparaison `=== null`, sans même un `Date.now()`
662
- (`RevocationGuardStorage.ts:163-168`).
667
+ (`RevocationGuardStorage.ts:181-186`).
663
668
  - **Administration bornée** — jamais plus de `SCAN_PAGE = 200` enregistrements en mémoire
664
669
  (`sessions-service.ts:75`), garde-fou à 5 000 pages (`sessions-service.ts:83`), parcours interrompu
665
670
  **journalisé**.
@@ -706,7 +711,7 @@ une révocation sans effet, **401** sur `mine` sans identité.
706
711
  **Écrans** — la page **Sessions** (`/nodefony/sessions`) de Studio liste les sessions vivantes par `ref` et permet la
707
712
  révocation unitaire ou en masse (`@nodefony/studio/frontend/src/routes/sessions/`). L'écran **Stores**
708
713
  affiche le backend réellement résolu, sa provenance et son emplacement physique : ces informations sont
709
- publiées au boot par `registerStoreResolution()` (`sessions-service.ts:290`), avec le chemin du fichier
714
+ publiées au boot par `registerStoreResolution()` (`sessions-service.ts:321`), avec le chemin du fichier
710
715
  SQLite quand c'est pertinent (`SessionStorage.location`,
711
716
  `@nodefony/drizzle/nodefony/src/SessionStorage.ts:55`).
712
717
 
@@ -714,7 +719,7 @@ SQLite quand c'est pertinent (`SessionStorage.location`,
714
719
 
715
720
  | Symptôme | Cause | Correction |
716
721
  | ------------------------------------------------------ | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
717
- | Aucun `Set-Cookie`, `session` toujours vide | La route n'a **aucun intent** : ni `@UseSession`, ni paramètre `@Session` | Ajouter l'un des deux — l'activation est paresseuse (`http-kernel.ts:1142`) |
722
+ | Aucun `Set-Cookie`, `session` toujours vide | La route n'a **aucun intent** : ni `@UseSession`, ni paramètre `@Session` | Ajouter l'un des deux — l'activation est paresseuse (`http-kernel.ts:1283`) |
718
723
  | `context.session` est `null` dans un contrôleur WS | Intent posé sur la classe au lieu de la route, ou absent | `@UseSession()` **sur la route** WebSocket ; `Controller.startSession()` n'existe plus |
719
724
  | Mutation ignorée, WARNING « READONLY SESSION mutated » | La route est en `@UseSession({ readOnly: true })` | Retirer `readOnly` sur les routes qui écrivent (`session.ts:255-264`) |
720
725
  | Session détruite qui « revient » après logout | Une requête en vol réécrit le blob supprimé | Déjà couvert : pierre tombale 5 min (`RevocationGuardStorage.ts:121`) |
package/docs/upload.md CHANGED
@@ -62,10 +62,10 @@ flowchart TD
62
62
 
63
63
  Trois idées portent tout le reste :
64
64
 
65
- 1. **Le `Content-Type` décide, pas la méthode.** `parseRequest()` (`context/http/Request.ts:429`)
65
+ 1. **Le `Content-Type` décide, pas la méthode.** `parseRequest()` (`context/http/Request.ts:513`)
66
66
  aiguille vers `ParserJson` / `ParserQs` / `ParserXml` / **busboy** / parser brut selon l'en-tête.
67
67
  2. **Seul le multipart écrit sur disque.** Les fichiers sont **streamés** au fil de l'eau vers un
68
- fichier temporaire (`parseMultipart()`, `context/http/Request.ts:499`) — jamais bufferisés en RAM.
68
+ fichier temporaire (`parseMultipart()`, `context/http/Request.ts:585`) — jamais bufferisés en RAM.
69
69
  JSON / urlencoded / XML restent en mémoire (petits corps).
70
70
  3. **Deux budgets distincts, jamais confondus.** Le corps **non-multipart** est borné par `maxBodySize`
71
71
  (défaut **1 MiB**) ; le multipart a **ses propres** limites busboy (`maxFileSize`, `maxTotalFileSize`,
@@ -117,16 +117,16 @@ Trois choix structurent l'implémentation, et chacun a une raison de sécurité
117
117
 
118
118
  **Le multipart ne touche jamais la RAM.** Là où l'ancien chemin bufferisait le corps entier, busboy lit
119
119
  le flux et écrit chaque fichier au fil de l'eau dans le dossier temporaire (`streamMultipart()`,
120
- `context/http/Request.ts:537`). Un upload de 1 Go ne coûte donc pas 1 Go de heap — seuls les petits
120
+ `context/http/Request.ts:626`). Un upload de 1 Go ne coûte donc pas 1 Go de heap — seuls les petits
121
121
  champs texte restent en mémoire. C'est ce qui rend un endpoint d'upload public tenable.
122
122
 
123
123
  **Le nom du fichier temporaire n'est jamais celui du client.** Chaque fichier reçu est écrit sous un nom
124
- `randomUUID()` + extension d'origine (`context/http/Request.ts:594`) : un nom malveillant
124
+ `randomUUID()` + extension d'origine (`context/http/Request.ts:683`) : un nom malveillant
125
125
  (`../../etc/passwd`) ne peut pas influencer le **chemin** d'écriture. Le nom d'origine est conservé en
126
126
  **métadonnée** (`filename`), pas dans le chemin.
127
127
 
128
128
  **Deux budgets, secure-by-default.** Le corps non-multipart est plafonné à **1 MiB** par défaut
129
- (`maxBodySize`, `http/nodefony/config/config.ts:1026`) — un `POST` JSON géant est rejeté avant d'être bufferisé. Le
129
+ (`maxBodySize`, `http/nodefony/config/config.ts:1027`) — un `POST` JSON géant est rejeté avant d'être bufferisé. Le
130
130
  multipart, lui, a ses propres bornes busboy (par fichier, cumul, nombre) qui coupent le flux et
131
131
  nettoient les temporaires déjà posés au moindre dépassement (`context/http/Request.ts:481`).
132
132
 
@@ -257,19 +257,19 @@ sequenceDiagram
257
257
  Les points d'implémentation qui expliquent des comportements surprenants :
258
258
 
259
259
  1. **L'aiguillage lit le `Content-Type`, pas la méthode** — `parseRequest()`
260
- (`context/http/Request.ts:429`). `PATCH` porte un corps comme `POST`/`PUT` : il figure dans la table
261
- des méthodes parsées (`context/http/Request.ts:79`) — l'oubli laissait tout `PATCH` avec un corps vide.
260
+ (`context/http/Request.ts:513`). `PATCH` porte un corps comme `POST`/`PUT` : il figure dans la table
261
+ des méthodes parsées (`context/http/Request.ts:94`) — l'oubli laissait tout `PATCH` avec un corps vide.
262
262
  2. **Le multipart draine sur un `finish`, après flush de tous les writes** — `streamMultipart()`
263
- (`context/http/Request.ts:537`) accumule les `Promise` d'écriture disque et ne résout `{ fields, files }`
264
- qu'une fois tous les fichiers fermés (`context/http/Request.ts:538`).
263
+ (`context/http/Request.ts:626`) accumule les `Promise` d'écriture disque et ne résout `{ fields, files }`
264
+ qu'une fois tous les fichiers fermés (`bb.on("finish")`, `context/http/Request.ts:707`).
265
265
  3. **Une limite dépassée nettoie les temporaires déjà posés** — `abort()`
266
- (`context/http/Request.ts:557`) délie le flux, détruit les write-streams ouverts et `unlink` les temp
267
- déjà écrits (`context/http/Request.ts:568`) avant de rejeter en `413` : pas d'orphelins sur le disque.
266
+ (`context/http/Request.ts:646`) délie le flux, détruit les write-streams ouverts et `unlink` les temp
267
+ déjà écrits (`context/http/Request.ts:64`) avant de rejeter en `413` : pas d'orphelins sur le disque.
268
268
  4. **Les autres formats drainent AVANT de concaténer** — la base `Parser.parse()` attend `end`
269
269
  (`context/http/parser.ts:111`) avant `Buffer.concat` : sans ce drain, `ParserQs`/`ParserXml`
270
270
  lisaient un corps partiel → `queryPost` vide (bug de régression, cf tests).
271
271
  5. **Un multipart sans boundary exploitable ne crashe pas** — `new Busboy()` lève **synchroniquement** ;
272
- c'est rattrapé et on bascule sur le `Parser` brut (`context/http/Request.ts:375`).
272
+ c'est rattrapé et on bascule sur le `Parser` brut (`context/http/Request.ts:417`).
273
273
 
274
274
  ## ⚙️ Configuration — deux budgets, jamais confondus
275
275
 
@@ -283,10 +283,16 @@ non-multipart n'écoute pas `upload.*`.
283
283
  | `maxBodySize` | octets | `1_048_576` (1 MiB) | Plafond d'un corps **JSON / urlencoded / XML / brut** → `413`. `0` = illimité. |
284
284
 
285
285
  Deux rideaux, tous deux `runtimeMutable` (éditable à chaud) : un **pré-check** sur `Content-Length`
286
- qui rejette **avant** de lire (`enforceBodyLimit()`, `context/http/Request.ts:407`), puis un **compteur
286
+ qui rejette **avant** de lire (`enforceBodyLimit()`, `context/http/Request.ts:490`), puis un **compteur
287
287
  en streaming** qui coupe le socket si le corps déborde sans `Content-Length` honnête — chunked ou
288
288
  menteur (`Parser.write()`, `context/http/parser.ts:33`, dépassement `context/http/parser.ts:33`).
289
- Champ `maxBodySize` du schéma : `config/config.ts:1026`.
289
+ Champ `maxBodySize` du schéma : `config/config.ts:1027`.
290
+
291
+ **Par requête** : `maxBodySize` et les limites `upload.*` (hors `uploadDir`) sont sur la liste blanche
292
+ du calque de configuration — une application peut les changer pour une requête
293
+ (`overlayConfig("@nodefony/http", { maxBodySize: … })`, posé dans un écouteur `onRequestScope`). Ils
294
+ sont lus au moment de lire le corps (`Request.readBodyLimits()`), donc un calque posé après ce moment
295
+ (au pare-feu, dans un contrôleur) n'agit pas. Détail : [configuration](../../../../../docs/guides/configuration.md).
290
296
 
291
297
  ### Fichiers multipart — `upload.*`
292
298
 
@@ -294,14 +300,14 @@ Table dérivée de `uploadSchema` (`config/config.ts:159`). Toutes les bornes so
294
300
 
295
301
  | Option | Type | Défaut | Effet |
296
302
  | ------------------ | -------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------- |
297
- | `uploadDir` | chemin | `""` → `kernel.tmpDir` | Dossier de dépôt des temporaires. Vide = temp du kernel (`config/config.ts:146`). |
298
- | `maxFileSize` | octets | `524_288_000` (500 MB) | Taille max d'**UN** fichier (busboy `limits.fileSize`) → `413` (`config/config.ts:155`). |
299
- | `maxTotalFileSize` | octets | `524_288_000` (500 MB) | Taille **CUMULÉE** des fichiers d'une requête (compteur Nodefony) → `413` (`config/config.ts:167`). |
300
- | `maxFiles` | entier | `1000` | Nombre max de fichiers (busboy `limits.files`) → `413` (`config/config.ts:179`). |
301
- | `maxFields` | entier | `1000` | Nombre max de champs texte (busboy `limits.fields`) → `413` (`config/config.ts:190`). |
302
- | `maxFieldsSize` | octets | `2_097_152` (2 MB) | Taille max d'un champ texte (busboy `limits.fieldSize`) (`config/config.ts:201`). |
303
- | `hashAlgorithm` | `false` \| `sha256`/`sha1`/`md5` | `false` | Hash calculé pendant le stream (intégrité) (`config/config.ts:212`). |
304
- | `encoding` | chaîne | `"utf-8"` | Encodage par défaut des champs texte (busboy `defCharset`) (`config/config.ts:219`). |
303
+ | `uploadDir` | chemin | `""` → `kernel.tmpDir` | Dossier de dépôt des temporaires. Vide = temp du kernel (`config/config.ts:162`). |
304
+ | `maxFileSize` | octets | `524_288_000` (500 MB) | Taille max d'**UN** fichier (busboy `limits.fileSize`) → `413` (`config/config.ts:171`). |
305
+ | `maxTotalFileSize` | octets | `524_288_000` (500 MB) | Taille **CUMULÉE** des fichiers d'une requête (compteur Nodefony) → `413` (`config/config.ts:183`). |
306
+ | `maxFiles` | entier | `1000` | Nombre max de fichiers (busboy `limits.files`) → `413` (`config/config.ts:195`). |
307
+ | `maxFields` | entier | `1000` | Nombre max de champs texte (busboy `limits.fields`) → `413` (`config/config.ts:206`). |
308
+ | `maxFieldsSize` | octets | `2_097_152` (2 MB) | Taille max d'un champ texte (busboy `limits.fieldSize`) (`config/config.ts:217`). |
309
+ | `hashAlgorithm` | `false` \| `sha256`/`sha1`/`md5` | `false` | Hash calculé pendant le stream (intégrité) (`config/config.ts:228`). |
310
+ | `encoding` | chaîne | `"utf-8"` | Encodage par défaut des champs texte (busboy `defCharset`) (`config/config.ts:235`). |
305
311
 
306
312
  > [!WARNING]
307
313
  > `uploadDir` vide est **résolu au boot** sur le répertoire temporaire du kernel par le builder de config
@@ -316,16 +322,16 @@ recommandé) et les **getters** de `Controller` (impératif). Les signatures exa
316
322
 
317
323
  | Accès | Source lue | Ancrage |
318
324
  | -------------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------- |
319
- | `@UploadedFiles() f: IUploadedFile[]` | tous les fichiers (`queryFile`) | `resolveParamArg` `"files"` (`routerDecorators.ts:1240`) |
320
- | `@UploadedFile() f: IUploadedFile` | le **premier** fichier | `resolveParamArg` `"file"` (`routerDecorators.ts:1239`) |
321
- | `@Body() body` | tous les champs parsés (`queryPost`) | `resolveParamArg` `"body"` (`routerDecorators.ts:1178`) |
325
+ | `@UploadedFiles() f: IUploadedFile[]` | tous les fichiers (`queryFile`) | `resolveParamArg` `"files"` (`routerDecorators.ts:1283`) |
326
+ | `@UploadedFile() f: IUploadedFile` | le **premier** fichier | `resolveParamArg` `"file"` (`routerDecorators.ts:1282`) |
327
+ | `@Body() body` | tous les champs parsés (`queryPost`) | `resolveParamArg` `"body"` (`routerDecorators.ts:1232`) |
322
328
  | `@Body("label") v` | un seul champ du body | même source, clé (`routerDecorators.ts:1223`) |
323
- | `@Body({ stream: true }) s: NodeJS.ReadableStream` | le **flux brut**, parse **sauté** | `resolveParamArg` stream (`routerDecorators.ts:1297`) |
324
- | `this.queryFile` | équivalent getter des fichiers | `Controller.queryFile` (`framework/nodefony/src/Controller.ts:239`) |
325
- | `this.queryPost` | équivalent getter des champs | `Controller.queryPost` (`framework/nodefony/src/Controller.ts:248`) |
329
+ | `@Body({ stream: true }) s: NodeJS.ReadableStream` | le **flux brut**, parse **sauté** | `resolveParamArg` stream (`routerDecorators.ts:1326`) |
330
+ | `this.queryFile` | équivalent getter des fichiers | `Controller.queryFile` (`framework/nodefony/src/Controller.ts:341`) |
331
+ | `this.queryPost` | équivalent getter des champs | `Controller.queryPost` (`framework/nodefony/src/Controller.ts:351`) |
326
332
 
327
333
  Les décorateurs `@UploadedFile` / `@UploadedFiles` sont des fabriques de paramètre
328
- (`routerDecorators.ts:1254`), exportées par `@nodefony/framework` ; leurs interfaces `IUploadedFile` /
334
+ (`routerDecorators.ts:1287`), exportées par `@nodefony/framework` ; leurs interfaces `IUploadedFile` /
329
335
  `IParsedUploadFile` viennent de `@nodefony/http` (`interfaces/IUpload.ts:49`, `interfaces/IUpload.ts:7`).
330
336
 
331
337
  ### Un fichier uploadé — `UploadedFile`
@@ -340,8 +346,8 @@ sur disque. Ses membres utiles :
340
346
  | `prettySize` | Taille lisible (`« 1.2 MB »`). | `getPrettySize()` (`service/upload/upload-service.ts:143`) |
341
347
  | `mimeType` | Type MIME déclaré (`image/png`…), sinon deviné de l'extension. | `getMimeType()` (`service/upload/upload-service.ts:153`) |
342
348
  | `hash` / `hashAlgorithm` | Empreinte d'intégrité si `upload.hashAlgorithm` est réglé. | `interfaces/IUpload.ts:55` |
343
- | `moveAsync(target)` | Déplace le temp — **non bloquant, recommandé** dans le pipeline. | `moveAsync()` (`service/upload/upload-service.ts:193`) |
344
- | `move(target)` | Variante **synchrone** (compat) — bloque l'event-loop. | `move()` (`service/upload/upload-service.ts:160`) |
349
+ | `moveAsync(target)` | Déplace le temp — **non bloquant, recommandé** dans le pipeline. | `moveAsync()` (`service/upload/upload-service.ts:218`) |
350
+ | `move(target)` | Variante **synchrone** (compat) — bloque l'event-loop. | `move()` (`service/upload/upload-service.ts:186`) |
345
351
 
346
352
  `move`/`moveAsync` acceptent un **fichier cible** ou un **dossier existant** : sur un dossier, la
347
353
  destination est bâtie avec `filename` — d'où l'avertissement de sécurité ci-dessous.
@@ -351,7 +357,7 @@ destination est bâtie avec `filename` — d'où l'avertissement de sécurité c
351
357
  Pour piper directement un très gros corps (vidéo, backup) vers le disque ou S3 sans passer par busboy ni
352
358
  par la RAM, `@Body({ stream: true })` court-circuite le parse et injecte l'`IncomingMessage` brut (un
353
359
  `Readable`). Le pipeline sait le sauter en amont via `routeExpectsBodyStream()`
354
- (`routerDecorators.ts:1379`), mémoïsé sur la route.
360
+ (`routerDecorators.ts:1408`), mémoïsé sur la route.
355
361
 
356
362
  ```ts
357
363
  // fragment — le contrôleur pipe le flux lui-même (0 parse, 0 pic RAM)
@@ -370,10 +376,10 @@ piégé. Les défenses en place, et **ce qui reste à ta charge**.
370
376
  <!-- prettier-ignore -->
371
377
  | Menace | Défense côté framework | À ta charge |
372
378
  | --- | --- | --- |
373
- | **Path traversal** (chemin d'écriture) | Le temp est nommé `randomUUID()` + extension — jamais le nom client (`context/http/Request.ts:594`). | La **destination** de `move()` (voir avertissement). |
379
+ | **Path traversal** (chemin d'écriture) | Le temp est nommé `randomUUID()` + extension — jamais le nom client (`context/http/Request.ts:683`). | La **destination** de `move()` (voir avertissement). |
374
380
  | **Saturation RAM** | Multipart streamé (jamais bufferisé) ; corps non-multipart borné (`maxBodySize`). | Resserrer `maxBodySize` selon l'endpoint. |
375
- | **Saturation disque** | `maxFileSize` + `maxTotalFileSize` + `maxFiles` ; `abort()` nettoie les temp à l'abandon (`context/http/Request.ts:557`). | Purger les temp non déplacés (TTL / cron). |
376
- | **DoS par quantité** | `maxFields` / `maxFiles` / `parts` → `413` (`context/http/Request.ts:610`). | — |
381
+ | **Saturation disque** | `maxFileSize` + `maxTotalFileSize` + `maxFiles` ; `abort()` nettoie les temp à l'abandon (`context/http/Request.ts:646`). | Purger les temp non déplacés (TTL / cron). |
382
+ | **DoS par quantité** | `maxFields` / `maxFiles` / `parts` → `413` (`context/http/Request.ts:754`). | — |
377
383
  | **Type de fichier hostile** | `mimeType` **déclaré** est exposé tel quel. | Valider le type/contenu réel (le MIME client est déclaratif). |
378
384
 
379
385
  > [!WARNING]
@@ -390,7 +396,7 @@ Le parsing du corps est sur le chemin de chaque requête écrivante — les choi
390
396
  - **Multipart en flux pur** — plus de double-bufferisation : busboy écrit sur disque au fil de l'eau,
391
397
  seuls les champs texte restent en mémoire (`context/http/Request.ts:400`).
392
398
  - **Rejet AVANT lecture** — le pré-check `Content-Length` renvoie `413` sans lire un octet
393
- (`context/http/Request.ts:400`) ; le compteur streaming **libère immédiatement** la RAM déjà
399
+ (`context/http/Request.ts:466`) ; le compteur streaming **libère immédiatement** la RAM déjà
394
400
  bufferisée au dépassement (`context/http/parser.ts:43`).
395
401
  - **`stat` non bloquant** — `UploadedFile.create()` résout les stats du fichier en async
396
402
  (`service/upload/upload-service.ts:130`), plus de `lstatSync` par fichier uploadé.
@@ -406,12 +412,12 @@ pipeline : `npm run test:memory` (skill `nodefony-check-memory-health`).
406
412
 
407
413
  | Domaine | Norme | Ancrage |
408
414
  | ---------------------------------- | -------------------------------- | ------------------------------------------------------------- |
409
- | Formulaire avec fichiers | RFC 7578 (`multipart/form-data`) | `parseMultipart()` via busboy (`context/http/Request.ts:499`) |
410
- | Corps trop gros → 413 | RFC 9110 §15.5.14 | `enforceBodyLimit()` (`context/http/Request.ts:407`) |
415
+ | Formulaire avec fichiers | RFC 7578 (`multipart/form-data`) | `parseMultipart()` via busboy (`context/http/Request.ts:585`) |
416
+ | Corps trop gros → 413 | RFC 9110 §15.5.14 | `enforceBodyLimit()` (`context/http/Request.ts:490`) |
411
417
  | 413 en streaming (chunked/menteur) | RFC 9110 §15.5.14 | `Parser.write()` (`context/http/parser.ts:33`) |
412
418
  | Bornes multipart → 413 | RFC 9110 §15.5.14 | `stream.on("limit")` (`context/http/Request.ts:481`) |
413
- | Défense path traversal (nom temp) | OWASP — File Upload | `randomUUID()` (`context/http/Request.ts:594`) |
414
- | Charset du corps honoré | RFC 9110 (Content-Type) | `getCharset()` (`context/http/Request.ts:799`) |
419
+ | Défense path traversal (nom temp) | OWASP — File Upload | `randomUUID()` (`context/http/Request.ts:683`) |
420
+ | Charset du corps honoré | RFC 9110 (Content-Type) | `getCharset()` (`context/http/Request.ts:886`) |
415
421
 
416
422
  ## ⚠️ Pièges
417
423
 
@@ -423,9 +429,9 @@ pipeline : `npm run test:memory` (skill `nodefony-check-memory-health`).
423
429
  | `413` sur upload sans message clair | Une borne busboy atteinte en streaming (fichier, cumul, nombre) | Vérifier les bornes `upload.*` — 413 émis sur `stream.on("limit")` (`context/http/Request.ts:481`) |
424
430
  | Un fichier écrit `../../etc/…` après un `move` | `move(dossier)` utilise le nom **client** (`upload-service.ts:197`) | Passer une cible complète, ou `path.basename(file.filename)` |
425
431
  | Des fichiers temporaires s'accumulent dans `uploadDir` | Le contrôleur ne déplace jamais le temp | Appeler `moveAsync()` (ou purger l'ancien temp par TTL) |
426
- | `queryPost` vide sur `PATCH` | Déjà géré : `PATCH` est dans la table des méthodes parsées | Aucune — corps `PATCH` parsé comme `POST` (`context/http/Request.ts:78`) |
427
- | Corps `latin1` mal décodé | Déjà géré : le `charset=` du `Content-Type` est honoré | Aucune — `getCharset()` normalise (`context/http/Request.ts:799`) |
428
- | `multipart` sans boundary fait planter | `new Busboy()` throw synchrone | Déjà géré : bascule sur le parser brut (`context/http/Request.ts:502`) |
432
+ | `queryPost` vide sur `PATCH` | Déjà géré : `PATCH` est dans la table des méthodes parsées | Aucune — corps `PATCH` parsé comme `POST` (`context/http/Request.ts:93`) |
433
+ | Corps `latin1` mal décodé | Déjà géré : le `charset=` du `Content-Type` est honoré | Aucune — `getCharset()` normalise (`context/http/Request.ts:886`) |
434
+ | `multipart` sans boundary fait planter | `new Busboy()` throw synchrone | Déjà géré : bascule sur le parser brut (`context/http/Request.ts:521`) |
429
435
 
430
436
  ## 🧪 Tests & couverture
431
437
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/http",
3
- "version": "10.0.0-alpha.9",
3
+ "version": "10.0.0-beta.2",
4
4
  "description": "Serveurs HTTP, HTTPS, HTTP/2 et WebSocket natifs pour Nodefony : sessions, contextes de requête, certificats TLS",
5
5
  "author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
6
6
  "main": "./dist/index.js",
@@ -56,35 +56,32 @@
56
56
  "http-terminator": "3.2.0",
57
57
  "mime-types": "3.0.2",
58
58
  "ms": "^2.1.3",
59
- "node-forge": "1.4.0",
60
- "qs": "^6.15.3",
59
+ "qs": "^6.16.0",
61
60
  "serve-static": "2.2.1",
62
61
  "tslib": "2.8.1",
63
- "ws": "8.21.3",
62
+ "ws": "8.22.0",
64
63
  "xml2js": "0.6.2"
65
64
  },
66
65
  "devDependencies": {
67
66
  "@types/chai": "5.2.3",
68
67
  "@types/mime-types": "3.0.1",
69
68
  "@types/ms": "2.1.0",
70
- "@types/node": "26.6.2",
71
- "@types/node-forge": "1.3.14",
69
+ "@types/node": "26.6.3",
72
70
  "@types/qs": "6.15.1",
73
71
  "@types/serve-static": "2.2.0",
74
72
  "@types/websocket": "1.0.10",
75
- "@types/ws": "8.18.1",
73
+ "@types/ws": "8.18.2",
76
74
  "@types/xml2js": "0.4.14",
77
- "@vitest/coverage-v8": "5.0.1",
78
- "chai": "6.2.2",
79
- "nodefony": "^10.0.0-alpha.9",
75
+ "@vitest/coverage-v8": "5.0.3",
76
+ "nodefony": "^10.0.0-beta.2",
80
77
  "tsx": "4.23.15",
81
- "vitest": "5.0.1"
78
+ "vitest": "5.0.3"
82
79
  },
83
80
  "license": "Apache-2.0",
84
81
  "readmeFilename": "README.md",
85
82
  "contributors": [],
86
83
  "peerDependencies": {
87
- "nodefony": "^10.0.0-alpha.9",
84
+ "nodefony": "^10.0.0-beta.2",
88
85
  "zod": "^4.6.5"
89
86
  },
90
87
  "files": [