@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
@@ -1,4 +1,5 @@
1
1
  import { getRealtimeHub } from "./RealtimeHub.js";
2
+ import { isRealtimePayload, realtimePayloadError } from "nodefony";
2
3
  // nodefony/src/server/ServerRealtimeSocket.ts
3
4
  /**
4
5
  * **L4 — façade serveur « la socket Nodefony ».** Un handle SERVEUR qui implémente
@@ -38,6 +39,7 @@ var ServerRealtimeSocket = class {
38
39
  }
39
40
  /** Émet sur un canal — fan-out aux abonnés via le hub (+ backplane si broadcast). */
40
41
  publish(channel, payload) {
42
+ if (!isRealtimePayload(payload)) throw realtimePayloadError(channel, payload);
41
43
  this.hub.publish(channel, payload);
42
44
  }
43
45
  /**
@@ -0,0 +1,176 @@
1
+ // nodefony/src/server/admissionBudget.ts
2
+ /**
3
+ * Coupe-circuit des admissions (décision de l'auteur sur #622) : 50 délais
4
+ * dépassés en 5 s suspendent les appels d'`allow` pendant 5 s. Constantes
5
+ * internes : un exploitant règle le délai et les plafonds, pas la mécanique qui
6
+ * protège sa base quand elle ne répond plus.
7
+ */
8
+ const BREAKER_THRESHOLD = 50;
9
+ const BREAKER_WINDOW_MS = 5e3;
10
+ const BREAKER_OPEN_MS = 5e3;
11
+ /** Un avertissement par cause et par fenêtre : sous une base bloquée, un journal par abonné serait un amplificateur. */
12
+ const WARN_INTERVAL_MS = 1e4;
13
+ /** Rendu par {@link AdmissionBudget.acquire} quand une place est réservée. */
14
+ const ADMISSION_GRANTED = -1;
15
+ /** Rendu par {@link AdmissionBudget.authenticateWithin} quand l'authentification n'a pas répondu à temps. */
16
+ const AUTHENTICATE_TIMED_OUT = Symbol("authenticate-timed-out");
17
+ /**
18
+ * Budget des admissions d'UN exemplaire — appels d'`allow` en vol, coupe-circuit et compteurs de refus (ADR-0021 §S10 bis point 3, §S9).
19
+ *
20
+ * Partagé par toutes les connexions du hub ; chaque connexion garde sa propre
21
+ * file ({@link ConnectionAdmission}). Aucune horloge n'est lue tant que le
22
+ * coupe-circuit est fermé : une règle synchrone ne paie que deux incréments.
23
+ */
24
+ var AdmissionBudget = class {
25
+ /** Délai de l'authentification à la poignée de main (ADR-0021 §S11). */
26
+ authenticateTimeoutMs = 1e4;
27
+ /** Délai d'un appel d'`allow`, compté depuis la réception du `subscribe`, file comprise. */
28
+ timeoutMs = 5e3;
29
+ /** Appels d'`allow` en vol par connexion ; au-delà, la demande attend son tour. */
30
+ maxPerConnection = 8;
31
+ #maxTotal = 512;
32
+ #pending = 0;
33
+ #timedOutTotal = 0;
34
+ #saturatedTotal = 0;
35
+ #authenticateTimedOutTotal = 0;
36
+ #windowStart = -1e400;
37
+ #windowTimeouts = 0;
38
+ #openUntil = 0;
39
+ #lastTimeoutWarn = -1e400;
40
+ #lastThrowWarn = -1e400;
41
+ #lastAuthenticateWarn = -1e400;
42
+ /** Applique les bornes de la configuration (boot, cold path). */
43
+ configure(limits) {
44
+ this.authenticateTimeoutMs = limits.authenticateTimeoutMs;
45
+ this.timeoutMs = limits.allowTimeoutMs;
46
+ this.maxPerConnection = limits.maxPendingAllowsPerConnection;
47
+ this.#maxTotal = limits.maxPendingAllowsTotal;
48
+ }
49
+ /**
50
+ * Réserve une place pour UN appel d'`allow`.
51
+ *
52
+ * @returns {@link ADMISSION_GRANTED} si la place est prise ; sinon la demande
53
+ * est comptée dans `allowSaturatedTotal` et la valeur rendue est le délai à
54
+ * annoncer au client (`retryAfterMs`, coupe-circuit ouvert), ou `0` quand il
55
+ * n'est pas connu (plafond global atteint).
56
+ */
57
+ acquire() {
58
+ if (this.#openUntil !== 0) {
59
+ const now = performance.now();
60
+ if (now < this.#openUntil) {
61
+ this.#saturatedTotal += 1;
62
+ return Math.ceil(this.#openUntil - now);
63
+ }
64
+ this.#openUntil = 0;
65
+ }
66
+ if (this.#pending >= this.#maxTotal) {
67
+ this.#saturatedTotal += 1;
68
+ return 0;
69
+ }
70
+ this.#pending += 1;
71
+ return -1;
72
+ }
73
+ /** Rend la place d'un appel terminé, dépassé ou abandonné à la fermeture. */
74
+ release() {
75
+ if (this.#pending > 0) this.#pending -= 1;
76
+ }
77
+ /** Un appel d'`allow` n'a pas répondu dans son délai : compté, et porté au coupe-circuit. */
78
+ recordTimeout(now) {
79
+ this.#timedOutTotal += 1;
80
+ if (now - this.#windowStart >= BREAKER_WINDOW_MS) {
81
+ this.#windowStart = now;
82
+ this.#windowTimeouts = 0;
83
+ }
84
+ this.#windowTimeouts += 1;
85
+ if (this.#windowTimeouts >= BREAKER_THRESHOLD) {
86
+ this.#openUntil = now + BREAKER_OPEN_MS;
87
+ this.#windowTimeouts = 0;
88
+ }
89
+ }
90
+ /** Une demande a épuisé son délai dans la file, sans appel d'`allow`. */
91
+ recordQueueExpired() {
92
+ this.#saturatedTotal += 1;
93
+ }
94
+ /** Délai restant du coupe-circuit à `now`, ou `undefined` s'il est fermé. */
95
+ retryAfter(now) {
96
+ return this.#openUntil > now ? Math.ceil(this.#openUntil - now) : void 0;
97
+ }
98
+ /** Faut-il journaliser cette cause maintenant ? Vrai au plus une fois par fenêtre et par cause. */
99
+ shouldWarn(kind, now) {
100
+ if (kind === "authenticate") {
101
+ if (now - this.#lastAuthenticateWarn < WARN_INTERVAL_MS) return false;
102
+ this.#lastAuthenticateWarn = now;
103
+ return true;
104
+ }
105
+ if (kind === "timeout") {
106
+ if (now - this.#lastTimeoutWarn < WARN_INTERVAL_MS) return false;
107
+ this.#lastTimeoutWarn = now;
108
+ return true;
109
+ }
110
+ if (now - this.#lastThrowWarn < WARN_INTERVAL_MS) return false;
111
+ this.#lastThrowWarn = now;
112
+ return true;
113
+ }
114
+ /**
115
+ * Borne l'authentification d'une poignée de main par `authenticateTimeoutMs` (ADR-0021 §S11).
116
+ *
117
+ * Une identité arrivée après le délai est IGNORÉE : la promesse rendue est
118
+ * déjà réglée sur {@link AUTHENTICATE_TIMED_OUT}, et un rejet tardif ne lève
119
+ * rien (la promesse d'origine garde son gestionnaire). Le minuteur est coupé
120
+ * dès la réponse et ne retient pas le processus à l'arrêt. Chaque dépassement
121
+ * compte une poignée de main dans `authenticateTimedOutTotal`.
122
+ *
123
+ * @param pending - la promesse rendue par `authenticate()`
124
+ * @returns le jeton, ou {@link AUTHENTICATE_TIMED_OUT} au dépassement
125
+ */
126
+ authenticateWithin(pending) {
127
+ return new Promise((resolve, reject) => {
128
+ const timer = setTimeout(() => {
129
+ this.#authenticateTimedOutTotal++;
130
+ resolve(AUTHENTICATE_TIMED_OUT);
131
+ }, this.authenticateTimeoutMs);
132
+ timer.unref();
133
+ pending.then((value) => {
134
+ clearTimeout(timer);
135
+ resolve(value);
136
+ }, (error) => {
137
+ clearTimeout(timer);
138
+ reject(error instanceof Error ? error : new Error(String(error)));
139
+ });
140
+ });
141
+ }
142
+ /** Appels d'`allow` en vol sur l'exemplaire. */
143
+ get pending() {
144
+ return this.#pending;
145
+ }
146
+ /** Cumul `allowTimedOutTotal` — unité : un appel d'`allow`. */
147
+ get timedOutTotal() {
148
+ return this.#timedOutTotal;
149
+ }
150
+ /** Cumul `allowSaturatedTotal` — unité : une demande d'abonnement. */
151
+ get saturatedTotal() {
152
+ return this.#saturatedTotal;
153
+ }
154
+ /** Cumul `authenticateTimedOutTotal` — unité : une poignée de main. */
155
+ get authenticateTimedOutTotal() {
156
+ return this.#authenticateTimedOutTotal;
157
+ }
158
+ /** Remet compteurs, coupe-circuit et bornes à leurs défauts (hub vidé). */
159
+ reset() {
160
+ this.authenticateTimeoutMs = 1e4;
161
+ this.#authenticateTimedOutTotal = 0;
162
+ this.#lastAuthenticateWarn = -1e400;
163
+ this.timeoutMs = 5e3;
164
+ this.maxPerConnection = 8;
165
+ this.#maxTotal = 512;
166
+ this.#pending = 0;
167
+ this.#timedOutTotal = 0;
168
+ this.#saturatedTotal = 0;
169
+ this.#windowStart = -1e400;
170
+ this.#windowTimeouts = 0;
171
+ this.#openUntil = 0;
172
+ this.#lastTimeoutWarn = -1e400;
173
+ this.#lastThrowWarn = -1e400;
174
+ }
175
+ };
176
+ export { ADMISSION_GRANTED, AUTHENTICATE_TIMED_OUT, AdmissionBudget };
@@ -0,0 +1,95 @@
1
+ import { declaresAction, getDeclaredChannelPolicies, isAdmissionClass, samePolicy } from "../../decorators/realtimeDecorators.js";
2
+ import { samePresence } from "./channelPresence.js";
3
+ import { BootConfigurationError, Injector } from "nodefony";
4
+ // nodefony/src/server/channelPolicyRegistry.ts
5
+ /**
6
+ * Inscrit au hub les politiques déclarées par une classe de contrôleur — une fois par classe et par hub, et seulement si l'inscription entière réussit.
7
+ *
8
+ * Appelée pour chaque contrôleur connu au démarrage du noyau, puis à la
9
+ * poignée de main pour une classe qui y aurait échappé : celle-ci ne paie alors
10
+ * qu'un `has`. Une politique refusée ne marque PAS la classe — elle est refusée
11
+ * de nouveau à la poignée de main suivante plutôt que de laisser le canal
12
+ * ouvert.
13
+ *
14
+ * @param hub - le hub qui porte le registre.
15
+ * @param ctor - constructeur du contrôleur.
16
+ * @param resolveAdmission - résolution des classes d'admission ; `null` hors
17
+ * démarrage, et le hub refuse alors une politique qui en désigne une.
18
+ * Chaque nom est déclaré au hub SORTE PAR SORTE (lecture, écriture), sans
19
+ * héritage : le hub les fusionne avec les motifs de toutes les classes
20
+ * (ADR-0022 §6, décision 1 quater). Deux classes qui déclarent la même sorte
21
+ * différemment sous un même nom refusent le démarrage : la seconde effaçait la
22
+ * première sans un mot.
23
+ *
24
+ * @throws BootConfigurationError Politique illisible ou contredite, `allow`
25
+ * posé sur une action ou sur une écriture distincte de la lecture, classe
26
+ * d'admission non résolue, ou deux classes en désaccord sur un nom.
27
+ */
28
+ function registerControllerPolicies(hub, ctor, resolveAdmission) {
29
+ if (hub.hasPolicyOwner(ctor)) return;
30
+ const declared = getDeclaredChannelPolicies(ctor);
31
+ if (declared !== null) {
32
+ const owner = typeof ctor === "function" ? ctor.name : "(contrôleur)";
33
+ for (const [name, { read, write }] of Object.entries(declared.kinds)) {
34
+ if (write?.allow !== void 0 && read !== null && !samePolicy(read, write)) throw new BootConfigurationError(`${owner} : « allow » est posé sur l'écriture du canal « ${name} », qui déclare aussi une lecture différente — il ne s'applique qu'à la lecture (demandé au subscribe). Le poser sur @RealtimeChannel ; l'écriture se garde par authenticated, roles ou scopes.`);
35
+ const allow = (read ?? write)?.allow;
36
+ let admission;
37
+ if (allow !== void 0) {
38
+ if (declaresAction(ctor, name)) throw new BootConfigurationError(`${owner} : « allow » est posé sur l'action « ${name} » — il ne s'applique qu'aux canaux (demandé au subscribe). Une action se garde par authenticated, roles ou scopes.`);
39
+ if (isAdmissionClass(allow) && resolveAdmission !== null) admission = resolveAdmission(allow, name);
40
+ }
41
+ if (read !== null) claim(hub, name, "read", ctor, owner, read);
42
+ if (write !== null) claim(hub, name, "write", ctor, owner, write);
43
+ hub.declareChannelPolicy(name, read, write, admission);
44
+ }
45
+ for (const name of declared.actions) hub.declareActionDefault(name);
46
+ }
47
+ hub.addPolicyOwner(ctor);
48
+ }
49
+ /**
50
+ * Revendique une sorte de politique d'un nom pour cette classe — refuse le démarrage si une AUTRE classe en a déclaré une différente : le registre est unique par nom, une seule s'appliquerait.
51
+ */
52
+ function claim(hub, name, kind, ctor, owner, policy) {
53
+ const previous = hub.claimChannelPolicy(name, kind, ctor, policy);
54
+ if (previous === null || previous.owner === ctor || samePolicy(previous.policy, policy) && samePresence(previous.policy, policy)) return;
55
+ const which = kind === "read" ? "LECTURE" : "ÉCRITURE";
56
+ throw new BootConfigurationError(`canal temps réel « ${name} » : ${previous.ownerName} et ${owner} déclarent des politiques de ${which} différentes sous ce nom — le registre est unique par nom, une seule s'appliquerait. Les accorder, ou renommer l'un des canaux.`);
57
+ }
58
+ /**
59
+ * Fabrique la résolution des classes d'admission du démarrage : portée singleton exigée, instance du conteneur réutilisée, sinon construite UNE fois par l'injecteur.
60
+ *
61
+ * @param get - l'instance déjà construite d'une classe : le démarrage la lit
62
+ * dans le conteneur du noyau par sa clé (`null` tant que la classe n'y a
63
+ * jamais été rangée), le harnais de test parmi les instances que le test lui
64
+ * donne. `undefined` → construite par l'injecteur.
65
+ * @param log - journal du module (une ligne par canal résolu).
66
+ * @returns le résolveur, qui mémorise l'instance de chaque classe.
67
+ */
68
+ function createAdmissionResolver(get, log) {
69
+ const resolved = /* @__PURE__ */ new Map();
70
+ return (admissionClass, channel) => {
71
+ let admission = resolved.get(admissionClass);
72
+ if (admission === void 0) {
73
+ admission = resolveAdmissionInstance(admissionClass, channel, get);
74
+ resolved.set(admissionClass, admission);
75
+ }
76
+ log(`canal ${channel}, admission ${admissionClass.name}, résolue`);
77
+ return admission;
78
+ };
79
+ }
80
+ /** Résout l'instance d'une classe d'admission ou refuse le démarrage en nommant la classe ET le canal. */
81
+ function resolveAdmissionInstance(admissionClass, channel, get) {
82
+ const where = `canal temps réel « ${channel} » : la classe d'admission ${admissionClass.name}`;
83
+ if (!Injector.isInjectable(admissionClass)) throw new BootConfigurationError(`${where} ne porte pas @injectable`);
84
+ const scope = Injector.scopeOf(admissionClass);
85
+ if (scope !== "singleton") throw new BootConfigurationError(`${where} est de portée « ${scope} » : une seule instance sert tous les abonnés en même temps, elle doit être un singleton (et ne garder aucun état par abonné).`);
86
+ let instance = get(Injector.containerKeyOf(admissionClass), admissionClass);
87
+ if (!(instance instanceof admissionClass)) try {
88
+ instance = Injector.instantiate(admissionClass);
89
+ } catch (error) {
90
+ throw new BootConfigurationError(`${where} ne se résout pas — ${error instanceof Error ? error.message : String(error)}`, { cause: error });
91
+ }
92
+ if (!(instance instanceof admissionClass) || typeof instance.allow !== "function") throw new BootConfigurationError(`${where} n'a pas de méthode allow`);
93
+ return instance;
94
+ }
95
+ export { createAdmissionResolver, registerControllerPolicies };
@@ -0,0 +1,191 @@
1
+ import { isAdmissionClass, samePolicy } from "../../decorators/realtimeDecorators.js";
2
+ import { BootConfigurationError, USER_CHANNEL_PREFIX, isOwnUserChannel } from "nodefony";
3
+ // nodefony/src/server/channelPolicyResolution.ts
4
+ /**
5
+ * Règle d'admission prête à appeler d'une politique, ou `null` sans `allow`.
6
+ *
7
+ * Une flèche se retient telle quelle. Une classe exige son instance — résolue au
8
+ * démarrage, jamais ici : le hub n'a pas d'injecteur, et résoudre à la première
9
+ * poignée de main laisserait sans politique un exemplaire qui n'émet que des
10
+ * webhooks. La méthode est liée une fois, et la règle se construit une fois :
11
+ * le `subscribe` n'alloue rien et n'appelle qu'une fonction.
12
+ *
13
+ * @throws BootConfigurationError La politique désigne une classe d'admission et
14
+ * aucune instance de CETTE classe n'est fournie.
15
+ */
16
+ function admissionRuleOf(name, policy, admission) {
17
+ const allow = policy.allow;
18
+ if (allow === void 0) return null;
19
+ const anonymous = policy.anonymous === true;
20
+ if (!isAdmissionClass(allow)) return {
21
+ allow,
22
+ anonymous
23
+ };
24
+ 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.`);
25
+ return {
26
+ allow: admission.allow.bind(admission),
27
+ anonymous
28
+ };
29
+ }
30
+ /**
31
+ * Construit une déclaration et sa vue seule : lecture déclarée, sinon l'écriture, sinon le défaut d'action ; écriture gardée seulement si elle exige autre chose que la lecture.
32
+ *
33
+ * @param name - nom exact ou motif (pour les messages).
34
+ * @param read - lecture déclarée, ou `null`.
35
+ * @param write - écriture déclarée, ou `null`.
36
+ * @param fallback - défaut fermé d'une action, ou `null`.
37
+ * @param admission - instance de la classe d'admission quand la politique qui
38
+ * sert de lecture en désigne une.
39
+ */
40
+ function declarationOf(name, read, write, fallback, admission) {
41
+ const policy = read ?? write ?? fallback;
42
+ return {
43
+ read,
44
+ write,
45
+ fallback,
46
+ instance: admission,
47
+ self: policy === null ? null : {
48
+ policy,
49
+ write: read !== null && write !== null && !samePolicy(read, write) ? write : null,
50
+ admission: admissionRuleOf(name, policy, admission)
51
+ }
52
+ };
53
+ }
54
+ /**
55
+ * Politique INTÉGRÉE du canal personnel `user:<id>` (ADR-0022 §6, D1) — connexion authentifiée, admise sur SON canal seulement.
56
+ *
57
+ * Constante de module : elle ne s'inscrit nulle part, donc aucun `clear()`,
58
+ * aucun ordre de démarrage, aucun oubli ne peut la retirer. Elle sert de REPLI
59
+ * de LECTURE aux noms `user:…` : seule une déclaration qui vise `user:`
60
+ * (nom exact, ou motif dont le littéral commence par `user:`) la remplace — un
61
+ * motif attrape-tout (`*`) ne retire pas la garde « son canal seulement ».
62
+ */
63
+ const USER_CHANNEL_DECLARATION = Object.freeze({
64
+ read: Object.freeze({
65
+ authenticated: true,
66
+ allow: isOwnUserChannel
67
+ }),
68
+ write: null,
69
+ fallback: null,
70
+ instance: void 0,
71
+ self: Object.freeze({
72
+ policy: Object.freeze({
73
+ authenticated: true,
74
+ allow: isOwnUserChannel
75
+ }),
76
+ write: null,
77
+ admission: Object.freeze({
78
+ allow: isOwnUserChannel,
79
+ anonymous: false
80
+ })
81
+ })
82
+ });
83
+ /**
84
+ * Résout les politiques d'un nom (ADR-0022 §6, décision 1 quater) : la LECTURE appartient à la déclaration la plus spécifique qui déclare quelque chose sur ce nom ; l'ÉCRITURE vient de la plus spécifique qui déclare une écriture.
85
+ *
86
+ * L'ordre de spécificité : le nom exact, puis les motifs du plus spécifique au
87
+ * plus général. Pourquoi deux règles et non une :
88
+ * - la LECTURE reste à la déclaration propriétaire du nom (déclarée, sinon
89
+ * héritée de sa propre écriture) : une commande `task:start` déclarée par
90
+ * `@RealtimeInbound` sous une famille `task:*` à `allow` ne devient pas un
91
+ * canal à membres, et une écriture exacte ne voit pas sa lecture s'ouvrir par
92
+ * un motif plus général ;
93
+ * - l'ÉCRITURE descend jusqu'au motif qui en déclare une : elle s'ajoute à la
94
+ * lecture (une trame entrante exige les deux), elle ne peut que durcir — une
95
+ * écriture de famille (`mix:*` réservée) n'est plus perdue sur un nom exact
96
+ * qui ne déclare que sa lecture.
97
+ *
98
+ * Ensuite : un nom `user:…` sans déclaration qui le vise garde la politique
99
+ * intégrée du canal personnel (un motif qui ne vise pas `user:` est ignoré) ;
100
+ * une action sans politique (nom exact qui ne déclare rien) prend la lecture du
101
+ * motif qui la couvre et le défaut fermé pour écriture — un motif de lecture
102
+ * public ne l'ouvre donc pas.
103
+ *
104
+ * Rend la vue d'une déclaration quand une seule suffit (aucune allocation) ;
105
+ * n'alloue que si l'écriture vient d'une autre déclaration que la lecture.
106
+ *
107
+ * @param name - nom résolu.
108
+ * @param exact - déclaration exacte de ce nom, ou `undefined`.
109
+ * @param patterns - motifs, triés du plus spécifique au plus général, ou `null`.
110
+ * @returns les politiques, ou `null` si aucune ne couvre le nom.
111
+ */
112
+ function resolveDeclaredPolicies(name, exact, patterns) {
113
+ const declares = (d) => d.read !== null || d.write !== null;
114
+ let owner = exact !== void 0 && declares(exact) ? exact : null;
115
+ let writeFrom = exact !== void 0 && exact.write !== null ? exact : null;
116
+ const isUser = name.startsWith(USER_CHANNEL_PREFIX);
117
+ if ((owner === null || writeFrom === null) && patterns !== null) for (const pattern of patterns) {
118
+ if (isUser && !pattern.source.startsWith(USER_CHANNEL_PREFIX)) continue;
119
+ if (!pattern.re.test(name)) continue;
120
+ if (owner === null && declares(pattern)) owner = pattern;
121
+ if (writeFrom === null && pattern.write !== null) writeFrom = pattern;
122
+ if (owner !== null && writeFrom !== null) break;
123
+ }
124
+ let write = writeFrom?.write ?? null;
125
+ if (write === null && exact !== void 0 && !declares(exact)) write = exact.fallback;
126
+ let own = owner?.self ?? null;
127
+ if (own === null) {
128
+ if (isUser) own = USER_CHANNEL_DECLARATION.self;
129
+ else return exact?.self ?? null;
130
+ if (own === null) return null;
131
+ }
132
+ const distinct = write === null || samePolicy(own.policy, write) ? null : write;
133
+ if (distinct === own.write) return own;
134
+ return {
135
+ policy: own.policy,
136
+ write: distinct,
137
+ admission: own.admission
138
+ };
139
+ }
140
+ /** Échantillon d'un motif : son littéral, chaque `*` vidé — un nom qu'il couvre toujours. */
141
+ function sampleOf(pattern) {
142
+ return pattern.source.split("*").join("");
143
+ }
144
+ /**
145
+ * Deux déclarations peuvent-elles s'appliquer au MÊME nom ? Exact : le même nom, ou couvert par le motif ; deux motifs : l'un couvre l'échantillon de l'autre.
146
+ *
147
+ * Prudente : elle répond « oui » dès qu'un nom commun est plausible — elle sert à
148
+ * refuser un démarrage ambigu, jamais à ouvrir un accès.
149
+ */
150
+ function couldOverlap(a, b) {
151
+ if (a.pattern === null && b.pattern === null) return a.name === b.name;
152
+ if (a.pattern === null) return b.pattern?.re.test(a.name) === true;
153
+ if (b.pattern === null) return a.pattern.re.test(b.name);
154
+ return a.pattern.re.test(sampleOf(b.pattern)) || b.pattern.re.test(sampleOf(a.pattern));
155
+ }
156
+ /**
157
+ * Défaut de l'ensemble des déclarations qui refuse le démarrage, ou `null` : une ÉCRITURE à `allow` qui s'appliquerait sous une lecture différente (son `allow` ne serait évalué nulle part), ou — face à un verrou qui ne juge pas l'écriture — une écriture qui diffère d'une lecture qu'elle peut rencontrer.
158
+ *
159
+ * Cold path (inscription d'une politique, pose du verrou) : toutes les paires.
160
+ *
161
+ * @param exact - déclarations exactes, par nom.
162
+ * @param patterns - déclarations par motif.
163
+ * @param writeJudged - le verrou en place sait juger l'écriture (ou aucun verrou).
164
+ * @returns le message du refus, ou `null`.
165
+ */
166
+ function policyDeclarationsDefect(exact, patterns, writeJudged) {
167
+ const all = [];
168
+ if (exact !== null) for (const [name, decl] of exact) all.push({
169
+ name,
170
+ pattern: null,
171
+ decl
172
+ });
173
+ for (const pattern of patterns ?? []) all.push({
174
+ name: pattern.source,
175
+ pattern,
176
+ decl: pattern
177
+ });
178
+ for (const writer of all) {
179
+ const write = writer.decl.write;
180
+ if (write === null) continue;
181
+ for (const reader of all) {
182
+ const read = reader.decl.read;
183
+ if (read === null || samePolicy(read, write)) continue;
184
+ if (reader !== writer && !couldOverlap(writer, reader)) continue;
185
+ if (write.allow !== void 0) return `canal temps réel « ${writer.name} » : « allow » est posé sur l'écriture du canal « ${writer.name} », qui rencontre une lecture différente (« ${reader.name} ») — il ne s'applique qu'à la lecture (demandé au subscribe). Le poser sur @RealtimeChannel ; l'écriture se garde par authenticated, roles ou scopes.`;
186
+ if (!writeJudged) return `canal temps réel « ${writer.name} » : il déclare une politique d'ÉCRITURE distincte de sa lecture, et le verrou des trames posé sur le hub ne sait pas la juger (il ne déclare pas « judgesWrite ») — l'écriture resterait ouverte. Aligner @nodefony/security sur la version de @nodefony/realtime.`;
187
+ }
188
+ }
189
+ return null;
190
+ }
191
+ export { USER_CHANNEL_DECLARATION, admissionRuleOf, declarationOf, policyDeclarationsDefect, resolveDeclaredPolicies };