@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.
- package/README.md +2 -2
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +17 -10
- package/dist/nodefony/config/config.js +6 -3
- package/dist/nodefony/config/configSchema.js +19 -0
- package/dist/nodefony/config/defineModuleConfig.js +42 -15
- package/dist/nodefony/decorators/realtimeDecorators.js +68 -17
- package/dist/nodefony/src/backplane/RedisBackplane.js +21 -4
- package/dist/nodefony/src/backplane/bootNotices.js +43 -0
- package/dist/nodefony/src/backplane/publishQueue.js +1 -4
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +15 -4
- package/dist/nodefony/src/guards.js +41 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +1 -4
- package/dist/nodefony/src/server/RealtimeController.js +120 -50
- package/dist/nodefony/src/server/RealtimeHub.js +29 -51
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +13 -5
- package/dist/nodefony/src/server/syslogUplink.js +4 -2
- package/dist/nodefony/src/service/RealtimeService.js +3 -2
- package/dist/nodefony/testing/index.js +41 -1
- package/dist/types/nodefony/config/configSchema.d.ts +12 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +29 -6
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +19 -2
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +1 -1
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +7 -0
- package/dist/types/nodefony/src/backplane/bootNotices.d.ts +25 -0
- package/dist/types/nodefony/src/guards.d.ts +32 -0
- package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +13 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +24 -4
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +22 -16
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +1 -1
- package/dist/types/nodefony/testing/index.d.ts +2 -2
- package/docs/actions.md +111 -28
- package/docs/architecture.md +77 -75
- package/docs/cadence-et-contre-pression.md +634 -0
- package/docs/configuration.md +39 -34
- package/docs/cookbook-chat.md +31 -27
- package/docs/index.md +35 -21
- package/docs/observabilite.md +17 -15
- package/docs/protocole.md +74 -63
- package/docs/securite.md +49 -49
- package/docs/vocabulaire.md +76 -75
- package/package.json +16 -12
- 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:
|
|
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{"
|
|
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
|
|
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`
|
|
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:
|
|
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:
|
|
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:
|
|
130
|
-
|
|
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:
|
|
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:
|
|
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 {
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
341
|
-
| `unsubscribe` | client→server | non | « arrête » — dernier abonné, le producteur est libéré | `RealtimeController.ts:
|
|
342
|
-
| `ping` | client→server | non | Battement de cœur — **no-op serveur**, aucun pong | `
|
|
343
|
-
| `<canal>` | server→client | non | Push d'un message : le **nom du canal est la `method`**
|
|
344
|
-
| `<canal entrant>` | client→server | non | Le client pousse sur un canal déclaré entrant | `RealtimeController.ts:
|
|
345
|
-
| `realtime:welcome` | server→client | non | L'accueil : 5 champs, dont l'identité résolue | `RealtimeController.ts:
|
|
346
|
-
| `realtime:denied` | server→client | non | Rend OBSERVABLE le refus d'une notification | `RealtimeController.ts:
|
|
347
|
-
| `api.request` | client→server | **oui** | Pont API — rejoue une route HTTP sur la socket (désactivé par défaut) | `RealtimeController.ts:
|
|
348
|
-
| `<action>` | client→server | **oui** | Toute action déclarée par `@RealtimeAction` | `realtimeDecorators.ts:
|
|
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:
|
|
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
|
-
> `
|
|
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:
|
|
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
|
|
401
|
-
| -------- |
|
|
402
|
-
| `-32601` | `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts:
|
|
403
|
-
| `-32603` | même méthode (`JsonRpcPeer.ts:
|
|
404
|
-
| `-32001` | le refus du verrou de frame (`JsonRpcPeer.ts:
|
|
405
|
-
| `-32000` | défaut du constructeur de `RpcError` (`JsonRpcPeer.ts:
|
|
406
|
-
| `-32602` | le pont API, via `RpcError` (`RealtimeController.ts:
|
|
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:
|
|
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:
|
|
420
|
-
- une frame lisible mais non conforme
|
|
421
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
437
|
-
| une **notification** | la notification `realtime:denied` (`RealtimeController.ts:
|
|
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:
|
|
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()` (`
|
|
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:
|
|
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:
|
|
475
|
-
| Le handler passé à `subscribe` n'est jamais appelé | `
|
|
476
|
-
| `request()` expire immédiatement, ou ignore le délai | Signature **positionnelle** `(méthode, params, 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` (`
|
|
478
|
-
| Une frame malformée ne renvoie **aucune** erreur | Ni `-32700` ni `-32600` ne sont émis — silence + audit (`JsonRpcPeer.ts:
|
|
479
|
-
| L'exception du serveur n'arrive jamais au client | Zero Trust : tout throw ordinaire devient `-32603` générique (`JsonRpcPeer.ts:
|
|
480
|
-
| Une notification refusée disparaît sans trace côté client | Sans `id`, aucune réponse possible (`beforeDispatch`, `JsonRpcPeer.ts:
|
|
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:
|
|
483
|
-
| Une réponse reçue est ignorée sans message | Corrélation sur `id` **numériques** seulement (`JsonRpcPeer.ts:
|
|
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 `
|
|
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
|
|
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:
|
|
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:
|
|
122
|
-
(`firewall.ts:
|
|
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 {
|
|
223
|
+
import { NodefonySocket } from "nodefony/client";
|
|
224
224
|
|
|
225
|
-
const socket = new
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
316
|
-
`compileMatcher()` (`RealtimeHub.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
483
|
-
(`realtimeDecorators.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
544
|
-
`RealtimeHub.hasFrameAuthorizer()` — `RealtimeHub.ts:
|
|
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:
|
|
552
|
-
émet un WARNING explicite, une seule fois par process (`RealtimeController.ts:
|
|
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:
|
|
579
|
-
| Notification (`subscribe`, inbound) | `realtime:denied { channel, reason: "forbidden" }` (`RealtimeController.ts:
|
|
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:
|
|
608
|
-
| `subscribe` / flux de canal | **périodique**, 30 s | ≤ 30 s | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts:
|
|
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:
|
|
616
|
-
`RealtimeHub.registerRevocable()` (`RealtimeHub.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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`).
|