@nodefony/realtime 10.0.0-beta.4 → 10.0.0-beta.6

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 (78) hide show
  1. package/README.md +79 -10
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +79 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +125 -19
  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 +143 -31
  9. package/dist/nodefony/src/backplane/controlOrder.js +57 -0
  10. package/dist/nodefony/src/backplane/envelope.js +113 -12
  11. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  12. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  13. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  14. package/dist/nodefony/src/server/RealtimeConnection.js +110 -0
  15. package/dist/nodefony/src/server/RealtimeController.js +344 -85
  16. package/dist/nodefony/src/server/RealtimeHub.js +604 -61
  17. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  18. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  19. package/dist/nodefony/src/server/channelPolicyRegistry.js +73 -0
  20. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  21. package/dist/nodefony/src/server/contractRoutes.js +85 -0
  22. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  23. package/dist/nodefony/src/server/frameAdmission.js +168 -0
  24. package/dist/nodefony/src/server/subscribedAck.js +10 -1
  25. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  26. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  27. package/dist/nodefony/src/service/RealtimeService.js +42 -1
  28. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  29. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  30. package/dist/nodefony/testing/backplaneConformance.js +271 -0
  31. package/dist/nodefony/testing/index.js +21 -4
  32. package/dist/types/index.d.ts +34 -7
  33. package/dist/types/nodefony/config/config.d.ts +9 -0
  34. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +86 -6
  35. package/dist/types/nodefony/interfaces/IBackplane.d.ts +34 -50
  36. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  37. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  38. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  39. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  40. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  41. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  42. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  43. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  44. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +79 -16
  45. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +52 -0
  46. package/dist/types/nodefony/src/backplane/envelope.d.ts +34 -0
  47. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  48. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  49. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +17 -0
  50. package/dist/types/nodefony/src/server/RealtimeController.d.ts +93 -13
  51. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +321 -24
  52. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  53. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  54. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +34 -0
  55. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  56. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  57. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  58. package/dist/types/nodefony/src/server/frameAdmission.d.ts +70 -0
  59. package/dist/types/nodefony/src/server/subscribedAck.d.ts +7 -0
  60. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  61. package/dist/types/nodefony/src/service/RealtimeService.d.ts +10 -2
  62. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  63. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  64. package/dist/types/nodefony/testing/backplaneConformance.d.ts +66 -0
  65. package/dist/types/nodefony/testing/index.d.ts +17 -4
  66. package/docs/actions.md +442 -19
  67. package/docs/architecture.md +83 -8
  68. package/docs/cadence-et-contre-pression.md +87 -11
  69. package/docs/canaux-a-membres.md +267 -0
  70. package/docs/cas-d-usage.md +1702 -0
  71. package/docs/configuration.md +137 -17
  72. package/docs/cookbook-chat.md +333 -85
  73. package/docs/index.md +11 -1
  74. package/docs/observabilite.md +71 -14
  75. package/docs/protocole.md +133 -24
  76. package/docs/securite.md +294 -33
  77. package/docs/vocabulaire.md +13 -12
  78. package/package.json +14 -11
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.
@@ -499,9 +505,12 @@ cumulatifs (ET) ; un axe absent n'impose rien :
499
505
  | `scopes` | un des scopes suffit — axe API (JWT, clé API), une session BFF n'en porte pas | comparaison directe |
500
506
 
501
507
  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()`
508
+ objet sans contrainte — le canal reste libre, le registre reste vide. Les déclarations sont inscrites
509
+ au hub **au démarrage** (`channelPolicyRegistry.ts#registerControllerPolicies`, à `onKernelReady`) :
510
+ un exemplaire qui n'a encore vu aucune poignée de main a déjà ses politiques — celui qui n'émet
511
+ que des webhooks aussi. Un contrôleur échappé au démarrage s'inscrit à sa première poignée de main
512
+ (`RealtimeHub.registerChannelPolicy()`, `RealtimeHub.ts#RealtimeHub.registerChannelPolicy`, idempotent) ;
513
+ le décideur les relit par `RealtimeService.resolveChannelPolicy()`
505
514
  (`RealtimeService.ts#RealtimeService.resolveChannelPolicy`).
506
515
 
507
516
  ### ⚠️ Une policy est attachée à un nom EXACT — les canaux dérivés n'héritent pas
@@ -539,6 +548,213 @@ Deux façons de fermer, selon l'intention :
539
548
  > Le nom exact l'emporte sur un motif ; entre deux motifs, le plus spécifique gagne. Un `*` dans un
540
549
  > nom souscrit est refusé : il désigne un motif de politique, jamais un canal.
541
550
 
551
+ ### 📬 Le canal personnel `user:<id>` — fourni, rien à déclarer
552
+
553
+ Livrer un message à **une** personne — sur tous ses onglets, sur tous les serveurs, et à personne
554
+ d'autre — est une brique du framework. L'application n'écrit ni canal, ni règle, ni déclaration de
555
+ diffusion :
556
+
557
+ ```typescript
558
+ // côté serveur — un service, un job, un contrôleur
559
+ getRealtimeHub<ChatContract>().publishToUser(bob.id, message);
560
+
561
+ // côté navigateur — cet onglet s'abonne quand son écran écoute
562
+ useEffect(() => chat.onUser((m) => addPrivate(m)), []);
563
+ ```
564
+
565
+ La forme du message se déclare dans le contrat, par la clé gabarit du canal personnel :
566
+
567
+ ```typescript
568
+ interface ChatContract extends IRealtimeContract {
569
+ serverToClient: {
570
+ [channel: `user:${string}`]: { from: string; text: string };
571
+ };
572
+ }
573
+ ```
574
+
575
+ Ce que le framework garantit, écrit une fois :
576
+
577
+ | Garantie | Comment |
578
+ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
579
+ | **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`. |
580
+ | **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`. |
581
+ | **Tous les serveurs** | Dès qu'un bus est branché, le préfixe `user:` est diffusé par le framework (`RealtimeHub.ts#RealtimeHub.setBackplane`). |
582
+ | **Sans sécurité chargée** | La règle s'applique quand même : `allow` ne fait que retirer des abonnés. |
583
+ | **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. |
584
+
585
+ L'`id` est l'identifiant **stable** du compte (`RealtimeIdentity.id`), jamais le login : un login
586
+ libéré se réattribue, et le nouveau compte recevrait les messages de l'ancien. Un appelant sans
587
+ compte local (`<émetteur>#<sujet>`, connexion d'un fournisseur d'identité) a lui aussi son canal ;
588
+ un **anonyme** n'en a pas — il n'a pas d'identifiant qui survive à une reconnexion. Le support
589
+ anonyme passe par un salon privé de l'application (`support:<conversation>` + `allow`, ci-dessous).
590
+
591
+ **Le prix assumé** : un compte supprimé garde son abonnement jusqu'à son éjection explicite. Et une
592
+ publication sans abonné est perdue sans erreur — c'est une diffusion, pas une boîte aux lettres :
593
+ l'historique d'une conversation vit dans la base de l'application.
594
+
595
+ **Surcharger la règle** : une politique que l'application écrit sur `user:…` (nom exact ou motif)
596
+ l'emporte sur la règle intégrée — c'est ainsi qu'on ouvre `user:public` à tous. Une telle
597
+ déclaration remplace la garantie « toi seul » : à n'écrire qu'en connaissance de cause.
598
+
599
+ ### 🪪 `allow` — admettre un abonné par SON identité
600
+
601
+ Les trois axes décrivent une **classe** d'identité : « un administrateur », « un service qui porte
602
+ `metrics:read` ». Ils ne savent pas dire « **ce** compte, et lui seul, sur **ce** canal » — la boîte
603
+ aux lettres `user:42`, la salle d'une partie, le tableau de bord d'une seule usine. C'est le rôle
604
+ d'`allow` : une règle qui reçoit le canal demandé et l'identité de l'abonné, et répond oui ou non.
605
+ Elle se joue au `subscribe`, une fois, dans le contrôleur
606
+ (`RealtimeController.ts#RealtimeController.startChannel`) ; la règle elle-même est un contrat du cœur
607
+ (`channelAdmission.ts#IChannelAdmission`), isomorphe : rien n'y dépend de la socket.
608
+
609
+ > L'exemple ci-dessous écrit à la main la règle de `user:*` pour montrer la mécanique. Pour le canal
610
+ > personnel lui-même, **ne rien écrire** : le framework le fournit (section précédente), et une
611
+ > politique écrite ainsi remplacerait la sienne.
612
+
613
+ **La forme courte — une flèche, pour une règle qui ne lit rien :**
614
+
615
+ ```typescript
616
+ // nodefony/controllers/InboxRealtimeController.ts
617
+ import { controller, route } from "@nodefony/framework";
618
+ import { Context } from "@nodefony/http";
619
+ import { RealtimeChannel, RealtimeController } from "@nodefony/realtime";
620
+ import type { RealtimePublish } from "@nodefony/realtime";
621
+
622
+ @controller("/rt")
623
+ class InboxRealtimeController extends RealtimeController {
624
+ constructor(context: Context) {
625
+ super("InboxRealtimeController", context);
626
+ }
627
+
628
+ @route("inbox-ws", {
629
+ path: "/inbox",
630
+ requirements: { methods: ["WEBSOCKET"] },
631
+ })
632
+ async realtime(message: string | Buffer | null): Promise<void> {
633
+ this.handleRealtime(message);
634
+ }
635
+
636
+ // La règle du MOTIF : `user:42` n'est ouvert qu'au compte 42.
637
+ @RealtimeChannel("user:*", {
638
+ allow: (channel, who) => channel === `user:${who.id}`,
639
+ })
640
+ inbox(): () => void {
641
+ return () => {};
642
+ }
643
+
644
+ // Un producteur décoré ne sert que son nom exact : les canaux `user:<id>`
645
+ // sont servis par la fabrique dynamique.
646
+ override createRealtimeChannel(
647
+ channel: string,
648
+ publish: RealtimePublish,
649
+ ): (() => void) | null {
650
+ if (!channel.startsWith("user:")) return null;
651
+ publish(channel, { unread: 0 });
652
+ return () => {};
653
+ }
654
+ }
655
+
656
+ export default InboxRealtimeController;
657
+ ```
658
+
659
+ Le compte 42 reçoit `realtime:subscribed` pour `user:42` ; le compte 43 et l'anonyme reçoivent
660
+ `realtime:denied`, motif `forbidden` — prouvé de bout en bout, vrai client et vrai verrou, par
661
+ `realtimeChannelAuth.e2e.test.ts`.
662
+
663
+ **La forme longue — une classe, dès que la règle lit les données de l'application.** Ses services lui
664
+ arrivent par le constructeur, comme à tout service ; elle se déclare dans `@services([…])` de son
665
+ module, et le décorateur la nomme :
666
+
667
+ ```typescript
668
+ // nodefony/services/RoomAdmission.ts
669
+ import { Inject, Module, Service, injectable } from "nodefony";
670
+ import type { IChannelAdmission, ISubscriberIdentity } from "nodefony";
671
+ import type RoomMembersService from "./RoomMembersService";
672
+
673
+ @injectable()
674
+ class RoomAdmission extends Service implements IChannelAdmission {
675
+ constructor(
676
+ module: Module,
677
+ @Inject("RoomMembersService") private readonly members: RoomMembersService,
678
+ ) {
679
+ super(
680
+ "roomAdmission",
681
+ module.container ?? undefined,
682
+ module.notificationsCenter,
683
+ );
684
+ }
685
+
686
+ // `room:<partie>` n'est ouvert qu'aux joueurs de la partie.
687
+ async allow(channel: string, who: ISubscriberIdentity): Promise<boolean> {
688
+ if (who.id === null) return false;
689
+ return this.members.isPlayer(channel.slice("room:".length), who.id);
690
+ }
691
+ }
692
+
693
+ export default RoomAdmission;
694
+
695
+ // Dans le contrôleur :
696
+ // @RealtimeChannel("room:*", { allow: RoomAdmission })
697
+ ```
698
+
699
+ Le démarrage résout la classe une fois et journalise « canal room:\*, admission RoomAdmission,
700
+ résolue » (`channelPolicyRegistry.ts#createAdmissionResolver`). Une dépendance absente, une portée
701
+ autre que singleton, une classe sans méthode `allow` font **échouer le démarrage** en nommant la
702
+ classe et le canal — jamais un refus silencieux de 100 % des abonnés en production. Une seule
703
+ instance sert tous les abonnés en même temps : elle ne garde **aucun état par abonné**.
704
+
705
+ Un **module npm** livre sa politique de la même façon : la classe vit dans le module, déclarée dans
706
+ ses `@services([…])`, son contrôleur la nomme. L'application qui l'installe n'écrit rien.
707
+
708
+ **Le contrat, en six règles :**
709
+
710
+ | Règle | Pourquoi |
711
+ | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
712
+ | **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 |
713
+ | **`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 |
714
+ | **Seul `true` admet** | `1`, `"yes"`, un objet refusent — une réponse ambiguë ne vaut pas un oui |
715
+ | **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` |
716
+ | **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à |
717
+ | **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 |
718
+
719
+ Le `realtime:welcome` annonce un canal à `allow` sur le seul verdict du verrou : la règle n'est
720
+ jamais jouée à la poignée de main — elle coûterait un appel par canal déclaré et par connexion, pour
721
+ annoncer un motif dont aucun nom concret n'est connu.
722
+
723
+ **Quand la règle est lente ou en panne** — la base qu'elle lit ne répond plus :
724
+
725
+ <!-- prettier-ignore -->
726
+ | Situation | L'abonné reçoit | Le serveur |
727
+ | --- | --- | --- |
728
+ | 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 |
729
+ | aucune réponse dans `allowTimeoutMs` (5 s) | `unavailable` | `allowTimedOutTotal` ; un verdict arrivé après coup est ignoré, jamais une admission |
730
+ | 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 |
731
+ | plus de `maxPendingAllowsTotal` (512) appels en vol sur l'exemplaire | `unavailable`, sans appel | `allowSaturatedTotal` |
732
+ | 50 délais dépassés en 5 s | `unavailable` et `retryAfterMs`, sans appel, pendant 5 s (coupe-circuit) | `allowSaturatedTotal` |
733
+
734
+ Un délai dépassé n'est **pas** un refus de droit : le client réessaie `unavailable` au plus trois
735
+ fois, avec un recul exponentiel à gigue complète, après `retryAfterMs` quand il est donné.
736
+ `forbidden` et `limit` ne se réessaient jamais. Le tout reste borné : 300 passerelles qui rejouent
737
+ 10 000 abonnements contre une base **bloquée** déclenchent environ 1 600 appels de la règle, jamais
738
+ plus de 512 à la fois, et la tempête s'éteint en moins d'une minute
739
+ (`admissionStorm.e2e.test.ts`). Réglages : [`configuration.md`](./configuration.md) ; compteurs :
740
+ [`observabilite.md`](./observabilite.md).
741
+
742
+ **Le prix assumé — une réponse retenue.** Une admission ne se périme pas seule : un membre retiré
743
+ d'une salle reste abonné tant que sa connexion vit, la règle n'est pas rejouée. Pour couper tout de
744
+ suite, on retire son appartenance (là où la règle la lit), PUIS on l'éjecte :
745
+ `getRealtimeHub().evictUser(canal, compte)` coupe tous ses onglets, sur tous les serveurs
746
+ (`realtime:closed { reason: "evicted" }`). L'éjection seule ne suffit pas — son navigateur rejoue
747
+ `subscribe` au rechargement, et c'est la règle qui tranche alors. Détail :
748
+ [canaux à membres](./canaux-a-membres.md). Fermer la connexion entière, c'est la révocation
749
+ ([verrou 4](#-verrou-4--la-révocation--une-socket-survit-à-sa-session)).
750
+
751
+ **`room:*` et `pattern: "room:"` — deux grammaires, une même famille.** Le décorateur écrit un motif
752
+ (`"room:*"`) ; la configuration de `@nodefony/security` écrit un préfixe
753
+ (`realtimeChannels: [{ pattern: "room:", roles }]`). Ils couvrent les mêmes canaux. La règle de
754
+ configuration est jugée par le verrou AVANT la politique du décorateur et la remplace de ce côté —
755
+ c'est ainsi qu'une exploitation durcit un namespace sans toucher au code ; elle ne porte jamais
756
+ d'`allow`, qui reste déclaré sur le décorateur et s'applique toujours à l'abonnement.
757
+
542
758
  ### ⚠️ La condition d'activation — le point critique
543
759
 
544
760
  Le verrou n'existe que si quelqu'un le pose. Deux conditions doivent être vraies **en même temps** :
@@ -623,44 +839,89 @@ ou changée → `-32000` avec `status: 401`, et le client bascule sur un `fetch`
623
839
  courant. Une erreur de re-validation vaut refus (fail-closed).
624
840
 
625
841
  **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.
842
+ `isValid` (`RealtimeController.ts#RealtimeController.onHandshake`). Une connexion anonyme n'y entre jamais,
843
+ coût nul. Toute connexion authentifiée par le pare-feu y entre : sa session est relue, ou, pour un
844
+ jeton porteur (`jwt`, `external-jwt`, `apikey`), son expiration (`exp`), la révocation de son `jti` et
845
+ la révocation en masse de son identité (`invalidBefore`).
627
846
  `RealtimeHub.registerRevocable()` (`RealtimeHub.ts#RealtimeHub.registerRevocable`) démarre un `setInterval` `unref` au premier
628
847
  inscrit et l'arrête dès que le registre se vide : zéro timer au repos. Période :
629
848
  `REVOCATION_REVALIDATE_MS` (`RealtimeHub.ts#channelFamily`), 30 s, alignée sur le heartbeat WS.
630
849
 
631
850
  Ce que `realtimeRevocation.attack.test.ts` prouve exactement :
632
851
 
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 |
852
+ | Cas | Comportement prouvé |
853
+ | ------------------------------------------ | --------------------------------------------------------- |
854
+ | `isValid()` renvoie `false` | `close(4001, "session revoked")` |
855
+ | `status()` rend `expired` (jeton porteur) | `close(4006, "token expired")` |
856
+ | `status()` rend `revoked` | `close(4001, "session revoked")`, même si `exp` est passé |
857
+ | `isValid()` renvoie `true` | socket **intacte** (contrôle positif, pas de faux refus) |
858
+ | `isValid()` **throw** (store indisponible) | socket fermée — fail-closed, parité avec `api.request` |
859
+ | déjà révoqué | retiré du registre avant le close → aucun re-close |
860
+ | déconnexion propre | `unregisterRevocable` → plus jamais re-validé |
861
+ | registre vide | tick no-op, aucun crash |
641
862
 
642
863
  La source de vérité côté security est `buildSessionRevalidator()`
643
864
  (`FirewallRealtimeAuthenticator.ts#buildSessionRevalidator`) : il relit la session BFF **par son id capturé au handshake**
644
865
  et vérifie qu'elle est toujours vivante **et toujours celle du même utilisateur** — ce second point
645
866
  attrape le changement de compte sur un navigateur partagé.
646
867
 
868
+ **Le code de fermeture dit la cause** (`realtime/closeCodes.ts`, ADR-0021 §S11). Un jeton porteur
869
+ sait pourquoi il est mort : `IRealtimeToken.status()` rend `expired` ou `revoked`, et la
870
+ révocation l'emporte — la liste de refus est interrogée avant de conclure à l'expiration.
871
+
872
+ | Cause sur une socket OUVERTE | Code | Client 10.0 |
873
+ | ------------------------------------------------------------- | -------------------------------------- | -------------------- |
874
+ | jeton porteur arrivé à `exp` | `4006` (`TOKEN_EXPIRED_CLOSE_CODE`) | s'arrête (définitif) |
875
+ | jeton porteur révoqué (`jti`, `invalidBefore`), expiré ou non | `4001` (`IDENTITY_DEAD_CLOSE_CODE`) | s'arrête |
876
+ | session expirée ou détruite (la cause n'est pas connaissable) | `4001` | s'arrête |
877
+ | droits changés, identité vivante | `4005` (`IDENTITY_REFRESH_CLOSE_CODE`) | revient aussitôt |
878
+ | refus à la poignée de main, quel qu'il soit | 401 uniforme → `1008` | s'arrête |
879
+ | authentification sans réponse dans `authenticateTimeoutMs` | `1013` (`TRY_AGAIN_LATER_CLOSE_CODE`) | revient, avec recul |
880
+ | client qui ne vide plus sa file (contre-pression) | `1013` | revient, avec recul |
881
+
882
+ > [!IMPORTANT]
883
+ > **Jamais de `4006` à la poignée de main.** Un refus distinct y dirait à qui tient un jeton volé
884
+ > qu'il a été émis par l'application : un jeton expiré présenté à l'entrée reçoit le même 401 que
885
+ > n'importe quel autre refus.
886
+
887
+ **Recette — un jeton d'une heure sur une socket de sept jours.** La socket vit plus longtemps que son
888
+ jeton : au bout d'une heure, elle est fermée en `4006`, et le client 10.0 ne revient pas seul. C'est
889
+ à l'application de **recréer sa socket avec un jeton neuf avant `exp`** — par exemple obtenir un
890
+ nouveau jeton cinq minutes avant l'échéance, ouvrir la nouvelle socket, puis fermer l'ancienne.
891
+ Un client qui saura obtenir un jeton frais de lui-même viendra dans une version 10.x, sans que le
892
+ serveur change.
893
+
894
+ **Un changement de rôles coupe sur-le-champ, sans attendre le tick.** Quand une mise à jour du compte
895
+ touche ses rôles, le hub ferme toutes les sockets de cette identité avec le code **4005**
896
+ (`realtime/closeCodes.ts#IDENTITY_REFRESH_CLOSE_CODE`, `RealtimeHub.ts#RealtimeHub.#revocation`). À la
897
+ différence de 4001 (identité morte, définitif), 4005 est **reconnectable** : le client se reconnecte
898
+ seul et reçoit ses nouveaux rôles dans l'accueil. Les flux SSE du même compte suivent la même règle.
899
+ Aucune lecture de base par tick ni par frame : le coût n'existe qu'à la mise à jour qui touche
900
+ `roles`.
901
+
647
902
  > [!CAUTION]
648
903
  > **Trois limites à connaître.**
649
904
  >
650
905
  > 1. **Fenêtre de 30 s.** Entre le logout et le tick suivant, les flux continuent. Pour une coupure
651
- > immédiate, fermer la socket explicitement côté serveur.
652
- > 2. **Best-effort si la session est illisible au handshake.** Si l'id ou le store ne sont pas
653
- > accessibles, `buildSessionRevalidator()` renvoie `null` ; `UserRealtimeToken.isValid()`
654
- > (`UserRealtimeToken.ts#UserRealtimeToken.isValid`) répond alors toujours `true`. La connexion **est** inscrite au
655
- > registre (le token expose bien `isValid`) mais ne sera **jamais** révoquée. Choix assumé pour
656
- > éviter les faux refus — à connaître, car cela ne se voit pas.
657
- > 3. **Les identités sans `isValid` ne sont pas révocables du tout** : anonyme, JWT, clé API. Pour
658
- > un JWT, la révocation reste portée par son `exp` et par la reconnexion.
906
+ > immédiate, fermer la socket explicitement côté serveur. (Un changement de **rôles**, lui, coupe
907
+ > immédiatement — ci-dessus.)
908
+ > 2. **Une identité qu'on ne pourra pas re-valider ferme la socket.** Si la session est illisible au
909
+ > handshake (ni id ni store exploitable), `buildSessionRevalidator()`
910
+ > (`FirewallRealtimeAuthenticator.ts#buildSessionRevalidator`) rend un revalidateur qui répond
911
+ > toujours `false` : la socket est fermée en 4001 au premier tick, et un `WARNING` le dit. Même
912
+ > règle pour un jeton porteur qui n'a ni `exp`, ni de quoi interroger le store de révocation
913
+ > (`jti`, `iat`). Un refus visible plutôt qu'une connexion que rien ne pourrait plus couper.
914
+ > 3. **Un jeton porteur n'est jugé sur sa signature qu'à l'ouverture.** Le tick relit `exp`, `jti` et
915
+ > `invalidBefore`, jamais la clé ni l'émetteur : une clé retirée du jeu publié par l'émetteur laisse
916
+ > la socket vivre jusqu'à `exp`, et un émetteur retiré de `resourceServer.issuers` ne compte qu'à
917
+ > la connexion suivante — la liste ne change qu'au redémarrage, qui ferme les sockets. Une
918
+ > connexion anonyme, elle, n'est pas révocable du tout.
659
919
 
660
920
  ## 🚧 Plafonds anti-abus — borner une connexion
661
921
 
662
922
  Une connexion ouverte est une ressource. Trois bornes existent ; il faut aussi savoir ce qui **n'est
663
- pas** borné.
923
+ pas** borné. Les appels en vol de la règle `allow` ont leurs propres plafonds, par connexion et par
924
+ exemplaire : voir [`allow` — quand la règle est lente](#-allow--admettre-un-abonné-par-son-identité).
664
925
 
665
926
  ### Canaux par connexion
666
927
 
@@ -774,16 +1035,16 @@ n'est pas appliquée.
774
1035
 
775
1036
  ## 📜 Normes appliquées
776
1037
 
777
- | Norme | Application |
778
- | ------------------------------------- | -------------------------------------------------------------------------------------------- |
779
- | **RFC 6455 §10.2** (Origin) | Contrôle d'origine à l'upgrade, aux deux étages |
780
- | **RFC 6455 §7.4.2** (codes 4000-4999) | `4001` unauthorized / session revoked, `4003` origin not allowed |
781
- | **RFC 6455 §7.4.1 / registre IANA** | `1008` Policy Violation (origine transport), `1009` Message Too Big, `1013` Try Again Later |
782
- | **RFC 6455 §4.1** | `Sec-WebSocket-Protocol` normalisé en liste dans le DTO de handshake |
783
- | **RFC 9110 §7.2** | Comparaison du `Host` par le matcher d'authenticator |
784
- | **JSON-RPC 2.0 §5.1** | `-32001` (plage `Server error`) pour une requête refusée, `-32602` pour des params invalides |
785
- | **OWASP WSTG-CLNT-10** | Anti-CSWSH : validation d'`Origin` obligatoire, les navigateurs n'appliquent pas CORS aux WS |
786
- | **Zero Trust / OWASP A01** | Zone protégée + anonyme = refus ; token toujours présent, jamais `null` |
1038
+ | Norme | Application |
1039
+ | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
1040
+ | **RFC 6455 §10.2** (Origin) | Contrôle d'origine à l'upgrade, aux deux étages |
1041
+ | **RFC 6455 §7.4.2** (codes 4000-4999) | `4001` unauthorized / session revoked, `4003` origin not allowed, `4006` token expired |
1042
+ | **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) |
1043
+ | **RFC 6455 §4.1** | `Sec-WebSocket-Protocol` normalisé en liste dans le DTO de handshake |
1044
+ | **RFC 9110 §7.2** | Comparaison du `Host` par le matcher d'authenticator |
1045
+ | **JSON-RPC 2.0 §5.1** | `-32001` (plage `Server error`) pour une requête refusée, `-32602` pour des params invalides |
1046
+ | **OWASP WSTG-CLNT-10** | Anti-CSWSH : validation d'`Origin` obligatoire, les navigateurs n'appliquent pas CORS aux WS |
1047
+ | **Zero Trust / OWASP A01** | Zone protégée + anonyme = refus ; token toujours présent, jamais `null` |
787
1048
 
788
1049
  ## 📡 Observabilité — Studio
789
1050
 
@@ -812,7 +1073,7 @@ n'est pas appliquée.
812
1073
  | 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) |
813
1074
  | `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` |
814
1075
  | 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 |
815
- | 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` |
1076
+ | 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` |
816
1077
  | 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 |
817
1078
 
818
1079
  ## 🧪 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/realtime",
3
- "version": "10.0.0-beta.4",
3
+ "version": "10.0.0-beta.6",
4
4
  "nodefony": {
5
5
  "configSchema": "./dist/nodefony/config/configSchema.js"
6
6
  },
@@ -44,20 +44,23 @@
44
44
  "esm"
45
45
  ],
46
46
  "peerDependencies": {
47
- "@nodefony/framework": "^10.0.0-beta.4",
48
- "@nodefony/http": "^10.0.0-beta.4",
49
- "nodefony": "^10.0.0-beta.4",
47
+ "@nodefony/framework": "^10.0.0-beta.6",
48
+ "@nodefony/http": "^10.0.0-beta.6",
49
+ "nodefony": "^10.0.0-beta.6",
50
50
  "reflect-metadata": "0.2.2",
51
51
  "zod": "^4.6.7"
52
52
  },
53
53
  "devDependencies": {
54
- "@nodefony/framework": "^10.0.0-beta.4",
55
- "@nodefony/http": "^10.0.0-beta.4",
56
- "@types/node": "26.6.5",
57
- "@vitest/coverage-v8": "5.0.5",
58
- "nodefony": "^10.0.0-beta.4",
54
+ "@nodefony/drizzle": "^10.0.0-beta.5",
55
+ "@nodefony/framework": "^10.0.0-beta.6",
56
+ "@nodefony/http": "^10.0.0-beta.6",
57
+ "@nodefony/orm-core": "^10.0.0-beta.5",
58
+ "@nodefony/security": "^10.0.0-beta.5",
59
+ "@types/node": "26.6.6",
60
+ "@vitest/coverage-v8": "5.1.0",
61
+ "nodefony": "^10.0.0-beta.6",
59
62
  "reflect-metadata": "0.2.2",
60
- "vitest": "5.0.5"
63
+ "vitest": "5.1.0"
61
64
  },
62
65
  "private": false,
63
66
  "engines": {
@@ -65,7 +68,7 @@
65
68
  },
66
69
  "license": "Apache-2.0",
67
70
  "dependencies": {
68
- "tslib": "2.8.1"
71
+ "tslib": "2.9.1"
69
72
  },
70
73
  "files": [
71
74
  "dist",