@nodefony/http 10.0.0-beta.1 → 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 (33) hide show
  1. package/README.md +5 -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 +2 -2
  6. package/dist/nodefony/command/certificatesCommand.js +0 -1
  7. package/dist/nodefony/config/config.js +4 -3
  8. package/dist/nodefony/service/certificates.js +74 -143
  9. package/dist/nodefony/service/http-kernel.js +2 -2
  10. package/dist/nodefony/service/servers/server-http.js +3 -3
  11. package/dist/nodefony/service/servers/server-https.js +3 -3
  12. package/dist/nodefony/service/servers/server-websocket-secure.js +3 -3
  13. package/dist/nodefony/service/servers/server-websocket.js +3 -3
  14. package/dist/nodefony/service/sessions/sessions-service.js +3 -3
  15. package/dist/nodefony/service/trace.js +2 -1
  16. package/dist/nodefony/service/upload/upload-service.js +3 -3
  17. package/dist/nodefony/service/x509.js +324 -0
  18. package/dist/nodefony/src/context/http/Request.js +17 -4
  19. package/dist/nodefony/src/context/http/Response.js +30 -6
  20. package/dist/nodefony/src/context/http/urlFastPath.js +35 -10
  21. package/dist/nodefony/src/servers/portBinder.js +57 -2
  22. package/dist/types/nodefony/config/config.d.ts +1 -0
  23. package/dist/types/nodefony/service/certificates.d.ts +32 -47
  24. package/dist/types/nodefony/service/x509.d.ts +86 -0
  25. package/dist/types/nodefony/src/servers/portBinder.d.ts +10 -46
  26. package/docs/cookies.md +10 -10
  27. package/docs/index.md +4 -2
  28. package/docs/observabilite.md +13 -13
  29. package/docs/rate-limit.md +19 -19
  30. package/docs/servers.md +50 -47
  31. package/docs/session.md +17 -17
  32. package/docs/upload.md +29 -29
  33. package/package.json +7 -10
@@ -1,18 +1,18 @@
1
- import type pkg from "node-forge";
2
1
  import { Service, Module } from "nodefony";
3
- /**
4
- * Type-valeur du backend node-forge runtime (objet `.pki`/`.md`). L'import type
5
- * `pkg` sert pour les namespaces de types (`pkg.pki.Certificate`) ; `ForgeModule`
6
- * pour la valeur chargée dynamiquement (node-forge est un module `export =`).
7
- */
8
- type ForgeModule = typeof import("node-forge");
2
+ import { type KeyObject } from "node:crypto";
3
+ import { type ICertificateAttribute } from "./x509.js";
4
+ /** Paire de clés RSA du certificat auto-signé. */
5
+ export interface IRsaKeyPair {
6
+ privateKey: KeyObject;
7
+ publicKey: KeyObject;
8
+ }
9
9
  /** Algorithmes de hachage de signature autorisés (SHA-1 banni). */
10
10
  export type CertHash = "sha256" | "sha384" | "sha512";
11
11
  /** Stratégie de fourniture du certificat exposée en configuration. */
12
12
  export type CertStrategyConfig = "auto" | "mkcert" | "selfsigned" | "explicit";
13
13
  /**
14
- * Options de génération du certificat AUTO-SIGNÉ (node-forge, JavaScript pur —
15
- * aucun binaire externe n'est invoqué).
14
+ * Options de génération du certificat AUTO-SIGNÉ (`node:crypto` — aucun
15
+ * binaire externe ni aucune dépendance tierce).
16
16
  *
17
17
  * ⚠️ Portée : ces réglages ne valent QUE pour la stratégie `selfsigned`. Sous
18
18
  * `mkcert` (le défaut en développement) ils sont ignorés — mkcert ne reçoit que
@@ -29,7 +29,7 @@ export interface SelfSignedOptions {
29
29
  /** Recul de `notBefore` (minutes) — tolérance au décalage d'horloge client. */
30
30
  backdateMinutes: number;
31
31
  /** Attributs du sujet/issuer (commonName, organizationName…). */
32
- attrs: pkg.pki.CertificateField[];
32
+ attrs: ICertificateAttribute[];
33
33
  }
34
34
  /** Options de génération du certificat TLS en mode développement. */
35
35
  export interface CertificateDevOptions {
@@ -37,7 +37,7 @@ export interface CertificateDevOptions {
37
37
  * Préférer `mkcert` (CA locale ajoutée au trust store système) pour générer
38
38
  * le certificat de dev. Donne un HTTPS sans erreur navigateur — indispensable
39
39
  * pour les sous-ressources cross-origin (Vite) et le WSS du HMR.
40
- * Si `mkcert` est absent, on retombe sur un auto-signé node-forge (non trusté).
40
+ * Si `mkcert` est absent, on retombe sur un auto-signé (non trusté).
41
41
  * Ignoré hors `development`.
42
42
  */
43
43
  useMkcert: boolean;
@@ -96,7 +96,7 @@ export interface CertificateInfo {
96
96
  *
97
97
  * Trois stratégies : `explicit` (certificat fourni en config — le cas de
98
98
  * PRODUCTION), `mkcert` (CA locale trustée, confort de dev) et `selfsigned`
99
- * (auto-signé node-forge, secours). La génération est réservée au
99
+ * (auto-signé, secours). La génération est réservée au
100
100
  * DÉVELOPPEMENT : en production sans certificat fourni, le service crie un
101
101
  * avertissement (Nodefony n'est pas une autorité de certification).
102
102
  *
@@ -108,8 +108,7 @@ export interface CertificateInfo {
108
108
  declare class Certificate extends Service {
109
109
  module: Module;
110
110
  files: filesCertType[];
111
- keysPair: pkg.pki.rsa.KeyPair | null;
112
- certForge: pkg.pki.Certificate | null;
111
+ keysPair: IRsaKeyPair | null;
113
112
  ca: Buffer | string | null;
114
113
  key: Buffer | string | null;
115
114
  cert: Buffer | string | null;
@@ -119,13 +118,6 @@ declare class Certificate extends Service {
119
118
  intermediateCertPem: Buffer | string | null;
120
119
  /** CAROOT résolu de mkcert (null tant que non détecté / indisponible). */
121
120
  private mkcertCaRoot;
122
- /**
123
- * Backend crypto node-forge, chargé PARESSEUSEMENT. La génération de
124
- * certificat est un confort de DÉVELOPPEMENT : en production avec un
125
- * certificat fourni (`strategy: "explicit"`), cette grosse dépendance n'est
126
- * jamais importée (règle perf-mémoire — pas de dep chargée « au cas où »).
127
- */
128
- private forge;
129
121
  path: string;
130
122
  serverPath: string;
131
123
  caPath: string;
@@ -138,14 +130,6 @@ declare class Certificate extends Service {
138
130
  private get certOptions();
139
131
  /** Vrai en environnement de développement (mkcert réservé à ce mode). */
140
132
  private isDev;
141
- /**
142
- * Charge node-forge à la demande (idempotent) — uniquement sur le chemin de
143
- * GÉNÉRATION (selfsigned / mkcert / inspection). Le chemin `explicit` (prod)
144
- * ne l'appelle jamais.
145
- */
146
- loadForge(): Promise<ForgeModule>;
147
- /** Accès au backend forge déjà chargé (lève si `loadForge` n'a pas été appelé). */
148
- private get forgeLib();
149
133
  /**
150
134
  * Fabrique-t-on un certificat au démarrage ?
151
135
  *
@@ -182,7 +166,7 @@ declare class Certificate extends Service {
182
166
  /**
183
167
  * Génère (ou recharge) le certificat serveur selon la stratégie résolue :
184
168
  * `explicit` (fourni en config / prod), `mkcert` (dev, CA trustée) ou
185
- * `selfsigned` (auto-signé node-forge, secours). Régénère automatiquement si
169
+ * `selfsigned` (auto-signé, secours). Régénère automatiquement si
186
170
  * le certificat présent sur disque n'est pas adéquat pour la stratégie active
187
171
  * (expiré, SHA-1, SAN incomplet).
188
172
  *
@@ -195,7 +179,7 @@ declare class Certificate extends Service {
195
179
  * erreur si absents.
196
180
  * - `mkcert` : dev + binaire mkcert + CA locale présents. Forcé hors dev →
197
181
  * retombe sur `selfsigned` avec avertissement.
198
- * - `selfsigned` : auto-signé node-forge (fallback). En PRODUCTION, crie un
182
+ * - `selfsigned` : auto-signé (fallback). En PRODUCTION, crie un
199
183
  * avertissement : la génération n'est pas le rôle d'un serveur de prod.
200
184
  */
201
185
  private resolveStrategy;
@@ -248,30 +232,31 @@ declare class Certificate extends Service {
248
232
  * par mkcert passe aussi par ici). Échec silencieux hors POSIX.
249
233
  */
250
234
  private restrictPrivateKey;
251
- generateKeys(): pkg.pki.rsa.KeyPair;
235
+ /** Génère la paire RSA (taille configurée) hors de la boucle d'événements. */
236
+ generateKeys(): Promise<IRsaKeyPair>;
237
+ /** Clé privée en PEM PKCS#1 (`BEGIN RSA PRIVATE KEY`). */
252
238
  generatePrivateKeyPem(): Buffer;
239
+ /** Clé publique en PEM SPKI (`BEGIN PUBLIC KEY`). */
253
240
  generatePublickeyPem(): Buffer;
254
- generateCertPem(): Buffer;
255
- createCertificate(): pkg.pki.Certificate;
256
- /** subjectAltName de l'auto-signé : DNS + IP (config explicite sinon dérivé). */
257
- private altNames;
258
- setExtension(): void;
259
241
  /**
260
- * Signe le certificat avec le hachage configuré (SHA-256 par défaut).
261
- * SHA-1 est INTERDIT (collision SHAttered 2017, CA/Browser Forum depuis 2016) :
262
- * `node-forge` signe en SHA-1 par défaut si on ne passe pas de digest → on en
263
- * passe toujours un.
242
+ * Fabrique et signe le certificat auto-signé avec la paire courante.
243
+ *
244
+ * Série aléatoire unique (RFC 5280 §4.1.2.2), `notBefore` reculé (décalage
245
+ * d'horloge du client), SAN DNS + IP (config explicite sinon dérivé), signé
246
+ * avec le hachage configuré — jamais SHA-1 (collision SHAttered 2017,
247
+ * CA/Browser Forum depuis 2016).
248
+ *
249
+ * @returns le certificat en PEM
250
+ * @throws Si la paire de clés n'a pas été générée
264
251
  */
265
- sign(): void;
252
+ createCertificate(): Promise<Buffer>;
266
253
  /**
267
254
  * Résumé introspectable du certificat serveur courant — réutilisé par la
268
255
  * commande CLI `certificates` et un futur endpoint d'admin Studio (parité
269
- * CLI ↔ Web). Parse le certificat chargé (charge node-forge à la demande).
256
+ * CLI ↔ Web).
270
257
  */
271
258
  describe(): Promise<CertificateInfo>;
272
- /** Nom lisible de l'OID d'algorithme de signature. */
273
- private oidName;
274
- /** Digest node-forge correspondant au hachage configuré (jamais SHA-1). */
275
- private digestFor;
259
+ /** Hachage configuré, ramené à SHA-256 s'il sort du contrat (jamais SHA-1). */
260
+ private allowedHash;
276
261
  }
277
262
  export default Certificate;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Fabrique et lecture de certificats X.509 par `node:crypto` seul — aucune
3
+ * dépendance tierce.
4
+ *
5
+ * `node:crypto` sait LIRE un certificat (`X509Certificate`) et SIGNER, pas
6
+ * en FABRIQUER un : ce module encode le `TBSCertificate` en DER (RFC 5280
7
+ * §4.1), le signe avec `crypto.sign`, puis l'assemble. Seul le sous-ensemble
8
+ * nécessaire à un certificat serveur auto-signé est couvert. Les fonctions
9
+ * sont PURES (aucun disque, aucun kernel) : elles s'éprouvent sans décor.
10
+ *
11
+ * Pourquoi pas node-forge : sa vérification de signature RSA PKCS#1 v1.5
12
+ * accepte des signatures forgées (GHSA-86w9-cpqp-85rv), sans aucune version
13
+ * corrigée. Nous ne vérifions aucune signature, mais `npm audit` signale le
14
+ * paquet pour tous ceux qui installent `@nodefony/http`.
15
+ */
16
+ import { type KeyObject } from "node:crypto";
17
+ /** Hachages de signature que le builder sait déclarer. */
18
+ export type X509Hash = "sha1" | "sha256" | "sha384" | "sha512";
19
+ /**
20
+ * Attribut d'un nom distinctif (sujet / émetteur). Forme reprise de la config
21
+ * historique : `name` long (`commonName`), `shortName` (`CN`) ou `type` (OID
22
+ * pointé) — le premier présent fait foi.
23
+ */
24
+ export interface ICertificateAttribute {
25
+ name?: string | undefined;
26
+ shortName?: string | undefined;
27
+ type?: string | undefined;
28
+ value?: string | undefined;
29
+ }
30
+ /** Ce qu'il faut pour fabriquer un certificat serveur auto-signé. */
31
+ export interface ISelfSignedSpec {
32
+ privateKey: KeyObject;
33
+ publicKey: KeyObject;
34
+ /** Numéro de série en hexadécimal (entier positif, RFC 5280 §4.1.2.2). */
35
+ serialHex: string;
36
+ notBefore: Date;
37
+ notAfter: Date;
38
+ /** Sujet ET émetteur (auto-signé). Vide = nom vide, SAN alors critique. */
39
+ attributes: ICertificateAttribute[];
40
+ dns: string[];
41
+ ip: string[];
42
+ /** `sha1` n'est accepté que pour fabriquer des témoins de test. */
43
+ hash: X509Hash;
44
+ }
45
+ /** OID de la signature RSA-SHA1, que le service régénère. */
46
+ export declare const SHA1_WITH_RSA_OID: string;
47
+ /** Octets d'une adresse IP pour un SAN `iPAddress` (4 ou 16 octets). */
48
+ export declare function ipAddressBytes(ip: string): Buffer;
49
+ /**
50
+ * OID de l'algorithme de signature d'un certificat DER (champ
51
+ * `signatureAlgorithm`, RFC 5280 §4.1.1.2). Lu dans le DER plutôt que par
52
+ * `X509Certificate.signatureAlgorithmOid`, absent des Node du plancher.
53
+ *
54
+ * @param der - certificat encodé DER (`X509Certificate.raw`)
55
+ * @throws Si le DER n'a pas la forme d'un certificat
56
+ */
57
+ export declare function signatureAlgorithmOid(der: Buffer): string;
58
+ /** Nom lisible d'un OID de signature (l'OID lui-même s'il est inconnu). */
59
+ export declare function signatureAlgorithmName(oid: string): string;
60
+ /**
61
+ * Entrées DNS et IP d'un `subjectAltName` tel que le rend
62
+ * `X509Certificate.subjectAltName` (`DNS:a, IP Address:1.2.3.4`).
63
+ */
64
+ export declare function parseSubjectAltName(value: string | undefined): {
65
+ dns: string[];
66
+ ip: string[];
67
+ };
68
+ /**
69
+ * Valeur d'un champ d'un nom distinctif tel que le rend
70
+ * `X509Certificate.subject` / `.issuer` (une ligne `CLÉ=valeur` par champ).
71
+ */
72
+ export declare function distinguishedNameField(dn: string, key: string): string | null;
73
+ /**
74
+ * Fabrique un certificat serveur auto-signé et le rend en PEM.
75
+ *
76
+ * Contenu : version 3, sujet = émetteur, `basicConstraints` cA=false,
77
+ * `keyUsage` (digitalSignature, keyEncipherment — critique),
78
+ * `extKeyUsage` (serverAuth, clientAuth), `subjectAltName` (critique si le
79
+ * sujet est vide, RFC 5280 §4.2.1.6) et `subjectKeyIdentifier` (SHA-1 de la
80
+ * clé publique, méthode 1 de RFC 5280 §4.2.1.2).
81
+ *
82
+ * @param spec - clés, série, validité, noms et hachage
83
+ * @returns le certificat encodé PEM
84
+ * @throws Si un attribut ou une adresse IP est invalide
85
+ */
86
+ export declare function createSelfSignedCertificate(spec: ISelfSignedSpec): Promise<string>;
@@ -1,48 +1,3 @@
1
- /**
2
- * **portBinder** — écoute sur le port désiré, ou sur le prochain port libre.
3
- *
4
- * Pourquoi ce fichier existe : lancer deux apps Nodefony côte à côte (le repo et
5
- * une app générée, deux projets, un banc et une démo…) faisait mourir la seconde
6
- * en `EADDRINUSE`. Le port désiré est une PRÉFÉRENCE en développement ; il reste
7
- * un CONTRAT en production.
8
- *
9
- * ## Pourquoi retenter au `listen()` plutôt que sonder d'abord
10
- *
11
- * La tentation est d'appeler « le port 5151 est-il libre ? » puis de binder. C'est
12
- * une **course** (TOCTOU) : entre la réponse et le bind, un autre process peut
13
- * prendre le port — et on échoue quand même, après avoir cru le contraire. Le
14
- * `listen()` est, lui, **atomique** : soit il réussit, soit le noyau dit
15
- * `EADDRINUSE`. On retente donc sur l'échec réel, jamais sur une prédiction.
16
- *
17
- * ## Pourquoi une SONDE malgré cela
18
- *
19
- * Le `listen()` est atomique, mais il ne dit pas tout : un port peut être **déjà
20
- * servi** et le noyau l'accorder quand même. Lier `0.0.0.0:P` alors qu'un autre
21
- * processus tient `127.0.0.1:P` **réussit** sur macOS et les BSD — ce sont deux
22
- * liaisons distinctes, et les connexions locales partent à la PLUS SPÉCIFIQUE.
23
- * Entre familles d'adresses (`::1` tenu, `0.0.0.0` demandé), même linux
24
- * l'accorde. L'application annonce alors `READY`, publie ses ports… et ne reçoit
25
- * rien : une panne muette, et un banc qui interroge le serveur du VOISIN.
26
- *
27
- * On constate donc, AVANT de binder, qu'aucun serveur ne répond déjà là où notre
28
- * liaison va recevoir (`isPortListening` — une connexion en boucle locale, aucun
29
- * outil système, même verdict sur les trois plateformes). Ce n'est pas la sonde
30
- * « ce port est-il libre ? » écartée plus haut : elle ne remplace pas le repli
31
- * atomique, qui reste le seul à trancher la course — elle ajoute le seul conflit
32
- * que le noyau n'exprime JAMAIS. La course résiduelle (un tiers prend le port
33
- * entre la sonde et le bind) retombe donc sur `EADDRINUSE`, inchangé.
34
- *
35
- * ## Pourquoi sauter les ports réservés
36
- *
37
- * HTTP veut 5151, HTTPS veut 5152. Si 5151 est pris, incrémenter naïvement ferait
38
- * voler 5152 à HTTPS — qui se décalerait à son tour, en cascade. Les ports que les
39
- * AUTRES serveurs convoitent sont donc sautés d'emblée.
40
- *
41
- * ## Fail-loud
42
- *
43
- * Un décalage est TOUJOURS annoncé (`onShift`) : une app qui écoute ailleurs que
44
- * là où on l'attend sans le dire est une dégradation silencieuse.
45
- */
46
1
  import type { AddressInfo } from "node:net";
47
2
  import { resolvePortPolicy } from "nodefony";
48
3
  import type { PortPolicy } from "nodefony";
@@ -138,9 +93,18 @@ export declare function detectPortConflict(port: number, host: string | undefine
138
93
  * handler d'erreur durable s'installe APRÈS, sur le serveur qui écoute — sinon il
139
94
  * verrait passer les `EADDRINUSE` de repli et croirait à une panne).
140
95
  *
96
+ * @param server - serveur à mettre en écoute.
97
+ * @param host - hôte passé à `listen`.
98
+ * @param plan - port désiré et nombre de replis permis.
99
+ * @param probe - sonde de présence (injectée par le banc).
100
+ * @param sharedListener - vrai dans un worker `node:cluster` : le port y est
101
+ * PARTAGÉ par le maître, et un voisin qui y répond est un frère, pas un
102
+ * conflit. Sans cette exception, `nodefony cluster -w N` ne gardait qu'un
103
+ * worker en vie — les autres mouraient sur « port déjà servi » et
104
+ * bouclaient en relance.
141
105
  * @returns l'adresse obtenue + le port désiré si un décalage a eu lieu.
142
106
  * @throws l'erreur de `listen` : soit un code qui ne dit pas « ce port est pris »
143
107
  * (`ENOTFOUND`, `EACCES` sous 1024 — cf `isPortUnavailable`), soit un port
144
108
  * indisponible après épuisement des essais (le fallback n'est PAS infini).
145
109
  */
146
- export declare function bindWithFallback(server: Listenable, host: string, plan: BindPlan, probe?: PortListeningProbe): Promise<BindResult>;
110
+ export declare function bindWithFallback(server: Listenable, host: string, plan: BindPlan, probe?: PortListeningProbe, sharedListener?: boolean): Promise<BindResult>;
package/docs/cookies.md CHANGED
@@ -255,13 +255,13 @@ constructeur de `Cookie` (`cookie.ts:130`).
255
255
 
256
256
  | Besoin | Appel | Où |
257
257
  | ------------------------------ | ----------------------------------------------------- | ---------------------------------------- |
258
- | Lire un cookie entrant | `context.getRequestCookies("nom")` → `Cookie \| null` | `getRequestCookies()` (`Context.ts:660`) |
258
+ | Lire un cookie entrant | `context.getRequestCookies("nom")` → `Cookie \| null` | `getRequestCookies()` (`Context.ts:674`) |
259
259
  | Lire tous les cookies entrants | `context.cookies` → `Record<string, Cookie>` | `cookies` (`Context.ts:193`) |
260
- | Écrire un cookie sortant | `context.setCookie(new Cookie(…))` | `setCookie()` (`Context.ts:667`) |
261
- | Supprimer un cookie sortant | `response.deleteCookieByName("nom")` | `http/Response.ts:156` |
260
+ | Écrire un cookie sortant | `context.setCookie(new Cookie(…))` | `setCookie()` (`Context.ts:685`) |
261
+ | Supprimer un cookie sortant | `response.deleteCookieByName("nom")` | `http/Response.ts:213` |
262
262
 
263
- Côté réponse HTTP, `addCookie()` (`http/Response.ts:138`) enregistre le cookie, et `setCookies()`
264
- (`http/Response.ts:164`) émet **une ligne `Set-Cookie` par cookie** — un tableau passé à Node, jamais une
263
+ Côté réponse HTTP, `addCookie()` (`http/Response.ts:195`) enregistre le cookie, et `setCookies()`
264
+ (`http/Response.ts:221`) émet **une ligne `Set-Cookie` par cookie** — un tableau passé à Node, jamais une
265
265
  boucle de `setHeader` (qui écraserait tout sauf le dernier). Pour expirer un cookie chez le client :
266
266
  `clearCookie()` (`cookie.ts:200`) recule `Expires` à l'époque.
267
267
 
@@ -269,7 +269,7 @@ boucle de `setHeader` (qui écraserait tout sauf le dernier). Pour expirer un co
269
269
 
270
270
  `cookiesParser(context)` (`cookie.ts:87`) lit l'en-tête `Cookie:` (via la bibliothèque `cookie`,
271
271
  `parser()` `cookie.ts:50`), crée un `Cookie` par entrée et l'ajoute au contexte avec `addRequestCookie()`
272
- (`Context.ts:650`). Il est déclenché automatiquement par le pipeline : `parseCookies()` est appelé à
272
+ (`Context.ts:664`). Il est déclenché automatiquement par le pipeline : `parseCookies()` est appelé à
273
273
  l'initialisation du contexte HTTP (`HttpContext.ts:190`) **et** WebSocket (`WebsocketContext.ts:170`).
274
274
 
275
275
  ### Côté WebSocket — lecture oui, écriture non
@@ -284,12 +284,12 @@ posé pendant la **phase HTTP** qui précède l'upgrade. La forme d'un cookie d
284
284
 
285
285
  Les cookies **applicatifs** ne se configurent pas par schéma : on les construit dans le code, avec les
286
286
  défauts sûrs de `cookieDefaultSettings` (`cookie.ts:39`). Le seul cookie **piloté par la config** est celui
287
- de la **session** — bloc Zod `sessionCookieSchema` (`config.ts:748`), avec notamment `hostPrefix`
287
+ de la **session** — bloc Zod `sessionCookieSchema` (`config.ts:752`), avec notamment `hostPrefix`
288
288
  (`config.ts:770`) qui décide du préfixe `__Host-`. Tout cela est documenté dans [Sessions](session.md) :
289
289
  cette page ne le duplique pas.
290
290
 
291
291
  Le nom effectif du cookie de session (avec ou sans `__Host-` selon le transport) est calculé par
292
- `getSessionCookieName()` (`Context.ts:714`) — encore un détail qui appartient à la page Sessions.
292
+ `getSessionCookieName()` (`Context.ts:733`) — encore un détail qui appartient à la page Sessions.
293
293
 
294
294
  ## 🛡️ Défenses par attribut
295
295
 
@@ -322,7 +322,7 @@ Le nom effectif du cookie de session (avec ou sans `__Host-` selon le transport)
322
322
  | `new Cookie(..., { signed: true })` **jette** | Aucun `secret` configuré → refus du secret prévisible (fail-closed) | Passer un `secret` réel (`{ signed: true, secret: … }`) |
323
323
  | Modifier un cookie entrant ne change rien côté client | Lecture (`context.cookies`) et écriture (réponse) ne sont pas symétriques | (Re)poser le cookie sur la réponse : `context.setCookie(new Cookie(…))` |
324
324
  | Le cookie WS posé au handshake n'arrive jamais | `setCookie`/`setCookies` de la réponse WS sont des no-op (`ws`) | Poser le cookie pendant la phase HTTP avant l'upgrade (cf session) |
325
- | Deux `Set-Cookie` s'écrasent, un seul survit | Un `setHeader('Set-Cookie', str)` remplace le précédent | Déjà géré : `setCookies()` passe un **tableau** (`http/Response.ts:164`) |
325
+ | Deux `Set-Cookie` s'écrasent, un seul survit | Un `setHeader('Set-Cookie', str)` remplace le précédent | Déjà géré : `setCookies()` passe un **tableau** (`http/Response.ts:221`) |
326
326
  | `SameSite` mal orthographié devient `Lax` silencieusement | Fallback fail-safe sur `Lax` | Attendu — vérifier la casse ; `Strict`/`Lax`/`None` seulement |
327
327
  | `maxAge` interprété en millisecondes | `maxAge` est en **secondes** (comme `Set-Cookie` Max-Age) | Passer des secondes (`30*24*60*60`), pas des ms |
328
328
 
@@ -330,7 +330,7 @@ Le nom effectif du cookie de session (avec ou sans `__Host-` selon le transport)
330
330
 
331
331
  Il n'y a pas d'écran Studio dédié aux cookies applicatifs (le cookie **de session** est surfacé dans
332
332
  l'écran **Sessions**). En développement, chaque écriture de cookie est journalisée en `DEBUG` par la
333
- réponse HTTP (`ADD COOKIE ==> …`, `setCookie()` `http/Response.ts:184`) — visible via le skill
333
+ réponse HTTP (`ADD COOKIE ==> …`, `setCookie()` `http/Response.ts:241`) — visible via le skill
334
334
  `nodefony-tail-error-logs` ou le Suivi de requête. Sur le fil, un `curl -i` montre les lignes `Set-Cookie`.
335
335
 
336
336
  ## 🧪 Tests & couverture
package/docs/index.md CHANGED
@@ -125,8 +125,10 @@ se périmeraient en silence.
125
125
  ## ⚙️ Configuration
126
126
 
127
127
  Tout se déclare dans `nodefony.config.ts` via `use("@nodefony/http", { … })`. Les blocs Zod
128
- (`nodefony/config/config.ts`) couvrent : `servers` (ports, transport, TLS, HTTP/2), `session` et
129
- `cookie`, `trustProxy`, `certificates`, `upload`, le rate-limit et les fichiers statiques. Chaque page
128
+ (`nodefony/config/config.ts`) couvrent : `http`, `https`, `http2` (transport, TLS), `websocket` / `websocketSecure`, `session`
129
+ (dont `session.cookie`), `trustProxy` et `trustedHosts`, `certificates`, `upload` et `maxBodySize`,
130
+ `rateLimit`, `health` et `statics`. La topologie (`servers` : ports, protocole, politique de port) est
131
+ un bloc de la configuration d'application, pas de ce module. Chaque page
130
132
  de brique détaille son bloc et ses défauts réels.
131
133
 
132
134
  ## 📜 Normes appliquées
@@ -112,12 +112,12 @@ un `requestId`, un `traceparent` et un contrat de logger **uniques** couvrent le
112
112
 
113
113
  **Le `requestId` est un citoyen du contexte, pas un décor.** Il naît dans le constructeur de base
114
114
  `Context.requestId = randomUUID()` (`Context.ts:244`), voyage dans l'ALS via `RequestContext.run(...)`
115
- (`http-kernel.ts:455` pour HTTP, `http-kernel.ts:455` pour WS), et se lit de n'importe où avec
115
+ (`http-kernel.ts:1510` pour HTTP, `http-kernel.ts:1929` pour WS), et se lit de n'importe où avec
116
116
  `RequestContext.getRequestId()` — un controller, un service, un adapter ORM, sans jamais le threader.
117
117
 
118
118
  **La ligne de bilan est branchable.** Le kernel ne code pas un format en dur : il consulte un
119
119
  `IRequestLogger` (`IRequestLogger.ts:25`) résolu au boot depuis la config (`applyRequestLoggerFromConfig`,
120
- `http-kernel.ts:682`), remplaçable à chaud par `httpKernel.setRequestLogger(...)` (`http-kernel.ts:888`).
120
+ `http-kernel.ts:682`), remplaçable à chaud par `httpKernel.setRequestLogger(...)` (`http-kernel.ts:910`).
121
121
  Trois formateurs sont livrés ; un quatrième maison s'écrit en implémentant l'interface.
122
122
 
123
123
  **Zero Trust sur l'entrée cliente.** Un `X-Request-Id` fourni par le client finit réfléchi en réponse,
@@ -226,16 +226,16 @@ GET 200 /trace/whoami 3.1ms 127.0.0.1 [demo-abc]
226
226
  | ------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
227
227
  | Génération | `Context.requestId = randomUUID()` (`Context.ts:244`) | UUID v4 posé dans le constructeur de base — HTTP **et** WS. |
228
228
  | Adoption HTTP | `sanitizeRequestId(headers["x-request-id"])` (`HttpContext.ts:158`) | Remplace l'UUID **si** la valeur cliente est sûre, sinon on garde l'UUID. |
229
- | Adoption WS | `sanitizeRequestId(...)` au handshake (`WebsocketContext.ts:139`) | Même validation, stable sur toute la durée de la socket (handshake → close). |
230
- | Réflexion HTTP/1.1 | `Response.setHeader("x-request-id", …)` (`Response.ts:191`) | Écrit dans `writeHead()`, sur **chaque** réponse. |
229
+ | Adoption WS | `sanitizeRequestId(...)` au handshake (`WebsocketContext.ts:162`) | Même validation, stable sur toute la durée de la socket (handshake → close). |
230
+ | Réflexion HTTP/1.1 | `Response.setHeader("x-request-id", …)` (`Response.ts:248`) | Écrit dans `writeHead()`, sur **chaque** réponse. |
231
231
  | Réflexion HTTP/2 | `this.headers["x-request-id"] = requestId` (`http2/Response.ts:71`) | Sinon les réponses du port 5152 sortiraient sans corrélation. |
232
- | ALS (HTTP) | `RequestContext.run({ requestId, … })` (`http-kernel.ts:455`) | Ouvre la bulle → tout `Pdu` créé dedans est tagué. |
233
- | ALS (WS) | `RequestContext.run({ requestId, … })` (`http-kernel.ts:455`) | Handshake **et** messages : la bulle ouverte à la connexion est reliée à chaque message par `AsyncResource.bind`, sinon l'identité résolue au handshake se perdrait au premier message. |
232
+ | ALS (HTTP) | `RequestContext.run({ requestId, … })` (`http-kernel.ts:1510`) | Ouvre la bulle → tout `Pdu` créé dedans est tagué. |
233
+ | ALS (WS) | `RequestContext.run({ requestId, … })` (`http-kernel.ts:1929`) | Handshake **et** messages : la bulle ouverte à la connexion est reliée à chaque message par `AsyncResource.bind`, sinon l'identité résolue au handshake se perdrait au premier message. |
234
234
  | Capture dans le log | `Pdu.requestId = Pdu.requestIdProvider?.()` (`Pdu.ts:262`) | Provider injectable branché sur l'ALS côté Node — 0 lecture côté navigateur. |
235
235
 
236
236
  > [!IMPORTANT]
237
237
  > Les logs de **fin** de requête (bilan `req`, `onClose`) sont émis **hors** de la bulle ALS (déjà
238
- > refermée). L'override `Context.log()` (`Context.ts:520`) rouvre alors une micro-bulle depuis
238
+ > refermée). L'override `Context.log()` (`Context.ts:535`) rouvre alors une micro-bulle depuis
239
239
  > `this.requestId` pour que le `Pdu` capture quand même la corrélation — sinon la ligne d'entrée d'une
240
240
  > trace serait la seule à ne PAS porter son `requestId`.
241
241
 
@@ -249,15 +249,15 @@ Nodefony implémente **W3C Trace Context** (le code s'y réfère explicitement,
249
249
  - La validation refuse `version=ff` et un `traceId`/`spanId` tout-à-zéro — `parseTraceparent()`
250
250
  (`trace.ts:38`), conforme à la spec (le récepteur NE DOIT PAS propager ces valeurs).
251
251
 
252
- Le `traceparent` résolu est propagé en ALS **et** réfléchi sur la réponse HTTP (`context/http/Response.ts:494`). Côté
252
+ Le `traceparent` résolu est propagé en ALS **et** réfléchi sur la réponse HTTP (`context/http/Response.ts:551`). Côté
253
253
  **WebSocket**, il est propagé en ALS mais **pas** réfléchi dans la réponse de handshake — la bibliothèque
254
254
  `ws` n'expose pas proprement ce chemin (`http-kernel.ts:1419`) ; la corrélation reste visible côté serveur.
255
255
 
256
256
  ### Le contrat de logger — `IRequestLogger`
257
257
 
258
258
  Le kernel tient un `IRequestLogger` singleton (`http-kernel.ts:289`) et lui délègue le rendu de la ligne
259
- de bilan, au teardown, via `Context.logRequest()` (`Context.ts:595`) côté HTTP et
260
- `WebsocketContext.logRequest()` (`WebsocketContext.ts:209`) côté WS. Le contrat a trois méthodes
259
+ de bilan, au teardown, via `Context.logRequest()` (`Context.ts:608`) côté HTTP et
260
+ `WebsocketContext.logRequest()` (`WebsocketContext.ts:233`) côté WS. Le contrat a trois méthodes
261
261
  (`IRequestLogger.ts:25`) :
262
262
 
263
263
  - `renderHttp(context, error?)` → `{ text, severity, msgid }` remis à `context.log()`.
@@ -337,7 +337,7 @@ Singleton sans état, 0 allocation par requête (`request-logger.ts:21`). Conser
337
337
  ### Écrire son propre formateur
338
338
 
339
339
  Implémenter `IRequestLogger` (`IRequestLogger.ts:25`) et l'injecter — NCSA Common Log Format, syslog RFC
340
- 5424 texte, OpenTelemetry logs… `httpKernel.setRequestLogger(monLogger)` (`http-kernel.ts:888`). Les trois
340
+ 5424 texte, OpenTelemetry logs… `httpKernel.setRequestLogger(monLogger)` (`http-kernel.ts:910`). Les trois
341
341
  formateurs et le type sont exportés depuis `@nodefony/http` (`index.ts:221`).
342
342
 
343
343
  ## 🔐 Sécurité
@@ -418,9 +418,9 @@ instancié **qu'en dev** (fuite d'info + coût en prod).
418
418
  | Symptôme | Cause | Correction |
419
419
  | --------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
420
420
  | Le `X-Request-Id` que j'envoie n'est pas réfléchi | Valeur non conforme (espace, CR/LF, non-ASCII, > 128) → **rejetée** | Utiliser `[A-Za-z0-9._-]{1,128}` (UUID/nanoid/traceparent OK) — sinon UUID serveur |
421
- | Les logs de fin de requête n'ont pas de `requestId` | Ils sont émis hors bulle ALS | Déjà géré : l'override `log()` rouvre une micro-bulle (`Context.ts:520`) |
421
+ | Les logs de fin de requête n'ont pas de `requestId` | Ils sont émis hors bulle ALS | Déjà géré : l'override `log()` rouvre une micro-bulle (`Context.ts:535`) |
422
422
  | Réponse HTTP/2 sans `x-request-id` | Chemin de réponse h2 distinct du 1.1 | Déjà géré (`http2/Response.ts:106`) — le port 5152 réfléchit aussi |
423
- | Pas de `traceparent` renvoyé sur un WebSocket | `ws` n'expose pas l'écriture d'en-tête au handshake | Attendu — la trace WS reste propagée en ALS (`http-kernel.ts:1698`) |
423
+ | Pas de `traceparent` renvoyé sur un WebSocket | `ws` n'expose pas l'écriture d'en-tête au handshake | Attendu — la trace WS reste propagée en ALS (`http-kernel.ts:1915`) |
424
424
  | Frame WS binaire loggée en `{"0":..,"1":..}` | Sérialisation naïve d'un Buffer | Déjà géré : résumé `[binary N B]` (`wsLogContent.ts:63`) |
425
425
  | Le format de log ne change pas malgré la config | Un `setRequestLogger(...)` programmatique gagne sur la config | L'override est volontaire (last setter wins) — retirer l'appel, ou le régler |
426
426
  | Logs d'audit trop volumineux en prod | `stack` sérialisée, ou 100 % des 2xx audités | `includeStack:false` (défaut prod) + `sampleRate` via `setRequestLogger` |
@@ -55,7 +55,7 @@ flowchart TD
55
55
  Trois idées à retenir :
56
56
 
57
57
  1. **La clé est l'IP, pas l'utilisateur.** On borne du **trafic**, pas des identités. L'IP est
58
- résolue exactement comme pour les logs et l'audit (`resolveForwarded()`, `http-kernel.ts:1020`) —
58
+ résolue exactement comme pour les logs et l'audit (`resolveForwarded()`, `http-kernel.ts:1089`) —
59
59
  non falsifiable tant que `trustProxy` n'accorde pas sa confiance à un proxy.
60
60
  2. **Le verdict porte tout.** Un seul appel `hit(key)` (`IRateLimitStore.ts:79`) rend un
61
61
  `RateLimitVerdict` (`IRateLimitStore.ts:20`) qui contient déjà limite, restant, reset et
@@ -106,7 +106,7 @@ Trois choix structurent l'implémentation, et chacun est un compromis assumé.
106
106
 
107
107
  **Désactivé par défaut — opt-in explicite.** En cloud-native, le plafond par IP est souvent mieux placé
108
108
  à l'**ingress/gateway** (il voit tout le trafic, tous les pods, et rejette avant le coût TLS). Le module
109
- laisse donc `rateLimit` désarmé par défaut (`config.ts:1065`) : `null` tant qu'on ne l'active pas → **0
109
+ laisse donc `rateLimit` désarmé par défaut (`config.ts:1069`) : `null` tant qu'on ne l'active pas → **0
110
110
  coût** sur le chemin chaud. On l'active quand on n'a **pas** d'edge devant soi (bare-metal, VPS), ou en
111
111
  défense en profondeur.
112
112
 
@@ -118,7 +118,7 @@ court intervalle (`MemoryRateLimitStore.ts:29`). Acceptable pour une défense de
118
118
  _sliding window_ viendrait en option si le besoin s'en fait sentir.
119
119
 
120
120
  **Refoulé avant toute allocation.** Le verdict est rendu **avant** le contexte, la portée DI et l'ALS
121
- (`http-kernel.ts:1019`) : un flood coûte un `Map.get` et rien d'autre. Le contrat `hit()` est
121
+ (`http-kernel.ts:1097`) : un flood coûte un `Map.get` et rien d'autre. Le contrat `hit()` est
122
122
  **synchrone** à dessein (`IRateLimitStore.ts:8`) — aucune `Promise`, aucune microtask sur le chemin de
123
123
  chaque requête.
124
124
 
@@ -147,7 +147,7 @@ export default defineConfig(() => ({
147
147
  ```
148
148
 
149
149
  Les trois clés `enabled` / `windowS` / `max` sont **éditables à chaud** (`runtimeMutable`) : le kernel
150
- reconstruit le compteur sans redémarrage (`configureRateLimit()`, `http-kernel.ts:433`).
150
+ reconstruit le compteur sans redémarrage (`configureRateLimit()`, `http-kernel.ts:444`).
151
151
 
152
152
  ### 2. Observer le 429 et les en-têtes
153
153
 
@@ -178,7 +178,7 @@ X-RateLimit-Reset: 1753082460
178
178
  Retry-After: 42
179
179
  ```
180
180
 
181
- - `X-RateLimit-Remaining` : requêtes restantes dans la fenêtre (`http-kernel.ts:1030`).
181
+ - `X-RateLimit-Remaining` : requêtes restantes dans la fenêtre (`http-kernel.ts:1099`).
182
182
  - `X-RateLimit-Reset` : **epoch en secondes** de la fin de fenêtre (`http-kernel.ts:1031`).
183
183
  - `Retry-After` (sur le `429` seulement) : secondes à attendre, **jamais 0** — un `Retry-After: 0`
184
184
  relancerait un client bien élevé immédiatement (`MemoryRateLimitStore.ts:80`).
@@ -203,12 +203,12 @@ flowchart TD
203
203
 
204
204
  Autour de ce cœur, le kernel orchestre le cycle de vie :
205
205
 
206
- - **Construction / reconfiguration** : `configureRateLimit()` (`http-kernel.ts:433`) instancie le store
207
- depuis la config (`windowMs = windowS × 1000`, `http-kernel.ts:433`) et arme un `GcScheduler`
208
- (`http-kernel.ts:443`) qui **purge les fenêtres expirées** hors du chemin chaud.
209
- - **Émission HTTP** : sous le quota, les en-têtes `X-RateLimit-*` sont posés (`http-kernel.ts:1029`) et
210
- la requête continue ; au-delà, `Retry-After` (`http-kernel.ts:1036`) puis `writeHead(429)`
211
- (`http-kernel.ts:1041`) — corps vide, on ne journalise pas chaque rejet (amplificateur sous flood).
206
+ - **Construction / reconfiguration** : `configureRateLimit()` (`http-kernel.ts:444`) instancie le store
207
+ depuis la config (`windowMs = windowS × 1000`, `http-kernel.ts:454`) et arme un `GcScheduler`
208
+ (`http-kernel.ts:460`) qui **purge les fenêtres expirées** hors du chemin chaud.
209
+ - **Émission HTTP** : sous le quota, les en-têtes `X-RateLimit-*` sont posés (`http-kernel.ts:1098`) et
210
+ la requête continue ; au-delà, `Retry-After` (`http-kernel.ts:1105`) puis `writeHead(429)`
211
+ (`http-kernel.ts:1110`) — corps vide, on ne journalise pas chaque rejet (amplificateur sous flood).
212
212
  - **Borne mémoire** : au cap `maxTracked`, le store purge les expirées puis évince en **FIFO**
213
213
  (`#evict`, `MemoryRateLimitStore.ts:169`) — la mémoire ne dérive jamais.
214
214
 
@@ -242,19 +242,19 @@ Et un réglage **séparé**, propre au WebSocket, à la racine du module :
242
242
  Un WebSocket ne peut **pas** recevoir un `429` : au moment où le rate-limit décide, le `101 Switching
243
243
  Protocols` est déjà parti sur le fil (émis par la bibliothèque `ws`). Le refoulement se fait donc par
244
244
  une **fermeture RFC 6455 `1013 Try Again Later`**, décidée dans `onWebsocketRequest()`
245
- (`http-kernel.ts:1619`) — **avant** `enterScope`, l'ALS et le pipeline, comme le `429` HTTP.
245
+ (`http-kernel.ts:1848`) — **avant** `enterScope`, l'ALS et le pipeline, comme le `429` HTTP.
246
246
 
247
247
  Deux plafonds distincts, tous deux par IP forwarded-aware :
248
248
 
249
249
  | Plafond | Ce qu'il borne | Source de config | Refus |
250
250
  | ---------------------- | ---------------------------------------------- | ----------------------- | ---------------------------------------------------------- |
251
- | Débit de handshakes | Ouvertures/seconde (le **même** compteur HTTP) | `rateLimit` | close `1013` (`http-kernel.ts:1638`) |
252
- | Connexions simultanées | Sockets **ouvertes** en même temps par IP | `wsMaxConnectionsPerIp` | close `1013` — `tryAcquire` refuse (`http-kernel.ts:1649`) |
251
+ | Débit de handshakes | Ouvertures/seconde (le **même** compteur HTTP) | `rateLimit` | close `1013` (`http-kernel.ts:448`) |
252
+ | Connexions simultanées | Sockets **ouvertes** en même temps par IP | `wsMaxConnectionsPerIp` | close `1013` — `tryAcquire` refuse (`http-kernel.ts:1882`) |
253
253
 
254
254
  Le cap concurrent est porté par un compteur dédié, `WsConnectionCounter` (`WsConnectionCounter.ts:18`) :
255
255
  `tryAcquire(ip)` (`WsConnectionCounter.ts:33`) réserve un créneau à l'upgrade, `release(ip)`
256
256
  (`WsConnectionCounter.ts:45`) le rend à la fermeture — branché sur `ws.once("close", …)`
257
- (`http-kernel.ts:1652`), donc jamais de fuite de compteur, même sur un `terminate` de heartbeat.
257
+ (`http-kernel.ts:1887`), donc jamais de fuite de compteur, même sur un `terminate` de heartbeat.
258
258
 
259
259
  > [!WARNING]
260
260
  > `wsMaxConnectionsPerIp` a une **portée par process** (1 pod) : il ne voit que le trafic de son propre
@@ -280,14 +280,14 @@ Un adapter doit fournir : `hit(key)` (verdict de fenêtre), `gc()` (purge), `lis
280
280
 
281
281
  | Domaine | Norme | Ancrage |
282
282
  | ---------------------------------- | ---------------- | ------------------------------------------------- |
283
- | `429 Too Many Requests` | RFC 6585 §4 | `writeHead(429)` (`http-kernel.ts:1041`) |
284
- | `Retry-After` (delta-seconds) | RFC 9110 §10.2.3 | en-tête posé sur le `429` (`http-kernel.ts:1036`) |
285
- | IP cliente derrière proxy | RFC 7239 | `resolveForwarded()` (`http-kernel.ts:1020`) |
283
+ | `429 Too Many Requests` | RFC 6585 §4 | `writeHead(429)` (`http-kernel.ts:1110`) |
284
+ | `Retry-After` (delta-seconds) | RFC 9110 §10.2.3 | en-tête posé sur le `429` (`http-kernel.ts:1105`) |
285
+ | IP cliente derrière proxy | RFC 7239 | `resolveForwarded()` (`http-kernel.ts:1089`) |
286
286
  | WebSocket — close `1013` Try Again | RFC 6455 §7.4.1 | refus d'upgrade (`http-kernel.ts:1378`) |
287
287
 
288
288
  > [!NOTE]
289
289
  > Les en-têtes émis sont la famille **de facto** `X-RateLimit-Limit/Remaining/Reset`
290
- > (`http-kernel.ts:1000`), largement déployée et lue par les clients. Le brouillon IETF
290
+ > (`http-kernel.ts:1098`), largement déployée et lue par les clients. Le brouillon IETF
291
291
  > `draft-ietf-httpapi-ratelimit-headers` (en-têtes `RateLimit` / `RateLimit-Policy`) n'est **pas** encore
292
292
  > émis — une évolution possible, pas une régression : rien ne le promet aujourd'hui.
293
293