@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.
- package/README.md +55 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +91 -12
- package/dist/nodefony/config/config.js +7 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
- package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
- package/dist/nodefony/src/backplane/RedisBackplane.js +146 -41
- package/dist/nodefony/src/backplane/controlOrder.js +86 -0
- package/dist/nodefony/src/backplane/envelope.js +505 -27
- package/dist/nodefony/src/backplane/ingress.js +93 -0
- package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
- package/dist/nodefony/src/backplane/publishQueue.js +97 -3
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
- package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
- package/dist/nodefony/src/server/RealtimeConnection.js +135 -0
- package/dist/nodefony/src/server/RealtimeController.js +409 -107
- package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
- package/dist/nodefony/src/server/admissionBudget.js +176 -0
- package/dist/nodefony/src/server/channelPolicyRegistry.js +95 -0
- package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
- package/dist/nodefony/src/server/channelPresence.js +488 -0
- package/dist/nodefony/src/server/connectionAdmission.js +242 -0
- package/dist/nodefony/src/server/contractRoutes.js +75 -0
- package/dist/nodefony/src/server/deniedDetail.js +10 -3
- package/dist/nodefony/src/server/frameAdmission.js +160 -0
- package/dist/nodefony/src/server/presenceBus.js +486 -0
- package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
- package/dist/nodefony/src/server/remotePresence.js +541 -0
- package/dist/nodefony/src/server/subscribedAck.js +13 -1
- package/dist/nodefony/src/server/syslogUplink.js +3 -4
- package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
- package/dist/nodefony/src/service/RealtimeService.js +51 -1
- package/dist/nodefony/src/service/publicOrigin.js +40 -0
- package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
- package/dist/nodefony/testing/backplaneConformance.js +347 -0
- package/dist/nodefony/testing/index.js +43 -12
- package/dist/nodefony/testing/presenceClient.js +54 -0
- package/dist/types/index.d.ts +39 -7
- package/dist/types/nodefony/config/config.d.ts +9 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
- package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
- package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
- package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
- package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
- package/dist/types/nodefony/interfaces/index.d.ts +2 -1
- package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +89 -18
- package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
- package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
- package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
- package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
- package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
- package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
- package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +20 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
- package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
- package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +41 -0
- package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
- package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
- package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
- package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
- package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
- package/dist/types/nodefony/src/server/frameAdmission.d.ts +66 -0
- package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
- package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
- package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +10 -0
- package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -2
- package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
- package/dist/types/nodefony/testing/backplaneConformance.d.ts +78 -0
- package/dist/types/nodefony/testing/index.d.ts +13 -2
- package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
- package/docs/actions.md +420 -18
- package/docs/architecture.md +101 -11
- package/docs/cadence-et-contre-pression.md +87 -11
- package/docs/canaux-a-membres.md +277 -0
- package/docs/cas-d-usage.md +1703 -0
- package/docs/configuration.md +175 -19
- package/docs/cookbook-chat.md +701 -224
- package/docs/index.md +11 -1
- package/docs/observabilite.md +97 -21
- package/docs/protocole.md +142 -33
- package/docs/securite.md +347 -41
- package/docs/vocabulaire.md +13 -12
- 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
|
|
447
|
-
| notification `method` = canal inbound |
|
|
448
|
-
| action `@RealtimeAction` |
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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 : `
|
|
502
|
-
objet sans contrainte — le canal reste libre, le registre reste vide. Les déclarations sont
|
|
503
|
-
au hub **au
|
|
504
|
-
|
|
505
|
-
|
|
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.
|
|
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`)
|
|
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
|
-
| `
|
|
637
|
-
| `
|
|
638
|
-
|
|
|
639
|
-
|
|
|
640
|
-
|
|
|
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. **
|
|
662
|
-
>
|
|
663
|
-
> (`
|
|
664
|
-
>
|
|
665
|
-
>
|
|
666
|
-
>
|
|
667
|
-
>
|
|
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 `
|
|
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
|
package/docs/vocabulaire.md
CHANGED
|
@@ -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,
|
|
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#
|
|
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` (`
|
|
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` (`
|
|
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` (`
|
|
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#
|
|
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#
|
|
724
|
-
(`JsonRpcPeer.ts#
|
|
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` (`
|
|
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
|
-
(`
|
|
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
|
-
(`
|
|
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
|
-
(`
|
|
794
|
+
(`nodefony/src/realtime/platformHealth.ts#IRealtimeClusterHealth`).
|
|
794
795
|
|
|
795
796
|
### `plan de données admin` — la sonde servie à Studio
|
|
796
797
|
|