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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +5 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +15 -3
  4. package/dist/nodefony/decorators/realtimeDecorators.js +231 -39
  5. package/dist/nodefony/src/backplane/RedisBackplane.js +33 -40
  6. package/dist/nodefony/src/backplane/controlOrder.js +34 -5
  7. package/dist/nodefony/src/backplane/envelope.js +422 -45
  8. package/dist/nodefony/src/backplane/ingress.js +93 -0
  9. package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
  10. package/dist/nodefony/src/backplane/publishQueue.js +97 -3
  11. package/dist/nodefony/src/server/RealtimeConnection.js +31 -6
  12. package/dist/nodefony/src/server/RealtimeController.js +87 -44
  13. package/dist/nodefony/src/server/RealtimeHub.js +631 -127
  14. package/dist/nodefony/src/server/admissionBudget.js +8 -8
  15. package/dist/nodefony/src/server/channelPolicyRegistry.js +35 -13
  16. package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
  17. package/dist/nodefony/src/server/channelPresence.js +488 -0
  18. package/dist/nodefony/src/server/contractRoutes.js +3 -13
  19. package/dist/nodefony/src/server/frameAdmission.js +27 -35
  20. package/dist/nodefony/src/server/presenceBus.js +486 -0
  21. package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
  22. package/dist/nodefony/src/server/remotePresence.js +541 -0
  23. package/dist/nodefony/src/server/subscribedAck.js +3 -0
  24. package/dist/nodefony/src/service/RealtimeService.js +9 -0
  25. package/dist/nodefony/testing/backplaneConformance.js +79 -3
  26. package/dist/nodefony/testing/index.js +22 -8
  27. package/dist/nodefony/testing/presenceClient.js +54 -0
  28. package/dist/types/index.d.ts +6 -1
  29. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +117 -16
  30. package/dist/types/nodefony/interfaces/IBackplane.d.ts +12 -1
  31. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +10 -2
  32. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +23 -0
  33. package/dist/types/nodefony/src/backplane/envelope.d.ts +50 -7
  34. package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
  35. package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
  36. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
  37. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +3 -0
  38. package/dist/types/nodefony/src/server/RealtimeController.d.ts +19 -1
  39. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +139 -30
  40. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +9 -2
  41. package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
  42. package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
  43. package/dist/types/nodefony/src/server/contractRoutes.d.ts +1 -1
  44. package/dist/types/nodefony/src/server/frameAdmission.d.ts +14 -18
  45. package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
  46. package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
  47. package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
  48. package/dist/types/nodefony/src/server/subscribedAck.d.ts +3 -0
  49. package/dist/types/nodefony/src/service/RealtimeService.d.ts +8 -1
  50. package/dist/types/nodefony/testing/backplaneConformance.d.ts +14 -2
  51. package/dist/types/nodefony/testing/index.d.ts +1 -1
  52. package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
  53. package/docs/actions.md +1 -0
  54. package/docs/architecture.md +21 -6
  55. package/docs/canaux-a-membres.md +13 -3
  56. package/docs/cas-d-usage.md +4 -3
  57. package/docs/configuration.md +38 -2
  58. package/docs/cookbook-chat.md +400 -152
  59. package/docs/observabilite.md +28 -9
  60. package/docs/protocole.md +10 -10
  61. package/docs/securite.md +64 -10
  62. package/package.json +12 -12
@@ -0,0 +1,150 @@
1
+ import type { PresencePairs } from "nodefony";
2
+ /**
3
+ * Messages de la présence répartie entre serveurs — la LISTE FERMÉE de ce qui
4
+ * circule sur `PLATFORM_BUS_CHANNELS.presence` (lots, demandes, réponses d'état)
5
+ * et `PLATFORM_BUS_CHANNELS.presenceAlive` (preuve de vie), ADR-0025 P2.
6
+ *
7
+ * Comme un ordre de contrôle (`controlOrder.ts#readControlOrder`), chaque
8
+ * message voyage dans la charge d'un message ordinaire du bus, sous le sceau de
9
+ * l'enveloppe : aucun champ neuf dans l'enveloppe, aucune méthode neuve dans
10
+ * `IBackplane`. L'émetteur (`originId`) se lit dans l'enveloppe, jamais dans la
11
+ * charge.
12
+ *
13
+ * Lecture par champs PROPRES (`ownField`), textes bornés (`isBoundedBusText`),
14
+ * entiers sûrs ; un champ inconnu est toléré (déploiement progressif), un champ
15
+ * connu de forme fausse fait refuser le message entier. Les tableaux rendus
16
+ * sont CEUX reçus, validés en place — aucune copie, aucune allocation par
17
+ * membre : le lecteur ne les modifie pas, l'appelant non plus.
18
+ */
19
+ /** Plus petit intervalle de preuve de vie qu'un pair peut annoncer (ms). */
20
+ export declare const MIN_PRESENCE_INTERVAL_MS = 100;
21
+ /** Plus grand intervalle de preuve de vie qu'un pair peut annoncer (ms) — 10 min. */
22
+ export declare const MAX_PRESENCE_INTERVAL_MS = 600000;
23
+ /**
24
+ * Plafond d'identités nommées qu'un pod peut annoncer pour UN canal — au-delà,
25
+ * l'entrée est refusée (ADR-0025 P2, « plafond de membres par pod et par canal »).
26
+ */
27
+ export declare const MAX_PRESENCE_MEMBERS = 100000;
28
+ /** Plafond d'entrées de canal dans un même message (lot, demande ou part de réponse). */
29
+ export declare const MAX_PRESENCE_CHANNELS = 4096;
30
+ /** Plafond de parts d'une réponse d'état (`n`). */
31
+ export declare const MAX_PRESENCE_PARTS = 4096;
32
+ /**
33
+ * Changements d'un pod sur UN canal, en valeurs ABSOLUES : rejouer une entrée
34
+ * déjà comprise dans un état ne change rien.
35
+ */
36
+ export interface IPresenceChannelDelta {
37
+ /** Nom exact du canal. */
38
+ readonly channel: string;
39
+ /** Identités dont le NOMBRE d'onglets sur ce pod change, à plat `[identité, onglets, …]` (onglets ≥ 1) ; `null` si aucune. */
40
+ readonly set: PresencePairs | null;
41
+ /** Identités qui n'ont plus aucun onglet sur ce pod ; `null` si aucune. */
42
+ readonly gone: readonly string[] | null;
43
+ /** Connexions non nommées de ce pod sur ce canal — valeur absolue. */
44
+ readonly unnamed: number;
45
+ }
46
+ /** État COMPLET d'un pod sur un canal, dans une réponse d'état. */
47
+ export interface IPresenceChannelState {
48
+ /** Nom exact du canal. */
49
+ readonly channel: string;
50
+ /** Toutes les identités nommées de ce pod, à plat `[identité, onglets, …]` (onglets ≥ 1). */
51
+ readonly members: PresencePairs;
52
+ /** Connexions non nommées de ce pod sur ce canal. */
53
+ readonly unnamed: number;
54
+ }
55
+ /** Lot de changements d'un pod, scellé toutes les 50 ms, chaîné par `prev`. */
56
+ export interface IPresenceBatch {
57
+ /** Discriminant. */
58
+ readonly op: "batch";
59
+ /** Vie du processus émetteur (`Date.now() × 1024 + aléa sur 10 bits`). */
60
+ readonly incarnation: number;
61
+ /** Numéro du message, strictement croissant par (`originId`, incarnation), ≥ 1. */
62
+ readonly seq: number;
63
+ /** `seq` du lot précédent de l'émetteur, `0` pour le premier ; `< seq`. */
64
+ readonly prev: number;
65
+ /** Changements par canal. */
66
+ readonly channels: readonly IPresenceChannelDelta[];
67
+ }
68
+ /** Demande d'état — le *fetch* de RFC 2778 §2.1, la *presence probe* de RFC 6121 §4.3. */
69
+ export interface IPresenceRequest {
70
+ /** Discriminant. */
71
+ readonly op: "request";
72
+ /** Vie du processus demandeur. */
73
+ readonly incarnation: number;
74
+ /** Numéro du message (même suite que les lots du demandeur). */
75
+ readonly seq: number;
76
+ /** Canaux dont l'état est demandé. */
77
+ readonly channels: readonly string[];
78
+ /**
79
+ * Pod visé (`originId`) — il répond TOUJOURS, même vide (RFC 6121 §4.3.2) ;
80
+ * `null` = demande non ciblée, servie par les seuls pods qui ont des présents.
81
+ */
82
+ readonly to: string | null;
83
+ }
84
+ /**
85
+ * Une part d'une réponse d'état. Les `n` parts d'une réponse partent d'un bloc,
86
+ * à `seq` consécutifs (`seq` de la part `i` = `seq` de la part 0 + `i`), et
87
+ * s'appliquent d'un bloc : une part perdue laisse la vue inchangée.
88
+ */
89
+ export interface IPresenceStatePart {
90
+ /** Discriminant. */
91
+ readonly op: "state";
92
+ /** Vie du processus qui répond. */
93
+ readonly incarnation: number;
94
+ /** Numéro du message. */
95
+ readonly seq: number;
96
+ /** `seq` du dernier lot émis avant la réponse — il ferme le trou (RFC 5263 §4.5) ; `< seq`. */
97
+ readonly last: number;
98
+ /** Rang de la part, `0 ≤ i < n`. */
99
+ readonly i: number;
100
+ /** Nombre de parts de la réponse, `1 ≤ n ≤ MAX_PRESENCE_PARTS`. */
101
+ readonly n: number;
102
+ /** États complets par canal (un canal peut s'étendre sur plusieurs parts). */
103
+ readonly channels: readonly IPresenceChannelState[];
104
+ }
105
+ /** Message lu sur `PLATFORM_BUS_CHANNELS.presence`. */
106
+ export type PresenceMessage = IPresenceBatch | IPresenceRequest | IPresenceStatePart;
107
+ /** Preuve de vie, lue sur `PLATFORM_BUS_CHANNELS.presenceAlive` — quelques dizaines d'octets, indépendante du nombre de canaux. */
108
+ export interface IPresenceAlive {
109
+ /** Vie du processus émetteur. */
110
+ readonly incarnation: number;
111
+ /** Numéro du message (même suite que les lots). */
112
+ readonly seq: number;
113
+ /** `seq` du dernier lot émis, `0` si aucun ; `< seq`. */
114
+ readonly last: number;
115
+ /** Intervalle de l'émetteur (ms), ANNONCÉ : il fixe le délai d'oubli chez les pairs (RFC 6665 §3.1.1). */
116
+ readonly interval: number;
117
+ /** `true` = arrêt propre : les pairs l'oublient aussitôt (RFC 6121 §4.5.1). */
118
+ readonly final: boolean;
119
+ }
120
+ /**
121
+ * Lit un message du canal de présence — `null` si sa forme n'est pas celle d'un message connu.
122
+ *
123
+ * Une seule garde pour les deux bords : l'émetteur la passe en test (il n'émet
124
+ * jamais ce que ses pairs refuseraient), le récepteur avant d'appliquer.
125
+ *
126
+ * @param payload - la charge du message reçu (forme inconnue).
127
+ * @returns le message, ou `null` (forme invalide, ou type inconnu : cf
128
+ * {@link isUnknownPresenceMessage} pour les distinguer).
129
+ */
130
+ export declare function readPresenceMessage(payload: unknown): PresenceMessage | null;
131
+ /**
132
+ * La charge est-elle un message de présence d'un TYPE que cette version ne connaît pas — `op` lisible, hors de la liste fermée ?
133
+ *
134
+ * C'est ce qu'émet un serveur plus récent pendant un déploiement progressif :
135
+ * jeté et compté dans `ingressUnknownDiscardedTotal`, jamais comme une
136
+ * injection (ADR-0025, « Règle de lecture du bus »).
137
+ *
138
+ * @param payload - la charge du message reçu (forme inconnue).
139
+ */
140
+ export declare function isUnknownPresenceMessage(payload: unknown): boolean;
141
+ /**
142
+ * Lit une preuve de vie — `null` si sa forme est fausse.
143
+ *
144
+ * `interval` hors de [{@link MIN_PRESENCE_INTERVAL_MS}, {@link MAX_PRESENCE_INTERVAL_MS}]
145
+ * est refusé : un pair qui annoncerait 1 ms se ferait oublier à chaque tic, un
146
+ * qui annoncerait une heure garderait ses fantômes une demi-journée.
147
+ *
148
+ * @param payload - la charge du message reçu (forme inconnue).
149
+ */
150
+ export declare function readPresenceAlive(payload: unknown): IPresenceAlive | null;
@@ -24,6 +24,18 @@
24
24
  * Ce qui est jeté est le message **nouveau**, pas le plus ancien : contrairement au
25
25
  * WS (drop latest-wins sur une file qu'on possède), ce qui est déjà remis au client
26
26
  * réseau ne peut plus être repris.
27
+ *
28
+ * **Les ordres de contrôle ne sont JAMAIS jetés** (#762) : terminer un canal ou
29
+ * éjecter un compte passe hors quota, même file saturée. Sinon, sous charge, un
30
+ * modérateur éjecte un compte sur un serveur et ce compte continue de lire sur un
31
+ * autre, sans que personne le sache. Les ordres sont rares et petits (une décision
32
+ * du serveur, quelques dizaines d'octets) : les exempter ne rouvre pas la mémoire.
33
+ * La **preuve de vie** de la présence répartie passe aussi hors quota (ADR-0025
34
+ * P2) : jetée, elle ferait oublier un pod vivant chez ses pairs ; elle est petite
35
+ * et d'intervalle borné. Liste des canaux prioritaires : `isPriorityBusChannel`
36
+ * (cœur), seule source.
37
+ * Un ordre que le bus REFUSE malgré tout (bus injoignable) est compté à part
38
+ * (`controlLostTotal`) et annoncé une fois par épisode.
27
39
  */
28
40
  import type { IBackplaneQueueInfo } from "../../interfaces/IBackplane.js";
29
41
  /** Sévérité d'une transition de la file — mappée telle quelle sur le syslog. */
@@ -59,12 +71,24 @@ export declare class BackplanePublishQueue {
59
71
  * (sinon elle ne partirait jamais — famine silencieuse), quitte à dépasser
60
72
  * transitoirement. Même règle que le drop WS, qui teste `bufferedAmount` seul.
61
73
  *
74
+ * Un canal prioritaire (`isPriorityBusChannel` du cœur : ordre de contrôle,
75
+ * preuve de vie de la présence) est admis hors quota : la file l'applique, pas
76
+ * le driver, pour qu'un driver qui emploie la file la porte sans y penser.
77
+ *
78
+ * @param channel - canal logique de la publication.
62
79
  * @param bytes - taille de la charge sérialisée.
63
80
  * @param emit - envoi réel ; appelé UNIQUEMENT si la publication est admise.
64
81
  * Son retour est suivi s'il s'agit d'une promesse (transport asynchrone).
65
82
  * @returns `true` si la publication est partie, `false` si elle a été jetée.
66
83
  */
67
- send(bytes: number, emit: () => unknown): boolean;
84
+ send(channel: string, bytes: number, emit: () => unknown): boolean;
85
+ /**
86
+ * Attend que toutes les publications en vol soient acquittées ou refusées par le bus — à l'arrêt, pour que ce qui est déjà émis (la preuve de vie finale de la présence) parte avant la sortie du processus.
87
+ *
88
+ * Ne borne rien : un bus qui ne répond plus ne la résout jamais. L'appelant
89
+ * pose la limite (l'arrêt du noyau a la sienne).
90
+ */
91
+ settled(): Promise<void>;
68
92
  /** Compteurs pour la carte d'identité du backplane (sonde + Studio). */
69
93
  describe(): IBackplaneQueueInfo;
70
94
  }
@@ -1,4 +1,5 @@
1
1
  import type { RealtimeClosedReason } from "nodefony";
2
+ import type { ChannelFrame } from "./RealtimeHub.js";
2
3
  /**
3
4
  * Ce que la poignée d'une connexion demande au contrôleur qui la sert — imposer
4
5
  * un abonnement, y mettre fin. Implémenté par `RealtimeController`, une fois par
@@ -14,4 +15,6 @@ export interface IRealtimeConnectionHost {
14
15
  * @returns `false` si la connexion n'est pas abonnée à ce canal.
15
16
  */
16
17
  end(channel: string, reason: RealtimeClosedReason): boolean;
18
+ /** Écrit une trame partagée, encodée pour le codec de cette connexion. */
19
+ send(frame: ChannelFrame): void;
17
20
  }
@@ -3,6 +3,23 @@ import type { ContextType } from "@nodefony/http";
3
3
  import { Controller } from "@nodefony/framework";
4
4
  import type { ControllerScope } from "@nodefony/framework";
5
5
  import type { IRealtimeController, RealtimePublish, RealtimeInboundHandlers, RealtimeActionHandler } from "../../interfaces/IRealtimeController.js";
6
+ /**
7
+ * Préfixe de la ligne `ERROR` écrite quand la poignée de main lève (socket fermée
8
+ * en 1011). Le harnais de test la relit pour citer l'exception : le client, lui,
9
+ * ne reçoit que « internal error » — rien d'interne ne sort par la socket.
10
+ */
11
+ export declare const HANDSHAKE_FAILED_LOG = "WS realtime handshake failed";
12
+ /**
13
+ * L'attente d'un `Retry-After` (RFC 9110 §10.2.3), en secondes : un délai
14
+ * (`120`) tel quel, une date HTTP ramenée à l'écart avec `now` (jamais
15
+ * négatif). Une valeur illisible, ou absente, ne dit rien : `null`.
16
+ *
17
+ * @param value - l'en-tête posé par l'action, s'il l'a été
18
+ * @param now - l'instant de référence d'une date HTTP, en ms (défaut : l'horloge,
19
+ * lue seulement pour une date)
20
+ * @returns les secondes à attendre, ou `null`
21
+ */
22
+ export declare function retryAfterSeconds(value: string | undefined, now?: number): number | null;
6
23
  /**
7
24
  * RealtimeController — base d'un endpoint WebSocket temps réel SERVEUR (JSON-RPC 2.0).
8
25
  *
@@ -340,7 +357,8 @@ export declare abstract class RealtimeController<Emit extends EventsMap = Defaul
340
357
  * mutualisé tant que Ph.6 n'a pas tranché).
341
358
  *
342
359
  * Erreurs → {@link RpcError} (`data.status` = statut HTTP équivalent) :
343
- * `-32602` params invalides, 404 path inconnu, 408 timeout d'action…
360
+ * `-32602` params invalides, 404 path inconnu, 408 timeout d'action… Un
361
+ * `Retry-After` posé par l'action part en `data.retryAfter` (secondes).
344
362
  * Tout autre throw de l'action reste opaque (`-32603`, Zero Trust).
345
363
  */
346
364
  private invokeApiRequest;
@@ -1,7 +1,8 @@
1
1
  import { JsonRpcPeer, isPlatformChannel, type IFrameCodec, type IFrameCodecSet, type RealtimeFrameData } from "nodefony";
2
2
  import { RealtimeConnection } from "./RealtimeConnection.js";
3
+ import { type IChannelPresenceView } from "./channelPresence.js";
3
4
  import type { IRealtimeConnection } from "../../interfaces/IRealtimeConnection.js";
4
- import type { IChannelAdmission, UserChannelPayload } from "nodefony";
5
+ import type { IChannelAdmission, IChannelPolicies, UserChannelPayload } from "nodefony";
5
6
  import { AdmissionBudget, type IAdmissionLimits } from "./admissionBudget.js";
6
7
  import type { IChannelAdmissionRule } from "./connectionAdmission.js";
7
8
  import type { IRealtimeConnectionState } from "../../interfaces/IRealtimeConnectionState.js";
@@ -33,8 +34,21 @@ export type OriginGuard = (origin: string | undefined) => boolean;
33
34
  * `@nodefony/security` au boot via {@link RealtimeService.setFrameAuthorizer}.
34
35
  * Doctrine SYNC stricte (cf `JsonRpcPeerOptions.beforeDispatch`) : un `await` par
35
36
  * frame coûterait une microtask et sérialiserait le pipeline RPC du peer.
37
+ *
38
+ * `judgesWrite` : le verrou sait juger une politique d'ÉCRITURE distincte de la
39
+ * lecture (ADR-0022 §6, décision 1 quater). Un verrou qui ne le déclare pas ne
40
+ * lirait que la lecture, et laisserait ouverte une écriture plus stricte : le
41
+ * hub refuse alors de démarrer dès qu'une telle écriture est déclarée.
42
+ *
43
+ * `explain` : la phrase qui dit POURQUOI une trame a été refusée, quand le
44
+ * verrou sait la rédiger (`null` sinon). Lu après un refus seulement
45
+ * ({@link RealtimeHub.explainDenial}) — jamais sur le trajet d'une trame
46
+ * acceptée. Elle va au journal du serveur, et au client hors production.
36
47
  */
37
- export type FrameAuthorizer = (frame: unknown, token: IRealtimeToken) => boolean;
48
+ export type FrameAuthorizer = ((frame: unknown, token: IRealtimeToken) => boolean) & {
49
+ readonly judgesWrite?: true;
50
+ readonly explain?: (frame: unknown, token: IRealtimeToken) => string | null;
51
+ };
38
52
  /**
39
53
  * Seuil d'alerte slow-consumer (octets de `bufferedAmount`). Au-delà, la connexion
40
54
  * est comptée comme « lente » par la sonde (PAS encore de drop/close — la sonde MESURE
@@ -179,6 +193,17 @@ export declare class ChannelFrame {
179
193
  * a déclenché la création (elle peut fermer alors que le provider partagé survit).
180
194
  */
181
195
  export type ChannelFactory = (channel: string, publish: RealtimePublish) => (() => void) | null;
196
+ /**
197
+ * Qui a déclaré une sorte de politique sous un nom, et laquelle — retenu au démarrage pour refuser deux classes en désaccord sur un même nom.
198
+ */
199
+ export interface IChannelPolicyClaim {
200
+ /** Constructeur du contrôleur qui a déclaré le premier. */
201
+ readonly owner: object;
202
+ /** Son nom, pour le message de refus. */
203
+ readonly ownerName: string;
204
+ /** La politique déclarée. */
205
+ readonly policy: IChannelPolicy;
206
+ }
182
207
  /**
183
208
  * RealtimeHub — broker temps réel **PAR INSTANCE** (1 pod = 1 process = 1 hub local).
184
209
  *
@@ -272,6 +297,38 @@ export declare class RealtimeHub {
272
297
  * et retire le canal (libère timers/listeners). No-op si non abonné.
273
298
  */
274
299
  unsubscribe(channel: string, sink: ChannelSink): void;
300
+ /**
301
+ * Présence d'un canal vue de CE pod (ADR-0025 P3) — `count`, `unnamed`, `members` (toujours, quel que soit le seuil de la trame), `partial`.
302
+ *
303
+ * La voie de l'observateur qui lit sans figurer (modérateur, tableau de
304
+ * bord). Chemin froid : une copie à chaque appel.
305
+ *
306
+ * @param channel - nom exact du canal.
307
+ * @returns la vue (vide si personne n'est là), ou `null` si la politique du
308
+ * canal ne déclare pas `presence`.
309
+ */
310
+ presence(channel: string): IChannelPresenceView | null;
311
+ /**
312
+ * Arrêt propre de la présence répartie (ADR-0025 P5) — le dernier lot part, puis une preuve de vie FINALE : les pairs retirent aussitôt les présents de ce serveur, sans attendre le délai d'oubli. Plus rien ne part ensuite ; idempotent, sans effet sans bus.
313
+ *
314
+ * Appelé par le module à l'arrêt du noyau (`onTerminate`), AVANT que le bus ne
315
+ * se ferme. Les connexions qui se ferment ensuite ne publient plus rien : la
316
+ * preuve de vie finale les a déjà retirées chez les pairs.
317
+ */
318
+ endPresence(): void;
319
+ /**
320
+ * Un abonnement vient d'être acquitté : sur un canal à présence, la liste complète part vers CETTE connexion, puis les changements en attente partent si la fenêtre le permet.
321
+ *
322
+ * Appelé par le contrôleur APRÈS `realtime:subscribed`, à chaque (re-)`subscribe`
323
+ * — aucune borne : la contre-pression peut avoir jeté la liste d'avant sans
324
+ * le dire, et c'est le re-`subscribe` qui répare (ADR-0025 P2 bis). La liste
325
+ * est encodée une fois par envoi de changements et par codec, puis servie
326
+ * telle quelle.
327
+ *
328
+ * @param channel - nom exact du canal acquitté.
329
+ * @param connection - la connexion abonnée.
330
+ */
331
+ presenceSubscribed(channel: string, connection: RealtimeConnection): void;
275
332
  /**
276
333
  * Les connexions abonnées à un canal par un client — **local à ce pod** : les membres servis par un autre exemplaire n'y figurent pas.
277
334
  *
@@ -578,6 +635,10 @@ export declare class RealtimeHub {
578
635
  * **Seam #1 (P13 → P6)** — pose le verrou de frame (cf {@link FrameAuthorizer}).
579
636
  * `null` retire la politique (bypass total). Posé 1× au boot par
580
637
  * `@nodefony/security` depuis les zones `realtime: true`. Cold path.
638
+ *
639
+ * @throws BootConfigurationError Le verrou ne déclare pas `judgesWrite`
640
+ * alors qu'une écriture distincte de la lecture est inscrite : il la
641
+ * laisserait ouverte.
581
642
  */
582
643
  setFrameAuthorizer(authorizer: FrameAuthorizer | null, options?: {
583
644
  readonly silentProbe?: FrameAuthorizer | null;
@@ -621,10 +682,7 @@ export declare class RealtimeHub {
621
682
  */
622
683
  hasUnenforcedChannelPolicies(): boolean;
623
684
  /**
624
- * **Seam #1b** — déclare la politique d'autorisation d'un canal/méthode
625
- * (`@RealtimeChannel`/`@RealtimeInbound` avec opts). Appelé au handshake par
626
- * le controller pour CHAQUE canal décoré (idempotent : même nom = écrase, les
627
- * controllers d'un même endpoint déclarent la même policy). Cold path.
685
+ * **Seam #1b** — pose la politique d'un canal/méthode en REMPLAÇANT ce que ce nom déclarait (voie programmatique ; les décorateurs passent par {@link RealtimeHub.declareChannelPolicy}). Cold path.
628
686
  *
629
687
  * Deux formes de nom, et la seconde garde une FAMILLE :
630
688
  *
@@ -634,24 +692,42 @@ export declare class RealtimeHub {
634
692
  * Le `*` remplace n'importe quelle suite de caractères ; tout le reste du
635
693
  * motif est littéral (échappé), y compris `.` et `(`.
636
694
  *
637
- * Précédence : le nom **exact** l'emporte toujours — c'est ainsi qu'on ouvre
638
- * une exception dans une famille gardée. Entre deux motifs, le plus
639
- * **spécifique** gagne (littéral le plus long).
695
+ * Précédence, SORTE PAR SORTE (ADR-0022 §6, décision 1 quater) : la lecture
696
+ * d'un nom vient de la déclaration la plus spécifique qui POSE une lecture —
697
+ * le nom **exact**, puis le motif au littéral le plus long ; l'écriture de
698
+ * même. C'est ainsi qu'on ouvre une exception dans une famille gardée, sans
699
+ * effacer la sorte que l'exception ne déclare pas.
640
700
  *
641
- * @param name - nom exact du canal (subscribe), de la méthode inbound, ou
701
+ * @param name - nom exact du canal, de la méthode entrante ou de l'action, ou
642
702
  * MOTIF de famille s'il contient `*`.
643
- * Une politique à `allow` porte sa règle d'admission : une flèche est
644
- * retenue telle quelle ; une classe exige son instance, résolue au démarrage
645
- * par l'injecteur, dont la méthode est liée ICI, une fois — le `subscribe`
646
- * n'appelle qu'une fonction prête.
647
- *
648
- * @param policy - exigences ({@link IChannelPolicy}) lues par security.
703
+ * @param policy - exigences de LECTURE ({@link IChannelPolicy}) lues par
704
+ * security ; seules exigées au `subscribe`.
649
705
  * @param admission - instance de la classe d'admission quand `policy.allow`
650
706
  * en est une ; ignorée sinon.
651
- * @throws BootConfigurationError La politique désigne une classe d'admission
652
- * et aucune instance de CETTE classe n'est fournie.
707
+ * @param write - exigences d'ÉCRITURE, exigées EN PLUS de la lecture sur une
708
+ * trame entrante ; `null` : ce nom n'en déclare pas.
709
+ * @throws BootConfigurationError Classe d'admission sans instance ; `allow`
710
+ * sur une écriture qui rencontre une lecture différente ; écriture distincte
711
+ * face à un verrou qui ne sait pas la juger.
653
712
  */
654
- registerChannelPolicy(name: string, policy: IChannelPolicy, admission?: IChannelAdmission): void;
713
+ registerChannelPolicy(name: string, policy: IChannelPolicy, admission?: IChannelAdmission, write?: IChannelPolicy | null): void;
714
+ /**
715
+ * Déclare la lecture et/ou l'écriture d'un nom en les AJOUTANT à ce qu'il déclarait — une sorte posée remplace la sienne, une sorte `null` garde celle d'avant. Voie des décorateurs (`channelPolicyRegistry.ts#registerControllerPolicies`). Cold path.
716
+ *
717
+ * @param name - nom exact ou motif.
718
+ * @param read - lecture déclarée, ou `null`.
719
+ * @param write - écriture déclarée, ou `null`.
720
+ * @param admission - instance de la classe d'admission désignée par l'une ou
721
+ * l'autre ; ignorée sinon.
722
+ * @throws BootConfigurationError Mêmes refus que {@link RealtimeHub.registerChannelPolicy}.
723
+ */
724
+ declareChannelPolicy(name: string, read: IChannelPolicy | null, write: IChannelPolicy | null, admission?: IChannelAdmission): void;
725
+ /**
726
+ * Pose le défaut FERMÉ d'une action (`{ authenticated: true }`) — son ÉCRITURE tant que rien d'exact ne déclare son nom et qu'aucun motif ne pose d'écriture ; une lecture de motif s'y ajoute sans l'ouvrir. Idempotent : une poignée de main n'en paie qu'une lecture.
727
+ *
728
+ * @param name - nom de l'action.
729
+ */
730
+ declareActionDefault(name: string): void;
655
731
  /**
656
732
  * Nombre maximum de `*` dans un motif.
657
733
  *
@@ -669,6 +745,26 @@ export declare class RealtimeHub {
669
745
  * la frame `subscribe`/inbound. O(1), 0 alloc quand le registre est vide.
670
746
  */
671
747
  resolveChannelPolicy(name: string): IChannelPolicy | null;
748
+ /**
749
+ * Lecture ET écriture qui s'appliquent à ce nom, en UNE résolution — `null` si aucune politique ne le couvre (ADR-0022 §6, décision 1 quater).
750
+ *
751
+ * Même résolution que {@link resolveChannelPolicy} : l'entrée la plus
752
+ * spécifique (nom exact, puis motif) gouverne les deux sortes. Rendue telle
753
+ * quelle : aucune allocation, une seule recherche par trame jugée.
754
+ *
755
+ * @param name - nom du canal, de la méthode entrante ou de l'action.
756
+ */
757
+ resolveChannelPolicies(name: string): IChannelPolicies | null;
758
+ /**
759
+ * Retient la PREMIÈRE classe qui déclare une sorte de politique sous ce nom, et rend celle qui l'avait déjà déclarée — pour refuser au démarrage deux classes en désaccord.
760
+ *
761
+ * @param name - nom exact ou motif.
762
+ * @param kind - sorte déclarée : lecture ou écriture.
763
+ * @param owner - constructeur du contrôleur qui déclare.
764
+ * @param policy - la politique déclarée.
765
+ * @returns la déclaration antérieure de cette sorte, ou `null`.
766
+ */
767
+ claimChannelPolicy(name: string, kind: "read" | "write", owner: object, policy: IChannelPolicy): IChannelPolicyClaim | null;
672
768
  /**
673
769
  * Règle d'admission prête à appeler pour ce canal — `null` s'il ne déclare pas `allow`. Lue au `subscribe`, jamais par trame.
674
770
  *
@@ -689,7 +785,7 @@ export declare class RealtimeHub {
689
785
  */
690
786
  addPolicyOwner(owner: object): void;
691
787
  /**
692
- * Une politique est-elle déclarée sous ce nom EXACT — motifs ignorés ? C'est la question du défaut fermé des actions.
788
+ * Ce nom EXACT porte-t-il une déclaration — motifs ignorés ?
693
789
  *
694
790
  * @param name - nom d'action ou de canal.
695
791
  */
@@ -730,16 +826,6 @@ export declare class RealtimeHub {
730
826
  * @param factory - {@link ChannelFactory} qui crée le provider partagé.
731
827
  */
732
828
  registerSystemChannel(channel: string, factory: ChannelFactory): void;
733
- /**
734
- * Pont hot-path entre `JsonRpcPeer.beforeDispatch` et le verrou métier : lit
735
- * le token déjà caché du peer (O(1), 0 lecture base) puis délègue à
736
- * l'authorizer. `true` (autorisée) si aucun verrou posé (bypass 0-coût) —
737
- * mais en pratique `beforeDispatch` n'est branché que si `hasFrameAuthorizer()`.
738
- *
739
- * @param frame - frame JSON-RPC entrante (brute) à autoriser.
740
- * @param peer - peer émetteur (clé du mapping `peer → token`).
741
- * @returns `true` si la frame peut être dispatchée, `false` si refusée.
742
- */
743
829
  /**
744
830
  * Ouvre (ou referme) la réception des journaux du navigateur, avec ses bornes.
745
831
  *
@@ -748,6 +834,29 @@ export declare class RealtimeHub {
748
834
  setClientLogsLimits(limits: IClientLogsLimits | null): void;
749
835
  /** Bornes en vigueur, ou `null` si le canal montant est fermé. */
750
836
  get clientLogsLimits(): IClientLogsLimits | null;
837
+ /**
838
+ * La raison d'un refus du verrou, quand il sait la dire — `null` sinon, ou si l'explication lève.
839
+ *
840
+ * Chemin FROID : appelé après un refus, jamais par trame acceptée. Le
841
+ * verdict, lui, est déjà rendu : une explication fautive ne change rien à la
842
+ * décision, elle se tait.
843
+ *
844
+ * @param frame - la trame refusée.
845
+ * @param peer - la connexion qui l'a émise.
846
+ * @returns la phrase, ou `null`.
847
+ */
848
+ explainDenial(frame: unknown, peer: JsonRpcPeer): string | null;
849
+ /**
850
+ * Pont hot-path entre `JsonRpcPeer.beforeDispatch` et le verrou métier : lit
851
+ * le token déjà caché du peer (O(1), 0 lecture base) puis délègue à
852
+ * l'authorizer. `true` (autorisée) si aucun verrou posé : `beforeDispatch`
853
+ * est armé sur TOUTE connexion, et c'est ici que le bypass se paie — un
854
+ * appel et une comparaison de champ.
855
+ *
856
+ * @param frame - frame JSON-RPC entrante (brute) à autoriser.
857
+ * @param peer - peer émetteur (clé du mapping `peer → token`).
858
+ * @returns `true` si la frame peut être dispatchée, `false` si refusée.
859
+ */
751
860
  runAuthorizer(frame: unknown, peer: JsonRpcPeer): boolean;
752
861
  /**
753
862
  * Inscrit une connexion qui vient de retenir son premier « oui » du verrou.
@@ -17,8 +17,15 @@ export type AdmissionResolver = (admissionClass: ChannelAdmissionClass, channel:
17
17
  * @param ctor - constructeur du contrôleur.
18
18
  * @param resolveAdmission - résolution des classes d'admission ; `null` hors
19
19
  * démarrage, et le hub refuse alors une politique qui en désigne une.
20
- * @throws BootConfigurationError Politique illisible, `allow` posé sur une
21
- * action, ou classe d'admission non résolue.
20
+ * Chaque nom est déclaré au hub SORTE PAR SORTE (lecture, écriture), sans
21
+ * héritage : le hub les fusionne avec les motifs de toutes les classes
22
+ * (ADR-0022 §6, décision 1 quater). Deux classes qui déclarent la même sorte
23
+ * différemment sous un même nom refusent le démarrage : la seconde effaçait la
24
+ * première sans un mot.
25
+ *
26
+ * @throws BootConfigurationError Politique illisible ou contredite, `allow`
27
+ * posé sur une action ou sur une écriture distincte de la lecture, classe
28
+ * d'admission non résolue, ou deux classes en désaccord sur un nom.
22
29
  */
23
30
  export declare function registerControllerPolicies(hub: RealtimeHub, ctor: object, resolveAdmission: AdmissionResolver | null): void;
24
31
  /**
@@ -0,0 +1,112 @@
1
+ import { type IChannelAdmission, type IChannelPolicies } from "nodefony";
2
+ import type { IChannelPolicy } from "../../interfaces/IChannelPolicy.js";
3
+ import type { IChannelAdmissionRule } from "./connectionAdmission.js";
4
+ /**
5
+ * Politiques qui s'appliquent à un nom, résolues : la LECTURE, l'ÉCRITURE quand elle en diffère, et — quand la lecture porte `allow` — la règle d'admission prête à appeler.
6
+ *
7
+ * Satisfait {@link IChannelPolicies} : le hub la rend telle quelle au juge, sans
8
+ * allocation.
9
+ */
10
+ export interface IChannelPolicyEntry extends IChannelPolicies {
11
+ readonly admission: IChannelAdmissionRule | null;
12
+ }
13
+ /**
14
+ * Ce qu'UNE déclaration (nom exact ou motif) pose sur un nom, par sorte — et sa vue seule, calculée une fois.
15
+ *
16
+ * `read` et `write` sont `null` quand cette déclaration ne pose pas la sorte :
17
+ * la résolution la cherche alors dans une déclaration plus générale
18
+ * (ADR-0022 §6, décision 1 quater). `fallback` est le défaut fermé d'une action
19
+ * sans politique : son écriture tant que rien d'exact ne déclare le nom et
20
+ * qu'aucun motif ne pose d'écriture.
21
+ */
22
+ export interface IPolicyDeclaration {
23
+ readonly read: IChannelPolicy | null;
24
+ readonly write: IChannelPolicy | null;
25
+ readonly fallback: IChannelPolicy | null;
26
+ /** Instance de la classe d'admission désignée par l'une des politiques, gardée pour une fusion ultérieure. */
27
+ readonly instance: IChannelAdmission | undefined;
28
+ /** Vue de cette déclaration seule, ou `null` si elle ne pose rien. */
29
+ readonly self: IChannelPolicyEntry | null;
30
+ }
31
+ /** Déclaration portée par un MOTIF de nom (`chat:room:*`), compilée et pesée. */
32
+ export interface IPatternDeclaration extends IPolicyDeclaration {
33
+ readonly source: string;
34
+ readonly re: RegExp;
35
+ /** Longueur du littéral (`*` retirés) : le plus spécifique d'abord. */
36
+ readonly weight: number;
37
+ }
38
+ /**
39
+ * Règle d'admission prête à appeler d'une politique, ou `null` sans `allow`.
40
+ *
41
+ * Une flèche se retient telle quelle. Une classe exige son instance — résolue au
42
+ * démarrage, jamais ici : le hub n'a pas d'injecteur, et résoudre à la première
43
+ * poignée de main laisserait sans politique un exemplaire qui n'émet que des
44
+ * webhooks. La méthode est liée une fois, et la règle se construit une fois :
45
+ * le `subscribe` n'alloue rien et n'appelle qu'une fonction.
46
+ *
47
+ * @throws BootConfigurationError La politique désigne une classe d'admission et
48
+ * aucune instance de CETTE classe n'est fournie.
49
+ */
50
+ export declare function admissionRuleOf(name: string, policy: IChannelPolicy, admission: IChannelAdmission | undefined): IChannelAdmissionRule | null;
51
+ /**
52
+ * Construit une déclaration et sa vue seule : lecture déclarée, sinon l'écriture, sinon le défaut d'action ; écriture gardée seulement si elle exige autre chose que la lecture.
53
+ *
54
+ * @param name - nom exact ou motif (pour les messages).
55
+ * @param read - lecture déclarée, ou `null`.
56
+ * @param write - écriture déclarée, ou `null`.
57
+ * @param fallback - défaut fermé d'une action, ou `null`.
58
+ * @param admission - instance de la classe d'admission quand la politique qui
59
+ * sert de lecture en désigne une.
60
+ */
61
+ export declare function declarationOf(name: string, read: IChannelPolicy | null, write: IChannelPolicy | null, fallback: IChannelPolicy | null, admission: IChannelAdmission | undefined): IPolicyDeclaration;
62
+ /**
63
+ * Politique INTÉGRÉE du canal personnel `user:<id>` (ADR-0022 §6, D1) — connexion authentifiée, admise sur SON canal seulement.
64
+ *
65
+ * Constante de module : elle ne s'inscrit nulle part, donc aucun `clear()`,
66
+ * aucun ordre de démarrage, aucun oubli ne peut la retirer. Elle sert de REPLI
67
+ * de LECTURE aux noms `user:…` : seule une déclaration qui vise `user:`
68
+ * (nom exact, ou motif dont le littéral commence par `user:`) la remplace — un
69
+ * motif attrape-tout (`*`) ne retire pas la garde « son canal seulement ».
70
+ */
71
+ export declare const USER_CHANNEL_DECLARATION: IPolicyDeclaration;
72
+ /**
73
+ * Résout les politiques d'un nom (ADR-0022 §6, décision 1 quater) : la LECTURE appartient à la déclaration la plus spécifique qui déclare quelque chose sur ce nom ; l'ÉCRITURE vient de la plus spécifique qui déclare une écriture.
74
+ *
75
+ * L'ordre de spécificité : le nom exact, puis les motifs du plus spécifique au
76
+ * plus général. Pourquoi deux règles et non une :
77
+ * - la LECTURE reste à la déclaration propriétaire du nom (déclarée, sinon
78
+ * héritée de sa propre écriture) : une commande `task:start` déclarée par
79
+ * `@RealtimeInbound` sous une famille `task:*` à `allow` ne devient pas un
80
+ * canal à membres, et une écriture exacte ne voit pas sa lecture s'ouvrir par
81
+ * un motif plus général ;
82
+ * - l'ÉCRITURE descend jusqu'au motif qui en déclare une : elle s'ajoute à la
83
+ * lecture (une trame entrante exige les deux), elle ne peut que durcir — une
84
+ * écriture de famille (`mix:*` réservée) n'est plus perdue sur un nom exact
85
+ * qui ne déclare que sa lecture.
86
+ *
87
+ * Ensuite : un nom `user:…` sans déclaration qui le vise garde la politique
88
+ * intégrée du canal personnel (un motif qui ne vise pas `user:` est ignoré) ;
89
+ * une action sans politique (nom exact qui ne déclare rien) prend la lecture du
90
+ * motif qui la couvre et le défaut fermé pour écriture — un motif de lecture
91
+ * public ne l'ouvre donc pas.
92
+ *
93
+ * Rend la vue d'une déclaration quand une seule suffit (aucune allocation) ;
94
+ * n'alloue que si l'écriture vient d'une autre déclaration que la lecture.
95
+ *
96
+ * @param name - nom résolu.
97
+ * @param exact - déclaration exacte de ce nom, ou `undefined`.
98
+ * @param patterns - motifs, triés du plus spécifique au plus général, ou `null`.
99
+ * @returns les politiques, ou `null` si aucune ne couvre le nom.
100
+ */
101
+ export declare function resolveDeclaredPolicies(name: string, exact: IPolicyDeclaration | undefined, patterns: readonly IPatternDeclaration[] | null): IChannelPolicyEntry | null;
102
+ /**
103
+ * Défaut de l'ensemble des déclarations qui refuse le démarrage, ou `null` : une ÉCRITURE à `allow` qui s'appliquerait sous une lecture différente (son `allow` ne serait évalué nulle part), ou — face à un verrou qui ne juge pas l'écriture — une écriture qui diffère d'une lecture qu'elle peut rencontrer.
104
+ *
105
+ * Cold path (inscription d'une politique, pose du verrou) : toutes les paires.
106
+ *
107
+ * @param exact - déclarations exactes, par nom.
108
+ * @param patterns - déclarations par motif.
109
+ * @param writeJudged - le verrou en place sait juger l'écriture (ou aucun verrou).
110
+ * @returns le message du refus, ou `null`.
111
+ */
112
+ export declare function policyDeclarationsDefect(exact: ReadonlyMap<string, IPolicyDeclaration> | null, patterns: readonly IPatternDeclaration[] | null, writeJudged: boolean): string | null;