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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +55 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +91 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
  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 +146 -41
  9. package/dist/nodefony/src/backplane/controlOrder.js +86 -0
  10. package/dist/nodefony/src/backplane/envelope.js +505 -27
  11. package/dist/nodefony/src/backplane/ingress.js +93 -0
  12. package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
  13. package/dist/nodefony/src/backplane/publishQueue.js +97 -3
  14. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  15. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  16. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  17. package/dist/nodefony/src/server/RealtimeConnection.js +135 -0
  18. package/dist/nodefony/src/server/RealtimeController.js +409 -107
  19. package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
  20. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  21. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  22. package/dist/nodefony/src/server/channelPolicyRegistry.js +95 -0
  23. package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
  24. package/dist/nodefony/src/server/channelPresence.js +488 -0
  25. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  26. package/dist/nodefony/src/server/contractRoutes.js +75 -0
  27. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  28. package/dist/nodefony/src/server/frameAdmission.js +160 -0
  29. package/dist/nodefony/src/server/presenceBus.js +486 -0
  30. package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
  31. package/dist/nodefony/src/server/remotePresence.js +541 -0
  32. package/dist/nodefony/src/server/subscribedAck.js +13 -1
  33. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  34. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  35. package/dist/nodefony/src/service/RealtimeService.js +51 -1
  36. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  37. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  38. package/dist/nodefony/testing/backplaneConformance.js +347 -0
  39. package/dist/nodefony/testing/index.js +43 -12
  40. package/dist/nodefony/testing/presenceClient.js +54 -0
  41. package/dist/types/index.d.ts +39 -7
  42. package/dist/types/nodefony/config/config.d.ts +9 -0
  43. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
  44. package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
  45. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  46. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  47. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  48. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  49. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  50. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  51. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  52. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  53. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +89 -18
  54. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
  55. package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
  56. package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
  57. package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
  58. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
  59. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  60. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  61. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +20 -0
  62. package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
  63. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
  64. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  65. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  66. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +41 -0
  67. package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
  68. package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
  69. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  70. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  71. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  72. package/dist/types/nodefony/src/server/frameAdmission.d.ts +66 -0
  73. package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
  74. package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
  75. package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
  76. package/dist/types/nodefony/src/server/subscribedAck.d.ts +10 -0
  77. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  78. package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -2
  79. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  80. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  81. package/dist/types/nodefony/testing/backplaneConformance.d.ts +78 -0
  82. package/dist/types/nodefony/testing/index.d.ts +13 -2
  83. package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
  84. package/docs/actions.md +420 -18
  85. package/docs/architecture.md +101 -11
  86. package/docs/cadence-et-contre-pression.md +87 -11
  87. package/docs/canaux-a-membres.md +277 -0
  88. package/docs/cas-d-usage.md +1703 -0
  89. package/docs/configuration.md +175 -19
  90. package/docs/cookbook-chat.md +701 -224
  91. package/docs/index.md +11 -1
  92. package/docs/observabilite.md +97 -21
  93. package/docs/protocole.md +142 -33
  94. package/docs/securite.md +347 -41
  95. package/docs/vocabulaire.md +13 -12
  96. package/package.json +16 -13
@@ -1,4 +1,5 @@
1
- import { createHmac, timingSafeEqual } from "node:crypto";
1
+ import { ownField } from "nodefony";
2
+ import { createHmac, createSecretKey, timingSafeEqual } from "node:crypto";
2
3
  // nodefony/src/backplane/envelope.ts
3
4
  /**
4
5
  * **Sceau d'enveloppe backplane** — authenticité des messages transportés sur un
@@ -25,25 +26,211 @@ 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
+ * **Aucune reconversion de la charge** (ADR-0021 S6) : à l'envoi, la charge est
41
+ * encodée UNE fois et l'enveloppe assemblée par fragments ; à la réception, le
42
+ * sceau se vérifie sur la TRANCHE reçue de la charge, et l'écho d'un pod se
43
+ * reconnaît à la fin du message, sans le décoder. Une forme différente (producteur
44
+ * tiers : clés dans un autre ordre, espaces, nombres non canoniques) retombe sur la
45
+ * vérification par re-sérialisation : on accepte strictement plus qu'avant, jamais
46
+ * moins.
47
+ *
48
+ * Pourquoi lire la tranche reçue est sûr : le sceau valide un triplet (origine,
49
+ * canal, texte de la charge) émis par un pair ; la forme vérifiée autour de la
50
+ * tranche est exactement celle qu'il écrit, donc le message reçu EST ce message
51
+ * émis, octet pour octet — et son décodage, déterministe, rend la charge signée.
32
52
  */
33
53
  /** Nom du champ portant le sceau dans l'enveloppe sérialisée. */
34
54
  const SEAL_FIELD = "sig";
55
+ /** Longueur d'un sceau : HMAC-SHA256 (32 octets) en base64url, sans remplissage. */
56
+ const SEAL_CHARS = 43;
57
+ const TEXT_HEAD = "{\"channel\":";
58
+ const TEXT_PAYLOAD_KEY = ",\"payload\":";
59
+ const TEXT_ORIGIN_KEY = ",\"originId\":";
60
+ const TEXT_SEAL_KEY = `,"${SEAL_FIELD}":"`;
61
+ const TEXT_SEAL_END = "\"}";
62
+ /**
63
+ * Guillemet fermant du canal suivi de la clé de charge. Cette suite ne peut
64
+ * apparaître DANS une chaîne JSON — un `"` y est toujours échappé — donc sa
65
+ * première occurrence après l'ouverture du canal en marque la fin.
66
+ */
67
+ const TEXT_CHANNEL_CLOSE = `"${TEXT_PAYLOAD_KEY}`;
68
+ const TEXT_HEAD_LEN = 11;
69
+ const TEXT_PAYLOAD_KEY_LEN = 11;
70
+ const TEXT_ORIGIN_KEY_LEN = 12;
71
+ const TEXT_SEAL_KEY_LEN = TEXT_SEAL_KEY.length;
72
+ const TEXT_SEAL_END_LEN = 2;
73
+ const TEXT_CHANNEL_CLOSE_LEN = TEXT_CHANNEL_CLOSE.length;
74
+ /** `"` — ouvre la chaîne JSON du canal. */
75
+ const QUOTE = 34;
76
+ /** En-tête d'une map CBOR de 3 entrées (enveloppe nue). */
77
+ const CBOR_MAP_3 = 163;
78
+ /** En-tête d'une map CBOR de 4 entrées (enveloppe scellée). */
79
+ const CBOR_MAP_4 = 164;
80
+ /** Type majeur 3 (chaîne de texte), longueur dans l'octet initial (< 24). */
81
+ const CBOR_TEXT = 96;
82
+ /** Chaîne de texte, longueur sur 1 / 2 / 4 octets qui suivent. */
83
+ const CBOR_TEXT_1 = 120;
84
+ const CBOR_TEXT_2 = 121;
85
+ const CBOR_TEXT_4 = 122;
86
+ /** Taille de l'en-tête CBOR d'une chaîne de texte de `len` octets (forme la plus courte). */
87
+ function cborTextHeadSize(len) {
88
+ if (len < 24) return 1;
89
+ if (len < 256) return 2;
90
+ if (len < 65536) return 3;
91
+ return 5;
92
+ }
93
+ /** Écrit l'en-tête CBOR d'une chaîne de `len` octets ; rend la position suivante. */
94
+ function writeCborTextHead(out, at, len) {
95
+ if (len < 24) {
96
+ out[at] = CBOR_TEXT + len;
97
+ return at + 1;
98
+ }
99
+ if (len < 256) {
100
+ out[at] = CBOR_TEXT_1;
101
+ out[at + 1] = len;
102
+ return at + 2;
103
+ }
104
+ if (len < 65536) {
105
+ out[at] = CBOR_TEXT_2;
106
+ out[at + 1] = len >>> 8;
107
+ out[at + 2] = len & 255;
108
+ return at + 3;
109
+ }
110
+ out[at] = CBOR_TEXT_4;
111
+ out[at + 1] = len >>> 24;
112
+ out[at + 2] = len >>> 16 & 255;
113
+ out[at + 3] = len >>> 8 & 255;
114
+ out[at + 4] = len & 255;
115
+ return at + 5;
116
+ }
117
+ /** L'en-tête CBOR d'une chaîne de `len` octets se trouve-t-il à `at` (forme la plus courte) ? */
118
+ function cborTextHeadAt(raw, at, len) {
119
+ if (len < 24) return raw[at] === CBOR_TEXT + len;
120
+ if (len < 256) return raw[at] === CBOR_TEXT_1 && raw[at + 1] === len;
121
+ if (len < 65536) return raw[at] === CBOR_TEXT_2 && raw[at + 1] === len >>> 8 && raw[at + 2] === (len & 255);
122
+ return raw[at] === CBOR_TEXT_4 && raw[at + 1] === len >>> 24 && raw[at + 2] === (len >>> 16 & 255) && raw[at + 3] === (len >>> 8 & 255) && raw[at + 4] === (len & 255);
123
+ }
124
+ /**
125
+ * Longueur de la chaîne CBOR dont l'en-tête est à `at`, et taille de cet en-tête,
126
+ * écrites dans `into` (`[longueur, taille]`) ; `false` si ce n'est pas une chaîne
127
+ * de texte à longueur définie.
128
+ */
129
+ function readCborTextHead(raw, at, into) {
130
+ const b = raw[at];
131
+ if (b === void 0) return false;
132
+ if (b >= CBOR_TEXT && b < CBOR_TEXT_1) {
133
+ into[0] = b - CBOR_TEXT;
134
+ into[1] = 1;
135
+ return true;
136
+ }
137
+ if (b === CBOR_TEXT_1 && at + 1 < raw.length) {
138
+ into[0] = raw[at + 1] ?? 0;
139
+ into[1] = 2;
140
+ return true;
141
+ }
142
+ if (b === CBOR_TEXT_2 && at + 2 < raw.length) {
143
+ into[0] = (raw[at + 1] ?? 0) << 8 | (raw[at + 2] ?? 0);
144
+ into[1] = 3;
145
+ return true;
146
+ }
147
+ if (b === CBOR_TEXT_4 && at + 4 < raw.length) {
148
+ into[0] = ((raw[at + 1] ?? 0) << 24 >>> 0) + ((raw[at + 2] ?? 0) << 16 | (raw[at + 3] ?? 0) << 8 | (raw[at + 4] ?? 0));
149
+ into[1] = 5;
150
+ return true;
151
+ }
152
+ return false;
153
+ }
154
+ /** Chaîne CBOR complète (en-tête + octets UTF-8) — pour les fragments fixes. */
155
+ function cborText(value) {
156
+ const len = Buffer.byteLength(value, "utf8");
157
+ const out = new Uint8Array(cborTextHeadSize(len) + len);
158
+ Buffer.from(out.buffer, out.byteOffset, out.byteLength).write(value, writeCborTextHead(out, 0, len), "utf8");
159
+ return out;
160
+ }
161
+ const CBOR_KEY_CHANNEL = cborText("channel");
162
+ const CBOR_KEY_PAYLOAD = cborText("payload");
163
+ const CBOR_KEY_ORIGIN = cborText("originId");
164
+ const CBOR_KEY_SEAL = cborText(SEAL_FIELD);
165
+ /** En-tête de la chaîne du sceau : 43 caractères ASCII. */
166
+ const CBOR_SEAL_HEAD = Uint8Array.of(CBOR_TEXT_1, SEAL_CHARS);
167
+ /** Octets fixes de la queue scellée, après la chaîne de l'origine. */
168
+ const CBOR_SEAL_TAIL_LEN = CBOR_KEY_SEAL.length + CBOR_SEAL_HEAD.length + SEAL_CHARS;
169
+ /** `frag` est-il présent tel quel dans `raw` à la position `at` ? Aucune allocation. */
170
+ function bytesAt(raw, at, frag) {
171
+ if (at < 0 || at + frag.length > raw.length) return false;
172
+ for (let i = 0; i < frag.length; i += 1) if (raw[at + i] !== frag[i]) return false;
173
+ return true;
174
+ }
175
+ /** Vue `Buffer` sur les mêmes octets (aucune copie) — pour les lectures de chaînes. */
176
+ function asBuffer(raw) {
177
+ return Buffer.isBuffer(raw) ? raw : Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength);
178
+ }
35
179
  /**
36
- * Sceau HMAC-SHA256 (base64url) d'un triplet **déjà sérialisé**. Le `\n` sépare
37
- * trois fragments JSON : les deux premiers sont des chaînes échappées, donc
38
- * aucun décalage de frontière n'est possible entre les parties.
180
+ * Début de la chaîne canonique : `originId\nchannel\n`, fragments JSON. Les deux
181
+ * premiers sont des chaînes échappées : aucun décalage de frontière n'est
182
+ * possible, et la charge, en dernier, n'a pas de frontière à garder.
183
+ */
184
+ function sealHead(originIdJson, channelJson) {
185
+ return `${originIdJson}\n${channelJson}\n`;
186
+ }
187
+ /** Dernière clé préparée et son secret (un serveur n'en a qu'un : la case unique suffit). */
188
+ let preparedSecret = null;
189
+ let preparedKey = null;
190
+ /**
191
+ * Clé HMAC préparée UNE fois par secret : une chaîne passée à `createHmac` est
192
+ * réencodée à chaque appel (mesuré : ~150 ns par sceau). Mêmes octets de clé — le
193
+ * secret en UTF-8, comme `createHmac` l'aurait fait.
194
+ */
195
+ function keyOf(secret) {
196
+ if (secret !== preparedSecret || preparedKey === null) {
197
+ preparedKey = createSecretKey(Buffer.from(secret, "utf8"));
198
+ preparedSecret = secret;
199
+ }
200
+ return preparedKey;
201
+ }
202
+ /**
203
+ * Sceau (HMAC-SHA256 en base64url) de la chaîne canonique — un seul calcul.
39
204
  *
40
- * Prend les fragments et non le message : la charge n'est sérialisée **qu'une
41
- * fois** par publication (elle sert et au sceau et à l'enveloppe finale) — sur
42
- * un canal de fan-out, une seconde sérialisation par message se paierait à
43
- * chaque diffusion.
205
+ * Une charge TEXTE est concaténée à l'en-tête : un seul passage vers le moteur de
206
+ * hachage coûte moins qu'une concaténation (mesuré : ~250 ns de moins) ; des
207
+ * OCTETS s'ajoutent tels quels, sans copie. Rendu en base64url, jamais en
208
+ * `Buffer` brut : allouer le `Buffer` du résultat coûte plus que de l'encoder
209
+ * (mesuré : ~900 ns).
44
210
  */
45
- function seal(originIdJson, channelJson, payloadJson, secret) {
46
- return createHmac("sha256", secret).update(`${originIdJson}\n${channelJson}\n${payloadJson}`).digest("base64url");
211
+ function sealText(head, payload, secret) {
212
+ const hmac = createHmac("sha256", keyOf(secret));
213
+ return (typeof payload === "string" ? hmac.update(head + payload) : hmac.update(head).update(payload)).digest("base64url");
214
+ }
215
+ /**
216
+ * Le sceau fourni correspond-il au sceau calculé ? À temps constant.
217
+ *
218
+ * Voie courante : les deux chaînes base64url comparées caractère par caractère,
219
+ * SANS sortie anticipée (le OU accumulé ne dépend que de la longueur, publique :
220
+ * celle de SHA-256) et sans allocation. Repli : un sceau écrit sous une autre forme
221
+ * base64 (remplissage, alphabet standard) est décodé et comparé par
222
+ * `timingSafeEqual` — on accepte ce qu'on acceptait avant. Le repli ne se prend
223
+ * que si la voie courante échoue : il ne révèle rien du secret.
224
+ */
225
+ function sealMatches(provided, expected) {
226
+ if (provided.length === expected.length) {
227
+ let diff = 0;
228
+ for (let i = 0; i < expected.length; i += 1) diff |= provided.charCodeAt(i) ^ expected.charCodeAt(i);
229
+ if (diff === 0) return true;
230
+ }
231
+ const a = Buffer.from(provided, "base64url");
232
+ const b = Buffer.from(expected, "base64url");
233
+ return a.length === b.length && timingSafeEqual(a, b);
47
234
  }
48
235
  /**
49
236
  * Sérialise un message backplane, scellé si un secret est fourni.
@@ -61,14 +248,24 @@ function sealBackplaneEnvelope(msg, secret) {
61
248
  const channelJson = JSON.stringify(msg.channel);
62
249
  const originIdJson = JSON.stringify(msg.originId);
63
250
  const payloadJson = JSON.stringify(msg.payload) ?? "null";
64
- const sig = seal(originIdJson, channelJson, payloadJson, secret);
65
- return `{"channel":${channelJson},"payload":${payloadJson},"originId":${originIdJson},"${SEAL_FIELD}":"${sig}"}`;
251
+ const sig = sealText(sealHead(originIdJson, channelJson), payloadJson, secret);
252
+ return TEXT_HEAD + channelJson + TEXT_PAYLOAD_KEY + payloadJson + TEXT_ORIGIN_KEY + originIdJson + TEXT_SEAL_KEY + sig + TEXT_SEAL_END;
66
253
  }
67
254
  /** Type-guard d'enveloppe — narrowing sûr d'un message brut venu du bus. */
68
255
  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";
256
+ return typeof ownField(m, "channel") === "string" && typeof ownField(m, "originId") === "string";
257
+ }
258
+ /**
259
+ * Tranche de la charge dans une enveloppe texte reçue, si elle a EXACTEMENT la
260
+ * forme qu'écrit {@link sealBackplaneEnvelope} pour ce canal, cette origine et ce
261
+ * sceau ; `null` sinon (forme tierce → repli).
262
+ */
263
+ function receivedPayloadText(raw, channelJson, originIdJson, provided) {
264
+ const start = TEXT_HEAD_LEN + channelJson.length + TEXT_PAYLOAD_KEY_LEN;
265
+ const originAt = raw.length - TEXT_SEAL_END_LEN - provided.length - TEXT_SEAL_KEY_LEN - originIdJson.length - TEXT_ORIGIN_KEY_LEN;
266
+ if (originAt <= start) return null;
267
+ const sealAt = originAt + TEXT_ORIGIN_KEY_LEN + originIdJson.length + TEXT_SEAL_KEY_LEN;
268
+ return raw.startsWith(TEXT_HEAD) && raw.startsWith(channelJson, TEXT_HEAD_LEN) && raw.startsWith(TEXT_PAYLOAD_KEY, start - TEXT_PAYLOAD_KEY_LEN) && raw.startsWith(TEXT_ORIGIN_KEY, originAt) && raw.startsWith(originIdJson, originAt + TEXT_ORIGIN_KEY_LEN) && raw.startsWith(TEXT_SEAL_KEY, sealAt - TEXT_SEAL_KEY_LEN) && raw.startsWith(provided, sealAt) && raw.startsWith(TEXT_SEAL_END, sealAt + provided.length) ? raw.slice(start, originAt) : null;
72
269
  }
73
270
  /**
74
271
  * Parse et **vérifie** une enveloppe reçue du bus partagé.
@@ -78,6 +275,10 @@ function isEnvelope(m) {
78
275
  * `sig`. Sans secret, l'enveloppe nue est acceptée (compat ; l'alerte de boot qui
79
276
  * signale ce mode non authentifié est portée par le wiring du driver).
80
277
  *
278
+ * Le sceau se vérifie sur la tranche REÇUE de la charge quand l'enveloppe a la
279
+ * forme qu'écrit {@link sealBackplaneEnvelope} — aucune re-sérialisation ; une
280
+ * autre forme retombe sur la re-sérialisation de la charge lue.
281
+ *
81
282
  * @param raw - message brut lu sur le bus.
82
283
  * @param secret - secret partagé attendu, ou `null`/`undefined` si aucun.
83
284
  * @returns le message vérifié, ou `null` s'il doit être ignoré (malformé, non
@@ -93,17 +294,294 @@ function openBackplaneEnvelope(raw, secret) {
93
294
  if (!isEnvelope(parsed)) return null;
94
295
  const msg = {
95
296
  channel: parsed.channel,
96
- payload: parsed.payload,
297
+ payload: parsed.payload === null ? void 0 : parsed.payload,
97
298
  originId: parsed.originId
98
299
  };
99
300
  if (!secret) return msg;
100
301
  const provided = parsed[SEAL_FIELD];
101
302
  if (typeof provided !== "string") return null;
102
- const payloadText = JSON.stringify(msg.payload);
103
- const expected = seal(JSON.stringify(msg.originId), JSON.stringify(msg.channel), payloadText ?? "null", secret);
104
- const a = Buffer.from(provided, "base64url");
105
- const b = Buffer.from(expected, "base64url");
106
- if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
107
- return msg;
303
+ const channelJson = JSON.stringify(msg.channel);
304
+ const originIdJson = JSON.stringify(msg.originId);
305
+ const head = sealHead(originIdJson, channelJson);
306
+ const slice = receivedPayloadText(raw, channelJson, originIdJson, provided);
307
+ if (slice !== null && sealMatches(provided, sealText(head, slice, secret))) return msg;
308
+ return sealMatches(provided, sealText(head, JSON.stringify(msg.payload) ?? "null", secret)) ? msg : null;
309
+ }
310
+ /** Encode par le codec, et refuse un codec texte : le sujet binaire porte des octets. */
311
+ function encodeBytes(codec, value) {
312
+ const out = codec.encode(value);
313
+ if (typeof out === "string") throw new TypeError(`backplane : le codec « ${codec.protocol} » rend du texte — le sujet binaire du bus exige un codec binaire`);
314
+ return out;
315
+ }
316
+ /**
317
+ * Assemble l'enveloppe binaire scellée par fragments : la charge, déjà encodée,
318
+ * est recopiée telle quelle — jamais ré-encodée. Une seule zone mémoire.
319
+ *
320
+ * Zone PROPRE (`new Uint8Array`), jamais le pool partagé de `Buffer.allocUnsafe` :
321
+ * une charge décodée de ces octets rend des vues, qui retiendraient tout le bloc du
322
+ * pool. Un `Uint8Array`, comme le codec : un `Buffer` y ferait décoder des
323
+ * `Buffer`. Les chaînes ASCII (le cas courant : canal, origine, sceau) s'écrivent
324
+ * par une boucle, sans objet intermédiaire.
325
+ */
326
+ function frameSealedBytes(channel, originId, payloadBytes, sig) {
327
+ const channelLen = Buffer.byteLength(channel, "utf8");
328
+ const originLen = Buffer.byteLength(originId, "utf8");
329
+ const out = new Uint8Array(1 + CBOR_KEY_CHANNEL.length + cborTextHeadSize(channelLen) + channelLen + CBOR_KEY_PAYLOAD.length + payloadBytes.byteLength + CBOR_KEY_ORIGIN.length + cborTextHeadSize(originLen) + originLen + CBOR_SEAL_TAIL_LEN);
330
+ out[0] = CBOR_MAP_4;
331
+ let at = 1;
332
+ out.set(CBOR_KEY_CHANNEL, at);
333
+ at = writeCborTextHead(out, at + CBOR_KEY_CHANNEL.length, channelLen);
334
+ at = writeUtf8(out, at, channel, channelLen);
335
+ out.set(CBOR_KEY_PAYLOAD, at);
336
+ at += CBOR_KEY_PAYLOAD.length;
337
+ out.set(payloadBytes, at);
338
+ at += payloadBytes.byteLength;
339
+ out.set(CBOR_KEY_ORIGIN, at);
340
+ at = writeCborTextHead(out, at + CBOR_KEY_ORIGIN.length, originLen);
341
+ at = writeUtf8(out, at, originId, originLen);
342
+ out.set(CBOR_KEY_SEAL, at);
343
+ at += CBOR_KEY_SEAL.length;
344
+ out.set(CBOR_SEAL_HEAD, at);
345
+ writeUtf8(out, at + CBOR_SEAL_HEAD.length, sig, SEAL_CHARS);
346
+ return out;
347
+ }
348
+ /** Encodeur UTF-8 partagé, pour les chaînes non ASCII. */
349
+ const UTF8 = new TextEncoder();
350
+ /**
351
+ * Écrit `value` en UTF-8 à `at` ; rend la position suivante. `byteLen` égal au
352
+ * nombre de caractères = chaîne ASCII : boucle directe, sans vue intermédiaire.
353
+ */
354
+ function writeUtf8(out, at, value, byteLen) {
355
+ if (byteLen === value.length) for (let i = 0; i < byteLen; i += 1) out[at + i] = value.charCodeAt(i);
356
+ else UTF8.encodeInto(value, out.subarray(at, at + byteLen));
357
+ return at + byteLen;
108
358
  }
109
- export { openBackplaneEnvelope, sealBackplaneEnvelope };
359
+ /** Verdicts déjà CONSTATÉS, par codec (un serveur n'en a qu'un : la case unique suffit). */
360
+ let framedCodec = null;
361
+ let framedVerdict = false;
362
+ const framedVerdicts = /* @__PURE__ */ new WeakMap();
363
+ /**
364
+ * Le codec écrit-il le CBOR le plus court, celui que {@link frameSealedBytes}
365
+ * assemble ? CONSTATÉ, jamais déduit de son nom : une enveloppe d'épreuve est
366
+ * encodée par le codec et comparée à l'assemblage par fragments, une fois par
367
+ * codec. Faux → le codec garde la voie générique (double encodage), qui reste
368
+ * correcte pour n'importe quel format.
369
+ */
370
+ function framesAsCbor(codec) {
371
+ if (codec === framedCodec) return framedVerdict;
372
+ let verdict = framedVerdicts.get(codec);
373
+ if (verdict === void 0) {
374
+ verdict = probeFraming(codec);
375
+ framedVerdicts.set(codec, verdict);
376
+ }
377
+ framedCodec = codec;
378
+ framedVerdict = verdict;
379
+ return verdict;
380
+ }
381
+ /** Épreuve de {@link framesAsCbor} : chaque longueur d'en-tête utile, octets compris. */
382
+ function probeFraming(codec) {
383
+ const channel = "é".repeat(20);
384
+ const originId = "pod";
385
+ const payload = {
386
+ seq: 7,
387
+ list: [
388
+ 1,
389
+ -2.5,
390
+ null,
391
+ "x"
392
+ ],
393
+ raw: new Uint8Array([0, 255])
394
+ };
395
+ const sig = "A".repeat(SEAL_CHARS);
396
+ try {
397
+ return asBuffer(encodeBytes(codec, {
398
+ channel,
399
+ payload,
400
+ originId,
401
+ sig
402
+ })).equals(asBuffer(frameSealedBytes(channel, originId, encodeBytes(codec, payload), sig)));
403
+ } catch {
404
+ return false;
405
+ }
406
+ }
407
+ /**
408
+ * Encode un message backplane pour le **sujet binaire** du bus (ADR-0021 S6) :
409
+ * la MÊME enveloppe `{ channel, payload, originId }`, encodée par le codec
410
+ * binaire du serveur, octets natifs dans `payload`, scellée si un secret est
411
+ * fourni.
412
+ *
413
+ * Scellée, la charge est encodée UNE fois : le sceau se calcule sur ses octets,
414
+ * et l'enveloppe s'assemble autour d'eux. Un codec qui n'écrit pas le CBOR le plus
415
+ * court (constaté, cf {@link framesAsCbor}) encode l'enveloppe entière une seconde
416
+ * fois — correct pour tout format, plus coûteux.
417
+ *
418
+ * @param msg - message à transporter (canal, charge, origine).
419
+ * @param codec - le codec binaire du serveur (`CBOR_FRAME_CODEC` de `nodefony/cbor`).
420
+ * @param secret - secret partagé entre pods ; `null`/`undefined` → enveloppe nue.
421
+ * @returns les octets à publier sur le sujet binaire.
422
+ * @throws TypeError si le codec rend du texte.
423
+ */
424
+ function sealBackplaneEnvelopeBytes(msg, codec, secret) {
425
+ const payload = msg.payload ?? null;
426
+ if (!secret) return encodeBytes(codec, {
427
+ channel: msg.channel,
428
+ payload,
429
+ originId: msg.originId
430
+ });
431
+ const payloadBytes = encodeBytes(codec, payload);
432
+ const sig = sealText(sealHead(JSON.stringify(msg.originId), JSON.stringify(msg.channel)), payloadBytes, secret);
433
+ if (framesAsCbor(codec)) return frameSealedBytes(msg.channel, msg.originId, payloadBytes, sig);
434
+ return encodeBytes(codec, {
435
+ channel: msg.channel,
436
+ payload,
437
+ originId: msg.originId,
438
+ [SEAL_FIELD]: sig
439
+ });
440
+ }
441
+ /**
442
+ * Tranche de la charge dans une enveloppe binaire reçue, si elle a EXACTEMENT la
443
+ * forme qu'assemble {@link frameSealedBytes} pour ce canal, cette origine et ce
444
+ * sceau ; `null` sinon (forme tierce → repli). Aucune copie : une vue.
445
+ */
446
+ function receivedPayloadBytes(raw, channel, originId, provided) {
447
+ if (provided.length !== SEAL_CHARS || raw[0] !== CBOR_MAP_4) return null;
448
+ const channelLen = Buffer.byteLength(channel, "utf8");
449
+ const originLen = Buffer.byteLength(originId, "utf8");
450
+ const channelHeadAt = 1 + CBOR_KEY_CHANNEL.length;
451
+ const payloadKeyAt = channelHeadAt + cborTextHeadSize(channelLen) + channelLen;
452
+ const start = payloadKeyAt + CBOR_KEY_PAYLOAD.length;
453
+ const originAt = raw.length - CBOR_SEAL_TAIL_LEN - originLen - cborTextHeadSize(originLen) - CBOR_KEY_ORIGIN.length;
454
+ if (originAt <= start) return null;
455
+ const originHeadAt = originAt + CBOR_KEY_ORIGIN.length;
456
+ const sealKeyAt = originHeadAt + cborTextHeadSize(originLen) + originLen;
457
+ return bytesAt(raw, 1, CBOR_KEY_CHANNEL) && cborTextHeadAt(raw, channelHeadAt, channelLen) && bytesAt(raw, payloadKeyAt, CBOR_KEY_PAYLOAD) && bytesAt(raw, originAt, CBOR_KEY_ORIGIN) && cborTextHeadAt(raw, originHeadAt, originLen) && bytesAt(raw, sealKeyAt, CBOR_KEY_SEAL) && bytesAt(raw, sealKeyAt + CBOR_KEY_SEAL.length, CBOR_SEAL_HEAD) ? raw.subarray(start, originAt) : null;
458
+ }
459
+ /**
460
+ * Décode et **vérifie** une enveloppe reçue sur le sujet binaire du bus — même
461
+ * règle que {@link openBackplaneEnvelope} : fail-closed strict sous un secret,
462
+ * aucun downgrade en retirant `sig`.
463
+ *
464
+ * Le sceau se vérifie sur la tranche REÇUE de la charge quand l'enveloppe a la
465
+ * forme qu'assemble l'émetteur — aucun ré-encodage ; une autre forme retombe sur
466
+ * le ré-encodage de la charge décodée (le codec CBOR du cœur rend la forme la plus
467
+ * courte de chaque valeur, donc émetteur et récepteur signent les mêmes octets).
468
+ *
469
+ * @param raw - octets lus sur le sujet binaire.
470
+ * @param codec - le codec binaire du serveur.
471
+ * @param secret - secret partagé attendu, ou `null`/`undefined` si aucun.
472
+ * @returns le message vérifié, ou `null` s'il doit être ignoré.
473
+ */
474
+ function openBackplaneEnvelopeBytes(raw, codec, secret) {
475
+ let parsed;
476
+ try {
477
+ parsed = codec.decode(raw);
478
+ } catch {
479
+ return null;
480
+ }
481
+ if (!isEnvelope(parsed)) return null;
482
+ const msg = {
483
+ channel: parsed.channel,
484
+ payload: parsed.payload === null ? void 0 : parsed.payload,
485
+ originId: parsed.originId
486
+ };
487
+ if (!secret) return msg;
488
+ const provided = parsed[SEAL_FIELD];
489
+ if (typeof provided !== "string") return null;
490
+ const head = sealHead(JSON.stringify(msg.originId), JSON.stringify(msg.channel));
491
+ if (framesAsCbor(codec)) {
492
+ const slice = receivedPayloadBytes(raw, msg.channel, msg.originId, provided);
493
+ if (slice !== null && sealMatches(provided, sealText(head, slice, secret))) return msg;
494
+ }
495
+ let payloadBytes;
496
+ try {
497
+ payloadBytes = encodeBytes(codec, parsed.payload ?? null);
498
+ } catch {
499
+ return null;
500
+ }
501
+ return sealMatches(provided, sealText(head, payloadBytes, secret)) ? msg : null;
502
+ }
503
+ /**
504
+ * Reconnaît l'**écho** d'un pod — ce que le bus lui renvoie de ses propres
505
+ * publications — à la FIN du message, sans le décoder (ADR-0021 S6, ADR-0025).
506
+ *
507
+ * Un bus pub/sub comme Redis rend à l'émetteur chacune de ses publications : sur un
508
+ * pod chargé, c'est un message reçu sur deux, et la présence répartie en fait un
509
+ * signal (le bus est vivant tant que l'écho revient). Le reconnaître coûte, sous un
510
+ * secret, UN HMAC sur la tranche de la charge : ni `JSON.parse`, ni décodage CBOR.
511
+ *
512
+ * Sous un secret, l'écho est VÉRIFIÉ avant d'être compté : un tiers qui forgerait
513
+ * l'origine de ce pod pour simuler un bus vivant échoue au sceau. Sans secret, rien
514
+ * ne peut l'être — le bus est ouvert, et le wiring l'annonce au démarrage.
515
+ *
516
+ * Un message que ce lecteur ne reconnaît pas (forme tierce, sceau faux) n'est PAS
517
+ * jeté : il suit la voie ordinaire, qui le vérifie en entier.
518
+ */
519
+ var BackplaneEcho = class {
520
+ #secret;
521
+ /** `originId\n` en JSON : début de la chaîne canonique de CE pod. */
522
+ #sealHeadStart;
523
+ /** Texte, scellé : `,"originId":<origine>,"sig":"`. */
524
+ #textTail;
525
+ /** Texte, nu : `,"originId":<origine>}`. */
526
+ #textBareTail;
527
+ /** CBOR : clé `originId` puis la chaîne de l'origine. */
528
+ #bytesOrigin;
529
+ /** Lecture d'un en-tête de chaîne CBOR, réutilisée (aucune allocation par message). */
530
+ #head = [0, 0];
531
+ /**
532
+ * @param originId - identité de CE pod sur le bus.
533
+ * @param secret - secret partagé, ou `null` sur un bus non scellé.
534
+ */
535
+ constructor(originId, secret) {
536
+ this.#secret = secret;
537
+ const originIdJson = JSON.stringify(originId);
538
+ this.#sealHeadStart = `${originIdJson}\n`;
539
+ this.#textTail = TEXT_ORIGIN_KEY + originIdJson + TEXT_SEAL_KEY;
540
+ this.#textBareTail = `${TEXT_ORIGIN_KEY}${originIdJson}}`;
541
+ const origin = cborText(originId);
542
+ this.#bytesOrigin = new Uint8Array(CBOR_KEY_ORIGIN.length + origin.length);
543
+ this.#bytesOrigin.set(CBOR_KEY_ORIGIN, 0);
544
+ this.#bytesOrigin.set(origin, CBOR_KEY_ORIGIN.length);
545
+ }
546
+ /**
547
+ * Ce message texte est-il l'écho de ce pod (sceau vérifié sous un secret) ?
548
+ *
549
+ * @param raw - message brut lu sur le sujet texte.
550
+ */
551
+ text(raw) {
552
+ const secret = this.#secret;
553
+ if (secret === null) return raw.endsWith(this.#textBareTail);
554
+ const tail = this.#textTail;
555
+ const tailAt = raw.length - TEXT_SEAL_END_LEN - SEAL_CHARS - tail.length;
556
+ if (tailAt <= TEXT_HEAD_LEN || !raw.startsWith(tail, tailAt) || !raw.endsWith(TEXT_SEAL_END) || !raw.startsWith(TEXT_HEAD) || raw.charCodeAt(TEXT_HEAD_LEN) !== QUOTE) return false;
557
+ const close = raw.indexOf(TEXT_CHANNEL_CLOSE, 12);
558
+ const start = close + TEXT_CHANNEL_CLOSE_LEN;
559
+ if (close < 0 || start >= tailAt) return false;
560
+ const sealAt = tailAt + tail.length;
561
+ return sealMatches(raw.slice(sealAt, sealAt + SEAL_CHARS), sealText(`${this.#sealHeadStart}${raw.slice(TEXT_HEAD_LEN, close + 1)}\n`, raw.slice(start, tailAt), secret));
562
+ }
563
+ /**
564
+ * Ce message binaire est-il l'écho de ce pod (sceau vérifié sous un secret) ?
565
+ *
566
+ * @param raw - octets lus sur le sujet binaire.
567
+ * @param codec - le codec binaire du serveur ; un codec qui n'écrit pas le CBOR
568
+ * attendu (cf {@link framesAsCbor}) laisse tout message à la voie ordinaire.
569
+ */
570
+ bytes(raw, codec) {
571
+ if (!framesAsCbor(codec)) return false;
572
+ const origin = this.#bytesOrigin;
573
+ const secret = this.#secret;
574
+ if (secret === null) return raw[0] === CBOR_MAP_3 && bytesAt(raw, raw.length - origin.length, origin);
575
+ const originAt = raw.length - CBOR_SEAL_TAIL_LEN - origin.length;
576
+ const sealKeyAt = originAt + origin.length;
577
+ const head = this.#head;
578
+ if (raw[0] !== CBOR_MAP_4 || !bytesAt(raw, originAt, origin) || !bytesAt(raw, sealKeyAt, CBOR_KEY_SEAL) || !bytesAt(raw, sealKeyAt + CBOR_KEY_SEAL.length, CBOR_SEAL_HEAD) || !bytesAt(raw, 1, CBOR_KEY_CHANNEL) || !readCborTextHead(raw, 1 + CBOR_KEY_CHANNEL.length, head)) return false;
579
+ const channelAt = 1 + CBOR_KEY_CHANNEL.length + head[1];
580
+ const channelEnd = channelAt + head[0];
581
+ const start = channelEnd + CBOR_KEY_PAYLOAD.length;
582
+ if (start >= originAt || !bytesAt(raw, channelEnd, CBOR_KEY_PAYLOAD)) return false;
583
+ const view = asBuffer(raw);
584
+ return sealMatches(view.toString("latin1", raw.length - SEAL_CHARS, raw.length), sealText(`${this.#sealHeadStart}${JSON.stringify(view.toString("utf8", channelAt, channelEnd))}\n`, raw.subarray(start, originAt), secret));
585
+ }
586
+ };
587
+ export { BackplaneEcho, openBackplaneEnvelope, openBackplaneEnvelopeBytes, sealBackplaneEnvelope, sealBackplaneEnvelopeBytes };
@@ -0,0 +1,93 @@
1
+ import { BackplaneEcho, openBackplaneEnvelope, openBackplaneEnvelopeBytes } from "./envelope.js";
2
+ // nodefony/src/backplane/ingress.ts
3
+ /**
4
+ * **Entrée d'un bus partagé** — le tri de ce qu'un pilote reçoit, mutualisé pour
5
+ * que tout pilote à transport réseau (Redis aujourd'hui ; Kafka, NATS, RabbitMQ…
6
+ * demain) l'hérite sans le réécrire : écho de ce pod reconnu et compté, sceau
7
+ * vérifié, refus comptés et annoncés.
8
+ *
9
+ * Le pilote ne garde que son transport : il passe chaque message reçu à
10
+ * {@link text} ou {@link bytes} et remet au hub ce qui en sort.
11
+ *
12
+ * Ordre du tri, du moins cher au plus cher :
13
+ * 1. l'ÉCHO de ce pod se reconnaît à la fin du message, sans le décoder — un HMAC
14
+ * sous un secret (cf {@link BackplaneEcho}) ;
15
+ * 2. sinon, l'enveloppe est ouverte et son sceau vérifié sur la tranche reçue ;
16
+ * 3. un message ouvert qui porte l'origine de ce pod sous une autre forme est
17
+ * encore un écho — compté, jamais réinjecté (anti-boucle).
18
+ */
19
+ var BackplaneIngress = class {
20
+ #originId;
21
+ #secret;
22
+ #echo;
23
+ #notice;
24
+ #echoTotal = 0;
25
+ #sealRejectedTotal = 0;
26
+ /**
27
+ * @param originId - identité de CE pod sur le bus.
28
+ * @param secret - secret partagé, ou `null` sur un bus non scellé.
29
+ * @param notice - annonce des sceaux refusés ; omis = silencieux (tests).
30
+ */
31
+ constructor(originId, secret, notice = null) {
32
+ this.#originId = originId;
33
+ this.#secret = secret;
34
+ this.#echo = new BackplaneEcho(originId, secret);
35
+ this.#notice = notice;
36
+ }
37
+ /**
38
+ * Trie un message reçu sur le sujet TEXTE du bus.
39
+ *
40
+ * @param raw - message brut tel que lu sur le transport.
41
+ * @returns le message à remettre au hub, ou `null` s'il est jeté (écho de ce
42
+ * pod, malformé, sceau refusé — chacun compté).
43
+ */
44
+ text(raw) {
45
+ if (this.#echo.text(raw)) {
46
+ this.#echoTotal += 1;
47
+ return null;
48
+ }
49
+ return this.#admit(openBackplaneEnvelope(raw, this.#secret));
50
+ }
51
+ /**
52
+ * Trie un message reçu sur le sujet BINAIRE du bus (ADR-0021 S6).
53
+ *
54
+ * @param raw - octets tels que lus sur le transport.
55
+ * @param codec - le codec binaire du serveur.
56
+ * @returns le message à remettre au hub, ou `null` s'il est jeté.
57
+ */
58
+ bytes(raw, codec) {
59
+ if (this.#echo.bytes(raw, codec)) {
60
+ this.#echoTotal += 1;
61
+ return null;
62
+ }
63
+ return this.#admit(openBackplaneEnvelopeBytes(raw, codec, this.#secret));
64
+ }
65
+ /** Échos de ce pod reçus du bus (monotone) — le bus rend ce qu'on y publie. */
66
+ get echoTotal() {
67
+ return this.#echoTotal;
68
+ }
69
+ /** Messages jetés au sceau refusé, sous un secret (monotone). */
70
+ get sealRejectedTotal() {
71
+ return this.#sealRejectedTotal;
72
+ }
73
+ /** Fin du tri commune aux deux sujets : refus compté, écho tardif, ou message admis. */
74
+ #admit(msg) {
75
+ if (msg === null) {
76
+ if (this.#secret !== null) this.#sealRejected();
77
+ return null;
78
+ }
79
+ if (msg.originId === this.#originId) {
80
+ this.#echoTotal += 1;
81
+ return null;
82
+ }
83
+ return msg;
84
+ }
85
+ /** Compte un sceau refusé ; annonce le premier, puis un sur cent (pas d'inondation du journal). */
86
+ #sealRejected() {
87
+ this.#sealRejectedTotal += 1;
88
+ const n = this.#sealRejectedTotal;
89
+ if (n !== 1 && n % 100 !== 0) return;
90
+ 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");
91
+ }
92
+ };
93
+ export { BackplaneIngress };