@nodefony/realtime 10.0.0-beta.3 → 10.0.0-beta.5

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 (51) hide show
  1. package/README.md +29 -10
  2. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/index.js +4 -6
  4. package/dist/nodefony/config/config.js +1 -2
  5. package/dist/nodefony/config/configSchema.js +1 -2
  6. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  7. package/dist/nodefony/decorators/realtimeDecorators.js +1 -2
  8. package/dist/nodefony/interfaces/IRealtimeConnectionState.js +1 -0
  9. package/dist/nodefony/src/backplane/ClusterBackplane.js +1 -2
  10. package/dist/nodefony/src/backplane/LoopbackBackplane.js +1 -2
  11. package/dist/nodefony/src/backplane/RedisBackplane.js +1 -2
  12. package/dist/nodefony/src/backplane/backplaneRegistry.js +1 -2
  13. package/dist/nodefony/src/backplane/bootNotices.js +1 -2
  14. package/dist/nodefony/src/backplane/envelope.js +1 -2
  15. package/dist/nodefony/src/backplane/originId.js +1 -2
  16. package/dist/nodefony/src/backplane/publishQueue.js +0 -1
  17. package/dist/nodefony/src/cluster/ClusterProbeClient.js +1 -2
  18. package/dist/nodefony/src/guards.js +1 -2
  19. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +1 -2
  20. package/dist/nodefony/src/server/RealtimeAdminApi.js +1 -2
  21. package/dist/nodefony/src/server/RealtimeController.js +158 -84
  22. package/dist/nodefony/src/server/RealtimeHub.js +3 -3
  23. package/dist/nodefony/src/server/ServerRealtimeSocket.js +18 -9
  24. package/dist/nodefony/src/server/deniedDetail.js +1 -2
  25. package/dist/nodefony/src/server/subscribedAck.js +29 -0
  26. package/dist/nodefony/src/server/syslogUplink.js +1 -2
  27. package/dist/nodefony/src/server/welcomeEnv.js +1 -2
  28. package/dist/nodefony/src/service/RealtimeService.js +1 -2
  29. package/dist/nodefony/src/transport/WsConnectionTransport.js +42 -2
  30. package/dist/nodefony/testing/index.js +77 -4
  31. package/dist/types/index.d.ts +1 -0
  32. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +40 -0
  33. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +13 -0
  34. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  35. package/dist/types/nodefony/src/server/RealtimeController.d.ts +60 -2
  36. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +18 -8
  37. package/dist/types/nodefony/src/server/subscribedAck.d.ts +23 -0
  38. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +26 -1
  39. package/dist/types/nodefony/testing/index.d.ts +11 -4
  40. package/docs/actions.md +67 -44
  41. package/docs/architecture.md +125 -119
  42. package/docs/cadence-et-contre-pression.md +31 -22
  43. package/docs/configuration.md +106 -82
  44. package/docs/cookbook-chat.md +56 -34
  45. package/docs/index.md +35 -24
  46. package/docs/observabilite.md +49 -49
  47. package/docs/protocole.md +91 -68
  48. package/docs/securite.md +135 -115
  49. package/docs/vocabulaire.md +117 -114
  50. package/package.json +10 -10
  51. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/securite.md CHANGED
@@ -49,7 +49,7 @@ collectées, personne ne les fait respecter.
49
49
  ```mermaid
50
50
  flowchart TD
51
51
  C["Client — GET /realtime<br/>Upgrade: websocket"] --> H1["1. Transport HTTP<br/>same-origin anti-CSWSH"]
52
- H1 -->|refus| X1["close 1008"]
52
+ H1 -->|refus| X1["403 au handshake<br/>aucune socket"]
53
53
  H1 --> H2["2. Firewall HTTP<br/>zone protégée ? 401 si anonyme"]
54
54
  H2 --> H3["3. Origin realtime<br/>allowlist stricte (opt-in)"]
55
55
  H3 -->|refus| X2["close 4003"]
@@ -115,14 +115,14 @@ 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:132`). L'opt-out est explicite ; un opt-in aurait été
118
+ `security/nodefony/config/config.ts#areaSchema`). 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: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
121
+ (`firewall.ts#Firewall.matchPath`), et la **même** hiérarchie de rôles, `Firewall.hasRole()`
122
+ (`firewall.ts#Firewall.hasRole`). 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
- (`FirewallRealtimeAuthenticator.ts:57`) ne relit ni cookie ni base, il **promeut** l'`IUser` déjà
125
+ (`FirewallRealtimeAuthenticator.ts#FirewallRealtimeAuthenticator`) ne relit ni cookie ni base, il **promeut** l'`IUser` déjà
126
126
  posé dans l'ALS. Zéro lecture redondante par connexion.
127
127
 
128
128
  Le compromis assumé : l'identité est **figée au handshake**, les frames lisent un cache O(1). C'est
@@ -136,36 +136,45 @@ aux administrateurs, et voir ce que reçoit un client non autorisé.
136
136
  ### 1. Déclarer la zone et activer l'origine stricte
137
137
 
138
138
  ```typescript
139
- // nodefony.config.ts — extrait
140
- use("@nodefony/security", {
141
- areas: {
142
- // Zone protégée : le handshake WS de /rt passe par le MÊME contrôle que
143
- // le HTTP. `realtime` vaut true par défaut → ne pas l'écrire suffit.
144
- app: {
145
- pattern: "^/rt",
146
- authenticators: ["session"],
139
+ // nodefony/config/security.ts — la zone du handshake et la hiérarchie de rôles
140
+ import type { ISecurityConfigInput } from "@nodefony/security";
141
+
142
+ export const securityConfig = () =>
143
+ ({
144
+ areas: {
145
+ // Zone protégée : le handshake WS de /rt passe par le MÊME contrôle que
146
+ // le HTTP. `realtime` vaut true par défaut → ne pas l'écrire suffit.
147
+ app: {
148
+ pattern: "^/rt",
149
+ authenticators: ["session"],
150
+ },
147
151
  },
148
- },
149
- roleHierarchy: {
150
- ROLE_ADMIN: ["ROLE_USER"],
151
- },
152
- });
152
+ roleHierarchy: {
153
+ ROLE_ADMIN: ["ROLE_USER"],
154
+ },
155
+ }) satisfies ISecurityConfigInput;
156
+ ```
153
157
 
154
- use("@nodefony/realtime", {
155
- csrf: {
156
- checkOrigin: {
157
- // Deuxième barrière d'origine : allowlist EXACTE (pas de wildcard).
158
- enabled: true,
159
- allowList: ["https://app.exemple.com", "https://127.0.0.1:5152"],
160
- // Un client sans en-tête Origin (mobile natif, script) est refusé.
161
- allowMissingOrigin: false,
158
+ ```typescript
159
+ // nodefony/config/realtime.ts — la barrière d'origine et la garde anti-OOM
160
+ import type { IRealtimeConfigInput } from "@nodefony/realtime";
161
+
162
+ export const realtimeConfig = () =>
163
+ ({
164
+ csrf: {
165
+ checkOrigin: {
166
+ // Deuxième barrière d'origine : allowlist EXACTE (pas de wildcard).
167
+ enabled: true,
168
+ allowList: ["https://app.exemple.com", "https://127.0.0.1:5152"],
169
+ // Un client sans en-tête Origin (mobile natif, script) est refusé.
170
+ allowMissingOrigin: false,
171
+ },
162
172
  },
163
- },
164
- limits: {
165
- // Garde anti-OOM : au-delà, le subscribe est refusé (pas de close).
166
- maxChannelsPerConnection: 64,
167
- },
168
- });
173
+ limits: {
174
+ // Garde anti-OOM : au-delà, le subscribe est refusé (pas de close).
175
+ maxChannelsPerConnection: 64,
176
+ },
177
+ }) satisfies IRealtimeConfigInput;
169
178
  ```
170
179
 
171
180
  ### 2. Le controller : un canal libre, un canal gardé
@@ -255,20 +264,20 @@ vers ton domaine, et le navigateur y joindra les cookies de la victime. L'en-tê
255
264
 
256
265
  Nodefony pose **deux barrières successives**, à deux étages différents.
257
266
 
258
- | Barrière | Étage | Défaut | Origin absent | Refus |
259
- | -------------------- | -------------------- | ----------------------------------- | ------------- | ------------ |
260
- | Contrôle same-origin | `@nodefony/http` | **actif** (`allowedOrigins: false`) | accepté | `close 1008` |
261
- | Allowlist stricte | `@nodefony/realtime` | **inactif** (`enabled: false`) | configurable | `close 4003` |
267
+ | Barrière | Étage | Défaut | Origin absent | Refus |
268
+ | -------------------- | -------------------- | ----------------------------------- | ------------- | ----------------- |
269
+ | Contrôle same-origin | `@nodefony/http` | **actif** (`allowedOrigins: false`) | accepté | `403` (handshake) |
270
+ | Allowlist stricte | `@nodefony/realtime` | **inactif** (`enabled: false`) | configurable | `close 4003` |
262
271
 
263
272
  **Barrière 1 — transport, active sans rien faire.** `HttpKernel.checkWebsocketOrigin()`
264
- (`http-kernel.ts:724`) exige que l'`Origin` du handshake corresponde au `Host` servi, avec tolérance
273
+ (`http-kernel.ts#HttpKernel.checkWebsocketOrigin`) exige que l'`Origin` du handshake corresponde au `Host` servi, avec tolérance
265
274
  loopback en développement et une allowlist optionnelle (`allowedOrigins`,
266
- `http/nodefony/config/config.ts:570`) acceptant le hostname exact ou un wildcard à un label. Une
275
+ `http/nodefony/config/config.ts#websocketSchema`) acceptant le hostname exact ou un wildcard à un label. Une
267
276
  requête **sans** `Origin` est acceptée : un attaquant non-navigateur n'a pas besoin de CSWSH.
268
277
 
269
278
  **Barrière 2 — module realtime, opt-in et plus stricte.** `RealtimeHub.checkOrigin()`
270
- (`RealtimeHub.ts:967`) consulte une garde compilée une fois au boot par `buildOriginGuard()`
271
- (`RealtimeService.ts:313`) depuis `checkOriginSchema` (`realtime/nodefony/config/config.ts:162`).
279
+ (`RealtimeHub.ts#RealtimeHub.checkOrigin`) consulte une garde compilée une fois au boot par `buildOriginGuard()`
280
+ (`RealtimeService.ts#buildOriginGuard`) depuis `checkOriginSchema` (`realtime/nodefony/config/config.ts#checkOriginSchema`).
272
281
  Trois différences comptent :
273
282
 
274
283
  1. **Match exact scheme + host + port**, aucun wildcard. Durcissement volontaire par rapport au
@@ -292,18 +301,18 @@ n'est jamais traitée.
292
301
 
293
302
  ### Le pipeline exact
294
303
 
295
- `RealtimeController.onHandshake()` (`RealtimeController.ts:450`) exécute, une fois par connexion :
304
+ `RealtimeController.onHandshake()` (`RealtimeController.ts#RealtimeController.onHandshake`) exécute, une fois par connexion :
296
305
 
297
306
  1. Construction d'un DTO neutre `IRealtimeHandshake` par `buildHandshakeFromContext()`
298
- (`RealtimeController.ts:1356`) — headers, cookies aplatis, url, origin, sous-protocoles. Aucune
307
+ (`RealtimeController.ts#buildHandshakeFromContext`) — headers, cookies aplatis, url, origin, sous-protocoles. Aucune
299
308
  dépendance à `@nodefony/security` dans le contrat.
300
309
  2. Contrôle d'origine (verrou 1).
301
- 3. Résolution de l'authenticator par `RealtimeHub.resolveAuthenticator()` (`RealtimeHub.ts:943`) :
310
+ 3. Résolution de l'authenticator par `RealtimeHub.resolveAuthenticator()` (`RealtimeHub.ts#RealtimeHub.resolveAuthenticator`) :
302
311
  les matchers sont testés dans l'ordre d'enregistrement, **le premier qui matche capture**.
303
312
  4. `authenticator.authenticate(handshake)` — **async autorisé** (on est en cold path, une fois par
304
313
  connexion : lire un store est acceptable ici, jamais par frame).
305
314
  5. Pose du token sur la WeakMap `peer → token` via `RealtimeHub.setTokenForPeer()`
306
- (`RealtimeHub.ts:999`), **avant** l'envoi du `welcome` : le lookup est garanti dès la première
315
+ (`RealtimeHub.ts#RealtimeHub.setTokenForPeer`), **avant** l'envoi du `welcome` : le lookup est garanti dès la première
307
316
  frame.
308
317
 
309
318
  Un `throw` de `authenticate()` ferme la socket en `4001` « unauthorized », après un log `WARNING`
@@ -312,8 +321,8 @@ d'audit défectueux ne peut pas empêcher la fermeture.
312
321
 
313
322
  ### Les matchers — quelle porte, quel vigile
314
323
 
315
- `RealtimeHub.useAuthenticator()` (`RealtimeHub.ts:925`) associe un sélecteur à une stratégie.
316
- `compileMatcher()` (`RealtimeHub.ts:1399`) le compile une fois :
324
+ `RealtimeHub.useAuthenticator()` (`RealtimeHub.ts#RealtimeHub.useAuthenticator`) associe un sélecteur à une stratégie.
325
+ `compileMatcher()` (`RealtimeHub.ts#compileMatcher`) le compile une fois :
317
326
 
318
327
  - `pattern` chaîne → RegExp **préfixe ancrée**, méta-caractères échappés par `RegExp.escape`.
319
328
  `"/admin/"` matche `/admin/` et `/admin/foo`, littéralement.
@@ -321,17 +330,17 @@ d'audit défectueux ne peut pas empêcher la fermeture.
321
330
  - `host` optionnel → comparaison **stricte** (insensible à la casse) sur l'en-tête `Host`, sans
322
331
  wildcard.
323
332
  - Le match porte sur le **path**, query comprise, jamais sur l'URL absolue : `handshakePath()`
324
- (`RealtimeController.ts:1408`) extrait `pathname + search` du `WebsocketContext.url`, qui est
333
+ (`RealtimeController.ts#handshakePath`) extrait `pathname + search` du `WebsocketContext.url`, qui est
325
334
  absolu. Sans cette extraction, un matcher `^/nodefony/…` ne se déclencherait jamais.
326
335
 
327
336
  `@nodefony/security` enregistre ces matchers automatiquement dans `Firewall.#wireRealtime()`
328
- (`firewall.ts:306`) : **une instance d'authenticator par zone protégée**, car le hub dédoublonne par
337
+ (`firewall.ts#Firewall.#wireRealtime`) : **une instance d'authenticator par zone protégée**, car le hub dédoublonne par
329
338
  identité d'instance et une instance partagée n'enregistrerait que le premier matcher.
330
339
 
331
340
  ### Zero Trust — il y a toujours un token
332
341
 
333
- `RealtimeHub.getTokenForPeer()` (`RealtimeHub.ts:1011`) ne renvoie **jamais** `null` : à défaut de
334
- token posé, c'est `ANONYMOUS_REALTIME_TOKEN` (`AnonymousRealtimeToken.ts:18`), singleton gelé,
342
+ `RealtimeHub.getTokenForPeer()` (`RealtimeHub.ts#RealtimeHub.getTokenForPeer`) ne renvoie **jamais** `null` : à défaut de
343
+ token posé, c'est `ANONYMOUS_REALTIME_TOKEN` (`AnonymousRealtimeToken.ts#ANONYMOUS_REALTIME_TOKEN`), singleton gelé,
335
344
  `isAuthenticated() === false`, `roles: ["ROLE_ANONYMOUS"]`. Le code consommateur (verrou, audit)
336
345
  n'a jamais à écrire « et s'il n'y a pas de token ? ».
337
346
 
@@ -346,7 +355,7 @@ n'a jamais à écrire « et s'il n'y a pas de token ? ».
346
355
  > [!WARNING]
347
356
  > La troisième ligne est un comportement à comprendre : un authenticator qui **matche mais ne
348
357
  > supporte pas** le handshake fait retomber la connexion en anonyme, sans fermeture. C'est le cas
349
- > de `FirewallRealtimeAuthenticator.supports()` (`FirewallRealtimeAuthenticator.ts:80`) quand aucune
358
+ > de `FirewallRealtimeAuthenticator.supports()` (`FirewallRealtimeAuthenticator.ts#FirewallRealtimeAuthenticator.supports`) quand aucune
350
359
  > identité authentifiée n'est en ALS. Ce n'est pas un trou **parce que** le firewall HTTP a déjà
351
360
  > refusé l'anonyme en amont sur une zone protégée (Zero Trust appliqué à la requête d'upgrade). Sur
352
361
  > une route WS **hors zone**, en revanche, la connexion anonyme aboutit : la protection y repose
@@ -422,13 +431,13 @@ sequenceDiagram
422
431
  P-->>C: realtime:denied {channel, reason:"forbidden"}
423
432
  ```
424
433
 
425
- Le verrou est **strictement synchrone** — `FrameAuthorizer` (`RealtimeHub.ts:40`). Un `await` par
434
+ Le verrou est **strictement synchrone** — `FrameAuthorizer` (`RealtimeHub.ts#FrameAuthorizer`). Un `await` par
426
435
  frame coûterait une microtask et sérialiserait le pipeline RPC de la connexion. Il ne lit que de la
427
436
  mémoire : le token déjà résolu, et la cible de la frame.
428
437
 
429
438
  ### Les trois surfaces gardées
430
439
 
431
- `buildFrameAuthorizer()` (`frameAuthorizer.ts:388`) ne garde que ce qui atteint des données :
440
+ `buildFrameAuthorizer()` (`frameAuthorizer.ts#buildFrameAuthorizer`) ne garde que ce qui atteint des données :
432
441
 
433
442
  <!-- prettier-ignore -->
434
443
  | Frame | Contrôle appliqué |
@@ -439,9 +448,9 @@ mémoire : le token déjà résolu, et la cible de la frame.
439
448
  | action `@RealtimeAction` | Authentifié **par défaut** ; rôle/scope si déclarés ; ouverte seulement si `{ authenticated: false }` est écrit |
440
449
  | `ping`, `unsubscribe` | **passent** — pas de surface de données |
441
450
 
442
- Deux détails évitent des faux refus : `authorizeApiRequest()` (`frameAuthorizer.ts:330`) laisse
451
+ Deux détails évitent des faux refus : `authorizeApiRequest()` (`frameAuthorizer.ts#authorizeApiRequest`) laisse
443
452
  passer une frame au `path` invalide (le handler renverra `-32602` — le verrou ne duplique pas la
444
- validation), et `authorizeChannel()` (`frameAuthorizer.ts:359`) laisse passer un `channel`
453
+ validation), et `authorizeChannel()` (`frameAuthorizer.ts#authorizeChannel`) laisse passer un `channel`
445
454
  non-chaîne (`startChannel` ignore de toute façon un canal absent).
446
455
 
447
456
  ### Le plancher système — les namespaces réservés
@@ -449,51 +458,51 @@ non-chaîne (`startChannel` ignore de toute façon un canal absent).
449
458
  Certains namespaces exposent l'intérieur du pod. Ils portent une politique par défaut que la config
450
459
  peut **resserrer**, jamais desserrer.
451
460
 
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 |
461
+ | Cible | Politique par défaut |
462
+ | ------------------------------------------ | ---------------------------------------------------------------------------------- |
463
+ | `nodefony:audit` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts#SECURITY_CHANNEL_POLICY`) |
464
+ | tout le reste de `nodefony:` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts#SYSTEM_CHANNEL_POLICY`) |
465
+ | tout canal contenant `:health` ou `:stats` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts#matchSystemPolicy`) |
466
+ | tout le reste | libre, sauf policy déclarée |
458
467
 
459
468
  Le journal d'audit est un **canal précis**, pas un namespace : sa règle est posée devant celle du
460
469
  territoire (premier match gagnant), et elle n'existe que si le hub réserve bien ce territoire.
461
470
 
462
471
  Trois durcissements méritent d'être connus :
463
472
 
464
- - **Match insensible à la casse, sans allocation** — `startsWithCI()` (`frameAuthorizer.ts:181`).
473
+ - **Match insensible à la casse, sans allocation** — `startsWithCI()` (`frameAuthorizer.ts#buildSystemRules`).
465
474
  Un `NODEFONY:syslog` ne contourne pas le plancher `nodefony:` par un changement de casse.
466
- - **Plancher irréductible** — `floorReserved()` (`frameAuthorizer.ts:269`) : une règle de config qui
475
+ - **Plancher irréductible** — `floorReserved()` (`frameAuthorizer.ts#floorReserved`) : une règle de config qui
467
476
  tenterait d'ouvrir un namespace réservé (`{ authenticated: false }`) se voit ré-imposer
468
477
  `authenticated: true`. Le test porte sur le **namespace du canal**, pas sur le préfixe de la règle
469
478
  qui a matché : un préfixe de config plus court ou altéré ne contourne rien.
470
479
  - **La config passe avant les défauts** — les règles de `realtimeChannels`
471
- (`security/nodefony/config/config.ts:1447`) sont placées en tête, premier match gagnant. On peut
480
+ (`security/nodefony/config/config.ts#securityConfigSchema`) sont placées en tête, premier match gagnant. On peut
472
481
  donc re-cibler `nodefony:syslog` sur `ROLE_SECURITY_AUDITOR` ; on ne peut pas l'ouvrir à l'anonyme.
473
482
 
474
483
  Le canal du journal d'audit (`nodefony:audit`) est enregistré comme **canal système** sur le hub
475
- (`RealtimeHub.registerSystemChannel()`, `RealtimeHub.ts:1278`) : il devient servable par n'importe
484
+ (`RealtimeHub.registerSystemChannel()`, `RealtimeHub.ts#RealtimeHub.registerSystemChannel`) : il devient servable par n'importe
476
485
  quel endpoint realtime, sans qu'aucun controller ne le connaisse — et il est gardé par sa règle
477
486
  dédiée. Son enregistrement est **couplé** à la pose du verrou (même condition), donc il n'existe
478
487
  jamais de canal d'audit non gardé.
479
488
 
480
489
  ### La policy métier — déclarer sur le canal
481
490
 
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
491
+ `@RealtimeChannel(name, policy)` (`realtimeDecorators.ts#RealtimeChannel`) et `@RealtimeInbound(name, policy)`
492
+ (`realtimeDecorators.ts#RealtimeInbound`) attachent un `IChannelPolicy` au **nom** du canal. Les trois axes sont
484
493
  cumulatifs (ET) ; un axe absent n'impose rien :
485
494
 
486
- | Axe | Sens | Évalué par |
487
- | --------------- | ----------------------------------------------------------------------------- | ---------------------------------------- |
488
- | `authenticated` | token non anonyme | `satisfies()` (`frameAuthorizer.ts:290`) |
489
- | `roles` | un des rôles suffit, **hiérarchie comprise** | `Firewall.hasRole()` (`firewall.ts:521`) |
490
- | `scopes` | un des scopes suffit — axe API (JWT, clé API), une session BFF n'en porte pas | comparaison directe |
495
+ | Axe | Sens | Évalué par |
496
+ | --------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------- |
497
+ | `authenticated` | token non anonyme | `satisfies()` (`frameAuthorizer.ts#satisfies`) |
498
+ | `roles` | un des rôles suffit, **hiérarchie comprise** | `Firewall.hasRole()` (`firewall.ts#Firewall.hasRole`) |
499
+ | `scopes` | un des scopes suffit — axe API (JWT, clé API), une session BFF n'en porte pas | comparaison directe |
491
500
 
492
- Une policy **vide** n'est pas enregistrée : `definePolicy()` (`realtimeDecorators.ts:91`) ignore un
501
+ Une policy **vide** n'est pas enregistrée : `definePolicy()` (`realtimeDecorators.ts#definePolicy`) ignore un
493
502
  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:1124`,
503
+ au hub **au handshake**, pas au boot (`RealtimeHub.registerChannelPolicy()`, `RealtimeHub.ts#RealtimeHub.registerChannelPolicy`,
495
504
  idempotent) ; le décideur les relit par `RealtimeService.resolveChannelPolicy()`
496
- (`RealtimeService.ts:291`).
505
+ (`RealtimeService.ts#RealtimeService.resolveChannelPolicy`).
497
506
 
498
507
  ### ⚠️ Une policy est attachée à un nom EXACT — les canaux dérivés n'héritent pas
499
508
 
@@ -507,12 +516,14 @@ C'est le piège le plus facile à ne pas voir, parce que tout a l'air correct :
507
516
  là, le canal fonctionne, et le refus attendu n'arrive jamais.
508
517
 
509
518
  Le serveur le **dit** désormais. Au premier canal servi dynamiquement sans politique, alors que le
510
- module en déclare pour d'autres canaux, un avertissement est journalisé une fois
511
- (`RealtimeHub.noticeUnguardedDynamicChannel()`, `RealtimeHub.ts:1251`) :
519
+ module en déclare pour d'autres canaux (hors canaux de plateforme `nodefony:`, que leur plancher
520
+ garde déjà), un avertissement est journalisé une fois
521
+ (`RealtimeHub.noticeUnguardedDynamicChannel()`, `RealtimeHub.ts#RealtimeHub.noticeUnguardedDynamicChannel`) :
512
522
 
513
523
  ```
514
- le canal "chat:room:1000" est servi par une fabrique DYNAMIQUE et n'est couvert par AUCUNE
515
- politique déclarée, alors que ce module en déclare pour d'autres canaux. […]
524
+ le canal "chat:room:1000" n'a aucune règle d'accès : tout client connecté peut s'y abonner.
525
+ Il est produit par createRealtimeChannel(), et une règle déclarée sur un nom exact ne couvre pas
526
+ ses variantes (suffixe de cadence, identifiant). […]
516
527
  ```
517
528
 
518
529
  Deux façons de fermer, selon l'intention :
@@ -535,21 +546,21 @@ Le verrou n'existe que si quelqu'un le pose. Deux conditions doivent être vraie
535
546
  1. `@nodefony/security` est chargé, et
536
547
  2. au moins une zone a `security: true` **et** `realtime: true`.
537
548
 
538
- C'est exactement le test de `Firewall.#wireRealtime()` (`firewall.ts:306`) : sans zone qualifiante,
549
+ C'est exactement le test de `Firewall.#wireRealtime()` (`firewall.ts#Firewall.#wireRealtime`) : sans zone qualifiante,
539
550
  `wired` reste faux, `setFrameAuthorizer` n'est jamais appelé, et **aucune** policy de canal n'est
540
551
  évaluée — ni métier, ni système. `nodefony:syslog` redevient un canal ordinaire.
541
552
 
542
553
  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:562`, via
544
- `RealtimeHub.hasFrameAuthorizer()` — `RealtimeHub.ts:1039`). Choix de performance délibéré (un hub
554
+ posé au moment de son handshake (`RealtimeController.ts#RealtimeController.onHandshake`, via
555
+ `RealtimeHub.hasFrameAuthorizer()` — `RealtimeHub.ts#RealtimeHub.hasFrameAuthorizer`). Choix de performance délibéré (un hub
545
556
  non sécurisé garde un coût nul par frame), mais avec une conséquence : **une connexion ouverte avant
546
557
  la pose du verrou n'est jamais gardée**, et ce jusqu'à sa fermeture. En fonctionnement normal le
547
558
  firewall se construit au boot, avant tout trafic ; le cas ne se présente qu'en appelant
548
559
  `setFrameAuthorizer()` à chaud.
549
560
 
550
561
  **Le refus de dégrader en silence.** Quand des policies sont déclarées sans décideur câblé,
551
- `RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:1098`) renvoie `true` et le controller
552
- émet un WARNING explicite, une seule fois par process (`RealtimeController.ts:639`) :
562
+ `RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts#RealtimeHub.hasUnenforcedChannelPolicies`) renvoie `true` et le controller
563
+ émet un WARNING explicite, une seule fois par process (`RealtimeController.ts#RealtimeController.onHandshake`) :
553
564
 
554
565
  ```text
555
566
  Realtime channel policies declared but NO frame authorizer is wired —
@@ -573,12 +584,12 @@ l'alerte. Le test ne prouve pas que le canal est fermé — il prouve que le fra
573
584
 
574
585
  Le refus doit être observable sans devenir un oracle. Nodefony tranche ainsi :
575
586
 
576
- | Type de frame refusée | Réponse au client |
577
- | ----------------------------------- | -------------------------------------------------------------------------------- |
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
- | Dépassement du plafond de canaux | `realtime:denied { channel, reason: "limit" }` |
581
- | Canal sans aucun producteur | `realtime:denied { channel, reason: "unknown" }` |
587
+ | Type de frame refusée | Réponse au client |
588
+ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |
589
+ | Requête (avec `id`) | `-32001 "unauthorized"` — `JsonRpcPeer.receive()` (`JsonRpcPeer.ts#JsonRpcPeer.receive`) |
590
+ | Notification (`subscribe`, inbound) | `realtime:denied { channel, reason: "forbidden" }` (`RealtimeController.ts#RealtimeController.onHandshake`) |
591
+ | Dépassement du plafond de canaux | `realtime:denied { channel, reason: "limit" }` |
592
+ | Canal sans aucun producteur | `realtime:denied { channel, reason: "unknown" }` |
582
593
 
583
594
  Le message est **générique** dans les trois cas : jamais « il te manque `ROLE_ADMIN` », jamais le nom
584
595
  de la zone. Sans `realtime:denied`, une notification refusée serait droppée en silence (elle n'a pas
@@ -590,7 +601,7 @@ Le motif `limit` est volontairement distinct de
590
601
  chercher un problème de droits.
591
602
 
592
603
  Côté serveur, chaque refus alimente le journal d'audit via le rapporteur `onDeny`
593
- (`FrameDenyReporter`, `frameAuthorizer.ts:57`), tiré **uniquement** sur refus — le chemin autorisé
604
+ (`FrameDenyReporter`, `frameAuthorizer.ts#FrameDenyReporter`), tiré **uniquement** sur refus — le chemin autorisé
594
605
  n'alloue rien. L'entrée porte `category: "ws"`, `action: "frame.denied"`, l'acteur, la ressource et
595
606
  une raison machine stable (`zone_protected` ou `channel_policy`).
596
607
 
@@ -602,20 +613,20 @@ ne peut pas relire la session. Un administrateur qui se déconnecte garderait se
602
613
 
603
614
  Nodefony ferme l'écart par deux mécanismes de granularité différente.
604
615
 
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`) |
616
+ | Surface | Re-validation | Fenêtre d'exposition | Où |
617
+ | --------------------------- | -------------------- | -------------------- | ----------------------------------------------------------------------------------------------------- |
618
+ | `api.request` (data plane) | **à chaque frame** | nulle | `RealtimeController.invokeApiRequest()` (`RealtimeController.ts#RealtimeController.invokeApiRequest`) |
619
+ | `subscribe` / flux de canal | **périodique**, 30 s | ≤ 30 s | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts#RealtimeHub.revalidateRevocable`) |
609
620
 
610
621
  **Sur `api.request`**, `token.isValid()` est appelé avant l'exécution de l'action ; identité périmée
611
622
  ou changée → `-32000` avec `status: 401`, et le client bascule sur un `fetch` HTTP porteur du cookie
612
623
  courant. Une erreur de re-validation vaut refus (fail-closed).
613
624
 
614
625
  **Sur les canaux**, le hub n'inscrit au registre de révocation que les connexions dont le token porte
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
626
+ `isValid` (`RealtimeController.ts#RealtimeController.onHandshake`) — anonymes et JWT n'y entrent jamais, coût nul.
627
+ `RealtimeHub.registerRevocable()` (`RealtimeHub.ts#RealtimeHub.registerRevocable`) démarre un `setInterval` `unref` au premier
617
628
  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:112`), 30 s, alignée sur le heartbeat WS.
629
+ `REVOCATION_REVALIDATE_MS` (`RealtimeHub.ts#channelFamily`), 30 s, alignée sur le heartbeat WS.
619
630
 
620
631
  Ce que `realtimeRevocation.attack.test.ts` prouve exactement :
621
632
 
@@ -629,18 +640,27 @@ Ce que `realtimeRevocation.attack.test.ts` prouve exactement :
629
640
  | registre vide | tick no-op, aucun crash |
630
641
 
631
642
  La source de vérité côté security est `buildSessionRevalidator()`
632
- (`FirewallRealtimeAuthenticator.ts:275`) : il relit la session BFF **par son id capturé au handshake**
643
+ (`FirewallRealtimeAuthenticator.ts#buildSessionRevalidator`) : il relit la session BFF **par son id capturé au handshake**
633
644
  et vérifie qu'elle est toujours vivante **et toujours celle du même utilisateur** — ce second point
634
645
  attrape le changement de compte sur un navigateur partagé.
635
646
 
647
+ **Un changement de rôles coupe sur-le-champ, sans attendre le tick.** Quand une mise à jour du compte
648
+ touche ses rôles, le hub ferme toutes les sockets de cette identité avec le code **4005**
649
+ (`realtime/closeCodes.ts#IDENTITY_REFRESH_CLOSE_CODE`, `RealtimeHub.ts#RealtimeHub.#revocation`). À la
650
+ différence de 4001 (identité morte, définitif), 4005 est **reconnectable** : le client se reconnecte
651
+ seul et reçoit ses nouveaux rôles dans l'accueil. Les flux SSE du même compte suivent la même règle.
652
+ Aucune lecture de base par tick ni par frame : le coût n'existe qu'à la mise à jour qui touche
653
+ `roles`.
654
+
636
655
  > [!CAUTION]
637
656
  > **Trois limites à connaître.**
638
657
  >
639
658
  > 1. **Fenêtre de 30 s.** Entre le logout et le tick suivant, les flux continuent. Pour une coupure
640
- > immédiate, fermer la socket explicitement côté serveur.
659
+ > immédiate, fermer la socket explicitement côté serveur. (Un changement de **rôles**, lui, coupe
660
+ > immédiatement — ci-dessus.)
641
661
  > 2. **Best-effort si la session est illisible au handshake.** Si l'id ou le store ne sont pas
642
662
  > accessibles, `buildSessionRevalidator()` renvoie `null` ; `UserRealtimeToken.isValid()`
643
- > (`UserRealtimeToken.ts:78`) répond alors toujours `true`. La connexion **est** inscrite au
663
+ > (`UserRealtimeToken.ts#UserRealtimeToken.isValid`) répond alors toujours `true`. La connexion **est** inscrite au
644
664
  > registre (le token expose bien `isValid`) mais ne sera **jamais** révoquée. Choix assumé pour
645
665
  > éviter les faux refus — à connaître, car cela ne se voit pas.
646
666
  > 3. **Les identités sans `isValid` ne sont pas révocables du tout** : anonyme, JWT, clé API. Pour
@@ -656,12 +676,12 @@ pas** borné.
656
676
  Chaque canal ouvert coûte un provider, un ticker et une entrée de Map. Sans borne, une connexion
657
677
  peut abonner jusqu'à l'OOM — un déni de service mémoire déclenché par **un seul** client.
658
678
 
659
- `RealtimeController.startChannel()` (`RealtimeController.ts:869`) refuse au-delà de
660
- `limits.maxChannelsPerConnection` (`realtime/nodefony/config/config.ts:142`), défaut **256**,
679
+ `RealtimeController.startChannel()` (`RealtimeController.ts#RealtimeController.startChannel`) refuse au-delà de
680
+ `limits.maxChannelsPerConnection` (`realtime/nodefony/config/config.ts#limitsSchema`), défaut **256**,
661
681
  `null` pour illimité. Points prouvés par `realtimeChannelCap.attack.test.ts` :
662
682
 
663
683
  - le plafond est actif **même sans `RealtimeService`** — le hub porte le défaut 256
664
- (`RealtimeHub.ts:380`), il n'y a pas de fenêtre où la garde n'existerait pas ;
684
+ (`RealtimeHub.ts#RealtimeHub.#slowConsumerBytes`), il n'y a pas de fenêtre où la garde n'existerait pas ;
665
685
  - au-delà, le hub **n'est jamais appelé** : le canal n'est pas ouvert, aucun provider ne démarre ;
666
686
  - le refus est **observable** (`realtime:denied`, motif `limit`) ;
667
687
  - un re-`subscribe` d'un canal déjà tenu **ne consomme pas de slot** (l'idempotence est vérifiée
@@ -676,16 +696,16 @@ amplificateur.
676
696
 
677
697
  Un onglet en arrière-plan, un mobile en zone blanche, une fenêtre TCP pleine : la file d'envoi
678
698
  grossit sans borne, et le multiplexage concentre le risque (une socket lente bloque tous ses
679
- canaux). `WsConnectionTransport.send()` (`WsConnectionTransport.ts:82`) applique deux seuils :
699
+ canaux). `WsConnectionTransport.send()` (`WsConnectionTransport.ts#WsConnectionTransport.send`) applique deux seuils :
680
700
 
681
- | `bufferedAmount` | Action |
682
- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
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
- | `websocket.backpressureCloseAfterDrops` drops CONSÉCUTIFS — 1000 par défaut | `close(1013)` « Try Again Later » ; le client se reconnecte et resynchronise |
701
+ | `bufferedAmount` | Action |
702
+ | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
703
+ | > `websocket.maxBackpressure` — 4 MiB par défaut (`http`, `http/nodefony/config/config.ts#httpConfigSchema`) | politique `websocket.backpressurePolicy` : `drop` (défaut) jette la frame — canaux d'état, le prochain snapshot la remplace |
704
+ | `websocket.backpressureCloseAfterDrops` drops CONSÉCUTIFS — 1000 par défaut | `close(1013)` « Try Again Later » ; le client se reconnecte et resynchronise |
685
705
 
686
706
  ### Taille des messages entrants
687
707
 
688
- Portée par `@nodefony/http` : `websocket.maxPayload` (`http/nodefony/config/config.ts:543`), défaut
708
+ Portée par `@nodefony/http` : `websocket.maxPayload` (`http/nodefony/config/config.ts#websocketSchema`), défaut
689
709
  **1 MiB**, au-delà fermeture RFC 6455 `1009` « Message Too Big ». C'est un durcissement par rapport
690
710
  au défaut de la librairie `ws`.
691
711
 
@@ -703,7 +723,7 @@ au défaut de la librairie `ws`.
703
723
 
704
724
  Seules les clés à **effet de sécurité** figurent ici ; le catalogue complet est dans
705
725
  [`configuration.md`](./configuration.md).
706
- Source unique des défauts : `realtimeConfigSchema` (`realtime/nodefony/config/config.ts:259`).
726
+ Source unique des défauts : `realtimeConfigSchema` (`realtime/nodefony/config/config.ts#realtimeConfigSchema`).
707
727
 
708
728
  | Clé | Défaut | Effet de sécurité |
709
729
  | ------------------------------------- | -------- | ----------------------------------------------------------------------------------------- |
@@ -728,9 +748,9 @@ Source unique des défauts : `realtimeConfigSchema` (`realtime/nodefony/config/c
728
748
  Cette section existe pour éviter une confiance mal placée. Chaque point est vérifié au code.
729
749
 
730
750
  **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:327`). Rien n'empêche le
751
+ registre global (`#channelPolicies` du hub singleton, `RealtimeHub.ts#RealtimeHub.#channelPolicies`). Rien n'empêche le
732
752
  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:757`, idempotent
753
+ plus faible sur ce même nom — la déclaration la plus récente écrase (`RealtimeHub.ts#RealtimeHub.#admitFromBackplane`, idempotent
734
754
  par écrasement). En pratique, l'exploitation exige que le controller fautif accepte le nom (sa
735
755
  factory doit renvoyer un provider), donc un override permissif de `createRealtimeChannel` ; et les
736
756
  namespaces réservés restent couverts par le plancher système **si** le verrou est posé. Une garde
@@ -757,7 +777,7 @@ distincts.
757
777
  confidentialité et l'intégrité sont déléguées à TLS — `wss://` n'est pas optionnel en production.
758
778
 
759
779
  **6. `enabled: false` désarme aussi les gardes du module.** Le service devient inerte
760
- (`RealtimeService.init()`, `RealtimeService.ts:77`) : ni garde d'origine, ni plafond de canaux posés
780
+ (`RealtimeService.init()`, `RealtimeService.ts#RealtimeService.init`) : ni garde d'origine, ni plafond de canaux posés
761
781
  depuis la config. Le hub conserve son défaut de 256 canaux, mais l'allowlist d'origines configurée
762
782
  n'est pas appliquée.
763
783
 
@@ -777,7 +797,7 @@ n'est pas appliquée.
777
797
  ## 📡 Observabilité — Studio
778
798
 
779
799
  - **Santé de la socket** : `/nodefony/realtime/api/health`, alimenté par `RealtimeHub.probe()`
780
- (`RealtimeHub.ts:847`) — canaux, abonnés, fan-out, connexions, back-pressure et carte d'identité
800
+ (`RealtimeHub.ts#RealtimeHub.probe`) — canaux, abonnés, fan-out, connexions, back-pressure et carte d'identité
781
801
  du backplane. Un `drops` qui grimpe signale des clients en souffrance ; un `slowConsumers` non nul
782
802
  précède souvent une fermeture `1013`. Même snapshot en flux sur le canal `nodefony:socket`
783
803
  (namespace réservé : `ROLE_NODEFONY_ADMIN`).