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

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 (26) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorateParam.js +1 -1
  3. package/dist/index.js +3 -3
  4. package/dist/nodefony/controllers/DevkitController.js +1 -1
  5. package/dist/nodefony/controllers/McpController.js +2 -2
  6. package/dist/nodefony/controllers/ProfileSessionController.js +1 -1
  7. package/dist/nodefony/mcp/profileRoute.js +28 -9
  8. package/dist/nodefony/service/DevkitService.js +4 -4
  9. package/docs/index.md +1 -1
  10. package/package.json +11 -11
  11. package/skills/nodefony-add-crud/SKILL.md +38 -23
  12. package/skills/nodefony-add-realtime-channel/SKILL.md +376 -111
  13. package/skills/nodefony-add-realtime-channel/references/binaire.md +191 -0
  14. package/skills/nodefony-add-realtime-channel/references/production.md +121 -0
  15. package/skills/nodefony-browser/SKILL.md +27 -2
  16. package/skills/nodefony-browser/scripts/audit.mjs +12 -0
  17. package/skills/nodefony-browser/scripts/inspect.mjs +40 -81
  18. package/skills/nodefony-browser/scripts/lib/actions.mjs +113 -0
  19. package/skills/nodefony-browser/scripts/lib/browser.mjs +50 -11
  20. package/skills/nodefony-browser/scripts/lib/probes.mjs +56 -0
  21. package/skills/nodefony-browser/scripts/watch.mjs +27 -11
  22. package/skills/nodefony-dev/SKILL.md +11 -0
  23. package/skills/nodefony-dev/references/rapport-de-build.md +19 -0
  24. package/skills/nodefony-devops/SKILL.md +28 -21
  25. package/skills/nodefony-migrate-schema/SKILL.md +110 -20
  26. package/skills/nodefony-migrate-schema/references/verdicts.md +22 -20
@@ -0,0 +1,113 @@
1
+ import { USER, signInElsewhere } from "./browser.mjs";
2
+
3
+ /**
4
+ * Joue une séquence d'interactions (`NF_BROWSER_ACTIONS`, analysée par
5
+ * `parseActions`) sur une page — la seule implémentation, partagée par
6
+ * `inspect.mjs` (agir PUIS photographier) et `watch.mjs` (agir PENDANT qu'on
7
+ * observe le trafic).
8
+ *
9
+ * Une action qui ne trouve pas sa cible ARRÊTE la séquence : une mesure faite
10
+ * sur un écran qu'on n'a pas ouvert est pire qu'aucune mesure.
11
+ *
12
+ * @param {import("playwright").Page} page - la page pilotée.
13
+ * @param {{ verb: string, target: string, value?: string }[]} actions - la séquence.
14
+ * @returns {Promise<{ code: number, message: string } | null>} `null` si tout a
15
+ * été joué ; sinon le code de sortie à rendre (64 usage, 65 cible
16
+ * introuvable) et le message qui nomme l'action.
17
+ */
18
+ export async function playActions(page, actions) {
19
+ for (const { verb, target, value } of actions) {
20
+ if (verb === "connecter") {
21
+ if (!USER) {
22
+ return {
23
+ code: 64, // EX_USAGE
24
+ message:
25
+ "connecter: exige NF_BROWSER_USER, NF_BROWSER_PASSWORD et NF_BROWSER_LOGIN",
26
+ };
27
+ }
28
+ await signInElsewhere(page.context());
29
+ continue;
30
+ }
31
+ if (verb === "defiler") {
32
+ const pixels = Number.parseInt(target, 10);
33
+ if (!Number.isFinite(pixels)) {
34
+ return {
35
+ code: 64, // EX_USAGE
36
+ message: `defiler attend un nombre de pixels, reçu « ${target} »`,
37
+ };
38
+ }
39
+ // Le conteneur qui défile RÉELLEMENT, pas la fenêtre : sur une console à
40
+ // barre latérale fixe, `window.scrollBy` ne bouge rien du tout.
41
+ await page.evaluate((dy) => {
42
+ const scrollable = [...document.querySelectorAll("*")].find(
43
+ (el) =>
44
+ el.scrollHeight > el.clientHeight + 40 && el.clientHeight > 200,
45
+ );
46
+ (scrollable ?? window).scrollBy(0, dy);
47
+ }, pixels);
48
+ continue;
49
+ }
50
+ // Le premier candidat VISIBLE, pas le premier du DOM : un libellé apparaît
51
+ // souvent d'abord dans un menu replié ou un gabarit caché, et agir là ne fait
52
+ // rien tout en passant pour un succès.
53
+ const candidates = page.getByText(target, { exact: false });
54
+ let locator = null;
55
+ const total = await candidates.count();
56
+ for (let i = 0; i < total; i += 1) {
57
+ const c = candidates.nth(i);
58
+ if (await c.isVisible().catch(() => false)) {
59
+ locator = c;
60
+ break;
61
+ }
62
+ }
63
+ // Aucun candidat visible À CET INSTANT ne dit pas que la cible est un
64
+ // sélecteur : juste après un clic qui navigue, la page suivante n'est pas
65
+ // encore rendue. On attend donc le texte OU le sélecteur — et le texte seul
66
+ // quand la cible n'est pas du CSS valide. Trancher sur l'instant faisait
67
+ // chercher une balise `<FreeOTP>` une fois sur trois.
68
+ if (locator === null) {
69
+ // Une navigation en vol détruirait le contexte de l'évaluation ci-dessous.
70
+ await page.waitForLoadState("domcontentloaded").catch(() => {});
71
+ const isCss = await page
72
+ .evaluate((s) => {
73
+ try {
74
+ document.querySelector(s);
75
+ return true;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }, target)
80
+ .catch(() => false);
81
+ locator = isCss
82
+ ? candidates.or(page.locator(target)).first()
83
+ : candidates.first();
84
+ }
85
+ try {
86
+ await locator.waitFor({ timeout: 15000 });
87
+ if (verb === "clic") await locator.click();
88
+ else if (verb === "double") await locator.dblclick();
89
+ else if (verb === "droit") await locator.click({ button: "right" });
90
+ else if (verb === "survol") await locator.hover();
91
+ else if (verb === "saisir") await locator.fill(value);
92
+ // `choisir` : la cible peut être le `<label>` qui ENVELOPPE la liste —
93
+ // Playwright reporte alors le choix sur son contrôle. La valeur est celle
94
+ // de l'option (`value`), sinon son libellé.
95
+ else if (verb === "choisir") {
96
+ const chosen = await locator.selectOption(value);
97
+ if (chosen.length === 0) throw new Error(`option « ${value} » absente`);
98
+ } else if (verb === "touche") await locator.press(value || "Enter");
99
+ else if (verb === "voir") await locator.scrollIntoViewIfNeeded();
100
+ // `attendre` : le `waitFor` ci-dessus EST l'action.
101
+ } catch {
102
+ return {
103
+ code: 65, // EX_DATAERR
104
+ message:
105
+ `Action « ${verb}:${target} » impossible\n` +
106
+ `Page réellement ouverte : ${page.url()}\n` +
107
+ "→ le libellé a changé, l'élément n'est pas encore rendu, ou il faut " +
108
+ "agir sur autre chose avant lui (NF_BROWSER_ACTIONS accepte une séquence, séparée par « | »).",
109
+ };
110
+ }
111
+ }
112
+ return null;
113
+ }
@@ -13,7 +13,7 @@
13
13
  * `@env` NF_BROWSER_USER identifiant ; sans lui, aucune authentification n'est tentée
14
14
  * `@env` NF_BROWSER_PASSWORD mot de passe associé
15
15
  * `@env` NF_BROWSER_WHOAMI chemin qui répond 2xx à une session authentifiée, et 401/403 ou un 2xx portant `{ user: null }` sinon — prouve une session reprise (défaut `/nodefony/security/api/auth/me` ; vide = aucune reprise)
16
- * `@env` NF_BROWSER_ENGINE navigateur imposé (chromium, chrome, msedge) ; sans lui, le premier qui répond
16
+ * `@env` NF_BROWSER_ENGINE navigateur imposé (chromium, chrome, msedge, firefox, webkit) ; sans lui, le premier qui répond
17
17
  * `@env` NF_BROWSER_COLOR_SCHEME schéma de couleurs émulé (light, dark, no-preference) ; sans lui, celui du navigateur
18
18
  * `@env` NF_BROWSER_STORAGE entrées de stockage local posées AVANT chargement (`clé=valeur`, séparées par des virgules)
19
19
  */
@@ -22,6 +22,7 @@ import path from "node:path";
22
22
  import {
23
23
  environmentDefaults,
24
24
  authStateName,
25
+ browserEngine,
25
26
  browserOrder,
26
27
  sessionVerdict,
27
28
  parseColorScheme,
@@ -38,9 +39,10 @@ import {
38
39
  * en pair optionnel — et un `MODULE_NOT_FOUND` nu, qui ne dit ni quoi
39
40
  * installer ni pourquoi, n'est pas une réponse acceptable.
40
41
  */
41
- let chromium;
42
+ let launchers;
42
43
  try {
43
- ({ chromium } = await import("playwright"));
44
+ const { chromium, firefox, webkit } = await import("playwright");
45
+ launchers = { chromium, firefox, webkit };
44
46
  } catch {
45
47
  console.error(
46
48
  "Playwright est absent — c'est lui qui pilote le navigateur.\n\n" +
@@ -205,10 +207,12 @@ if (storageRejected.length > 0) {
205
207
  /**
206
208
  * Ouvre un navigateur et un contexte prêts à mesurer.
207
209
  *
210
+ * @param options.anonymous - ne reprendre AUCUN état d'authentification : la
211
+ * séquence se connecte elle-même en route (`connecter:`).
208
212
  * @returns `{ browser, ctx, page, reuse }` — `reuse` dit si un état
209
213
  * d'authentification a été repris, ce qui décide s'il faut se connecter.
210
214
  */
211
- export async function open() {
215
+ export async function open({ anonymous = false } = {}) {
212
216
  // On CONSTATE quel navigateur répond, au lieu de supposer lequel est là.
213
217
  //
214
218
  // `channel` demande un navigateur COMPLET plutôt que le
@@ -222,10 +226,14 @@ export async function open() {
222
226
  const failures = [];
223
227
  for (const channel of BROWSERS) {
224
228
  try {
225
- browser = await chromium.launch({
226
- channel: channel,
227
- args: ["--no-sandbox"],
228
- });
229
+ // `--no-sandbox` est un drapeau de Chromium : Firefox et WebKit n'en
230
+ // ont pas l'usage, et le pilote ne leur passe aucun canal.
231
+ const { engine, channel: variant } = browserEngine(channel);
232
+ browser = await launchers[engine].launch(
233
+ engine === "chromium"
234
+ ? { channel: variant, args: ["--no-sandbox"] }
235
+ : {},
236
+ );
229
237
  browserUsed = channel;
230
238
  break;
231
239
  } catch (e) {
@@ -238,7 +246,8 @@ export async function open() {
238
246
  `${failures.join("\n")}\n\n` +
239
247
  "Installer celui du pilote (une fois par machine, partagé par tous tes projets) :\n\n" +
240
248
  " npx playwright install chromium\n\n" +
241
- "Ou viser un navigateur déjà présent : NF_BROWSER_ENGINE=chrome (ou msedge).",
249
+ "Ou viser un navigateur déjà présent : NF_BROWSER_ENGINE=chrome (ou msedge).\n" +
250
+ "Autre moteur : NF_BROWSER_ENGINE=firefox (ou webkit), après npx playwright install firefox.",
242
251
  );
243
252
  process.exit(69); // EX_UNAVAILABLE
244
253
  }
@@ -253,7 +262,7 @@ export async function open() {
253
262
  : {}),
254
263
  ...(COLOR_SCHEME ? { colorScheme: COLOR_SCHEME } : {}),
255
264
  };
256
- let reuse = Boolean(USER) && existsSync(STATE);
265
+ let reuse = !anonymous && Boolean(USER) && existsSync(STATE);
257
266
  let ctx = null;
258
267
  if (reuse) {
259
268
  try {
@@ -411,6 +420,24 @@ export async function signIn(page, ctx) {
411
420
  await ctx.storageState({ path: STATE });
412
421
  }
413
422
 
423
+ /**
424
+ * Se connecte dans un AUTRE onglet du même contexte, puis le referme — la page
425
+ * mesurée n'est ni rechargée ni quittée. C'est le geste d'un utilisateur qui
426
+ * ouvre sa session ailleurs (console, autre onglet) pendant qu'une page déjà
427
+ * ouverte doit le constater d'elle-même : les cookies sont partagés, rien
428
+ * d'autre.
429
+ *
430
+ * @param ctx - le contexte de la page mesurée.
431
+ */
432
+ export async function signInElsewhere(ctx) {
433
+ const tab = await ctx.newPage();
434
+ try {
435
+ await signIn(tab, ctx);
436
+ } finally {
437
+ await tab.close();
438
+ }
439
+ }
440
+
414
441
  /**
415
442
  * Ouvre la page demandée, en garantissant que c'est bien ELLE qu'on mesure.
416
443
  *
@@ -424,8 +451,20 @@ export async function signIn(page, ctx) {
424
451
  * @param ctx - son contexte.
425
452
  * @param pathname - le chemin à ouvrir, relatif à l'origine.
426
453
  * @param reuse - un état d'authentification a-t-il été repris au lancement.
454
+ * @param options.anonymous - ouvrir la page SANS se connecter d'abord : la
455
+ * séquence s'en charge en route (`connecter:`).
427
456
  */
428
- export async function goTo(page, ctx, pathname, reuse) {
457
+ export async function goTo(
458
+ page,
459
+ ctx,
460
+ pathname,
461
+ reuse,
462
+ { anonymous = false } = {},
463
+ ) {
464
+ if (anonymous) {
465
+ await page.goto(`${BASE}${pathname}`, { waitUntil: "domcontentloaded" });
466
+ return;
467
+ }
429
468
  if (USER && !reuse) await signIn(page, ctx);
430
469
  await page.goto(`${BASE}${pathname}`, { waitUntil: "domcontentloaded" });
431
470
  if (USER && reuse) {
@@ -159,8 +159,24 @@ export const ACTION_VERBS = [
159
159
  "voir",
160
160
  "defiler",
161
161
  "attendre",
162
+ "connecter",
162
163
  ];
163
164
 
165
+ /**
166
+ * La séquence se connecte-t-elle EN COURS de route (`connecter:`) ?
167
+ *
168
+ * Alors la page doit s'ouvrir ANONYME — ni état d'authentification repris, ni
169
+ * connexion préalable : c'est précisément le passage « sans session » →
170
+ * « avec session », sans recharger, qu'on veut observer. Se connecter avant
171
+ * aurait rendu l'action sans objet, et la mesure verte pour la mauvaise raison.
172
+ *
173
+ * @param actions - la séquence analysée par `parseActions`.
174
+ * @returns `true` si une action `connecter` y figure.
175
+ */
176
+ export function signsInMidway(actions) {
177
+ return actions.some((a) => a.verb === "connecter");
178
+ }
179
+
164
180
  /**
165
181
  * Analyse une séquence d'actions (`NF_BROWSER_ACTIONS`).
166
182
  *
@@ -240,6 +256,25 @@ export function parseColorScheme(raw) {
240
256
  return { schema: null, invalid: v };
241
257
  }
242
258
 
259
+ /**
260
+ * Rend une expression JS fournie par l'utilisateur ÉVALUABLE dans la page —
261
+ * une forme fonction (`() => x`, `function`, `async`) y est APPELÉE.
262
+ *
263
+ * Le piège est réel : `page.evaluate` et `waitForFunction` évaluent une chaîne
264
+ * comme une EXPRESSION. Donner « () => x » y définit une fonction sans jamais
265
+ * l'appeler : l'objet fonction est truthy (une attente réussit TOUJOURS) et ne
266
+ * se sérialise pas (une lecture rend `undefined`). Faux vert vécu, découvert
267
+ * seulement en éprouvant le sens négatif.
268
+ *
269
+ * @param {string|undefined} raw - expression brute (`NF_BROWSER_UNTIL`, `NF_BROWSER_EVAL`).
270
+ * @returns {string} l'expression à évaluer, vide si rien n'est demandé.
271
+ */
272
+ export function pageExpression(raw) {
273
+ const v = (raw ?? "").trim();
274
+ if (!v) return "";
275
+ return /^(\(|function\b|async\b)/.test(v) ? `(${v})()` : v;
276
+ }
277
+
243
278
  /**
244
279
  * Analyse une coupure réseau demandée (`NF_BROWSER_CUT`), au format
245
280
  * `<début>:<durée>` en millisecondes — `4000:3000` coupe le réseau 4 s après
@@ -709,3 +744,24 @@ export function browserOrder(explicit) {
709
744
  if (v) return [v];
710
745
  return ["chromium", "chrome", "msedge"];
711
746
  }
747
+
748
+ /**
749
+ * Traduit un nom de navigateur en MOTEUR du pilote et, pour Chromium, en canal.
750
+ *
751
+ * Un comportement que dicte une norme — la politique de sécurité du contenu, le
752
+ * rapport de violation, un cookie `SameSite` — diverge d'un moteur à l'autre :
753
+ * le constater sous Chromium seul ne dit rien de Firefox ni de Safari. `firefox`
754
+ * et `webkit` désignent donc les moteurs que le pilote installe lui-même
755
+ * (`npx playwright install firefox`) ; tout autre nom reste un canal de
756
+ * Chromium (`chrome`, `msedge`, `chrome-beta`…), comme avant.
757
+ *
758
+ * @param {string} name - un nom rendu par `browserOrder`.
759
+ * @returns {{ engine: "chromium"|"firefox"|"webkit", channel: string|undefined }}
760
+ * le moteur à lancer, et le canal Chromium (absent pour les deux autres).
761
+ */
762
+ export function browserEngine(name) {
763
+ const v = name.trim().toLowerCase();
764
+ if (v === "firefox" || v === "webkit")
765
+ return { engine: v, channel: undefined };
766
+ return { engine: "chromium", channel: name.trim() };
767
+ }
@@ -15,16 +15,25 @@
15
15
  * `@env` NF_BROWSER_UNTIL expression JS évaluée DANS la page ; l'observation s'arrête dès qu'elle est vraie (point d'arrêt applicatif)
16
16
  * `@env` NF_BROWSER_CUT coupure réseau `<début>:<durée>` en ms (`4000:3000`), comptée depuis l'ouverture de la page — éprouve la REPRISE d'un flux ; chaque socket dit alors quand il s'est fermé et rouvert
17
17
  * `@env` NF_BROWSER_MAXFRAMES nombre de frames WebSocket conservées par sens (défaut 12)
18
+ * `@env` NF_BROWSER_ACTIONS séquence d'interactions jouée UNE FOIS la page ouverte, PENDANT l'observation — même grammaire que `inspect.mjs` (`clic`, `saisir:Nom=x`, `attendre`…). C'est ce qui fait voir la trame qu'un GESTE envoie : un bouton qui écrit par la socket, un formulaire soumis
18
19
  * `@requires` conteneur du profil `browser` démarré · serveur Nodefony joignable
19
20
  * `@output` JSON : sockets et leurs frames (horodatées), requêtes non-2xx, erreurs console, verdict de la condition
20
21
  */
21
22
  import { open, goTo } from "./lib/browser.mjs";
22
- import { parseCut, sendableCloseCode } from "./lib/probes.mjs";
23
+ import {
24
+ parseActions,
25
+ parseCut,
26
+ pageExpression,
27
+ sendableCloseCode,
28
+ signsInMidway,
29
+ } from "./lib/probes.mjs";
30
+ import { playActions } from "./lib/actions.mjs";
23
31
 
24
32
  const PAGE = process.argv[2] ?? process.env.NF_BROWSER_PAGE ?? "/";
25
33
  const DURATION = Number(process.argv[3] ?? 8000);
26
34
  const UNTIL = process.env.NF_BROWSER_UNTIL ?? "";
27
35
  const MAX = Number(process.env.NF_BROWSER_MAXFRAMES ?? 12);
36
+ const ACTIONS = parseActions(process.env.NF_BROWSER_ACTIONS);
28
37
  const { cut: CUT, invalid: invalidCut } = parseCut(process.env.NF_BROWSER_CUT);
29
38
  if (invalidCut) {
30
39
  console.error(
@@ -35,7 +44,8 @@ if (invalidCut) {
35
44
  // Sockets que la coupure FERME (cf « La coupure réseau » plus bas).
36
45
  const isRouted = (url) => !new URL(url).pathname.startsWith("/_vite/");
37
46
 
38
- const { browser, ctx, page, reuse } = await open();
47
+ const ANONYMOUS = signsInMidway(ACTIONS);
48
+ const { browser, ctx, page, reuse } = await open({ anonymous: ANONYMOUS });
39
49
 
40
50
  const t0 = Date.now();
41
51
  const at = () => Date.now() - t0;
@@ -144,7 +154,7 @@ if (CUT) {
144
154
  });
145
155
  }
146
156
 
147
- await goTo(page, ctx, PAGE, reuse);
157
+ await goTo(page, ctx, PAGE, reuse, { anonymous: ANONYMOUS });
148
158
 
149
159
  if (CUT) {
150
160
  cutTimers.push(
@@ -168,6 +178,17 @@ if (CUT) {
168
178
  );
169
179
  }
170
180
 
181
+ // ── Les gestes : joués PENDANT l'observation ───────────────────────────────
182
+ // Les écouteurs sont déjà posés : la trame qu'un clic envoie est enregistrée
183
+ // comme les autres. Une cible introuvable arrête la sonde (code 65), comme
184
+ // `inspect.mjs` — observer un geste qui n'a pas eu lieu ne prouve rien.
185
+ const failed = await playActions(page, ACTIONS);
186
+ if (failed !== null) {
187
+ console.error(failed.message);
188
+ await browser.close();
189
+ process.exit(failed.code);
190
+ }
191
+
171
192
  // ── Le « point d'arrêt » : une CONDITION, pas une ligne de code ──────────────
172
193
  // `waitForFunction` réévalue l'expression dans la page à chaque animation frame.
173
194
  // C'est ce qui remplace utilement un breakpoint dans un pilotage automatisé :
@@ -176,14 +197,9 @@ if (CUT) {
176
197
  // observe simplement pendant la durée demandée.
177
198
  let verdict = "durée écoulée";
178
199
  if (UNTIL) {
179
- // ⚠️ Une chaîne passée à `waitForFunction` est évaluée comme une EXPRESSION.
180
- // Donner « () => x » y définit une fonction sans jamais l'appeler : l'objet
181
- // fonction est truthy, donc l'attente réussit TOUJOURS — y compris sur une
182
- // condition impossible. Faux vert vécu, découvert seulement en éprouvant le
183
- // sens négatif. On invoque donc explicitement les formes fonction.
184
- const expr = /^\s*(\(|function\b|async\b)/.test(UNTIL)
185
- ? `(${UNTIL})()`
186
- : UNTIL;
200
+ // Une forme fonction est APPELÉE (cf `pageExpression`) : passée telle quelle,
201
+ // l'attente réussirait TOUJOURS, condition impossible comprise.
202
+ const expr = pageExpression(UNTIL);
187
203
  try {
188
204
  await page.waitForFunction(expr, null, { timeout: DURATION });
189
205
  verdict = `condition VRAIE après ${at()} ms`;
@@ -456,6 +456,13 @@ Ne concerne qu'une application qui a un frontend. Sauf mention, **vaut pour les
456
456
  sur les scripts, et le navigateur refuse un `<script>` en ligne qui n'en porte pas (« Refused to
457
457
  execute inline script »). Un `curl` ne le voit JAMAIS, il ne lit que le corps. Signe le script
458
458
  (valeur `this.context?.cspNonce`) ou sors-le dans un fichier servi ; ne desserre PAS la politique.
459
+ - **Une image, une police ou une API tierce est bloquée** (« violates the following Content
460
+ Security Policy directive ») : AJOUTE l'origine exacte par `headers.cspSources` de
461
+ `@nodefony/security` (`{ cdn: { "img-src": ["https://cdn.example.com"] } }`), ou sur la seule
462
+ route qui en a besoin par `@Csp` — ne réécris pas `headers.csp`. Une socket servie depuis une
463
+ AUTRE origine se déclare par `realtime.publicOrigin`. Ce que les navigateurs ont bloqué se lit,
464
+ en administrateur, sur `GET /nodefony/security/api/csp-reports` (`headers.cspReport`) — Brave
465
+ n'envoie aucun rapport, éprouve sous Chrome ou Firefox.
459
466
  - **En production, la modification front n'apparaît jamais** : hors développement il n'y a PAS de
460
467
  rechargement à chaud, et le manifeste est lu AU BOOT. `npm run build`, puis **redémarre le
461
468
  serveur**, puis rechargement forcé.
@@ -474,6 +481,10 @@ npm run verify # types, style, mise en forme, .env.example, tests, build,
474
481
  npm run test:e2e # boot réel + HTTP/WS — le gate LENT, hors `verify`
475
482
  ```
476
483
 
484
+ Ton diff change ce que le build produit (taille, dépendance, build plus lent) ? Chaque build
485
+ écrit un rapport : lis [`references/rapport-de-build.md`](references/rapport-de-build.md) avant de
486
+ recalculer quoi que ce soit à la main.
487
+
477
488
  Ton diff ajoute une route, une requête ou touche la sécurité ? Relis-le AVANT de commiter :
478
489
  skill `nodefony-security-review` — un script y nomme, ligne par ligne, ce qu'un œil laisse passer.
479
490
 
@@ -0,0 +1,19 @@
1
+ # Le rapport de build — ce que pèse ce que tu bâtis
2
+
3
+ > Chargé depuis `nodefony-dev` §10, quand un changement touche ce que le build produit.
4
+
5
+ Chaque build (`npm run build` : serveur ET front) écrit, sans rien demander, un rapport sous
6
+ `node_modules/.rolldown/report/<dossier de sortie>/` — le chemin relatif au projet, chaque `..`
7
+ devenu `__` (le front bâti dans `public/dist` → `node_modules/.rolldown/report/public/dist/`).
8
+
9
+ - **Quand le lire** : une taille qui change, une dépendance qui entre, un paquet présent en deux
10
+ versions, un build qui ralentit. Pas pour savoir si le build passe : son code de sortie le dit.
11
+ - **Comment** : `entry.md` d'abord (une vingtaine de lignes, il nomme le fichier à ouvrir
12
+ ensuite). Pour juger TON changement, épingle la base AVANT de modifier
13
+ (`cp .state.json baseline.json` dans ce dossier), rebâtis, lis `delta.md`. Un chiffre :
14
+ `jq '.metrics["output.total_bytes"]' metrics.json` — ne lis pas les `.json` en entier.
15
+ - **Ce qu'il ne dit pas** : ses octets sont comptés AVANT minification et compression, jamais
16
+ un poids réseau ; il ne vérifie aucun type (`npm run typecheck`) ; le mode watch et le serveur
17
+ de développement n'en écrivent pas ; une installation propre l'efface, base comprise.
18
+ - **Le couper** (côté serveur) : `defineNodefonyRolldownConfig({ buildReport: false })` dans
19
+ `rolldown.config.ts`.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: nodefony-devops
3
3
  metadata:
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  description: >
6
6
  Exploite CETTE application Nodefony en conteneur : image, compose, frontal (nginx, HAProxy, h2c,
7
7
  Ingress), secrets, variables, Kubernetes, Podman, nombre de pods. N'enseigne pas Docker — il
@@ -80,6 +80,12 @@ Ce que l'application ne peut pas deviner. Chacun de ces oublis produit une dégr
80
80
  clair »), le cookie de session perd son préfixe `__Host-`, et l'adresse cliente devient celle du
81
81
  frontal — donc une vague de reconnexions WebSocket épuise **un seul** quota. Sans le moindre
82
82
  message. Détail et preuve → [`references/frontal.md`](references/frontal.md).
83
+ - **Une seule CSP, et c'est l'application qui la pose.** Elle porte un nonce neuf par page, que le
84
+ frontal ne peut pas connaître. N'ajoute pas de `Content-Security-Policy` dans nginx, haproxy ou
85
+ une annotation d'ingress : le navigateur appliquerait les deux, et les scripts signés seraient
86
+ bloqués — page blanche. La poser au frontal quand même : `headers: { csp: "" }` dans la config
87
+ de `@nodefony/security`, puis une politique SANS nonce au frontal →
88
+ `node_modules/@nodefony/security/docs/headers.md` § « Poser la CSP au proxy ».
83
89
  - **Une inactivité de frontal ≥ 300 s.** Plus courte que le battement, elle coupe des WebSocket et
84
90
  des flux SSE **vivants**. `proxy:generate` la dérive ; un contrôleur d'entrée Kubernetes, non.
85
91
  - **Un bus temps réel partagé dès deux exemplaires** : `NF_REALTIME_DRIVER=redis`. Sans lui, un
@@ -116,26 +122,27 @@ Chaque ligne a la commande qui la **prouve**. Une case cochée sans commande est
116
122
 
117
123
  Le tableau qu'on lit en urgence.
118
124
 
119
- | Symptôme | Cause | Correction |
120
- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
121
- | L'exemplaire redémarre en boucle, journaux muets | La sonde de vivacité tombe avant la fin du démarrage | Sonde de démarrage sur `/readyz`, dimensionnée sur le **pire** cas |
122
- | Le conteneur sort en **137** à l'arrêt | Il a été tué : la fenêtre d'arrêt est ≤ au drain | Fenêtre ≥ 30 s (Podman : `--stop-timeout 20`, son défaut est **10 s**) |
123
- | Des `5xx` **pendant** chaque déploiement | Le répartiteur envoie encore du trafic quand le drain a commencé | Un délai avant drain, et un court arrêt avant terminaison |
124
- | Le cookie de session n'a pas le préfixe `__Host-` en production | Schéma constaté `http` derrière le frontal | `NF__HTTP__TRUSTPROXY` |
125
- | La WebSocket se ferme seule au bout d'une minute | L'inactivité du frontal est plus courte que le battement | Régénérer le frontal ; sur un contrôleur d'entrée, `proxy-read-timeout` ≥ 300 s → [`references/frontal.md`](references/frontal.md) |
126
- | Un message temps réel n'arrive qu'à une partie des clients | Plusieurs exemplaires, bus local : chaque pod ne diffuse qu'à ses propres clients | `NF_REALTIME_DRIVER=redis` + `NF_REDIS_URL` → [`references/dimensionnement.md`](references/dimensionnement.md) |
127
- | Depuis l'activation de h2c, le pod n'est jamais prêt | La sonde vise le port h2c (5153), qui refuse HTTP/1.1 | Sonder le port HTTP (5151) |
128
- | Depuis l'activation de h2c, les WebSocket ne s'ouvrent plus | Le frontal les envoie au port h2c | Router `Upgrade: websocket` vers le port HTTP → [`references/frontal.md`](references/frontal.md) §8 |
129
- | `DB_SQLITE_FALLBACK` en production alors que la base est posée | Le nom de la variable est faux d'un caractère, ou le Secret n'est pas monté dans CE conteneur | `npx nodefony doctor --env production` nomme la variable lue (`NF_DATABASE_URL` ou `NF_DATABASE_URL_FILE`) |
130
- | Le pod est tué (`OOMKilled`) avec peu de connexions | La contre-pression : des clients lents retiennent jusqu'à 4 Mio chacun | Baisser `maxBackpressure` sur `websocket` **et** `websocketSecure` → [`references/dimensionnement.md`](references/dimensionnement.md) |
131
- | Toutes les connexions répondent `403` « doit passer par HTTPS » | Même cause : le mot de passe arrive sur une requête que l'application croit en clair | `NF__HTTP__TRUSTPROXY` — jamais `allowInsecureCredentials` |
132
- | Sortie **78** au démarrage, une clé ou une variable nommée | Faute de configuration : clé inconnue (ou retirée par une mise à jour), valeur invalide, secret requis absent (`NF_CSRF_SECRET`, `NF_JWT_KEYSET`) | Lire la ligne qui la nomme ; `npx nodefony doctor --env production` annonce AVANT les variables requises (pas `NF_JWT_KEYSET`, exigée seulement si l'app émet des jetons) |
133
- | Les journaux d'audit montrent tous la **même adresse** | L'adresse du client n'est pas propagée, ou le frontal n'est pas de confiance | `trustProxy` ; en Podman sans privilèges, **c'est irréparable** (§7) |
134
- | `EACCES` au premier démarrage sur un volume | Le volume est né `root:root` : monté **par-dessus** un dossier dont l'image avait fixé le porteur | Volume nommé (il hérite du porteur) ou groupe de système de fichiers |
135
- | La base refuse des connexions dès qu'on monte en exemplaires | Une base administrée les compte ; le pool n'est pas borné | Réduire les exemplaires — le plafond n'est pas encore réglable (§6) |
136
- | La sonde répond `429` | Ce n'est **pas** la sonde : c'est la limitation de débit — donc l'appel n'a pas pris le raccourci | Interroger `/livez` et `/readyz` nus, sans en-tête ni préfixe ajouté |
137
- | Un secret retiré du `Dockerfile` reste dans l'image | Une couche antérieure le contient encore ; l'effacer plus loin ne l'enlève pas | `npx nodefony image:check`, et reconstruire sans jamais l'avoir copié |
138
- | La variable posée « ne fait rien » | Le nom est faux d'un caractère — une variable inconnue est **ignorée**, pas refusée | `npx nodefony doctor` → [`references/variables.md`](references/variables.md) |
125
+ | Symptôme | Cause | Correction |
126
+ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
+ | L'exemplaire redémarre en boucle, journaux muets | La sonde de vivacité tombe avant la fin du démarrage | Sonde de démarrage sur `/readyz`, dimensionnée sur le **pire** cas |
128
+ | Page blanche derrière le frontal, console « Refused to execute… blocked by CSP » | Deux CSP : celle de l'application et une ajoutée au frontal | Retirer celle du frontal, ou `headers.csp: ""` dans l'application |
129
+ | Le conteneur sort en **137** à l'arrêt | Il a été tué : la fenêtre d'arrêt est ≤ au drain | Fenêtre ≥ 30 s (Podman : `--stop-timeout 20`, son défaut est **10 s**) |
130
+ | Des `5xx` **pendant** chaque déploiement | Le répartiteur envoie encore du trafic quand le drain a commencé | Un délai avant drain, et un court arrêt avant terminaison |
131
+ | Le cookie de session n'a pas le préfixe `__Host-` en production | Schéma constaté `http` derrière le frontal | `NF__HTTP__TRUSTPROXY` |
132
+ | La WebSocket se ferme seule au bout d'une minute | L'inactivité du frontal est plus courte que le battement | Régénérer le frontal ; sur un contrôleur d'entrée, `proxy-read-timeout` ≥ 300 s → [`references/frontal.md`](references/frontal.md) |
133
+ | Un message temps réel n'arrive qu'à une partie des clients | Plusieurs exemplaires, bus local : chaque pod ne diffuse qu'à ses propres clients | `NF_REALTIME_DRIVER=redis` + `NF_REDIS_URL` → [`references/dimensionnement.md`](references/dimensionnement.md) |
134
+ | Depuis l'activation de h2c, le pod n'est jamais prêt | La sonde vise le port h2c (5153), qui refuse HTTP/1.1 | Sonder le port HTTP (5151) |
135
+ | Depuis l'activation de h2c, les WebSocket ne s'ouvrent plus | Le frontal les envoie au port h2c | Router `Upgrade: websocket` vers le port HTTP → [`references/frontal.md`](references/frontal.md) §8 |
136
+ | `DB_SQLITE_FALLBACK` en production alors que la base est posée | Le nom de la variable est faux d'un caractère, ou le Secret n'est pas monté dans CE conteneur | `npx nodefony doctor --env production` nomme la variable lue (`NF_DATABASE_URL` ou `NF_DATABASE_URL_FILE`) |
137
+ | Le pod est tué (`OOMKilled`) avec peu de connexions | La contre-pression : des clients lents retiennent jusqu'à 4 Mio chacun | Baisser `maxBackpressure` sur `websocket` **et** `websocketSecure` → [`references/dimensionnement.md`](references/dimensionnement.md) |
138
+ | Toutes les connexions répondent `403` « doit passer par HTTPS » | Même cause : le mot de passe arrive sur une requête que l'application croit en clair | `NF__HTTP__TRUSTPROXY` — jamais `allowInsecureCredentials` |
139
+ | Sortie **78** au démarrage, une clé ou une variable nommée | Faute de configuration : clé inconnue (ou retirée par une mise à jour), valeur invalide, secret requis absent (`NF_CSRF_SECRET`, `NF_JWT_KEYSET`) | Lire la ligne qui la nomme ; `npx nodefony doctor --env production` annonce AVANT les variables requises (pas `NF_JWT_KEYSET`, exigée seulement si l'app émet des jetons) |
140
+ | Les journaux d'audit montrent tous la **même adresse** | L'adresse du client n'est pas propagée, ou le frontal n'est pas de confiance | `trustProxy` ; en Podman sans privilèges, **c'est irréparable** (§7) |
141
+ | `EACCES` au premier démarrage sur un volume | Le volume est né `root:root` : monté **par-dessus** un dossier dont l'image avait fixé le porteur | Volume nommé (il hérite du porteur) ou groupe de système de fichiers |
142
+ | La base refuse des connexions dès qu'on monte en exemplaires | Une base administrée les compte ; le pool n'est pas borné | Réduire les exemplaires — le plafond n'est pas encore réglable (§6) |
143
+ | La sonde répond `429` | Ce n'est **pas** la sonde : c'est la limitation de débit — donc l'appel n'a pas pris le raccourci | Interroger `/livez` et `/readyz` nus, sans en-tête ni préfixe ajouté |
144
+ | Un secret retiré du `Dockerfile` reste dans l'image | Une couche antérieure le contient encore ; l'effacer plus loin ne l'enlève pas | `npx nodefony image:check`, et reconstruire sans jamais l'avoir copié |
145
+ | La variable posée « ne fait rien » | Le nom est faux d'un caractère — une variable inconnue est **ignorée**, pas refusée | `npx nodefony doctor` → [`references/variables.md`](references/variables.md) |
139
146
 
140
147
  ## 6. Kubernetes — ce qui est rendu, et ce qui te reste
141
148