@nodefony/realtime 10.0.0-alpha.8 → 10.0.0-beta.1
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/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +2 -2
- package/dist/nodefony/src/backplane/envelope.js +2 -1
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +1 -1
- package/dist/nodefony/src/server/RealtimeController.js +52 -11
- package/dist/nodefony/src/server/RealtimeHub.js +14 -12
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +9 -7
- package/dist/nodefony/src/service/RealtimeService.js +7 -6
- package/dist/nodefony/testing/index.js +4 -2
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +3 -3
- package/dist/types/nodefony/interfaces/IRealtimeAuthenticatorMatcher.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +1 -1
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +27 -0
- package/dist/types/nodefony/testing/index.d.ts +2 -2
- package/docs/actions.md +14 -13
- package/docs/architecture.md +35 -35
- package/docs/configuration.md +12 -11
- package/docs/cookbook-chat.md +18 -18
- package/docs/index.md +5 -8
- package/docs/observabilite.md +11 -13
- package/docs/protocole.md +28 -26
- package/docs/securite.md +54 -54
- package/docs/vocabulaire.md +35 -35
- package/package.json +12 -13
package/docs/protocole.md
CHANGED
|
@@ -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:390`) 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
|
|
|
@@ -126,7 +126,7 @@ divergeaient ; c'est précisément la classe de bug que l'isomorphisme supprime.
|
|
|
126
126
|
|
|
127
127
|
**Le rôle se lit sur `method`, pas sur `id`.** La lecture naïve (« `id` présent = requête ») casse
|
|
128
128
|
sur les réponses, qui portent un `id` sans être des appels. `JsonRpcPeer.receive()`
|
|
129
|
-
(`JsonRpcPeer.ts:
|
|
129
|
+
(`JsonRpcPeer.ts:390`) teste donc `method` d'abord, puis la présence d'un `id`. Une frame `id` seul
|
|
130
130
|
est une **réponse** et ne déclenche jamais `-32601` à tort.
|
|
131
131
|
|
|
132
132
|
**Un échec ne raconte rien par défaut.** Un handler qui lève une exception ordinaire produit un
|
|
@@ -246,7 +246,7 @@ Sur le fil, dans l'inspecteur réseau du navigateur, exactement cinq frames :
|
|
|
246
246
|
```jsonc
|
|
247
247
|
// 1. serveur → client : l'accueil, aussitôt après le handshake
|
|
248
248
|
{"jsonrpc":"2.0","method":"realtime:welcome","params":{
|
|
249
|
-
"ts":1770000000000,"protocol":"jsonrpc-2.0",
|
|
249
|
+
"ts":1770000000000,"protocol":"jsonrpc-2.0","env":"development",
|
|
250
250
|
"channels":["chat:room-42"],"methods":["chat:history"],
|
|
251
251
|
"identity":{"type":"anonymous","authenticated":false,
|
|
252
252
|
"userIdentifier":"anonymous","roles":["ROLE_ANONYMOUS"],"scopes":[]}}}
|
|
@@ -264,9 +264,11 @@ Sur le fil, dans l'inspecteur réseau du navigateur, exactement cinq frames :
|
|
|
264
264
|
{"jsonrpc":"2.0","method":"chat:room-42","params":{"text":"salut"}}
|
|
265
265
|
```
|
|
266
266
|
|
|
267
|
-
L'accueil porte
|
|
268
|
-
(`IRealtimeWelcome`, `RealtimeEventMap.ts:
|
|
269
|
-
(`
|
|
267
|
+
L'accueil porte cinq champs — `ts`, `protocol`, `channels`, `methods`, `identity`
|
|
268
|
+
(`IRealtimeWelcome`, `RealtimeEventMap.ts:231`) — plus un sixième, `env`, **uniquement hors
|
|
269
|
+
production** (`welcomeEnv()`, `welcomeEnv.ts:19`) : c'est lui que porte l'exemple ci-dessus, capturé
|
|
270
|
+
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`.
|
|
270
272
|
|
|
271
273
|
## 🔌 Anatomie d'une frame, champ par champ
|
|
272
274
|
|
|
@@ -335,19 +337,19 @@ ouvertes à l'application. Colonne `id` : présent = requête (réponse due), ab
|
|
|
335
337
|
|
|
336
338
|
| Méthode | Direction | `id` ? | Rôle | Ancrage |
|
|
337
339
|
| ------------------ | ------------- | :-----: | --------------------------------------------------------------------- | --------------------------- |
|
|
338
|
-
| `subscribe` | client→server | non | « pousse-moi ce canal » — `params.channel` | `RealtimeController.ts:
|
|
339
|
-
| `unsubscribe` | client→server | non | « arrête » — dernier abonné, le producteur est libéré | `RealtimeController.ts:
|
|
340
|
-
| `ping` | client→server | non | Battement de cœur — **no-op serveur**, aucun pong | `RealtimeClient.ts:
|
|
341
|
-
| `<canal>` | server→client | non | Push d'un message : le **nom du canal est la `method`** | `RealtimeController.ts:
|
|
342
|
-
| `<canal entrant>` | client→server | non | Le client pousse sur un canal déclaré entrant | `RealtimeController.ts:
|
|
343
|
-
| `realtime:welcome` | server→client | non | L'accueil : 5 champs, dont l'identité résolue | `RealtimeController.ts:
|
|
344
|
-
| `realtime:denied` | server→client | non | Rend OBSERVABLE le refus d'une notification | `RealtimeController.ts:
|
|
345
|
-
| `api.request` | client→server | **oui** | Pont API — rejoue une route HTTP sur la socket (désactivé par défaut) | `RealtimeController.ts:
|
|
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` |
|
|
346
348
|
| `<action>` | client→server | **oui** | Toute action déclarée par `@RealtimeAction` | `realtimeDecorators.ts:101` |
|
|
347
349
|
|
|
348
350
|
> [!TIP]
|
|
349
351
|
> **L'accueil est ta carte du territoire.** `methods` et `channels` sont construits à partir de ce
|
|
350
|
-
> que l'endpoint expose réellement (`RealtimeController.ts:
|
|
352
|
+
> que l'endpoint expose réellement (`RealtimeController.ts:710`) : un client peut activer ou griser
|
|
351
353
|
> ses commandes sans rien coder en dur. Côté navigateur, ils se lisent en `socket.serverMethods` et
|
|
352
354
|
> `socket.serverChannels`.
|
|
353
355
|
|
|
@@ -357,12 +359,12 @@ Les quatre formes de frame circulent en permanence sous tes yeux — ce schéma
|
|
|
357
359
|
> `nodefony:kernel:ping` et `nodefony:kernel:gc` (`StudioRealtimeController.ts:114`) sont des exemples d'actions,
|
|
358
360
|
> **pas des méthodes du cœur temps réel** : elles sont déclarées par le contrôleur
|
|
359
361
|
> d'administration de `@nodefony/studio`. Un endpoint applicatif ne les expose pas. Le helper
|
|
360
|
-
> `RealtimeClient.ping()` (`RealtimeClient.ts:
|
|
362
|
+
> `RealtimeClient.ping()` (`RealtimeClient.ts:878`) mesure le RTT en les appelant — il suppose donc
|
|
361
363
|
> un endpoint qui les déclare, contrairement à la notification `ping` du battement de cœur, qui
|
|
362
364
|
> n'attend jamais de réponse.
|
|
363
365
|
|
|
364
366
|
`subscribe` et `unsubscribe` ne sont **pas** des actions enregistrées : elles sont traitées dans
|
|
365
|
-
`onRealtimeNotification()` (`RealtimeController.ts:
|
|
367
|
+
`onRealtimeNotification()` (`RealtimeController.ts:784`). Envoyées avec un `id`, elles seraient
|
|
366
368
|
classées « requête », ne trouveraient aucun handler et récolteraient un `-32601`.
|
|
367
369
|
|
|
368
370
|
## Une conversation type, de bout en bout
|
|
@@ -396,11 +398,11 @@ de `RpcError`.
|
|
|
396
398
|
|
|
397
399
|
| Code | Émis par | Déclencheur | Ce que voit le pair |
|
|
398
400
|
| -------- | ---------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------- |
|
|
399
|
-
| `-32601` | `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts:
|
|
401
|
+
| `-32601` | `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts:499`) | requête vers une action non enregistrée | `method not found: <nom>` |
|
|
400
402
|
| `-32603` | même méthode (`JsonRpcPeer.ts:528`) | le handler a levé une exception **ordinaire** | `internal error` — générique, rien d'autre |
|
|
401
403
|
| `-32001` | le refus du verrou de frame (`JsonRpcPeer.ts:400`) | `beforeDispatch` a dit non **sur une requête** | `unauthorized`, sans jamais dire pourquoi |
|
|
402
404
|
| `-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 |
|
|
403
|
-
| `-32602` | le pont API, via `RpcError` (`RealtimeController.ts:
|
|
405
|
+
| `-32602` | le pont API, via `RpcError` (`RealtimeController.ts:787`) | `api.request` appelé avec un `params.path` invalide | message explicite (l'appel est malformé) |
|
|
404
406
|
|
|
405
407
|
Et un échec qui n'est **pas** une frame : l'expiration. `startCall()` ne reçoit rien dans le délai
|
|
406
408
|
imparti, supprime l'entrée en attente et rejette localement avec `RPC timeout: <méthode>`
|
|
@@ -419,7 +421,7 @@ porte** ici :
|
|
|
419
421
|
|
|
420
422
|
Le choix est délibéré : une frame cassée n'a pas d'`id` digne de confiance, donc pas de corrélation
|
|
421
423
|
possible ; et répondre systématiquement à du bruit offre à un attaquant un amplificateur gratuit.
|
|
422
|
-
Le refus reste **traçable** — c'est le motif `invalid` de `FrameAuditReason` (`JsonRpcPeer.ts:
|
|
424
|
+
Le refus reste **traçable** — c'est le motif `invalid` de `FrameAuditReason` (`JsonRpcPeer.ts:155`),
|
|
423
425
|
qui alimente le journal d'audit avec le pair concerné.
|
|
424
426
|
|
|
425
427
|
### Le refus d'une notification n'est pas une erreur
|
|
@@ -431,12 +433,12 @@ radicalement :
|
|
|
431
433
|
| La frame refusée est… | Ce qui part | Pourquoi |
|
|
432
434
|
| --------------------- | --------------------------------------------------------------- | ---------------------------------------------------- |
|
|
433
435
|
| une **requête** | `-32001 "unauthorized"` (`JsonRpcPeer.ts:413`) | Un `id` existe : il y a un canal de réponse |
|
|
434
|
-
| une **notification** | la notification `realtime:denied` (`RealtimeController.ts:
|
|
436
|
+
| une **notification** | la notification `realtime:denied` (`RealtimeController.ts:496`) | Aucun `id` : sans elle, le client se croirait abonné |
|
|
435
437
|
|
|
436
|
-
`IRealtimeDenied` (`RealtimeEventMap.ts:
|
|
438
|
+
`IRealtimeDenied` (`RealtimeEventMap.ts:269`) porte `channel` et `reason` — plus un `detail` optionnel, posé hors production seulement — et le motif
|
|
437
439
|
est **générique**. Jamais « il te manque `ROLE_ADMIN` » : ce serait un oracle d'autorisation, un
|
|
438
440
|
attaquant y lirait la carte des droits. Deux motifs circulent : `forbidden` (le verrou a dit non) et
|
|
439
|
-
`limit` (le plafond de canaux de la connexion est atteint, `RealtimeController.ts:
|
|
441
|
+
`limit` (le plafond de canaux de la connexion est atteint, `RealtimeController.ts:811`). Côté client,
|
|
440
442
|
`onDenied()` (`RealtimeClient.ts:469`) branche un handler dessus.
|
|
441
443
|
|
|
442
444
|
> [!CAUTION]
|
|
@@ -467,14 +469,14 @@ décrits dans [la page sécurité](./securite.md).
|
|
|
467
469
|
| Symptôme | Cause | Correction |
|
|
468
470
|
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
469
471
|
| `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)` |
|
|
470
|
-
| Le handler passé à `subscribe` n'est jamais appelé | `RealtimeClient.subscribe()` prend **un seul** argument (`RealtimeClient.ts:
|
|
472
|
+
| 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 |
|
|
471
473
|
| `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 |
|
|
472
474
|
| 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 |
|
|
473
475
|
| 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 |
|
|
474
476
|
| 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` |
|
|
475
|
-
| 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:
|
|
477
|
+
| 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`) |
|
|
476
478
|
| `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 |
|
|
477
|
-
| Le battement de cœur ne renvoie aucun pong | La notification `ping` est un no-op serveur (`RealtimeController.ts:
|
|
479
|
+
| 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`) |
|
|
478
480
|
| 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 |
|
|
479
481
|
| 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à |
|
|
480
482
|
|
package/docs/securite.md
CHANGED
|
@@ -93,16 +93,16 @@ Les étapes 1 et 2 appartiennent à `@nodefony/http` et `@nodefony/security` : l
|
|
|
93
93
|
Une WebSocket authentifiée est une cible de choix : elle porte une identité, vit longtemps et
|
|
94
94
|
diffuse en continu.
|
|
95
95
|
|
|
96
|
-
| Attaque | Ce qui la bloque
|
|
97
|
-
| ------------------------------------------------------------------------------------------------------ |
|
|
98
|
-
| **CSWSH** — `evil.com` fait `new WebSocket("wss://app.exemple.com/rt")`, le navigateur joint le cookie | Contrôle d'`Origin` same-origin par défaut, puis allowlist stricte
|
|
99
|
-
| **Écoute des flux internes** — un visiteur s'abonne à `nodefony:syslog` et lit les logs du pod | Plancher des namespaces réservés (authentifié + `
|
|
100
|
-
| **Élévation par canal métier** — un `ROLE_USER` s'abonne au canal admin d'un autre module | Policy déclarée sur le canal, évaluée avec la hiérarchie de rôles
|
|
101
|
-
| **Pont API plus permissif que REST** — `api.request {path}` pour contourner un 401 HTTP | Re-match de la MÊME zone firewall que `GET {path}`
|
|
102
|
-
| **Socket zombie** — un admin se déconnecte, sa socket continue de diffuser | Re-validation périodique de l'identité, fermeture `4001`
|
|
103
|
-
| **DoS mémoire par abonnements** — une connexion ouvre des milliers de canaux | Plafond de canaux par connexion (256 par défaut)
|
|
104
|
-
| **DoS mémoire par lenteur** — un client ne lit pas, la file d'envoi enfle jusqu'à l'OOM | Jet de frames à 1 MiB, fermeture `1013` à 8 MiB
|
|
105
|
-
| **Oracle d'autorisation** — sonder les canaux pour cartographier les droits | Motif de refus **générique** (`forbidden`), jamais le détail
|
|
96
|
+
| Attaque | Ce qui la bloque | Où |
|
|
97
|
+
| ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | ------------------------------------------------------------ |
|
|
98
|
+
| **CSWSH** — `evil.com` fait `new WebSocket("wss://app.exemple.com/rt")`, le navigateur joint le cookie | Contrôle d'`Origin` same-origin par défaut, puis allowlist stricte | `@nodefony/http` transport, puis `csrf.checkOrigin` realtime |
|
|
99
|
+
| **Écoute des flux internes** — un visiteur s'abonne à `nodefony:syslog` et lit les logs du pod | Plancher des namespaces réservés (authentifié + `ROLE_NODEFONY_ADMIN`) | Verrou de frame `@nodefony/security` |
|
|
100
|
+
| **Élévation par canal métier** — un `ROLE_USER` s'abonne au canal admin d'un autre module | Policy déclarée sur le canal, évaluée avec la hiérarchie de rôles | `@RealtimeChannel(name, { roles })` + verrou de frame |
|
|
101
|
+
| **Pont API plus permissif que REST** — `api.request {path}` pour contourner un 401 HTTP | Re-match de la MÊME zone firewall que `GET {path}` | Verrou de frame, surface `api.request` |
|
|
102
|
+
| **Socket zombie** — un admin se déconnecte, sa socket continue de diffuser | Re-validation périodique de l'identité, fermeture `4001` | Tick de révocation du hub |
|
|
103
|
+
| **DoS mémoire par abonnements** — une connexion ouvre des milliers de canaux | Plafond de canaux par connexion (256 par défaut) | `limits.maxChannelsPerConnection` |
|
|
104
|
+
| **DoS mémoire par lenteur** — un client ne lit pas, la file d'envoi enfle jusqu'à l'OOM | Jet de frames à 1 MiB, fermeture `1013` à 8 MiB | Back-pressure du transport WS |
|
|
105
|
+
| **Oracle d'autorisation** — sonder les canaux pour cartographier les droits | Motif de refus **générique** (`forbidden`), jamais le détail | `realtime:denied` |
|
|
106
106
|
|
|
107
107
|
> [!IMPORTANT]
|
|
108
108
|
> Les lignes 2 à 5 supposent `@nodefony/security` chargé **avec au moins une zone protégée**. Sans
|
|
@@ -118,7 +118,7 @@ politiques.**
|
|
|
118
118
|
`security/nodefony/config/config.ts:109`). 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:
|
|
121
|
+
(`firewall.ts:719`), et la **même** hiérarchie de rôles, `Firewall.hasRole()`
|
|
122
122
|
(`firewall.ts:482`). 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`
|
|
@@ -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:621`) 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
266
|
`http/nodefony/config/config.ts:552`) 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:986`) 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:374`) 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:1198`) — 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:961`) :
|
|
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:1018`), **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:943`) associe un sélecteur à une stratégie.
|
|
316
|
+
`compileMatcher()` (`RealtimeHub.ts:1418`) 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:1250`) 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:279`) : **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:1030`) 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 ? ».
|
|
@@ -439,9 +439,9 @@ mémoire : le token déjà résolu, et la cible de la frame.
|
|
|
439
439
|
| action `@RealtimeAction` | Authentifié **par défaut** ; rôle/scope si déclarés ; ouverte seulement si `{ authenticated: false }` est écrit |
|
|
440
440
|
| `ping`, `unsubscribe` | **passent** — pas de surface de données |
|
|
441
441
|
|
|
442
|
-
Deux détails évitent des faux refus : `authorizeApiRequest()` (`frameAuthorizer.ts:
|
|
442
|
+
Deux détails évitent des faux refus : `authorizeApiRequest()` (`frameAuthorizer.ts:330`) laisse
|
|
443
443
|
passer une frame au `path` invalide (le handler renverra `-32602` — le verrou ne duplique pas la
|
|
444
|
-
validation), et `authorizeChannel()` (`frameAuthorizer.ts:
|
|
444
|
+
validation), et `authorizeChannel()` (`frameAuthorizer.ts:359`) laisse passer un `channel`
|
|
445
445
|
non-chaîne (`startChannel` ignore de toute façon un canal absent).
|
|
446
446
|
|
|
447
447
|
### Le plancher système — les namespaces réservés
|
|
@@ -449,12 +449,12 @@ non-chaîne (`startChannel` ignore de toute façon un canal absent).
|
|
|
449
449
|
Certains namespaces exposent l'intérieur du pod. Ils portent une politique par défaut que la config
|
|
450
450
|
peut **resserrer**, jamais desserrer.
|
|
451
451
|
|
|
452
|
-
| Cible | Politique par défaut
|
|
453
|
-
| ------------------------------------------ |
|
|
454
|
-
| `nodefony:audit` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:
|
|
455
|
-
| tout le reste de `nodefony:` | authentifié + `
|
|
456
|
-
| tout canal contenant `:health` ou `:stats` | authentifié + `
|
|
457
|
-
| tout le reste | libre, sauf policy déclarée
|
|
452
|
+
| Cible | Politique par défaut |
|
|
453
|
+
| ------------------------------------------ | -------------------------------------------------------------- |
|
|
454
|
+
| `nodefony:audit` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:120`) |
|
|
455
|
+
| tout le reste de `nodefony:` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:89`) |
|
|
456
|
+
| tout canal contenant `:health` ou `:stats` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:253`) |
|
|
457
|
+
| tout le reste | libre, sauf policy déclarée |
|
|
458
458
|
|
|
459
459
|
Le journal d'audit est un **canal précis**, pas un namespace : sa règle est posée devant celle du
|
|
460
460
|
territoire (premier match gagnant), et elle n'existe que si le hub réserve bien ce territoire.
|
|
@@ -463,29 +463,29 @@ Trois durcissements méritent d'être connus :
|
|
|
463
463
|
|
|
464
464
|
- **Match insensible à la casse, sans allocation** — `startsWithCI()` (`frameAuthorizer.ts:181`).
|
|
465
465
|
Un `NODEFONY:syslog` ne contourne pas le plancher `nodefony:` par un changement de casse.
|
|
466
|
-
- **Plancher irréductible** — `floorReserved()` (`frameAuthorizer.ts:
|
|
466
|
+
- **Plancher irréductible** — `floorReserved()` (`frameAuthorizer.ts:269`) : une règle de config qui
|
|
467
467
|
tenterait d'ouvrir un namespace réservé (`{ authenticated: false }`) se voit ré-imposer
|
|
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:1130`) 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:1297`) : 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:192`) et `@RealtimeInbound(name, policy)`
|
|
483
|
+
(`realtimeDecorators.ts:231`) 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
|
-
| `authenticated` | token non anonyme | `satisfies()` (`frameAuthorizer.ts:
|
|
488
|
+
| `authenticated` | token non anonyme | `satisfies()` (`frameAuthorizer.ts:290`) |
|
|
489
489
|
| `roles` | un des rôles suffit, **hiérarchie comprise** | `Firewall.hasRole()` (`firewall.ts:482`) |
|
|
490
490
|
| `scopes` | un des scopes suffit — axe API (JWT, clé API), une session BFF n'en porte pas | comparaison directe |
|
|
491
491
|
|
|
@@ -531,21 +531,21 @@ Le verrou n'existe que si quelqu'un le pose. Deux conditions doivent être vraie
|
|
|
531
531
|
1. `@nodefony/security` est chargé, et
|
|
532
532
|
2. au moins une zone a `security: true` **et** `realtime: true`.
|
|
533
533
|
|
|
534
|
-
C'est exactement le test de `Firewall.#wireRealtime()` (`firewall.ts:
|
|
534
|
+
C'est exactement le test de `Firewall.#wireRealtime()` (`firewall.ts:279`) : sans zone qualifiante,
|
|
535
535
|
`wired` reste faux, `setFrameAuthorizer` n'est jamais appelé, et **aucune** policy de canal n'est
|
|
536
536
|
évaluée — ni métier, ni système. `nodefony:syslog` redevient un canal ordinaire.
|
|
537
537
|
|
|
538
538
|
Deuxième subtilité : `beforeDispatch` n'est branché sur une connexion que si le verrou est **déjà**
|
|
539
|
-
posé au moment de son handshake (`RealtimeController.ts:
|
|
540
|
-
`RealtimeHub.hasFrameAuthorizer()` — `RealtimeHub.ts:
|
|
539
|
+
posé au moment de son handshake (`RealtimeController.ts:504`, via
|
|
540
|
+
`RealtimeHub.hasFrameAuthorizer()` — `RealtimeHub.ts:1058`). Choix de performance délibéré (un hub
|
|
541
541
|
non sécurisé garde un coût nul par frame), mais avec une conséquence : **une connexion ouverte avant
|
|
542
542
|
la pose du verrou n'est jamais gardée**, et ce jusqu'à sa fermeture. En fonctionnement normal le
|
|
543
543
|
firewall se construit au boot, avant tout trafic ; le cas ne se présente qu'en appelant
|
|
544
544
|
`setFrameAuthorizer()` à chaud.
|
|
545
545
|
|
|
546
546
|
**Le refus de dégrader en silence.** Quand des policies sont déclarées sans décideur câblé,
|
|
547
|
-
`RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:
|
|
548
|
-
émet un WARNING explicite, une seule fois par process (`RealtimeController.ts:
|
|
547
|
+
`RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:1117`) renvoie `true` et le controller
|
|
548
|
+
émet un WARNING explicite, une seule fois par process (`RealtimeController.ts:558`) :
|
|
549
549
|
|
|
550
550
|
```text
|
|
551
551
|
Realtime channel policies declared but NO frame authorizer is wired —
|
|
@@ -571,7 +571,7 @@ Le refus doit être observable sans devenir un oracle. Nodefony tranche ainsi :
|
|
|
571
571
|
|
|
572
572
|
| Type de frame refusée | Réponse au client |
|
|
573
573
|
| ----------------------------------- | -------------------------------------------------------------------------------- |
|
|
574
|
-
| Requête (avec `id`) | `-32001 "unauthorized"` — `JsonRpcPeer.receive()` (`JsonRpcPeer.ts:
|
|
574
|
+
| Requête (avec `id`) | `-32001 "unauthorized"` — `JsonRpcPeer.receive()` (`JsonRpcPeer.ts:390`) |
|
|
575
575
|
| Notification (`subscribe`, inbound) | `realtime:denied { channel, reason: "forbidden" }` (`RealtimeController.ts:432`) |
|
|
576
576
|
| Dépassement du plafond de canaux | `realtime:denied { channel, reason: "limit" }` |
|
|
577
577
|
| Canal sans aucun producteur | `realtime:denied { channel, reason: "unknown" }` |
|
|
@@ -600,16 +600,16 @@ Nodefony ferme l'écart par deux mécanismes de granularité différente.
|
|
|
600
600
|
|
|
601
601
|
| Surface | Re-validation | Fenêtre d'exposition | Où |
|
|
602
602
|
| --------------------------- | -------------------- | -------------------- | --------------------------------------------------------------------- |
|
|
603
|
-
| `api.request` (data plane) | **à chaque frame** | nulle | `RealtimeController.invokeApiRequest()` (`RealtimeController.ts:
|
|
604
|
-
| `subscribe` / flux de canal | **périodique**, 30 s | ≤ 30 s | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts:
|
|
603
|
+
| `api.request` (data plane) | **à chaque frame** | nulle | `RealtimeController.invokeApiRequest()` (`RealtimeController.ts:949`) |
|
|
604
|
+
| `subscribe` / flux de canal | **périodique**, 30 s | ≤ 30 s | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts:826`) |
|
|
605
605
|
|
|
606
606
|
**Sur `api.request`**, `token.isValid()` est appelé avant l'exécution de l'action ; identité périmée
|
|
607
607
|
ou changée → `-32000` avec `status: 401`, et le client bascule sur un `fetch` HTTP porteur du cookie
|
|
608
608
|
courant. Une erreur de re-validation vaut refus (fail-closed).
|
|
609
609
|
|
|
610
610
|
**Sur les canaux**, le hub n'inscrit au registre de révocation que les connexions dont le token porte
|
|
611
|
-
`isValid` (`RealtimeController.ts:
|
|
612
|
-
`RealtimeHub.registerRevocable()` (`RealtimeHub.ts:
|
|
611
|
+
`isValid` (`RealtimeController.ts:659`) — anonymes et JWT n'y entrent jamais, coût nul.
|
|
612
|
+
`RealtimeHub.registerRevocable()` (`RealtimeHub.ts:794`) démarre un `setInterval` `unref` au premier
|
|
613
613
|
inscrit et l'arrête dès que le registre se vide : zéro timer au repos. Période :
|
|
614
614
|
`REVOCATION_REVALIDATE_MS` (`RealtimeHub.ts:111`), 30 s, alignée sur le heartbeat WS.
|
|
615
615
|
|
|
@@ -625,7 +625,7 @@ Ce que `realtimeRevocation.attack.test.ts` prouve exactement :
|
|
|
625
625
|
| registre vide | tick no-op, aucun crash |
|
|
626
626
|
|
|
627
627
|
La source de vérité côté security est `buildSessionRevalidator()`
|
|
628
|
-
(`FirewallRealtimeAuthenticator.ts:
|
|
628
|
+
(`FirewallRealtimeAuthenticator.ts:240`) : il relit la session BFF **par son id capturé au handshake**
|
|
629
629
|
et vérifie qu'elle est toujours vivante **et toujours celle du même utilisateur** — ce second point
|
|
630
630
|
attrape le changement de compte sur un navigateur partagé.
|
|
631
631
|
|
|
@@ -652,12 +652,12 @@ pas** borné.
|
|
|
652
652
|
Chaque canal ouvert coûte un provider, un ticker et une entrée de Map. Sans borne, une connexion
|
|
653
653
|
peut abonner jusqu'à l'OOM — un déni de service mémoire déclenché par **un seul** client.
|
|
654
654
|
|
|
655
|
-
`RealtimeController.startChannel()` (`RealtimeController.ts:
|
|
655
|
+
`RealtimeController.startChannel()` (`RealtimeController.ts:815`) refuse au-delà de
|
|
656
656
|
`limits.maxChannelsPerConnection` (`realtime/nodefony/config/config.ts:142`), défaut **256**,
|
|
657
657
|
`null` pour illimité. Points prouvés par `realtimeChannelCap.attack.test.ts` :
|
|
658
658
|
|
|
659
659
|
- le plafond est actif **même sans `RealtimeService`** — le hub porte le défaut 256
|
|
660
|
-
(`RealtimeHub.ts:
|
|
660
|
+
(`RealtimeHub.ts:374`), il n'y a pas de fenêtre où la garde n'existerait pas ;
|
|
661
661
|
- au-delà, le hub **n'est jamais appelé** : le canal n'est pas ouvert, aucun provider ne démarre ;
|
|
662
662
|
- le refus est **observable** (`realtime:denied`, motif `limit`) ;
|
|
663
663
|
- un re-`subscribe` d'un canal déjà tenu **ne consomme pas de slot** (l'idempotence est vérifiée
|
|
@@ -672,7 +672,7 @@ amplificateur.
|
|
|
672
672
|
|
|
673
673
|
Un onglet en arrière-plan, un mobile en zone blanche, une fenêtre TCP pleine : la file d'envoi
|
|
674
674
|
grossit sans borne, et le multiplexage concentre le risque (une socket lente bloque tous ses
|
|
675
|
-
canaux). `WsConnectionTransport.send()` (`WsConnectionTransport.ts:
|
|
675
|
+
canaux). `WsConnectionTransport.send()` (`WsConnectionTransport.ts:82`) applique deux seuils :
|
|
676
676
|
|
|
677
677
|
| `bufferedAmount` | Action |
|
|
678
678
|
| ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -681,7 +681,7 @@ canaux). `WsConnectionTransport.send()` (`WsConnectionTransport.ts:76`) applique
|
|
|
681
681
|
|
|
682
682
|
### Taille des messages entrants
|
|
683
683
|
|
|
684
|
-
Portée par `@nodefony/http` : `websocket.maxPayload` (`http/nodefony/config/config.ts:
|
|
684
|
+
Portée par `@nodefony/http` : `websocket.maxPayload` (`http/nodefony/config/config.ts:543`), défaut
|
|
685
685
|
**1 MiB**, au-delà fermeture RFC 6455 `1009` « Message Too Big ». C'est un durcissement par rapport
|
|
686
686
|
au défaut de la librairie `ws`.
|
|
687
687
|
|
|
@@ -693,7 +693,7 @@ au défaut de la librairie `ws`.
|
|
|
693
693
|
| Limite de **fréquence** des frames entrantes | Un client authentifié peut inonder le peer ; seul le coût CPU le freine |
|
|
694
694
|
| Limite de **connexions par IP ou par utilisateur** | Rien n'empêche N sockets par client au niveau du module |
|
|
695
695
|
| Plafond **global** de canaux du process | Le plafond est par connexion ; M connexions × 256 canaux reste possible |
|
|
696
|
-
| Seuils de back-pressure **configurables** | `slowConsumer.bytes` ne pilote que le **comptage** de la sonde, pas les seuils de drop/close (`WsConnectionTransport` est construit sans override, `RealtimeController.ts:
|
|
696
|
+
| Seuils de back-pressure **configurables** | `slowConsumer.bytes` ne pilote que le **comptage** de la sonde, pas les seuils de drop/close (`WsConnectionTransport` est construit sans override, `RealtimeController.ts:421`) |
|
|
697
697
|
|
|
698
698
|
## ⚙️ Configuration de sécurité
|
|
699
699
|
|
|
@@ -773,10 +773,10 @@ n'est pas appliquée.
|
|
|
773
773
|
## 📡 Observabilité — Studio
|
|
774
774
|
|
|
775
775
|
- **Santé de la socket** : `/nodefony/realtime/api/health`, alimenté par `RealtimeHub.probe()`
|
|
776
|
-
(`RealtimeHub.ts:
|
|
776
|
+
(`RealtimeHub.ts:865`) — canaux, abonnés, fan-out, connexions, back-pressure et carte d'identité
|
|
777
777
|
du backplane. Un `drops` qui grimpe signale des clients en souffrance ; un `slowConsumers` non nul
|
|
778
778
|
précède souvent une fermeture `1013`. Même snapshot en flux sur le canal `nodefony:socket`
|
|
779
|
-
(namespace réservé : `
|
|
779
|
+
(namespace réservé : `ROLE_NODEFONY_ADMIN`).
|
|
780
780
|
- **Journal d'audit** : les refus de frame arrivent sur le canal `nodefony:audit`
|
|
781
781
|
(`ROLE_NODEFONY_ADMIN`) et dans le data plane d'audit de `@nodefony/security`.
|
|
782
782
|
- **Page module** : `/nodefony/modules/realtime` — la config effective (dont `csrf.checkOrigin` et
|