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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +55 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +91 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
  6. package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
  7. package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
  8. package/dist/nodefony/src/backplane/RedisBackplane.js +146 -41
  9. package/dist/nodefony/src/backplane/controlOrder.js +86 -0
  10. package/dist/nodefony/src/backplane/envelope.js +505 -27
  11. package/dist/nodefony/src/backplane/ingress.js +93 -0
  12. package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
  13. package/dist/nodefony/src/backplane/publishQueue.js +97 -3
  14. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  15. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  16. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  17. package/dist/nodefony/src/server/RealtimeConnection.js +135 -0
  18. package/dist/nodefony/src/server/RealtimeController.js +409 -107
  19. package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
  20. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  21. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  22. package/dist/nodefony/src/server/channelPolicyRegistry.js +95 -0
  23. package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
  24. package/dist/nodefony/src/server/channelPresence.js +488 -0
  25. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  26. package/dist/nodefony/src/server/contractRoutes.js +75 -0
  27. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  28. package/dist/nodefony/src/server/frameAdmission.js +160 -0
  29. package/dist/nodefony/src/server/presenceBus.js +486 -0
  30. package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
  31. package/dist/nodefony/src/server/remotePresence.js +541 -0
  32. package/dist/nodefony/src/server/subscribedAck.js +13 -1
  33. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  34. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  35. package/dist/nodefony/src/service/RealtimeService.js +51 -1
  36. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  37. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  38. package/dist/nodefony/testing/backplaneConformance.js +347 -0
  39. package/dist/nodefony/testing/index.js +43 -12
  40. package/dist/nodefony/testing/presenceClient.js +54 -0
  41. package/dist/types/index.d.ts +39 -7
  42. package/dist/types/nodefony/config/config.d.ts +9 -0
  43. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
  44. package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
  45. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  46. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  47. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  48. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  49. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  50. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  51. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  52. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  53. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +89 -18
  54. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
  55. package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
  56. package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
  57. package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
  58. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
  59. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  60. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  61. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +20 -0
  62. package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
  63. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
  64. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  65. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  66. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +41 -0
  67. package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
  68. package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
  69. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  70. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  71. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  72. package/dist/types/nodefony/src/server/frameAdmission.d.ts +66 -0
  73. package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
  74. package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
  75. package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
  76. package/dist/types/nodefony/src/server/subscribedAck.d.ts +10 -0
  77. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  78. package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -2
  79. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  80. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  81. package/dist/types/nodefony/testing/backplaneConformance.d.ts +78 -0
  82. package/dist/types/nodefony/testing/index.d.ts +13 -2
  83. package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
  84. package/docs/actions.md +420 -18
  85. package/docs/architecture.md +101 -11
  86. package/docs/cadence-et-contre-pression.md +87 -11
  87. package/docs/canaux-a-membres.md +277 -0
  88. package/docs/cas-d-usage.md +1703 -0
  89. package/docs/configuration.md +175 -19
  90. package/docs/cookbook-chat.md +701 -224
  91. package/docs/index.md +11 -1
  92. package/docs/observabilite.md +97 -21
  93. package/docs/protocole.md +142 -33
  94. package/docs/securite.md +347 -41
  95. package/docs/vocabulaire.md +13 -12
  96. package/package.json +16 -13
@@ -0,0 +1,242 @@
1
+ import "./admissionBudget.js";
2
+ import resolveBackplaneOriginId from "../backplane/originId.js";
3
+ import { isPromise } from "nodefony";
4
+ // nodefony/src/server/connectionAdmission.ts
5
+ const QUEUED = 0;
6
+ const IN_FLIGHT = 1;
7
+ const DONE = 2;
8
+ /**
9
+ * Admission des abonnements d'UNE connexion aux canaux qui déclarent `allow` — appels bornés, file d'attente, délai, verdict (ADR-0021 §S10 et §S10 bis).
10
+ *
11
+ * Coûts, par construction :
12
+ * - une règle qui répond `true`/`false` sans promesse ne crée ni minuteur, ni
13
+ * promesse, ni entrée d'attente ;
14
+ * - au-delà de `maxPendingAllowsPerConnection` appels en vol, la demande ATTEND
15
+ * son tour (jamais `limit` : le rejeu au `welcome` renvoie tous les canaux
16
+ * d'un coup) ; la file reste `null` tant que personne n'attend ;
17
+ * - un seul minuteur par connexion, armé sur l'échéance de tête : les demandes
18
+ * arrivent dans l'ordre de leurs échéances, la plus ancienne non tranchée est
19
+ * donc toujours la prochaine à expirer.
20
+ *
21
+ * Seul `=== true` admet ; une exception ou un rejet valent `forbidden` ; un
22
+ * délai dépassé ou une saturation valent `unavailable`, et un verdict arrivé
23
+ * après coup est ignoré.
24
+ */
25
+ var ConnectionAdmission = class {
26
+ #host;
27
+ #budget;
28
+ #who;
29
+ #explain;
30
+ #pending = null;
31
+ #inFlight = 0;
32
+ #timer = null;
33
+ #onTimer = null;
34
+ /**
35
+ * @param host - la connexion qui ouvre ou refuse.
36
+ * @param budget - le budget de l'exemplaire (plafond global, coupe-circuit).
37
+ * @param who - identité d'admission gelée de la connexion.
38
+ * @param explain - rédiger le détail des refus (hors production).
39
+ */
40
+ constructor(host, budget, who, explain) {
41
+ this.#host = host;
42
+ this.#budget = budget;
43
+ this.#who = who;
44
+ this.#explain = explain;
45
+ }
46
+ /** Demandes en attente de verdict — comptées dans le plafond de canaux de la connexion. */
47
+ get unsettled() {
48
+ return this.#pending === null ? 0 : this.#pending.size;
49
+ }
50
+ /**
51
+ * Un abonnement à ce canal attend-il déjà son verdict ? S'il avait été annulé par un `unsubscribe`, il est ranimé : le verdict à venir répondra.
52
+ *
53
+ * @returns `true` si la demande est déjà en attente (rien d'autre à faire).
54
+ */
55
+ revive(channel) {
56
+ const record = this.#pending?.get(channel);
57
+ if (record === void 0) return false;
58
+ record.cancelled = false;
59
+ return true;
60
+ }
61
+ /** `unsubscribe` pendant l'attente : le verdict à venir sera jeté sans réponse. */
62
+ cancel(channel) {
63
+ const record = this.#pending?.get(channel);
64
+ if (record !== void 0) record.cancelled = true;
65
+ }
66
+ /**
67
+ * Demande l'admission sur `channel` selon sa règle — l'anonyme est refusé d'office sauf `anonymous: true`.
68
+ *
69
+ * @param channel - nom exact du canal demandé.
70
+ * @param rule - règle résolue par le hub pour ce canal.
71
+ */
72
+ request(channel, rule) {
73
+ if (!this.#who.authenticated && !rule.anonymous) {
74
+ this.#host.deny(channel, "forbidden", this.#explain ? `le canal « ${channel} » déclare une règle d'admission (allow) : une connexion anonyme n'y entre pas sans \`anonymous: true\` dans sa politique` : void 0);
75
+ return;
76
+ }
77
+ if (this.#inFlight >= this.#budget.maxPerConnection) {
78
+ this.#track(channel, rule, QUEUED);
79
+ return;
80
+ }
81
+ this.#call(channel, rule, null);
82
+ }
83
+ /** Fermeture de la connexion : minuteur coupé, places rendues, verdicts à venir jetés. */
84
+ close() {
85
+ if (this.#timer !== null) {
86
+ clearTimeout(this.#timer);
87
+ this.#timer = null;
88
+ }
89
+ const pending = this.#pending;
90
+ if (pending !== null) {
91
+ for (const record of pending.values()) {
92
+ if (record.status === IN_FLIGHT) this.#budget.release();
93
+ record.status = DONE;
94
+ }
95
+ this.#pending = null;
96
+ }
97
+ this.#inFlight = 0;
98
+ }
99
+ /** Appelle `allow` ; `record` est `null` pour une demande qui n'a pas attendu. */
100
+ #call(channel, rule, record) {
101
+ const granted = this.#budget.acquire();
102
+ if (granted !== -1) {
103
+ if (record !== null) this.#finish(record);
104
+ this.#host.deny(channel, "unavailable", !this.#explain ? void 0 : granted > 0 ? `admissions suspendues pendant ${granted} ms après une série de règles allow sans réponse sur ce serveur — réessaie après ce délai` : "trop d'admissions en cours sur ce serveur (maxPendingAllowsTotal) — réessaie", granted > 0 ? granted : void 0);
105
+ return;
106
+ }
107
+ let verdict;
108
+ try {
109
+ verdict = rule.allow(channel, this.#who);
110
+ } catch (error) {
111
+ this.#budget.release();
112
+ if (record !== null) this.#finish(record);
113
+ this.#refuseOnError(channel, error);
114
+ return;
115
+ }
116
+ if (!isPromise(verdict)) {
117
+ this.#budget.release();
118
+ if (record !== null) this.#finish(record);
119
+ this.#conclude(channel, verdict);
120
+ return;
121
+ }
122
+ const tracked = record ?? this.#track(channel, rule, IN_FLIGHT);
123
+ tracked.status = IN_FLIGHT;
124
+ this.#inFlight += 1;
125
+ verdict.then((value) => {
126
+ if (this.#settle(tracked)) this.#conclude(tracked.channel, value);
127
+ this.#pump();
128
+ }, (error) => {
129
+ if (this.#settle(tracked)) this.#refuseOnError(tracked.channel, error);
130
+ this.#pump();
131
+ });
132
+ }
133
+ /** Verdict d'une règle : seul `true` admet. */
134
+ #conclude(channel, verdict) {
135
+ if (verdict === true) {
136
+ this.#open(channel);
137
+ return;
138
+ }
139
+ this.#host.deny(channel, "forbidden", !this.#explain ? void 0 : verdict === false ? `la règle d'admission (allow) du canal « ${channel} » a refusé cette identité` : `la règle d'admission (allow) du canal « ${channel} » a rendu une valeur de type ${typeof verdict} : seul \`true\` admet`);
140
+ }
141
+ /** Une règle qui lève ou rejette refuse (fail-closed) ; le message ne part au client qu'hors production. */
142
+ #refuseOnError(channel, error) {
143
+ const warn = this.#budget.shouldWarn("throw", performance.now());
144
+ const message = warn || this.#explain ? error instanceof Error ? error.message : String(error) : "";
145
+ if (warn) this.#host.warn(`règle d'admission (allow) du canal « ${channel} » en échec : ${message}`);
146
+ this.#host.deny(channel, "forbidden", this.#explain ? `la règle d'admission (allow) du canal « ${channel} » a levé : ${message}` : void 0);
147
+ }
148
+ /** Ouvre le canal admis ; un échec d'ouverture est journalisé, jamais propagé (rappel de promesse ou de minuteur). */
149
+ #open(channel) {
150
+ try {
151
+ this.#host.open(channel);
152
+ } catch (error) {
153
+ this.#host.warn(`canal « ${channel} » admis mais non ouvert : ` + (error instanceof Error ? error.message : String(error)));
154
+ }
155
+ }
156
+ /**
157
+ * Termine un appel en vol dont le verdict arrive.
158
+ *
159
+ * @returns `false` si la demande est déjà tranchée (délai dépassé, connexion
160
+ * fermée) ou annulée : le verdict est alors jeté.
161
+ */
162
+ #settle(record) {
163
+ if (record.status === DONE) return false;
164
+ this.#inFlight -= 1;
165
+ this.#budget.release();
166
+ this.#finish(record);
167
+ return !record.cancelled;
168
+ }
169
+ /** Inscrit une demande qui attend ; arme le minuteur s'il ne l'est pas déjà sur une échéance plus proche. */
170
+ #track(channel, rule, status) {
171
+ const deadline = performance.now() + this.#budget.timeoutMs;
172
+ const record = {
173
+ channel,
174
+ rule,
175
+ deadline,
176
+ status,
177
+ cancelled: false
178
+ };
179
+ (this.#pending ??= /* @__PURE__ */ new Map()).set(channel, record);
180
+ if (this.#timer === null) this.#arm(deadline);
181
+ return record;
182
+ }
183
+ /** Marque une demande tranchée et la retire ; plus rien en attente → minuteur coupé. */
184
+ #finish(record) {
185
+ record.status = DONE;
186
+ const pending = this.#pending;
187
+ if (pending === null) return;
188
+ pending.delete(record.channel);
189
+ if (pending.size === 0 && this.#timer !== null) {
190
+ clearTimeout(this.#timer);
191
+ this.#timer = null;
192
+ }
193
+ }
194
+ /** Lance les demandes en attente, dans l'ordre, tant que des places se libèrent. */
195
+ #pump() {
196
+ while (this.unsettled > this.#inFlight && this.#inFlight < this.#budget.maxPerConnection) {
197
+ const record = this.#nextQueued();
198
+ if (record === null) return;
199
+ if (record.cancelled) {
200
+ this.#finish(record);
201
+ continue;
202
+ }
203
+ this.#call(record.channel, record.rule, record);
204
+ }
205
+ }
206
+ /** Première demande qui n'a pas encore appelé `allow` — les appels en vol, au plus `maxPerConnection`, la précèdent. */
207
+ #nextQueued() {
208
+ if (this.#pending === null) return null;
209
+ for (const record of this.#pending.values()) if (record.status === QUEUED) return record;
210
+ return null;
211
+ }
212
+ #arm(deadline) {
213
+ this.#timer = setTimeout(this.#onTimer ??= () => this.#expire(), Math.max(1, Math.ceil(deadline - performance.now())));
214
+ this.#timer.unref();
215
+ }
216
+ /** Échéance de tête atteinte : tranche en `unavailable` toutes les demandes échues, puis réarme sur la suivante. */
217
+ #expire() {
218
+ this.#timer = null;
219
+ const pending = this.#pending;
220
+ if (pending === null) return;
221
+ const now = performance.now();
222
+ let timedOut = null;
223
+ for (const record of pending.values()) {
224
+ if (record.deadline > now) {
225
+ this.#arm(record.deadline);
226
+ break;
227
+ }
228
+ const called = record.status === IN_FLIGHT;
229
+ if (called) {
230
+ this.#inFlight -= 1;
231
+ this.#budget.release();
232
+ this.#budget.recordTimeout(now);
233
+ timedOut = record.channel;
234
+ } else this.#budget.recordQueueExpired();
235
+ this.#finish(record);
236
+ if (!record.cancelled) this.#host.deny(record.channel, "unavailable", !this.#explain ? void 0 : called ? `la règle d'admission (allow) du canal « ${record.channel} » n'a pas répondu en ${this.#budget.timeoutMs} ms (allowTimeoutMs) — réessaie` : `demande d'abonnement à « ${record.channel} » restée en attente ${this.#budget.timeoutMs} ms derrière les admissions de cette connexion (allowTimeoutMs) — réessaie`, this.#budget.retryAfter(now));
237
+ }
238
+ if (timedOut !== null && this.#budget.shouldWarn("timeout", now)) this.#host.warn(`règle d'admission (allow) du canal « ${timedOut} » sans réponse en ${this.#budget.timeoutMs} ms (exemplaire ${resolveBackplaneOriginId()})`);
239
+ this.#pump();
240
+ }
241
+ };
242
+ export { ConnectionAdmission };
@@ -0,0 +1,75 @@
1
+ import { isRecord } from "../guards.js";
2
+ import { BootConfigurationError, bridgeServesVerb, isSimpleRouteTemplate } from "nodefony";
3
+ // nodefony/src/server/contractRoutes.ts
4
+ const BRIDGE_VERBS = /* @__PURE__ */ new Set([
5
+ "GET",
6
+ "POST",
7
+ "PUT",
8
+ "PATCH",
9
+ "DELETE"
10
+ ]);
11
+ const ROUTE_PARTS = [
12
+ "body",
13
+ "query",
14
+ "params"
15
+ ];
16
+ /** Chemin comparable : barres finales retirées, la racine reste `/`. */
17
+ function normalizePath(path) {
18
+ let end = path.length;
19
+ while (end > 1 && path.charCodeAt(end - 1) === 47) end--;
20
+ return end === path.length ? path : path.slice(0, end);
21
+ }
22
+ /**
23
+ * Lit la table `serverRoutes` qu'une classe de contrôleur déclare en statique —
24
+ * `null` quand elle n'en déclare pas (aucun contrôle).
25
+ *
26
+ * @param ctor - la classe du contrôleur.
27
+ * @returns la table, ou `null`.
28
+ * @throws BootConfigurationError si la table n'est pas un objet.
29
+ */
30
+ function readContractRoutes(ctor) {
31
+ const table = Reflect.get(ctor, "serverRoutes");
32
+ if (table === void 0) return null;
33
+ if (!isRecord(table)) throw new BootConfigurationError(`${typeof ctor === "function" ? ctor.name : "(contrôleur)"} : « serverRoutes » doit être un objet { "<VERBE> <chemin>": { body?, query? } }.`);
34
+ return table;
35
+ }
36
+ /**
37
+ * Vérifie, au démarrage, que chaque route du contrat est SERVIE par le pont :
38
+ * une route montée au même chemin, qui accepte le verbe par la socket, et dont
39
+ * les schémas sont LES MÊMES OBJETS que ceux que la table déclare. Coût nul par
40
+ * requête : la vérification n'a lieu qu'une fois.
41
+ *
42
+ * Sans elle, un contrat qui type `chat.post("/chat/send", …)` compilerait
43
+ * contre une route absente, servie en HTTP seulement, ou validant un autre
44
+ * schéma — et l'erreur n'apparaîtrait qu'au premier appel.
45
+ *
46
+ * @param owner - nom de la classe qui déclare la table (pour le message).
47
+ * @param table - la table `serverRoutes` (`"<VERBE> <chemin>"` → `{ body?, query? }`).
48
+ * @param mounted - les routes montées.
49
+ * @throws BootConfigurationError à la première entrée qui n'est pas servie ;
50
+ * le message nomme la classe, l'entrée et la cause.
51
+ */
52
+ function assertContractRoutes(owner, table, mounted) {
53
+ for (const entry in table) {
54
+ const where = `${owner}.serverRoutes « ${entry} »`;
55
+ const space = entry.indexOf(" ");
56
+ const verb = space === -1 ? "" : entry.slice(0, space);
57
+ const rawPath = space === -1 ? "" : entry.slice(space + 1);
58
+ if (!BRIDGE_VERBS.has(verb) || rawPath.charCodeAt(0) !== 47) throw new BootConfigurationError(`${where} : la clé s'écrit "<VERBE> <chemin>", VERBE parmi ${[...BRIDGE_VERBS].join(", ")}, chemin commençant par « / ».`);
59
+ const declared = table[entry];
60
+ if (!isRecord(declared)) throw new BootConfigurationError(`${where} : la valeur s'écrit { body?, query?, params? } (les schémas de la route).`);
61
+ for (const key in declared) if (key !== "body" && key !== "query" && key !== "params") throw new BootConfigurationError(`${where} : clé « ${key} » inconnue — seuls body, query et params se déclarent.`);
62
+ if (!isSimpleRouteTemplate(rawPath)) throw new BootConfigurationError(`${where} : seuls des segments « {nom} » entiers se déclarent dans un contrat — pas de contrainte en ligne « {id}(\\d+) », de segment facultatif « {slug}? » ni de variable collée « .{format} ». Contraindre la variable par « requirements » sur la route.`);
63
+ const path = normalizePath(rawPath);
64
+ const atPath = mounted.filter((r) => normalizePath(r.path) === path);
65
+ if (atPath.length === 0) throw new BootConfigurationError(`${where} : aucune route montée au chemin ${path}.`);
66
+ const route = atPath.find((r) => bridgeServesVerb(r.methods, verb));
67
+ if (route === void 0) throw new BootConfigurationError(`${where} : la route ${path} ne sert pas ${verb} par la socket — ses méthodes doivent inclure "${verb}" et "WEBSOCKET"${verb === "GET" ? " (ou « WEBSOCKET » seul)" : ""}.`);
68
+ for (const part of ROUTE_PARTS) {
69
+ const expected = declared[part];
70
+ const actual = route.schema?.[part];
71
+ if (expected !== actual) throw new BootConfigurationError(`${where} : le schéma « ${part} » ${expected === void 0 ? "n'est pas déclaré, mais la route en valide un" : actual === void 0 ? "est déclaré, mais la route n'en valide aucun" : "n'est pas celui que la route valide (même objet attendu)"}.`);
72
+ }
73
+ }
74
+ }
75
+ export { assertContractRoutes, readContractRoutes };
@@ -22,7 +22,14 @@
22
22
  * sinon un objet vide.
23
23
  */
24
24
  function deniedDetail(env, detail) {
25
- if (!env || env === "production") return {};
26
- return { detail };
25
+ return explainsDenials(env) ? { detail } : {};
27
26
  }
28
- export { deniedDetail };
27
+ /**
28
+ * Le détail d'un refus part-il au client dans ce mode ? Hors production seulement — la règle de {@link deniedDetail}, lue AVANT de rédiger la phrase : en production, aucune chaîne n'est composée pour être jetée.
29
+ *
30
+ * @param env - le mode du kernel serveur, s'il est connu ; une absence vaut production.
31
+ */
32
+ function explainsDenials(env) {
33
+ return !!env && env !== "production";
34
+ }
35
+ export { deniedDetail, explainsDenials };
@@ -0,0 +1,160 @@
1
+ // nodefony/src/server/frameAdmission.ts
2
+ /**
3
+ * Verdicts du verrou retenus par connexion, au plus (ADR-0022 §6, décision 1 ter).
4
+ *
5
+ * Borne la table qu'un client remplit en poussant sur des canaux entrants : au-delà,
6
+ * le verrou est consulté à chaque trame, il n'est jamais contourné. Une admission
7
+ * par `allow` n'y est pas soumise — elle est déjà bornée par le plafond de canaux
8
+ * de la connexion.
9
+ */
10
+ const MAX_RETAINED_FRAME_VERDICTS = 64;
11
+ /**
12
+ * Méthodes du protocole : leurs trames ne sont JAMAIS admises d'office, même si un
13
+ * canal entrant porte ce nom. `subscribe` et `api.request` restent sur le verrou ;
14
+ * `ping` et `unsubscribe` passent sans règle (le verrou n'a rien à en dire).
15
+ */
16
+ const PROTOCOL_METHODS = /* @__PURE__ */ new Set([
17
+ "subscribe",
18
+ "unsubscribe",
19
+ "ping",
20
+ "api.request"
21
+ ]);
22
+ /**
23
+ * La connexion peut-elle admettre d'office les trames de ce nom ?
24
+ *
25
+ * Seulement un canal ENTRANT déclaré — une clé PROPRE de la table, jamais une
26
+ * héritée de son prototype —, qui n'est ni une méthode du protocole ni une
27
+ * action : la lecture admise ne regarde que le nom, et une requête d'action du
28
+ * même nom passerait sinon sans le verrou. Cold path (admission, jamais par trame).
29
+ */
30
+ function canAdmit(state, inbound, name) {
31
+ return Object.hasOwn(inbound, name) && !PROTOCOL_METHODS.has(name) && !state.peer.methods.includes(name);
32
+ }
33
+ /**
34
+ * Table des admis de la connexion, créée à la première admission.
35
+ *
36
+ * Mêmes clés, dans le même ordre, que la table entrante, toutes à `false` :
37
+ * toutes les connexions d'un contrôleur partagent ainsi UNE forme d'objet, et la
38
+ * lecture chaude reste monomorphe. Mesuré sur la trame réelle (médiane de 9) :
39
+ * objet de forme commune ≈ 0 ns, objets de formes diverses +15 ns, `Set` +26 à
40
+ * +30 ns. Une admission passe une clé existante à `true`, un retrait à `false` —
41
+ * jamais d'ajout ni de `delete`, qui changeraient la forme.
42
+ */
43
+ function admittedTable(state, inbound) {
44
+ let admitted = state.admitted;
45
+ if (admitted === null) {
46
+ admitted = {};
47
+ for (const name of Object.keys(inbound)) admitted[name] = false;
48
+ state.admitted = admitted;
49
+ }
50
+ return admitted;
51
+ }
52
+ /**
53
+ * Retire TOUTE admission d'un canal à la connexion — marque de sa règle ET verdict retenu du verrou — quand le serveur l'en éjecte ou le termine (ADR-0022 §6 décision 3).
54
+ *
55
+ * Plus fort que {@link releaseRuleAdmission}, qui garde le verdict du verrou :
56
+ * après une éjection, une trame entrante sur ce canal repasse par le verrou.
57
+ *
58
+ * @param state - état de la connexion.
59
+ * @param channel - canal dont la connexion est retirée.
60
+ */
61
+ function revokeAdmission(state, channel) {
62
+ const retained = state.retained;
63
+ if (retained !== null) {
64
+ const at = retained.indexOf(channel);
65
+ if (at !== -1) {
66
+ retained.splice(at, 1);
67
+ if (retained.length === 0) state.retained = null;
68
+ }
69
+ }
70
+ const admitted = state.admitted;
71
+ if (admitted?.[channel] === true) admitted[channel] = false;
72
+ }
73
+ /**
74
+ * Retire l'admission d'un canal à la fin de son abonnement quand elle en dépendait — celle d'un canal à `allow`, dont l'abonnement est la condition.
75
+ *
76
+ * Un verdict du verrou sur un canal SANS `allow` ne tient qu'à l'identité et
77
+ * survit au désabonnement ; sur un canal à `allow`, il ne valait que le temps
78
+ * de l'abonnement (ADR-0022 §6, décisions 1 et 1 quater).
79
+ *
80
+ * @param state - état de la connexion.
81
+ * @param channel - canal quitté.
82
+ * @param hub - hub qui porte les règles d'admission.
83
+ */
84
+ function releaseRuleAdmission(state, channel, hub) {
85
+ if (state.admitted?.[channel] !== true) return;
86
+ if (hub.resolveChannelAdmission(channel) !== null) revokeAdmission(state, channel);
87
+ }
88
+ /**
89
+ * Retient le « oui » du verrou pour un canal entrant (jamais un refus).
90
+ *
91
+ * Au plus {@link MAX_RETAINED_FRAME_VERDICTS} par connexion, sauf sur un canal
92
+ * à `allow` (`bySubscription`) : ceux-là sont déjà bornés par le plafond de
93
+ * canaux ouverts. La première retenue inscrit la connexion auprès du hub, qui
94
+ * pourra la vider si une politique arrive après coup.
95
+ */
96
+ function retainVerdict(state, method, hub, bySubscription) {
97
+ const retained = state.retained;
98
+ if (!bySubscription && retained !== null && retained.length >= 64) return;
99
+ const inbound = state.inbound;
100
+ if (inbound === null || !canAdmit(state, inbound, method)) return;
101
+ admittedTable(state, inbound)[method] = true;
102
+ if (retained === null) {
103
+ state.retained = [method];
104
+ hub.trackRetainedVerdicts(state);
105
+ } else retained.push(method);
106
+ }
107
+ /**
108
+ * Oublie les verdicts du verrou retenus par la connexion — leur politique vient
109
+ * de changer. Ceux des canaux à `allow` compris : une écriture inscrite après
110
+ * coup doit être jugée à la trame suivante, abonnement ou pas. `allow`, lui,
111
+ * n'est pas redemandé — l'abonnement reste.
112
+ *
113
+ * @param state - état de la connexion.
114
+ */
115
+ function dropRetainedVerdicts(state) {
116
+ const retained = state.retained;
117
+ if (retained === null) return;
118
+ const admitted = state.admitted;
119
+ if (admitted !== null) for (const name of retained) admitted[name] = false;
120
+ state.retained = null;
121
+ }
122
+ /**
123
+ * Juge une trame entrante que la connexion n'a pas admise d'office (ADR-0022 §6, décision 1 ter).
124
+ *
125
+ * Dans l'ordre :
126
+ * - une REQUÊTE (action, `api.request`) passe par le verrou, toujours ;
127
+ * - `ping` et `unsubscribe` passent sans règle ;
128
+ * - `subscribe` passe par le verrou (`allow` est jugé après, à l'ouverture) ;
129
+ * - un nom hérité du prototype de la table entrante est refusé ;
130
+ * - un canal entrant à `allow` exige l'abonnement de CETTE connexion (`allow`
131
+ * n'est jamais rappelé), puis passe par le verrou comme les autres ;
132
+ * - un canal entrant passe par le verrou — lecture ET écriture —, dont le « oui »
133
+ * est retenu ;
134
+ * - toute autre notification passe par le verrou.
135
+ *
136
+ * Le premier test du contrôleur (`admitted[method] === true`) a déjà écarté les trames
137
+ * admises : rien ici n'est sur le chemin d'une trame chaude.
138
+ *
139
+ * @param state - état de la connexion.
140
+ * @param frame - trame déjà classée par le pair.
141
+ * @param hub - hub qui porte le verrou et les règles.
142
+ * @returns `true` si la trame peut être distribuée.
143
+ */
144
+ function gateFrame(state, frame, hub) {
145
+ if ("id" in frame) return hub.runAuthorizer(frame, state.peer);
146
+ const method = frame.method;
147
+ if (method === "ping" || method === "unsubscribe") return true;
148
+ const inbound = state.inbound;
149
+ if (method === "subscribe" || inbound === null) return hub.runAuthorizer(frame, state.peer);
150
+ if (!Object.hasOwn(inbound, method)) {
151
+ if (method in inbound) return false;
152
+ return hub.runAuthorizer(frame, state.peer);
153
+ }
154
+ const bySubscription = hub.resolveChannelAdmission(method) !== null;
155
+ if (bySubscription && !state.channels.has(method)) return false;
156
+ if (!hub.runAuthorizer(frame, state.peer)) return false;
157
+ retainVerdict(state, method, hub, bySubscription);
158
+ return true;
159
+ }
160
+ export { MAX_RETAINED_FRAME_VERDICTS, dropRetainedVerdicts, gateFrame, releaseRuleAdmission, revokeAdmission };