@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
@@ -12,9 +12,38 @@ import { ownField } from "nodefony";
12
12
  */
13
13
  /** Longueur maximale d'un champ texte d'un ordre (nom de canal, identifiant de compte). */
14
14
  const MAX_CONTROL_FIELD_LENGTH = 1024;
15
- function isBoundedText(value) {
15
+ /**
16
+ * Un texte venu du bus est-il recevable — chaîne non vide d'au plus {@link MAX_CONTROL_FIELD_LENGTH} caractères ?
17
+ *
18
+ * Seule borne des textes du bus (nom de canal, identifiant de compte), lue par
19
+ * les ordres de contrôle ET par les messages de présence (ADR-0025 P2).
20
+ *
21
+ * @param value - champ reçu (forme inconnue).
22
+ */
23
+ function isBoundedBusText(value) {
16
24
  return typeof value === "string" && value.length > 0 && value.length <= 1024;
17
25
  }
26
+ /** Les types d'ordre que cette version sait appliquer — liste fermée. */
27
+ function isKnownOp(op) {
28
+ return op === "close" || op === "evictUser";
29
+ }
30
+ /**
31
+ * La charge est-elle un ordre d'un TYPE que cette version ne connaît pas — `op` lisible, mais hors de la liste fermée ?
32
+ *
33
+ * C'est ce qu'émet un serveur d'une version plus récente pendant un
34
+ * déploiement progressif (ADR-0025, « Règle de lecture du bus ») : l'ordre
35
+ * n'est pas appliqué, mais il ne se compte pas comme une injection. Rien
36
+ * d'autre que `op` n'est lu — ses autres champs appartiennent à une version
37
+ * qui n'est pas celle-ci. Une charge sans `op` textuel, ou d'un type CONNU de
38
+ * forme fausse, n'est pas « inconnue » : elle est invalide.
39
+ *
40
+ * @param payload - la charge du message reçu (forme inconnue).
41
+ * @returns `true` si le type d'ordre est inconnu de cette version.
42
+ */
43
+ function isUnknownControlOrder(payload) {
44
+ const op = ownField(payload, "op");
45
+ return isBoundedBusText(op) && !isKnownOp(op);
46
+ }
18
47
  /**
19
48
  * 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
49
  *
@@ -31,15 +60,15 @@ function isBoundedText(value) {
31
60
  */
32
61
  function readControlOrder(payload) {
33
62
  const channel = ownField(payload, "channel");
34
- if (!isBoundedText(channel)) return null;
63
+ if (!isBoundedBusText(channel)) return null;
35
64
  const op = ownField(payload, "op");
65
+ if (!isKnownOp(op)) return null;
36
66
  if (op === "close") return {
37
67
  op,
38
68
  channel
39
69
  };
40
- if (op !== "evictUser") return null;
41
70
  const user = ownField(payload, "user");
42
- if (!isBoundedText(user)) return null;
71
+ if (!isBoundedBusText(user)) return null;
43
72
  const generation = ownField(payload, "generation");
44
73
  if (generation === void 0) return {
45
74
  op,
@@ -54,4 +83,4 @@ function readControlOrder(payload) {
54
83
  generation
55
84
  };
56
85
  }
57
- export { MAX_CONTROL_FIELD_LENGTH, readControlOrder };
86
+ export { MAX_CONTROL_FIELD_LENGTH, isBoundedBusText, isUnknownControlOrder, readControlOrder };
@@ -1,5 +1,5 @@
1
1
  import { ownField } from "nodefony";
2
- import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { createHmac, createSecretKey, timingSafeEqual } from "node:crypto";
3
3
  // nodefony/src/backplane/envelope.ts
4
4
  /**
5
5
  * **Sceau d'enveloppe backplane** — authenticité des messages transportés sur un
@@ -37,23 +37,200 @@ import { createHmac, timingSafeEqual } from "node:crypto";
37
37
  * captés), parade réservée par le champ optionnel `generation` de l'ordre. Un
38
38
  * anti-rejeu par horodatage exigerait des horloges alignées entre pods.
39
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.
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.
42
52
  */
43
53
  /** Nom du champ portant le sceau dans l'enveloppe sérialisée. */
44
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
+ }
179
+ /**
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
+ }
45
202
  /**
46
- * Sceau HMAC-SHA256 (base64url) d'un triplet **déjà sérialisé**. Le `\n` sépare
47
- * trois fragments JSON : les deux premiers sont des chaînes échappées, donc
48
- * aucun décalage de frontière n'est possible entre les parties.
203
+ * Sceau (HMAC-SHA256 en base64url) de la chaîne canonique — un seul calcul.
49
204
  *
50
- * Prend les fragments et non le message : la charge n'est sérialisée **qu'une
51
- * fois** par publication (elle sert et au sceau et à l'enveloppe finale) — sur
52
- * un canal de fan-out, une seconde sérialisation par message se paierait à
53
- * 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).
54
210
  */
55
- function seal(originIdJson, channelJson, payloadJson, secret) {
56
- 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);
57
234
  }
58
235
  /**
59
236
  * Sérialise un message backplane, scellé si un secret est fourni.
@@ -71,14 +248,26 @@ function sealBackplaneEnvelope(msg, secret) {
71
248
  const channelJson = JSON.stringify(msg.channel);
72
249
  const originIdJson = JSON.stringify(msg.originId);
73
250
  const payloadJson = JSON.stringify(msg.payload) ?? "null";
74
- const sig = seal(originIdJson, channelJson, payloadJson, secret);
75
- 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;
76
253
  }
77
254
  /** Type-guard d'enveloppe — narrowing sûr d'un message brut venu du bus. */
78
255
  function isEnvelope(m) {
79
256
  return typeof ownField(m, "channel") === "string" && typeof ownField(m, "originId") === "string";
80
257
  }
81
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;
269
+ }
270
+ /**
82
271
  * Parse et **vérifie** une enveloppe reçue du bus partagé.
83
272
  *
84
273
  * Fail-closed strict quand un secret est posé : une enveloppe sans sceau ou au
@@ -86,6 +275,10 @@ function isEnvelope(m) {
86
275
  * `sig`. Sans secret, l'enveloppe nue est acceptée (compat ; l'alerte de boot qui
87
276
  * signale ce mode non authentifié est portée par le wiring du driver).
88
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
+ *
89
282
  * @param raw - message brut lu sur le bus.
90
283
  * @param secret - secret partagé attendu, ou `null`/`undefined` si aucun.
91
284
  * @returns le message vérifié, ou `null` s'il doit être ignoré (malformé, non
@@ -107,35 +300,109 @@ function openBackplaneEnvelope(raw, secret) {
107
300
  if (!secret) return msg;
108
301
  const provided = parsed[SEAL_FIELD];
109
302
  if (typeof provided !== "string") return null;
110
- const payloadText = JSON.stringify(msg.payload);
111
- return sameSeal(provided, seal(JSON.stringify(msg.originId), JSON.stringify(msg.channel), payloadText ?? "null", secret)) ? msg : null;
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;
112
315
  }
113
316
  /**
114
- * Compare deux sceaux base64url à temps constant.
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.
115
319
  *
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).
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.
118
325
  */
119
- function sameSeal(provided, expected) {
120
- const a = Buffer.from(provided, "base64url");
121
- const b = Buffer.from(expected, "base64url");
122
- return a.length === b.length && timingSafeEqual(a, b);
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;
123
347
  }
348
+ /** Encodeur UTF-8 partagé, pour les chaînes non ASCII. */
349
+ const UTF8 = new TextEncoder();
124
350
  /**
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.
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.
130
353
  */
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");
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;
133
358
  }
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;
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
+ }
139
406
  }
140
407
  /**
141
408
  * Encode un message backplane pour le **sujet binaire** du bus (ADR-0021 S6) :
@@ -143,9 +410,10 @@ function encodeBytes(codec, value) {
143
410
  * binaire du serveur, octets natifs dans `payload`, scellée si un secret est
144
411
  * fourni.
145
412
  *
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.
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.
149
417
  *
150
418
  * @param msg - message à transporter (canal, charge, origine).
151
419
  * @param codec - le codec binaire du serveur (`CBOR_FRAME_CODEC` de `nodefony/cbor`).
@@ -160,7 +428,9 @@ function sealBackplaneEnvelopeBytes(msg, codec, secret) {
160
428
  payload,
161
429
  originId: msg.originId
162
430
  });
163
- const sig = sealBytes(msg.originId, msg.channel, encodeBytes(codec, payload), secret);
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);
164
434
  return encodeBytes(codec, {
165
435
  channel: msg.channel,
166
436
  payload,
@@ -169,14 +439,32 @@ function sealBackplaneEnvelopeBytes(msg, codec, secret) {
169
439
  });
170
440
  }
171
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
+ /**
172
460
  * Décode et **vérifie** une enveloppe reçue sur le sujet binaire du bus — même
173
461
  * règle que {@link openBackplaneEnvelope} : fail-closed strict sous un secret,
174
462
  * aucun downgrade en retirant `sig`.
175
463
  *
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.
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).
180
468
  *
181
469
  * @param raw - octets lus sur le sujet binaire.
182
470
  * @param codec - le codec binaire du serveur.
@@ -199,12 +487,101 @@ function openBackplaneEnvelopeBytes(raw, codec, secret) {
199
487
  if (!secret) return msg;
200
488
  const provided = parsed[SEAL_FIELD];
201
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
+ }
202
495
  let payloadBytes;
203
496
  try {
204
497
  payloadBytes = encodeBytes(codec, parsed.payload ?? null);
205
498
  } catch {
206
499
  return null;
207
500
  }
208
- return sameSeal(provided, sealBytes(msg.originId, msg.channel, payloadBytes, secret)) ? msg : null;
501
+ return sealMatches(provided, sealText(head, payloadBytes, secret)) ? msg : null;
209
502
  }
210
- export { openBackplaneEnvelope, openBackplaneEnvelopeBytes, sealBackplaneEnvelope, sealBackplaneEnvelopeBytes };
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 };