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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +79 -10
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +79 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +125 -19
  6. package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
  7. package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
  8. package/dist/nodefony/src/backplane/RedisBackplane.js +143 -31
  9. package/dist/nodefony/src/backplane/controlOrder.js +57 -0
  10. package/dist/nodefony/src/backplane/envelope.js +113 -12
  11. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  12. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  13. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  14. package/dist/nodefony/src/server/RealtimeConnection.js +110 -0
  15. package/dist/nodefony/src/server/RealtimeController.js +344 -85
  16. package/dist/nodefony/src/server/RealtimeHub.js +604 -61
  17. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  18. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  19. package/dist/nodefony/src/server/channelPolicyRegistry.js +73 -0
  20. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  21. package/dist/nodefony/src/server/contractRoutes.js +85 -0
  22. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  23. package/dist/nodefony/src/server/frameAdmission.js +168 -0
  24. package/dist/nodefony/src/server/subscribedAck.js +10 -1
  25. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  26. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  27. package/dist/nodefony/src/service/RealtimeService.js +42 -1
  28. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  29. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  30. package/dist/nodefony/testing/backplaneConformance.js +271 -0
  31. package/dist/nodefony/testing/index.js +21 -4
  32. package/dist/types/index.d.ts +34 -7
  33. package/dist/types/nodefony/config/config.d.ts +9 -0
  34. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +86 -6
  35. package/dist/types/nodefony/interfaces/IBackplane.d.ts +34 -50
  36. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  37. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  38. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  39. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  40. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  41. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  42. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  43. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  44. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +79 -16
  45. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +52 -0
  46. package/dist/types/nodefony/src/backplane/envelope.d.ts +34 -0
  47. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  48. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  49. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +17 -0
  50. package/dist/types/nodefony/src/server/RealtimeController.d.ts +93 -13
  51. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +321 -24
  52. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  53. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  54. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +34 -0
  55. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  56. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  57. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  58. package/dist/types/nodefony/src/server/frameAdmission.d.ts +70 -0
  59. package/dist/types/nodefony/src/server/subscribedAck.d.ts +7 -0
  60. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  61. package/dist/types/nodefony/src/service/RealtimeService.d.ts +10 -2
  62. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  63. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  64. package/dist/types/nodefony/testing/backplaneConformance.d.ts +66 -0
  65. package/dist/types/nodefony/testing/index.d.ts +17 -4
  66. package/docs/actions.md +442 -19
  67. package/docs/architecture.md +83 -8
  68. package/docs/cadence-et-contre-pression.md +87 -11
  69. package/docs/canaux-a-membres.md +267 -0
  70. package/docs/cas-d-usage.md +1702 -0
  71. package/docs/configuration.md +137 -17
  72. package/docs/cookbook-chat.md +333 -85
  73. package/docs/index.md +11 -1
  74. package/docs/observabilite.md +71 -14
  75. package/docs/protocole.md +133 -24
  76. package/docs/securite.md +294 -33
  77. package/docs/vocabulaire.md +13 -12
  78. package/package.json +14 -11
@@ -1,7 +1,8 @@
1
- import { isCallable, isRecord, isThenable } from "../guards.js";
2
1
  import resolveBackplaneOriginId from "./originId.js";
3
- import { openBackplaneEnvelope, sealBackplaneEnvelope } from "./envelope.js";
2
+ import { isCallable, isRecord, isThenable } from "../guards.js";
3
+ import { openBackplaneEnvelope, openBackplaneEnvelopeBytes, sealBackplaneEnvelope, sealBackplaneEnvelopeBytes } from "./envelope.js";
4
4
  import { BackplanePublishQueue } from "./publishQueue.js";
5
+ import { containsBytes } from "nodefony";
5
6
  // nodefony/src/backplane/RedisBackplane.ts
6
7
  /**
7
8
  * BASE du canal Redis **dédié** transportant les enveloppes realtime entre pods.
@@ -28,22 +29,18 @@ function resolveRedisChannel(namespace) {
28
29
  return namespace ? `${REDIS_RT_CHANNEL}:${namespace}` : REDIS_RT_CHANNEL;
29
30
  }
30
31
  /**
31
- * Adapte deux clients Redis (publisher + subscriber dédié) au seam
32
- * {@link IRedisBackplaneTransport}. SEUL point de contact avec la lib `redis` —
33
- * et même ici le couplage est **structurel** (aucun import). Le consommateur
34
- * branche typiquement les connexions du module `@nodefony/redis` :
35
- *
36
- * ```ts
37
- * const redis = kernel.get("redis"); // RedisService
38
- * const bp = new RedisBackplane(
39
- * createRedisServiceTransport(redis.getClient("publish")!, redis.getClient("subscribe")!),
40
- * );
41
- * defineRealtimeConfig({ backplane: { driver: "redis" } }, { backplane: bp });
42
- * ```
32
+ * Construit le **sujet binaire** du bus (ADR-0021 S6) : le canal texte suffixé du
33
+ * protocole du codec. Un second sujet, versionné par son nom, que les exemplaires
34
+ * plus anciens n'écoutent pas — le message texte n'est pas touché, et un codec
35
+ * binaire d'un autre format aurait son propre sujet.
43
36
  *
44
- * Le listener Redis est conservé pour un `unsubscribe(channel, listener)` ciblé
45
- * (n'arrache pas d'éventuels autres abonnés du même client).
37
+ * @param redisChannel - le canal texte effectif ({@link resolveRedisChannel}).
38
+ * @param codec - le codec binaire du serveur.
39
+ * @returns le nom du canal Redis qui porte les enveloppes binaires.
46
40
  */
41
+ function resolveRedisBytesChannel(redisChannel, codec) {
42
+ return `${redisChannel}:${codec.protocol}`;
43
+ }
47
44
  /**
48
45
  * Vrai si la valeur expose la surface d'un client Redis publisher — vérifié au
49
46
  * démarrage, sur le client que rend le service `redis`.
@@ -55,27 +52,77 @@ function isRedisPublisher(value) {
55
52
  function isRedisSubscriber(value) {
56
53
  return isRecord(value) && isCallable(value.subscribe) && isCallable(value.unsubscribe);
57
54
  }
55
+ /**
56
+ * Vrai si la valeur expose la surface d'un client abonné capable du mode octets.
57
+ *
58
+ * La forme ne dit pas si le troisième argument est compris : ce garde vaut pour
59
+ * le client que rend le service `redis` (node-redis, qui l'accepte). Un client qui
60
+ * l'ignorerait livrerait du texte au sujet binaire — illisible pour le codec,
61
+ * donc jeté, compté sous un secret (`sealRejectedTotal`), jamais réinjecté.
62
+ */
63
+ function isRedisBytesSubscriber(value) {
64
+ return isRedisSubscriber(value);
65
+ }
58
66
  /** Ramène l'acquittement d'une commande Redis au contrat du transport. */
59
67
  function settled(ack) {
60
68
  return isThenable(ack) ? ack.then(() => void 0) : void 0;
61
69
  }
62
- function createRedisServiceTransport(publisher, subscriber) {
63
- let listener = null;
64
- return {
70
+ /**
71
+ * Adapte deux clients Redis (publisher + subscriber dédié) au seam
72
+ * {@link IRedisBackplaneTransport}. SEUL point de contact avec la lib `redis` —
73
+ * et même ici le couplage est **structurel** (aucun import). Le consommateur
74
+ * branche typiquement les connexions du module `@nodefony/redis` :
75
+ *
76
+ * ```ts
77
+ * const redis = kernel.get("redis"); // RedisService
78
+ * const bp = new RedisBackplane(
79
+ * createRedisServiceTransport(redis.getClient("publish")!, redis.getClient("subscribe")!),
80
+ * );
81
+ * defineRealtimeConfig({ backplane: { driver: "redis" } }, { backplane: bp });
82
+ * ```
83
+ *
84
+ * Chaque listener Redis est conservé, par canal, pour un `unsubscribe(channel,
85
+ * listener)` ciblé (n'arrache pas d'éventuels autres abonnés du même client).
86
+ *
87
+ * @param publisher - connexion de publication.
88
+ * @param subscriber - connexion d'abonnement dédiée.
89
+ * @param bytesSubscriber - la MÊME connexion d'abonnement, vue en mode octets
90
+ * ({@link IRedisBytesSubscriber}) ; absente, le transport n'offre pas de sujet
91
+ * binaire et les octets restent sur le serveur qui les publie.
92
+ * @returns le transport du {@link RedisBackplane}.
93
+ */
94
+ function createRedisServiceTransport(publisher, subscriber, bytesSubscriber) {
95
+ const listeners = /* @__PURE__ */ new Map();
96
+ const transport = {
65
97
  publish(channel, message) {
66
- const ack = publisher.publish(channel, message);
98
+ const ack = publisher.publish(channel, typeof message === "string" ? message : Buffer.from(message.buffer, message.byteOffset, message.byteLength));
67
99
  return isThenable(ack) ? ack : void 0;
68
100
  },
69
101
  subscribe(channel, onMessage) {
70
- listener = (message) => onMessage(message);
71
- return settled(subscriber.subscribe(channel, listener));
102
+ const fn = (message) => onMessage(message);
103
+ listeners.set(channel, {
104
+ bytes: false,
105
+ fn
106
+ });
107
+ return settled(subscriber.subscribe(channel, fn));
72
108
  },
73
109
  unsubscribe(channel) {
74
- const current = listener;
75
- listener = null;
76
- return settled(current ? subscriber.unsubscribe(channel, current) : subscriber.unsubscribe(channel));
110
+ const current = listeners.get(channel);
111
+ listeners.delete(channel);
112
+ if (current === void 0) return settled(subscriber.unsubscribe(channel));
113
+ if (!current.bytes) return settled(subscriber.unsubscribe(channel, current.fn));
114
+ return settled(bytesSubscriber?.unsubscribe(channel, current.fn, true));
77
115
  }
78
116
  };
117
+ if (bytesSubscriber !== void 0) transport.subscribeBytes = (channel, onMessage) => {
118
+ const fn = (message) => onMessage(message);
119
+ listeners.set(channel, {
120
+ bytes: true,
121
+ fn
122
+ });
123
+ return settled(bytesSubscriber.subscribe(channel, fn, true));
124
+ };
125
+ return transport;
79
126
  }
80
127
  /**
81
128
  * Backplane **Redis pub/sub** (P13.5) — implémentation du port {@link IBackplane}
@@ -119,14 +166,23 @@ var RedisBackplane = class RedisBackplane {
119
166
  #secret;
120
167
  /** Borne mémoire des publications en vol (cf {@link BackplanePublishQueue}). */
121
168
  #queue;
169
+ #notice;
170
+ /** Messages reçus dont le sceau est refusé alors qu'un secret est posé (monotone). */
171
+ #sealRejectedTotal = 0;
122
172
  #handler = null;
123
173
  #started = false;
174
+ /**
175
+ * Sujet binaire : son codec et son canal Redis ({@link resolveRedisBytesChannel}) ;
176
+ * `null` tant que {@link enableBytes} n'a pas armé les octets.
177
+ */
178
+ #bytes = null;
124
179
  constructor(transport, originId = resolveBackplaneOriginId(), redisChannel = REDIS_RT_CHANNEL, secret = null, options = {}) {
125
180
  this.#transport = transport;
126
181
  this.originId = originId;
127
182
  this.#redisChannel = redisChannel;
128
183
  this.#secret = secret;
129
- this.#queue = new BackplanePublishQueue(options.maxQueueBytes ?? 8388608, options.onNotice ?? null);
184
+ this.#notice = options.onNotice ?? null;
185
+ this.#queue = new BackplanePublishQueue(options.maxQueueBytes ?? 8388608, this.#notice);
130
186
  }
131
187
  /**
132
188
  * Abonne au canal Redis dédié. Idempotent (le flag est posé avant l'`await` →
@@ -135,7 +191,32 @@ var RedisBackplane = class RedisBackplane {
135
191
  async start() {
136
192
  if (this.#started) return;
137
193
  this.#started = true;
138
- await this.#transport.subscribe(this.#redisChannel, (raw) => this.#ingress(raw));
194
+ await Promise.all([this.#transport.subscribe(this.#redisChannel, (raw) => this.#ingress(raw)), this.#subscribeBytes()]);
195
+ }
196
+ /**
197
+ * Arme le sujet binaire (ADR-0021 S6) : les charges qui contiennent des octets
198
+ * partent en enveloppe encodée par `codec` sur un second canal Redis, et ce
199
+ * canal est écouté. Les charges sans octets gardent le sujet texte, intact pour
200
+ * les exemplaires plus anciens. Idempotent.
201
+ *
202
+ * @param codec - le codec binaire du serveur.
203
+ * @returns `false` si le transport ne sait pas s'abonner en octets.
204
+ */
205
+ enableBytes(codec) {
206
+ if (this.#transport.subscribeBytes === void 0) return false;
207
+ if (this.#bytes !== null) return true;
208
+ this.#bytes = {
209
+ codec,
210
+ channel: resolveRedisBytesChannel(this.#redisChannel, codec)
211
+ };
212
+ if (this.#started) this.#subscribeBytes().catch((err) => this.#notice?.(`backplane : abonnement au sujet binaire en échec — les octets des autres exemplaires ne seront pas reçus (${err instanceof Error ? err.message : String(err)})`, "WARNING"));
213
+ return true;
214
+ }
215
+ /** Abonne le sujet binaire s'il est armé ; sinon rien. */
216
+ async #subscribeBytes() {
217
+ const bytes = this.#bytes;
218
+ if (bytes === null) return;
219
+ await this.#transport.subscribeBytes?.(bytes.channel, (raw) => this.#ingressBytes(raw, bytes.codec));
139
220
  }
140
221
  /**
141
222
  * Propage une publication locale aux autres pods via Redis. NE refait PAS le
@@ -152,6 +233,12 @@ var RedisBackplane = class RedisBackplane {
152
233
  payload,
153
234
  originId: this.originId
154
235
  };
236
+ const bytes = this.#bytes;
237
+ if (bytes !== null && containsBytes(payload)) {
238
+ const bin = sealBackplaneEnvelopeBytes(env, bytes.codec, this.#secret);
239
+ this.#queue.send(bin.byteLength, () => this.#transport.publish(bytes.channel, bin));
240
+ return;
241
+ }
155
242
  const raw = sealBackplaneEnvelope(env, this.#secret);
156
243
  this.#queue.send(Buffer.byteLength(raw), () => this.#transport.publish(this.#redisChannel, raw));
157
244
  }
@@ -163,19 +250,42 @@ var RedisBackplane = class RedisBackplane {
163
250
  * Ouverture d'enveloppe (parse + vérification du sceau) + anti-echo + délégation.
164
251
  * Robuste par construction : un message malformé, non scellé alors qu'un secret
165
252
  * est exigé, ou au sceau invalide, est **ignoré** — jamais réinjecté, jamais fatal.
253
+ * Sous un secret, chaque rejet est compté (`sealRejectedTotal`) et journalisé la
254
+ * première fois puis une fois sur cent (ADR-0021 S6) : une rotation du secret ou
255
+ * un producteur mal configuré ne perd plus de messages en silence.
166
256
  */
167
257
  #ingress(raw) {
168
258
  const msg = openBackplaneEnvelope(raw, this.#secret);
169
- if (msg === null) return;
259
+ if (msg === null) {
260
+ if (this.#secret !== null) this.#sealRejected();
261
+ return;
262
+ }
263
+ if (msg.originId === this.originId) return;
264
+ this.#handler?.(msg);
265
+ }
266
+ /** Même tri que {@link #ingress}, sur le sujet binaire : décodage, sceau, anti-echo. */
267
+ #ingressBytes(raw, codec) {
268
+ const msg = openBackplaneEnvelopeBytes(raw, codec, this.#secret);
269
+ if (msg === null) {
270
+ if (this.#secret !== null) this.#sealRejected();
271
+ return;
272
+ }
170
273
  if (msg.originId === this.originId) return;
171
274
  this.#handler?.(msg);
172
275
  }
276
+ /** Compte un sceau refusé ; annonce le premier, puis un sur cent (pas d'inondation du journal). */
277
+ #sealRejected() {
278
+ this.#sealRejectedTotal += 1;
279
+ const n = this.#sealRejectedTotal;
280
+ if (n !== 1 && n % 100 !== 0) return;
281
+ this.#notice?.(`backplane : ${n} message(s) reçu(s) au sceau refusé, JETÉS. Causes : rotation du secret en cours (des exemplaires n'ont pas le même NF_REALTIME_BACKPLANE_SECRET), producteur mal configuré, ou écriture tierce dans le bus. Compteur : sealRejectedTotal.`, "WARNING");
282
+ }
173
283
  /** Désabonne du canal Redis et détache le handler. Idempotent. */
174
284
  async stop() {
175
285
  this.#handler = null;
176
286
  if (!this.#started) return;
177
287
  this.#started = false;
178
- await this.#transport.unsubscribe(this.#redisChannel);
288
+ await Promise.all([this.#transport.unsubscribe(this.#redisChannel), this.#bytes !== null ? this.#transport.unsubscribe(this.#bytes.channel) : void 0]);
179
289
  }
180
290
  describe() {
181
291
  return {
@@ -185,8 +295,10 @@ var RedisBackplane = class RedisBackplane {
185
295
  crossPod: true,
186
296
  channel: this.#redisChannel,
187
297
  sealed: this.#secret !== null,
188
- queue: this.#queue.describe()
298
+ ...this.#secret !== null && { sealRejectedTotal: this.#sealRejectedTotal },
299
+ queue: this.#queue.describe(),
300
+ ...this.#bytes !== null && { bytes: true }
189
301
  };
190
302
  }
191
303
  };
192
- export { REDIS_RT_CHANNEL, RedisBackplane, RedisBackplane as default, createRedisServiceTransport, isRedisPublisher, isRedisSubscriber, resolveRedisChannel };
304
+ export { REDIS_RT_CHANNEL, RedisBackplane, RedisBackplane as default, createRedisServiceTransport, isRedisBytesSubscriber, isRedisPublisher, isRedisSubscriber, resolveRedisBytesChannel, resolveRedisChannel };
@@ -0,0 +1,57 @@
1
+ import { ownField } from "nodefony";
2
+ // nodefony/src/backplane/controlOrder.ts
3
+ /**
4
+ * Ordres de contrôle entre serveurs — la LISTE FERMÉE de ce qui circule sur le
5
+ * canal `PLATFORM_BUS_CHANNELS.control` (ADR-0022 §6 décision 4).
6
+ *
7
+ * Un ordre ne fait qu'ENLEVER : terminer un canal, éjecter un compte d'un
8
+ * canal. Aucun n'admet — seul `allow` fait entrer. Il voyage dans la charge
9
+ * d'un message ordinaire du bus, donc sous le sceau de l'enveloppe
10
+ * (`envelope.ts#sealBackplaneEnvelope`), sans champ neuf dans l'enveloppe ni
11
+ * méthode neuve dans `IBackplane` : un driver tiers le transporte sans le savoir.
12
+ */
13
+ /** Longueur maximale d'un champ texte d'un ordre (nom de canal, identifiant de compte). */
14
+ const MAX_CONTROL_FIELD_LENGTH = 1024;
15
+ function isBoundedText(value) {
16
+ return typeof value === "string" && value.length > 0 && value.length <= 1024;
17
+ }
18
+ /**
19
+ * Lit un ordre de contrôle dans une charge venue du bus — `null` si la forme n'est pas celle d'un ordre connu.
20
+ *
21
+ * Une seule garde pour les deux bords : l'émetteur la passe avant de publier
22
+ * (il n'émet jamais ce que ses pairs refuseraient), le récepteur avant
23
+ * d'appliquer. Lecture par champs PROPRES (`ownField`) : une charge dont le
24
+ * prototype porterait `op` ne passe pas. Un champ inconnu est toléré — c'est
25
+ * ce qui laisse une version suivante ajouter un champ sans faire refuser ses
26
+ * ordres pendant un déploiement progressif ; un champ CONNU de forme fausse
27
+ * fait refuser l'ordre entier.
28
+ *
29
+ * @param payload - la charge du message reçu (forme inconnue).
30
+ * @returns l'ordre, ou `null`.
31
+ */
32
+ function readControlOrder(payload) {
33
+ const channel = ownField(payload, "channel");
34
+ if (!isBoundedText(channel)) return null;
35
+ const op = ownField(payload, "op");
36
+ if (op === "close") return {
37
+ op,
38
+ channel
39
+ };
40
+ if (op !== "evictUser") return null;
41
+ const user = ownField(payload, "user");
42
+ if (!isBoundedText(user)) return null;
43
+ const generation = ownField(payload, "generation");
44
+ if (generation === void 0) return {
45
+ op,
46
+ channel,
47
+ user
48
+ };
49
+ if (typeof generation !== "number" || !Number.isSafeInteger(generation) || generation < 0) return null;
50
+ return {
51
+ op,
52
+ channel,
53
+ user,
54
+ generation
55
+ };
56
+ }
57
+ export { MAX_CONTROL_FIELD_LENGTH, readControlOrder };
@@ -1,3 +1,4 @@
1
+ import { ownField } from "nodefony";
1
2
  import { createHmac, timingSafeEqual } from "node:crypto";
2
3
  // nodefony/src/backplane/envelope.ts
3
4
  /**
@@ -25,10 +26,19 @@ import { createHmac, timingSafeEqual } from "node:crypto";
25
26
  * canal (`chat:` → `nodefony:audit`), ni substitution de charge.
26
27
  *
27
28
  * Hors portée (assumé, cf sémantique at-most-once du port) : le **rejeu**. Un
28
- * tiers qui lit le bus peut re-publier un message scellé intact ; l'effet se
29
- * limite à re-diffuser une charge déjà diffusée sur un canal déjà broadcast, que
30
- * le client realtime re-synchronise. Un anti-rejeu (horodatage + fenêtre) exigerait
31
- * des horloges alignées entre pods pour un gain nul face à ce modèle de menace.
29
+ * tiers qui lit le bus peut re-publier un message scellé intact. Pour une
30
+ * publication, l'effet se limite à re-diffuser une charge déjà diffusée sur un
31
+ * canal déjà broadcast. Pour un **ordre** (fermer, éjecter), le rejeu RECOUPE —
32
+ * l'effet reste soustractif, jamais une admission (ADR-0022 §6 d4). Une fin vise
33
+ * un canal qui porte son instance (`room:42:<partie>`) : rejouée, elle ne désigne
34
+ * plus rien de vivant. Une éjection vise `(canal, compte)` : rejouée sur un canal
35
+ * DURABLE, elle recoupe le compte à chaque réadmission — risque accepté en 10.0,
36
+ * borné (écrire sur le bus est requis ; changer le secret rend caducs les ordres
37
+ * captés), parade réservée par le champ optionnel `generation` de l'ordre. Un
38
+ * anti-rejeu par horodatage exigerait des horloges alignées entre pods.
39
+ *
40
+ * La forme signée est la re-sérialisation de la charge par le récepteur ; elle
41
+ * n'est PAS promise comme contrat à un producteur écrit dans un autre langage.
32
42
  */
33
43
  /** Nom du champ portant le sceau dans l'enveloppe sérialisée. */
34
44
  const SEAL_FIELD = "sig";
@@ -66,9 +76,7 @@ function sealBackplaneEnvelope(msg, secret) {
66
76
  }
67
77
  /** Type-guard d'enveloppe — narrowing sûr d'un message brut venu du bus. */
68
78
  function isEnvelope(m) {
69
- if (typeof m !== "object" || m === null) return false;
70
- const e = m;
71
- return typeof e.channel === "string" && typeof e.originId === "string";
79
+ return typeof ownField(m, "channel") === "string" && typeof ownField(m, "originId") === "string";
72
80
  }
73
81
  /**
74
82
  * Parse et **vérifie** une enveloppe reçue du bus partagé.
@@ -93,17 +101,110 @@ function openBackplaneEnvelope(raw, secret) {
93
101
  if (!isEnvelope(parsed)) return null;
94
102
  const msg = {
95
103
  channel: parsed.channel,
96
- payload: parsed.payload,
104
+ payload: parsed.payload === null ? void 0 : parsed.payload,
97
105
  originId: parsed.originId
98
106
  };
99
107
  if (!secret) return msg;
100
108
  const provided = parsed[SEAL_FIELD];
101
109
  if (typeof provided !== "string") return null;
102
110
  const payloadText = JSON.stringify(msg.payload);
103
- const expected = seal(JSON.stringify(msg.originId), JSON.stringify(msg.channel), payloadText ?? "null", secret);
111
+ return sameSeal(provided, seal(JSON.stringify(msg.originId), JSON.stringify(msg.channel), payloadText ?? "null", secret)) ? msg : null;
112
+ }
113
+ /**
114
+ * Compare deux sceaux base64url à temps constant.
115
+ *
116
+ * Longueurs différentes → `timingSafeEqual` lève ; on tranche avant (la longueur
117
+ * du sceau n'est pas un secret : c'est celle de SHA-256).
118
+ */
119
+ function sameSeal(provided, expected) {
104
120
  const a = Buffer.from(provided, "base64url");
105
121
  const b = Buffer.from(expected, "base64url");
106
- if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
107
- return msg;
122
+ return a.length === b.length && timingSafeEqual(a, b);
123
+ }
124
+ /**
125
+ * Sceau d'une enveloppe BINAIRE : la même chaîne canonique que le texte, où le
126
+ * troisième fragment est la charge ENCODÉE par le codec au lieu de son JSON.
127
+ * Les deux premiers fragments restent des chaînes JSON échappées : aucun
128
+ * décalage de frontière n'est possible, et la charge, en dernier, n'a pas de
129
+ * frontière à garder.
130
+ */
131
+ function sealBytes(originId, channel, payloadBytes, secret) {
132
+ return createHmac("sha256", secret).update(`${JSON.stringify(originId)}\n${JSON.stringify(channel)}\n`).update(payloadBytes).digest("base64url");
133
+ }
134
+ /** Encode par le codec, et refuse un codec texte : le sujet binaire porte des octets. */
135
+ function encodeBytes(codec, value) {
136
+ const out = codec.encode(value);
137
+ if (typeof out === "string") throw new TypeError(`backplane : le codec « ${codec.protocol} » rend du texte — le sujet binaire du bus exige un codec binaire`);
138
+ return out;
139
+ }
140
+ /**
141
+ * Encode un message backplane pour le **sujet binaire** du bus (ADR-0021 S6) :
142
+ * la MÊME enveloppe `{ channel, payload, originId }`, encodée par le codec
143
+ * binaire du serveur, octets natifs dans `payload`, scellée si un secret est
144
+ * fourni.
145
+ *
146
+ * Scellée, la charge est encodée deux fois — une pour le sceau, une dans
147
+ * l'enveloppe : le codec générique ne permet pas d'assembler l'enveloppe à
148
+ * partir de fragments déjà encodés, comme le fait la voie texte.
149
+ *
150
+ * @param msg - message à transporter (canal, charge, origine).
151
+ * @param codec - le codec binaire du serveur (`CBOR_FRAME_CODEC` de `nodefony/cbor`).
152
+ * @param secret - secret partagé entre pods ; `null`/`undefined` → enveloppe nue.
153
+ * @returns les octets à publier sur le sujet binaire.
154
+ * @throws TypeError si le codec rend du texte.
155
+ */
156
+ function sealBackplaneEnvelopeBytes(msg, codec, secret) {
157
+ const payload = msg.payload ?? null;
158
+ if (!secret) return encodeBytes(codec, {
159
+ channel: msg.channel,
160
+ payload,
161
+ originId: msg.originId
162
+ });
163
+ const sig = sealBytes(msg.originId, msg.channel, encodeBytes(codec, payload), secret);
164
+ return encodeBytes(codec, {
165
+ channel: msg.channel,
166
+ payload,
167
+ originId: msg.originId,
168
+ [SEAL_FIELD]: sig
169
+ });
170
+ }
171
+ /**
172
+ * Décode et **vérifie** une enveloppe reçue sur le sujet binaire du bus — même
173
+ * règle que {@link openBackplaneEnvelope} : fail-closed strict sous un secret,
174
+ * aucun downgrade en retirant `sig`.
175
+ *
176
+ * Le sceau se recalcule sur le RÉ-ENCODAGE de la charge décodée, comme la voie
177
+ * texte le fait sur sa re-sérialisation : le codec CBOR du cœur rend la forme la
178
+ * plus courte de chaque valeur, donc émetteur et récepteur signent les mêmes
179
+ * octets.
180
+ *
181
+ * @param raw - octets lus sur le sujet binaire.
182
+ * @param codec - le codec binaire du serveur.
183
+ * @param secret - secret partagé attendu, ou `null`/`undefined` si aucun.
184
+ * @returns le message vérifié, ou `null` s'il doit être ignoré.
185
+ */
186
+ function openBackplaneEnvelopeBytes(raw, codec, secret) {
187
+ let parsed;
188
+ try {
189
+ parsed = codec.decode(raw);
190
+ } catch {
191
+ return null;
192
+ }
193
+ if (!isEnvelope(parsed)) return null;
194
+ const msg = {
195
+ channel: parsed.channel,
196
+ payload: parsed.payload === null ? void 0 : parsed.payload,
197
+ originId: parsed.originId
198
+ };
199
+ if (!secret) return msg;
200
+ const provided = parsed[SEAL_FIELD];
201
+ if (typeof provided !== "string") return null;
202
+ let payloadBytes;
203
+ try {
204
+ payloadBytes = encodeBytes(codec, parsed.payload ?? null);
205
+ } catch {
206
+ return null;
207
+ }
208
+ return sameSeal(provided, sealBytes(msg.originId, msg.channel, payloadBytes, secret)) ? msg : null;
108
209
  }
109
- export { openBackplaneEnvelope, sealBackplaneEnvelope };
210
+ export { openBackplaneEnvelope, openBackplaneEnvelopeBytes, sealBackplaneEnvelope, sealBackplaneEnvelopeBytes };
@@ -34,6 +34,7 @@ function mergeClusterHealth(instances, ts = Date.now()) {
34
34
  fanoutTotal: 0,
35
35
  inboundTotal: 0,
36
36
  ingressRejectedTotal: 0,
37
+ localOnlyTotal: 0,
37
38
  connectionCount: 0,
38
39
  bytesSentTotal: 0,
39
40
  messagesSentTotal: 0,
@@ -52,6 +53,7 @@ function mergeClusterHealth(instances, ts = Date.now()) {
52
53
  totals.fanoutTotal += h.fanoutTotal;
53
54
  totals.inboundTotal += h.inboundTotal;
54
55
  totals.ingressRejectedTotal += h.ingressRejectedTotal;
56
+ totals.localOnlyTotal += h.localOnlyTotal;
55
57
  totals.connectionCount += h.connectionCount;
56
58
  totals.bytesSentTotal += h.bytesSentTotal;
57
59
  totals.messagesSentTotal += h.messagesSentTotal;
@@ -1,5 +1,23 @@
1
1
  // nodefony/src/server/AnonymousRealtimeToken.ts
2
2
  /**
3
+ * Identité d'admission de l'ANONYME — gelée et partagée par toutes les connexions non authentifiées (0 allocation par connexion).
4
+ *
5
+ * Porte l'invariant de l'ADR-0021 §S10 : anonyme ⇔ `authenticated === false`,
6
+ * `id === null` et `issuer === null`. Un canal qui déclare `allow` refuse
7
+ * cette identité d'office, sauf option explicite `anonymous: true` — ce qui
8
+ * garantit que `user:${who.id}` ne vaut jamais `"user:null"`.
9
+ */
10
+ const ANONYMOUS_SUBSCRIBER_IDENTITY = Object.freeze({
11
+ type: "anonymous",
12
+ authenticated: false,
13
+ id: null,
14
+ userIdentifier: "anonymous",
15
+ roles: Object.freeze(["ROLE_ANONYMOUS"]),
16
+ scopes: Object.freeze([]),
17
+ issuer: null,
18
+ account: false
19
+ });
20
+ /**
3
21
  * Token realtime ANONYME — fallback Zero Trust quand aucun matcher
4
22
  * `IRealtimeAuthenticatorMatcher` ne capture le handshake.
5
23
  *
@@ -26,6 +44,9 @@ const ANONYMOUS_REALTIME_TOKEN = Object.freeze({
26
44
  getScopes() {
27
45
  return [];
28
46
  },
29
- getAttribute(_key) {}
47
+ getAttribute(_key) {},
48
+ getIdentity() {
49
+ return ANONYMOUS_SUBSCRIBER_IDENTITY;
50
+ }
30
51
  });
31
- export { ANONYMOUS_REALTIME_TOKEN, ANONYMOUS_REALTIME_TOKEN as default };
52
+ export { ANONYMOUS_REALTIME_TOKEN, ANONYMOUS_REALTIME_TOKEN as default, ANONYMOUS_SUBSCRIBER_IDENTITY };
@@ -0,0 +1,75 @@
1
+ import { RealtimeController } from "./RealtimeController.js";
2
+ // nodefony/src/server/ContractRealtimeController.ts
3
+ /**
4
+ * Socle commun des deux formes de {@link ContractRealtimeController} : l'étiquette
5
+ * de contrat et les canaux annoncés. Les actions y gardent le défaut de la base
6
+ * (`{}`) — un contrat SANS `serverMethods` n'a rien à écrire.
7
+ *
8
+ * Ne s'étend pas directement : on étend {@link ContractRealtimeController}, qui
9
+ * choisit cette forme ou la forme stricte selon le contrat.
10
+ *
11
+ * @typeParam C - le contrat de l'application.
12
+ */
13
+ var ContractRealtimeControllerBase = class extends RealtimeController {
14
+ /** Canaux annoncés au handshake, contraints aux noms de `serverToClient`. */
15
+ realtimeChannels() {
16
+ return [];
17
+ }
18
+ };
19
+ /**
20
+ * Forme stricte de {@link ContractRealtimeController}, retenue quand le contrat
21
+ * déclare des `serverMethods` : `realtimeActions()` y devient ABSTRAITE et typée
22
+ * par le contrat — la seule construction qui fait d'une méthode OUBLIÉE une
23
+ * erreur de compilation (un décorateur ne voit jamais la classe entière).
24
+ *
25
+ * Ne s'étend pas directement : on étend {@link ContractRealtimeController}.
26
+ *
27
+ * @typeParam C - le contrat de l'application.
28
+ */
29
+ var ContractRealtimeControllerStrict = class extends ContractRealtimeControllerBase {};
30
+ /**
31
+ * Base d'un contrôleur qui IMPLÉMENTE un contrat temps réel — la porte du
32
+ * serveur. Le contrat s'écrit UNE fois, sur `extends` ; la direction du serveur
33
+ * en est dérivée (il émet `serverToClient`, écoute `clientToServer`, appelle
34
+ * `clientMethods`) et il DOIT servir chaque méthode de `serverMethods`.
35
+ *
36
+ * Quand le contrat déclare des `serverMethods`, `realtimeActions()` est
37
+ * abstraite et typée par lui : une méthode OUBLIÉE ne compile pas. Un contrat
38
+ * SANS `serverMethods` (tableau de bord, chat qui ne fait que diffuser) n'a rien
39
+ * à écrire. Les entrées client (`realtimeInbound()`) restent facultatives canal
40
+ * par canal : accepter une entrée est une permission, pas une promesse. Les
41
+ * politiques d'accès se posent sur la classe par `@RealtimePolicy`, qui lit le
42
+ * contrat sur cette classe.
43
+ *
44
+ * Le fichier du contrat, fait de types seuls, est partagé avec le navigateur, qui
45
+ * entre par `NodefonySocket.fromContract<C>()`.
46
+ *
47
+ * Un contrat qui déclare des `serverRoutes` (routes du pont, typées côté client
48
+ * par le schéma que la route valide) se double d'une table statique de VALEURS,
49
+ * `static readonly serverRoutes = { … } satisfies ContractRouteSchemas<C>` : le
50
+ * compilateur y exige chaque route du contrat avec son schéma, et le démarrage
51
+ * vérifie que chacune est montée, servie par la socket et valide LE MÊME schéma
52
+ * (`BootConfigurationError` sinon). Sans cette table, aucun contrôle.
53
+ *
54
+ * @typeParam C - le contrat de l'application.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * @RealtimePolicy({ "chat:send": { roles: ["ROLE_USER"] } })
59
+ * class ChatRt extends ContractRealtimeController<ChatContract> {
60
+ * protected realtimeActions() {
61
+ * return { "chat:send": (p: { text: string }) => ({ id: this.store(p.text) }) };
62
+ * }
63
+ * }
64
+ *
65
+ * class DashRt extends ContractRealtimeController<DashContract> {} // sans serverMethods
66
+ *
67
+ * class ChatRoutesRt extends ContractRealtimeController<ChatContract> {
68
+ * static readonly serverRoutes = {
69
+ * "POST /chat/send": { body: SendBody }, // le schéma de la route, pas une copie
70
+ * } satisfies ContractRouteSchemas<ChatContract>;
71
+ * }
72
+ * ```
73
+ */
74
+ const ContractRealtimeController = ContractRealtimeControllerStrict;
75
+ export { ContractRealtimeController, ContractRealtimeControllerBase, ContractRealtimeControllerStrict };