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

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 (32) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorateParam.js +1 -1
  3. package/dist/index.js +1 -1
  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/service/DevkitService.js +3 -3
  8. package/package.json +11 -11
  9. package/skills/nodefony-add-grpc-service/SKILL.md +179 -0
  10. package/skills/nodefony-add-grpc-service/scripts/call.mjs +218 -0
  11. package/skills/nodefony-add-realtime-binary/SKILL.md +301 -0
  12. package/skills/nodefony-add-realtime-binary/references/messages.md +20 -0
  13. package/skills/nodefony-add-realtime-binary/references/mesures.md +59 -0
  14. package/skills/nodefony-add-realtime-channel/SKILL.md +408 -326
  15. package/skills/nodefony-add-realtime-channel/references/contrat.md +22 -0
  16. package/skills/nodefony-add-realtime-channel/references/production.md +21 -2
  17. package/skills/nodefony-add-realtime-presence/SKILL.md +328 -0
  18. package/skills/nodefony-authentication/SKILL.md +2 -2
  19. package/skills/nodefony-browser/SKILL.md +11 -1
  20. package/skills/nodefony-browser/references/sondes.md +19 -0
  21. package/skills/nodefony-browser/scripts/audit.mjs +5 -2
  22. package/skills/nodefony-browser/scripts/inspect.mjs +69 -7
  23. package/skills/nodefony-browser/scripts/lib/actions.mjs +54 -3
  24. package/skills/nodefony-browser/scripts/lib/browser.mjs +18 -1
  25. package/skills/nodefony-browser/scripts/lib/probes.mjs +52 -3
  26. package/skills/nodefony-dev/SKILL.md +20 -15
  27. package/skills/nodefony-devops/references/dimensionnement.md +1 -1
  28. package/skills/nodefony-devops/references/frontal.md +5 -2
  29. package/skills/nodefony-devops/references/image.md +23 -15
  30. package/skills/nodefony-migrate-schema/references/verdicts.md +1 -0
  31. package/skills/nodefony-protect-route/SKILL.md +27 -32
  32. package/skills/nodefony-add-realtime-channel/references/binaire.md +0 -191
@@ -1,5 +1,31 @@
1
1
  import { USER, signInElsewhere } from "./browser.mjs";
2
2
 
3
+ /**
4
+ * Attend que le défilement se pose : la position de la fenêtre ne bouge plus
5
+ * pendant trois images (une molette peut défiler en douceur). Borné à une
6
+ * seconde environ — jamais une attente aveugle.
7
+ *
8
+ * @param {import("playwright").Page} page - la page pilotée.
9
+ */
10
+ async function settleScroll(page) {
11
+ await page.evaluate(
12
+ () =>
13
+ new Promise((resolve) => {
14
+ let last = window.scrollY;
15
+ let still = 0;
16
+ let frames = 0;
17
+ const tick = () => {
18
+ frames += 1;
19
+ still = window.scrollY === last ? still + 1 : 0;
20
+ last = window.scrollY;
21
+ if (still >= 3 || frames >= 60) resolve(undefined);
22
+ else requestAnimationFrame(tick);
23
+ };
24
+ requestAnimationFrame(tick);
25
+ }),
26
+ );
27
+ }
28
+
3
29
  /**
4
30
  * Joue une séquence d'interactions (`NF_BROWSER_ACTIONS`, analysée par
5
31
  * `parseActions`) sur une page — la seule implémentation, partagée par
@@ -28,14 +54,23 @@ export async function playActions(page, actions) {
28
54
  await signInElsewhere(page.context());
29
55
  continue;
30
56
  }
31
- if (verb === "defiler") {
57
+ if (verb === "defiler" || verb === "molette") {
32
58
  const pixels = Number.parseInt(target, 10);
33
59
  if (!Number.isFinite(pixels)) {
34
60
  return {
35
61
  code: 64, // EX_USAGE
36
- message: `defiler attend un nombre de pixels, reçu « ${target} »`,
62
+ message: `${verb} attend un nombre de pixels, reçu « ${target} »`,
37
63
  };
38
64
  }
65
+ if (verb === "molette") {
66
+ // Une VRAIE molette, là où est le pointeur (`survol:` avant) : c'est
67
+ // le navigateur qui choisit quoi faire défiler, et qui passe à la page
68
+ // quand le conteneur est en bout — ce que `defiler`, qui appelle
69
+ // `scrollBy` sur un élément choisi, ne peut pas montrer.
70
+ await page.mouse.wheel(0, pixels);
71
+ await settleScroll(page);
72
+ continue;
73
+ }
39
74
  // Le conteneur qui défile RÉELLEMENT, pas la fenêtre : sur une console à
40
75
  // barre latérale fixe, `window.scrollBy` ne bouge rien du tout.
41
76
  await page.evaluate((dy) => {
@@ -47,6 +82,12 @@ export async function playActions(page, actions) {
47
82
  }, pixels);
48
83
  continue;
49
84
  }
85
+ // Une touche sans cible part à l'élément qui a le focus (onglets,
86
+ // listes, menus : c'est lui que le clavier pilote).
87
+ if (verb === "touche" && target === "") {
88
+ await page.keyboard.press(value || "Enter");
89
+ continue;
90
+ }
50
91
  // Le premier candidat VISIBLE, pas le premier du DOM : un libellé apparaît
51
92
  // souvent d'abord dans un menu replié ou un gabarit caché, et agir là ne fait
52
93
  // rien tout en passant pour un succès.
@@ -83,7 +124,13 @@ export async function playActions(page, actions) {
83
124
  : candidates.first();
84
125
  }
85
126
  try {
86
- await locator.waitFor({ timeout: 15000 });
127
+ // Un champ de fichier est presque toujours CACHÉ derrière un bouton
128
+ // (trombone, zone de dépôt) : l'attendre visible échouerait sur un champ
129
+ // pourtant présent. Il suffit qu'il soit attaché au document.
130
+ await locator.waitFor({
131
+ state: verb === "deposer" ? "attached" : "visible",
132
+ timeout: 15000,
133
+ });
87
134
  if (verb === "clic") await locator.click();
88
135
  else if (verb === "double") await locator.dblclick();
89
136
  else if (verb === "droit") await locator.click({ button: "right" });
@@ -97,6 +144,10 @@ export async function playActions(page, actions) {
97
144
  if (chosen.length === 0) throw new Error(`option « ${value} » absente`);
98
145
  } else if (verb === "touche") await locator.press(value || "Enter");
99
146
  else if (verb === "voir") await locator.scrollIntoViewIfNeeded();
147
+ // `deposer` : le fichier passe par le champ lui-même, comme un choix dans
148
+ // la boîte du système — la page reçoit le même `change` qu'à la main. Un
149
+ // chemin relatif part du répertoire courant.
150
+ else if (verb === "deposer") await locator.setInputFiles(value);
100
151
  // `attendre` : le `waitFor` ci-dessus EST l'action.
101
152
  } catch {
102
153
  return {
@@ -15,6 +15,7 @@
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
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
+ * `@env` NF_BROWSER_REDUCED_MOTION préférence de mouvement émulée (reduce, no-preference) — `reduce` mesure une page à fondus au repos
18
19
  * `@env` NF_BROWSER_STORAGE entrées de stockage local posées AVANT chargement (`clé=valeur`, séparées par des virgules)
19
20
  */
20
21
  import { existsSync, mkdirSync, rmSync } from "node:fs";
@@ -26,6 +27,7 @@ import {
26
27
  browserOrder,
27
28
  sessionVerdict,
28
29
  parseColorScheme,
30
+ parseReducedMotion,
29
31
  parseStorage,
30
32
  parseViewport,
31
33
  } from "./probes.mjs";
@@ -193,6 +195,16 @@ if (invalidScheme) {
193
195
  );
194
196
  process.exit(64); // EX_USAGE
195
197
  }
198
+ const { motion: REDUCED_MOTION, invalid: invalidMotion } = parseReducedMotion(
199
+ process.env.NF_BROWSER_REDUCED_MOTION,
200
+ );
201
+ if (invalidMotion) {
202
+ console.error(
203
+ `NF_BROWSER_REDUCED_MOTION inconnu : « ${invalidMotion} ».\n` +
204
+ "Valeurs acceptées (celles de la média query standard) : reduce, no-preference.",
205
+ );
206
+ process.exit(64); // EX_USAGE
207
+ }
196
208
  const { entries: STORAGE, rejected: storageRejected } = parseStorage(
197
209
  process.env.NF_BROWSER_STORAGE,
198
210
  );
@@ -209,10 +221,13 @@ if (storageRejected.length > 0) {
209
221
  *
210
222
  * @param options.anonymous - ne reprendre AUCUN état d'authentification : la
211
223
  * séquence se connecte elle-même en route (`connecter:`).
224
+ * @param options.permissions - permissions accordées d'avance à toutes les
225
+ * origines (`notifications`…) : une page qui les DEMANDE les reçoit sans
226
+ * boîte de dialogue — un navigateur piloté n'en montre aucune.
212
227
  * @returns `{ browser, ctx, page, reuse }` — `reuse` dit si un état
213
228
  * d'authentification a été repris, ce qui décide s'il faut se connecter.
214
229
  */
215
- export async function open({ anonymous = false } = {}) {
230
+ export async function open({ anonymous = false, permissions = [] } = {}) {
216
231
  // On CONSTATE quel navigateur répond, au lieu de supposer lequel est là.
217
232
  //
218
233
  // `channel` demande un navigateur COMPLET plutôt que le
@@ -261,6 +276,8 @@ export async function open({ anonymous = false } = {}) {
261
276
  ? { isMobile: true, hasTouch: true, deviceScaleFactor: 2 }
262
277
  : {}),
263
278
  ...(COLOR_SCHEME ? { colorScheme: COLOR_SCHEME } : {}),
279
+ ...(REDUCED_MOTION ? { reducedMotion: REDUCED_MOTION } : {}),
280
+ ...(permissions.length > 0 ? { permissions } : {}),
264
281
  };
265
282
  let reuse = !anonymous && Boolean(USER) && existsSync(STATE);
266
283
  let ctx = null;
@@ -27,6 +27,8 @@ export const FAMILIES = Object.freeze({
27
27
  stockage: "cookies (attributs, jamais les valeurs) et Web Storage",
28
28
  responsive: "débordement horizontal à plusieurs largeurs d'écran",
29
29
  pli: "proportions — l'action principale visible sans défiler, la place du bloc principal dans l'écran",
30
+ notifications:
31
+ "notifications du navigateur — permission accordée, chaque `new Notification()` relevée (titre, corps, étiquette)",
30
32
  });
31
33
 
32
34
  /**
@@ -145,7 +147,7 @@ export function parseProbes(raw) {
145
147
  }
146
148
 
147
149
  /** Verbes qui prennent une VALEUR après la cible. Les autres n'en ont aucune. */
148
- const VERBS_WITH_VALUE = new Set(["saisir", "choisir"]);
150
+ const VERBS_WITH_VALUE = new Set(["saisir", "choisir", "touche", "deposer"]);
149
151
 
150
152
  /** Verbes reconnus d'une action, le premier étant celui par défaut. */
151
153
  export const ACTION_VERBS = [
@@ -155,9 +157,11 @@ export const ACTION_VERBS = [
155
157
  "survol",
156
158
  "saisir",
157
159
  "choisir",
160
+ "deposer",
158
161
  "touche",
159
162
  "voir",
160
163
  "defiler",
164
+ "molette",
161
165
  "attendre",
162
166
  "connecter",
163
167
  ];
@@ -190,8 +194,9 @@ export function signsInMidway(actions) {
190
194
  * s'arrêtait en annonçant un élément introuvable, ce qui envoyait chercher un
191
195
  * défaut dans la page. D'où deux règles :
192
196
  *
193
- * 1. seuls les verbes qui PRENNENT une valeur (`saisir`, `choisir`) découpent
194
- * sur `=` ;
197
+ * 1. seuls les verbes qui PRENNENT une valeur (`saisir`, `choisir`, `touche`,
198
+ * `deposer`)
199
+ * découpent sur `=` ;
195
200
  * pour tous les autres, la cible est le reste entier ;
196
201
  * 2. même pour ceux-là, la coupure ignore les `=` situés à l'intérieur de
197
202
  * crochets — `saisir:input[name=q]=bonjour` saisit bien « bonjour » dans
@@ -223,6 +228,11 @@ export function parseActions(raw) {
223
228
  else if (c === "]") depth = Math.max(0, depth - 1);
224
229
  else if (c === "=" && depth === 0) cut = i;
225
230
  }
231
+ // `touche:Enter` SANS `=` : le reste est la TOUCHE, frappée sur
232
+ // l'élément focalisé — pas un texte à chercher dans la page.
233
+ if (cut === -1 && verb === "touche") {
234
+ return { verb, target: "", value: rest.trim() };
235
+ }
226
236
  return cut === -1
227
237
  ? { verb, target: rest.trim(), value: "" }
228
238
  : {
@@ -256,6 +266,28 @@ export function parseColorScheme(raw) {
256
266
  return { schema: null, invalid: v };
257
267
  }
258
268
 
269
+ /**
270
+ * Lit la préférence de mouvement à émuler (`NF_BROWSER_REDUCED_MOTION`) — les
271
+ * valeurs de la média query standard `prefers-reduced-motion`.
272
+ *
273
+ * Pourquoi : une page qui fait apparaître ses sections au défilement (fondu lié
274
+ * à `animation-timeline: view()`) laisse hors de la vue des textes à demi
275
+ * transparents. `axe` mesure alors un contraste que personne ne lira jamais à
276
+ * l'écran — des rouges qui envoient chercher un défaut de palette. `reduce`
277
+ * coupe ces animations quand la page respecte la préférence, et la mesure
278
+ * porte alors sur le texte au repos.
279
+ *
280
+ * @param {string|undefined} raw - la valeur brute de la variable.
281
+ * @returns {{ motion: "reduce"|"no-preference"|null, invalid: string|null }}
282
+ */
283
+ export function parseReducedMotion(raw) {
284
+ const v = (raw ?? "").trim().toLowerCase();
285
+ if (!v) return { motion: null, invalid: null };
286
+ if (v === "reduce" || v === "no-preference")
287
+ return { motion: v, invalid: null };
288
+ return { motion: null, invalid: v };
289
+ }
290
+
259
291
  /**
260
292
  * Rend une expression JS fournie par l'utilisateur ÉVALUABLE dans la page —
261
293
  * une forme fonction (`() => x`, `function`, `async`) y est APPELÉE.
@@ -765,3 +797,20 @@ export function browserEngine(name) {
765
797
  return { engine: v, channel: undefined };
766
798
  return { engine: "chromium", channel: name.trim() };
767
799
  }
800
+
801
+ /**
802
+ * Le nom d'un fichier produit par une sonde : préfixe, instant à la seconde,
803
+ * puis le numéro du processus — deux sondes lancées dans la même seconde
804
+ * (mesures en parallèle) écrivaient le MÊME fichier, et la seconde capture
805
+ * effaçait la première sans un mot.
806
+ *
807
+ * @param {string} prefix - ce que montre le fichier (`demo-chat`, `lighthouse-…`)
808
+ * @param {Date} at - l'instant de la mesure
809
+ * @param {number} pid - le processus qui écrit
810
+ * @param {string} ext - l'extension, sans point
811
+ * @returns {string}
812
+ */
813
+ export function stampedName(prefix, at, pid, ext) {
814
+ const stamp = at.toISOString().replace(/[:.]/gu, "-").slice(0, 19);
815
+ return `${prefix}-${stamp}-${pid}.${ext}`;
816
+ }
@@ -20,8 +20,8 @@ metadata:
20
20
  # nodefony-dev — développer dans cette application sans rien inventer
21
21
 
22
22
  > **Maintenance** : vérité courante, jamais un journal. Éditer en place ; l'historique vit dans
23
- > `git log`. Ce fichier n'a **pas** de `references/` — et c'est une décision mesurée, pas un
24
- > oubli : sur 1020 transcripts d'agents lâchés dans une application générée, une page de
23
+ > `git log`. Ce fichier n'a qu'UNE page en `references/` (`rapport-de-build.md`, un rapport qu'on
24
+ > lit quand le build le désigne) — et c'est une décision mesurée : sur 1020 transcripts d'agents lâchés dans une application générée, une page de
25
25
  > `references/` a été ouverte **0 fois**, quand un script cité comme commande l'a été dans 80 %
26
26
  > des cas. Ce qui doit atteindre un agent s'EXÉCUTE ou vit dans ce corps ; ce qu'on déporterait
27
27
  > « pour alléger » n'atteindrait plus personne.
@@ -41,18 +41,22 @@ yeux. Ils ont simplement continué sans elle.
41
41
  Ces skills sont installés avec le framework et se chargent par leur nom. Chacun porte les pièges
42
42
  de son geste, et ceux-là ne se devinent pas — ils se paient.
43
43
 
44
- | Ce que tu t'apprêtes à faire | Le skill |
45
- | -------------------------------------------------------------------- | ------------------------------- |
46
- | Une ressource : table, validation, service, REST et WebSocket | `nodefony-add-crud` |
47
- | Une logique métier réutilisable, hors de tout controller | `nodefony-add-service` |
48
- | Un flux temps réel (WebSocket ou SSE), un canal, un abonnement | `nodefony-add-realtime-channel` |
49
- | Se connecter : /login, second facteur, retour après connexion, jeton | `nodefony-authentication` |
50
- | Réserver une route ou une page à des rôles, ouvrir à un partenaire | `nodefony-protect-route` |
51
- | Changer une entité déjà en base, ou déployer un schéma changé | `nodefony-migrate-schema` |
52
- | Conclure quoi que ce soit d'un ÉCRAN — affichage, contraste, console | `nodefony-browser` |
53
- | Quelque chose qui marchait ne marche plus : démarrage, 404, journal | `nodefony-troubleshoot` |
54
- | Relire la sécurité de ton diff AVANT de commiter | `nodefony-security-review` |
55
- | Monter Nodefony de version, ou une montée qui a laissé des rouges | `nodefony-upgrade` |
44
+ | Ce que tu t'apprêtes à faire | Le skill |
45
+ | ------------------------------------------------------------------------- | -------------------------------- |
46
+ | Une ressource : table, validation, service, REST et WebSocket | `nodefony-add-crud` |
47
+ | Une logique métier réutilisable, hors de tout controller | `nodefony-add-service` |
48
+ | Du temps réel : chat, canal, notification, jeu, sondes, tableau de bord | `nodefony-add-realtime-channel` |
49
+ | Afficher qui est en ligne, combien de présents, « en train d'écrire » | `nodefony-add-realtime-presence` |
50
+ | Des octets en temps réel : image, audio, mesures binaires, CBOR | `nodefony-add-realtime-binary` |
51
+ | Exposer ou appeler un service gRPC | `nodefony-add-grpc-service` |
52
+ | Se connecter : /login, second facteur, retour après connexion, jeton | `nodefony-authentication` |
53
+ | Réserver une route ou une page à des rôles, ouvrir à un partenaire | `nodefony-protect-route` |
54
+ | Changer une entité déjà en base, ou déployer un schéma changé | `nodefony-migrate-schema` |
55
+ | Mettre en production : image, conteneur, variables, santé, plusieurs pods | `nodefony-devops` |
56
+ | Conclure quoi que ce soit d'un ÉCRAN — affichage, contraste, console | `nodefony-browser` |
57
+ | Quelque chose qui marchait ne marche plus : démarrage, 404, journal | `nodefony-troubleshoot` |
58
+ | Relire la sécurité de ton diff AVANT de commiter | `nodefony-security-review` |
59
+ | Monter Nodefony de version, ou une montée qui a laissé des rouges | `nodefony-upgrade` |
56
60
 
57
61
  Aucune ligne ne correspond ? Alors seulement, continue ici. Et si ta tâche est EXACTEMENT celle
58
62
  d'un spécialiste, va droit à lui : il n'y a pas à passer par cette page d'abord.
@@ -344,7 +348,8 @@ ont leur skill, qui part du symptôme : **`nodefony-troubleshoot`**.
344
348
  par requête = `overlayConfig()` (clés de la liste blanche du module), lue par `useConfig()`.
345
349
  - **Tu lis une liste sans la BORNER.** Un `find` sans limite matérialise la table entière —
346
350
  indolore sur les quelques lignes du poste de développement, fatal sur les dizaines de milliers
347
- de la production. Le service d'une entité hérite `findPage({ limit: 25 })`.
351
+ de la production. Le service d'une entité hérite `findPage({ limit })` — `limit` est OBLIGATOIRE,
352
+ aucun défaut ne le pose pour toi : écris-le (`findPage({ limit: 25 })`).
348
353
 
349
354
  ### Ce qui tue le processus, pas la requête
350
355
 
@@ -22,7 +22,7 @@ file, et la latence monte.
22
22
  Mesurées sur Nodefony **10.0.0**, en **production**, Node 26.10, **un** process, route de banc
23
23
  **triviale**, charge de 1 Ko. Chaque débit est ramené à ELU 1 (débit observé ÷ ELU), donc c'est un
24
24
  **plafond**. Source et calculateur :
25
- [peut-on partir en production ?](https://nodefony.github.io/nodefony-core/performance/latest/).
25
+ [peut-on partir en production ?](https://nodefony.net/performance/latest/).
26
26
 
27
27
  | Constante | Valeur | Ce qu'elle dit |
28
28
  | ------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------- |
@@ -207,9 +207,12 @@ Avec `--reencrypt`, h2c est ignoré : le port TLS négocie déjà HTTP/2. **C'es
207
207
  contre un frontal réel** (HAProxy 3.0, configuration générée) : requête servie en h2c, sonde lue
208
208
  sur le port HTTP, WebSocket ouverte sur le port HTTP.
209
209
 
210
- ### nginx — reste en HTTP/1.1 vers l'application
210
+ ### nginx — HTTP/1.1 vers l'application, gRPC vers h2c
211
211
 
212
- nginx ne parle pas HTTP/2 à ses amonts (hors gRPC) : `proxy:generate nginx` ignore h2c.
212
+ nginx ne parle HTTP/2 à ses amonts que par `grpc_pass`. Avec `servers.h2c` actif,
213
+ `proxy:generate nginx` écoute en HTTP/2 et envoie les seuls appels gRPC (`content-type:
214
+ application/grpc…`) au port h2c ; le reste, gRPC-Web compris, garde le port HTTP en HTTP/1.1.
215
+ Re-chiffrement demandé : pas de lien h2c, la configuration le dit en commentaire.
213
216
 
214
217
  ### Envoy
215
218
 
@@ -7,12 +7,13 @@ du frontal ne sort que si tu le demandes explicitement.
7
7
 
8
8
  ## 1. La carte
9
9
 
10
- | Étage | Base | Ce qu'il fait | Descend dans l'image finale ? |
11
- | ----------- | ------------------- | --------------------------------------------------------------------- | ----------------------------- |
12
- | `build` | `node:24-alpine` | Installe, compile, élague les dépendances de développement | seulement son `/app` |
13
- | `proxyconf` | hérite de `build` | **Dérive** la configuration nginx et rassemble les fichiers statiques | non — étage intermédiaire |
14
- | `edge` | `nginx:1.30-alpine` | Le frontal, avec la configuration dérivée ci-dessus | non — cible séparée |
15
- | _(final)_ | `node:24-alpine` | L'application en production | **oui** |
10
+ | Étage | Base | Ce qu'il fait | Descend dans l'image finale ? |
11
+ | ----------- | ------------------------------------- | ----------------------------------------------------------------------- | ----------------------------- |
12
+ | `build` | `node:24-slim` | Installe, compile, élague les dépendances de développement | seulement son `/app` |
13
+ | `proxyconf` | hérite de `build` | **Dérive** la configuration nginx et rassemble les fichiers statiques | non — étage intermédiaire |
14
+ | `edge` | `nginx:1.30-alpine` | Le frontal, avec la configuration dérivée ci-dessus | non — cible séparée |
15
+ | `nf-dirs` | `node:24-slim` | Crée `tmp/` et `var/` vides — distroless n'a pas de shell pour le faire | seulement ces deux dossiers |
16
+ | _(final)_ | `gcr.io/distroless/nodejs24-debian13` | L'application en production — ni shell, ni npm, `node` hors du `PATH` | **oui** |
16
17
 
17
18
  ```bash
18
19
  docker build -t <app> . # l'application
@@ -27,7 +28,7 @@ docker build -t <app>-edge --target edge . # le frontal, seulement si tu le ve
27
28
  | `USER` | **`1000:1000`** — numérique, donc reportable tel quel dans un `runAsUser` |
28
29
  | `ENV` | `NODE_ENV=production` |
29
30
  | `EXPOSE` | `5151` |
30
- | `CMD` | `["node_modules/.bin/nodefony", "production"]` |
31
+ | `CMD` | `["node_modules/nodefony/bin/nodefony", "production"]` |
31
32
  | `HEALTHCHECK` | `/readyz`, intervalle 10 s, délai 2 s, **période de démarrage 20 s**, 3 essais |
32
33
 
33
34
  **`node` est le process n° 1.** Il n'y a pas d'`ENTRYPOINT` d'init : le signal `SIGTERM` arrive
@@ -71,14 +72,21 @@ Sans eux l'image se construit quand même — avec des étiquettes vides. Une im
71
72
 
72
73
  ## 3. Changer la base — c'est prévu, et c'est écrit dans le fichier
73
74
 
74
- Le gabarit porte deux alternatives en commentaire, avec leur mode d'emploi. La règle commune :
75
- **remplacer les DEUX `FROM`, jamais un seul** — un étage de compilation `alpine` et une exécution
76
- `slim` produisent des binaires natifs qui ne se chargeront pas.
77
-
78
- | Tu veux… | Bascule | Ce qu'il faut aussi changer |
79
- | ----------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------- |
80
- | Un paquet natif qui ne publie **pas** de binaire musl | `node:24-slim` (glibc) sur les deux `FROM` | rien |
81
- | La surface d'attaque minimale | `gcr.io/distroless/nodejs24-debian12` en finale, `slim` en build | **`CMD` et `HEALTHCHECK` en `/nodejs/bin/node`** — pas de shell |
75
+ L'image finale est distroless `debian13` : aucune vulnérabilité critique ni haute, Node à jour. Le
76
+ gabarit porte en commentaire comment passer à une base À SHELL, avec son mode d'emploi. La règle
77
+ commune : **l'étage de construction et l'étage final partagent la même libc** — une compilation
78
+ `alpine` (musl) et une exécution glibc produisent des binaires natifs qui ne se chargeront pas.
79
+
80
+ | Tu veux… | Bascule | Ce qu'il faut aussi changer |
81
+ | ---------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------ |
82
+ | Un `apk add` dans l'image finale | `node:24-alpine` sur les deux `FROM` | retirer l'étage `nf-dirs`, remettre le `RUN mkdir … && chown`, sonde en `node` |
83
+ | Un shell glibc (`bash`, `apt-get`) | `node:24-slim` sur les deux `FROM` | idem |
84
+
85
+ Sous distroless, rien ne lit un shebang : `node` n'est pas sur le `PATH` et il n'y a pas de
86
+ `/usr/bin/env`. L'`ENTRYPOINT` de l'image est `/nodejs/bin/node`, donc une commande se change par ses
87
+ **arguments** — `docker run <image> node_modules/nodefony/bin/nodefony …`, `args:` en Kubernetes (jamais
88
+ `command:`, qui remplacerait l'`ENTRYPOINT`), `command:` dans compose. Un diagnostic :
89
+ `docker exec <conteneur> /nodejs/bin/node …`.
82
90
 
83
91
  Le symptôme d'un mélange musl/glibc est franc : un `.node` introuvable, ou `Error relocating`, **au
84
92
  démarrage** — jamais à la construction. L'image se construit parfaitement et ne démarre pas.
@@ -130,6 +130,7 @@ n'a jamais les deux — c'est le discriminant à tester.
130
130
  | `NF_GENERATE_MISSING_ENTITY` | une entité est enregistrée sans fichier qui la fournisse | `nodefony inspect entities --json` pour la situer |
131
131
  | `NF_GENERATE_UNREADABLE_ENTITY` | rien d'écrit, et un fichier d'entités ne s'importe pas : sa table n'est vue par personne | corriger la cause nommée (souvent un paquet absent des dépendances), ou sortir le fichier de `nodefony/entity/` |
132
132
  | `NF_GENERATE_FRAMEWORK_TABLE` | une entité de l'application usurpe une table du framework | la renommer, ou écrire une migration libre (`--custom`) |
133
+ | `NF_GENERATE_DEV_MODULE_TABLE` | un module `policy: "dev"` lie ses tables au connecteur généré : elles iraient en base de production | lui donner un connecteur à lui, sans migration ; ou retirer sa politique `dev` |
133
134
  | `NF_GENERATE_DESTRUCTIVE` | la migration ÉCRITE supprime des données | relire le fichier, puis annuler (`git checkout`) ou appliquer — la mise en service a sa propre garde |
134
135
  | `NF_GENERATE_DATABASE_BEHIND` | rien à écrire, RIEN n'attend, et pourtant la base ne porte pas le schéma | `repair --forget <source>/<tag>` : l'historique affirme une migration jamais exécutée |
135
136
  | `NF_GENERATE_TOOL_MISSING` | l'outil de génération n'est pas installé | l'installer en dépendance de développement |
@@ -245,10 +245,10 @@ Un service partenaire, un script, un agent ne stockent aucun cookie. Le
245
245
  `nodefony/config/security.ts` généré (preset `complete`) pose **deux zones, une par sorte de
246
246
  preuve** — choisis selon ce que l'appelant détient, n'en ajoute pas une troisième :
247
247
 
248
- | L'appelant présente | Zone | Préfixe | Obtenir la preuve |
249
- | ---------------------------------------------- | --------- | -------------- | --------------------------------------- |
250
- | une **clé d'API** opaque (`nf_…`) | `machine` | `/api/machine` | `POST /nodefony/security/api/keys` |
251
- | un **jeton d'accès** (de l'app ou de Keycloak) | `partner` | `/api/partner` | `npx nodefony app:partner:call` le fait |
248
+ | L'appelant présente | Zone | Préfixe | Obtenir la preuve |
249
+ | ------------------------------------ | --------- | -------------- | ---------------------------------- |
250
+ | une **clé d'API** opaque (`nf_…`) | `machine` | `/api/machine` | `POST /nodefony/security/api/keys` |
251
+ | un **jeton d'accès** signé par l'app | `service` | `/api/service` | émis par l'app (voir la démo) |
252
252
 
253
253
  ### Clé d'API : la zone `machine`
254
254
 
@@ -275,27 +275,30 @@ qui ne stocke pas de cookie ne doit rien recevoir qu'il faille stocker.**
275
275
 
276
276
  Les clés s'émettent par `POST /nodefony/security/api/keys`.
277
277
 
278
- ### Jeton d'accès : la zone `partner`, et la commande qui l'éprouve
278
+ ### Jeton d'accès : la zone `service`
279
279
 
280
- La zone accepte un jeton signé par l'application (`jwt`) **et** un jeton de Keycloak
281
- (`external-jwt`) ; les deux s'aiguillent sur l'émetteur, leur ordre ne décide de rien. Une route
282
- qui tombe sous `/api/partner` est protégée sans rien ajouter. L'exemple qui marche est déjà dans
283
- l'application — **lis-le avant d'écrire un appelant** :
284
-
285
- ```bash
286
- npx nodefony app:partner:call # jeton signé par l'app pour `admin` (--as <compte>)
287
- npx nodefony app:partner:call --issuer keycloak # client <app>-machine, flux client_credentials
280
+ ```ts
281
+ service: {
282
+ pattern: "^/api/service",
283
+ authenticators: ["jwt"], // jeton signé par CETTE application
284
+ stateless: true,
285
+ }
288
286
  ```
289
287
 
290
- Source : `nodefony/command/PartnerCallCommand.ts` (la commande) et
291
- `nodefony/controllers/PartnerController.ts` (`GET /api/partner/whoami`, qui rend l'identité ET
292
- l'émetteur). Un jeton Keycloak s'obtient par `requestClientCredentialsToken` de
293
- `@nodefony/security` — découverte du point de jeton comprise —, jamais par un `fetch` recopié.
288
+ Une route qui tombe sous `/api/service` est protégée sans rien ajouter. L'application générée
289
+ n'y met AUCUN controller : l'exemple complet — émission du jeton, appel, et la variante Keycloak
290
+ (`client_credentials`) — vit dans la démo, qu'on copie dans l'application pour la lire.
291
+
292
+ **Accepter aussi les jetons d'un fournisseur** (Keycloak) : ajouter `"external-jwt"` à la zone ;
293
+ `jwt` et `external-jwt` s'aiguillent sur l'émetteur du jeton, leur ordre ne décide de rien. Un jeton
294
+ Keycloak s'obtient par `requestClientCredentialsToken` de `@nodefony/security` — découverte du point
295
+ de jeton comprise —, jamais par un `fetch` recopié.
294
296
 
295
297
  🔴 **L'audience vit en UN endroit.** Un jeton n'ouvre que la zone dont il porte la ressource
296
- (RFC 8707). `partnerResource(ctx.env)` est lue par la zone (`resource`), par `jwt.audiences` et par
297
- la commande. Une ressource recopiée à la main finit par différer, et le seul symptôme est un refus
298
- sans explication. Ouvrir une AUTRE ressource à des jetons, c'est trois gestes, pas un :
298
+ (RFC 8707). Écris-la UNE fois (une fonction de `nodefony/config/security.ts`, sur le modèle de
299
+ `publicBaseUrl`), lue par la zone (`resource`) et par `jwt.audiences`. Une ressource recopiée à la
300
+ main finit par différer, et le seul symptôme est un refus sans explication. Ouvrir une ressource
301
+ à des jetons, c'est trois gestes, pas un :
299
302
 
300
303
  | Oublié | Symptôme |
301
304
  | ----------------------------------------- | ------------------------------------------------------------- |
@@ -303,19 +306,11 @@ sans explication. Ouvrir une AUTRE ressource à des jetons, c'est trois gestes,
303
306
  | `security:keycloak:realm --write` relancé | **401** du jeton Keycloak : le realm n'inscrit pas l'audience |
304
307
  | la `resource` sur la zone | l'application **refuse de démarrer** en nommant la zone |
305
308
 
306
- 🔴 **`subjectPolicy: "ephemeral"` s'écrit SUR LA ZONE, jamais dans `resourceServer`.** Le client
309
+ 🔴 **`subjectPolicy: "ephemeral"` s'écrit SUR LA ZONE, jamais dans `resourceServer`.** Un client
307
310
  machine de Keycloak n'a pas de compte ici : `ephemeral` l'accepte le temps de la requête, avec les
308
- seuls `ephemeralRoles` de la zone (`ROLE_PARTNER`). Posé dans `resourceServer.subjectPolicy`
309
- (global), il ferait accepter un appelant sans compte par **toutes** les zones `external-jwt`. Et
310
- un rôle fort dans `ephemeralRoles` est accordé à **quiconque** détient un jeton valide du realm.
311
-
312
- `whoami` n'exige pas `ROLE_PARTNER` — un compte de l'app ne l'a pas. Pour réserver une action aux
313
- partenaires : `@IsGranted("ROLE_PARTNER")`.
314
-
315
- **Prouver** : `--issuer nodefony` rend 200 sans Keycloak ; `--issuer keycloak` rend 200 avec le
316
- profil `keycloak` du compose allumé ; et le MÊME jeton machine est refusé (401) sur une zone qui
317
- garde `subjectPolicy` par défaut (`require`). Chaque échec de la commande sort en code 1 avec le
318
- geste qui répare — lis-le avant de chercher ailleurs.
311
+ seuls `ephemeralRoles` de la zone. Posé dans `resourceServer.subjectPolicy` (global), il ferait
312
+ accepter un appelant sans compte par **toutes** les zones `external-jwt`. Et un rôle fort dans
313
+ `ephemeralRoles` est accordé à **quiconque** détient un jeton valide du realm.
319
314
 
320
315
  ## Les gestes qui affaiblissent en silence
321
316