@nodefony/realtime 10.0.0-beta.2 → 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 (45) hide show
  1. package/README.md +2 -2
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +17 -10
  5. package/dist/nodefony/config/config.js +6 -3
  6. package/dist/nodefony/config/configSchema.js +19 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +42 -15
  8. package/dist/nodefony/decorators/realtimeDecorators.js +68 -17
  9. package/dist/nodefony/src/backplane/RedisBackplane.js +21 -4
  10. package/dist/nodefony/src/backplane/bootNotices.js +43 -0
  11. package/dist/nodefony/src/backplane/publishQueue.js +1 -4
  12. package/dist/nodefony/src/cluster/ClusterProbeClient.js +15 -4
  13. package/dist/nodefony/src/guards.js +41 -0
  14. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +1 -4
  15. package/dist/nodefony/src/server/RealtimeController.js +120 -50
  16. package/dist/nodefony/src/server/RealtimeHub.js +29 -51
  17. package/dist/nodefony/src/server/ServerRealtimeSocket.js +13 -5
  18. package/dist/nodefony/src/server/syslogUplink.js +4 -2
  19. package/dist/nodefony/src/service/RealtimeService.js +3 -2
  20. package/dist/nodefony/testing/index.js +41 -1
  21. package/dist/types/nodefony/config/configSchema.d.ts +12 -0
  22. package/dist/types/nodefony/config/defineModuleConfig.d.ts +29 -6
  23. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +19 -2
  24. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +1 -1
  25. package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +1 -1
  26. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +7 -0
  27. package/dist/types/nodefony/src/backplane/bootNotices.d.ts +25 -0
  28. package/dist/types/nodefony/src/guards.d.ts +32 -0
  29. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +13 -0
  30. package/dist/types/nodefony/src/server/RealtimeController.d.ts +24 -4
  31. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +22 -16
  32. package/dist/types/nodefony/src/service/RealtimeService.d.ts +1 -1
  33. package/dist/types/nodefony/testing/index.d.ts +2 -2
  34. package/docs/actions.md +111 -28
  35. package/docs/architecture.md +77 -75
  36. package/docs/cadence-et-contre-pression.md +634 -0
  37. package/docs/configuration.md +39 -34
  38. package/docs/cookbook-chat.md +31 -27
  39. package/docs/index.md +35 -21
  40. package/docs/observabilite.md +17 -15
  41. package/docs/protocole.md +74 -63
  42. package/docs/securite.md +49 -49
  43. package/docs/vocabulaire.md +76 -75
  44. package/package.json +16 -12
  45. package/docs/couverture-bancs.md +0 -139
package/docs/protocole.md CHANGED
@@ -31,7 +31,7 @@ source: "src/packages/@nodefony/realtime/docs/protocole.md"
31
31
  > **frames** dont la forme est normée. Trois formes existent, et une seule règle les distingue —
32
32
  > **la nature d'une frame se lit sur `method`, jamais sur `id`**. Cette page donne la grammaire
33
33
  > champ par champ, les méthodes que le serveur comprend nativement, et ce qui revient exactement
34
- > quand ça rate. Le moteur est `JsonRpcPeer` (`JsonRpcPeer.ts:271`), écrit une fois et branché des
34
+ > quand ça rate. Le moteur est `JsonRpcPeer` (`JsonRpcPeer.ts:311`), écrit une fois et branché des
35
35
  > deux côtés du fil.
36
36
 
37
37
  📍 [Documentation](../../../../../docs/index.md) › [Realtime](index.md) › **Protocole**
@@ -52,12 +52,12 @@ cœur du protocole : il décide s'il faut répondre, à qui, et si une réponse
52
52
 
53
53
  ```mermaid
54
54
  flowchart TD
55
- IN["Frame entrante (JSON déjà parsé)"] --> V{"jsonrpc = 2.0 ?"}
55
+ IN["Frame entrante (JSON déjà parsé)"] --> V{"frame JSON-RPC 2.0 valide ?"}
56
56
  V -->|non| INV["invalid — auditée, AUCUNE réponse"]
57
57
  V -->|oui| M{"method présent ?"}
58
58
  M -->|"method + id"| REQ["requête — un handler doit rendre result ou error"]
59
59
  M -->|"method seul"| NOT["notification — pub/sub, aucune réponse"]
60
- M -->|"id seul"| RES["réponse — résout une requête sortante en attente"]
60
+ M -->|"id + result ou error"| RES["réponse — résout une requête sortante en attente"]
61
61
  REQ --> OUT["result · error"]
62
62
  NOT --> HUB["subscribe · unsubscribe · canal entrant"]
63
63
  RES --> PROM["la Promise de l'appelant"]
@@ -79,7 +79,7 @@ Deux conséquences que le reste de la page décline :
79
79
  | Pair (peer) | Le moteur de protocole d'une connexion — `JsonRpcPeer`, identique client et serveur. |
80
80
  | Requête | Frame avec `method` **et** `id` — appelle exactement une réponse. |
81
81
  | Notification | Frame avec `method` seul — aucune réponse, jamais. |
82
- | Réponse | Frame avec `id` seul, portant `result` **ou** `error`. |
82
+ | Réponse | Frame avec `id` sans `method`, portant `result` **ou** `error` bien formé. |
83
83
  | Dispatch | Classer une frame entrante puis la router vers son handler. |
84
84
  | Enveloppe | Réponse qui joint des métadonnées serveur dans un champ **frère** du `result`. |
85
85
  | Accueil, welcome | La 1ʳᵉ notification poussée par le serveur : protocole, canaux, actions, identité. |
@@ -106,7 +106,7 @@ Nodefony ne l'a pas inventée. Le calcul, mis à plat :
106
106
 
107
107
  > [!NOTE]
108
108
  > **Le batch est une propriété de la norme, pas du moteur Nodefony.** `JsonRpcPeer.receive()`
109
- > (`JsonRpcPeer.ts:390`) attend un **objet** portant `jsonrpc: "2.0"` ; un tableau de frames n'en
109
+ > (`JsonRpcPeer.ts:419`) attend un **objet** portant `jsonrpc: "2.0"` ; un tableau de frames n'en
110
110
  > porte pas et tombe donc en `invalid`. Une frame = un objet. Le multiplexage rend le batch peu
111
111
  > utile ici : les canaux voyagent déjà dans la même connexion.
112
112
 
@@ -118,21 +118,25 @@ d'une action. La norme fournit la grammaire ; le vocabulaire est nodefonien.
118
118
 
119
119
  Trois partis pris distinguent cette implémentation d'un simple « serveur WebSocket qui parle JSON ».
120
120
 
121
- **Le même fichier tourne dans le navigateur et dans Node.** `JsonRpcPeer` (`JsonRpcPeer.ts:271`)
121
+ **Le même fichier tourne dans le navigateur et dans Node.** `JsonRpcPeer` (`JsonRpcPeer.ts:311`)
122
122
  n'a aucune dépendance Node — seulement `setTimeout`. Classer, router, corréler les `id` : ce travail
123
123
  est identique des deux côtés, il est donc écrit **une seule fois**. Chaque côté l'entoure de son
124
124
  transport et de ses handlers. Historiquement, cette discrimination vivait à deux endroits qui
125
- divergeaient ; c'est précisément la classe de bug que l'isomorphisme supprime.
125
+ divergeaient ; c'est précisément la classe de bug que l'isomorphisme supprime. Le classement
126
+ lui-même est `classifyJsonRpcFrame` (`jsonrpc/index.ts:178`), la brique que partage aussi le
127
+ serveur MCP du framework : les deux portes ne peuvent plus diverger sur ce qu'est une frame valide.
126
128
 
127
129
  **Le rôle se lit sur `method`, pas sur `id`.** La lecture naïve (« `id` présent = requête ») casse
128
130
  sur les réponses, qui portent un `id` sans être des appels. `JsonRpcPeer.receive()`
129
- (`JsonRpcPeer.ts:390`) teste donc `method` d'abord, puis la présence d'un `id`. Une frame `id` seul
130
- est une **réponse** et ne déclenche jamais `-32601` à tort.
131
+ (`JsonRpcPeer.ts:419`) teste donc `method` d'abord, puis la présence d'un `id` — chaîne ou nombre :
132
+ un `id` `null`, objet ou booléen rend la frame `invalid`, il n'en fait pas une notification. Une
133
+ frame `id` sans `method`, portant `result` ou une `error` bien formée, est une **réponse** et ne
134
+ déclenche jamais `-32601` à tort ; sans l'un ni l'autre, elle est `invalid`.
131
135
 
132
136
  **Un échec ne raconte rien par défaut.** Un handler qui lève une exception ordinaire produit un
133
- `-32603 "internal error"` **générique** (`JsonRpcPeer.ts:528`) : ni message d'origine, ni pile
137
+ `-32603 "internal error"` **générique** (`JsonRpcPeer.ts:573`) : ni message d'origine, ni pile
134
138
  d'appels. Pour exposer volontairement un refus au pair — un 404, un droit manquant — il existe une
135
- porte explicite, `RpcError` (`JsonRpcPeer.ts:70`), et elle seule.
139
+ porte explicite, `RpcError` (`JsonRpcPeer.ts:101`), et elle seule.
136
140
 
137
141
  ## 🚀 Démarrage rapide
138
142
 
@@ -202,14 +206,14 @@ export default ChatController;
202
206
 
203
207
  ```ts
204
208
  // frontend/src/chat.ts
205
- import { RealtimeClient, RpcError } from "nodefony/client";
209
+ import { NodefonySocket, RpcError } from "nodefony/client";
206
210
 
207
211
  interface ChatHistory {
208
212
  messages: string[];
209
213
  }
210
214
 
211
215
  export async function joinChat(): Promise<void> {
212
- const socket = new RealtimeClient({
216
+ const socket = new NodefonySocket({
213
217
  url: "wss://127.0.0.1:5152/chat/realtime",
214
218
  });
215
219
  await socket.connect();
@@ -268,7 +272,7 @@ L'accueil porte cinq champs — `ts`, `protocol`, `channels`, `methods`, `identi
268
272
  (`IRealtimeWelcome`, `RealtimeEventMap.ts:231`) — plus un sixième, `env`, **uniquement hors
269
273
  production** (`welcomeEnv()`, `welcomeEnv.ts:19`) : c'est lui que porte l'exemple ci-dessus, capturé
270
274
  en développement. Émis par le contrôleur au handshake (`peer.notify("realtime:welcome")`,
271
- `RealtimeController.ts:708`). Il n'y a pas de champ `version`.
275
+ `RealtimeController.ts:796`). Il n'y a pas de champ `version`.
272
276
 
273
277
  ## 🔌 Anatomie d'une frame, champ par champ
274
278
 
@@ -285,7 +289,7 @@ en développement. Émis par le contrôleur au handshake (`peer.notify("realtime
285
289
 
286
290
  | Champ | Obligatoire | Type accepté | Rôle exact |
287
291
  | --------- | ------------------------- | -------------------- | ------------------------------------------------------------------------- |
288
- | `jsonrpc` | oui, toujours | `"2.0"` littéral | Sans lui, `JsonRpcPeer.receive()` classe `invalid` (`JsonRpcPeer.ts:371`) |
292
+ | `jsonrpc` | oui, toujours | `"2.0"` littéral | Sans lui, `JsonRpcPeer.receive()` classe `invalid` (`JsonRpcPeer.ts:419`) |
289
293
  | `method` | sur un appel | chaîne | Décide de la nature ET de l'aiguillage (canal, action, verbe pub/sub) |
290
294
  | `params` | non | tout JSON | Charge applicative. Vient du réseau : **jamais** digne de confiance |
291
295
  | `id` | sur une requête / réponse | nombre **ou** chaîne | Corrèle l'aller et le retour |
@@ -293,7 +297,7 @@ en développement. Émis par le contrôleur au handshake (`peer.notify("realtime
293
297
  > [!IMPORTANT]
294
298
  > Le pair accepte un `id` **chaîne** en entrée (conforme à la norme), mais il n'attribue jamais que
295
299
  > des `id` **numériques** à ses propres appels sortants. Une réponse portant un `id` chaîne est donc
296
- > ignorée sans erreur (`JsonRpcPeer.handleResponse()`, `JsonRpcPeer.ts:556`) : elle ne peut, par
300
+ > ignorée sans erreur (`JsonRpcPeer.handleResponse()`, `JsonRpcPeer.ts:579`) : elle ne peut, par
297
301
  > construction, correspondre à aucune requête émise par ce pair.
298
302
 
299
303
  ### La réponse — `result` ou `error`, jamais les deux
@@ -312,9 +316,9 @@ distinguer un 404 d'un refus d'autorisation sans analyser un message de texte.
312
316
 
313
317
  ### L'enveloppe — joindre une méta sans polluer le `result`
314
318
 
315
- Un handler peut rendre une `RpcEnvelope` (`JsonRpcPeer.ts:104`) au lieu d'une valeur nue. Le pair la
319
+ Un handler peut rendre une `RpcEnvelope` (`JsonRpcPeer.ts:139`) au lieu d'une valeur nue. Le pair la
316
320
  déballe : le `result` reste **exactement** la valeur, et la méta voyage dans un champ frère
317
- `meta` (`RpcMeta`, `JsonRpcPeer.ts:90`).
321
+ `meta` (`RpcMeta`, `JsonRpcPeer.ts:125`).
318
322
 
319
323
  ```jsonc
320
324
  {
@@ -337,20 +341,20 @@ ouvertes à l'application. Colonne `id` : présent = requête (réponse due), ab
337
341
 
338
342
  | Méthode | Direction | `id` ? | Rôle | Ancrage |
339
343
  | ------------------ | ------------- | :-----: | --------------------------------------------------------------------- | --------------------------- |
340
- | `subscribe` | client→server | non | « pousse-moi ce canal » — `params.channel` | `RealtimeController.ts:522` |
341
- | `unsubscribe` | client→server | non | « arrête » — dernier abonné, le producteur est libéré | `RealtimeController.ts:775` |
342
- | `ping` | client→server | non | Battement de cœur — **no-op serveur**, aucun pong | `RealtimeClient.ts:882` |
343
- | `<canal>` | server→client | non | Push d'un message : le **nom du canal est la `method`** | `RealtimeController.ts:972` |
344
- | `<canal entrant>` | client→server | non | Le client pousse sur un canal déclaré entrant | `RealtimeController.ts:736` |
345
- | `realtime:welcome` | server→client | non | L'accueil : 5 champs, dont l'identité résolue | `RealtimeController.ts:693` |
346
- | `realtime:denied` | server→client | non | Rend OBSERVABLE le refus d'une notification | `RealtimeController.ts:496` |
347
- | `api.request` | client→server | **oui** | Pont API — rejoue une route HTTP sur la socket (désactivé par défaut) | `RealtimeController.ts:582` |
348
- | `<action>` | client→server | **oui** | Toute action déclarée par `@RealtimeAction` | `realtimeDecorators.ts:101` |
344
+ | `subscribe` | client→server | non | « pousse-moi ce canal » — `params.channel` | `RealtimeController.ts:577` |
345
+ | `unsubscribe` | client→server | non | « arrête » — dernier abonné, le producteur est libéré | `RealtimeController.ts:849` |
346
+ | `ping` | client→server | non | Battement de cœur — **no-op serveur**, aucun pong | `NodefonySocket.ts:1085` |
347
+ | `<canal>` | server→client | non | Push d'un message : le **nom du canal est la `method`** du `notify` | `RealtimeController.ts:874` |
348
+ | `<canal entrant>` | client→server | non | Le client pousse sur un canal déclaré entrant | `RealtimeController.ts:825` |
349
+ | `realtime:welcome` | server→client | non | L'accueil : 5 champs, dont l'identité résolue | `RealtimeController.ts:796` |
350
+ | `realtime:denied` | server→client | non | Rend OBSERVABLE le refus d'une notification | `RealtimeController.ts:573` |
351
+ | `api.request` | client→server | **oui** | Pont API — rejoue une route HTTP sur la socket (désactivé par défaut) | `RealtimeController.ts:651` |
352
+ | `<action>` | client→server | **oui** | Toute action déclarée par `@RealtimeAction` | `realtimeDecorators.ts:191` |
349
353
 
350
354
  > [!TIP]
351
355
  > **L'accueil est ta carte du territoire.** `channels` ne liste que les canaux que ce visiteur
352
356
  > pourrait obtenir (filtrés par le même verrou que `subscribe`), `methods` les actions de l'endpoint
353
- > (`RealtimeController.ts:710`) : un client peut activer ou griser
357
+ > (`RealtimeController.ts:786`) : un client peut activer ou griser
354
358
  > ses commandes sans rien coder en dur. Côté navigateur, ils se lisent en `socket.serverMethods` et
355
359
  > `socket.serverChannels`.
356
360
 
@@ -360,12 +364,12 @@ Les quatre formes de frame circulent en permanence sous tes yeux — ce schéma
360
364
  > `nodefony:kernel:ping` et `nodefony:kernel:gc` (`StudioRealtimeController.ts:114`) sont des exemples d'actions,
361
365
  > **pas des méthodes du cœur temps réel** : elles sont déclarées par le contrôleur
362
366
  > d'administration de `@nodefony/studio`. Un endpoint applicatif ne les expose pas. Le helper
363
- > `RealtimeClient.ping()` (`RealtimeClient.ts:878`) mesure le RTT en les appelant — il suppose donc
367
+ > `NodefonySocket.ping()` (`NodefonySocket.ts:1085`) mesure le RTT en les appelant — il suppose donc
364
368
  > un endpoint qui les déclare, contrairement à la notification `ping` du battement de cœur, qui
365
369
  > n'attend jamais de réponse.
366
370
 
367
371
  `subscribe` et `unsubscribe` ne sont **pas** des actions enregistrées : elles sont traitées dans
368
- `onRealtimeNotification()` (`RealtimeController.ts:784`). Envoyées avec un `id`, elles seraient
372
+ `onRealtimeNotification()` (`RealtimeController.ts:840`). Envoyées avec un `id`, elles seraient
369
373
  classées « requête », ne trouveraient aucun handler et récolteraient un `-32601`.
370
374
 
371
375
  ## Une conversation type, de bout en bout
@@ -397,52 +401,59 @@ Voici **tout** ce que le moteur produit. Les codes de la plage `-32000`…`-3209
397
401
  la norme aux erreurs applicatives serveur ; Nodefony y place son refus d'autorisation et le défaut
398
402
  de `RpcError`.
399
403
 
400
- | Code | Émis par | Déclencheur | Ce que voit le pair |
401
- | -------- | ---------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------- |
402
- | `-32601` | `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts:499`) | requête vers une action non enregistrée | `method not found: <nom>` |
403
- | `-32603` | même méthode (`JsonRpcPeer.ts:528`) | le handler a levé une exception **ordinaire** | `internal error` — générique, rien d'autre |
404
- | `-32001` | le refus du verrou de frame (`JsonRpcPeer.ts:400`) | `beforeDispatch` a dit non **sur une requête** | `unauthorized`, sans jamais dire pourquoi |
405
- | `-32000` | défaut du constructeur de `RpcError` (`JsonRpcPeer.ts:74`) | le handler expose volontairement son refus | le message ET le `data` choisis par le handler |
406
- | `-32602` | le pont API, via `RpcError` (`RealtimeController.ts:787`) | `api.request` appelé avec un `params.path` invalide | message explicite (l'appel est malformé) |
404
+ | Code | Émis par | Déclencheur | Ce que voit le pair |
405
+ | -------- | ----------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------- |
406
+ | `-32601` | `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts:526`) | requête vers une action non enregistrée | `method not found: <nom>` |
407
+ | `-32603` | même méthode (`JsonRpcPeer.ts:573`) | le handler a levé une exception **ordinaire** | `internal error` — générique, rien d'autre |
408
+ | `-32001` | le refus du verrou de frame (`JsonRpcPeer.ts:449`) | `beforeDispatch` a dit non **sur une requête** | `unauthorized`, sans jamais dire pourquoi |
409
+ | `-32000` | défaut du constructeur de `RpcError` (`JsonRpcPeer.ts:101`) | le handler expose volontairement son refus | le message ET le `data` choisis par le handler |
410
+ | `-32602` | le pont API, via `RpcError` (`RealtimeController.ts:1027`) | `api.request` appelé avec un `params.path` invalide | message explicite (l'appel est malformé) |
407
411
 
408
412
  Et un échec qui n'est **pas** une frame : l'expiration. `startCall()` ne reçoit rien dans le délai
409
413
  imparti, supprime l'entrée en attente et rejette localement avec `RPC timeout: <méthode>`
410
- (`JsonRpcPeer.ts:428`). Défaut de 30 000 ms, réglable par appel. Aucun octet ne part sur le fil : c'est une décision du
414
+ (`JsonRpcPeer.ts:502`). Défaut de 30 000 ms, réglable par appel. Aucun octet ne part sur le fil : c'est une décision du
411
415
  client, le serveur peut très bien répondre après, sa réponse sera ignorée.
412
416
 
417
+ L'inverse n'attend pas : une frame qui porte l'`id` d'une requête en attente sans être une réponse
418
+ valide — ni `result` ni `error` bien formé — fait échouer l'appel **aussitôt**, en `RpcError`
419
+ `-32603` « réponse JSON-RPC invalide » (`JsonRpcPeer.ts:611`). Si elle porte `result` ET une `error`
420
+ bien formée (la norme l'interdit), l'erreur annoncée est rendue telle quelle. Rien ne part sur le
421
+ fil : c'est un échec local, et la frame est auditée `invalid`.
422
+
413
423
  ### Les deux codes de la norme que Nodefony n'émet jamais
414
424
 
415
425
  `-32700 Parse error` et `-32600 Invalid Request` figurent dans la norme, mais **aucune frame ne les
416
426
  porte** ici :
417
427
 
418
428
  - un JSON illisible n'atteint même pas le pair — le contrôleur l'abandonne à la lecture
419
- (`RealtimeController.ts:446`) ;
420
- - une frame lisible mais non conforme (pas d'objet, `jsonrpc` absent ou faux) est classée `invalid`,
421
- signalée à l'audit, et **rien n'est renvoyé** (`JsonRpcPeer.ts:429`).
429
+ (`RealtimeController.ts:586`) ;
430
+ - une frame lisible mais non conforme — pas d'objet, un lot, `jsonrpc` absent ou faux, `id` `null`,
431
+ objet ou booléen, `method` non chaîne, réponse sans `result` ni `error` bien formé, ou avec les
432
+ deux — est classée `invalid`, signalée à l'audit, et **rien n'est renvoyé** (`JsonRpcPeer.ts:423`).
422
433
 
423
434
  Le choix est délibéré : une frame cassée n'a pas d'`id` digne de confiance, donc pas de corrélation
424
435
  possible ; et répondre systématiquement à du bruit offre à un attaquant un amplificateur gratuit.
425
- Le refus reste **traçable** — c'est le motif `invalid` de `FrameAuditReason` (`JsonRpcPeer.ts:155`),
436
+ Le refus reste **traçable** — c'est le motif `invalid` de `FrameAuditReason` (`JsonRpcPeer.ts:182`),
426
437
  qui alimente le journal d'audit avec le pair concerné.
427
438
 
428
439
  ### Le refus d'une notification n'est pas une erreur
429
440
 
430
441
  C'est la subtilité la plus importante de la page. Le verrou de frame s'applique aux requêtes **et**
431
- aux notifications (`beforeDispatch`, `JsonRpcPeer.ts:192`), mais leurs conséquences diffèrent
442
+ aux notifications (`beforeDispatch`, `JsonRpcPeer.ts:219`), mais leurs conséquences diffèrent
432
443
  radicalement :
433
444
 
434
445
  | La frame refusée est… | Ce qui part | Pourquoi |
435
446
  | --------------------- | --------------------------------------------------------------- | ---------------------------------------------------- |
436
- | une **requête** | `-32001 "unauthorized"` (`JsonRpcPeer.ts:413`) | Un `id` existe : il y a un canal de réponse |
437
- | une **notification** | la notification `realtime:denied` (`RealtimeController.ts:496`) | Aucun `id` : sans elle, le client se croirait abonné |
447
+ | une **requête** | `-32001 "unauthorized"` (`JsonRpcPeer.ts:449`) | Un `id` existe : il y a un canal de réponse |
448
+ | une **notification** | la notification `realtime:denied` (`RealtimeController.ts:573`) | Aucun `id` : sans elle, le client se croirait abonné |
438
449
 
439
450
  `IRealtimeDenied` (`RealtimeEventMap.ts:269`) porte `channel` et `reason` — plus un `detail` optionnel, posé hors production seulement — et le motif
440
451
  est **générique**. Jamais « il te manque `ROLE_ADMIN` » : ce serait un oracle d'autorisation, un
441
452
  attaquant y lirait la carte des droits. Trois motifs circulent : `forbidden` (le verrou a dit non),
442
- `limit` (le plafond de canaux de la connexion est atteint, `RealtimeController.ts:811`) et `unknown`
453
+ `limit` (le plafond de canaux de la connexion est atteint, `RealtimeController.ts:852`) et `unknown`
443
454
  (aucun producteur ne sert ce nom — jamais un oracle : un canal gardé rend `forbidden`, qu'il existe ou
444
455
  non). Côté client,
445
- `onDenied()` (`RealtimeClient.ts:469`) branche un handler dessus.
456
+ `onDenied()` (`NodefonySocket.ts:678`) branche un handler dessus.
446
457
 
447
458
  > [!CAUTION]
448
459
  > Un `-32403 Forbidden` circule dans d'anciennes notes. **Ce code n'existe pas** dans Nodefony, et il
@@ -461,7 +472,7 @@ en découlent :
461
472
  politique du canal ; la demande a exactement la même forme qu'un abonnement autorisé, seule la
462
473
  réponse diffère.
463
474
  3. **Le client ne pousse que là où c'est déclaré.** Un canal n'accepte d'entrée que si un handler
464
- entrant existe pour ce nom (`RealtimeController.ts:608`) ; sinon la notification est ignorée en
475
+ entrant existe pour ce nom (`RealtimeController.ts:825`) ; sinon la notification est ignorée en
465
476
  silence. Le défaut est fermé.
466
477
 
467
478
  Les mécanismes eux-mêmes — authenticator, verrou de frame, politique de canal, révocation — sont
@@ -469,19 +480,19 @@ décrits dans [la page sécurité](./securite.md).
469
480
 
470
481
  ## ⚠️ Pièges
471
482
 
472
- | Symptôme | Cause | Correction |
473
- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
474
- | `subscribe` répond `-32601 method not found` | Envoyé **avec un `id`** : classé requête, or c'est une notification (`RealtimeController.ts:592`) | L'émettre sans `id` — `socket.subscribe(canal)` |
475
- | Le handler passé à `subscribe` n'est jamais appelé | `RealtimeClient.subscribe()` prend **un seul** argument (`RealtimeClient.ts:532`) | `subscribe(canal)` **et** `on(canal, handler)`, deux gestes distincts |
476
- | `request()` expire immédiatement, ou ignore le délai | Signature **positionnelle** `(méthode, params, ms)` (`RealtimeClient.ts:727`) — un objet d'options n'est pas lu | `request(m, p, 5000)` ; le défaut est 30 000 ms |
477
- | Un tableau de frames n'obtient aucune réponse | Le batch n'est pas implémenté : un tableau n'a pas de `jsonrpc` → `invalid` (`JsonRpcPeer.ts:371`) | Une frame = un objet ; le multiplexage remplace le batch |
478
- | Une frame malformée ne renvoie **aucune** erreur | Ni `-32700` ni `-32600` ne sont émis — silence + audit (`JsonRpcPeer.ts:396`) | Lire le motif `invalid` côté serveur, pas la réponse |
479
- | L'exception du serveur n'arrive jamais au client | Zero Trust : tout throw ordinaire devient `-32603` générique (`JsonRpcPeer.ts:528`) | Lever une `RpcError` pour exposer volontairement code et `data` |
480
- | Une notification refusée disparaît sans trace côté client | Sans `id`, aucune réponse possible (`beforeDispatch`, `JsonRpcPeer.ts:151`) | Écouter `realtime:denied` via `onDenied()` (`RealtimeClient.ts:471`) |
481
- | `nodefony:kernel:ping` répond `-32601` sur mon endpoint | L'action `nodefony:kernel:ping` est déclarée par `@nodefony/studio` (`StudioRealtimeController.ts:114`) | Déclarer la sienne, ou lire `serverMethods` avant d'appeler |
482
- | Le battement de cœur ne renvoie aucun pong | La notification `ping` est un no-op serveur (`RealtimeController.ts:791`) | Pour mesurer un RTT, utiliser une action RPC — `ping()` (`RealtimeClient.ts:878`) |
483
- | Une réponse reçue est ignorée sans message | Corrélation sur `id` **numériques** seulement (`JsonRpcPeer.ts:537`) | Ne pas fabriquer soi-même de réponse à `id` chaîne |
484
- | Les premières frames envoyées après `connect()` semblent perdues | Le transport n'est branché qu'une fois le handshake terminé | Attendre `realtime:welcome` — le client le fait déjà |
483
+ | Symptôme | Cause | Correction |
484
+ | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
485
+ | `subscribe` répond `-32601 method not found` | Envoyé **avec un `id`** : classé requête, or c'est une notification (`RealtimeController.ts:809`) | L'émettre sans `id` — `socket.subscribe(canal)` |
486
+ | Le handler passé à `subscribe` n'est jamais appelé | `NodefonySocket.subscribe()` prend **un seul** argument (`NodefonySocket.ts:739`) | `subscribe(canal)` **et** `on(canal, handler)`, deux gestes distincts |
487
+ | `request()` expire immédiatement, ou ignore le délai | Signature **positionnelle** `(méthode, params, ms)` (`NodefonySocket.ts:936`) — un objet d'options n'est pas lu | `request(m, p, 5000)` ; le défaut est 30 000 ms |
488
+ | Un tableau de frames n'obtient aucune réponse | Le batch n'est pas implémenté : un tableau n'a pas de `jsonrpc` → `invalid` (`jsonrpc/index.ts:178`) | Une frame = un objet ; le multiplexage remplace le batch |
489
+ | Une frame malformée ne renvoie **aucune** erreur | Ni `-32700` ni `-32600` ne sont émis — silence + audit (`JsonRpcPeer.ts:423`) | Lire le motif `invalid` côté serveur, pas la réponse |
490
+ | L'exception du serveur n'arrive jamais au client | Zero Trust : tout throw ordinaire devient `-32603` générique (`JsonRpcPeer.ts:573`) | Lever une `RpcError` pour exposer volontairement code et `data` |
491
+ | Une notification refusée disparaît sans trace côté client | Sans `id`, aucune réponse possible (`beforeDispatch`, `JsonRpcPeer.ts:219`) | Écouter `realtime:denied` via `onDenied()` (`NodefonySocket.ts:678`) |
492
+ | `nodefony:kernel:ping` répond `-32601` sur mon endpoint | L'action `nodefony:kernel:ping` est déclarée par `@nodefony/studio` (`StudioRealtimeController.ts:114`) | Déclarer la sienne, ou lire `serverMethods` avant d'appeler |
493
+ | Le battement de cœur ne renvoie aucun pong | La notification `ping` est un no-op serveur (`RealtimeController.ts:831`) | Pour mesurer un RTT, utiliser une action RPC — `ping()` (`NodefonySocket.ts:1085`) |
494
+ | Une réponse reçue est ignorée sans message | Corrélation sur `id` **numériques** seulement (`JsonRpcPeer.ts:582`) | Ne pas fabriquer soi-même de réponse à `id` chaîne |
495
+ | Les premières frames envoyées après `connect()` semblent perdues | Le transport n'est branché qu'une fois le handshake terminé | Attendre `realtime:welcome` — le client le fait déjà |
485
496
 
486
497
  ## 🧪 Tests & couverture
487
498
 
@@ -491,7 +502,7 @@ carte de l'aperçu, régénérée depuis les résultats réels — jamais figés
491
502
 
492
503
  - **Le moteur, dans le cœur** (`src/nodefony/src/tests/`) : `JsonRpcPeer.test.ts` (classement des
493
504
  frames, corrélation, timeout, erreurs), `JsonRpcPeer.types.test.ts` (le contrat générique tient à
494
- la compilation), et la famille `RealtimeClient*.test.ts` — dispatch, identité annoncée par
505
+ la compilation), et la famille `NodefonySocket*.test.ts` — dispatch, identité annoncée par
495
506
  l'accueil, transport, `ping`. Ces suites **ne sont pas comptées** dans la carte du module
496
507
  `@nodefony/realtime` : elles appartiennent au workspace `nodefony`.
497
508
  - **Le câblage serveur, dans ce module** : `RealtimeController.test.ts` couvre les réponses
@@ -508,7 +519,7 @@ Couverture : `npm run coverage` dans `@nodefony/realtime` et dans le workspace `
508
519
  > [!CAUTION]
509
520
  > Plusieurs suites bout en bout exigent une infrastructure. **Un test sauté compte comme vert** :
510
521
  > avant de conclure « tout passe », vérifier les variables d'environnement — la source unique est
511
- > `vitest.gates.ts` à la racine, dont le rapport s'affiche en fin d'exécution.
522
+ > `scripts/test/vitest/gates.ts` à la racine, dont le rapport s'affiche en fin d'exécution.
512
523
 
513
524
  ## 🔗 Pour aller plus loin
514
525
 
package/docs/securite.md CHANGED
@@ -115,11 +115,11 @@ HTTP et WebSocket vivent dans le même contexte : la sécurité en hérite, **on
115
115
  politiques.**
116
116
 
117
117
  - Une zone du firewall couvre HTTP **et** WS par défaut (`realtime: true` —
118
- `security/nodefony/config/config.ts:109`). L'opt-out est explicite ; un opt-in aurait été
118
+ `security/nodefony/config/config.ts:132`). L'opt-out est explicite ; un opt-in aurait été
119
119
  _fail-open_ (une zone qui oublie le flag laisserait le WS anonyme).
120
120
  - Le verrou de frame consulte la **même** fonction de match que le HTTP, `Firewall.matchPath()`
121
- (`firewall.ts:719`), et la **même** hiérarchie de rôles, `Firewall.hasRole()`
122
- (`firewall.ts:482`). Invariant par construction : `api.request {path}` n'accorde jamais plus que
121
+ (`firewall.ts:783`), et la **même** hiérarchie de rôles, `Firewall.hasRole()`
122
+ (`firewall.ts:521`). Invariant par construction : `api.request {path}` n'accorde jamais plus que
123
123
  `GET {path}`.
124
124
  - L'identité du handshake est celle du firewall HTTP : `FirewallRealtimeAuthenticator`
125
125
  (`FirewallRealtimeAuthenticator.ts:57`) ne relit ni cookie ni base, il **promeut** l'`IUser` déjà
@@ -220,9 +220,9 @@ refus est **visible**, jamais un silence.
220
220
 
221
221
  ```typescript
222
222
  // frontend — le client isomorphe du core
223
- import { RealtimeClient } from "nodefony/client";
223
+ import { NodefonySocket } from "nodefony/client";
224
224
 
225
- const socket = new RealtimeClient({ url: "wss://app.exemple.com/rt/orders" });
225
+ const socket = new NodefonySocket({ url: "wss://app.exemple.com/rt/orders" });
226
226
 
227
227
  // Refus poussés par le serveur — première classe côté client.
228
228
  socket.onDenied((denied) => {
@@ -261,13 +261,13 @@ Nodefony pose **deux barrières successives**, à deux étages différents.
261
261
  | Allowlist stricte | `@nodefony/realtime` | **inactif** (`enabled: false`) | configurable | `close 4003` |
262
262
 
263
263
  **Barrière 1 — transport, active sans rien faire.** `HttpKernel.checkWebsocketOrigin()`
264
- (`http-kernel.ts:621`) exige que l'`Origin` du handshake corresponde au `Host` servi, avec tolérance
264
+ (`http-kernel.ts:724`) exige que l'`Origin` du handshake corresponde au `Host` servi, avec tolérance
265
265
  loopback en développement et une allowlist optionnelle (`allowedOrigins`,
266
- `http/nodefony/config/config.ts:552`) acceptant le hostname exact ou un wildcard à un label. Une
266
+ `http/nodefony/config/config.ts:570`) acceptant le hostname exact ou un wildcard à un label. Une
267
267
  requête **sans** `Origin` est acceptée : un attaquant non-navigateur n'a pas besoin de CSWSH.
268
268
 
269
269
  **Barrière 2 — module realtime, opt-in et plus stricte.** `RealtimeHub.checkOrigin()`
270
- (`RealtimeHub.ts:986`) consulte une garde compilée une fois au boot par `buildOriginGuard()`
270
+ (`RealtimeHub.ts:967`) consulte une garde compilée une fois au boot par `buildOriginGuard()`
271
271
  (`RealtimeService.ts:313`) depuis `checkOriginSchema` (`realtime/nodefony/config/config.ts:162`).
272
272
  Trois différences comptent :
273
273
 
@@ -292,18 +292,18 @@ n'est jamais traitée.
292
292
 
293
293
  ### Le pipeline exact
294
294
 
295
- `RealtimeController.onHandshake()` (`RealtimeController.ts:374`) exécute, une fois par connexion :
295
+ `RealtimeController.onHandshake()` (`RealtimeController.ts:450`) exécute, une fois par connexion :
296
296
 
297
297
  1. Construction d'un DTO neutre `IRealtimeHandshake` par `buildHandshakeFromContext()`
298
- (`RealtimeController.ts:1198`) — headers, cookies aplatis, url, origin, sous-protocoles. Aucune
298
+ (`RealtimeController.ts:1356`) — headers, cookies aplatis, url, origin, sous-protocoles. Aucune
299
299
  dépendance à `@nodefony/security` dans le contrat.
300
300
  2. Contrôle d'origine (verrou 1).
301
- 3. Résolution de l'authenticator par `RealtimeHub.resolveAuthenticator()` (`RealtimeHub.ts:961`) :
301
+ 3. Résolution de l'authenticator par `RealtimeHub.resolveAuthenticator()` (`RealtimeHub.ts:943`) :
302
302
  les matchers sont testés dans l'ordre d'enregistrement, **le premier qui matche capture**.
303
303
  4. `authenticator.authenticate(handshake)` — **async autorisé** (on est en cold path, une fois par
304
304
  connexion : lire un store est acceptable ici, jamais par frame).
305
305
  5. Pose du token sur la WeakMap `peer → token` via `RealtimeHub.setTokenForPeer()`
306
- (`RealtimeHub.ts:1018`), **avant** l'envoi du `welcome` : le lookup est garanti dès la première
306
+ (`RealtimeHub.ts:999`), **avant** l'envoi du `welcome` : le lookup est garanti dès la première
307
307
  frame.
308
308
 
309
309
  Un `throw` de `authenticate()` ferme la socket en `4001` « unauthorized », après un log `WARNING`
@@ -312,8 +312,8 @@ d'audit défectueux ne peut pas empêcher la fermeture.
312
312
 
313
313
  ### Les matchers — quelle porte, quel vigile
314
314
 
315
- `RealtimeHub.useAuthenticator()` (`RealtimeHub.ts:943`) associe un sélecteur à une stratégie.
316
- `compileMatcher()` (`RealtimeHub.ts:1418`) le compile une fois :
315
+ `RealtimeHub.useAuthenticator()` (`RealtimeHub.ts:925`) associe un sélecteur à une stratégie.
316
+ `compileMatcher()` (`RealtimeHub.ts:1399`) le compile une fois :
317
317
 
318
318
  - `pattern` chaîne → RegExp **préfixe ancrée**, méta-caractères échappés par `RegExp.escape`.
319
319
  `"/admin/"` matche `/admin/` et `/admin/foo`, littéralement.
@@ -321,16 +321,16 @@ d'audit défectueux ne peut pas empêcher la fermeture.
321
321
  - `host` optionnel → comparaison **stricte** (insensible à la casse) sur l'en-tête `Host`, sans
322
322
  wildcard.
323
323
  - Le match porte sur le **path**, query comprise, jamais sur l'URL absolue : `handshakePath()`
324
- (`RealtimeController.ts:1250`) extrait `pathname + search` du `WebsocketContext.url`, qui est
324
+ (`RealtimeController.ts:1408`) extrait `pathname + search` du `WebsocketContext.url`, qui est
325
325
  absolu. Sans cette extraction, un matcher `^/nodefony/…` ne se déclencherait jamais.
326
326
 
327
327
  `@nodefony/security` enregistre ces matchers automatiquement dans `Firewall.#wireRealtime()`
328
- (`firewall.ts:279`) : **une instance d'authenticator par zone protégée**, car le hub dédoublonne par
328
+ (`firewall.ts:306`) : **une instance d'authenticator par zone protégée**, car le hub dédoublonne par
329
329
  identité d'instance et une instance partagée n'enregistrerait que le premier matcher.
330
330
 
331
331
  ### Zero Trust — il y a toujours un token
332
332
 
333
- `RealtimeHub.getTokenForPeer()` (`RealtimeHub.ts:1030`) ne renvoie **jamais** `null` : à défaut de
333
+ `RealtimeHub.getTokenForPeer()` (`RealtimeHub.ts:1011`) ne renvoie **jamais** `null` : à défaut de
334
334
  token posé, c'est `ANONYMOUS_REALTIME_TOKEN` (`AnonymousRealtimeToken.ts:18`), singleton gelé,
335
335
  `isAuthenticated() === false`, `roles: ["ROLE_ANONYMOUS"]`. Le code consommateur (verrou, audit)
336
336
  n'a jamais à écrire « et s'il n'y a pas de token ? ».
@@ -422,7 +422,7 @@ sequenceDiagram
422
422
  P-->>C: realtime:denied {channel, reason:"forbidden"}
423
423
  ```
424
424
 
425
- Le verrou est **strictement synchrone** — `FrameAuthorizer` (`RealtimeHub.ts:39`). Un `await` par
425
+ Le verrou est **strictement synchrone** — `FrameAuthorizer` (`RealtimeHub.ts:40`). Un `await` par
426
426
  frame coûterait une microtask et sérialiserait le pipeline RPC de la connexion. Il ne lit que de la
427
427
  mémoire : le token déjà résolu, et la cible de la frame.
428
428
 
@@ -468,32 +468,32 @@ Trois durcissements méritent d'être connus :
468
468
  `authenticated: true`. Le test porte sur le **namespace du canal**, pas sur le préfixe de la règle
469
469
  qui a matché : un préfixe de config plus court ou altéré ne contourne rien.
470
470
  - **La config passe avant les défauts** — les règles de `realtimeChannels`
471
- (`security/nodefony/config/config.ts:1130`) sont placées en tête, premier match gagnant. On peut
471
+ (`security/nodefony/config/config.ts:1447`) sont placées en tête, premier match gagnant. On peut
472
472
  donc re-cibler `nodefony:syslog` sur `ROLE_SECURITY_AUDITOR` ; on ne peut pas l'ouvrir à l'anonyme.
473
473
 
474
474
  Le canal du journal d'audit (`nodefony:audit`) est enregistré comme **canal système** sur le hub
475
- (`RealtimeHub.registerSystemChannel()`, `RealtimeHub.ts:1297`) : il devient servable par n'importe
475
+ (`RealtimeHub.registerSystemChannel()`, `RealtimeHub.ts:1278`) : il devient servable par n'importe
476
476
  quel endpoint realtime, sans qu'aucun controller ne le connaisse — et il est gardé par sa règle
477
477
  dédiée. Son enregistrement est **couplé** à la pose du verrou (même condition), donc il n'existe
478
478
  jamais de canal d'audit non gardé.
479
479
 
480
480
  ### La policy métier — déclarer sur le canal
481
481
 
482
- `@RealtimeChannel(name, policy)` (`realtimeDecorators.ts:192`) et `@RealtimeInbound(name, policy)`
483
- (`realtimeDecorators.ts:231`) attachent un `IChannelPolicy` au **nom** du canal. Les trois axes sont
482
+ `@RealtimeChannel(name, policy)` (`realtimeDecorators.ts:240`) et `@RealtimeInbound(name, policy)`
483
+ (`realtimeDecorators.ts:278`) attachent un `IChannelPolicy` au **nom** du canal. Les trois axes sont
484
484
  cumulatifs (ET) ; un axe absent n'impose rien :
485
485
 
486
486
  | Axe | Sens | Évalué par |
487
487
  | --------------- | ----------------------------------------------------------------------------- | ---------------------------------------- |
488
488
  | `authenticated` | token non anonyme | `satisfies()` (`frameAuthorizer.ts:290`) |
489
- | `roles` | un des rôles suffit, **hiérarchie comprise** | `Firewall.hasRole()` (`firewall.ts:482`) |
489
+ | `roles` | un des rôles suffit, **hiérarchie comprise** | `Firewall.hasRole()` (`firewall.ts:521`) |
490
490
  | `scopes` | un des scopes suffit — axe API (JWT, clé API), une session BFF n'en porte pas | comparaison directe |
491
491
 
492
- Une policy **vide** n'est pas enregistrée : `definePolicy()` (`realtimeDecorators.ts:40`) ignore un
492
+ Une policy **vide** n'est pas enregistrée : `definePolicy()` (`realtimeDecorators.ts:91`) ignore un
493
493
  objet sans contrainte — le canal reste libre, le registre reste vide. Les déclarations sont publiées
494
- au hub **au handshake**, pas au boot (`RealtimeHub.registerChannelPolicy()`, `RealtimeHub.ts:1127`,
494
+ au hub **au handshake**, pas au boot (`RealtimeHub.registerChannelPolicy()`, `RealtimeHub.ts:1124`,
495
495
  idempotent) ; le décideur les relit par `RealtimeService.resolveChannelPolicy()`
496
- (`RealtimeService.ts:277`).
496
+ (`RealtimeService.ts:291`).
497
497
 
498
498
  ### ⚠️ Une policy est attachée à un nom EXACT — les canaux dérivés n'héritent pas
499
499
 
@@ -508,7 +508,7 @@ là, le canal fonctionne, et le refus attendu n'arrive jamais.
508
508
 
509
509
  Le serveur le **dit** désormais. Au premier canal servi dynamiquement sans politique, alors que le
510
510
  module en déclare pour d'autres canaux, un avertissement est journalisé une fois
511
- (`RealtimeHub.noticeUnguardedDynamicChannel()`, `RealtimeHub.ts:1254`) :
511
+ (`RealtimeHub.noticeUnguardedDynamicChannel()`, `RealtimeHub.ts:1251`) :
512
512
 
513
513
  ```
514
514
  le canal "chat:room:1000" est servi par une fabrique DYNAMIQUE et n'est couvert par AUCUNE
@@ -535,21 +535,21 @@ Le verrou n'existe que si quelqu'un le pose. Deux conditions doivent être vraie
535
535
  1. `@nodefony/security` est chargé, et
536
536
  2. au moins une zone a `security: true` **et** `realtime: true`.
537
537
 
538
- C'est exactement le test de `Firewall.#wireRealtime()` (`firewall.ts:279`) : sans zone qualifiante,
538
+ C'est exactement le test de `Firewall.#wireRealtime()` (`firewall.ts:306`) : sans zone qualifiante,
539
539
  `wired` reste faux, `setFrameAuthorizer` n'est jamais appelé, et **aucune** policy de canal n'est
540
540
  évaluée — ni métier, ni système. `nodefony:syslog` redevient un canal ordinaire.
541
541
 
542
542
  Deuxième subtilité : `beforeDispatch` n'est branché sur une connexion que si le verrou est **déjà**
543
- posé au moment de son handshake (`RealtimeController.ts:504`, via
544
- `RealtimeHub.hasFrameAuthorizer()` — `RealtimeHub.ts:1058`). Choix de performance délibéré (un hub
543
+ posé au moment de son handshake (`RealtimeController.ts:562`, via
544
+ `RealtimeHub.hasFrameAuthorizer()` — `RealtimeHub.ts:1039`). Choix de performance délibéré (un hub
545
545
  non sécurisé garde un coût nul par frame), mais avec une conséquence : **une connexion ouverte avant
546
546
  la pose du verrou n'est jamais gardée**, et ce jusqu'à sa fermeture. En fonctionnement normal le
547
547
  firewall se construit au boot, avant tout trafic ; le cas ne se présente qu'en appelant
548
548
  `setFrameAuthorizer()` à chaud.
549
549
 
550
550
  **Le refus de dégrader en silence.** Quand des policies sont déclarées sans décideur câblé,
551
- `RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:1117`) renvoie `true` et le controller
552
- émet un WARNING explicite, une seule fois par process (`RealtimeController.ts:558`) :
551
+ `RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:1098`) renvoie `true` et le controller
552
+ émet un WARNING explicite, une seule fois par process (`RealtimeController.ts:639`) :
553
553
 
554
554
  ```text
555
555
  Realtime channel policies declared but NO frame authorizer is wired —
@@ -575,8 +575,8 @@ Le refus doit être observable sans devenir un oracle. Nodefony tranche ainsi :
575
575
 
576
576
  | Type de frame refusée | Réponse au client |
577
577
  | ----------------------------------- | -------------------------------------------------------------------------------- |
578
- | Requête (avec `id`) | `-32001 "unauthorized"` — `JsonRpcPeer.receive()` (`JsonRpcPeer.ts:390`) |
579
- | Notification (`subscribe`, inbound) | `realtime:denied { channel, reason: "forbidden" }` (`RealtimeController.ts:432`) |
578
+ | Requête (avec `id`) | `-32001 "unauthorized"` — `JsonRpcPeer.receive()` (`JsonRpcPeer.ts:419`) |
579
+ | Notification (`subscribe`, inbound) | `realtime:denied { channel, reason: "forbidden" }` (`RealtimeController.ts:553`) |
580
580
  | Dépassement du plafond de canaux | `realtime:denied { channel, reason: "limit" }` |
581
581
  | Canal sans aucun producteur | `realtime:denied { channel, reason: "unknown" }` |
582
582
 
@@ -602,20 +602,20 @@ ne peut pas relire la session. Un administrateur qui se déconnecte garderait se
602
602
 
603
603
  Nodefony ferme l'écart par deux mécanismes de granularité différente.
604
604
 
605
- | Surface | Re-validation | Fenêtre d'exposition | Où |
606
- | --------------------------- | -------------------- | -------------------- | --------------------------------------------------------------------- |
607
- | `api.request` (data plane) | **à chaque frame** | nulle | `RealtimeController.invokeApiRequest()` (`RealtimeController.ts:949`) |
608
- | `subscribe` / flux de canal | **périodique**, 30 s | ≤ 30 s | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts:826`) |
605
+ | Surface | Re-validation | Fenêtre d'exposition | Où |
606
+ | --------------------------- | -------------------- | -------------------- | ---------------------------------------------------------------------- |
607
+ | `api.request` (data plane) | **à chaque frame** | nulle | `RealtimeController.invokeApiRequest()` (`RealtimeController.ts:1070`) |
608
+ | `subscribe` / flux de canal | **périodique**, 30 s | ≤ 30 s | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts:815`) |
609
609
 
610
610
  **Sur `api.request`**, `token.isValid()` est appelé avant l'exécution de l'action ; identité périmée
611
611
  ou changée → `-32000` avec `status: 401`, et le client bascule sur un `fetch` HTTP porteur du cookie
612
612
  courant. Une erreur de re-validation vaut refus (fail-closed).
613
613
 
614
614
  **Sur les canaux**, le hub n'inscrit au registre de révocation que les connexions dont le token porte
615
- `isValid` (`RealtimeController.ts:659`) — anonymes et JWT n'y entrent jamais, coût nul.
616
- `RealtimeHub.registerRevocable()` (`RealtimeHub.ts:794`) démarre un `setInterval` `unref` au premier
615
+ `isValid` (`RealtimeController.ts:715`) — anonymes et JWT n'y entrent jamais, coût nul.
616
+ `RealtimeHub.registerRevocable()` (`RealtimeHub.ts:796`) démarre un `setInterval` `unref` au premier
617
617
  inscrit et l'arrête dès que le registre se vide : zéro timer au repos. Période :
618
- `REVOCATION_REVALIDATE_MS` (`RealtimeHub.ts:111`), 30 s, alignée sur le heartbeat WS.
618
+ `REVOCATION_REVALIDATE_MS` (`RealtimeHub.ts:112`), 30 s, alignée sur le heartbeat WS.
619
619
 
620
620
  Ce que `realtimeRevocation.attack.test.ts` prouve exactement :
621
621
 
@@ -629,7 +629,7 @@ Ce que `realtimeRevocation.attack.test.ts` prouve exactement :
629
629
  | registre vide | tick no-op, aucun crash |
630
630
 
631
631
  La source de vérité côté security est `buildSessionRevalidator()`
632
- (`FirewallRealtimeAuthenticator.ts:240`) : il relit la session BFF **par son id capturé au handshake**
632
+ (`FirewallRealtimeAuthenticator.ts:275`) : il relit la session BFF **par son id capturé au handshake**
633
633
  et vérifie qu'elle est toujours vivante **et toujours celle du même utilisateur** — ce second point
634
634
  attrape le changement de compte sur un navigateur partagé.
635
635
 
@@ -656,12 +656,12 @@ pas** borné.
656
656
  Chaque canal ouvert coûte un provider, un ticker et une entrée de Map. Sans borne, une connexion
657
657
  peut abonner jusqu'à l'OOM — un déni de service mémoire déclenché par **un seul** client.
658
658
 
659
- `RealtimeController.startChannel()` (`RealtimeController.ts:815`) refuse au-delà de
659
+ `RealtimeController.startChannel()` (`RealtimeController.ts:869`) refuse au-delà de
660
660
  `limits.maxChannelsPerConnection` (`realtime/nodefony/config/config.ts:142`), défaut **256**,
661
661
  `null` pour illimité. Points prouvés par `realtimeChannelCap.attack.test.ts` :
662
662
 
663
663
  - le plafond est actif **même sans `RealtimeService`** — le hub porte le défaut 256
664
- (`RealtimeHub.ts:374`), il n'y a pas de fenêtre où la garde n'existerait pas ;
664
+ (`RealtimeHub.ts:380`), il n'y a pas de fenêtre où la garde n'existerait pas ;
665
665
  - au-delà, le hub **n'est jamais appelé** : le canal n'est pas ouvert, aucun provider ne démarre ;
666
666
  - le refus est **observable** (`realtime:denied`, motif `limit`) ;
667
667
  - un re-`subscribe` d'un canal déjà tenu **ne consomme pas de slot** (l'idempotence est vérifiée
@@ -680,7 +680,7 @@ canaux). `WsConnectionTransport.send()` (`WsConnectionTransport.ts:82`) applique
680
680
 
681
681
  | `bufferedAmount` | Action |
682
682
  | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
683
- | > `websocket.maxBackpressure` — 4 MiB par défaut (`http`, `http/nodefony/config/config.ts:1048`) | politique `websocket.backpressurePolicy` : `drop` (défaut) jette la frame — canaux d'état, le prochain snapshot la remplace |
683
+ | > `websocket.maxBackpressure` — 4 MiB par défaut (`http`, `http/nodefony/config/config.ts:1226`) | politique `websocket.backpressurePolicy` : `drop` (défaut) jette la frame — canaux d'état, le prochain snapshot la remplace |
684
684
  | `websocket.backpressureCloseAfterDrops` drops CONSÉCUTIFS — 1000 par défaut | `close(1013)` « Try Again Later » ; le client se reconnecte et resynchronise |
685
685
 
686
686
  ### Taille des messages entrants
@@ -728,9 +728,9 @@ Source unique des défauts : `realtimeConfigSchema` (`realtime/nodefony/config/c
728
728
  Cette section existe pour éviter une confiance mal placée. Chaque point est vérifié au code.
729
729
 
730
730
  **1. Il n'y a pas de frontière de canal entre modules.** Un nom de canal est une chaîne dans un
731
- registre global (`#channelPolicies` du hub singleton, `RealtimeHub.ts:325`). Rien n'empêche le
731
+ registre global (`#channelPolicies` du hub singleton, `RealtimeHub.ts:327`). Rien n'empêche le
732
732
  controller du module A de servir un canal au nom d'un canal du module B, ni de déclarer une policy
733
- plus faible sur ce même nom — la déclaration la plus récente écrase (`RealtimeHub.ts:751`, idempotent
733
+ plus faible sur ce même nom — la déclaration la plus récente écrase (`RealtimeHub.ts:757`, idempotent
734
734
  par écrasement). En pratique, l'exploitation exige que le controller fautif accepte le nom (sa
735
735
  factory doit renvoyer un provider), donc un override permissif de `createRealtimeChannel` ; et les
736
736
  namespaces réservés restent couverts par le plancher système **si** le verrou est posé. Une garde
@@ -769,7 +769,7 @@ n'est pas appliquée.
769
769
  | **RFC 6455 §7.4.2** (codes 4000-4999) | `4001` unauthorized / session revoked, `4003` origin not allowed |
770
770
  | **RFC 6455 §7.4.1 / registre IANA** | `1008` Policy Violation (origine transport), `1009` Message Too Big, `1013` Try Again Later |
771
771
  | **RFC 6455 §4.1** | `Sec-WebSocket-Protocol` normalisé en liste dans le DTO de handshake |
772
- | **RFC 7230 §5.4** | Comparaison du `Host` par le matcher d'authenticator |
772
+ | **RFC 9110 §7.2** | Comparaison du `Host` par le matcher d'authenticator |
773
773
  | **JSON-RPC 2.0 §5.1** | `-32001` (plage `Server error`) pour une requête refusée, `-32602` pour des params invalides |
774
774
  | **OWASP WSTG-CLNT-10** | Anti-CSWSH : validation d'`Origin` obligatoire, les navigateurs n'appliquent pas CORS aux WS |
775
775
  | **Zero Trust / OWASP A01** | Zone protégée + anonyme = refus ; token toujours présent, jamais `null` |
@@ -777,7 +777,7 @@ n'est pas appliquée.
777
777
  ## 📡 Observabilité — Studio
778
778
 
779
779
  - **Santé de la socket** : `/nodefony/realtime/api/health`, alimenté par `RealtimeHub.probe()`
780
- (`RealtimeHub.ts:865`) — canaux, abonnés, fan-out, connexions, back-pressure et carte d'identité
780
+ (`RealtimeHub.ts:847`) — canaux, abonnés, fan-out, connexions, back-pressure et carte d'identité
781
781
  du backplane. Un `drops` qui grimpe signale des clients en souffrance ; un `slowConsumers` non nul
782
782
  précède souvent une fermeture `1013`. Même snapshot en flux sur le canal `nodefony:socket`
783
783
  (namespace réservé : `ROLE_NODEFONY_ADMIN`).