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

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 (96) hide show
  1. package/README.md +55 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +91 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
  6. package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
  7. package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
  8. package/dist/nodefony/src/backplane/RedisBackplane.js +146 -41
  9. package/dist/nodefony/src/backplane/controlOrder.js +86 -0
  10. package/dist/nodefony/src/backplane/envelope.js +505 -27
  11. package/dist/nodefony/src/backplane/ingress.js +93 -0
  12. package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
  13. package/dist/nodefony/src/backplane/publishQueue.js +97 -3
  14. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  15. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  16. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  17. package/dist/nodefony/src/server/RealtimeConnection.js +135 -0
  18. package/dist/nodefony/src/server/RealtimeController.js +409 -107
  19. package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
  20. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  21. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  22. package/dist/nodefony/src/server/channelPolicyRegistry.js +95 -0
  23. package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
  24. package/dist/nodefony/src/server/channelPresence.js +488 -0
  25. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  26. package/dist/nodefony/src/server/contractRoutes.js +75 -0
  27. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  28. package/dist/nodefony/src/server/frameAdmission.js +160 -0
  29. package/dist/nodefony/src/server/presenceBus.js +486 -0
  30. package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
  31. package/dist/nodefony/src/server/remotePresence.js +541 -0
  32. package/dist/nodefony/src/server/subscribedAck.js +13 -1
  33. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  34. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  35. package/dist/nodefony/src/service/RealtimeService.js +51 -1
  36. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  37. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  38. package/dist/nodefony/testing/backplaneConformance.js +347 -0
  39. package/dist/nodefony/testing/index.js +43 -12
  40. package/dist/nodefony/testing/presenceClient.js +54 -0
  41. package/dist/types/index.d.ts +39 -7
  42. package/dist/types/nodefony/config/config.d.ts +9 -0
  43. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
  44. package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
  45. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  46. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  47. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  48. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  49. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  50. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  51. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  52. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  53. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +89 -18
  54. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
  55. package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
  56. package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
  57. package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
  58. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
  59. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  60. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  61. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +20 -0
  62. package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
  63. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
  64. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  65. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  66. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +41 -0
  67. package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
  68. package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
  69. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  70. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  71. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  72. package/dist/types/nodefony/src/server/frameAdmission.d.ts +66 -0
  73. package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
  74. package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
  75. package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
  76. package/dist/types/nodefony/src/server/subscribedAck.d.ts +10 -0
  77. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  78. package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -2
  79. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  80. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  81. package/dist/types/nodefony/testing/backplaneConformance.d.ts +78 -0
  82. package/dist/types/nodefony/testing/index.d.ts +13 -2
  83. package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
  84. package/docs/actions.md +420 -18
  85. package/docs/architecture.md +101 -11
  86. package/docs/cadence-et-contre-pression.md +87 -11
  87. package/docs/canaux-a-membres.md +277 -0
  88. package/docs/cas-d-usage.md +1703 -0
  89. package/docs/configuration.md +175 -19
  90. package/docs/cookbook-chat.md +701 -224
  91. package/docs/index.md +11 -1
  92. package/docs/observabilite.md +97 -21
  93. package/docs/protocole.md +142 -33
  94. package/docs/securite.md +347 -41
  95. package/docs/vocabulaire.md +13 -12
  96. package/package.json +16 -13
package/docs/securite.md CHANGED
@@ -310,7 +310,13 @@ n'est jamais traitée.
310
310
  3. Résolution de l'authenticator par `RealtimeHub.resolveAuthenticator()` (`RealtimeHub.ts#RealtimeHub.resolveAuthenticator`) :
311
311
  les matchers sont testés dans l'ordre d'enregistrement, **le premier qui matche capture**.
312
312
  4. `authenticator.authenticate(handshake)` — **async autorisé** (on est en cold path, une fois par
313
- connexion : lire un store est acceptable ici, jamais par frame).
313
+ connexion : lire un store est acceptable ici, jamais par frame), et **borné** par
314
+ `authenticateTimeoutMs` (10 s par défaut, `admissionBudget.ts#AdmissionBudget.authenticateWithin`).
315
+ Au-delà, la socket est fermée en `1013` « try again later » : le client recule puis revient, et une
316
+ identité arrivée après coup est ignorée. Ni `onFailure` ni `4001` — une base en panne n'est pas un
317
+ refus d'identité, et un code définitif arrêterait un client légitime à chaque panne. Compteur
318
+ `authenticateTimedOutTotal`, un `WARNING` par fenêtre de 10 s. Un client parti pendant
319
+ l'authentification n'est jamais inscrit au hub.
314
320
  5. Pose du token sur la WeakMap `peer → token` via `RealtimeHub.setTokenForPeer()`
315
321
  (`RealtimeHub.ts#RealtimeHub.setTokenForPeer`), **avant** l'envoi du `welcome` : le lookup est garanti dès la première
316
322
  frame.
@@ -443,9 +449,9 @@ mémoire : le token déjà résolu, et la cible de la frame.
443
449
  | Frame | Contrôle appliqué |
444
450
  | --- | --- |
445
451
  | `api.request {path}` | Re-match de zone HTTP : zone protégée + anonyme → refus |
446
- | `subscribe {channel}` | Plancher système, puis policy métier déclarée sur le canal |
447
- | notification `method` = canal inbound | Même politique que `subscribe` (on ne pousse pas sur un canal protégé) |
448
- | action `@RealtimeAction` | Authentifié **par défaut** ; rôle/scope si déclarés ; ouverte seulement si `{ authenticated: false }` est écrit |
452
+ | `subscribe {channel}` | Plancher système, puis policy de **LECTURE** déclarée sur le canal |
453
+ | notification `method` = canal inbound | Policy de lecture **ET** policy d'**ÉCRITURE** du même nom (on ne pousse pas sur un canal qu'on n'a pas le droit de lire) |
454
+ | action `@RealtimeAction` | Lecture **ET** écriture du nom. Sans politique : la lecture d'un canal homonyme si elle est déclarée, sinon authentifié **par défaut** ; ouverte seulement si `{ authenticated: false }` est écrit |
449
455
  | `ping`, `unsubscribe` | **passent** — pas de surface de données |
450
456
 
451
457
  Deux détails évitent des faux refus : `authorizeApiRequest()` (`frameAuthorizer.ts#authorizeApiRequest`) laisse
@@ -468,6 +474,22 @@ peut **resserrer**, jamais desserrer.
468
474
  Le journal d'audit est un **canal précis**, pas un namespace : sa règle est posée devant celle du
469
475
  territoire (premier match gagnant), et elle n'existe que si le hub réserve bien ce territoire.
470
476
 
477
+ **Les suffixes `:health` et `:stats` sont réservés dans TOUT espace de noms**, pas seulement sous
478
+ `nodefony:`. C'est la convention transverse des canaux de santé et de mesures : `net:health` est
479
+ donc refusé à quiconque ne porte pas `ROLE_NODEFONY_ADMIN`, même si l'application ne déclare rien
480
+ sur ce canal. Le hub, lui, ne réserve que `nodefony:` — d'où la surprise. Le refus le dit : sa
481
+ phrase nomme le suffixe, le rôle exigé et la règle qui le surcharge (`frameAuthorizer.ts#explainReservedSuffix`).
482
+ Pour ouvrir un tel canal à l'application, déclarer une **règle de préfixe** — placée avant les
483
+ défauts, elle gagne et la convention n'est plus consultée :
484
+
485
+ ```ts
486
+ use("@nodefony/security", {
487
+ realtimeChannels: [{ pattern: "net:", authenticated: true }],
488
+ });
489
+ ```
490
+
491
+ La règle ne descend pas sous ce qu'elle déclare : ici, `net:health` reste fermé à l'anonyme.
492
+
471
493
  Trois durcissements méritent d'être connus :
472
494
 
473
495
  - **Match insensible à la casse, sans allocation** — `startsWithCI()` (`frameAuthorizer.ts#buildSystemRules`).
@@ -488,9 +510,41 @@ jamais de canal d'audit non gardé.
488
510
 
489
511
  ### La policy métier — déclarer sur le canal
490
512
 
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
493
- cumulatifs (ET) ; un axe absent n'impose rien :
513
+ Deux politiques par **nom** de canal, chacune un `IChannelPolicy` :
514
+
515
+ - la **LECTURE** — `@RealtimeChannel(name, policy)` (`realtimeDecorators.ts#RealtimeChannel`) — exigée au
516
+ `subscribe` ;
517
+ - l'**ÉCRITURE** — `@RealtimeInbound(name, policy)` (`realtimeDecorators.ts#RealtimeInbound`),
518
+ `@RealtimeAction(name, policy)` — exigée **en plus** de la lecture sur toute trame entrante.
519
+
520
+ Ce qui n'est pas déclaré s'hérite, quel que soit l'ordre des méthodes : une lecture absente reprend
521
+ l'écriture (un canal gardé par `@RealtimeInbound` seul reste protégé en lecture), une action sans
522
+ politique reprend la lecture déclarée sous son nom, sinon le défaut fermé. Un canal que tout le monde
523
+ lit et que seuls certains écrivent s'écrit donc :
524
+
525
+ ```typescript
526
+ @RealtimeChannel("chat:typing", { authenticated: false }) // tout le monde voit « Untel écrit… »
527
+ typing(): () => void {
528
+ return () => {};
529
+ }
530
+ @RealtimeInbound("chat:typing", { authenticated: true }) // seuls les connectés écrivent
531
+ onTyping(): void {}
532
+ ```
533
+
534
+ Deux déclarations **contradictoires** d'une même sorte sur un nom — dans une classe, ou dans deux
535
+ classes — refusent le démarrage en nommant le canal : une seule s'appliquerait, l'autre
536
+ disparaîtrait. Une sous-classe qui redéclare la politique de son parent la surcharge. `allow` est
537
+ une règle de lecture : posé sur une écriture distincte de la lecture, il refuse le démarrage.
538
+
539
+ Avec des **motifs** (`room:*`), la lecture d'un nom appartient à la déclaration la plus spécifique
540
+ qui déclare quelque chose sur lui (le nom exact, sinon le motif au littéral le plus long) ; l'écriture
541
+ vient de la plus spécifique qui en déclare une, motifs compris. Une écriture de famille réservée
542
+ s'applique donc aussi à un nom exact qui n'a déclaré que sa lecture, et une commande déclarée par
543
+ `@RealtimeInbound("task:start")` sous une famille `task:*` à `allow` reste une commande. Le canal
544
+ personnel `user:<id>` garde sa règle « son canal seulement » tant qu'aucune déclaration ne vise
545
+ explicitement `user:` : un motif attrape-tout (`*`) ne la retire pas.
546
+
547
+ Les trois axes d'une politique sont cumulatifs (ET) ; un axe absent n'impose rien :
494
548
 
495
549
  | Axe | Sens | Évalué par |
496
550
  | --------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------- |
@@ -498,11 +552,17 @@ cumulatifs (ET) ; un axe absent n'impose rien :
498
552
  | `roles` | un des rôles suffit, **hiérarchie comprise** | `Firewall.hasRole()` (`firewall.ts#Firewall.hasRole`) |
499
553
  | `scopes` | un des scopes suffit — axe API (JWT, clé API), une session BFF n'en porte pas | comparaison directe |
500
554
 
501
- Une policy **vide** n'est pas enregistrée : `definePolicy()` (`realtimeDecorators.ts#definePolicy`) ignore un
502
- objet sans contrainte — le canal reste libre, le registre reste vide. Les déclarations sont publiées
503
- au hub **au handshake**, pas au boot (`RealtimeHub.registerChannelPolicy()`, `RealtimeHub.ts#RealtimeHub.registerChannelPolicy`,
504
- idempotent) ; le décideur les relit par `RealtimeService.resolveChannelPolicy()`
505
- (`RealtimeService.ts#RealtimeService.resolveChannelPolicy`).
555
+ Une policy **vide** n'est pas enregistrée : `declarePolicy()` (`realtimeDecorators.ts#declarePolicy`) ignore un
556
+ objet sans contrainte — le canal reste libre, le registre reste vide. Les déclarations sont inscrites
557
+ au hub **au démarrage** (`channelPolicyRegistry.ts#registerControllerPolicies`, à `onKernelReady`) :
558
+ un exemplaire qui n'a encore vu aucune poignée de main a déjà ses politiques — celui qui n'émet
559
+ que des webhooks aussi. Un contrôleur échappé au démarrage s'inscrit à sa première poignée de main
560
+ (`RealtimeHub.registerChannelPolicy()`, `RealtimeHub.ts#RealtimeHub.registerChannelPolicy`, idempotent) ;
561
+ le décideur les relit en une résolution par `RealtimeService.resolveChannelPolicies()`
562
+ (`RealtimeService.ts#RealtimeService.resolveChannelPolicies`). Un refus d'écriture est audité sous
563
+ le motif `channel_write_policy`, distinct de `channel_policy` (lecture). Un verrou qui ne sait pas
564
+ juger l'écriture (il ne déclare pas `judgesWrite`) refuse le démarrage dès qu'une écriture distincte
565
+ est déclarée : versions de `@nodefony/realtime` et `@nodefony/security` à aligner.
506
566
 
507
567
  ### ⚠️ Une policy est attachée à un nom EXACT — les canaux dérivés n'héritent pas
508
568
 
@@ -539,6 +599,213 @@ Deux façons de fermer, selon l'intention :
539
599
  > Le nom exact l'emporte sur un motif ; entre deux motifs, le plus spécifique gagne. Un `*` dans un
540
600
  > nom souscrit est refusé : il désigne un motif de politique, jamais un canal.
541
601
 
602
+ ### 📬 Le canal personnel `user:<id>` — fourni, rien à déclarer
603
+
604
+ Livrer un message à **une** personne — sur tous ses onglets, sur tous les serveurs, et à personne
605
+ d'autre — est une brique du framework. L'application n'écrit ni canal, ni règle, ni déclaration de
606
+ diffusion :
607
+
608
+ ```typescript
609
+ // côté serveur — un service, un job, un contrôleur
610
+ getRealtimeHub<ChatContract>().publishToUser(bob.id, message);
611
+
612
+ // côté navigateur — cet onglet s'abonne quand son écran écoute
613
+ useEffect(() => chat.onUser((m) => addPrivate(m)), []);
614
+ ```
615
+
616
+ La forme du message se déclare dans le contrat, par la clé gabarit du canal personnel :
617
+
618
+ ```typescript
619
+ interface ChatContract extends IRealtimeContract {
620
+ serverToClient: {
621
+ [channel: `user:${string}`]: { from: string; text: string };
622
+ };
623
+ }
624
+ ```
625
+
626
+ Ce que le framework garantit, écrit une fois :
627
+
628
+ | Garantie | Comment |
629
+ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
630
+ | **Toi seul** | Politique intégrée : connexion authentifiée, admission par **égalité de chaîne** avec `user:<ton id>` (`userChannel.ts#isOwnUserChannel`). Jamais un découpage : l'identité `4` n'entre pas sur `user:42`. |
631
+ | **Aucun oracle** | Le verrou passe avant la fabrique, et la règle ne lit que l'identité du demandeur, jamais une base : `user:999` (inexistant) et `user:bob` (pas le tien) reçoivent le **même** `forbidden`. |
632
+ | **Tous les serveurs** | Dès qu'un bus est branché, le préfixe `user:` est diffusé par le framework (`RealtimeHub.ts#RealtimeHub.setBackplane`). |
633
+ | **Sans sécurité chargée** | La règle s'applique quand même : `allow` ne fait que retirer des abonnés. |
634
+ | **Jamais d'office** | Rien n'est ouvert à la connexion : une application qui n'appelle jamais `onUser` ne paie rien. Mesuré : 494 octets par onglet abonné, envoi ~60 ns. |
635
+
636
+ L'`id` est l'identifiant **stable** du compte (`RealtimeIdentity.id`), jamais le login : un login
637
+ libéré se réattribue, et le nouveau compte recevrait les messages de l'ancien. Un appelant sans
638
+ compte local (`<émetteur>#<sujet>`, connexion d'un fournisseur d'identité) a lui aussi son canal ;
639
+ un **anonyme** n'en a pas — il n'a pas d'identifiant qui survive à une reconnexion. Le support
640
+ anonyme passe par un salon privé de l'application (`support:<conversation>` + `allow`, ci-dessous).
641
+
642
+ **Le prix assumé** : un compte supprimé garde son abonnement jusqu'à son éjection explicite. Et une
643
+ publication sans abonné est perdue sans erreur — c'est une diffusion, pas une boîte aux lettres :
644
+ l'historique d'une conversation vit dans la base de l'application.
645
+
646
+ **Surcharger la règle** : une politique que l'application écrit sur `user:…` (nom exact ou motif)
647
+ l'emporte sur la règle intégrée — c'est ainsi qu'on ouvre `user:public` à tous. Une telle
648
+ déclaration remplace la garantie « toi seul » : à n'écrire qu'en connaissance de cause.
649
+
650
+ ### 🪪 `allow` — admettre un abonné par SON identité
651
+
652
+ Les trois axes décrivent une **classe** d'identité : « un administrateur », « un service qui porte
653
+ `metrics:read` ». Ils ne savent pas dire « **ce** compte, et lui seul, sur **ce** canal » — la boîte
654
+ aux lettres `user:42`, la salle d'une partie, le tableau de bord d'une seule usine. C'est le rôle
655
+ d'`allow` : une règle qui reçoit le canal demandé et l'identité de l'abonné, et répond oui ou non.
656
+ Elle se joue au `subscribe`, une fois, dans le contrôleur
657
+ (`RealtimeController.ts#RealtimeController.startChannel`) ; la règle elle-même est un contrat du cœur
658
+ (`channelAdmission.ts#IChannelAdmission`), isomorphe : rien n'y dépend de la socket.
659
+
660
+ > L'exemple ci-dessous écrit à la main la règle de `user:*` pour montrer la mécanique. Pour le canal
661
+ > personnel lui-même, **ne rien écrire** : le framework le fournit (section précédente), et une
662
+ > politique écrite ainsi remplacerait la sienne.
663
+
664
+ **La forme courte — une flèche, pour une règle qui ne lit rien :**
665
+
666
+ ```typescript
667
+ // nodefony/controllers/InboxRealtimeController.ts
668
+ import { controller, route } from "@nodefony/framework";
669
+ import { Context } from "@nodefony/http";
670
+ import { RealtimeChannel, RealtimeController } from "@nodefony/realtime";
671
+ import type { RealtimePublish } from "@nodefony/realtime";
672
+
673
+ @controller("/rt")
674
+ class InboxRealtimeController extends RealtimeController {
675
+ constructor(context: Context) {
676
+ super("InboxRealtimeController", context);
677
+ }
678
+
679
+ @route("inbox-ws", {
680
+ path: "/inbox",
681
+ requirements: { methods: ["WEBSOCKET"] },
682
+ })
683
+ async realtime(message: string | Buffer | null): Promise<void> {
684
+ this.handleRealtime(message);
685
+ }
686
+
687
+ // La règle du MOTIF : `user:42` n'est ouvert qu'au compte 42.
688
+ @RealtimeChannel("user:*", {
689
+ allow: (channel, who) => channel === `user:${who.id}`,
690
+ })
691
+ inbox(): () => void {
692
+ return () => {};
693
+ }
694
+
695
+ // Un producteur décoré ne sert que son nom exact : les canaux `user:<id>`
696
+ // sont servis par la fabrique dynamique.
697
+ override createRealtimeChannel(
698
+ channel: string,
699
+ publish: RealtimePublish,
700
+ ): (() => void) | null {
701
+ if (!channel.startsWith("user:")) return null;
702
+ publish(channel, { unread: 0 });
703
+ return () => {};
704
+ }
705
+ }
706
+
707
+ export default InboxRealtimeController;
708
+ ```
709
+
710
+ Le compte 42 reçoit `realtime:subscribed` pour `user:42` ; le compte 43 et l'anonyme reçoivent
711
+ `realtime:denied`, motif `forbidden` — prouvé de bout en bout, vrai client et vrai verrou, par
712
+ `realtimeChannelAuth.e2e.test.ts`.
713
+
714
+ **La forme longue — une classe, dès que la règle lit les données de l'application.** Ses services lui
715
+ arrivent par le constructeur, comme à tout service ; elle se déclare dans `@services([…])` de son
716
+ module, et le décorateur la nomme :
717
+
718
+ ```typescript
719
+ // nodefony/services/RoomAdmission.ts
720
+ import { Inject, Module, Service, injectable } from "nodefony";
721
+ import type { IChannelAdmission, ISubscriberIdentity } from "nodefony";
722
+ import type RoomMembersService from "./RoomMembersService";
723
+
724
+ @injectable()
725
+ class RoomAdmission extends Service implements IChannelAdmission {
726
+ constructor(
727
+ module: Module,
728
+ @Inject("RoomMembersService") private readonly members: RoomMembersService,
729
+ ) {
730
+ super(
731
+ "roomAdmission",
732
+ module.container ?? undefined,
733
+ module.notificationsCenter,
734
+ );
735
+ }
736
+
737
+ // `room:<partie>` n'est ouvert qu'aux joueurs de la partie.
738
+ async allow(channel: string, who: ISubscriberIdentity): Promise<boolean> {
739
+ if (who.id === null) return false;
740
+ return this.members.isPlayer(channel.slice("room:".length), who.id);
741
+ }
742
+ }
743
+
744
+ export default RoomAdmission;
745
+
746
+ // Dans le contrôleur :
747
+ // @RealtimeChannel("room:*", { allow: RoomAdmission })
748
+ ```
749
+
750
+ Le démarrage résout la classe une fois et journalise « canal room:\*, admission RoomAdmission,
751
+ résolue » (`channelPolicyRegistry.ts#createAdmissionResolver`). Une dépendance absente, une portée
752
+ autre que singleton, une classe sans méthode `allow` font **échouer le démarrage** en nommant la
753
+ classe et le canal — jamais un refus silencieux de 100 % des abonnés en production. Une seule
754
+ instance sert tous les abonnés en même temps : elle ne garde **aucun état par abonné**.
755
+
756
+ Un **module npm** livre sa politique de la même façon : la classe vit dans le module, déclarée dans
757
+ ses `@services([…])`, son contrôleur la nomme. L'application qui l'installe n'écrit rien.
758
+
759
+ **Le contrat, en six règles :**
760
+
761
+ | Règle | Pourquoi |
762
+ | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
763
+ | **Ses arguments, et rien d'autre** | ni contexte de requête, ni session : à l'émission d'un webhook il n'y en a pas. Une règle qui les lirait passerait tous ses tests sur la socket, puis refuserait tout ailleurs |
764
+ | **`who.id`, jamais le login** | un e-mail change, un login libéré se réattribue ; `id` est l'identifiant stable du compte, `null` pour l'anonyme |
765
+ | **Seul `true` admet** | `1`, `"yes"`, un objet refusent — une réponse ambiguë ne vaut pas un oui |
766
+ | **L'anonyme est refusé d'office** | sans même appeler la règle, sauf `anonymous: true` dans la politique ; un authentificateur qui ne fournit pas d'identité d'admission (`getIdentity`) n'entre sur aucun canal à `allow` |
767
+ | **Elle s'ajoute aux trois axes** | `authenticated`, `roles`, `scopes` d'abord (le verrou), puis `allow` (l'abonnement). Une politique plus spécifique remplace celle du motif EN ENTIER, `allow` compris : `@RealtimeChannel("user:public", { authenticated: false })` ouvre ce canal-là |
768
+ | **Une fois par abonnement, jamais par trame** | admis, les trames entrantes du canal passent sans la rappeler ; une trame entrante sur un canal à `allow` SANS abonnement préalable est refusée |
769
+
770
+ Le `realtime:welcome` annonce un canal à `allow` sur le seul verdict du verrou : la règle n'est
771
+ jamais jouée à la poignée de main — elle coûterait un appel par canal déclaré et par connexion, pour
772
+ annoncer un motif dont aucun nom concret n'est connu.
773
+
774
+ **Quand la règle est lente ou en panne** — la base qu'elle lit ne répond plus :
775
+
776
+ <!-- prettier-ignore -->
777
+ | Situation | L'abonné reçoit | Le serveur |
778
+ | --- | --- | --- |
779
+ | la règle lève, ou sa promesse est rejetée | `realtime:denied` `forbidden` (fermé par défaut) | un `WARNING` par fenêtre de 10 s |
780
+ | aucune réponse dans `allowTimeoutMs` (5 s) | `unavailable` | `allowTimedOutTotal` ; un verdict arrivé après coup est ignoré, jamais une admission |
781
+ | plus de `maxPendingAllowsPerConnection` (8) appels en vol sur la connexion | rien : la demande **attend son tour** | — le rejeu de tous les abonnements au `welcome` n'en perd aucun |
782
+ | plus de `maxPendingAllowsTotal` (512) appels en vol sur l'exemplaire | `unavailable`, sans appel | `allowSaturatedTotal` |
783
+ | 50 délais dépassés en 5 s | `unavailable` et `retryAfterMs`, sans appel, pendant 5 s (coupe-circuit) | `allowSaturatedTotal` |
784
+
785
+ Un délai dépassé n'est **pas** un refus de droit : le client réessaie `unavailable` au plus trois
786
+ fois, avec un recul exponentiel à gigue complète, après `retryAfterMs` quand il est donné.
787
+ `forbidden` et `limit` ne se réessaient jamais. Le tout reste borné : 300 passerelles qui rejouent
788
+ 10 000 abonnements contre une base **bloquée** déclenchent environ 1 600 appels de la règle, jamais
789
+ plus de 512 à la fois, et la tempête s'éteint en moins d'une minute
790
+ (`admissionStorm.e2e.test.ts`). Réglages : [`configuration.md`](./configuration.md) ; compteurs :
791
+ [`observabilite.md`](./observabilite.md).
792
+
793
+ **Le prix assumé — une réponse retenue.** Une admission ne se périme pas seule : un membre retiré
794
+ d'une salle reste abonné tant que sa connexion vit, la règle n'est pas rejouée. Pour couper tout de
795
+ suite, on retire son appartenance (là où la règle la lit), PUIS on l'éjecte :
796
+ `getRealtimeHub().evictUser(canal, compte)` coupe tous ses onglets, sur tous les serveurs
797
+ (`realtime:closed { reason: "evicted" }`). L'éjection seule ne suffit pas — son navigateur rejoue
798
+ `subscribe` au rechargement, et c'est la règle qui tranche alors. Détail :
799
+ [canaux à membres](./canaux-a-membres.md). Fermer la connexion entière, c'est la révocation
800
+ ([verrou 4](#-verrou-4--la-révocation--une-socket-survit-à-sa-session)).
801
+
802
+ **`room:*` et `pattern: "room:"` — deux grammaires, une même famille.** Le décorateur écrit un motif
803
+ (`"room:*"`) ; la configuration de `@nodefony/security` écrit un préfixe
804
+ (`realtimeChannels: [{ pattern: "room:", roles }]`). Ils couvrent les mêmes canaux. La règle de
805
+ configuration est jugée par le verrou AVANT la politique du décorateur et la remplace de ce côté —
806
+ c'est ainsi qu'une exploitation durcit un namespace sans toucher au code ; elle ne porte jamais
807
+ d'`allow`, qui reste déclaré sur le décorateur et s'applique toujours à l'abonnement.
808
+
542
809
  ### ⚠️ La condition d'activation — le point critique
543
810
 
544
811
  Le verrou n'existe que si quelqu'un le pose. Deux conditions doivent être vraies **en même temps** :
@@ -592,7 +859,10 @@ Le refus doit être observable sans devenir un oracle. Nodefony tranche ainsi :
592
859
  | Canal sans aucun producteur | `realtime:denied { channel, reason: "unknown" }` |
593
860
 
594
861
  Le message est **générique** dans les trois cas : jamais « il te manque `ROLE_ADMIN` », jamais le nom
595
- de la zone. Sans `realtime:denied`, une notification refusée serait droppée en silence (elle n'a pas
862
+ de la zone. Hors production seulement, un champ `detail` ajoute la phrase qui aide le développeur
863
+ (`deniedDetail.ts#deniedDetail`) ; quand le verrou sait dire pourquoi il a refusé — un suffixe
864
+ `:health`/`:stats` réservé à la plateforme —, `detail` EST cette explication
865
+ (`RealtimeHub.ts#RealtimeHub.explainDenial`). Le journal du serveur la porte, lui, dans tous les modes. Sans `realtime:denied`, une notification refusée serait droppée en silence (elle n'a pas
596
866
  de canal de réponse) et le client se croirait abonné. **Aucun abonnement ne reste sans réponse** :
597
867
  un canal que personne ne produit — nom mal orthographié, module non chargé — rend `unknown`, qui
598
868
  n'ouvre aucun oracle (un canal gardé est tranché en amont, donc `forbidden` qu'il existe ou non).
@@ -623,27 +893,58 @@ ou changée → `-32000` avec `status: 401`, et le client bascule sur un `fetch`
623
893
  courant. Une erreur de re-validation vaut refus (fail-closed).
624
894
 
625
895
  **Sur les canaux**, le hub n'inscrit au registre de révocation que les connexions dont le token porte
626
- `isValid` (`RealtimeController.ts#RealtimeController.onHandshake`) — anonymes et JWT n'y entrent jamais, coût nul.
896
+ `isValid` (`RealtimeController.ts#RealtimeController.onHandshake`). Une connexion anonyme n'y entre jamais,
897
+ coût nul. Toute connexion authentifiée par le pare-feu y entre : sa session est relue, ou, pour un
898
+ jeton porteur (`jwt`, `external-jwt`, `apikey`), son expiration (`exp`), la révocation de son `jti` et
899
+ la révocation en masse de son identité (`invalidBefore`).
627
900
  `RealtimeHub.registerRevocable()` (`RealtimeHub.ts#RealtimeHub.registerRevocable`) démarre un `setInterval` `unref` au premier
628
901
  inscrit et l'arrête dès que le registre se vide : zéro timer au repos. Période :
629
902
  `REVOCATION_REVALIDATE_MS` (`RealtimeHub.ts#channelFamily`), 30 s, alignée sur le heartbeat WS.
630
903
 
631
904
  Ce que `realtimeRevocation.attack.test.ts` prouve exactement :
632
905
 
633
- | Cas | Comportement prouvé |
634
- | ------------------------------------------ | -------------------------------------------------------- |
635
- | `isValid()` renvoie `false` | `close(4001, "session revoked")` |
636
- | `isValid()` renvoie `true` | socket **intacte** (contrôle positif, pas de faux refus) |
637
- | `isValid()` **throw** (store indisponible) | socket fermée — fail-closed, parité avec `api.request` |
638
- | déjà révoqué | retiré du registre avant le close → aucun re-close |
639
- | déconnexion propre | `unregisterRevocable` → plus jamais re-validé |
640
- | registre vide | tick no-op, aucun crash |
906
+ | Cas | Comportement prouvé |
907
+ | ------------------------------------------ | --------------------------------------------------------- |
908
+ | `isValid()` renvoie `false` | `close(4001, "session revoked")` |
909
+ | `status()` rend `expired` (jeton porteur) | `close(4006, "token expired")` |
910
+ | `status()` rend `revoked` | `close(4001, "session revoked")`, même si `exp` est passé |
911
+ | `isValid()` renvoie `true` | socket **intacte** (contrôle positif, pas de faux refus) |
912
+ | `isValid()` **throw** (store indisponible) | socket fermée — fail-closed, parité avec `api.request` |
913
+ | déjà révoqué | retiré du registre avant le close → aucun re-close |
914
+ | déconnexion propre | `unregisterRevocable` → plus jamais re-validé |
915
+ | registre vide | tick no-op, aucun crash |
641
916
 
642
917
  La source de vérité côté security est `buildSessionRevalidator()`
643
918
  (`FirewallRealtimeAuthenticator.ts#buildSessionRevalidator`) : il relit la session BFF **par son id capturé au handshake**
644
919
  et vérifie qu'elle est toujours vivante **et toujours celle du même utilisateur** — ce second point
645
920
  attrape le changement de compte sur un navigateur partagé.
646
921
 
922
+ **Le code de fermeture dit la cause** (`realtime/closeCodes.ts`, ADR-0021 §S11). Un jeton porteur
923
+ sait pourquoi il est mort : `IRealtimeToken.status()` rend `expired` ou `revoked`, et la
924
+ révocation l'emporte — la liste de refus est interrogée avant de conclure à l'expiration.
925
+
926
+ | Cause sur une socket OUVERTE | Code | Client 10.0 |
927
+ | ------------------------------------------------------------- | -------------------------------------- | -------------------- |
928
+ | jeton porteur arrivé à `exp` | `4006` (`TOKEN_EXPIRED_CLOSE_CODE`) | s'arrête (définitif) |
929
+ | jeton porteur révoqué (`jti`, `invalidBefore`), expiré ou non | `4001` (`IDENTITY_DEAD_CLOSE_CODE`) | s'arrête |
930
+ | session expirée ou détruite (la cause n'est pas connaissable) | `4001` | s'arrête |
931
+ | droits changés, identité vivante | `4005` (`IDENTITY_REFRESH_CLOSE_CODE`) | revient aussitôt |
932
+ | refus à la poignée de main, quel qu'il soit | 401 uniforme → `1008` | s'arrête |
933
+ | authentification sans réponse dans `authenticateTimeoutMs` | `1013` (`TRY_AGAIN_LATER_CLOSE_CODE`) | revient, avec recul |
934
+ | client qui ne vide plus sa file (contre-pression) | `1013` | revient, avec recul |
935
+
936
+ > [!IMPORTANT]
937
+ > **Jamais de `4006` à la poignée de main.** Un refus distinct y dirait à qui tient un jeton volé
938
+ > qu'il a été émis par l'application : un jeton expiré présenté à l'entrée reçoit le même 401 que
939
+ > n'importe quel autre refus.
940
+
941
+ **Recette — un jeton d'une heure sur une socket de sept jours.** La socket vit plus longtemps que son
942
+ jeton : au bout d'une heure, elle est fermée en `4006`, et le client 10.0 ne revient pas seul. C'est
943
+ à l'application de **recréer sa socket avec un jeton neuf avant `exp`** — par exemple obtenir un
944
+ nouveau jeton cinq minutes avant l'échéance, ouvrir la nouvelle socket, puis fermer l'ancienne.
945
+ Un client qui saura obtenir un jeton frais de lui-même viendra dans une version 10.x, sans que le
946
+ serveur change.
947
+
647
948
  **Un changement de rôles coupe sur-le-champ, sans attendre le tick.** Quand une mise à jour du compte
648
949
  touche ses rôles, le hub ferme toutes les sockets de cette identité avec le code **4005**
649
950
  (`realtime/closeCodes.ts#IDENTITY_REFRESH_CLOSE_CODE`, `RealtimeHub.ts#RealtimeHub.#revocation`). À la
@@ -658,18 +959,23 @@ Aucune lecture de base par tick ni par frame : le coût n'existe qu'à la mise
658
959
  > 1. **Fenêtre de 30 s.** Entre le logout et le tick suivant, les flux continuent. Pour une coupure
659
960
  > immédiate, fermer la socket explicitement côté serveur. (Un changement de **rôles**, lui, coupe
660
961
  > immédiatement — ci-dessus.)
661
- > 2. **Best-effort si la session est illisible au handshake.** Si l'id ou le store ne sont pas
662
- > accessibles, `buildSessionRevalidator()` renvoie `null` ; `UserRealtimeToken.isValid()`
663
- > (`UserRealtimeToken.ts#UserRealtimeToken.isValid`) répond alors toujours `true`. La connexion **est** inscrite au
664
- > registre (le token expose bien `isValid`) mais ne sera **jamais** révoquée. Choix assumé pour
665
- > éviter les faux refus — à connaître, car cela ne se voit pas.
666
- > 3. **Les identités sans `isValid` ne sont pas révocables du tout** : anonyme, JWT, clé API. Pour
667
- > un JWT, la révocation reste portée par son `exp` et par la reconnexion.
962
+ > 2. **Une identité qu'on ne pourra pas re-valider ferme la socket.** Si la session est illisible au
963
+ > handshake (ni id ni store exploitable), `buildSessionRevalidator()`
964
+ > (`FirewallRealtimeAuthenticator.ts#buildSessionRevalidator`) rend un revalidateur qui répond
965
+ > toujours `false` : la socket est fermée en 4001 au premier tick, et un `WARNING` le dit. Même
966
+ > règle pour un jeton porteur qui n'a ni `exp`, ni de quoi interroger le store de révocation
967
+ > (`jti`, `iat`). Un refus visible plutôt qu'une connexion que rien ne pourrait plus couper.
968
+ > 3. **Un jeton porteur n'est jugé sur sa signature qu'à l'ouverture.** Le tick relit `exp`, `jti` et
969
+ > `invalidBefore`, jamais la clé ni l'émetteur : une clé retirée du jeu publié par l'émetteur laisse
970
+ > la socket vivre jusqu'à `exp`, et un émetteur retiré de `resourceServer.issuers` ne compte qu'à
971
+ > la connexion suivante — la liste ne change qu'au redémarrage, qui ferme les sockets. Une
972
+ > connexion anonyme, elle, n'est pas révocable du tout.
668
973
 
669
974
  ## 🚧 Plafonds anti-abus — borner une connexion
670
975
 
671
976
  Une connexion ouverte est une ressource. Trois bornes existent ; il faut aussi savoir ce qui **n'est
672
- pas** borné.
977
+ pas** borné. Les appels en vol de la règle `allow` ont leurs propres plafonds, par connexion et par
978
+ exemplaire : voir [`allow` — quand la règle est lente](#-allow--admettre-un-abonné-par-son-identité).
673
979
 
674
980
  ### Canaux par connexion
675
981
 
@@ -783,16 +1089,16 @@ n'est pas appliquée.
783
1089
 
784
1090
  ## 📜 Normes appliquées
785
1091
 
786
- | Norme | Application |
787
- | ------------------------------------- | -------------------------------------------------------------------------------------------- |
788
- | **RFC 6455 §10.2** (Origin) | Contrôle d'origine à l'upgrade, aux deux étages |
789
- | **RFC 6455 §7.4.2** (codes 4000-4999) | `4001` unauthorized / session revoked, `4003` origin not allowed |
790
- | **RFC 6455 §7.4.1 / registre IANA** | `1008` Policy Violation (origine transport), `1009` Message Too Big, `1013` Try Again Later |
791
- | **RFC 6455 §4.1** | `Sec-WebSocket-Protocol` normalisé en liste dans le DTO de handshake |
792
- | **RFC 9110 §7.2** | Comparaison du `Host` par le matcher d'authenticator |
793
- | **JSON-RPC 2.0 §5.1** | `-32001` (plage `Server error`) pour une requête refusée, `-32602` pour des params invalides |
794
- | **OWASP WSTG-CLNT-10** | Anti-CSWSH : validation d'`Origin` obligatoire, les navigateurs n'appliquent pas CORS aux WS |
795
- | **Zero Trust / OWASP A01** | Zone protégée + anonyme = refus ; token toujours présent, jamais `null` |
1092
+ | Norme | Application |
1093
+ | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
1094
+ | **RFC 6455 §10.2** (Origin) | Contrôle d'origine à l'upgrade, aux deux étages |
1095
+ | **RFC 6455 §7.4.2** (codes 4000-4999) | `4001` unauthorized / session revoked, `4003` origin not allowed, `4006` token expired |
1096
+ | **RFC 6455 §7.4.1 / registre IANA** | `1008` Policy Violation (origine transport), `1009` Message Too Big, `1013` Try Again Later (contre-pression, authentification sans réponse) |
1097
+ | **RFC 6455 §4.1** | `Sec-WebSocket-Protocol` normalisé en liste dans le DTO de handshake |
1098
+ | **RFC 9110 §7.2** | Comparaison du `Host` par le matcher d'authenticator |
1099
+ | **JSON-RPC 2.0 §5.1** | `-32001` (plage `Server error`) pour une requête refusée, `-32602` pour des params invalides |
1100
+ | **OWASP WSTG-CLNT-10** | Anti-CSWSH : validation d'`Origin` obligatoire, les navigateurs n'appliquent pas CORS aux WS |
1101
+ | **Zero Trust / OWASP A01** | Zone protégée + anonyme = refus ; token toujours présent, jamais `null` |
796
1102
 
797
1103
  ## 📡 Observabilité — Studio
798
1104
 
@@ -821,7 +1127,7 @@ n'est pas appliquée.
821
1127
  | Le client se croit abonné, ne reçoit rien | Refus d'autorisation, plafond atteint, **ou nom de canal sans producteur** (pas de réponse RPC) | Écouter `realtime:denied` : `forbidden` (droits), `limit` (borne), `unknown` (le nom ne désigne rien) |
822
1128
  | `slowConsumer.bytes` augmenté, les frames sont toujours jetées à 4 MiB | Cette clé pilote le **comptage** de la sonde, pas les seuils de drop/close du transport | Régler `websocket.maxBackpressure` / `.backpressureCloseAfterDrops` dans `@nodefony/http` |
823
1129
  | Deux déploiements se parlent en cross-talk | Pas de `backplane.namespace` sur un Redis mutualisé (la base Redis ne cloisonne pas le pub/sub) | Poser un `namespace` explicite par déploiement |
824
- | Le fan-out cross-pod s'arrête après avoir posé un secret | `backplane.secret` différent d'un pod à l'autre : les messages sont scellés, aucun ne se vérifie | Le **même** secret sur tous les pods (`NF_REALTIME_BACKPLANE_SECRET`) ; suivre `ingressRejectedTotal` |
1130
+ | Le fan-out cross-pod s'arrête après avoir posé un secret | `backplane.secret` différent d'un pod à l'autre : les messages sont scellés, aucun ne se vérifie | Le **même** secret sur tous les pods (`NF_REALTIME_BACKPLANE_SECRET`) ; suivre `sealRejectedTotal` |
825
1131
  | Un canal broadcast ne reçoit rien des autres pods | Le préfixe n'est pas déclaré côté receveur → l'entrée refuse le canal (compté) | Déclarer le préfixe (`broadcast` du controller) sur **tous** les pods, pas seulement l'émetteur |
826
1132
 
827
1133
  ## 🧪 Tests & couverture
@@ -305,7 +305,8 @@ front**. Une différence assumée : `request()` n'y est pas supporté — au-des
305
305
  ### `accueil` (welcome) — la première frame
306
306
 
307
307
  La notification `realtime:welcome`, poussée par le serveur juste après le handshake. Elle annonce le
308
- protocole, les canaux que ce visiteur peut obtenir, les actions de l'endpoint, et l'identité résolue. Un client
308
+ protocole, les canaux que ce visiteur peut obtenir, les actions de l'endpoint, l'identité résolue et, quand
309
+ le client en a offert un, le codec de trame retenu (`codec`, absent = JSON-RPC texte). Un client
309
310
  attend cette frame avant de pousser quoi que ce soit.
310
311
 
311
312
  `IRealtimeWelcome` (`RealtimeEventMap.ts#IRealtimeWelcome`).
@@ -480,7 +481,7 @@ et de ses handlers. Aucune dépendance Node — le même fichier tourne dans le
480
481
  Le geste du pair sur une frame entrante : décider de sa nature, trouver le handler, l'appeler,
481
482
  renvoyer la réponse s'il en faut une. Ce n'est pas une méthode publique — c'est un **moment**, et
482
483
  c'est précisément là que se greffe le [verrou de frame](#verrou-de-frame--la-décision-par-frame),
483
- par le crochet `beforeDispatch` (`JsonRpcPeer.ts#JsonRpcPeerOptions.beforeDispatch`).
484
+ par le crochet `beforeDispatch` (`JsonRpcPeer.ts#IJsonRpcPeerHooks.beforeDispatch`).
484
485
 
485
486
  ### `transport` — la couche octets
486
487
 
@@ -583,7 +584,7 @@ L'identifiant **unique dans le cluster** du process émetteur, joint à chaque m
583
584
  du nom du pod ou de l'hôte, jamais du seul identifiant de processus : deux conteneurs peuvent tous
584
585
  deux être le processus n° 1.
585
586
 
586
- `resolveBackplaneOriginId()` (`originId.ts#resolveBackplaneOriginId`), champ `IBackplane.originId` (`IBackplane.ts#IBackplaneInfo.kind`).
587
+ `resolveBackplaneOriginId()` (`originId.ts#resolveBackplaneOriginId`), champ `IBackplane.originId` (`nodefony/src/realtime/platformHealth.ts#IBackplaneInfo.kind`).
587
588
 
588
589
  ### `anti-écho` — ne pas rejouer sa propre voix
589
590
 
@@ -608,7 +609,7 @@ L'information que chaque driver déclare sur lui-même : sait-il franchir la mac
608
609
  dans la carte d'identité du backplane, journalisée au démarrage et lisible dans la sonde — c'est ce
609
610
  qui permet de vérifier en une ligne qu'un déploiement diffuse bien au-delà d'un pod.
610
611
 
611
- `IBackplaneInfo` (`IBackplane.ts#IBackplaneInfo`).
612
+ `IBackplaneInfo` (`nodefony/src/realtime/platformHealth.ts#IBackplaneInfo`).
612
613
 
613
614
  ## 🔐 La sécurité — qui parle, qui a le droit
614
615
 
@@ -667,7 +668,7 @@ un refus a donc toujours un auteur identifiable.
667
668
  Les exigences déclarées d'un canal : authentifié, rôles, scopes. Elles s'attachent au **nom** du
668
669
  canal, pas à la méthode qui l'implémente — parce que c'est le nom que le pare-feu résout.
669
670
 
670
- `IChannelPolicy` (`IChannelPolicy.ts#IChannelPolicy`), déclaration par `@RealtimeChannel`
671
+ `IChannelPolicy` (`nodefony/src/realtime/channelAdmission.ts#IChannelPolicy`), déclaration par `@RealtimeChannel`
671
672
  (`realtimeDecorators.ts#RealtimeChannel`), résolution `resolveChannelPolicy()` (`RealtimeHub.ts#RealtimeHub.resolveChannelPolicy`).
672
673
 
673
674
  ### `verrou de frame` — la décision par frame
@@ -712,7 +713,7 @@ Le signal émis sur les événements protocolaires qui méritent une trace : fra
712
713
  refusée, méthode inconnue, erreur interne. Émis sans attente, avec le pair — ce qui permet de
713
714
  retrouver **qui** a été refusé, pas seulement d'où venait le paquet.
714
715
 
715
- `FrameAuditReason` (`JsonRpcPeer.ts#FrameAuditReason`), crochet `onFrameAudit` (`JsonRpcPeer.ts#JsonRpcPeerOptions.onFrameAudit`).
716
+ `FrameAuditReason` (`JsonRpcPeer.ts#FrameAuditReason`), crochet `onFrameAudit` (`JsonRpcPeer.ts#IJsonRpcPeerHooks.onFrameAudit`).
716
717
 
717
718
  ### `seam` — le point de greffe
718
719
 
@@ -720,8 +721,8 @@ Littéralement une **couture** : un point prévu dans une couche basse pour qu'u
720
721
  greffe du comportement **sans modifier la couche basse**. Le module en expose cinq, et c'est ce qui
721
722
  permet à la couche sécurité de se brancher sans qu'aucune ligne de realtime ne la connaisse.
722
723
 
723
- Les deux du protocole sont `beforeDispatch` (`JsonRpcPeer.ts#JsonRpcPeerOptions.beforeDispatch`) et `onFrameAudit`
724
- (`JsonRpcPeer.ts#JsonRpcPeerOptions.onFrameAudit`) ; les trois du hub sont l'authenticator, le matcher et le contrôle d'origine.
724
+ Les deux du protocole sont `beforeDispatch` (`JsonRpcPeer.ts#IJsonRpcPeerHooks.beforeDispatch`) et `onFrameAudit`
725
+ (`JsonRpcPeer.ts#IJsonRpcPeerHooks.onFrameAudit`) ; les trois du hub sont l'authenticator, le matcher et le contrôle d'origine.
725
726
 
726
727
  → [Sécurité](./securite.md) pour le détail de chacun.
727
728
 
@@ -751,7 +752,7 @@ L'oscilloscope du module : « voici mon état, maintenant ». Canaux et abonnés
751
752
  livraisons, connexions, octets, back-pressure, carte d'identité du backplane. Lecture **pure**, sans
752
753
  allocation sur le chemin chaud.
753
754
 
754
- `RealtimeHub.probe()` (`RealtimeHub.ts#RealtimeHub.probe`) rend un `IRealtimeProbe` (`IRealtimeProbe.ts#IRealtimeProbe`).
755
+ `RealtimeHub.probe()` (`RealtimeHub.ts#RealtimeHub.probe`) rend un `IRealtimeProbe` (`nodefony/src/realtime/platformHealth.ts#IRealtimeProbe`).
755
756
 
756
757
  > [!WARNING]
757
758
  > `IRealtimeProbe` est la **forme des données**, pas une interface à implémenter avec une méthode
@@ -774,7 +775,7 @@ Une connexion dont le back-pressure dépasse le seuil de comptage. C'est une **m
774
775
  sanction : le seuil de comptage se règle indépendamment des seuils d'action.
775
776
 
776
777
  `slowConsumer.bytes` (`config.ts#slowConsumerSchema`), champ `backpressure.slowConsumers`
777
- (`IRealtimeProbe.ts#IRealtimeProbe.backpressure`).
778
+ (`nodefony/src/realtime/platformHealth.ts#IRealtimeProbe.backpressure`).
778
779
 
779
780
  ### `compteurs de canal` — mesurés à l'arrivée
780
781
 
@@ -782,7 +783,7 @@ Nombre de messages, date du dernier, débit instantané, historique glissant —
782
783
  point d'arrivée des frames, donc réutilisables par n'importe quelle application.
783
784
 
784
785
  `IChannelStats` (`IRealtimeSocket.ts#IChannelStats.method`), côté serveur `IRealtimeChannelStat`
785
- (`IRealtimeProbe.ts#IRealtimeChannelStat`).
786
+ (`nodefony/src/realtime/platformHealth.ts#IRealtimeChannelStat`).
786
787
 
787
788
  ### `santé agrégée` — la vue du pod entier
788
789
 
@@ -790,7 +791,7 @@ En multi-worker, chaque process a **sa** sonde. La vue du pod est leur fusion :
790
791
  maxima repris au plus haut, instances listées une à une.
791
792
 
792
793
  `mergeClusterHealth()` (`ClusterProbeClient.ts#mergeClusterHealth`), forme `IRealtimeClusterHealth`
793
- (`IRealtimeProbe.ts#IRealtimeClusterHealth`).
794
+ (`nodefony/src/realtime/platformHealth.ts#IRealtimeClusterHealth`).
794
795
 
795
796
  ### `plan de données admin` — la sonde servie à Studio
796
797