---
title: "Sécurité du temps réel — qui entre, qui écoute, jusqu'à quand"
source: src/packages/@nodefony/realtime/docs/securite.md
url: https://nodefony.github.io/nodefony-core/docs/modules/realtime/securite/
---


> Une WebSocket n'est pas une requête, c'est une **porte qui reste ouverte**. Nodefony la garde
> avec quatre verrous : l'origine à l'entrée, l'identité au handshake, l'autorisation par frame,
> la révocation dans la durée. Cette page dit ce que chaque verrou bloque **et où il s'arrête** —
> une protection partielle y est annoncée comme partielle.

📍 [Documentation](../../../) › [Realtime](../) › **Sécurité**

## 🧠 Le modèle mental — un transporteur, un décideur

La règle qui explique tout le reste : **`@nodefony/realtime` ne décide jamais d'une autorisation.**
Il transporte des déclarations (« ce canal exige `ROLE_ADMIN` ») jusqu'à un décideur, et
`@nodefony/security` est ce décideur. Le module realtime ignore la hiérarchie de rôles, ignore la
session, ignore l'utilisateur réel.

Conséquence directe, à garder en tête pendant toute la lecture : **sans `@nodefony/security` câblé,
aucune politique de canal n'est appliquée.** Le transport fonctionne, les déclarations sont
collectées, personne ne les fait respecter.

```mermaid
flowchart TD
  C["Client — GET /realtime<br/>Upgrade: websocket"] --> H1["1. Transport HTTP<br/>same-origin anti-CSWSH"]
  H1 -->|refus| X1["403 au handshake<br/>aucune socket"]
  H1 --> H2["2. Firewall HTTP<br/>zone protégée ? 401 si anonyme"]
  H2 --> H3["3. Origin realtime<br/>allowlist stricte (opt-in)"]
  H3 -->|refus| X2["close 4003"]
  H3 --> H4["4. Authenticator<br/>handshake → IRealtimeToken"]
  H4 -->|throw| X3["close 4001"]
  H4 --> W["welcome — la porte est ouverte"]
  W --> F["chaque frame entrante"]
  F --> V{"verrou de frame<br/>posé par security ?"}
  V -->|non| PASS["tout passe"]
  V -->|oui| D{"policy du canal<br/>satisfaite ?"}
  D -->|non| DEN["drop + realtime:denied<br/>+ audit"]
  D -->|oui| ACT["handler métier"]
  W -.->|toutes les 30 s| REV["re-validation identité<br/>session morte → close 4001"]
```

Les étapes 1 et 2 appartiennent à `@nodefony/http` et `@nodefony/security` : la requête d'upgrade
**est** une requête HTTP, elle traverse le pipeline normal avant d'atteindre le realtime. Les
étapes 3, 4 et le verrou de frame sont portés par ce module.

## 📖 Lexique

| Terme | Sens |
| --- | --- |
| Handshake | La requête HTTP `Upgrade: websocket` qui ouvre la connexion. **Tout le contrôle d'identité s'y joue.** |
| Frame | Un message JSON-RPC 2.0 circulant une fois la porte ouverte (`subscribe`, `api.request`, notification). |
| Canal (_channel_) | Un flux nommé auquel on s'abonne (`orders:feed`, `nodefony:syslog`) ; le hub le diffuse à tous ses abonnés. |
| CSWSH | _Cross-Site WebSocket Hijacking_ : un site tiers ouvre une WS vers ton app, **avec le cookie de la victime**. |
| Origin | En-tête RFC 6455 §10.2 disant d'où vient la page qui ouvre la socket. Seule preuve d'origine disponible. |
| Token realtime | `IRealtimeToken` — carte d'identité de la connexion, figée au handshake, lue en O(1) à chaque frame. |
| Verrou de frame | `FrameAuthorizer` — fonction **sync** qui accepte ou refuse une frame. Posée par security au boot. |
| Policy de canal | `IChannelPolicy` — exigences déclarées sur un canal (`authenticated`, `roles`, `scopes`). |
| Namespace réservé | Le préfixe `nodefony:`, qui marque les canaux de la plateforme (`nodefony:syslog`, `nodefony:audit`…) et porte un **plancher** d'autorisation que la config ne peut pas descendre. |
| Zero Trust | Aucune identité n'est supposée : un visiteur porte toujours un token, anonyme par défaut. |
| Fail-closed / loud | En cas de doute on **refuse** ; toute dégradation de sécurité est **annoncée** (WARNING), jamais silencieuse. |
| Backplane | Le bus qui propage les publications entre pods (Redis, IPC cluster). |
| BFF | _Backend-For-Frontend_ : la session serveur (cookie opaque) qui porte l'identité web. |

## 🔐 Qu'est-ce que ça défend, concrètement ?

Une WebSocket authentifiée est une cible de choix : elle porte une identité, vit longtemps et
diffuse en continu.

| Attaque                                                                                                | Ce qui la bloque                                                           | Où                                                           |
| ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | ------------------------------------------------------------ |
| **CSWSH** — `evil.com` fait `new WebSocket("wss://app.exemple.com/rt")`, le navigateur joint le cookie | Contrôle d'`Origin` same-origin par défaut, puis allowlist stricte         | `@nodefony/http` transport, puis `csrf.checkOrigin` realtime |
| **Écoute des flux internes** — un visiteur s'abonne à `nodefony:syslog` et lit les logs du pod         | Plancher des namespaces réservés (authentifié + `ROLE_NODEFONY_ADMIN`)     | Verrou de frame `@nodefony/security`                         |
| **Élévation par canal métier** — un `ROLE_USER` s'abonne au canal admin d'un autre module              | Policy déclarée sur le canal, évaluée avec la hiérarchie de rôles          | `@RealtimeChannel(name, { roles })` + verrou de frame        |
| **Pont API plus permissif que REST** — `api.request {path}` pour contourner un 401 HTTP                | Re-match de la MÊME zone firewall que `GET {path}`                         | Verrou de frame, surface `api.request`                       |
| **Socket zombie** — un admin se déconnecte, sa socket continue de diffuser                             | Re-validation périodique de l'identité, fermeture `4001`                   | Tick de révocation du hub                                    |
| **DoS mémoire par abonnements** — une connexion ouvre des milliers de canaux                           | Plafond de canaux par connexion (256 par défaut)                           | `limits.maxChannelsPerConnection`                            |
| **DoS mémoire par lenteur** — un client ne lit pas, la file d'envoi enfle jusqu'à l'OOM                | Jet de frames au-delà de 4 MiB, fermeture `1013` après 1000 jets d'affilée | Back-pressure du transport WS                                |
| **Oracle d'autorisation** — sonder les canaux pour cartographier les droits                            | Motif de refus **générique** (`forbidden`), jamais le détail               | `realtime:denied`                                            |

> [!IMPORTANT]
> Les lignes 2 à 5 supposent `@nodefony/security` chargé **avec au moins une zone protégée**. Sans
> cela, le verrou de frame n'est jamais posé et ces défenses sont inertes — c'est la condition
> détaillée au verrou 3, le point le plus important de la page.

## 🎯 La vision Nodefony — une seule politique, deux transports

HTTP et WebSocket vivent dans le même contexte : la sécurité en hérite, **on n'écrit pas deux
politiques.**

- Une zone du firewall couvre HTTP **et** WS par défaut (`realtime: true` —
  `security/nodefony/config/config.ts#areaSchema`). L'opt-out est explicite ; un opt-in aurait été
  _fail-open_ (une zone qui oublie le flag laisserait le WS anonyme).
- Le verrou de frame consulte la **même** fonction de match que le HTTP, `Firewall.matchPath()`
  (`firewall.ts#Firewall.matchPath`), et la **même** hiérarchie de rôles, `Firewall.hasRole()`
  (`firewall.ts#Firewall.hasRole`). Invariant par construction : `api.request {path}` n'accorde jamais plus que
  `GET {path}`.
- L'identité du handshake est celle du firewall HTTP : `FirewallRealtimeAuthenticator`
  (`FirewallRealtimeAuthenticator.ts#FirewallRealtimeAuthenticator`) ne relit ni cookie ni base, il **promeut** l'`IUser` déjà
  posé dans l'ALS. Zéro lecture redondante par connexion.

Le compromis assumé : l'identité est **figée au handshake**, les frames lisent un cache O(1). C'est
ce qui rend le temps réel tenable, et ce qui crée le besoin d'un tick de révocation (verrou 4).

## 🚀 Démarrage rapide

Objectif : dans une app générée par `nodefony create app`, exposer un canal `orders:feed` réservé
aux administrateurs, et voir ce que reçoit un client non autorisé.

### 1. Déclarer la zone et activer l'origine stricte

```typescript
// nodefony/config/security.ts — la zone du handshake et la hiérarchie de rôles
import type { ISecurityConfigInput } from "@nodefony/security";

export const securityConfig = () =>
  ({
    areas: {
      // Zone protégée : le handshake WS de /rt passe par le MÊME contrôle que
      // le HTTP. `realtime` vaut true par défaut → ne pas l'écrire suffit.
      app: {
        pattern: "^/rt",
        authenticators: ["session"],
      },
    },
    roleHierarchy: {
      ROLE_ADMIN: ["ROLE_USER"],
    },
  }) satisfies ISecurityConfigInput;
```

```typescript
// nodefony/config/realtime.ts — la barrière d'origine et la garde anti-OOM
import type { IRealtimeConfigInput } from "@nodefony/realtime";

export const realtimeConfig = () =>
  ({
    csrf: {
      checkOrigin: {
        // Deuxième barrière d'origine : allowlist EXACTE (pas de wildcard).
        enabled: true,
        allowList: ["https://app.exemple.com", "https://127.0.0.1:5152"],
        // Un client sans en-tête Origin (mobile natif, script) est refusé.
        allowMissingOrigin: false,
      },
    },
    limits: {
      // Garde anti-OOM : au-delà, le subscribe est refusé (pas de close).
      maxChannelsPerConnection: 64,
    },
  }) satisfies IRealtimeConfigInput;
```

### 2. Le controller : un canal libre, un canal gardé

```typescript
// nodefony/controllers/OrdersRealtimeController.ts — compile tel quel
import { controller, route } from "@nodefony/framework";
import { Context } from "@nodefony/http";
import { RealtimeChannel, RealtimeController } from "@nodefony/realtime";
import type { RealtimePublish } from "@nodefony/realtime";

@controller("/rt")
class OrdersRealtimeController extends RealtimeController {
  constructor(context: Context) {
    super("OrdersRealtimeController", context);
  }

  @route("orders-ws", {
    path: "/orders",
    requirements: { methods: ["WEBSOCKET"] },
  })
  async realtime(message: string | Buffer | null): Promise<void> {
    this.handleRealtime(message);
  }

  // Canal PUBLIC : aucune policy déclarée → libre pour tout connecté.
  @RealtimeChannel("orders:public")
  publicFeed(channel: string, publish: RealtimePublish): () => void {
    const timer = setInterval(() => publish(channel, { ts: Date.now() }), 2000);
    return () => clearInterval(timer);
  }

  // Canal GARDÉ : la policy est une DÉCLARATION, transportée jusqu'à
  // @nodefony/security qui l'évalue (hiérarchie de rôles comprise).
  @RealtimeChannel("orders:feed", {
    authenticated: true,
    roles: ["ROLE_ADMIN"],
  })
  adminFeed(channel: string, publish: RealtimePublish): () => void {
    const timer = setInterval(() => publish(channel, { pending: 3 }), 1000);
    return () => clearInterval(timer);
  }
}

export default OrdersRealtimeController;
```

### 3. Ce qu'observe le client refusé

Un `ROLE_USER` connecté demande les deux canaux. Le premier marche, le second est refusé — et le
refus est **visible**, jamais un silence.

```typescript
// frontend — le client isomorphe du core
import { NodefonySocket } from "nodefony/client";

const socket = new NodefonySocket({ url: "wss://app.exemple.com/rt/orders" });

// Refus poussés par le serveur — première classe côté client.
socket.onDenied((denied) => {
  // { channel: "orders:feed", reason: "forbidden" }
  // Motif GÉNÉRIQUE : jamais « il te manque ROLE_ADMIN » (pas d'oracle).
  console.warn("canal refusé", denied.channel, denied.reason);
});

await socket.connect();
socket.subscribe("orders:public"); // ✅ ticks reçus
socket.subscribe("orders:feed"); // ❌ aucun tick, un realtime:denied à la place
```

**Ce qu'on observe côté serveur** : un log `WARNING` « WS realtime frame refused by authorizer »
et une entrée d'audit `category: "ws"`, `action: "frame.denied"`, `outcome: "denied"`, avec
l'acteur et le canal visé. Le canal n'est jamais ouvert : le provider ne démarre pas, le hub n'est
même pas appelé.

> [!WARNING]
> Ce démarrage rapide ne protège `orders:feed` que si `@nodefony/security` est chargé **et** que la
> zone `app` reste protégée. Retire le module, ou passe la zone en `realtime: false`, et le canal
> redevient ouvert à tout connecté — le serveur le signalera par un WARNING au boot, mais il ne
> refusera pas de démarrer.

## 🛡️ Verrou 1 — l'origine : bloquer le CSWSH

Le navigateur n'applique **pas** CORS aux WebSockets. N'importe quelle page peut ouvrir une socket
vers ton domaine, et le navigateur y joindra les cookies de la victime. L'en-tête `Origin`
(RFC 6455 §10.2) est la seule information exploitable côté serveur.

Nodefony pose **deux barrières successives**, à deux étages différents.

| Barrière             | Étage                | Défaut                              | Origin absent | Refus             |
| -------------------- | -------------------- | ----------------------------------- | ------------- | ----------------- |
| Contrôle same-origin | `@nodefony/http`     | **actif** (`allowedOrigins: false`) | accepté       | `403` (handshake) |
| Allowlist stricte    | `@nodefony/realtime` | **inactif** (`enabled: false`)      | configurable  | `close 4003`      |

**Barrière 1 — transport, active sans rien faire.** `HttpKernel.checkWebsocketOrigin()`
(`http-kernel.ts#HttpKernel.checkWebsocketOrigin`) exige que l'`Origin` du handshake corresponde au `Host` servi, avec tolérance
loopback en développement et une allowlist optionnelle (`allowedOrigins`,
`http/nodefony/config/config.ts#websocketSchema`) acceptant le hostname exact ou un wildcard à un label. Une
requête **sans** `Origin` est acceptée : un attaquant non-navigateur n'a pas besoin de CSWSH.

**Barrière 2 — module realtime, opt-in et plus stricte.** `RealtimeHub.checkOrigin()`
(`RealtimeHub.ts#RealtimeHub.checkOrigin`) consulte une garde compilée une fois au boot par `buildOriginGuard()`
(`RealtimeService.ts#buildOriginGuard`) depuis `checkOriginSchema` (`realtime/nodefony/config/config.ts#checkOriginSchema`).
Trois différences comptent :

1. **Match exact scheme + host + port**, aucun wildcard. Durcissement volontaire par rapport au
   CORS `Access-Control-Allow-Origin: *`.
2. **Origin absent → refusé** par défaut (`allowMissingOrigin: false`). C'est le seul moyen de
   fermer la porte aux clients non-navigateur.
3. **Fail-closed** : `enabled: true` avec une `allowList` vide refuse **tout**. Une erreur de
   configuration ferme la porte, elle ne l'ouvre pas.

Le refus est journalisé (`WARNING`, avec l'origine reçue) puis la socket est fermée en `4003`
« origin not allowed » — plage 4000-4999 réservée aux applications (RFC 6455 §7.4.2). Aucune frame
n'est jamais traitée.

> [!CAUTION]
> **Ce que l'Origin ne prouve pas.** C'est une déclaration du navigateur, pas une preuve
> cryptographique : un client non-navigateur peut l'inventer. L'Origin protège les **victimes de
> navigateur** (CSWSH), pas contre un attaquant qui contrôle son propre client. La défense contre
> ce dernier, c'est l'authentification — verrou suivant.

## 🔐 Verrou 2 — l'identité : une seule fois, au handshake

### Le pipeline exact

`RealtimeController.onHandshake()` (`RealtimeController.ts#RealtimeController.onHandshake`) exécute, une fois par connexion :

1. Construction d'un DTO neutre `IRealtimeHandshake` par `buildHandshakeFromContext()`
   (`RealtimeController.ts#buildHandshakeFromContext`) — headers, cookies aplatis, url, origin, sous-protocoles. Aucune
   dépendance à `@nodefony/security` dans le contrat.
2. Contrôle d'origine (verrou 1).
3. Résolution de l'authenticator par `RealtimeHub.resolveAuthenticator()` (`RealtimeHub.ts#RealtimeHub.resolveAuthenticator`) :
   les matchers sont testés dans l'ordre d'enregistrement, **le premier qui matche capture**.
4. `authenticator.authenticate(handshake)` — **async autorisé** (on est en cold path, une fois par
   connexion : lire un store est acceptable ici, jamais par frame), et **borné** par
   `authenticateTimeoutMs` (10 s par défaut, `admissionBudget.ts#AdmissionBudget.authenticateWithin`).
   Au-delà, la socket est fermée en `1013` « try again later » : le client recule puis revient, et une
   identité arrivée après coup est ignorée. Ni `onFailure` ni `4001` — une base en panne n'est pas un
   refus d'identité, et un code définitif arrêterait un client légitime à chaque panne. Compteur
   `authenticateTimedOutTotal`, un `WARNING` par fenêtre de 10 s. Un client parti pendant
   l'authentification n'est jamais inscrit au hub.
5. Pose du token sur la WeakMap `peer → token` via `RealtimeHub.setTokenForPeer()`
   (`RealtimeHub.ts#RealtimeHub.setTokenForPeer`), **avant** l'envoi du `welcome` : le lookup est garanti dès la première
   frame.

Un `throw` de `authenticate()` ferme la socket en `4001` « unauthorized », après un log `WARNING`
nommant l'authenticator fautif. Le hook `onFailure` est invoqué dans un `try/catch` : un hook
d'audit défectueux ne peut pas empêcher la fermeture.

### Les matchers — quelle porte, quel vigile

`RealtimeHub.useAuthenticator()` (`RealtimeHub.ts#RealtimeHub.useAuthenticator`) associe un sélecteur à une stratégie.
`compileMatcher()` (`RealtimeHub.ts#compileMatcher`) le compile une fois :

- `pattern` chaîne → RegExp **préfixe ancrée**, méta-caractères échappés par `RegExp.escape`.
  `"/admin/"` matche `/admin/` et `/admin/foo`, littéralement.
- `pattern` RegExp → utilisée telle quelle.
- `host` optionnel → comparaison **stricte** (insensible à la casse) sur l'en-tête `Host`, sans
  wildcard.
- Le match porte sur le **path**, query comprise, jamais sur l'URL absolue : `handshakePath()`
  (`RealtimeController.ts#handshakePath`) extrait `pathname + search` du `WebsocketContext.url`, qui est
  absolu. Sans cette extraction, un matcher `^/nodefony/…` ne se déclencherait jamais.

`@nodefony/security` enregistre ces matchers automatiquement dans `Firewall.#wireRealtime()`
(`firewall.ts#Firewall.#wireRealtime`) : **une instance d'authenticator par zone protégée**, car le hub dédoublonne par
identité d'instance et une instance partagée n'enregistrerait que le premier matcher.

### Zero Trust — il y a toujours un token

`RealtimeHub.getTokenForPeer()` (`RealtimeHub.ts#RealtimeHub.getTokenForPeer`) ne renvoie **jamais** `null` : à défaut de
token posé, c'est `ANONYMOUS_REALTIME_TOKEN` (`AnonymousRealtimeToken.ts#ANONYMOUS_REALTIME_TOKEN`), singleton gelé,
`isAuthenticated() === false`, `roles: ["ROLE_ANONYMOUS"]`. Le code consommateur (verrou, audit)
n'a jamais à écrire « et s'il n'y a pas de token ? ».

| Situation au handshake                           | Token posé                  | Connexion    |
| ------------------------------------------------ | --------------------------- | ------------ |
| Aucun authenticator enregistré                   | `ANONYMOUS_REALTIME_TOKEN`  | ouverte      |
| Aucun matcher ne capture l'URL                   | `ANONYMOUS_REALTIME_TOKEN`  | ouverte      |
| Matcher trouvé mais `supports()` renvoie `false` | `ANONYMOUS_REALTIME_TOKEN`  | **ouverte**  |
| `authenticate()` réussit                         | le token de l'authenticator | ouverte      |
| `authenticate()` throw                           | aucun                       | `close 4001` |

> [!WARNING]
> La troisième ligne est un comportement à comprendre : un authenticator qui **matche mais ne
> supporte pas** le handshake fait retomber la connexion en anonyme, sans fermeture. C'est le cas
> de `FirewallRealtimeAuthenticator.supports()` (`FirewallRealtimeAuthenticator.ts#FirewallRealtimeAuthenticator.supports`) quand aucune
> identité authentifiée n'est en ALS. Ce n'est pas un trou **parce que** le firewall HTTP a déjà
> refusé l'anonyme en amont sur une zone protégée (Zero Trust appliqué à la requête d'upgrade). Sur
> une route WS **hors zone**, en revanche, la connexion anonyme aboutit : la protection y repose
> entièrement sur les policies de canal.

Le `welcome` transporte ensuite l'identité résolue (`type`, `authenticated`, `userIdentifier`,
`roles`, `scopes`) : le client sait qui il est sans appeler une route. Vue « sur soi » — aucun
secret, aucun claim d'un tiers.

## 🧑‍⚖️ Verrou 3 — l'autorisation par frame

C'est le verrou le plus important et celui dont les limites doivent être les mieux comprises.

### Et si le module de sécurité n'est pas chargé du tout ?

La question mérite d'être posée avant la mécanique, parce que la réponse a longtemps été mauvaise.
Sans `@nodefony/security`, personne ne pose de décideur : le hub laissait alors passer **tout**, y
compris les canaux qui décrivent l'intérieur du serveur — journaux, requêtes de base, métriques,
supervision. Un tableau de bord anonyme lisait les journaux du pod.

Le hub applique donc son **propre plancher**, qui ne dépend d'aucun module : tant qu'aucun verrou
n'est posé, une connexion cliente ne peut pas s'abonner à un canal de plateforme.

Un seul territoire porte cette marque : **`nodefony:`**. Tout ce que la plateforme expose sur la
socket y vit, et rien d'autre n'y entre.

| Canal                                                                                   | Ce qu'il expose             |
| --------------------------------------------------------------------------------------- | --------------------------- |
| `nodefony:audit`                                                                        | journal d'audit             |
| `nodefony:syslog`                                                                       | journaux du serveur         |
| `nodefony:orm:health` · `nodefony:orm:flow` · `nodefony:orm:rich`                       | requêtes et santé des bases |
| `nodefony:dashboard` · `nodefony:supervision` · `nodefony:debugbar` · `nodefony:socket` | métriques et supervision    |
| `nodefony:kernel:ping` · `nodefony:kernel:gc`                                           | contrôle du pod             |

Le raisonnement tient en une phrase : **sans module de sécurité, aucune identité n'existe**, donc
personne ne peut prouver qu'il a le droit de lire ces canaux — le seul état sûr est le refus. Les
canaux applicatifs (`chat:`, le vôtre) ne sont pas concernés : une application sans authentification
continue de fonctionner.

Trois conséquences pratiques :

- Le client **reçoit** `realtime:denied` avec le motif `forbidden` — il n'attend pas dans le vide.
- Le serveur **journalise une fois** au premier refus, en expliquant comment ouvrir (charger
  `@nodefony/security` et déclarer une zone protégée). Un écran vide doit toujours avoir une cause
  lisible quelque part.
- La sonde compte les refus (`systemFloorDeniedTotal`), ce qui distingue « configuration
  incomplète » de « quelqu'un insiste ».

Dès qu'un module de sécurité pose son verrou, ce plancher s'efface : c'est le verrou qui décide,
avec les rôles — ce que le hub, seul, ne sait pas faire.

> [!NOTE]
> Un service **du serveur** qui s'abonne à ses propres journaux n'est pas concerné : il ne passe pas
> par la porte des connexions clientes. Le plancher vise le réseau, pas le code local.

### La chaîne complète

```mermaid
sequenceDiagram
  participant C as Client
  participant P as JsonRpcPeer
  participant H as RealtimeHub
  participant S as security — verrou
  C->>P: {"method":"subscribe","params":{"channel":"nodefony:syslog"}}
  P->>P: beforeDispatch (sync, hot path)
  P->>H: runAuthorizer(frame, peer)
  H->>H: getTokenForPeer(peer) — O(1) WeakMap
  H->>S: frameAuthorizer(frame, token)
  S->>S: plancher système ? puis policy métier ?
  S-->>H: false
  H-->>P: false
  P->>P: onFrameAudit("denied", frame, peer)
  P-->>C: realtime:denied {channel, reason:"forbidden"}
```

Le verrou est **strictement synchrone** — `FrameAuthorizer` (`RealtimeHub.ts#FrameAuthorizer`). Un `await` par
frame coûterait une microtask et sérialiserait le pipeline RPC de la connexion. Il ne lit que de la
mémoire : le token déjà résolu, et la cible de la frame.

### Les trois surfaces gardées

`buildFrameAuthorizer()` (`frameAuthorizer.ts#buildFrameAuthorizer`) ne garde que ce qui atteint des données :

| Frame | Contrôle appliqué |
| --- | --- |
| `api.request {path}` | Re-match de zone HTTP : zone protégée + anonyme → refus |
| `subscribe {channel}` | Plancher système, puis policy métier déclarée sur le canal |
| notification `method` = canal inbound | Même politique que `subscribe` (on ne pousse pas sur un canal protégé) |
| action `@RealtimeAction` | Authentifié **par défaut** ; rôle/scope si déclarés ; ouverte seulement si `{ authenticated: false }` est écrit |
| `ping`, `unsubscribe` | **passent** — pas de surface de données |

Deux détails évitent des faux refus : `authorizeApiRequest()` (`frameAuthorizer.ts#authorizeApiRequest`) laisse
passer une frame au `path` invalide (le handler renverra `-32602` — le verrou ne duplique pas la
validation), et `authorizeChannel()` (`frameAuthorizer.ts#authorizeChannel`) laisse passer un `channel`
non-chaîne (`startChannel` ignore de toute façon un canal absent).

### Le plancher système — les namespaces réservés

Certains namespaces exposent l'intérieur du pod. Ils portent une politique par défaut que la config
peut **resserrer**, jamais desserrer.

| Cible                                      | Politique par défaut                                                               |
| ------------------------------------------ | ---------------------------------------------------------------------------------- |
| `nodefony:audit`                           | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts#SECURITY_CHANNEL_POLICY`) |
| tout le reste de `nodefony:`               | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts#SYSTEM_CHANNEL_POLICY`)   |
| tout canal contenant `:health` ou `:stats` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts#matchSystemPolicy`)       |
| tout le reste                              | libre, sauf policy déclarée                                                        |

Le journal d'audit est un **canal précis**, pas un namespace : sa règle est posée devant celle du
territoire (premier match gagnant), et elle n'existe que si le hub réserve bien ce territoire.

Trois durcissements méritent d'être connus :

- **Match insensible à la casse, sans allocation** — `startsWithCI()` (`frameAuthorizer.ts#buildSystemRules`).
  Un `NODEFONY:syslog` ne contourne pas le plancher `nodefony:` par un changement de casse.
- **Plancher irréductible** — `floorReserved()` (`frameAuthorizer.ts#floorReserved`) : une règle de config qui
  tenterait d'ouvrir un namespace réservé (`{ authenticated: false }`) se voit ré-imposer
  `authenticated: true`. Le test porte sur le **namespace du canal**, pas sur le préfixe de la règle
  qui a matché : un préfixe de config plus court ou altéré ne contourne rien.
- **La config passe avant les défauts** — les règles de `realtimeChannels`
  (`security/nodefony/config/config.ts#securityConfigSchema`) sont placées en tête, premier match gagnant. On peut
  donc re-cibler `nodefony:syslog` sur `ROLE_SECURITY_AUDITOR` ; on ne peut pas l'ouvrir à l'anonyme.

Le canal du journal d'audit (`nodefony:audit`) est enregistré comme **canal système** sur le hub
(`RealtimeHub.registerSystemChannel()`, `RealtimeHub.ts#RealtimeHub.registerSystemChannel`) : il devient servable par n'importe
quel endpoint realtime, sans qu'aucun controller ne le connaisse — et il est gardé par sa règle
dédiée. Son enregistrement est **couplé** à la pose du verrou (même condition), donc il n'existe
jamais de canal d'audit non gardé.

### La policy métier — déclarer sur le canal

`@RealtimeChannel(name, policy)` (`realtimeDecorators.ts#RealtimeChannel`) et `@RealtimeInbound(name, policy)`
(`realtimeDecorators.ts#RealtimeInbound`) attachent un `IChannelPolicy` au **nom** du canal. Les trois axes sont
cumulatifs (ET) ; un axe absent n'impose rien :

| Axe             | Sens                                                                          | Évalué par                                            |
| --------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------- |
| `authenticated` | token non anonyme                                                             | `satisfies()` (`frameAuthorizer.ts#satisfies`)        |
| `roles`         | un des rôles suffit, **hiérarchie comprise**                                  | `Firewall.hasRole()` (`firewall.ts#Firewall.hasRole`) |
| `scopes`        | un des scopes suffit — axe API (JWT, clé API), une session BFF n'en porte pas | comparaison directe                                   |

Une policy **vide** n'est pas enregistrée : `definePolicy()` (`realtimeDecorators.ts#definePolicy`) ignore un
objet sans contrainte — le canal reste libre, le registre reste vide. Les déclarations sont inscrites
au hub **au démarrage** (`channelPolicyRegistry.ts#registerControllerPolicies`, à `onKernelReady`) :
un exemplaire qui n'a encore vu aucune poignée de main a déjà ses politiques — celui qui n'émet
que des webhooks aussi. Un contrôleur échappé au démarrage s'inscrit à sa première poignée de main
(`RealtimeHub.registerChannelPolicy()`, `RealtimeHub.ts#RealtimeHub.registerChannelPolicy`, idempotent) ;
le décideur les relit par `RealtimeService.resolveChannelPolicy()`
(`RealtimeService.ts#RealtimeService.resolveChannelPolicy`).

### ⚠️ Une policy est attachée à un nom EXACT — les canaux dérivés n'héritent pas

`@RealtimeChannel("chat:room", { roles })` garde `chat:room`, **et rien d'autre**. Le registre est
indexé par le nom, à la lettre : un canal **dérivé** — `chat:room:1000` (cadence),
`orm:queries@1234` (forage), `chat:room:<identifiant>` — ne matche pas. Il est servi par la
fabrique dynamique du contrôleur (`createRealtimeChannel`), et `resolveChannelPolicy()` rend `null`
pour lui : aucune contrainte.

C'est le piège le plus facile à ne pas voir, parce que tout a l'air correct : la déclaration est
là, le canal fonctionne, et le refus attendu n'arrive jamais.

Le serveur le **dit** désormais. Au premier canal servi dynamiquement sans politique, alors que le
module en déclare pour d'autres canaux (hors canaux de plateforme `nodefony:`, que leur plancher
garde déjà), un avertissement est journalisé une fois
(`RealtimeHub.noticeUnguardedDynamicChannel()`, `RealtimeHub.ts#RealtimeHub.noticeUnguardedDynamicChannel`) :

```
le canal "chat:room:1000" n'a aucune règle d'accès : tout client connecté peut s'y abonner.
Il est produit par createRealtimeChannel(), et une règle déclarée sur un nom exact ne couvre pas
ses variantes (suffixe de cadence, identifiant). […]
```

Deux façons de fermer, selon l'intention :

- **la famille doit être gardée** → déclarer un **motif** : `@RealtimeChannel("chat:room:*", { roles })`
  garde tous les canaux de la famille ; l'avertissement propose le motif à écrire et se tait dès
  qu'un motif couvre le canal ;
- **le canal doit être gardé seul** → l'exposer sous un nom déclaré (un canal par nom exact), ou
  porter la vérification **dans la fabrique** elle-même, qui reçoit le nom demandé et peut refuser
  (`null`) ;
- **le canal est public** → rien à faire, l'avertissement dit seulement qu'il n'est pas gardé.

> Le nom exact l'emporte sur un motif ; entre deux motifs, le plus spécifique gagne. Un `*` dans un
> nom souscrit est refusé : il désigne un motif de politique, jamais un canal.

### 📬 Le canal personnel `user:<id>` — fourni, rien à déclarer

Livrer un message à **une** personne — sur tous ses onglets, sur tous les serveurs, et à personne
d'autre — est une brique du framework. L'application n'écrit ni canal, ni règle, ni déclaration de
diffusion :

```typescript
// côté serveur — un service, un job, un contrôleur
getRealtimeHub<ChatContract>().publishToUser(bob.id, message);

// côté navigateur — cet onglet s'abonne quand son écran écoute
useEffect(() => chat.onUser((m) => addPrivate(m)), []);
```

La forme du message se déclare dans le contrat, par la clé gabarit du canal personnel :

```typescript
interface ChatContract extends IRealtimeContract {
  serverToClient: {
    [channel: `user:${string}`]: { from: string; text: string };
  };
}
```

Ce que le framework garantit, écrit une fois :

| Garantie                  | Comment                                                                                                                                                                                                    |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **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`. |
| **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`.                |
| **Tous les serveurs**     | Dès qu'un bus est branché, le préfixe `user:` est diffusé par le framework (`RealtimeHub.ts#RealtimeHub.setBackplane`).                                                                                    |
| **Sans sécurité chargée** | La règle s'applique quand même : `allow` ne fait que retirer des abonnés.                                                                                                                                  |
| **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.                                                        |

L'`id` est l'identifiant **stable** du compte (`RealtimeIdentity.id`), jamais le login : un login
libéré se réattribue, et le nouveau compte recevrait les messages de l'ancien. Un appelant sans
compte local (`<émetteur>#<sujet>`, connexion d'un fournisseur d'identité) a lui aussi son canal ;
un **anonyme** n'en a pas — il n'a pas d'identifiant qui survive à une reconnexion. Le support
anonyme passe par un salon privé de l'application (`support:<conversation>` + `allow`, ci-dessous).

**Le prix assumé** : un compte supprimé garde son abonnement jusqu'à son éjection explicite. Et une
publication sans abonné est perdue sans erreur — c'est une diffusion, pas une boîte aux lettres :
l'historique d'une conversation vit dans la base de l'application.

**Surcharger la règle** : une politique que l'application écrit sur `user:…` (nom exact ou motif)
l'emporte sur la règle intégrée — c'est ainsi qu'on ouvre `user:public` à tous. Une telle
déclaration remplace la garantie « toi seul » : à n'écrire qu'en connaissance de cause.

### 🪪 `allow` — admettre un abonné par SON identité

Les trois axes décrivent une **classe** d'identité : « un administrateur », « un service qui porte
`metrics:read` ». Ils ne savent pas dire « **ce** compte, et lui seul, sur **ce** canal » — la boîte
aux lettres `user:42`, la salle d'une partie, le tableau de bord d'une seule usine. C'est le rôle
d'`allow` : une règle qui reçoit le canal demandé et l'identité de l'abonné, et répond oui ou non.
Elle se joue au `subscribe`, une fois, dans le contrôleur
(`RealtimeController.ts#RealtimeController.startChannel`) ; la règle elle-même est un contrat du cœur
(`channelAdmission.ts#IChannelAdmission`), isomorphe : rien n'y dépend de la socket.

> L'exemple ci-dessous écrit à la main la règle de `user:*` pour montrer la mécanique. Pour le canal
> personnel lui-même, **ne rien écrire** : le framework le fournit (section précédente), et une
> politique écrite ainsi remplacerait la sienne.

**La forme courte — une flèche, pour une règle qui ne lit rien :**

```typescript
// nodefony/controllers/InboxRealtimeController.ts
import { controller, route } from "@nodefony/framework";
import { Context } from "@nodefony/http";
import { RealtimeChannel, RealtimeController } from "@nodefony/realtime";
import type { RealtimePublish } from "@nodefony/realtime";

@controller("/rt")
class InboxRealtimeController extends RealtimeController {
  constructor(context: Context) {
    super("InboxRealtimeController", context);
  }

  @route("inbox-ws", {
    path: "/inbox",
    requirements: { methods: ["WEBSOCKET"] },
  })
  async realtime(message: string | Buffer | null): Promise<void> {
    this.handleRealtime(message);
  }

  // La règle du MOTIF : `user:42` n'est ouvert qu'au compte 42.
  @RealtimeChannel("user:*", {
    allow: (channel, who) => channel === `user:${who.id}`,
  })
  inbox(): () => void {
    return () => {};
  }

  // Un producteur décoré ne sert que son nom exact : les canaux `user:<id>`
  // sont servis par la fabrique dynamique.
  override createRealtimeChannel(
    channel: string,
    publish: RealtimePublish,
  ): (() => void) | null {
    if (!channel.startsWith("user:")) return null;
    publish(channel, { unread: 0 });
    return () => {};
  }
}

export default InboxRealtimeController;
```

Le compte 42 reçoit `realtime:subscribed` pour `user:42` ; le compte 43 et l'anonyme reçoivent
`realtime:denied`, motif `forbidden` — prouvé de bout en bout, vrai client et vrai verrou, par
`realtimeChannelAuth.e2e.test.ts`.

**La forme longue — une classe, dès que la règle lit les données de l'application.** Ses services lui
arrivent par le constructeur, comme à tout service ; elle se déclare dans `@services([…])` de son
module, et le décorateur la nomme :

```typescript
// nodefony/services/RoomAdmission.ts
import { Inject, Module, Service, injectable } from "nodefony";
import type { IChannelAdmission, ISubscriberIdentity } from "nodefony";
import type RoomMembersService from "./RoomMembersService";

@injectable()
class RoomAdmission extends Service implements IChannelAdmission {
  constructor(
    module: Module,
    @Inject("RoomMembersService") private readonly members: RoomMembersService,
  ) {
    super(
      "roomAdmission",
      module.container ?? undefined,
      module.notificationsCenter,
    );
  }

  // `room:<partie>` n'est ouvert qu'aux joueurs de la partie.
  async allow(channel: string, who: ISubscriberIdentity): Promise<boolean> {
    if (who.id === null) return false;
    return this.members.isPlayer(channel.slice("room:".length), who.id);
  }
}

export default RoomAdmission;

// Dans le contrôleur :
//   @RealtimeChannel("room:*", { allow: RoomAdmission })
```

Le démarrage résout la classe une fois et journalise « canal room:\*, admission RoomAdmission,
résolue » (`channelPolicyRegistry.ts#createAdmissionResolver`). Une dépendance absente, une portée
autre que singleton, une classe sans méthode `allow` font **échouer le démarrage** en nommant la
classe et le canal — jamais un refus silencieux de 100 % des abonnés en production. Une seule
instance sert tous les abonnés en même temps : elle ne garde **aucun état par abonné**.

Un **module npm** livre sa politique de la même façon : la classe vit dans le module, déclarée dans
ses `@services([…])`, son contrôleur la nomme. L'application qui l'installe n'écrit rien.

**Le contrat, en six règles :**

| Règle                                         | Pourquoi                                                                                                                                                                                                                                              |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **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                                                                        |
| **`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                                                                                                                                      |
| **Seul `true` admet**                         | `1`, `"yes"`, un objet refusent — une réponse ambiguë ne vaut pas un oui                                                                                                                                                                              |
| **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`                                                                |
| **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à |
| **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                                                                                                       |

Le `realtime:welcome` annonce un canal à `allow` sur le seul verdict du verrou : la règle n'est
jamais jouée à la poignée de main — elle coûterait un appel par canal déclaré et par connexion, pour
annoncer un motif dont aucun nom concret n'est connu.

**Quand la règle est lente ou en panne** — la base qu'elle lit ne répond plus :

| Situation | L'abonné reçoit | Le serveur |
| --- | --- | --- |
| 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 |
| aucune réponse dans `allowTimeoutMs` (5 s) | `unavailable` | `allowTimedOutTotal` ; un verdict arrivé après coup est ignoré, jamais une admission |
| 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 |
| plus de `maxPendingAllowsTotal` (512) appels en vol sur l'exemplaire | `unavailable`, sans appel | `allowSaturatedTotal` |
| 50 délais dépassés en 5 s | `unavailable` et `retryAfterMs`, sans appel, pendant 5 s (coupe-circuit) | `allowSaturatedTotal` |

Un délai dépassé n'est **pas** un refus de droit : le client réessaie `unavailable` au plus trois
fois, avec un recul exponentiel à gigue complète, après `retryAfterMs` quand il est donné.
`forbidden` et `limit` ne se réessaient jamais. Le tout reste borné : 300 passerelles qui rejouent
10 000 abonnements contre une base **bloquée** déclenchent environ 1 600 appels de la règle, jamais
plus de 512 à la fois, et la tempête s'éteint en moins d'une minute
(`admissionStorm.e2e.test.ts`). Réglages : [`configuration.md`](../configuration/) ; compteurs :
[`observabilite.md`](../observabilite/).

**Le prix assumé — une réponse retenue.** Une admission ne se périme pas seule : un membre retiré
d'une salle reste abonné tant que sa connexion vit, la règle n'est pas rejouée. Pour couper tout de
suite, on retire son appartenance (là où la règle la lit), PUIS on l'éjecte :
`getRealtimeHub().evictUser(canal, compte)` coupe tous ses onglets, sur tous les serveurs
(`realtime:closed { reason: "evicted" }`). L'éjection seule ne suffit pas — son navigateur rejoue
`subscribe` au rechargement, et c'est la règle qui tranche alors. Détail :
[canaux à membres](../canaux-a-membres/). Fermer la connexion entière, c'est la révocation
([verrou 4](#-verrou-4--la-révocation--une-socket-survit-à-sa-session)).

**`room:*` et `pattern: "room:"` — deux grammaires, une même famille.** Le décorateur écrit un motif
(`"room:*"`) ; la configuration de `@nodefony/security` écrit un préfixe
(`realtimeChannels: [{ pattern: "room:", roles }]`). Ils couvrent les mêmes canaux. La règle de
configuration est jugée par le verrou AVANT la politique du décorateur et la remplace de ce côté —
c'est ainsi qu'une exploitation durcit un namespace sans toucher au code ; elle ne porte jamais
d'`allow`, qui reste déclaré sur le décorateur et s'applique toujours à l'abonnement.

### ⚠️ La condition d'activation — le point critique

Le verrou n'existe que si quelqu'un le pose. Deux conditions doivent être vraies **en même temps** :

1. `@nodefony/security` est chargé, et
2. au moins une zone a `security: true` **et** `realtime: true`.

C'est exactement le test de `Firewall.#wireRealtime()` (`firewall.ts#Firewall.#wireRealtime`) : sans zone qualifiante,
`wired` reste faux, `setFrameAuthorizer` n'est jamais appelé, et **aucune** policy de canal n'est
évaluée — ni métier, ni système. `nodefony:syslog` redevient un canal ordinaire.

Deuxième subtilité : `beforeDispatch` n'est branché sur une connexion que si le verrou est **déjà**
posé au moment de son handshake (`RealtimeController.ts#RealtimeController.onHandshake`, via
`RealtimeHub.hasFrameAuthorizer()` — `RealtimeHub.ts#RealtimeHub.hasFrameAuthorizer`). Choix de performance délibéré (un hub
non sécurisé garde un coût nul par frame), mais avec une conséquence : **une connexion ouverte avant
la pose du verrou n'est jamais gardée**, et ce jusqu'à sa fermeture. En fonctionnement normal le
firewall se construit au boot, avant tout trafic ; le cas ne se présente qu'en appelant
`setFrameAuthorizer()` à chaud.

**Le refus de dégrader en silence.** Quand des policies sont déclarées sans décideur câblé,
`RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts#RealtimeHub.hasUnenforcedChannelPolicies`) renvoie `true` et le controller
émet un WARNING explicite, une seule fois par process (`RealtimeController.ts#RealtimeController.onHandshake`) :

```text
Realtime channel policies declared but NO frame authorizer is wired —
these policies are NOT enforced (a protected channel is currently open).
Load @nodefony/security with a realtime zone to enforce them.
```

C'est le comportement prouvé par `realtimeUnenforcedPolicy.attack.test.ts` : une policy déclarée
sans `frameAuthorizer` est détectée comme **inerte**, et l'ajout du `frameAuthorizer` éteint
l'alerte. Le test ne prouve pas que le canal est fermé — il prouve que le framework **le dit**.

> [!CAUTION]
> **Portée exacte de cette alerte.** Elle se déclenche uniquement s'il existe au moins une policy
> **déclarée** par un décorateur. Une application qui n'utilise aucun `@RealtimeChannel` avec
> policy, mais qui expose des canaux de namespace réservé (`nodefony:syslog`, `nodefony:orm:health`…), ne déclenche
> **rien** : le registre `#channelPolicies` reste vide, la condition est fausse, et les planchers
> système restent pourtant non appliqués. La détection couvre le risque métier, pas le risque
> plateforme.

### Ce que le refus laisse voir

Le refus doit être observable sans devenir un oracle. Nodefony tranche ainsi :

| Type de frame refusée               | Réponse au client                                                                                           |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| Requête (avec `id`)                 | `-32001 "unauthorized"` — `JsonRpcPeer.receive()` (`JsonRpcPeer.ts#JsonRpcPeer.receive`)                    |
| Notification (`subscribe`, inbound) | `realtime:denied { channel, reason: "forbidden" }` (`RealtimeController.ts#RealtimeController.onHandshake`) |
| Dépassement du plafond de canaux    | `realtime:denied { channel, reason: "limit" }`                                                              |
| Canal sans aucun producteur         | `realtime:denied { channel, reason: "unknown" }`                                                            |

Le message est **générique** dans les trois cas : jamais « il te manque `ROLE_ADMIN` », jamais le nom
de la zone. Sans `realtime:denied`, une notification refusée serait droppée en silence (elle n'a pas
de canal de réponse) et le client se croirait abonné. **Aucun abonnement ne reste sans réponse** :
un canal que personne ne produit — nom mal orthographié, module non chargé — rend `unknown`, qui
n'ouvre aucun oracle (un canal gardé est tranché en amont, donc `forbidden` qu'il existe ou non).
Le motif `limit` est volontairement distinct de
`forbidden` : une borne de ressource n'est pas un secret, et les confondre enverrait un développeur
chercher un problème de droits.

Côté serveur, chaque refus alimente le journal d'audit via le rapporteur `onDeny`
(`FrameDenyReporter`, `frameAuthorizer.ts#FrameDenyReporter`), tiré **uniquement** sur refus — le chemin autorisé
n'alloue rien. L'entrée porte `category: "ws"`, `action: "frame.denied"`, l'acteur, la ressource et
une raison machine stable (`zone_protected` ou `channel_policy`).

## ⏳ Verrou 4 — la révocation : une socket survit à sa session

Le problème est structurel : l'identité est figée au handshake, le verrou est sync, donc une frame
ne peut pas relire la session. Un administrateur qui se déconnecte garderait ses flux `nodefony:syslog` et
`nodefony:audit` tant que sa socket vit.

Nodefony ferme l'écart par deux mécanismes de granularité différente.

| Surface                     | Re-validation        | Fenêtre d'exposition | Où                                                                                                    |
| --------------------------- | -------------------- | -------------------- | ----------------------------------------------------------------------------------------------------- |
| `api.request` (data plane)  | **à chaque frame**   | nulle                | `RealtimeController.invokeApiRequest()` (`RealtimeController.ts#RealtimeController.invokeApiRequest`) |
| `subscribe` / flux de canal | **périodique**, 30 s | ≤ 30 s               | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts#RealtimeHub.revalidateRevocable`)                |

**Sur `api.request`**, `token.isValid()` est appelé avant l'exécution de l'action ; identité périmée
ou changée → `-32000` avec `status: 401`, et le client bascule sur un `fetch` HTTP porteur du cookie
courant. Une erreur de re-validation vaut refus (fail-closed).

**Sur les canaux**, le hub n'inscrit au registre de révocation que les connexions dont le token porte
`isValid` (`RealtimeController.ts#RealtimeController.onHandshake`). Une connexion anonyme n'y entre jamais,
coût nul. Toute connexion authentifiée par le pare-feu y entre : sa session est relue, ou, pour un
jeton porteur (`jwt`, `external-jwt`, `apikey`), son expiration (`exp`), la révocation de son `jti` et
la révocation en masse de son identité (`invalidBefore`).
`RealtimeHub.registerRevocable()` (`RealtimeHub.ts#RealtimeHub.registerRevocable`) démarre un `setInterval` `unref` au premier
inscrit et l'arrête dès que le registre se vide : zéro timer au repos. Période :
`REVOCATION_REVALIDATE_MS` (`RealtimeHub.ts#channelFamily`), 30 s, alignée sur le heartbeat WS.

Ce que `realtimeRevocation.attack.test.ts` prouve exactement :

| Cas                                        | Comportement prouvé                                       |
| ------------------------------------------ | --------------------------------------------------------- |
| `isValid()` renvoie `false`                | `close(4001, "session revoked")`                          |
| `status()` rend `expired` (jeton porteur)  | `close(4006, "token expired")`                            |
| `status()` rend `revoked`                  | `close(4001, "session revoked")`, même si `exp` est passé |
| `isValid()` renvoie `true`                 | socket **intacte** (contrôle positif, pas de faux refus)  |
| `isValid()` **throw** (store indisponible) | socket fermée — fail-closed, parité avec `api.request`    |
| déjà révoqué                               | retiré du registre avant le close → aucun re-close        |
| déconnexion propre                         | `unregisterRevocable` → plus jamais re-validé             |
| registre vide                              | tick no-op, aucun crash                                   |

La source de vérité côté security est `buildSessionRevalidator()`
(`FirewallRealtimeAuthenticator.ts#buildSessionRevalidator`) : il relit la session BFF **par son id capturé au handshake**
et vérifie qu'elle est toujours vivante **et toujours celle du même utilisateur** — ce second point
attrape le changement de compte sur un navigateur partagé.

**Le code de fermeture dit la cause** (`realtime/closeCodes.ts`, ADR-0021 §S11). Un jeton porteur
sait pourquoi il est mort : `IRealtimeToken.status()` rend `expired` ou `revoked`, et la
révocation l'emporte — la liste de refus est interrogée avant de conclure à l'expiration.

| Cause sur une socket OUVERTE                                  | Code                                   | Client 10.0          |
| ------------------------------------------------------------- | -------------------------------------- | -------------------- |
| jeton porteur arrivé à `exp`                                  | `4006` (`TOKEN_EXPIRED_CLOSE_CODE`)    | s'arrête (définitif) |
| jeton porteur révoqué (`jti`, `invalidBefore`), expiré ou non | `4001` (`IDENTITY_DEAD_CLOSE_CODE`)    | s'arrête             |
| session expirée ou détruite (la cause n'est pas connaissable) | `4001`                                 | s'arrête             |
| droits changés, identité vivante                              | `4005` (`IDENTITY_REFRESH_CLOSE_CODE`) | revient aussitôt     |
| refus à la poignée de main, quel qu'il soit                   | 401 uniforme → `1008`                  | s'arrête             |
| authentification sans réponse dans `authenticateTimeoutMs`    | `1013` (`TRY_AGAIN_LATER_CLOSE_CODE`)  | revient, avec recul  |
| client qui ne vide plus sa file (contre-pression)             | `1013`                                 | revient, avec recul  |

> [!IMPORTANT]
> **Jamais de `4006` à la poignée de main.** Un refus distinct y dirait à qui tient un jeton volé
> qu'il a été émis par l'application : un jeton expiré présenté à l'entrée reçoit le même 401 que
> n'importe quel autre refus.

**Recette — un jeton d'une heure sur une socket de sept jours.** La socket vit plus longtemps que son
jeton : au bout d'une heure, elle est fermée en `4006`, et le client 10.0 ne revient pas seul. C'est
à l'application de **recréer sa socket avec un jeton neuf avant `exp`** — par exemple obtenir un
nouveau jeton cinq minutes avant l'échéance, ouvrir la nouvelle socket, puis fermer l'ancienne.
Un client qui saura obtenir un jeton frais de lui-même viendra dans une version 10.x, sans que le
serveur change.

**Un changement de rôles coupe sur-le-champ, sans attendre le tick.** Quand une mise à jour du compte
touche ses rôles, le hub ferme toutes les sockets de cette identité avec le code **4005**
(`realtime/closeCodes.ts#IDENTITY_REFRESH_CLOSE_CODE`, `RealtimeHub.ts#RealtimeHub.#revocation`). À la
différence de 4001 (identité morte, définitif), 4005 est **reconnectable** : le client se reconnecte
seul et reçoit ses nouveaux rôles dans l'accueil. Les flux SSE du même compte suivent la même règle.
Aucune lecture de base par tick ni par frame : le coût n'existe qu'à la mise à jour qui touche
`roles`.

> [!CAUTION]
> **Trois limites à connaître.**
>
> 1. **Fenêtre de 30 s.** Entre le logout et le tick suivant, les flux continuent. Pour une coupure
>    immédiate, fermer la socket explicitement côté serveur. (Un changement de **rôles**, lui, coupe
>    immédiatement — ci-dessus.)
> 2. **Une identité qu'on ne pourra pas re-valider ferme la socket.** Si la session est illisible au
>    handshake (ni id ni store exploitable), `buildSessionRevalidator()`
>    (`FirewallRealtimeAuthenticator.ts#buildSessionRevalidator`) rend un revalidateur qui répond
>    toujours `false` : la socket est fermée en 4001 au premier tick, et un `WARNING` le dit. Même
>    règle pour un jeton porteur qui n'a ni `exp`, ni de quoi interroger le store de révocation
>    (`jti`, `iat`). Un refus visible plutôt qu'une connexion que rien ne pourrait plus couper.
> 3. **Un jeton porteur n'est jugé sur sa signature qu'à l'ouverture.** Le tick relit `exp`, `jti` et
>    `invalidBefore`, jamais la clé ni l'émetteur : une clé retirée du jeu publié par l'émetteur laisse
>    la socket vivre jusqu'à `exp`, et un émetteur retiré de `resourceServer.issuers` ne compte qu'à
>    la connexion suivante — la liste ne change qu'au redémarrage, qui ferme les sockets. Une
>    connexion anonyme, elle, n'est pas révocable du tout.

## 🚧 Plafonds anti-abus — borner une connexion

Une connexion ouverte est une ressource. Trois bornes existent ; il faut aussi savoir ce qui **n'est
pas** borné. Les appels en vol de la règle `allow` ont leurs propres plafonds, par connexion et par
exemplaire : voir [`allow` — quand la règle est lente](#-allow--admettre-un-abonné-par-son-identité).

### Canaux par connexion

Chaque canal ouvert coûte un provider, un ticker et une entrée de Map. Sans borne, une connexion
peut abonner jusqu'à l'OOM — un déni de service mémoire déclenché par **un seul** client.

`RealtimeController.startChannel()` (`RealtimeController.ts#RealtimeController.startChannel`) refuse au-delà de
`limits.maxChannelsPerConnection` (`realtime/nodefony/config/config.ts#limitsSchema`), défaut **256**,
`null` pour illimité. Points prouvés par `realtimeChannelCap.attack.test.ts` :

- le plafond est actif **même sans `RealtimeService`** — le hub porte le défaut 256
  (`RealtimeHub.ts#RealtimeHub.#slowConsumerBytes`), il n'y a pas de fenêtre où la garde n'existerait pas ;
- au-delà, le hub **n'est jamais appelé** : le canal n'est pas ouvert, aucun provider ne démarre ;
- le refus est **observable** (`realtime:denied`, motif `limit`) ;
- un re-`subscribe` d'un canal déjà tenu **ne consomme pas de slot** (l'idempotence est vérifiée
  avant le plafond) — un client qui redemande son canal n'est pas puni ;
- `null` rétablit le multiplexage illimité, opt-out explicite.

C'est une **garde**, pas une bride : sous le seuil, le multiplexage N-canaux reste entier. Le log de
refus est en `DEBUG` et non `WARNING` — un log par subscribe refusé sous flood serait lui-même un
amplificateur.

### Back-pressure — le client qui ne lit pas

Un onglet en arrière-plan, un mobile en zone blanche, une fenêtre TCP pleine : la file d'envoi
grossit sans borne, et le multiplexage concentre le risque (une socket lente bloque tous ses
canaux). `WsConnectionTransport.send()` (`WsConnectionTransport.ts#WsConnectionTransport.send`) applique deux seuils :

| `bufferedAmount`                                                                                             | Action                                                                                                                      |
| ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| > `websocket.maxBackpressure` — 4 MiB par défaut (`http`, `http/nodefony/config/config.ts#httpConfigSchema`) | politique `websocket.backpressurePolicy` : `drop` (défaut) jette la frame — canaux d'état, le prochain snapshot la remplace |
| `websocket.backpressureCloseAfterDrops` drops CONSÉCUTIFS — 1000 par défaut                                  | `close(1013)` « Try Again Later » ; le client se reconnecte et resynchronise                                                |

### Taille des messages entrants

Portée par `@nodefony/http` : `websocket.maxPayload` (`http/nodefony/config/config.ts#websocketSchema`), défaut
**1 MiB**, au-delà fermeture RFC 6455 `1009` « Message Too Big ». C'est un durcissement par rapport
au défaut de la librairie `ws`.

### Ce qui n'est PAS borné

| Absent | Conséquence |
| --- | --- |
| Limite de **fréquence** des frames entrantes | Un client authentifié peut inonder le peer ; seul le coût CPU le freine |
| Limite de **connexions par IP ou par utilisateur** | Rien n'empêche N sockets par client au niveau du module |
| Plafond **global** de canaux du process | Le plafond est par connexion ; M connexions × 256 canaux reste possible |
| Seuils de back-pressure dans ce module | aucun réglage dans `@nodefony/realtime` : ils se règlent sur le serveur WebSocket de `@nodefony/http` (`websocket.maxBackpressure`, `.backpressureCloseAfterDrops`) |

## ⚙️ Configuration de sécurité

Seules les clés à **effet de sécurité** figurent ici ; le catalogue complet est dans
[`configuration.md`](../configuration/).
Source unique des défauts : `realtimeConfigSchema` (`realtime/nodefony/config/config.ts#realtimeConfigSchema`).

| Clé                                   | Défaut   | Effet de sécurité                                                                         |
| ------------------------------------- | -------- | ----------------------------------------------------------------------------------------- |
| `csrf.checkOrigin.enabled`            | `false`  | Active l'allowlist d'origines à l'upgrade                                                 |
| `csrf.checkOrigin.allowList`          | `[]`     | Origines acceptées, **match exact**. Vide + activé = tout refusé                          |
| `csrf.checkOrigin.allowMissingOrigin` | `false`  | Accepter une upgrade sans `Origin` (clients non-navigateur)                               |
| `limits.maxChannelsPerConnection`     | `256`    | Plafond de canaux par connexion ; `null` = illimité                                       |
| `slowConsumer.bytes`                  | `1 MiB`  | Seuil de **comptage** des consommateurs lents dans la sonde                               |
| `backplane.namespace`                 | dérivé   | Cloison du pub/sub cross-pod                                                              |
| `backplane.secret`                    | _absent_ | Scelle les messages du transport partagé (HMAC) ; absent = bus ouvert + `WARNING` au boot |
| `enabled`                             | `true`   | `false` = module inerte : aucun câblage hub depuis la config                              |
| `areas.<nom>.realtime` _(security)_   | `true`   | La zone couvre aussi les frames WS. `false` = opt-out explicite                           |
| `realtimeChannels` _(security)_       | `[]`     | Règles de canal par préfixe, **placées avant** les défauts système                        |

> [!TIP]
> **`backplane.namespace` est une clé de sécurité, pas de confort.** Le numéro de base Redis ne
> cloisonne pas le pub/sub : deux déploiements de la même app sur un Redis mutualisé, sans
> namespace, échangent leurs fan-outs. Le poser dès que staging et production partagent un Redis.

## 🚫 Ce que ce module ne défend pas

Cette section existe pour éviter une confiance mal placée. Chaque point est vérifié au code.

**1. Il n'y a pas de frontière de canal entre modules.** Un nom de canal est une chaîne dans un
registre global (`#channelPolicies` du hub singleton, `RealtimeHub.ts#RealtimeHub.#channelPolicies`). Rien n'empêche le
controller du module A de servir un canal au nom d'un canal du module B, ni de déclarer une policy
plus faible sur ce même nom — la déclaration la plus récente écrase (`RealtimeHub.ts#RealtimeHub.#admitFromBackplane`, idempotent
par écrasement). En pratique, l'exploitation exige que le controller fautif accepte le nom (sa
factory doit renvoyer un provider), donc un override permissif de `createRealtimeChannel` ; et les
namespaces réservés restent couverts par le plancher système **si** le verrou est posé. Une garde
d'appartenance par module est un manque connu et assumé, pas une protection existante.

**2. Le backplane n'authentifie l'émetteur que si tu poses un secret.** Ce qui ENTRE par le bus est filtré par
canal — seul un canal déclaré broadcast est réinjecté (`RealtimeHub.#admitFromBackplane`), donc
`syslog:`, `nodefony:audit` et les canaux d'observabilité restent hors d'atteinte depuis le
transport. Mais l'**identité de l'émetteur** n'est vérifiée que lorsque `backplane.secret` est posé :
les messages sont alors scellés (HMAC-SHA256, `envelope.ts`) et un message non scellé ou altéré est
ignoré. Sans secret, quiconque écrit dans le Redis publie sur les canaux **broadcast** de tous les
pods — le boot l'annonce en `WARNING`. Le rejeu d'un message scellé intact reste possible : la
sémantique du transport est _at-most-once_, sans anti-rejeu.

**3. L'autorisation d'un canal n'est vérifiée qu'au `subscribe`.** Une fois abonné, le fan-out ne
re-consulte pas la policy : un changement de rôle en cours de connexion ne coupe pas le flux. Seule
la révocation de session (verrou 4, fenêtre 30 s) ferme la socket.

**4. Le contenu publié n'est pas filtré par abonné.** Le hub diffuse la charge telle quelle à tous
les abonnés du canal ; masquer partiellement un flux impose au provider de publier sur des canaux
distincts.

**5. Pas de limitation de débit ni de quota de connexions**, et **pas de chiffrement propre** : la
confidentialité et l'intégrité sont déléguées à TLS — `wss://` n'est pas optionnel en production.

**6. `enabled: false` désarme aussi les gardes du module.** Le service devient inerte
(`RealtimeService.init()`, `RealtimeService.ts#RealtimeService.init`) : ni garde d'origine, ni plafond de canaux posés
depuis la config. Le hub conserve son défaut de 256 canaux, mais l'allowlist d'origines configurée
n'est pas appliquée.

## 📜 Normes appliquées

| Norme                                 | Application                                                                                                                                  |
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| **RFC 6455 §10.2** (Origin)           | Contrôle d'origine à l'upgrade, aux deux étages                                                                                              |
| **RFC 6455 §7.4.2** (codes 4000-4999) | `4001` unauthorized / session revoked, `4003` origin not allowed, `4006` token expired                                                       |
| **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) |
| **RFC 6455 §4.1**                     | `Sec-WebSocket-Protocol` normalisé en liste dans le DTO de handshake                                                                         |
| **RFC 9110 §7.2**                     | Comparaison du `Host` par le matcher d'authenticator                                                                                         |
| **JSON-RPC 2.0 §5.1**                 | `-32001` (plage `Server error`) pour une requête refusée, `-32602` pour des params invalides                                                 |
| **OWASP WSTG-CLNT-10**                | Anti-CSWSH : validation d'`Origin` obligatoire, les navigateurs n'appliquent pas CORS aux WS                                                 |
| **Zero Trust / OWASP A01**            | Zone protégée + anonyme = refus ; token toujours présent, jamais `null`                                                                      |

## 📡 Observabilité — Studio

- **Santé de la socket** : `/nodefony/realtime/api/health`, alimenté par `RealtimeHub.probe()`
  (`RealtimeHub.ts#RealtimeHub.probe`) — canaux, abonnés, fan-out, connexions, back-pressure et carte d'identité
  du backplane. Un `drops` qui grimpe signale des clients en souffrance ; un `slowConsumers` non nul
  précède souvent une fermeture `1013`. Même snapshot en flux sur le canal `nodefony:socket`
  (namespace réservé : `ROLE_NODEFONY_ADMIN`).
- **Journal d'audit** : les refus de frame arrivent sur le canal `nodefony:audit`
  (`ROLE_NODEFONY_ADMIN`) et dans le data plane d'audit de `@nodefony/security`.
- **Page module** : `/nodefony/modules/realtime` — la config effective (dont `csrf.checkOrigin` et
  `limits`) y est lisible telle qu'appliquée au boot.

## ⚠️ Pièges

| Symptôme                                                                 | Cause                                                                                                  | Correction                                                                                            |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| Le canal `ROLE_ADMIN` est servi à tout le monde, sans erreur             | Aucune zone `security: true` + `realtime: true` → verrou de frame jamais posé                          | Déclarer une zone protégée couvrant la route WS ; chercher le WARNING « policies … NOT enforced »     |
| `nodefony:syslog` accessible en anonyme, **et aucun WARNING**            | L'alerte ne couvre que les policies **déclarées** ; un plancher système non appliqué reste muet        | Vérifier que le verrou est posé (log « Realtime data plane locked ») — ne pas se fier au silence      |
| L'allowlist d'origines est configurée mais tout passe                    | `csrf.checkOrigin.enabled` reste `false` (défaut)                                                      | Poser `enabled: true` ; vérifier `allowList` non vide, sinon **tout** est refusé                      |
| Le client mobile natif ne se connecte plus après activation de l'origine | `allowMissingOrigin: false` refuse les clients sans `Origin`                                           | Passer à `true` **uniquement** si un credential fort est vérifié (JWT signé, clé API)                 |
| Un matcher d'authenticator ne se déclenche jamais                        | Le matcher est comparé au **path**, pas à l'URL absolue ; un `pattern` chaîne est un préfixe **ancré** | Écrire `"/rt/"` et non `"rt"` ni `"wss://host/rt"`                                                    |
| Deux zones, un seul authenticator enregistré                             | Le hub dédoublonne par identité d'instance — une instance partagée n'enregistre que le premier matcher | Une **instance** d'authenticator par zone                                                             |
| Un admin déconnecté garde ses flux pendant une dizaine de secondes       | Le tick de révocation est périodique (30 s), pas immédiat                                              | Comportement attendu ; pour une coupure immédiate, fermer la socket côté serveur                      |
| Une session révoquée ne ferme **jamais** la socket                       | La session n'était pas lisible au handshake → revalidateur `null`, `isValid()` répond toujours `true`  | Vérifier que le handshake traverse bien la zone (session chargée avant le controller realtime)        |
| 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) |
| `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`             |
| 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                                                        |
| 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`    |
| 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       |

## 🧪 Tests & couverture

L'inventaire chiffré (cas comptés, fichiers, répartition unitaires / attaque / intégration / E2E)
est rendu par la carte de tests de la page, régénérée depuis le dépôt — aucun chiffre n'est figé
dans ce texte.

**Tests d'attaque** — ce sont eux qui font foi sur cette page :

| Fichier                                   | Ce qu'il prouve                                                                                                                         |
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `realtimeUnenforcedPolicy.attack.test.ts` | Une policy déclarée sans `frameAuthorizer` est détectée comme inerte (fail-loud). Ne prouve **pas** que le canal est fermé.             |
| `realtimeRevocation.attack.test.ts`       | Fermeture `4001` d'une session révoquée, contrôle positif, fail-closed sur erreur, pas de re-close                                      |
| `realtimeChannelCap.attack.test.ts`       | Le plafond refuse sans appeler le hub, le refus est observable, l'idempotence ne consomme pas de slot                                   |
| `backplaneInjection.attack.test.ts`       | Un pair ne peut pas pousser sur un canal non broadcast (rejet compté) ; sur bus scellé, un message forgé, altéré ou repointé est ignoré |

**Unitaires** : `RealtimeHubSecurity.test.ts` couvre les quatre seams (garde d'origine, matchers et
ordre de capture, mapping `peer → token` avec repli anonyme, verrou de frame et son retrait), le
registre de policies et l'absence d'allocation quand aucun seam n'est utilisé.

**E2E** : `realtimeChannelAuth.e2e.test.ts` exerce la matrice identité × canal (anonyme / user /
admin / service × canal libre, authentifié, rôle, scope, système) avec le **vrai** client isomorphe,
le **vrai** controller et le **vrai** verrou de `@nodefony/security` reliés par un câble loopback —
on observe le tick reçu ou le `realtime:denied`, jamais un booléen interne.
`realtimeFirewallWiring.e2e.test.ts` couvre la jonction firewall → hub.

**Ce qui manque** : pas de banc de charge dédié à la sécurité (surcoût du verrou sous flood), et
aucun test ne couvre le **rejeu** d'un message scellé — cohérent avec le fait que ce vecteur n'est
pas défendu (sémantique _at-most-once_).

Outils : `nodefony-security-review`, `nodefony-load-test`, `nodefony-check-memory-health`.
Couverture chiffrée : `npm run coverage` dans le module.

## 🔗 Pour aller plus loin

- ⬆️ **Retour au hub** : [Realtime — vue d'ensemble](../) · [Toute la documentation](../../../)
- [`architecture.md`](../architecture/) — la pile, le hub, le fan-out, le trajet d'une frame
- [`configuration.md`](../configuration/) — toutes les clés de `defineRealtimeConfig`
- [`vocabulaire.md`](../vocabulaire/) — socket, hub, peer, backplane, canal
- [`cookbook-chat.md`](../cookbook-chat/) — un canal broadcast de bout en bout
- [Sécurité — vue d'ensemble](../../security/) — le firewall, les zones, les
  authenticators, l'autorisation et l'audit
- [Firewall — le pare-feu applicatif](../../security/firewall/) — zones, Zero Trust,
  hiérarchie de rôles : la source de toutes les décisions appliquées ici
