@nodefony/devkit 10.0.0-beta.1 → 10.0.0-beta.3

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 (37) hide show
  1. package/README.md +27 -12
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateParam.js +1 -1
  5. package/dist/index.js +2 -2
  6. package/dist/nodefony/controllers/DevkitController.js +2 -2
  7. package/dist/nodefony/controllers/McpController.js +71 -6
  8. package/dist/nodefony/service/DevkitService.js +2 -2
  9. package/dist/nodefony/src/card.js +1 -2
  10. package/dist/types/nodefony/controllers/McpController.d.ts +12 -1
  11. package/docs/index.md +63 -15
  12. package/package.json +9 -9
  13. package/skills/nodefony-add-realtime-channel/SKILL.md +66 -9
  14. package/skills/nodefony-add-service/SKILL.md +11 -0
  15. package/skills/nodefony-authentication/SKILL.md +234 -0
  16. package/skills/nodefony-browser/SKILL.md +45 -21
  17. package/skills/nodefony-browser/references/sondes.md +13 -0
  18. package/skills/nodefony-browser/scripts/audit.mjs +59 -10
  19. package/skills/nodefony-browser/scripts/inspect.mjs +122 -4
  20. package/skills/nodefony-browser/scripts/lib/browser.mjs +112 -6
  21. package/skills/nodefony-browser/scripts/lib/probes.mjs +166 -7
  22. package/skills/nodefony-dev/SKILL.md +56 -81
  23. package/skills/nodefony-dev/scripts/docs.mjs +21 -5
  24. package/skills/nodefony-devops/SKILL.md +16 -13
  25. package/skills/nodefony-devops/references/compose.md +8 -0
  26. package/skills/nodefony-devops/references/frontal.md +5 -1
  27. package/skills/nodefony-devops/references/image.md +1 -1
  28. package/skills/nodefony-devops/references/secrets.md +18 -4
  29. package/skills/nodefony-devops/references/variables.md +52 -19
  30. package/skills/nodefony-migrate-schema/SKILL.md +5 -2
  31. package/skills/nodefony-migrate-schema/references/verdicts.md +20 -19
  32. package/skills/nodefony-protect-route/SKILL.md +118 -17
  33. package/skills/nodefony-security-review/SKILL.md +127 -0
  34. package/skills/nodefony-security-review/scripts/review-diff.mjs +1090 -0
  35. package/skills/nodefony-troubleshoot/SKILL.md +151 -0
  36. package/skills/nodefony-troubleshoot/scripts/log-errors.mjs +465 -0
  37. package/skills/nodefony-upgrade/SKILL.md +130 -0
package/README.md CHANGED
@@ -75,10 +75,13 @@ navigateur — d'où la commande, qui reste la porte utile.
75
75
  npx nodefony ai:mcp # écrit .mcp.json ; --dry-run pour voir sans écrire
76
76
  ```
77
77
 
78
- Quatre outils : **`nodefony_inspect`** (ce qui est monté), **`nodefony_check`**
79
- (ce qui manque), **`nodefony_symbols`** (ce qu'une API du framework signifie),
80
- **`nodefony_card`** (par où commencer). Ce sont les mêmes briques que les
81
- commandes du même nom — une source, plusieurs portes.
78
+ Sept outils : **`nodefony_inspect`** (ce qui est monté), **`nodefony_check`**
79
+ (ce qui manque — la commande s'appelle `doctor`), **`nodefony_symbols`** (ce qu'une
80
+ API du framework signifie), **`nodefony_card`** (par où commencer),
81
+ **`nodefony_docs`** (la documentation des modules installés), **`nodefony_admin_list`**
82
+ et **`nodefony_admin_call`** (lectures du data plane d'administration, jeton porteur
83
+ du scope `admin:read` exigé). Ce sont les mêmes briques que les commandes — une
84
+ source, plusieurs portes.
82
85
 
83
86
  **Ce n'est pas un process de plus.** Depuis la révision `2026-07-28` du transport
84
87
  « Streamable HTTP », un serveur MCP est un simple endpoint `POST` sans session :
@@ -162,7 +165,7 @@ connaît pas encore.
162
165
 
163
166
  ### Vos propres outils
164
167
 
165
- Ces quatre-là décrivent le framework ; ils ne savent rien de votre métier. Tout
168
+ Ces outils intégrés décrivent le framework ; ils ne savent rien de votre métier. Tout
166
169
  module de votre application peut publier les siens en implémentant
167
170
  `getMcpTools(): IMcpTool[]` :
168
171
 
@@ -203,12 +206,13 @@ c'est fermé par défaut, et c'est voulu. Détail et pièges :
203
206
 
204
207
  ## Les skills d'agent
205
208
 
206
- Le paquet livre six **skills** au format [Agent Skills](https://agentskills.io)
209
+ Le paquet livre huit **skills** au format [Agent Skills](https://agentskills.io)
207
210
  — la marche à suivre complète pour les tâches où un agent, sans eux, inventerait
208
211
  du code : créer une ressource REST, ajouter un service injectable, réserver une
209
212
  route à qui est habilité, ouvrir un canal temps réel, faire évoluer le schéma
210
- d'une base sans la détruire, et voir puis MESURER un écran dans un navigateur
211
- piloté. Ils sont lus par tout client conforme (Claude Code, Cursor, Copilot,
213
+ d'une base sans la détruire, voir puis MESURER un écran dans un navigateur
214
+ piloté — plus un skill qui conduit toute tâche de développement et un qui décrit
215
+ l'exploitation en conteneur. Ils sont lus par tout client conforme (Claude Code, Cursor, Copilot,
212
216
  VS Code, Codex, Goose…).
213
217
 
214
218
  `nodefony create app` les met à disposition à la création. Après un
@@ -224,11 +228,17 @@ npx nodefony ai:sync # --dry-run pour voir sans écrire, --json pour
224
228
  = nodefony-add-crud @nodefony/devkit
225
229
  = nodefony-add-realtime-channel @nodefony/devkit
226
230
  = nodefony-add-service @nodefony/devkit
231
+ = nodefony-authentication @nodefony/devkit
227
232
  = nodefony-browser @nodefony/devkit
233
+ = nodefony-dev @nodefony/devkit
234
+ = nodefony-devops @nodefony/devkit
228
235
  = nodefony-migrate-schema @nodefony/devkit
229
236
  = nodefony-protect-route @nodefony/devkit
237
+ = nodefony-security-review @nodefony/devkit
238
+ = nodefony-troubleshoot @nodefony/devkit
239
+ = nodefony-upgrade @nodefony/devkit
230
240
 
231
- 0 posé(s) · 0 mis à jour · 6 inchangé(s)
241
+ 0 posé(s) · 0 mis à jour · 12 inchangé(s)
232
242
  ```
233
243
 
234
244
  **Le préfixe `nodefony-` vous laisse la place.** Ces pointeurs arrivent dans
@@ -262,9 +272,14 @@ plus aucun paquet ne livre est **signalé, jamais supprimé** — vous avez pu e
262
272
 
263
273
  ## Configuration
264
274
 
265
- | Clé | Type | Défaut | Rôle |
266
- | --------- | --------- | ------ | ---------------------- |
267
- | `enabled` | `boolean` | `true` | Interrupteur du module |
275
+ | Clé | Type | Défaut | Rôle |
276
+ | -------------------- | ---------- | --------------- | ----------------------------------------------------- |
277
+ | `enabled` | `boolean` | `true` | Interrupteur du module |
278
+ | `mcp.enabled` | `boolean` | `true` | Interrupteur de la porte MCP |
279
+ | `mcp.allowedOrigins` | `string[]` | `[]` | Origines navigateur admises sur la porte MCP |
280
+ | `mcp.allowRemote` | `boolean` | `false` | Accepte un client hors de la boucle locale |
281
+ | `mcp.tools` | `string[]` | les sept outils | Outils exposés |
282
+ | `mcp.authorization` | objet | — | Autorisation OAuth de la porte (voir `docs/index.md`) |
268
283
 
269
284
  La source unique est le schéma Zod de `nodefony/config/config.ts` : c'est lui qui
270
285
  porte les défauts, les descriptions et la validation. Une clé inconnue ou mal
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorate.js
1
+ //#region \0@oxc-project+runtime@0.153.0/helpers/esm/decorate.js
2
2
  function __decorate(decorators, target, key, desc) {
3
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorateMetadata.js
1
+ //#region \0@oxc-project+runtime@0.153.0/helpers/esm/decorateMetadata.js
2
2
  function __decorateMetadata(k, v) {
3
3
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
4
4
  }
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorateParam.js
1
+ //#region \0@oxc-project+runtime@0.153.0/helpers/esm/decorateParam.js
2
2
  function __decorateParam(paramIndex, decorator) {
3
3
  return function(target, key) {
4
4
  decorator(target, key, paramIndex);
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import defaults, { devkitConfigSchema } from "./nodefony/config/config.js";
2
2
  import { defineDevkitConfig } from "./nodefony/config/defineModuleConfig.js";
3
3
  import { buildCard } from "./nodefony/src/card.js";
4
- import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
5
- import __decorate from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
4
+ import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
5
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
6
6
  import DevkitService_default from "./nodefony/service/DevkitService.js";
7
7
  import DevkitController_default from "./nodefony/controllers/DevkitController.js";
8
8
  import McpController_default from "./nodefony/controllers/McpController.js";
@@ -1,5 +1,5 @@
1
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
2
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
1
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
2
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
3
3
  import { Controller, controller, route } from "@nodefony/framework";
4
4
  //#region nodefony/controllers/DevkitController.ts
5
5
  /**
@@ -1,10 +1,15 @@
1
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
2
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
3
- import __decorateParam from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateParam.js";
4
- import { ACCESS_TOKEN_VERIFIER, JsonRpcError, Nodefony, authorizeProtectedResource, checkMcpAccess, collectMcpTools, handleMcpMessage, jsonRpcFailure, mcpCallerRoles, protectedResourceMetadataUrl, readBearerHeader } from "nodefony";
1
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
2
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
3
+ import __decorateParam from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateParam.js";
4
+ import { ACCESS_TOKEN_VERIFIER, JsonRpcError, Nodefony, authorizeProtectedResource, checkMcpAccess, collectMcpTools, createMcpResponseStream, handleMcpMessage, jsonRpcFailure, mcpCallerRoles, protectedResourceMetadataUrl, readBearerHeader } from "nodefony";
5
5
  import { Body, Controller, Headers, controller, route } from "@nodefony/framework";
6
6
  //#region nodefony/controllers/McpController.ts
7
7
  /**
8
+ * Plafond d'un événement sur le flux de réponse MCP, octets UTF-8 — celui de la
9
+ * réponse finale, qui porte le résultat entier de l'outil (16 Mio).
10
+ */
11
+ const MCP_STREAM_MAX_EVENT_BYTES = 16777216;
12
+ /**
8
13
  * Serveur **Model Context Protocol** de l'application — la porte par laquelle
9
14
  * un agent externe l'interroge.
10
15
  *
@@ -99,6 +104,29 @@ let McpController = class McpController extends Controller {
99
104
  return read.kind === "token" && read.token.startsWith("${") && read.token.endsWith("}");
100
105
  }
101
106
  /**
107
+ * Le corps reçu est-il un texte qui ne se PARSE pas en JSON ?
108
+ *
109
+ * Le parseur HTTP ignore un JSON malformé et laisse `@Body()` à `{}` : seul
110
+ * le brut (`request.data`) permet alors de distinguer un `{}` envoyé d'un
111
+ * texte illisible. Relu uniquement quand le corps n'a aucune clé.
112
+ *
113
+ * @param body - corps tel que `@Body()` le rend
114
+ * @returns `true` si le texte reçu n'est pas du JSON
115
+ */
116
+ #unparsableBody(body) {
117
+ if (body === null || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length > 0) return false;
118
+ const request = this.request;
119
+ if (!request || !("data" in request)) return false;
120
+ const text = request.data.toString(request.charset).trim();
121
+ if (text === "") return false;
122
+ try {
123
+ JSON.parse(text);
124
+ return false;
125
+ } catch {
126
+ return true;
127
+ }
128
+ }
129
+ /**
102
130
  * `POST /nodefony/mcp` — un message JSON-RPC entre, une réponse sort.
103
131
  *
104
132
  * Les trois statuts que rend cette route sont ceux que la spec impose, et pas
@@ -121,7 +149,8 @@ let McpController = class McpController extends Controller {
121
149
  this.log(`MCP refusé — ${verdict.why}`, "WARNING");
122
150
  return this.renderJson(jsonRpcFailure(null, JsonRpcError.INVALID_REQUEST, "origine refusée"), 403);
123
151
  }
124
- if (body === null || typeof body !== "object") return this.renderJson(jsonRpcFailure(null, JsonRpcError.PARSE_ERROR, "corps attendu : un message JSON-RPC"), 400);
152
+ if (this.#unparsableBody(body)) return this.renderJson(jsonRpcFailure(null, JsonRpcError.PARSE_ERROR, "corps illisible : JSON invalide"), 400);
153
+ if (body === null || typeof body !== "object") return this.renderJson(jsonRpcFailure(null, JsonRpcError.INVALID_REQUEST, "corps attendu : un message JSON-RPC (objet)"), 400);
125
154
  const authz = settings.authorization;
126
155
  const gateProtected = settings.authorization.authorizationServers.length > 0;
127
156
  let caller = {
@@ -186,23 +215,50 @@ let McpController = class McpController extends Controller {
186
215
  },
187
216
  deps: this.#service().mcpToolDeps()
188
217
  });
218
+ const context = this.context;
219
+ const stream = createMcpResponseStream({
220
+ signal: context?.signal,
221
+ open: this.acceptsSse() ? () => this.renderSse({ maxEventBytes: MCP_STREAM_MAX_EVENT_BYTES }) : void 0,
222
+ onError: (error, final) => this.log(`MCP — ${final ? "réponse finale" : "notification"} perdue sur le flux : ${error instanceof Error ? error.message : String(error)}`, final ? "WARNING" : "DEBUG")
223
+ });
189
224
  const reply = await handleMcpMessage(body, {
190
225
  tools,
191
226
  caller,
192
227
  withheldCount,
228
+ transport: stream.transport,
193
229
  serverInfo: {
194
230
  name: kernel?.projectName ?? "nodefony",
195
231
  version: Nodefony.version
196
232
  }
197
233
  }, { protocolVersion });
234
+ if (reply.failure) {
235
+ const { tool, error } = reply.failure;
236
+ this.log(`MCP — l'outil « ${tool} » a levé : ${error instanceof Error ? error.stack ?? error.message : String(error)}`, "ERROR");
237
+ }
238
+ const sse = await stream.finish(reply.body);
239
+ if (sse !== null) return sse.close();
198
240
  if (reply.body === null) return this.renderResponse("", void 0, reply.status);
199
241
  return this.renderJson(reply.body, reply.status);
200
242
  }
243
+ /**
244
+ * `GET` et `DELETE /nodefony/mcp` — refusés en `405`, avec `Allow: POST`.
245
+ *
246
+ * Un client d'une révision antérieure du transport ouvre un flux SSE en `GET`
247
+ * et clôt sa session en `DELETE` ; la révision `2026-07-28` a retiré les deux
248
+ * et demande ce `405` (transports, « Earlier Streamable HTTP Revisions »).
249
+ * Sans cette route, la requête tombait dans le repli SPA du Studio
250
+ * (`/nodefony/{page}`) : `200` + HTML, que le client prenait pour un flux
251
+ * rompu et rouvrait à chaque seconde, indéfiniment.
252
+ */
253
+ unsupported() {
254
+ if (!this.#service().mcpSettings().enabled) return this.renderJson({ error: "MCP désactivé" }, 404);
255
+ return this.renderResponse("", void 0, 405, { allow: "POST" });
256
+ }
201
257
  };
202
258
  __decorate([
203
259
  route("devkit-mcp", {
204
260
  path: "/mcp",
205
- method: "POST"
261
+ requirements: { methods: ["POST"] }
206
262
  }),
207
263
  __decorateParam(0, Body()),
208
264
  __decorateParam(1, Headers("origin")),
@@ -217,6 +273,15 @@ __decorate([
217
273
  ]),
218
274
  __decorateMetadata("design:returntype", Promise)
219
275
  ], McpController.prototype, "mcp", null);
276
+ __decorate([
277
+ route("devkit-mcp-unsupported", {
278
+ path: "/mcp",
279
+ requirements: { methods: ["GET", "DELETE"] }
280
+ }),
281
+ __decorateMetadata("design:type", Function),
282
+ __decorateMetadata("design:paramtypes", []),
283
+ __decorateMetadata("design:returntype", void 0)
284
+ ], McpController.prototype, "unsupported", null);
220
285
  McpController = __decorate([controller("/nodefony"), __decorateMetadata("design:paramtypes", [Object])], McpController);
221
286
  var McpController_default = McpController;
222
287
  //#endregion
@@ -1,7 +1,7 @@
1
1
  import defaults from "../config/config.js";
2
2
  import { buildCard } from "../src/card.js";
3
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
4
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
3
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
4
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
5
5
  import { Module, Nodefony, Service, extend, injectable, mcpDeclaredScopes } from "nodefony";
6
6
  //#region nodefony/service/DevkitService.ts
7
7
  /**
@@ -1,2 +1 @@
1
- import { buildCard, renderCard } from "nodefony";
2
- export { buildCard, renderCard };
1
+ export { buildCard, renderCard } from "nodefony";
@@ -65,6 +65,17 @@ declare class McpController extends Controller {
65
65
  * inconnue (afin de la distinguer d'un serveur qui n'hébergerait pas
66
66
  * d'endpoint MCP du tout).
67
67
  */
68
- mcp(body: unknown, origin?: string, protocolVersion?: string, authorization?: string): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
68
+ mcp(body: unknown, origin?: string, protocolVersion?: string, authorization?: string): Promise<void | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
69
+ /**
70
+ * `GET` et `DELETE /nodefony/mcp` — refusés en `405`, avec `Allow: POST`.
71
+ *
72
+ * Un client d'une révision antérieure du transport ouvre un flux SSE en `GET`
73
+ * et clôt sa session en `DELETE` ; la révision `2026-07-28` a retiré les deux
74
+ * et demande ce `405` (transports, « Earlier Streamable HTTP Revisions »).
75
+ * Sans cette route, la requête tombait dans le repli SPA du Studio
76
+ * (`/nodefony/{page}`) : `200` + HTML, que le client prenait pour un flux
77
+ * rompu et rouvrait à chaque seconde, indéfiniment.
78
+ */
79
+ unsupported(): import("nodefony").MaybePromise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
69
80
  }
70
81
  export default McpController;
package/docs/index.md CHANGED
@@ -198,7 +198,7 @@ convient.
198
198
 
199
199
  ### Ajouter VOS outils — ce que l'agent ne peut pas deviner
200
200
 
201
- Les quatre outils ci-dessus décrivent le framework. Ils ne savent rien de votre
201
+ Les outils intégrés ci-dessus décrivent le framework. Ils ne savent rien de votre
202
202
  métier — et c'est précisément ce qu'un agent invente le plus mal. N'importe quel
203
203
  module de votre application peut donc en publier :
204
204
 
@@ -277,6 +277,50 @@ est absent de `tools/list` **et** inappelable en le nommant, et le refus dit
277
277
  > `policy: "dev"`. Avant d'exposer une donnée par un outil, se demander si elle
278
278
  > supporterait d'être lue **sans identification**, par qui a accès à la machine.
279
279
 
280
+ ### Un outil LONG — progression et annulation
281
+
282
+ Le troisième paramètre du handler sert l'outil qui dure : `run.signal` est
283
+ abattu quand l'agent abandonne l'appel, `run.progress()` lui donne signe de vie.
284
+ Un outil court l'ignore ; un appel direct (un test) ne le fournit pas, d'où le
285
+ `run?.` :
286
+
287
+ ```ts
288
+ {
289
+ name: "shop_reindex",
290
+ description: "Réindexe le catalogue, lot par lot. Long : signale chaque lot.",
291
+ inputSchema: { type: "object", properties: {} },
292
+ handler: async (_args, _caller, run) => {
293
+ const lots = await this.lots();
294
+ for (const [i, lot] of lots.entries()) {
295
+ if (run?.signal.aborted) return mcpText("interrompu", true);
296
+ await this.reindex(lot, run?.signal);
297
+ run?.progress(i + 1, lots.length, `lot ${i + 1}/${lots.length}`);
298
+ }
299
+ return mcpText(`${lots.length} lots réindexés`);
300
+ },
301
+ }
302
+ ```
303
+
304
+ Ce que la porte fait du reste :
305
+
306
+ - **le flux ne s'ouvre qu'à la première progression.** Un agent qui envoie un
307
+ `progressToken` et accepte `text/event-stream` reçoit des
308
+ `notifications/progress`, puis la réponse, qui clôt le flux. Sans jeton, ou
309
+ pour un outil qui ne signale rien, la réponse reste du JSON ;
310
+ - **la norme est tenue pour vous** : une valeur qui ne croît pas est ignorée, la
311
+ cadence est bornée (une notification par 100 ms au plus), et plus rien ne
312
+ part une fois l'outil terminé ;
313
+ - **fermer le flux, c'est annuler** (transport HTTP, révision 2026-07-28) :
314
+ `run.signal` est abattu, à l'outil de s'arrêter.
315
+
316
+ Le journal `notifications/message` n'est **pas** proposé : la révision
317
+ 2026-07-28 le déprécie (« New implementations SHOULD NOT adopt it »). Ce qu'un
318
+ outil veut dire en route passe par le `message` de la progression ; ce qui doit
319
+ atteindre le modèle va dans la réponse finale. `subscriptions/listen` est refusé
320
+ par une erreur explicite (`-32601`) : le catalogue ne change qu'au rechargement
321
+ de l'application, qui redémarre son process, si bien qu'un flux d'abonnement
322
+ casserait au lieu de notifier. Le `tools/list` suivant rend l'état à jour.
323
+
280
324
  ## Les skills d'agent — répondre à « comment fait-on ça, ici ? »
281
325
 
282
326
  La carte dit **où aller**. Elle ne dit pas **comment faire**. Or c'est là qu'un
@@ -284,20 +328,24 @@ agent invente : faute d'une marche à suivre, il écrit un CRUD à la main, un
284
328
  service à méthodes `static` que le conteneur ne voit pas, un contrôle de droits
285
329
  dans le corps de l'action.
286
330
 
287
- Le paquet livre donc huit **skills** au format [Agent Skills](https://agentskills.io) :
288
- un qui porte la conduite commune de toute tâche, et un par geste où l'invention
289
- coûte cher :
290
-
291
- | Skill | Le besoin qu'il couvre |
292
- | ------------------------------- | --------------------------------------------------------------- |
293
- | `nodefony-dev` | conduire une tâche de bout en bout, avant toute modification |
294
- | `nodefony-add-crud` | exposer une ressource REST complète, entité comprise |
295
- | `nodefony-add-service` | ajouter de la logique métier réutilisable, vue du conteneur |
296
- | `nodefony-protect-route` | réserver une route à qui est habilité |
297
- | `nodefony-add-realtime-channel` | ouvrir un canal temps réel où le serveur pousse |
298
- | `nodefony-browser` | voir **et mesurer** un écran, sans navigateur sur le poste |
299
- | `nodefony-migrate-schema` | faire évoluer le schéma d'une base et le porter en production |
300
- | `nodefony-devops` | exploiter l'application en conteneur, sans réécrire son contrat |
331
+ Le paquet livre donc onze **skills** au format [Agent Skills](https://agentskills.io) :
332
+ un qui porte la conduite commune de toute tâche, un qui part du symptôme d'une panne,
333
+ et un par geste où l'invention coûte cher :
334
+
335
+ | Skill | Le besoin qu'il couvre |
336
+ | ------------------------------- | ---------------------------------------------------------------- |
337
+ | `nodefony-dev` | conduire une tâche de bout en bout, avant toute modification |
338
+ | `nodefony-add-crud` | exposer une ressource REST complète, entité comprise |
339
+ | `nodefony-add-service` | ajouter de la logique métier réutilisable, vue du conteneur |
340
+ | `nodefony-authentication` | connecter un utilisateur ou un programme, sans rien réécrire |
341
+ | `nodefony-protect-route` | réserver une route à qui est habilité |
342
+ | `nodefony-add-realtime-channel` | ouvrir un canal temps réel où le serveur pousse |
343
+ | `nodefony-browser` | voir **et mesurer** un écran, sans navigateur sur le poste |
344
+ | `nodefony-migrate-schema` | faire évoluer le schéma d'une base et le porter en production |
345
+ | `nodefony-devops` | exploiter l'application en conteneur, sans réécrire son contrat |
346
+ | `nodefony-troubleshoot` | partir d'un symptôme de panne pour trouver le geste qui la règle |
347
+ | `nodefony-security-review` | relire la sécurité d'un diff avant de le commiter |
348
+ | `nodefony-upgrade` | monter le framework de version sans rien laisser derrière |
301
349
 
302
350
  Tous portent le préfixe `nodefony-` : leurs pointeurs arrivent dans le dossier où
303
351
  vous écrivez aussi les vôtres, et sans namespace un skill maison du même nom
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/devkit",
3
- "version": "10.0.0-beta.1",
3
+ "version": "10.0.0-beta.3",
4
4
  "type": "module",
5
5
  "description": "Outillage de développement d'une application Nodefony : carte de visite du projet et portes de découverte pour un agent de développement",
6
6
  "author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
@@ -25,20 +25,20 @@
25
25
  "coverage": "vitest run --coverage"
26
26
  },
27
27
  "peerDependencies": {
28
- "@nodefony/framework": "^10.0.0-beta.1",
29
- "@nodefony/http": "^10.0.0-beta.1",
30
- "nodefony": "^10.0.0-beta.1",
31
- "zod": "^4.6.5",
28
+ "@nodefony/framework": "^10.0.0-beta.3",
29
+ "@nodefony/http": "^10.0.0-beta.3",
30
+ "nodefony": "^10.0.0-beta.3",
31
+ "zod": "^4.6.7",
32
32
  "playwright": "^1.50.0",
33
33
  "lighthouse": "^13.0.0"
34
34
  },
35
35
  "devDependencies": {
36
- "@nodefony/framework": "^10.0.0-beta.1",
37
- "@nodefony/http": "^10.0.0-beta.1",
38
- "nodefony": "^10.0.0-beta.1"
36
+ "@nodefony/framework": "^10.0.0-beta.3",
37
+ "@nodefony/http": "^10.0.0-beta.3",
38
+ "nodefony": "^10.0.0-beta.3"
39
39
  },
40
40
  "dependencies": {
41
- "axe-core": "4.13.0",
41
+ "axe-core": "4.14.0",
42
42
  "tslib": "2.8.1"
43
43
  },
44
44
  "peerDependenciesMeta": {
@@ -6,9 +6,11 @@ description: >
6
6
  Porte la façon de fermer un canal à certains rôles, le piège du canal public par défaut, celui
7
7
  du canal dont le nom est calculé, et la façade cliente à employer côté navigateur. À charger
8
8
  AVANT d'écrire du code WebSocket, un canal, ou un abonnement client.
9
+ Dit aussi quand un flux SSE suffit, et sa façade.
9
10
  Déclencheurs : "flux temps réel", "websocket", "canal", "push", "notifications en direct",
10
11
  "abonnement", "RealtimeController", "@RealtimeChannel", "socket client", "temps réel privé",
11
- "réserver un canal à un rôle", "mon canal est public", "diffuser à plusieurs onglets".
12
+ "réserver un canal à un rôle", "mon canal est public", "diffuser à plusieurs onglets",
13
+ "SSE", "server-sent events", "EventSource", "barre de progression", "réponse en streaming".
12
14
  ---
13
15
 
14
16
  # add-realtime-channel — le temps réel par sa couche
@@ -37,6 +39,33 @@ câblage. **N'écris pas de `new WebSocket(...)` ni de `ws.on(...)` côté serve
37
39
  existe, il est employé par le framework, et le reprendre à la main te prive du routage par canal,
38
40
  de l'authentification partagée avec HTTP, et de la contre-pression.
39
41
 
42
+ ## WebSocket, ou un simple flux SSE ?
43
+
44
+ | Besoin | Choisir |
45
+ | -------------------------------------------------------------- | ------------------------------------------- |
46
+ | Le serveur pousse, le client écoute (progression, journal) | **SSE** — une réponse HTTP qui ne finit pas |
47
+ | La réponse d'UNE requête arrive par morceaux (modèle, export) | **SSE** sur cette requête |
48
+ | Échange dans les deux sens, canaux, état partagé entre onglets | **WebSocket** — le reste de cette page |
49
+
50
+ SSE n'est pas une route d'un genre particulier : l'action d'une route ordinaire choisit de
51
+ répondre en flux.
52
+
53
+ ```ts
54
+ @route("progress-run", { path: "/run" })
55
+ async run() {
56
+ const sse = await this.renderSse(); // en-têtes, battement, contre-pression
57
+ await sse.send({ step: "calcul" }, { event: "step" });
58
+ return sse.close();
59
+ }
60
+ ```
61
+
62
+ `this.acceptsSse()` dit si le client a demandé `text/event-stream` (une même route peut répondre
63
+ en JSON ou en flux). Côté navigateur, `new NodefonySse(url)` (`nodefony/client`), qui se lit comme
64
+ `EventSource` ; les liaisons de front existent comme pour la socket. **N'écris jamais
65
+ `Content-Type: text/event-stream` à la main** : tu perdrais le battement qui empêche un proxy de
66
+ couper la connexion, et la contre-pression (`await sse.send()` freine si le client lit lentement).
67
+ Détail : `node_modules/@nodefony/http/docs/sse.md`.
68
+
40
69
  ## Fermer un canal
41
70
 
42
71
  ```ts
@@ -53,27 +82,55 @@ aucun rôle particulier n'est requis.
53
82
 
54
83
  ## 🔴 Le piège du canal dont le nom est calculé
55
84
 
56
- Une politique est indexée par le nom **EXACT** du canal. Un canal dont le nom se construit à
57
- l'exécution (`` `room:${id}` ``) **n'est couvert par aucune politique déclarée** — il naît public,
58
- et rien ne le signale.
85
+ La politique d'un `@RealtimeChannel` est indexée par le nom **EXACT** du canal. Un canal dont le
86
+ nom se construit à l'exécution (`` `room:${id}` ``) **n'est couvert par aucun décorateur** — il
87
+ naît public, et rien ne le signale.
59
88
 
60
89
  ```ts
61
90
  @RealtimeChannel("room:lobby", { authenticated: true }) // ✅ couvert
62
- // `room:42` construit à la volée // 🔴 PAS couvert
91
+ // `room:42` construit à la volée // 🔴 PAS couvert par le décorateur
63
92
  ```
64
93
 
65
- Tant qu'un canal à membres n'existe pas dans le framework, le geste montrable est le canal
66
- **gardé** par rôle ou authentification, pas le canal dynamique.
94
+ La parade est une règle **par préfixe** dans la configuration de sécurité, qui garde à la fois
95
+ les canaux ET les actions dont le nom commence ainsi :
96
+
97
+ ```ts
98
+ use("@nodefony/security", {
99
+ realtimeChannels: [
100
+ { pattern: "room:", authenticated: true, roles: ["ROLE_USER"] },
101
+ ],
102
+ });
103
+ ```
104
+
105
+ Elle exprime une classe d'identité (connecté, rôles), jamais une liste de membres : « seuls les
106
+ participants de la salle 42 » ne s'écrit pas encore dans le framework. Ne le simule pas avec un
107
+ nom de canal difficile à deviner.
108
+
109
+ ## Savoir QUI appelle une action
110
+
111
+ Les paramètres décorés d'une route valent aussi dans une `@RealtimeAction` : `@Body("clé")` lit la
112
+ charge de l'appel, `@CurrentUser()` l'utilisateur de la connexion (`undefined` pour un anonyme).
113
+ L'auteur d'un message se prend là, côté SERVEUR — jamais dans la charge, où le client peut mentir.
114
+
115
+ ```ts
116
+ @RealtimeAction("chat:send")
117
+ send(@Body("text") text: string, @CurrentUser() user: IUser) {
118
+ return { author: user.identifier, text };
119
+ }
120
+ ```
67
121
 
68
122
  ## Côté client — la façade, pas le socket
69
123
 
70
124
  ```ts
71
- import { RealtimeClient } from "nodefony/client";
125
+ import { NodefonySocket } from "nodefony/client";
72
126
 
73
- const client = RealtimeClient.shared();
127
+ const client = NodefonySocket.shared({ url: "/api/live/realtime" });
74
128
  client.subscribe("ops:alerts", (payload) => { … });
75
129
  ```
76
130
 
131
+ L'adresse est **obligatoire** : `shared()` sans `url` lève une erreur, parce que la route dépend de
132
+ l'application. Une application générée monte `/api/live/realtime`.
133
+
77
134
  En React, les hooks du paquet client font la même chose avec le cycle de vie du composant.
78
135
  **Importe depuis `nodefony/client`**, jamais depuis la racine `nodefony` : côté navigateur, elle
79
136
  n'expose pas ces symboles et le compilateur refuse.
@@ -88,6 +88,17 @@ dépend est refusé au démarrage. Pour **lire** une donnée de la requête depu
88
88
  besoin de portée : `RequestContext.getScope()` ou `RequestContext.get()` au moment de l'appel.
89
89
  Détail : `node_modules/nodefony/docs/service.md`.
90
90
 
91
+ **Qui est connecté, depuis un service** : `RequestContext.getAuthenticatedUser()` (l'utilisateur
92
+ connecté, ou `null`) et `RequestContext.getUserId()` (`string | null`). Jamais
93
+ `RequestContext.getUser()` pour décider : il rend l'utilisateur BRUT, anonyme compris.
94
+
95
+ **Le payload de la requête est une forme FERMÉE** (`RequestContextPayload`) :
96
+ `RequestContext.set("maCle", …)` sur une clé qu'il ne déclare pas ne compile pas. Ta clé s'ajoute
97
+ par augmentation — `declare module "nodefony" { interface RequestContextPayload { tenant?: string } }`
98
+ — mais `RequestContext.release()`, qui vide le payload à la fin de l'unité de travail, ne la
99
+ vide PAS d'office.
100
+ Détail : `node_modules/nodefony/docs/request-context.md`.
101
+
91
102
  ## Obtenir un service depuis un autre — deux voies, un choix
92
103
 
93
104
  | Voie | Quand |