@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
@@ -1,11 +1,14 @@
1
1
  import { RealtimeConnection } from "./RealtimeConnection.js";
2
+ import { ChannelPresence, presenceDefect, presenceSettingsOf } from "./channelPresence.js";
3
+ import { PresenceBus } from "./presenceBus.js";
2
4
  import { AdmissionBudget } from "./admissionBudget.js";
3
5
  import { dropRetainedVerdicts } from "./frameAdmission.js";
4
- import { isAdmissionClass } from "../../decorators/realtimeDecorators.js";
6
+ import { DEFAULT_ACTION_POLICY } from "../../decorators/realtimeDecorators.js";
7
+ import { USER_CHANNEL_DECLARATION, declarationOf, policyDeclarationsDefect, resolveDeclaredPolicies } from "./channelPolicyResolution.js";
5
8
  import { LoopbackBackplane } from "../backplane/LoopbackBackplane.js";
6
- import { readControlOrder } from "../backplane/controlOrder.js";
9
+ import { isUnknownControlOrder, readControlOrder } from "../backplane/controlOrder.js";
7
10
  import { ANONYMOUS_REALTIME_TOKEN } from "./AnonymousRealtimeToken.js";
8
- import { BootConfigurationError, IDENTITY_DEAD_CLOSE_CODE, IDENTITY_REFRESH_CLOSE_CODE, MAX_CHANNEL_PATTERN_WILDCARDS, NODEFONY_CHANNEL_NAMESPACE, PLATFORM_BUS_CHANNELS, REVOCATION_REVALIDATE_MS, RevocationWatch, TOKEN_EXPIRED_CLOSE_CODE, USER_CHANNEL_PREFIX, compileChannelPattern, containsBytes, countWildcards, frameCodecSet, isOwnUserChannel, isPlatformChannel, isRealtimePayload, realtimePayloadError, userChannel } from "nodefony";
11
+ import { BootConfigurationError, IDENTITY_DEAD_CLOSE_CODE, IDENTITY_REFRESH_CLOSE_CODE, JsonRpcPeer, MAX_CHANNEL_PATTERN_WILDCARDS, NODEFONY_CHANNEL_NAMESPACE, PLATFORM_BUS_CHANNELS, REALTIME_PRESENCE_METHOD, REVOCATION_REVALIDATE_MS, RevocationWatch, TOKEN_EXPIRED_CLOSE_CODE, USER_CHANNEL_PREFIX, channelPatternWeight, compileChannelPattern, containsBytes, countWildcards, frameCodecSet, isPlatformBusChannel, isPlatformChannel, isRealtimePayload, realtimePayloadError, userChannel } from "nodefony";
9
12
  import { frameRateRejectedTotal } from "@nodefony/http";
10
13
  // nodefony/src/server/RealtimeHub.ts
11
14
  /**
@@ -150,51 +153,29 @@ var ChannelFrame = class {
150
153
  }
151
154
  };
152
155
  /**
153
- * Règle d'admission prête à appeler d'une politique, ou `null` sans `allow`.
154
- *
155
- * Une flèche se retient telle quelle. Une classe exige son instance — résolue au
156
- * démarrage, jamais ici : le hub n'a pas d'injecteur, et résoudre à la première
157
- * poignée de main laisserait sans politique un exemplaire qui n'émet que des
158
- * webhooks. La méthode est liée une fois, et la règle se construit une fois :
159
- * le `subscribe` n'alloue rien et n'appelle qu'une fonction.
160
- */
161
- function admissionRuleOf(name, policy, admission) {
162
- const allow = policy.allow;
163
- if (allow === void 0) return null;
164
- const anonymous = policy.anonymous === true;
165
- if (!isAdmissionClass(allow)) return {
166
- allow,
167
- anonymous
168
- };
169
- if (!(admission instanceof allow)) throw new BootConfigurationError(`canal temps réel « ${name} » : la classe d'admission ${allow.name} n'a pas été résolue au démarrage du noyau. Son contrôleur doit être déclaré par @controllers dans un module : c'est là que le framework l'inscrit et résout ses dépendances.`);
170
- return {
171
- allow: admission.allow.bind(admission),
172
- anonymous
173
- };
174
- }
175
- /**
176
- * Politique INTÉGRÉE du canal personnel `user:<id>` (ADR-0022 §6, D1) — connexion authentifiée, admise sur SON canal seulement.
177
- *
178
- * Constante de module : elle ne s'inscrit nulle part, donc aucun `clear()`,
179
- * aucun ordre de démarrage, aucun oubli ne peut la retirer. Elle sert de REPLI :
180
- * une politique que l'application écrit sur `user:…` (nom exact ou motif)
181
- * l'emporte, comme toute déclaration explicite.
182
- */
183
- const USER_CHANNEL_POLICY = Object.freeze({
184
- policy: Object.freeze({
185
- authenticated: true,
186
- allow: isOwnUserChannel
187
- }),
188
- admission: Object.freeze({
189
- allow: isOwnUserChannel,
190
- anonymous: false
191
- })
192
- });
193
- /**
194
156
  * Fabrique intégrée du canal personnel : il n'a pas de source, seul `publish`
195
157
  * l'alimente. Partagée par tous les canaux `user:` — aucune allocation par canal.
196
158
  */
197
159
  const USER_CHANNEL_DISPOSE = () => {};
160
+ /** Identité sous laquelle une connexion figure dans la présence : nommée ⇔ compte local (ADR-0025 P3). */
161
+ function presenceKeyOf(member) {
162
+ return member.account ? member.userId : null;
163
+ }
164
+ /** Ensemble vide partagé : la présence répartie sans canal servi n'alloue rien. */
165
+ const EMPTY_PRESENCES = /* @__PURE__ */ new Set();
166
+ /** Un ensemble d'abonnés à un seul membre, sans le tableau temporaire de `new Set([sink])`. */
167
+ function singleSink(sink) {
168
+ const sinks = /* @__PURE__ */ new Set();
169
+ sinks.add(sink);
170
+ return sinks;
171
+ }
172
+ /**
173
+ * Prochain instant où la présence d'un canal a quelque chose à faire : la fin de la fenêtre si des changements attendent, la prochaine fin de grâce — `Infinity` sinon. O(1).
174
+ */
175
+ function presenceDue(presence) {
176
+ const deadline = presence.nextDeadline();
177
+ return presence.dirty && presence.windowEnd < deadline ? presence.windowEnd : deadline;
178
+ }
198
179
  /**
199
180
  * RealtimeHub — broker temps réel **PAR INSTANCE** (1 pod = 1 process = 1 hub local).
200
181
  *
@@ -224,6 +205,30 @@ const USER_CHANNEL_DISPOSE = () => {};
224
205
  */
225
206
  var RealtimeHub = class RealtimeHub {
226
207
  #channels = null;
208
+ #presenceActive = null;
209
+ #presenceTimer = null;
210
+ #presenceTimerAt = Number.POSITIVE_INFINITY;
211
+ #presenceBus = null;
212
+ #presenceServed = null;
213
+ /**
214
+ * Présences RÉPARTIES dont le dernier client local est parti pendant une grâce :
215
+ * encore servies au bus jusqu'à l'échéance, reprises si le canal se rouvre ici
216
+ * (ADR-0025 P5). `null` tant qu'aucune n'attend — le cas de presque tous les pods.
217
+ */
218
+ #presenceLingering = null;
219
+ /** Publication remise aux fabriques de canal — UNE fermeture par hub, pas une par canal ouvert. */
220
+ #publishFn = (ch, payload) => {
221
+ this.publish(ch, payload);
222
+ };
223
+ /** Rappel du tic de présence — UNE fermeture par hub, pas une par armement. */
224
+ #presenceTickFn = () => {
225
+ this.#presenceTick();
226
+ };
227
+ #presenceDeclared = false;
228
+ #presenceRejectedTotal = 0;
229
+ #presenceListTotal = 0;
230
+ #presenceFrameTotal = 0;
231
+ #presenceListBuiltTotal = 0;
227
232
  #publishTotal = 0;
228
233
  #fanoutTotal = 0;
229
234
  #closedBytesSent = 0;
@@ -233,6 +238,7 @@ var RealtimeHub = class RealtimeHub {
233
238
  #closedMalformedDiscarded = 0;
234
239
  #inboundTotal = 0;
235
240
  #ingressRejectedTotal = 0;
241
+ #ingressUnknownDiscardedTotal = 0;
236
242
  #localOnlyTotal = 0;
237
243
  #systemFloorDeniedTotal = 0;
238
244
  #notice = null;
@@ -252,12 +258,14 @@ var RealtimeHub = class RealtimeHub {
252
258
  #channelProbe = null;
253
259
  #retainers = null;
254
260
  #channelPolicies = null;
261
+ #policyDeclarations = null;
255
262
  /**
256
263
  * Classes de contrôleur dont les politiques sont déjà inscrites — `null`
257
264
  * tant qu'aucune ne l'est. Une poignée de main n'en paie qu'un `has` :
258
265
  * l'inscription elle-même n'a lieu qu'une fois par classe.
259
266
  */
260
267
  #policyOwners = null;
268
+ #policyClaims = null;
261
269
  /**
262
270
  * Politiques déclarées par MOTIF de nom (`chat:room:*`), triées du plus
263
271
  * spécifique au plus général, et `null` tant qu'aucune n'est déclarée.
@@ -319,11 +327,116 @@ var RealtimeHub = class RealtimeHub {
319
327
  if (!this.subscribe(channel, sink, factory, buildFrame)) return false;
320
328
  if (member !== void 0) {
321
329
  const st = this.#channels?.get(channel);
322
- if (st !== void 0) (st.members ??= /* @__PURE__ */ new Map()).set(sink, member);
330
+ if (st !== void 0) {
331
+ if (st.members === null) {
332
+ st.members = /* @__PURE__ */ new Map();
333
+ const settings = presenceSettingsOf(this.resolveChannelPolicy(channel));
334
+ if (settings !== null) this.#openPresence(st, channel, settings);
335
+ }
336
+ st.members.set(sink, member);
337
+ const presence = st.presence;
338
+ if (presence !== null) {
339
+ presence.join(presenceKeyOf(member));
340
+ if (presence.distributed) this.#presenceBusChanged(presence);
341
+ }
342
+ }
323
343
  }
324
344
  return true;
325
345
  }
326
346
  /**
347
+ * Ouvre la présence d'un canal à son premier client : répartie s'il est diffusé et qu'un bus est branché, locale sinon (avis unique : un bus est là, ce canal n'en profite pas).
348
+ */
349
+ #openPresence(st, channel, settings) {
350
+ const backplane = this.#backplane;
351
+ const distributed = backplane !== null && st.forward;
352
+ const lingering = this.#presenceLingering?.get(channel);
353
+ if (lingering !== void 0 && distributed) {
354
+ this.#presenceLingering?.delete(channel);
355
+ if (this.#presenceLingering?.size === 0) this.#presenceLingering = null;
356
+ lingering.live = true;
357
+ st.presence = lingering;
358
+ return;
359
+ }
360
+ const presence = new ChannelPresence(channel, settings, distributed);
361
+ st.presence = presence;
362
+ if (!distributed) {
363
+ if (backplane !== null) this.#notifyOnce("presence-local", `realtime: le canal « ${channel} » déclare la présence sans être diffusé entre serveurs (@RealtimeBroadcast) : sa présence ne compte que les connexions de CE serveur.`, "INFO");
364
+ return;
365
+ }
366
+ const bus = this.#presenceBusFor(backplane);
367
+ (this.#presenceServed ??= /* @__PURE__ */ new Set()).add(presence);
368
+ const now = performance.now();
369
+ bus.served(presence, now);
370
+ this.#armPresenceAt(bus.due(), now);
371
+ }
372
+ /** La présence répartie de ce hub, créée au premier besoin. */
373
+ #presenceBusFor(backplane) {
374
+ const existing = this.#presenceBus;
375
+ if (existing !== null) return existing;
376
+ const bus = new PresenceBus({
377
+ originId: backplane.originId,
378
+ publish: (channel, payload) => backplane.publish(channel, payload),
379
+ lookup: (channel) => this.#presenceLookup(channel),
380
+ served: (channel) => {
381
+ const presence = this.#channels?.get(channel)?.presence;
382
+ return presence != null && presence.distributed && presence.live ? presence : null;
383
+ },
384
+ servedPresences: () => this.#presenceServed ?? EMPTY_PRESENCES,
385
+ servedCount: () => this.#presenceServed?.size ?? 0,
386
+ echoTotal: () => backplane.describe().echoTotal
387
+ });
388
+ this.#presenceBus = bus;
389
+ return bus;
390
+ }
391
+ /**
392
+ * Résout un canal nommé par un pair : sa présence s'il est servi et diffusé ici ; `null` s'il déclare la présence et se diffuse sans être servi (lu puis jeté) ; `false` sinon (refusé, compté).
393
+ */
394
+ #presenceLookup(channel) {
395
+ const presence = this.#channels?.get(channel)?.presence;
396
+ if (presence != null && presence.distributed && presence.live) return presence;
397
+ if (!this.#isBroadcast(channel) || presenceSettingsOf(this.resolveChannelPolicy(channel)) === null) return false;
398
+ return null;
399
+ }
400
+ /** La part locale d'une présence répartie a changé : prochain lot armé. */
401
+ #presenceBusChanged(presence) {
402
+ const bus = this.#presenceBus;
403
+ if (bus === null) return;
404
+ const now = performance.now();
405
+ bus.changed(presence, now);
406
+ this.#armPresenceAt(bus.due(), now);
407
+ }
408
+ /** Rafraîchit les canaux que le bus a touchés (vue des pairs, état incomplet). */
409
+ #presenceTouchFromBus(bus, now) {
410
+ const touched = bus.takeTouched();
411
+ if (touched === null) return;
412
+ const channels = this.#channels;
413
+ for (const presence of touched) {
414
+ const st = channels?.get(presence.channel);
415
+ if (st === void 0 || st.presence !== presence) continue;
416
+ if (presence.busDirty) bus.changed(presence, now);
417
+ this.#presenceTouch(st, presence, now);
418
+ }
419
+ }
420
+ /**
421
+ * Entrée des deux canaux de présence du bus (ADR-0025 P2) — lots, demandes, réponses, preuve de vie.
422
+ */
423
+ #admitPresence(msg, alive) {
424
+ let bus = this.#presenceBus;
425
+ if (bus === null) {
426
+ const backplane = this.#backplane;
427
+ if (!this.#presenceDeclared || backplane === null) {
428
+ this.#presenceRejectedTotal += 1;
429
+ return;
430
+ }
431
+ bus = this.#presenceBusFor(backplane);
432
+ }
433
+ const now = performance.now();
434
+ if (alive) bus.receiveAlive(msg.originId, msg.payload, now);
435
+ else if (bus.receive(msg.originId, msg.payload, now)) this.#discardUnknownFromBus("presence");
436
+ this.#presenceTouchFromBus(bus, now);
437
+ this.#armPresenceAt(bus.due(), now);
438
+ }
439
+ /**
327
440
  * Abonne un consommateur qui **répond du canal** : il apporte la `factory`, donc
328
441
  * la réponse à « ce canal existe-t-il, et qui le produit ? ». Le provider partagé
329
442
  * est créé au 1ᵉʳ abonné de ce genre. Le sink est inscrit AVANT l'appel à
@@ -359,8 +472,9 @@ var RealtimeHub = class RealtimeHub {
359
472
  st = {
360
473
  dispose: null,
361
474
  owned: false,
362
- sinks: /* @__PURE__ */ new Set([sink]),
475
+ sinks: singleSink(sink),
363
476
  members: null,
477
+ presence: null,
364
478
  buildFrame: buildFrame ?? null,
365
479
  messages: 0,
366
480
  forward: this.#isBroadcast(channel)
@@ -399,8 +513,9 @@ var RealtimeHub = class RealtimeHub {
399
513
  channels.set(channel, {
400
514
  dispose: null,
401
515
  owned: false,
402
- sinks: /* @__PURE__ */ new Set([sink]),
516
+ sinks: singleSink(sink),
403
517
  members: null,
518
+ presence: null,
404
519
  buildFrame: null,
405
520
  messages: 0,
406
521
  forward: this.#isBroadcast(channel)
@@ -418,7 +533,7 @@ var RealtimeHub = class RealtimeHub {
418
533
  * canaux SYSTÈME en dernier recours. `null` = canal inconnu des deux.
419
534
  */
420
535
  #createProvider(channel, factory) {
421
- const publishFn = (ch, payload) => this.publish(ch, payload);
536
+ const publishFn = this.#publishFn;
422
537
  const dispose = factory(channel, publishFn);
423
538
  if (dispose !== null) return dispose;
424
539
  const sys = this.#systemChannelFactories?.get(channel);
@@ -434,15 +549,190 @@ var RealtimeHub = class RealtimeHub {
434
549
  const st = channels?.get(channel);
435
550
  if (!channels || !st) return;
436
551
  st.sinks.delete(sink);
552
+ const presence = st.presence;
553
+ if (presence !== null) {
554
+ const member = st.members?.get(sink);
555
+ if (member !== void 0) {
556
+ const now = performance.now();
557
+ presence.leave(presenceKeyOf(member), now);
558
+ this.#presenceTouch(st, presence, now);
559
+ if (presence.distributed) this.#presenceBusChanged(presence);
560
+ }
561
+ }
437
562
  st.members?.delete(sink);
438
563
  if (st.sinks.size === 0) {
439
564
  try {
440
565
  st.dispose?.();
441
566
  } catch {}
442
567
  channels.delete(channel);
568
+ if (presence !== null) {
569
+ presence.live = false;
570
+ if (!presence.distributed) {} else if (presence.hasLocalGrace) this.#lingerPresence(presence);
571
+ else this.#releasePresence(presence);
572
+ }
443
573
  }
444
574
  }
445
575
  /**
576
+ * Le dernier client local d'une présence répartie est parti pendant une grâce : elle reste servie au bus jusqu'à l'échéance (ADR-0025 P5) — le départ n'est publié qu'à ce moment, et un retour sur ce pod l'annule.
577
+ */
578
+ #lingerPresence(presence) {
579
+ (this.#presenceLingering ??= /* @__PURE__ */ new Map()).set(presence.channel, presence);
580
+ this.#armPresenceAt(presence.nextDeadline(), performance.now());
581
+ }
582
+ /**
583
+ * Un canal à présence répartie n'est plus servi ici : sa part locale part à zéro au prochain lot (aucun fantôme chez les pairs), sa vue des pairs est jetée.
584
+ */
585
+ #releasePresence(presence) {
586
+ this.#presenceServed?.delete(presence);
587
+ const bus = this.#presenceBus;
588
+ if (bus === null) return;
589
+ bus.remote.dropChannel(presence.channel);
590
+ presence.release();
591
+ this.#presenceBusChanged(presence);
592
+ }
593
+ /**
594
+ * Présence d'un canal vue de CE pod (ADR-0025 P3) — `count`, `unnamed`, `members` (toujours, quel que soit le seuil de la trame), `partial`.
595
+ *
596
+ * La voie de l'observateur qui lit sans figurer (modérateur, tableau de
597
+ * bord). Chemin froid : une copie à chaque appel.
598
+ *
599
+ * @param channel - nom exact du canal.
600
+ * @returns la vue (vide si personne n'est là), ou `null` si la politique du
601
+ * canal ne déclare pas `presence`.
602
+ */
603
+ presence(channel) {
604
+ const live = this.#channels?.get(channel)?.presence;
605
+ if (live != null) return live.view();
606
+ if (presenceSettingsOf(this.resolveChannelPolicy(channel)) === null) return null;
607
+ return {
608
+ count: 0,
609
+ unnamed: 0,
610
+ members: /* @__PURE__ */ new Map(),
611
+ partial: false
612
+ };
613
+ }
614
+ /**
615
+ * Arrêt propre de la présence répartie (ADR-0025 P5) — le dernier lot part, puis une preuve de vie FINALE : les pairs retirent aussitôt les présents de ce serveur, sans attendre le délai d'oubli. Plus rien ne part ensuite ; idempotent, sans effet sans bus.
616
+ *
617
+ * Appelé par le module à l'arrêt du noyau (`onTerminate`), AVANT que le bus ne
618
+ * se ferme. Les connexions qui se ferment ensuite ne publient plus rien : la
619
+ * preuve de vie finale les a déjà retirées chez les pairs.
620
+ */
621
+ endPresence() {
622
+ const bus = this.#presenceBus;
623
+ if (bus === null) return;
624
+ bus.end();
625
+ if (this.#presenceTimer !== null) clearTimeout(this.#presenceTimer);
626
+ this.#presenceTimer = null;
627
+ this.#presenceTimerAt = Number.POSITIVE_INFINITY;
628
+ }
629
+ /**
630
+ * Un abonnement vient d'être acquitté : sur un canal à présence, la liste complète part vers CETTE connexion, puis les changements en attente partent si la fenêtre le permet.
631
+ *
632
+ * Appelé par le contrôleur APRÈS `realtime:subscribed`, à chaque (re-)`subscribe`
633
+ * — aucune borne : la contre-pression peut avoir jeté la liste d'avant sans
634
+ * le dire, et c'est le re-`subscribe` qui répare (ADR-0025 P2 bis). La liste
635
+ * est encodée une fois par envoi de changements et par codec, puis servie
636
+ * telle quelle.
637
+ *
638
+ * @param channel - nom exact du canal acquitté.
639
+ * @param connection - la connexion abonnée.
640
+ */
641
+ presenceSubscribed(channel, connection) {
642
+ const st = this.#channels?.get(channel);
643
+ const presence = st?.presence;
644
+ if (st === void 0 || presence == null) return;
645
+ if (presence.listFrame === null) {
646
+ presence.listFrame = new ChannelFrame(JsonRpcPeer.buildNotification(REALTIME_PRESENCE_METHOD, presence.listParams()));
647
+ this.#presenceListBuiltTotal += 1;
648
+ }
649
+ this.#presenceListTotal += 1;
650
+ connection.deliver(presence.listFrame);
651
+ this.#presenceTouch(st, presence, performance.now());
652
+ }
653
+ /**
654
+ * Un changement de présence vient d'avoir lieu : part tout de suite sur un canal calme (front montant), sinon au tic qui ferme la fenêtre.
655
+ */
656
+ #presenceTouch(st, presence, now) {
657
+ if (presence.dirty && now >= presence.windowEnd) this.#presenceFlush(st, presence, now);
658
+ const due = presenceDue(presence);
659
+ if (due === 1e400) return;
660
+ (this.#presenceActive ??= /* @__PURE__ */ new Set()).add(st);
661
+ this.#armPresenceAt(due, now);
662
+ }
663
+ /** Envoie les changements en attente à tous les membres, et ouvre une fenêtre. */
664
+ #presenceFlush(st, presence, now) {
665
+ const params = presence.takeChanges();
666
+ if (params === null) return;
667
+ presence.windowEnd = now + 250;
668
+ const members = st.members;
669
+ if (members === null || members.size === 0) return;
670
+ const frame = new ChannelFrame(JsonRpcPeer.buildNotification(REALTIME_PRESENCE_METHOD, params));
671
+ this.#presenceFrameTotal += 1;
672
+ for (const member of members.values()) try {
673
+ member.deliver(frame);
674
+ } catch {}
675
+ }
676
+ /**
677
+ * Avance le minuteur UNIQUE de la présence à `at` s'il est plus proche que l'échéance déjà armée — O(1) par changement, aucun parcours des canaux, aucun minuteur par canal ni par présent.
678
+ */
679
+ #armPresenceAt(next, now) {
680
+ if (this.#presenceTimer !== null && this.#presenceTimerAt <= next) return;
681
+ if (this.#presenceTimer !== null) clearTimeout(this.#presenceTimer);
682
+ this.#presenceTimerAt = next;
683
+ this.#presenceTimer = setTimeout(this.#presenceTickFn, Math.max(0, Math.ceil(next - now)));
684
+ this.#presenceTimer.unref();
685
+ }
686
+ /** Tic unique : grâces écoulées, fenêtres échues ; se retire quand plus rien n'attend. */
687
+ #presenceTick() {
688
+ this.#presenceTimer = null;
689
+ this.#presenceTimerAt = Number.POSITIVE_INFINITY;
690
+ const now = performance.now();
691
+ const bus = this.#presenceBus;
692
+ if (bus !== null) {
693
+ bus.tick(now);
694
+ this.#presenceTouchFromBus(bus, now);
695
+ }
696
+ let next = Number.POSITIVE_INFINITY;
697
+ const active = this.#presenceActive;
698
+ if (active !== null) {
699
+ for (const st of active) {
700
+ const presence = st.presence;
701
+ if (presence === null || !presence.live) {
702
+ active.delete(st);
703
+ continue;
704
+ }
705
+ presence.expire(now);
706
+ if (bus !== null && presence.busDirty) bus.changed(presence, now);
707
+ if (presence.dirty && now >= presence.windowEnd) this.#presenceFlush(st, presence, now);
708
+ const due = presenceDue(presence);
709
+ if (due === 1e400) active.delete(st);
710
+ else if (due < next) next = due;
711
+ }
712
+ if (active.size === 0) this.#presenceActive = null;
713
+ }
714
+ const lingering = this.#presenceLingering;
715
+ if (lingering !== null) {
716
+ for (const presence of lingering.values()) {
717
+ presence.expire(now);
718
+ if (bus !== null && presence.busDirty) bus.changed(presence, now);
719
+ if (presence.hasLocalGrace) {
720
+ const due = presence.nextDeadline();
721
+ if (due < next) next = due;
722
+ } else {
723
+ lingering.delete(presence.channel);
724
+ this.#releasePresence(presence);
725
+ }
726
+ }
727
+ if (lingering.size === 0) this.#presenceLingering = null;
728
+ }
729
+ if (bus !== null) {
730
+ const due = bus.due();
731
+ if (due < next) next = due;
732
+ }
733
+ if (next !== Number.POSITIVE_INFINITY) this.#armPresenceAt(next, now);
734
+ }
735
+ /**
446
736
  * Les connexions abonnées à un canal par un client — **local à ce pod** : les membres servis par un autre exemplaire n'y figurent pas.
447
737
  *
448
738
  * Les écouteurs et services du serveur (`listen`, `subscribe`) ne sont pas
@@ -569,7 +859,12 @@ var RealtimeHub = class RealtimeHub {
569
859
  /** Applique un ordre reçu du bus : forme vérifiée, canal de la plateforme refusé, sinon compté en rejet. */
570
860
  #applyControl(payload) {
571
861
  const order = readControlOrder(payload);
572
- if (order === null || isReservedSystemChannel(order.channel)) {
862
+ if (order === null) {
863
+ if (isUnknownControlOrder(payload)) this.#discardUnknownFromBus("order");
864
+ else this.#ingressRejectedTotal += 1;
865
+ return;
866
+ }
867
+ if (isReservedSystemChannel(order.channel)) {
573
868
  this.#ingressRejectedTotal += 1;
574
869
  return;
575
870
  }
@@ -722,14 +1017,23 @@ var RealtimeHub = class RealtimeHub {
722
1017
  if (prefixes.includes(prefix)) return;
723
1018
  prefixes.push(prefix);
724
1019
  if (this.#channels) {
725
- for (const [channel, st] of this.#channels) if (!st.forward && channel.startsWith(prefix)) st.forward = true;
1020
+ for (const [channel, st] of this.#channels) if (!st.forward && channel.startsWith(prefix) && !isPlatformChannel(channel)) st.forward = true;
726
1021
  }
727
1022
  }
728
- /** Un canal est-il broadcast (préfixe déclaré) ? `false` si aucune politique (défaut local). */
1023
+ /**
1024
+ * Un canal est-il broadcast (préfixe déclaré) ? `false` si aucune politique (défaut local).
1025
+ *
1026
+ * Un canal de PLATEFORME ne l'est jamais, quel que soit le préfixe : un
1027
+ * préfixe large (`""`, `"n"`) le couvrirait, et {@link markBroadcastChannel}
1028
+ * ne refuse que le préfixe réservé lui-même. Sans cette exclusion, ses
1029
+ * publications sortaient sur le bus et l'entrée acceptait de faux évènements
1030
+ * d'admin venus d'un pair (F83). Le test n'est payé qu'après une
1031
+ * correspondance de préfixe.
1032
+ */
729
1033
  #isBroadcast(channel) {
730
1034
  const prefixes = this.#broadcastPrefixes;
731
1035
  if (prefixes === null) return false;
732
- for (const prefix of prefixes) if (channel.startsWith(prefix)) return true;
1036
+ for (const prefix of prefixes) if (channel.startsWith(prefix)) return !isPlatformChannel(channel);
733
1037
  return false;
734
1038
  }
735
1039
  /**
@@ -744,6 +1048,9 @@ var RealtimeHub = class RealtimeHub {
744
1048
  */
745
1049
  setBackplane(backplane) {
746
1050
  this.#backplane = backplane;
1051
+ this.#presenceBus = null;
1052
+ this.#presenceServed = null;
1053
+ this.#presenceLingering = null;
747
1054
  this.markBroadcastChannel(USER_CHANNEL_PREFIX);
748
1055
  backplane.onMessage((msg) => this.#admitFromBackplane(msg));
749
1056
  this.#armBusBytes();
@@ -775,18 +1082,50 @@ var RealtimeHub = class RealtimeHub {
775
1082
  * Seule exception, AVANT ce contrôle : le canal de contrôle
776
1083
  * (`PLATFORM_BUS_CHANNELS.control`), jamais diffusé aux abonnés — ses ordres,
777
1084
  * en liste fermée, sont appliqués aux connexions de ce pod ou comptés en rejet.
1085
+ * Puis, avant le contrôle aussi : un AUTRE canal du territoire `nodefony:bus:`
1086
+ * vient d'une version plus récente — jeté et compté à part
1087
+ * ({@link #discardUnknownFromBus}), jamais comme une injection.
778
1088
  */
779
1089
  #admitFromBackplane(msg) {
1090
+ const st = this.#channels?.get(msg.channel);
1091
+ if (st?.forward === true) {
1092
+ this.#fanout(st, msg.payload);
1093
+ return;
1094
+ }
780
1095
  if (msg.channel === PLATFORM_BUS_CHANNELS.control) {
781
1096
  this.#applyControl(msg.payload);
782
1097
  return;
783
1098
  }
1099
+ if (isPlatformBusChannel(msg.channel)) {
1100
+ if (msg.channel === PLATFORM_BUS_CHANNELS.presence) this.#admitPresence(msg, false);
1101
+ else if (msg.channel === PLATFORM_BUS_CHANNELS.presenceAlive) this.#admitPresence(msg, true);
1102
+ else this.#discardUnknownFromBus("channel");
1103
+ return;
1104
+ }
784
1105
  if (!this.#isBroadcast(msg.channel)) {
785
1106
  this.#ingressRejectedTotal += 1;
786
1107
  return;
787
1108
  }
788
1109
  this.publishLocal(msg.channel, msg.payload);
789
1110
  }
1111
+ /**
1112
+ * Jette un message du bus venu d'une version plus RÉCENTE — canal `nodefony:bus:` ou type d'ordre inconnus — et le compte à part.
1113
+ *
1114
+ * Règle de lecture figée par l'ADR-0025 (S-rt-92) : pendant un déploiement
1115
+ * progressif, un serveur ancien reçoit à la cadence du message ce qu'une
1116
+ * version suivante ajoute au bus. Le compter dans `#ingressRejectedTotal`
1117
+ * déclencherait de fausses alertes d'injection et noierait la vraie. Rien
1118
+ * n'atteint un abonné ; le journal ne parle qu'une fois par motif (pas une
1119
+ * ligne par message), sans recopier le nom reçu — une donnée venue du bus
1120
+ * n'entre pas dans le journal.
1121
+ *
1122
+ * @param kind - `channel` (canal inconnu) ou `order` (type d'ordre inconnu).
1123
+ */
1124
+ #discardUnknownFromBus(kind) {
1125
+ this.#ingressUnknownDiscardedTotal += 1;
1126
+ const what = kind === "channel" ? "un canal de la plateforme inconnu" : kind === "order" ? "un ordre de contrôle d'un type inconnu" : "un message de présence d'un type inconnu";
1127
+ this.#notifyOnce(`backplane:unknown-${kind}`, `realtime: ${what} de cette version est arrivé par le bus — ignoré (un serveur plus récent pendant un déploiement ?), compté dans ingressUnknownDiscardedTotal`, "INFO");
1128
+ }
790
1129
  /** Backplane cross-process branché, ou `null` en mono-process (lecture/tests/sonde). */
791
1130
  get backplane() {
792
1131
  return this.#backplane;
@@ -911,6 +1250,7 @@ var RealtimeHub = class RealtimeHub {
911
1250
  fanoutTotal: this.#fanoutTotal,
912
1251
  inboundTotal: this.#inboundTotal,
913
1252
  ingressRejectedTotal: this.#ingressRejectedTotal,
1253
+ ingressUnknownDiscardedTotal: this.#ingressUnknownDiscardedTotal,
914
1254
  localOnlyTotal: this.#localOnlyTotal,
915
1255
  systemFloorDeniedTotal: this.#systemFloorDeniedTotal,
916
1256
  sealRejectedTotal: backplane.sealRejectedTotal ?? 0,
@@ -929,7 +1269,38 @@ var RealtimeHub = class RealtimeHub {
929
1269
  slowConsumers,
930
1270
  drops
931
1271
  },
932
- backplane
1272
+ backplane,
1273
+ presence: this.#presenceProbe()
1274
+ };
1275
+ }
1276
+ /** Compteurs de la présence (chemin froid : un parcours des canaux ouverts). */
1277
+ #presenceProbe() {
1278
+ let channels = 0;
1279
+ if (this.#channels !== null) {
1280
+ for (const st of this.#channels.values()) if (st.presence !== null) channels += 1;
1281
+ }
1282
+ const bus = this.#presenceBus;
1283
+ const remote = bus?.remote;
1284
+ return {
1285
+ channels,
1286
+ listTotal: this.#presenceListTotal,
1287
+ listBuiltTotal: this.#presenceListBuiltTotal,
1288
+ frameTotal: this.#presenceFrameTotal,
1289
+ distributedChannels: this.#presenceServed?.size ?? 0,
1290
+ pods: remote?.size ?? 0,
1291
+ gaps: remote?.gaps ?? 0,
1292
+ gapTotal: remote?.gapTotal ?? 0,
1293
+ forgottenTotal: remote?.forgottenTotal ?? 0,
1294
+ presenceRejectedTotal: this.#presenceRejectedTotal + (remote?.rejectedTotal ?? 0),
1295
+ presenceStaleDiscardedTotal: remote?.staleTotal ?? 0,
1296
+ batchTotal: bus?.batchTotal ?? 0,
1297
+ requestTotal: bus?.requestTotal ?? 0,
1298
+ answerTotal: bus?.answerTotal ?? 0,
1299
+ aliveTotal: bus?.aliveTotal ?? 0,
1300
+ endedTotal: remote?.endedTotal ?? 0,
1301
+ busLost: bus?.busLost ?? false,
1302
+ busLostTotal: bus?.busLostTotal ?? 0,
1303
+ rearmTotal: bus?.rearmTotal ?? 0
933
1304
  };
934
1305
  }
935
1306
  /** Canaux actifs (≥ 1 abonné). Lecture seule. */
@@ -1056,8 +1427,16 @@ var RealtimeHub = class RealtimeHub {
1056
1427
  * **Seam #1 (P13 → P6)** — pose le verrou de frame (cf {@link FrameAuthorizer}).
1057
1428
  * `null` retire la politique (bypass total). Posé 1× au boot par
1058
1429
  * `@nodefony/security` depuis les zones `realtime: true`. Cold path.
1430
+ *
1431
+ * @throws BootConfigurationError Le verrou ne déclare pas `judgesWrite`
1432
+ * alors qu'une écriture distincte de la lecture est inscrite : il la
1433
+ * laisserait ouverte.
1059
1434
  */
1060
1435
  setFrameAuthorizer(authorizer, options) {
1436
+ if (authorizer !== null && authorizer.judgesWrite !== true) {
1437
+ const defect = policyDeclarationsDefect(this.#policyDeclarations, this.#channelPolicyPatterns, false);
1438
+ if (defect !== null) throw new BootConfigurationError(defect);
1439
+ }
1061
1440
  this.#frameAuthorizer = authorizer;
1062
1441
  this.#dropRetainedVerdicts();
1063
1442
  this.#channelProbe = options?.silentProbe ?? null;
@@ -1123,10 +1502,7 @@ var RealtimeHub = class RealtimeHub {
1123
1502
  return this.hasChannelPolicies() && this.#frameAuthorizer === null;
1124
1503
  }
1125
1504
  /**
1126
- * **Seam #1b** — déclare la politique d'autorisation d'un canal/méthode
1127
- * (`@RealtimeChannel`/`@RealtimeInbound` avec opts). Appelé au handshake par
1128
- * le controller pour CHAQUE canal décoré (idempotent : même nom = écrase, les
1129
- * controllers d'un même endpoint déclarent la même policy). Cold path.
1505
+ * **Seam #1b** — pose la politique d'un canal/méthode en REMPLAÇANT ce que ce nom déclarait (voie programmatique ; les décorateurs passent par {@link RealtimeHub.declareChannelPolicy}). Cold path.
1130
1506
  *
1131
1507
  * Deux formes de nom, et la seconde garde une FAMILLE :
1132
1508
  *
@@ -1136,35 +1512,124 @@ var RealtimeHub = class RealtimeHub {
1136
1512
  * Le `*` remplace n'importe quelle suite de caractères ; tout le reste du
1137
1513
  * motif est littéral (échappé), y compris `.` et `(`.
1138
1514
  *
1139
- * Précédence : le nom **exact** l'emporte toujours — c'est ainsi qu'on ouvre
1140
- * une exception dans une famille gardée. Entre deux motifs, le plus
1141
- * **spécifique** gagne (littéral le plus long).
1515
+ * Précédence, SORTE PAR SORTE (ADR-0022 §6, décision 1 quater) : la lecture
1516
+ * d'un nom vient de la déclaration la plus spécifique qui POSE une lecture —
1517
+ * le nom **exact**, puis le motif au littéral le plus long ; l'écriture de
1518
+ * même. C'est ainsi qu'on ouvre une exception dans une famille gardée, sans
1519
+ * effacer la sorte que l'exception ne déclare pas.
1142
1520
  *
1143
- * @param name - nom exact du canal (subscribe), de la méthode inbound, ou
1521
+ * @param name - nom exact du canal, de la méthode entrante ou de l'action, ou
1144
1522
  * MOTIF de famille s'il contient `*`.
1145
- * Une politique à `allow` porte sa règle d'admission : une flèche est
1146
- * retenue telle quelle ; une classe exige son instance, résolue au démarrage
1147
- * par l'injecteur, dont la méthode est liée ICI, une fois — le `subscribe`
1148
- * n'appelle qu'une fonction prête.
1149
- *
1150
- * @param policy - exigences ({@link IChannelPolicy}) lues par security.
1523
+ * @param policy - exigences de LECTURE ({@link IChannelPolicy}) lues par
1524
+ * security ; seules exigées au `subscribe`.
1151
1525
  * @param admission - instance de la classe d'admission quand `policy.allow`
1152
1526
  * en est une ; ignorée sinon.
1153
- * @throws BootConfigurationError La politique désigne une classe d'admission
1154
- * et aucune instance de CETTE classe n'est fournie.
1527
+ * @param write - exigences d'ÉCRITURE, exigées EN PLUS de la lecture sur une
1528
+ * trame entrante ; `null` : ce nom n'en déclare pas.
1529
+ * @throws BootConfigurationError Classe d'admission sans instance ; `allow`
1530
+ * sur une écriture qui rencontre une lecture différente ; écriture distincte
1531
+ * face à un verrou qui ne sait pas la juger.
1155
1532
  */
1156
- registerChannelPolicy(name, policy, admission) {
1157
- const entry = {
1158
- policy,
1159
- admission: admissionRuleOf(name, policy, admission)
1533
+ registerChannelPolicy(name, policy, admission, write = null) {
1534
+ this.#declare(name, policy, write, null, admission, false);
1535
+ }
1536
+ /**
1537
+ * Déclare la lecture et/ou l'écriture d'un nom en les AJOUTANT à ce qu'il déclarait — une sorte posée remplace la sienne, une sorte `null` garde celle d'avant. Voie des décorateurs (`channelPolicyRegistry.ts#registerControllerPolicies`). Cold path.
1538
+ *
1539
+ * @param name - nom exact ou motif.
1540
+ * @param read - lecture déclarée, ou `null`.
1541
+ * @param write - écriture déclarée, ou `null`.
1542
+ * @param admission - instance de la classe d'admission désignée par l'une ou
1543
+ * l'autre ; ignorée sinon.
1544
+ * @throws BootConfigurationError Mêmes refus que {@link RealtimeHub.registerChannelPolicy}.
1545
+ */
1546
+ declareChannelPolicy(name, read, write, admission) {
1547
+ this.#declare(name, read, write, null, admission, true);
1548
+ }
1549
+ /**
1550
+ * Pose le défaut FERMÉ d'une action (`{ authenticated: true }`) — son ÉCRITURE tant que rien d'exact ne déclare son nom et qu'aucun motif ne pose d'écriture ; une lecture de motif s'y ajoute sans l'ouvrir. Idempotent : une poignée de main n'en paie qu'une lecture.
1551
+ *
1552
+ * @param name - nom de l'action.
1553
+ */
1554
+ declareActionDefault(name) {
1555
+ if (this.#policyDeclarations?.get(name)?.fallback === DEFAULT_ACTION_POLICY) return;
1556
+ this.#declare(name, null, null, DEFAULT_ACTION_POLICY, void 0, true);
1557
+ }
1558
+ /**
1559
+ * Refuse une présence mal déclarée (ADR-0025 P3) : valeur illisible, posée sur une ÉCRITURE seule (rien ne s'y abonne), ou sur un canal réservé de la plateforme.
1560
+ *
1561
+ * Une seule porte pour les décorateurs et la voie programmatique. Une
1562
+ * politique posée par `@RealtimePolicy` sert la lecture ET l'écriture sous le
1563
+ * même objet : elle est admise, c'est la lecture qui la porte.
1564
+ *
1565
+ * @throws BootConfigurationError En nommant le canal et le défaut.
1566
+ */
1567
+ static #assertPresence(name, read, write) {
1568
+ const refuse = (why) => {
1569
+ throw new BootConfigurationError(`canal temps réel « ${name} » : présence refusée — ${why}`);
1160
1570
  };
1161
- this.#dropRetainedVerdicts();
1571
+ for (const policy of [read, write]) {
1572
+ const defect = presenceDefect(policy?.presence);
1573
+ if (defect !== null) refuse(defect);
1574
+ }
1575
+ if (write !== null && write !== read && presenceSettingsOf(write) !== null) refuse("« presence » est posée sur une politique d'ÉCRITURE (@RealtimeInbound, @RealtimeAction), où rien ne s'abonne. La poser sur @RealtimeChannel.");
1576
+ if (presenceSettingsOf(read) !== null && isReservedSystemChannel(name)) refuse(`un canal réservé de la plateforme (${RESERVED_SYSTEM_PREFIXES.join(" ")}) ne publie pas sa présence.`);
1577
+ }
1578
+ /** Construit la déclaration d'un nom (remplacée ou fusionnée), puis la valide et la publie. */
1579
+ #declare(name, read, write, fallback, admission, merge) {
1162
1580
  if (isReservedSystemChannel(name)) this.#notifyOnce("reserved-policy", `le canal "${name}" est déclaré dans un namespace RÉSERVÉ à la plateforme (${RESERVED_SYSTEM_PREFIXES.join(" ")}) : le plancher système s'y applique et exigera un rôle d'administration, quelle que soit la politique déclarée ici. Renommer le canal avec un préfixe applicatif si ce n'est pas voulu.`);
1163
- if (name.includes("*")) {
1164
- this.#registerChannelPolicyPattern(name, entry);
1581
+ const isPattern = name.includes("*");
1582
+ const previous = isPattern ? this.#channelPolicyPatterns?.find((p) => p.source === name) : this.#policyDeclarations?.get(name);
1583
+ const declaration = declarationOf(name, read ?? (merge ? previous?.read ?? null : null), write ?? (merge ? previous?.write ?? null : null), fallback ?? previous?.fallback ?? null, admission ?? previous?.instance);
1584
+ RealtimeHub.#assertPresence(name, declaration.read, declaration.write);
1585
+ if (presenceSettingsOf(declaration.read) !== null) this.#presenceDeclared = true;
1586
+ if (!isPattern) {
1587
+ const exact = new Map(this.#policyDeclarations ?? []);
1588
+ exact.set(name, declaration);
1589
+ this.#commitPolicies(exact, this.#channelPolicyPatterns);
1165
1590
  return;
1166
1591
  }
1167
- (this.#channelPolicies ??= /* @__PURE__ */ new Map()).set(name, entry);
1592
+ const re = compileChannelPattern(name);
1593
+ if (re === null) {
1594
+ this.#notifyOnce(`pattern-too-broad:${name}`, `le motif de politique "${name}" porte ${countWildcards(name)} caractères « * » (maximum ${RealtimeHub.MAX_PATTERN_WILDCARDS}) : il est IGNORÉ. Un motif de canal en demande un, deux au pire — au-delà, la mise en correspondance coûte cher sur des noms que le client choisit.`);
1595
+ return;
1596
+ }
1597
+ const weight = channelPatternWeight(name);
1598
+ const entry = {
1599
+ ...declaration,
1600
+ source: name,
1601
+ re,
1602
+ weight
1603
+ };
1604
+ const patterns = [...this.#channelPolicyPatterns ?? []];
1605
+ const at = patterns.findIndex((p) => p.source === name);
1606
+ if (at >= 0) patterns[at] = entry;
1607
+ else {
1608
+ const rank = patterns.findIndex((p) => p.weight < weight);
1609
+ if (rank < 0) patterns.push(entry);
1610
+ else patterns.splice(rank, 0, entry);
1611
+ }
1612
+ this.#commitPolicies(this.#policyDeclarations, patterns);
1613
+ }
1614
+ /**
1615
+ * Valide l'ensemble des déclarations, recalcule la vue fusionnée de chaque nom exact, puis la publie — rien n'est publié si la validation refuse.
1616
+ *
1617
+ * La vue est recalculée à chaque inscription (cold path) pour qu'une trame
1618
+ * sur un nom exact reste UNE lecture de `Map`, motifs compris.
1619
+ */
1620
+ #commitPolicies(exact, patterns) {
1621
+ const lock = this.#frameAuthorizer;
1622
+ const defect = policyDeclarationsDefect(exact, patterns, lock === null || lock.judgesWrite === true);
1623
+ if (defect !== null) throw new BootConfigurationError(defect);
1624
+ let merged = null;
1625
+ if (exact !== null) for (const [name, declaration] of exact) {
1626
+ const resolved = resolveDeclaredPolicies(name, declaration, patterns);
1627
+ if (resolved !== null) (merged ??= /* @__PURE__ */ new Map()).set(name, resolved);
1628
+ }
1629
+ this.#policyDeclarations = exact;
1630
+ this.#channelPolicyPatterns = patterns;
1631
+ this.#channelPolicies = merged;
1632
+ this.#dropRetainedVerdicts();
1168
1633
  }
1169
1634
  /**
1170
1635
  * Nombre maximum de `*` dans un motif.
@@ -1178,37 +1643,6 @@ var RealtimeHub = class RealtimeHub {
1178
1643
  */
1179
1644
  static MAX_PATTERN_WILDCARDS = MAX_CHANNEL_PATTERN_WILDCARDS;
1180
1645
  /**
1181
- * Compile un motif et l'insère à son rang de spécificité.
1182
- *
1183
- * Le motif est ÉCHAPPÉ intégralement avant que `*` ne devienne `.*` : rien de
1184
- * ce que l'auteur écrit n'est interprété comme une expression régulière. Un
1185
- * `.` reste un point, un `(` reste une parenthèse — sans quoi une déclaration
1186
- * anodine deviendrait une expression, avec ce que cela ouvre.
1187
- */
1188
- #registerChannelPolicyPattern(source, entry) {
1189
- const re = compileChannelPattern(source);
1190
- if (re === null) {
1191
- this.#notifyOnce(`pattern-too-broad:${source}`, `le motif de politique "${source}" porte ${countWildcards(source)} caractères « * » (maximum ${RealtimeHub.MAX_PATTERN_WILDCARDS}) : il est IGNORÉ. Un motif de canal en demande un, deux au pire — au-delà, la mise en correspondance coûte cher sur des noms que le client choisit.`);
1192
- return;
1193
- }
1194
- const weight = source.split("*").join("").length;
1195
- const policies = this.#channelPolicyPatterns ??= [];
1196
- const existing = policies.findIndex((p) => p.source === source);
1197
- const policyEntry = {
1198
- source,
1199
- re,
1200
- weight,
1201
- ...entry
1202
- };
1203
- if (existing >= 0) {
1204
- policies[existing] = policyEntry;
1205
- return;
1206
- }
1207
- const rank = policies.findIndex((p) => p.weight < weight);
1208
- if (rank < 0) policies.push(policyEntry);
1209
- else policies.splice(rank, 0, policyEntry);
1210
- }
1211
- /**
1212
1646
  * Politique déclarée pour ce canal/méthode, ou `null` si aucune. Lu par
1213
1647
  * `@nodefony/security` (`resolveChannelPolicy` de la surface DI) au moment de
1214
1648
  * la frame `subscribe`/inbound. O(1), 0 alloc quand le registre est vide.
@@ -1217,6 +1651,39 @@ var RealtimeHub = class RealtimeHub {
1217
1651
  return this.#resolvePolicyEntry(name)?.policy ?? null;
1218
1652
  }
1219
1653
  /**
1654
+ * Lecture ET écriture qui s'appliquent à ce nom, en UNE résolution — `null` si aucune politique ne le couvre (ADR-0022 §6, décision 1 quater).
1655
+ *
1656
+ * Même résolution que {@link resolveChannelPolicy} : l'entrée la plus
1657
+ * spécifique (nom exact, puis motif) gouverne les deux sortes. Rendue telle
1658
+ * quelle : aucune allocation, une seule recherche par trame jugée.
1659
+ *
1660
+ * @param name - nom du canal, de la méthode entrante ou de l'action.
1661
+ */
1662
+ resolveChannelPolicies(name) {
1663
+ return this.#resolvePolicyEntry(name);
1664
+ }
1665
+ /**
1666
+ * Retient la PREMIÈRE classe qui déclare une sorte de politique sous ce nom, et rend celle qui l'avait déjà déclarée — pour refuser au démarrage deux classes en désaccord.
1667
+ *
1668
+ * @param name - nom exact ou motif.
1669
+ * @param kind - sorte déclarée : lecture ou écriture.
1670
+ * @param owner - constructeur du contrôleur qui déclare.
1671
+ * @param policy - la politique déclarée.
1672
+ * @returns la déclaration antérieure de cette sorte, ou `null`.
1673
+ */
1674
+ claimChannelPolicy(name, kind, owner, policy) {
1675
+ const claims = this.#policyClaims ??= /* @__PURE__ */ new Map();
1676
+ const key = `${kind}\u0000${name}`;
1677
+ const previous = claims.get(key);
1678
+ if (previous !== void 0) return previous;
1679
+ claims.set(key, {
1680
+ owner,
1681
+ ownerName: typeof owner === "function" ? owner.name : "(contrôleur)",
1682
+ policy
1683
+ });
1684
+ return null;
1685
+ }
1686
+ /**
1220
1687
  * Règle d'admission prête à appeler pour ce canal — `null` s'il ne déclare pas `allow`. Lue au `subscribe`, jamais par trame.
1221
1688
  *
1222
1689
  * Même résolution que {@link resolveChannelPolicy} (nom exact, puis motif le
@@ -1241,23 +1708,23 @@ var RealtimeHub = class RealtimeHub {
1241
1708
  addPolicyOwner(owner) {
1242
1709
  (this.#policyOwners ??= /* @__PURE__ */ new WeakSet()).add(owner);
1243
1710
  }
1244
- /** Entrée du registre qui s'applique à ce nom — exact d'abord, puis motif. */
1711
+ /**
1712
+ * Politiques qui s'appliquent à ce nom : la vue fusionnée d'un nom exact (une lecture de `Map`), sinon la résolution par motifs, sorte par sorte.
1713
+ */
1245
1714
  #resolvePolicyEntry(name) {
1246
- const exact = this.#channelPolicies?.get(name);
1247
- if (exact !== void 0) return exact;
1715
+ const merged = this.#channelPolicies?.get(name);
1716
+ if (merged !== void 0) return merged;
1248
1717
  const patterns = this.#channelPolicyPatterns;
1249
- if (patterns !== null) {
1250
- for (const pattern of patterns) if (pattern.re.test(name)) return pattern;
1251
- }
1252
- return name.startsWith(USER_CHANNEL_PREFIX) ? USER_CHANNEL_POLICY : null;
1718
+ if (patterns === null) return name.startsWith(USER_CHANNEL_PREFIX) ? USER_CHANNEL_DECLARATION.self : null;
1719
+ return resolveDeclaredPolicies(name, void 0, patterns);
1253
1720
  }
1254
1721
  /**
1255
- * Une politique est-elle déclarée sous ce nom EXACT — motifs ignorés ? C'est la question du défaut fermé des actions.
1722
+ * Ce nom EXACT porte-t-il une déclaration — motifs ignorés ?
1256
1723
  *
1257
1724
  * @param name - nom d'action ou de canal.
1258
1725
  */
1259
1726
  hasExactChannelPolicy(name) {
1260
- return this.#channelPolicies?.has(name) === true;
1727
+ return this.#policyDeclarations?.has(name) === true;
1261
1728
  }
1262
1729
  /** Des politiques de canal sont-elles déclarées (au moins une) ? */
1263
1730
  hasChannelPolicies() {
@@ -1305,16 +1772,6 @@ var RealtimeHub = class RealtimeHub {
1305
1772
  (this.#systemChannelFactories ??= /* @__PURE__ */ new Map()).set(channel, factory);
1306
1773
  }
1307
1774
  /**
1308
- * Pont hot-path entre `JsonRpcPeer.beforeDispatch` et le verrou métier : lit
1309
- * le token déjà caché du peer (O(1), 0 lecture base) puis délègue à
1310
- * l'authorizer. `true` (autorisée) si aucun verrou posé (bypass 0-coût) —
1311
- * mais en pratique `beforeDispatch` n'est branché que si `hasFrameAuthorizer()`.
1312
- *
1313
- * @param frame - frame JSON-RPC entrante (brute) à autoriser.
1314
- * @param peer - peer émetteur (clé du mapping `peer → token`).
1315
- * @returns `true` si la frame peut être dispatchée, `false` si refusée.
1316
- */
1317
- /**
1318
1775
  * Ouvre (ou referme) la réception des journaux du navigateur, avec ses bornes.
1319
1776
  *
1320
1777
  * @param limits - bornes de débit et de taille, ou `null` pour refermer le canal.
@@ -1326,6 +1783,38 @@ var RealtimeHub = class RealtimeHub {
1326
1783
  get clientLogsLimits() {
1327
1784
  return this.#clientLogs;
1328
1785
  }
1786
+ /**
1787
+ * La raison d'un refus du verrou, quand il sait la dire — `null` sinon, ou si l'explication lève.
1788
+ *
1789
+ * Chemin FROID : appelé après un refus, jamais par trame acceptée. Le
1790
+ * verdict, lui, est déjà rendu : une explication fautive ne change rien à la
1791
+ * décision, elle se tait.
1792
+ *
1793
+ * @param frame - la trame refusée.
1794
+ * @param peer - la connexion qui l'a émise.
1795
+ * @returns la phrase, ou `null`.
1796
+ */
1797
+ explainDenial(frame, peer) {
1798
+ const explain = this.#frameAuthorizer?.explain;
1799
+ if (explain === void 0) return null;
1800
+ try {
1801
+ const said = explain(frame, this.getTokenForPeer(peer));
1802
+ return typeof said === "string" && said.length > 0 ? said : null;
1803
+ } catch {
1804
+ return null;
1805
+ }
1806
+ }
1807
+ /**
1808
+ * Pont hot-path entre `JsonRpcPeer.beforeDispatch` et le verrou métier : lit
1809
+ * le token déjà caché du peer (O(1), 0 lecture base) puis délègue à
1810
+ * l'authorizer. `true` (autorisée) si aucun verrou posé : `beforeDispatch`
1811
+ * est armé sur TOUTE connexion, et c'est ici que le bypass se paie — un
1812
+ * appel et une comparaison de champ.
1813
+ *
1814
+ * @param frame - frame JSON-RPC entrante (brute) à autoriser.
1815
+ * @param peer - peer émetteur (clé du mapping `peer → token`).
1816
+ * @returns `true` si la frame peut être dispatchée, `false` si refusée.
1817
+ */
1329
1818
  runAuthorizer(frame, peer) {
1330
1819
  const authorizer = this.#frameAuthorizer;
1331
1820
  if (authorizer === null) return true;
@@ -1394,6 +1883,18 @@ var RealtimeHub = class RealtimeHub {
1394
1883
  }
1395
1884
  /** Dispose tous les providers et vide le hub (arrêt process / reset de test). */
1396
1885
  clear() {
1886
+ if (this.#presenceTimer !== null) clearTimeout(this.#presenceTimer);
1887
+ this.#presenceTimer = null;
1888
+ this.#presenceTimerAt = Number.POSITIVE_INFINITY;
1889
+ this.#presenceActive = null;
1890
+ this.#presenceBus = null;
1891
+ this.#presenceServed = null;
1892
+ this.#presenceLingering = null;
1893
+ this.#presenceDeclared = false;
1894
+ this.#presenceRejectedTotal = 0;
1895
+ this.#presenceListTotal = 0;
1896
+ this.#presenceFrameTotal = 0;
1897
+ this.#presenceListBuiltTotal = 0;
1397
1898
  if (this.#channels) {
1398
1899
  for (const st of this.#channels.values()) try {
1399
1900
  st.dispose?.();
@@ -1413,6 +1914,8 @@ var RealtimeHub = class RealtimeHub {
1413
1914
  this.#channelPolicies = null;
1414
1915
  this.#channelPolicyPatterns = null;
1415
1916
  this.#policyOwners = null;
1917
+ this.#policyClaims = null;
1918
+ this.#policyDeclarations = null;
1416
1919
  this.#originGuard = null;
1417
1920
  this.#backplane = null;
1418
1921
  this.#busBytes = false;
@@ -1425,6 +1928,7 @@ var RealtimeHub = class RealtimeHub {
1425
1928
  this.#closedMalformedDiscarded = 0;
1426
1929
  this.#inboundTotal = 0;
1427
1930
  this.#ingressRejectedTotal = 0;
1931
+ this.#ingressUnknownDiscardedTotal = 0;
1428
1932
  this.#localOnlyTotal = 0;
1429
1933
  this.#systemFloorDeniedTotal = 0;
1430
1934
  this.admissionBudget.reset();