@nodefony/http 10.0.0-alpha.4 → 10.0.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -74,4 +74,4 @@ npm run test:memory # GATE mémoire (memory.test.ts) — AVANT tout commit
74
74
 
75
75
  ## Licence
76
76
 
77
- CeCILL-B — Christophe CAMENSULI.
77
+ Apache 2.0 — Christophe CAMENSULI.
@@ -43,14 +43,12 @@ var AssetsPublish = class extends Command {
43
43
  async generate(opts) {
44
44
  const outDir = opts.out ? isAbsolute(opts.out) ? opts.out : resolve(process.cwd(), opts.out) : join(process.cwd(), "dist-assets");
45
45
  const sources = this.collectSources();
46
- if (sources.length === 0) {
47
- this.log("Aucune source d'assets (0 mount natif, 0 bundle frontend) — rien à publier.", "WARNING");
48
- return this;
49
- }
46
+ if (sources.length === 0) process.stdout.write(`Aucune source d'assets (0 mount natif, 0 bundle frontend) — arbre vide → ${outDir}\n`);
50
47
  if (opts.clean && existsSync(outDir)) await fsp.rm(outDir, {
51
48
  recursive: true,
52
49
  force: true
53
50
  });
51
+ await fsp.mkdir(outDir, { recursive: true });
54
52
  const plan = planAssetPublish(sources, outDir);
55
53
  const manifest = {
56
54
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -1,6 +1,9 @@
1
+ import { resolveTrustedHostNames } from "../src/context/domainMatcher.js";
1
2
  import { defaultIntrospection, generateHaproxyConfig, generateNginxConfig } from "../src/proxy/generateProxyConfig.js";
3
+ import { planAssetPublish } from "../src/assets/collectAssets.js";
2
4
  import { Command } from "nodefony";
3
5
  import fsp from "node:fs/promises";
6
+ import { isAbsolute, resolve } from "node:path";
4
7
  //#region nodefony/command/proxyGenerateCommand.ts
5
8
  const options = {
6
9
  helpGroup: "FRONT ET RÉSEAU",
@@ -21,17 +24,20 @@ var ProxyGenerate = class extends Command {
21
24
  this.addOption("-b, --backend <host>", "backend host the proxy connects to (default 127.0.0.1)");
22
25
  this.addOption("-l, --listen <port>", "proxy listen port (default 80)");
23
26
  this.addOption("--reencrypt", "re-encrypt to the HTTPS backend (TLS proxy↔backend) instead of clear");
27
+ this.addOption("--assets-root <dir>", "serve every static asset from this single tree (see `assets:publish`)");
28
+ this.addOption("--tls-cert <file>", "TLS certificate chain (nginx only)");
29
+ this.addOption("--tls-key <file>", "TLS private key (nginx only)");
30
+ this.addOption("--tls-listen <port>", "TLS listen port (default 443)");
24
31
  }
25
32
  async generate(target, opts) {
26
- if (target !== "nginx" && target !== "haproxy") {
27
- this.log(`Cible inconnue '${target}' attendu: nginx | haproxy.`, "ERROR");
28
- return this;
29
- }
33
+ if (target !== "nginx" && target !== "haproxy") throw new Error(`Cible inconnue '${target}' — attendu: nginx | haproxy.`);
34
+ if (Boolean(opts.tlsCert) !== Boolean(opts.tlsKey)) throw new Error("--tls-cert et --tls-key vont ensemble — l'un sans l'autre ne produit aucune écoute TLS.");
35
+ if (opts.tlsCert && target === "haproxy") throw new Error("--tls-cert/--tls-key ne valent que pour nginx : haproxy exige un PEM combiné (cf docker/certs/build-haproxy-pem.sh).");
30
36
  const intro = this.buildIntrospection(opts);
31
37
  const conf = target === "nginx" ? generateNginxConfig(intro) : generateHaproxyConfig(intro);
32
38
  if (opts.out) {
33
39
  await fsp.writeFile(opts.out, conf, "utf8");
34
- this.log(`Configuration ${target} écrite → ${opts.out}`, "INFO");
40
+ process.stdout.write(`Configuration ${target} écrite → ${opts.out}\n`);
35
41
  } else process.stdout.write(conf);
36
42
  return this;
37
43
  }
@@ -42,14 +48,22 @@ var ProxyGenerate = class extends Command {
42
48
  const servers = (this.kernel?.options)?.servers;
43
49
  const staticSvc = module?.get("server-static");
44
50
  staticSvc?.mountModulePublics?.();
45
- const staticRoots = staticSvc?.servers ? Object.keys(staticSvc.servers) : [];
46
- const mounts = (staticSvc?.mounts ?? []).map((m) => ({
51
+ let staticRoots = staticSvc?.servers ? Object.keys(staticSvc.servers) : [];
52
+ let mounts = (staticSvc?.mounts ?? []).map((m) => ({
47
53
  prefix: m.prefix,
48
54
  dir: m.dir
49
55
  }));
56
+ if (opts.assetsRoot) {
57
+ const root = isAbsolute(opts.assetsRoot) ? opts.assetsRoot : resolve(process.cwd(), opts.assetsRoot);
58
+ mounts = planAssetPublish(mounts, root).map((p) => ({
59
+ prefix: p.prefix,
60
+ dir: p.target
61
+ }));
62
+ staticRoots = [root];
63
+ }
50
64
  return {
51
65
  ...defaultIntrospection,
52
- domains: httpOpts.trustedHosts ?? [],
66
+ domains: resolveTrustedHostNames(this.kernel?.domain ?? "", httpOpts.trustedHosts),
53
67
  backendHost: opts.backend ?? "127.0.0.1",
54
68
  httpPort: Number(servers?.http?.port) || defaultIntrospection.httpPort,
55
69
  httpsPort: Number(servers?.https?.port) || defaultIntrospection.httpsPort,
@@ -58,7 +72,12 @@ var ProxyGenerate = class extends Command {
58
72
  listen: opts.listen ? Number(opts.listen) : defaultIntrospection.listen,
59
73
  reencrypt: Boolean(opts.reencrypt),
60
74
  maxBodyBytes: Number(httpOpts.maxBodySize) || 0,
61
- keepaliveIntervalMs: Number(httpOpts.websocket?.keepaliveInterval) || 0
75
+ keepaliveIntervalMs: Number(httpOpts.websocket?.keepaliveInterval) || 0,
76
+ tls: opts.tlsCert && opts.tlsKey ? {
77
+ certPath: opts.tlsCert,
78
+ keyPath: opts.tlsKey,
79
+ listen: Number(opts.tlsListen) || 443
80
+ } : null
62
81
  };
63
82
  }
64
83
  };
@@ -98,7 +98,32 @@ var Certificate = class Certificate extends Service {
98
98
  if (!this.forge) throw new Error("node-forge non chargé — appeler loadForge() avant toute génération.");
99
99
  return this.forge;
100
100
  }
101
+ /**
102
+ * Fabrique-t-on un certificat au démarrage ?
103
+ *
104
+ * 🔴 Seulement si un serveur TLS est ACTIF. Sans cette question, le hook
105
+ * ci-dessous écrit dans `nodefony/config/certificates` à CHAQUE boot — y
106
+ * compris celui d'une application qui a coupé son écoute TLS, et y compris un
107
+ * run de console qui n'ouvre aucun port.
108
+ *
109
+ * Ce qu'il en coûtait, mesuré sur une image générée : le code d'une image
110
+ * appartient à `root` et le processus tourne en `1000` (c'est voulu — une
111
+ * application qui peut réécrire son propre `dist/` offre à une faille un moyen
112
+ * de PERSISTER). Le `mkdir` mourait donc en `EACCES`, le hook de boot était
113
+ * « critique », et l'application ne démarrait PAS — quel que soit son préset.
114
+ * L'erreur nommait un dossier de certificats sur une application qui n'en veut
115
+ * aucun : elle envoyait chercher du côté du TLS un défaut de permission.
116
+ *
117
+ * C'est aussi ce que le gabarit d'application promet en toutes lettres : en
118
+ * production, l'écoute TLS est coupée tant qu'aucun port HTTPS n'est demandé,
119
+ * précisément pour ne PAS fabriquer une clé RSA à chaque démarrage de chaque
120
+ * exemplaire. La promesse était écrite ; rien ne la tenait.
121
+ */
122
+ get tlsWanted() {
123
+ return !!this.module.kernel?.options?.servers?.https;
124
+ }
101
125
  async init() {
126
+ if (!this.tlsWanted) return this;
102
127
  this.kernel?.once("onBoot", async () => {
103
128
  this.options = extend(true, this.options, this.module.options.certificates || {});
104
129
  await this.generateServerCertificates();
@@ -74,6 +74,39 @@ function compileTrustedHosts(domain, trusted, isDev) {
74
74
  return compileDomainPatterns(patterns);
75
75
  }
76
76
  /**
77
+ * Rend les NOMS d'hôtes que la barrière `trustedHosts` accepte — la même
78
+ * politique que {@link compileTrustedHosts}, mais lisible par un humain ou par
79
+ * un générateur de configuration (`server_name` nginx, `hdr(host)` haproxy).
80
+ *
81
+ * Pourquoi une seconde lecture de la même règle : une `RegExp` ne se réécrit pas
82
+ * en nom d'hôte. La commande `proxy:generate` lisait donc `trustedHosts` à sa
83
+ * façon, en le supposant TOUJOURS `string[]` — alors que sa valeur par DÉFAUT
84
+ * est `false`, celle de toute application générée. Une politique lue à deux
85
+ * endroits finit par diverger : ici les deux fonctions partent de la même
86
+ * valeur, et la règle « le domaine canonique est toujours accepté » n'est
87
+ * écrite qu'une fois.
88
+ *
89
+ * Le loopback de développement n'en fait volontairement pas partie : une
90
+ * configuration de proxy décrit un déploiement, pas la machine de l'auteur.
91
+ *
92
+ * @param domain - domaine canonique du serveur (`kernel.domain`).
93
+ * @param trusted - config `http.trustedHosts` (optionnelle).
94
+ * @returns les noms acceptés, sans doublon. **Vide** si `trusted === true`
95
+ * (bypass : le proxy filtre déjà le `Host`, aucun nom n'est à imposer) ; les
96
+ * motifs `RegExp` sont écartés, faute d'être exprimables en nom d'hôte.
97
+ */
98
+ function resolveTrustedHostNames(domain, trusted) {
99
+ if (trusted === true) return [];
100
+ const patterns = [domain];
101
+ if (trusted) {
102
+ if (Array.isArray(trusted)) patterns.push(...trusted);
103
+ else patterns.push(trusted);
104
+ }
105
+ const names = [];
106
+ for (const p of patterns) if (typeof p === "string" && p && !names.includes(p)) names.push(p);
107
+ return names;
108
+ }
109
+ /**
77
110
  * Teste un `Host` entrant contre une liste de `RegExp` pré-compilée.
78
111
  *
79
112
  * @param regAlias - sortie de {@link compileTrustedHosts} ou {@link compileDomainPatterns}.
@@ -85,4 +118,4 @@ function isDomainAllowed(regAlias, domain) {
85
118
  return false;
86
119
  }
87
120
  //#endregion
88
- export { compileDomainPattern, compileDomainPatterns, compileTrustedHosts, isDomainAllowed };
121
+ export { compileDomainPattern, compileDomainPatterns, compileTrustedHosts, isDomainAllowed, resolveTrustedHostNames };
@@ -10,7 +10,8 @@ const defaultIntrospection = {
10
10
  listen: 80,
11
11
  reencrypt: false,
12
12
  maxBodyBytes: 0,
13
- keepaliveIntervalMs: 0
13
+ keepaliveIntervalMs: 0,
14
+ tls: null
14
15
  };
15
16
  /**
16
17
  * Délai d'inactivité, en secondes, qu'un proxy doit accorder à une connexion
@@ -68,23 +69,49 @@ function generateNginxConfig(intro) {
68
69
  const backendPort = intro.reencrypt ? intro.httpsPort : intro.httpPort;
69
70
  const idleSeconds = idleTimeoutSeconds(intro);
70
71
  const lines = [];
71
- lines.push("# Généré par `nodefony proxy:generate nginx` — NE PAS éditer à la main.", "# Reverse-proxy dérivé de l'introspection Nodefony (domaines, statiques, ports).", "worker_processes auto;", "events { worker_connections 1024; }", "", "http {", " # Upgrade WebSocket — HTTP et WS co-habitent sur le même port Nodefony.", " map $http_upgrade $connection_upgrade { default upgrade; '' close; }", "", ` upstream nodefony { server ${intro.backendHost}:${backendPort}; keepalive 32; }`, "");
72
+ lines.push("# Généré par `nodefony proxy:generate nginx` — NE PAS éditer à la main.", "# Reverse-proxy dérivé de l'introspection Nodefony (domaines, statiques, ports).", "worker_processes auto;", "events { worker_connections 1024; }", "", "http {", " include /etc/nginx/mime.types;", " default_type application/octet-stream;", "", " server_tokens off;", "", " sendfile on;", " tcp_nopush on;", " tcp_nodelay on;", "", " gzip on;", " gzip_vary on;", " gzip_min_length 1024;", " gzip_proxied any;", " gzip_types text/plain text/css text/xml application/javascript application/json application/xml image/svg+xml application/manifest+json;", "", " # Upgrade WebSocket — HTTP et WS co-habitent sur le même port Nodefony.", " map $http_upgrade $connection_upgrade { default upgrade; '' close; }", "", ` upstream nodefony { server ${intro.backendHost}:${backendPort}; keepalive 32; }`, "");
72
73
  if (intro.maxBodyBytes > 0) lines.push(` # Aligné sur \`http.maxBodySize\` (${intro.maxBodyBytes} octets) — sans quoi`, " # nginx rendrait 413 à 1 Mo, son défaut, sans que le serveur le sache.", ` client_max_body_size ${intro.maxBodyBytes};`, "");
73
- lines.push(" server {", ` listen ${intro.listen};`, ` server_name ${serverNames(intro.domains)};`);
74
+ lines.push(...nginxServerBlock(intro, scheme, idleSeconds, null));
75
+ if (intro.tls) lines.push("", ...nginxServerBlock(intro, scheme, idleSeconds, intro.tls));
76
+ lines.push("}", "");
77
+ return lines.join("\n");
78
+ }
79
+ /**
80
+ * Un bloc `server {}` nginx — corps IDENTIQUE en clair et en TLS.
81
+ *
82
+ * Le scheme annoncé au backend reste `$scheme`, que nginx CONSTATE sur la
83
+ * connexion entrante : le même corps sert donc les deux écoutes sans qu'aucune
84
+ * n'ait à savoir laquelle elle est. C'est ce qui fait qu'un cookie `Secure`
85
+ * tient derrière le frontal alors que le lien interne est en clair.
86
+ *
87
+ * @param intro - modèle d'introspection Nodefony.
88
+ * @param scheme - `http` ou `https` vers le BACKEND (re-chiffrement).
89
+ * @param idleSeconds - inactivité tolérée, dérivée du heartbeat WebSocket.
90
+ * @param tls - terminaison TLS de CE bloc, ou `null` pour une écoute en clair.
91
+ * @returns les lignes du bloc `server`.
92
+ */
93
+ function nginxServerBlock(intro, scheme, idleSeconds, tls) {
94
+ const lines = [" server {"];
95
+ if (tls) lines.push(` listen ${tls.listen} ssl;`, " http2 on;", ` server_name ${serverNames(intro.domains)};`, "", " # Terminaison TLS au frontal. Ces chemins sont ceux d'un MONTAGE, à", " # pourvoir au déploiement (volume compose, secret k8s) : une clé privée", " # n'entre pas dans une image, où la couche reste lisible même effacée.", ` ssl_certificate ${tls.certPath};`, ` ssl_certificate_key ${tls.keyPath};`, " ssl_protocols TLSv1.2 TLSv1.3;", " ssl_session_cache shared:SSL:10m;", " ssl_session_timeout 1h;", "", " error_page 497 =301 https://$http_host$request_uri;");
96
+ else lines.push(` listen ${intro.listen};`, ` server_name ${serverNames(intro.domains)};`);
74
97
  if (intro.reencrypt) lines.push(" # Re-encrypt : valider le cert backend (cf docker/certs).", " # proxy_ssl_trusted_certificate /etc/nginx/certs/ca.pem;", " # proxy_ssl_verify on; proxy_ssl_name nodefony.com;");
75
- for (const m of intro.mounts) lines.push("", ` location ${m.prefix} {`, ` alias ${ensureTrailingSlash(m.dir)};`, " access_log off;", " expires 1h;", " }");
98
+ for (const m of intro.mounts) {
99
+ const prefix = ensureTrailingSlash(m.prefix);
100
+ const dir = ensureTrailingSlash(m.dir);
101
+ lines.push("", ` location ${prefix}assets/ {`, ` alias ${dir}assets/;`, " access_log off;", " expires 1y;", " add_header Cache-Control \"public, immutable\";", " }", "", ` location ${prefix} {`, ` alias ${dir};`, " access_log off;", " expires 1h;", " }");
102
+ }
76
103
  lines.push("", ` location @nodefony {`, ` proxy_pass ${scheme}://nodefony;`, " proxy_http_version 1.1;", nginxForwardHeaders(idleSeconds), " }");
77
104
  if (intro.staticRoots.length === 0) lines.push("", " location / {", ` proxy_pass ${scheme}://nodefony;`, " proxy_http_version 1.1;", nginxForwardHeaders(idleSeconds), " }");
78
105
  else {
79
106
  const roots = intro.staticRoots;
80
- lines.push("", " # Statiques multi-dossiers (racine app + modules) : chaîne try_files,", " # fallback vers le backend Nodefony si aucun fichier ne matche.", " location / {", ` root ${roots[0]};`, ` try_files $uri ${roots.length > 1 ? "@r1" : "@nodefony"};`, " }");
107
+ lines.push("", roots.length > 1 ? " # Statiques multi-dossiers (racine app + modules) : chaîne try_files,\n # fallback vers le backend Nodefony si aucun fichier ne matche." : " # Statiques servis par le frontal ; fallback vers le backend\n # Nodefony si aucun fichier ne correspond.", " location / {", ` root ${roots[0]};`, ` try_files $uri ${roots.length > 1 ? "@r1" : "@nodefony"};`, " }");
81
108
  for (let i = 1; i < roots.length; i++) {
82
109
  const next = i + 1 < roots.length ? `@r${i + 1}` : "@nodefony";
83
110
  lines.push(` location @r${i} {`, ` root ${roots[i]};`, ` try_files $uri ${next};`, " }");
84
111
  }
85
112
  }
86
- lines.push(" }", "}", "");
87
- return lines.join("\n");
113
+ lines.push(" }");
114
+ return lines;
88
115
  }
89
116
  /**
90
117
  * Génère une configuration haproxy (reverse-proxy + Forwarded RFC 7239).
@@ -12,6 +12,10 @@ declare class ProxyGenerate extends Command {
12
12
  backend?: string;
13
13
  listen?: string;
14
14
  reencrypt?: boolean;
15
+ assetsRoot?: string;
16
+ tlsCert?: string;
17
+ tlsKey?: string;
18
+ tlsListen?: string;
15
19
  }): Promise<this>;
16
20
  /** Construit le modèle d'introspection depuis le kernel + le service statique. */
17
21
  private buildIntrospection;
@@ -146,6 +146,28 @@ declare class Certificate extends Service {
146
146
  loadForge(): Promise<ForgeModule>;
147
147
  /** Accès au backend forge déjà chargé (lève si `loadForge` n'a pas été appelé). */
148
148
  private get forgeLib();
149
+ /**
150
+ * Fabrique-t-on un certificat au démarrage ?
151
+ *
152
+ * 🔴 Seulement si un serveur TLS est ACTIF. Sans cette question, le hook
153
+ * ci-dessous écrit dans `nodefony/config/certificates` à CHAQUE boot — y
154
+ * compris celui d'une application qui a coupé son écoute TLS, et y compris un
155
+ * run de console qui n'ouvre aucun port.
156
+ *
157
+ * Ce qu'il en coûtait, mesuré sur une image générée : le code d'une image
158
+ * appartient à `root` et le processus tourne en `1000` (c'est voulu — une
159
+ * application qui peut réécrire son propre `dist/` offre à une faille un moyen
160
+ * de PERSISTER). Le `mkdir` mourait donc en `EACCES`, le hook de boot était
161
+ * « critique », et l'application ne démarrait PAS — quel que soit son préset.
162
+ * L'erreur nommait un dossier de certificats sur une application qui n'en veut
163
+ * aucun : elle envoyait chercher du côté du TLS un défaut de permission.
164
+ *
165
+ * C'est aussi ce que le gabarit d'application promet en toutes lettres : en
166
+ * production, l'écoute TLS est coupée tant qu'aucun port HTTPS n'est demandé,
167
+ * précisément pour ne PAS fabriquer une clé RSA à chaque démarrage de chaque
168
+ * exemplaire. La promesse était écrite ; rien ne la tenait.
169
+ */
170
+ private get tlsWanted();
149
171
  init(): Promise<this>;
150
172
  /**
151
173
  * Numéro de série X.509 — RFC 5280 §4.1.2.2 : entier positif unique par CA.
@@ -57,6 +57,29 @@ export declare function compileDomainPatterns(patterns: DomainPattern | DomainPa
57
57
  * @returns liste de `RegExp` pour {@link isDomainAllowed}.
58
58
  */
59
59
  export declare function compileTrustedHosts(domain: string, trusted: ITrustedHostsConfig | undefined, isDev: boolean): RegExp[];
60
+ /**
61
+ * Rend les NOMS d'hôtes que la barrière `trustedHosts` accepte — la même
62
+ * politique que {@link compileTrustedHosts}, mais lisible par un humain ou par
63
+ * un générateur de configuration (`server_name` nginx, `hdr(host)` haproxy).
64
+ *
65
+ * Pourquoi une seconde lecture de la même règle : une `RegExp` ne se réécrit pas
66
+ * en nom d'hôte. La commande `proxy:generate` lisait donc `trustedHosts` à sa
67
+ * façon, en le supposant TOUJOURS `string[]` — alors que sa valeur par DÉFAUT
68
+ * est `false`, celle de toute application générée. Une politique lue à deux
69
+ * endroits finit par diverger : ici les deux fonctions partent de la même
70
+ * valeur, et la règle « le domaine canonique est toujours accepté » n'est
71
+ * écrite qu'une fois.
72
+ *
73
+ * Le loopback de développement n'en fait volontairement pas partie : une
74
+ * configuration de proxy décrit un déploiement, pas la machine de l'auteur.
75
+ *
76
+ * @param domain - domaine canonique du serveur (`kernel.domain`).
77
+ * @param trusted - config `http.trustedHosts` (optionnelle).
78
+ * @returns les noms acceptés, sans doublon. **Vide** si `trusted === true`
79
+ * (bypass : le proxy filtre déjà le `Host`, aucun nom n'est à imposer) ; les
80
+ * motifs `RegExp` sont écartés, faute d'être exprimables en nom d'hôte.
81
+ */
82
+ export declare function resolveTrustedHostNames(domain: string, trusted: ITrustedHostsConfig | undefined): string[];
60
83
  /**
61
84
  * Teste un `Host` entrant contre une liste de `RegExp` pré-compilée.
62
85
  *
@@ -16,6 +16,22 @@ export interface ProxyStaticMount {
16
16
  /** Dossier absolu servi. */
17
17
  dir: string;
18
18
  }
19
+ /**
20
+ * Terminaison TLS au frontal — **nginx uniquement**.
21
+ *
22
+ * Les chemins sont ceux vus par le PROXY à l'exécution, jamais ceux de la
23
+ * machine qui a généré la configuration : une clé privée n'entre pas dans une
24
+ * image (une couche reste lisible même effacée plus loin), elle se MONTE au
25
+ * déploiement — volume compose, secret Kubernetes.
26
+ */
27
+ export interface ProxyTlsTermination {
28
+ /** Chaîne de certificats servie au client (`fullchain.pem`). */
29
+ certPath: string;
30
+ /** Clé privée correspondante (`privkey.pem`). */
31
+ keyPath: string;
32
+ /** Port d'écoute TLS du proxy. */
33
+ listen: number;
34
+ }
19
35
  /** Modèle d'introspection consommé par les générateurs. */
20
36
  export interface ProxyIntrospection {
21
37
  /** `server_name` (hôtes de confiance, IP exclues). Vide → `_` (catch-all). */
@@ -55,6 +71,14 @@ export interface ProxyIntrospection {
55
71
  * saines.
56
72
  */
57
73
  keepaliveIntervalMs: number;
74
+ /**
75
+ * Terminaison TLS au frontal, ou `null` pour n'écouter qu'en clair.
76
+ *
77
+ * **nginx seulement** : haproxy exige un PEM COMBINÉ (certificat + clé dans
78
+ * un même fichier), que Nodefony ne fabrique pas — la commande REFUSE donc
79
+ * l'option sur cette cible plutôt que de l'accepter et de la jeter.
80
+ */
81
+ tls: ProxyTlsTermination | null;
58
82
  }
59
83
  /** Valeurs par défaut d'un modèle d'introspection (complété par la commande). */
60
84
  export declare const defaultIntrospection: ProxyIntrospection;
package/docs/cookies.md CHANGED
@@ -284,8 +284,8 @@ 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:43`). Le seul cookie **piloté par la config** est celui
287
- de la **session** — bloc Zod `sessionCookieSchema` (`config.ts:727`), avec notamment `hostPrefix`
288
- (`config.ts:730`) qui décide du préfixe `__Host-`. Tout cela est documenté dans [Sessions](session.md) :
287
+ de la **session** — bloc Zod `sessionCookieSchema` (`config.ts:748`), avec notamment `hostPrefix`
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
@@ -112,7 +112,7 @@ 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:1300` pour HTTP, `http-kernel.ts:1590` pour WS), et se lit de n'importe où avec
115
+ (`http-kernel.ts:435` pour HTTP, `http-kernel.ts:435` 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
@@ -420,7 +420,7 @@ instancié **qu'en dev** (fuite d'info + coût en prod).
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
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:459`) |
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:71`) — 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:1296`) |
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:1505`) |
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` |
@@ -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:830`) : `null` tant qu'on ne l'active pas → **0
109
+ laisse donc `rateLimit` désarmé par défaut (`config.ts:1065`) : `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
 
@@ -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:322`).
150
+ reconstruit le compteur sans redémarrage (`configureRateLimit()`, `http-kernel.ts:416`).
151
151
 
152
152
  ### 2. Observer le 429 et les en-têtes
153
153
 
@@ -214,14 +214,14 @@ Autour de ce cœur, le kernel orchestre le cycle de vie :
214
214
 
215
215
  ## ⚙️ Configuration
216
216
 
217
- Table dérivée de `rateLimitSchema` (`config.ts:847`). Tout est optionnel : ce sont les défauts du
217
+ Table dérivée de `rateLimitSchema` (`config.ts:868`). Tout est optionnel : ce sont les défauts du
218
218
  schéma, écrits ici pour les montrer.
219
219
 
220
220
  | Option | Type | Défaut | Effet | Chaud |
221
221
  | ------------- | ------------ | --------- | -------------------------------------------------------------------------------- | ----- |
222
222
  | `enabled` | bool | `false` | Arme le rate-limit (HTTP **et** handshakes WS, même compteur) (`config.ts:849`). | oui |
223
223
  | `windowS` | int (s) | `60` | Largeur de la fenêtre fixe ; le compteur par IP repart à zéro (`config.ts:860`). | oui |
224
- | `max` | int | `300` | Requêtes/IP/fenêtre ; au-delà `429` + `Retry-After` (`config.ts:871`). | oui |
224
+ | `max` | int | `300` | Requêtes/IP/fenêtre ; au-delà `429` + `Retry-After` (`config.ts:892`). | oui |
225
225
  | `maxTracked` | int (≥ 1000) | `100 000` | Borne mémoire : IP suivies ; au cap, purge puis éviction FIFO (`config.ts:883`). | non |
226
226
  | `gcIntervalS` | int (s) | `300` | Intervalle du balayage de purge des fenêtres expirées, hors hot-path. | non |
227
227
  | `gcJitter` | bool | `true` | Étale le tick GC d'un jitter aléatoire (anti-thundering-herd multi-pod). | non |
@@ -242,7 +242,7 @@ 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:1505`) — **avant** `enterScope`, l'ALS et le pipeline, comme le `429` HTTP.
245
+ (`http-kernel.ts:1540`) — **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
 
package/docs/servers.md CHANGED
@@ -128,7 +128,7 @@ Nodefony crée un serveur HTTP/2 sécurisé avec `allowHTTP1: true` (`ServerHttp
128
128
 
129
129
  **Le WebSocket n'est jamais un citoyen de seconde zone.** Il est adossé au serveur HTTP porteur
130
130
  (`server-websocket.ts:80`), passe par le **même** rate-limit d'IP que les requêtes HTTP — un upgrade
131
- _est_ une requête HTTP (`HttpKernel.onWebsocketRequest()`, `http-kernel.ts:1497`) —, hérite de la même
131
+ _est_ une requête HTTP (`HttpKernel.onWebsocketRequest()`, `http-kernel.ts:1540`) —, hérite de la même
132
132
  session et du même firewall, et se ferme avec le même soin qu'une réponse HTTP.
133
133
 
134
134
  > [!NOTE]
@@ -249,7 +249,7 @@ export default PingController;
249
249
 
250
250
  ### 4. Ce qu'on observe au boot
251
251
 
252
- Le kernel démarre les serveurs à la phase `onReady` (`Kernel.ts:1090`), puis affiche les URL réellement
252
+ Le kernel démarre les serveurs à la phase `onReady` (`Kernel.ts:1214`), puis affiche les URL réellement
253
253
  en écoute — le récap de développement liste HTTP, HTTP/2, WS et WSS dans cet ordre
254
254
  (`BootReporter.ts:389`) :
255
255
 
@@ -264,7 +264,7 @@ en écoute — le récap de développement liste HTTP, HTTP/2, WS et WSS dans ce
264
264
  ```
265
265
 
266
266
  Hors écran animé (production, CI, `--debug`), ce sont les bannières par serveur qui sortent
267
- (`ServerHttp.showBanner()`, `server-http.ts:226`, appelées par le kernel — `Kernel.ts:457`) :
267
+ (`ServerHttp.showBanner()`, `server-http.ts:226`, appelées par le kernel — `Kernel.ts:545`) :
268
268
 
269
269
  ```text
270
270
  Server Listen on http://127.0.0.1:5151 Family: IPv4 Protocol : 1.1
@@ -399,7 +399,7 @@ C'est la distinction la plus utile de cette page, et celle qu'on rate le plus so
399
399
 
400
400
  | Question | Où ça se règle | Source |
401
401
  | ----------------------------------------- | ------------------------------ | --------------------------------------------------------- |
402
- | **Quels** serveurs, sur **quels ports** ? | `servers` (config d'app) | `serversSchema` (`src/nodefony/src/config/schema.ts:132`) |
402
+ | **Quels** serveurs, sur **quels ports** ? | `servers` (config d'app) | `serversSchema` (`src/nodefony/src/config/schema.ts:149`) |
403
403
  | **Comment** ces serveurs se comportent ? | `use("@nodefony/http", { … })` | `httpConfigSchema` (`config.ts:953`) |
404
404
 
405
405
  Autrement dit : la **topologie** est une propriété du déploiement (elle change entre le poste du dev,
@@ -422,7 +422,7 @@ Défauts matérialisés dans `defaultAppConfig` (`src/nodefony/src/config/defaul
422
422
  ### Niveau 2 — le transport HTTP / HTTPS
423
423
 
424
424
  Table dérivée de `httpServerSchema` (`config.ts:257`) ; la section `https` reprend les mêmes clés et en
425
- ajoute une (`httpsServerSchema`, `config.ts:315`).
425
+ ajoute une (`httpsServerSchema`, `config.ts:336`).
426
426
 
427
427
  | Option | Type | Défaut | Effet |
428
428
  | ---------------------------- | ----- | -------- | -------------------------------------------------------------------------------- |
@@ -441,7 +441,7 @@ quelle à Node. C'est délibéré — un schéma strict effacerait silencieuseme
441
441
 
442
442
  ### Niveau 2 — HTTP/2
443
443
 
444
- Depuis `http2Schema` (`config.ts:332`), appliqué seulement si défini
444
+ Depuis `http2Schema` (`config.ts:353`), appliqué seulement si défini
445
445
  (`maxSessionMemory`, `server-https.ts:197`).
446
446
 
447
447
  | Option | Type | Défaut | Effet |
@@ -451,7 +451,7 @@ Depuis `http2Schema` (`config.ts:332`), appliqué seulement si défini
451
451
 
452
452
  ### Niveau 2 — WebSocket (`websocket` et `websocketSecure`)
453
453
 
454
- Depuis `websocketSchema` (`config.ts:496`). Les deux sections partagent la forme et les défauts ; le WSS
454
+ Depuis `websocketSchema` (`config.ts:517`). Les deux sections partagent la forme et les défauts ; le WSS
455
455
  lit `websocketSecure` (`config.ts:1043`).
456
456
 
457
457
  | Option | Type | Défaut | Effet |
@@ -459,8 +459,8 @@ lit `websocketSecure` (`config.ts:1043`).
459
459
  | `keepaliveInterval` | ms | `20000` | Intervalle des pings — détecte les connexions zombies. |
460
460
  | `keepaliveGracePeriod` | ms | `10000` | Délai de grâce après un ping sans réponse avant fermeture. |
461
461
  | `closeTimeout` | ms | `5000` | Délai de fermeture propre avant destruction de la socket. |
462
- | `maxPayload` | octets | `1 MiB` | Taille max d'un message entrant → au-delà, **close 1009** (`config.ts:522`). |
463
- | `allowedOrigins` | bool \| str \| list | `false` | Allowlist d'`Origin` au handshake — **anti-CSWSH** (`config.ts:531`). |
462
+ | `maxPayload` | octets | `1 MiB` | Taille max d'un message entrant → au-delà, **close 1009** (`config.ts:543`). |
463
+ | `allowedOrigins` | bool \| str \| list | `false` | Allowlist d'`Origin` au handshake — **anti-CSWSH** (`config.ts:552`). |
464
464
  | `perMessageDeflate` | bool \| objet | `false` | Compression RFC 7692. Désactivée par défaut : coût CPU/RAM + risque de _zip bomb_. |
465
465
  | `skipUTF8Validation` | bool | `false` | Désactive la validation UTF-8 des frames texte (RFC 6455 §8.1). À laisser `false`. |
466
466
  | `autoPong` | bool | `true` | Répond automatiquement aux pings entrants (RFC 6455 §5.5.2-3). À laisser `true`. |
@@ -578,7 +578,7 @@ export default defineConfig(() => ({
578
578
 
579
579
  `node-forge` est une grosse dépendance. Elle est chargée **paresseusement**, uniquement sur le chemin
580
580
  de génération (`Certificate.loadForge()`, `certificates.ts:227`) : en production avec un certificat
581
- fourni, elle n'entre jamais dans le processus (`certificates.ts:334`).
581
+ fourni, elle n'entre jamais dans le processus (`certificates.ts:227`).
582
582
 
583
583
  ### Conformité de l'auto-signé
584
584
 
@@ -784,7 +784,7 @@ processus à l'arrêt.
784
784
 
785
785
  L'upgrade WebSocket **est** une requête HTTP : il passe donc par le **même** compteur de rate-limit par
786
786
  IP que les requêtes ordinaires, vérifié avant toute allocation de contexte
787
- (`HttpKernel.onWebsocketRequest()`, `http-kernel.ts:1497`). Le `101` étant déjà émis par `ws`, un `429`
787
+ (`HttpKernel.onWebsocketRequest()`, `http-kernel.ts:1540`). Le `101` étant déjà émis par `ws`, un `429`
788
788
  est impossible → la connexion est fermée en **1013 « Try Again Later »**
789
789
  (`rateLimiter`, `http-kernel.ts:287`), sans
790
790
  journalisation (un journal par handshake rejeté serait lui-même un amplificateur sous flood).
@@ -836,13 +836,13 @@ demande le backplane realtime.
836
836
  | ------------------------------------- | ------------------ | -------------------------------------------------------------------------- |
837
837
  | HTTP/1.1 (sémantique, message) | RFC 9110, 9112 | `node:http` + pipeline `HttpKernel.onHttpRequest()` (`http-kernel.ts:819`) |
838
838
  | HTTP/2 | RFC 9113 | `ServerHttps.createServerH2()` (`server-https.ts:174`) |
839
- | HTTP/2 Rapid Reset | CVE-2023-44487 | `maxConcurrentStreams` (`config.ts:334`) |
839
+ | HTTP/2 Rapid Reset | CVE-2023-44487 | `maxConcurrentStreams` (`config.ts:355`) |
840
840
  | En-têtes trop volumineux → 431 | RFC 6585 §5 | `handleClientError()` (`clientError.ts:25`) |
841
841
  | WebSocket — protocole | RFC 6455 | `ws@8` + options (`config.ts:496`) |
842
842
  | WebSocket — Close 1001 « Going Away » | RFC 6455 §7.4.1 | `Websocket.terminate()` (`server-websocket.ts:134`) |
843
- | WebSocket — 1009 « Message Too Big » | RFC 6455 §7.4.1 | `maxPayload` (`config.ts:522`) |
844
- | WebSocket — validation UTF-8 | RFC 6455 §8.1 | `skipUTF8Validation` (`config.ts:602`) |
845
- | WebSocket — compression | RFC 7692 | `perMessageDeflate` (`config.ts:546`) |
843
+ | WebSocket — 1009 « Message Too Big » | RFC 6455 §7.4.1 | `maxPayload` (`config.ts:543`) |
844
+ | WebSocket — validation UTF-8 | RFC 6455 §8.1 | `skipUTF8Validation` (`config.ts:623`) |
845
+ | WebSocket — compression | RFC 7692 | `perMessageDeflate` (`config.ts:567`) |
846
846
  | CSWSH (Origin au handshake) | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts:599`) |
847
847
  | En-têtes forwarded | RFC 7239 | `resolveForwarded()` (`forwarded.ts:253`) |
848
848
  | Certificat — série, SAN, extensions | RFC 5280 | `Certificate.generateSerialHex()` (`certificates.ts:264`) |
@@ -886,7 +886,7 @@ l'origine du transport.
886
886
 
887
887
  `proxy:generate` mérite un mot : la configuration nginx/HAProxy est **dérivée** des domaines de
888
888
  confiance, des ports effectifs et des dossiers statiques montés — donc elle ne diverge pas du code. Le
889
- résumé de certificat vient de `Certificate.describe()` (`certificates.ts:812`), source unique partagée
889
+ résumé de certificat vient de `Certificate.describe()` (`certificates.ts:853`), source unique partagée
890
890
  par la commande, le boot et un futur écran d'administration.
891
891
 
892
892
  **Runtime.** `nodefony status` et `nodefony stop` lisent les ports effectifs publiés au boot ; ils
package/docs/session.md CHANGED
@@ -144,7 +144,7 @@ seule présence d'un paramètre `@Session` — ou si un cookie arrive déjà : c
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:1391`) et `HttpKernel.onConnect()` (`http-kernel.ts:1659`) ;
147
+ `HttpKernel.onRequestEnd()` (`http-kernel.ts:1434`) et `HttpKernel.onConnect()` (`http-kernel.ts:1702`) ;
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é
@@ -278,12 +278,12 @@ faute de `Secure` (`Context.getSessionCookieName()`, `Context.ts:714`).
278
278
 
279
279
  ## ⚙️ Configuration
280
280
 
281
- Source unique des défauts : le schéma Zod `sessionSchema` (`config.ts:761`) et son sous-schéma
282
- `sessionCookieSchema` (`config.ts:727`).
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
283
 
284
284
  | Option | Type | Défaut | Effet |
285
285
  | ------------------- | ------- | ------------ | --------------------------------------------------------------------------------- |
286
- | `store` | string | `"auto"` | Backend de persistance — voir la résolution ci-dessous (`config.ts:755`). |
286
+ | `store` | string | `"auto"` | Backend de persistance — voir la résolution ci-dessous (`config.ts:795`). |
287
287
  | `name` | string | `"nodefony"` | Nom du cookie, préfixé `__Host-` selon `cookie.hostPrefix` (`config.ts:750`). |
288
288
  | `strictMode` | bool | `true` | Un identifiant inconnu du store est rejeté → session neuve (anti-fixation). |
289
289
  | `idleTimeoutS` | int ≥ 0 | `1800` | Inactivité max (30 min). `0` = pas d'expiration par inactivité (`config.ts:796`). |
@@ -498,7 +498,7 @@ C'est le différenciateur du framework appliqué à l'état de session : un seul
498
498
  <!-- prettier-ignore -->
499
499
  | Aspect | HTTP | WebSocket |
500
500
  | --- | --- | --- |
501
- | Ouverture | à chaque requête — `startSession()` dans `onRequestEnd()` (`http-kernel.ts:1391`) | **une fois** au handshake — `startSession()` dans `onConnect()` (`http-kernel.ts:1659`) |
501
+ | Ouverture | à chaque requête — `startSession()` dans `onRequestEnd()` (`http-kernel.ts:1434`) | **une fois** au handshake — `startSession()` dans `onConnect()` (`http-kernel.ts:1702`) |
502
502
  | Lecture du cookie | constructeur du contexte | constructeur, même nom effectif (`WebsocketContext.ts:172`) |
503
503
  | Sauvegarde | fin de requête | après **chaque frame** traitée (`WebsocketContext.ts:302`) |
504
504
  | Filet de fermeture | — | `once("onFinish")` sauve si non déjà fait (`http-kernel.ts:1185`) |
@@ -561,13 +561,13 @@ Trois barrières superposées :
561
561
  **liste blanche** : `ref`, `user`, `authenticated`, `ip`, `ua`, dates. Jamais un `delete` après coup.
562
562
  3. La `ref` elle-même est un HMAC tronqué non réversible (`computeSessionRef()`,
563
563
  `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:511`).
564
+ (`SessionsService.sessionRef()`, `sessions-service.ts:528`).
565
565
 
566
566
  ### Récapitulatif des défenses actives par défaut
567
567
 
568
568
  | Menace | Défense | Ancrage |
569
569
  | --------------------------------- | ------------------------------------------------- | -------------------------------------------------- |
570
- | Vol par script injecté (XSS) | `HttpOnly` | `sessionCookieSchema` (`config.ts:718`) |
570
+ | Vol par script injecté (XSS) | `HttpOnly` | `sessionCookieSchema` (`config.ts:748`) |
571
571
  | Interception réseau | `Secure` + `__Host-` sur TLS | `getSessionCookieName()` (`Context.ts:714`) |
572
572
  | Requête inter-sites | `SameSite=Lax` par défaut | `defaultCookieOptions` (`cookie.ts:48`) |
573
573
  | Fixation (cookie pré-posé) | `strictMode` + régénération au login | `Session.resume()` (`session.ts:189`) |
@@ -582,7 +582,7 @@ Trois barrières superposées :
582
582
 
583
583
  Les signatures vivent dans `.ai/symbols.json` (jamais recopiées ici). Voici les usages réels.
584
584
 
585
- **Depuis un contrôleur** — `this.session` est un getter sur le contexte (`Controller.ts:229`) ; un
585
+ **Depuis un contrôleur** — `this.session` est un getter sur le contexte (`Controller.ts:279`) ; un
586
586
  paramètre `@Session()` suffit à déclarer l'intent.
587
587
 
588
588
  | Besoin | Appel | Effet |