@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.
- package/README.md +55 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +91 -12
- package/dist/nodefony/config/config.js +7 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
- package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
- package/dist/nodefony/src/backplane/RedisBackplane.js +146 -41
- package/dist/nodefony/src/backplane/controlOrder.js +86 -0
- package/dist/nodefony/src/backplane/envelope.js +505 -27
- package/dist/nodefony/src/backplane/ingress.js +93 -0
- package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
- package/dist/nodefony/src/backplane/publishQueue.js +97 -3
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
- package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
- package/dist/nodefony/src/server/RealtimeConnection.js +135 -0
- package/dist/nodefony/src/server/RealtimeController.js +409 -107
- package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
- package/dist/nodefony/src/server/admissionBudget.js +176 -0
- package/dist/nodefony/src/server/channelPolicyRegistry.js +95 -0
- package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
- package/dist/nodefony/src/server/channelPresence.js +488 -0
- package/dist/nodefony/src/server/connectionAdmission.js +242 -0
- package/dist/nodefony/src/server/contractRoutes.js +75 -0
- package/dist/nodefony/src/server/deniedDetail.js +10 -3
- package/dist/nodefony/src/server/frameAdmission.js +160 -0
- package/dist/nodefony/src/server/presenceBus.js +486 -0
- package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
- package/dist/nodefony/src/server/remotePresence.js +541 -0
- package/dist/nodefony/src/server/subscribedAck.js +13 -1
- package/dist/nodefony/src/server/syslogUplink.js +3 -4
- package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
- package/dist/nodefony/src/service/RealtimeService.js +51 -1
- package/dist/nodefony/src/service/publicOrigin.js +40 -0
- package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
- package/dist/nodefony/testing/backplaneConformance.js +347 -0
- package/dist/nodefony/testing/index.js +43 -12
- package/dist/nodefony/testing/presenceClient.js +54 -0
- package/dist/types/index.d.ts +39 -7
- package/dist/types/nodefony/config/config.d.ts +9 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
- package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
- package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
- package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
- package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
- package/dist/types/nodefony/interfaces/index.d.ts +2 -1
- package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +89 -18
- package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
- package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
- package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
- package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
- package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
- package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
- package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +20 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
- package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
- package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +41 -0
- package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
- package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
- package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
- package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
- package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
- package/dist/types/nodefony/src/server/frameAdmission.d.ts +66 -0
- package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
- package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
- package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +10 -0
- package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -2
- package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
- package/dist/types/nodefony/testing/backplaneConformance.d.ts +78 -0
- package/dist/types/nodefony/testing/index.d.ts +13 -2
- package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
- package/docs/actions.md +420 -18
- package/docs/architecture.md +101 -11
- package/docs/cadence-et-contre-pression.md +87 -11
- package/docs/canaux-a-membres.md +277 -0
- package/docs/cas-d-usage.md +1703 -0
- package/docs/configuration.md +175 -19
- package/docs/cookbook-chat.md +701 -224
- package/docs/index.md +11 -1
- package/docs/observabilite.md +97 -21
- package/docs/protocole.md +142 -33
- package/docs/securite.md +347 -41
- package/docs/vocabulaire.md +13 -12
- package/package.json +16 -13
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
|
29
|
-
* limite à re-diffuser une charge déjà diffusée sur un
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
46
|
-
|
|
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 =
|
|
65
|
-
return
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
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
|
-
|
|
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 };
|