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