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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -163,20 +163,39 @@ authenticator aurait résolue, et le verrou de frame que `@nodefony/security` po
163
163
  Sans verrou, une `policy` déclarée n'est appliquée par personne — c'est vrai en test comme
164
164
  en production, `@nodefony/realtime` ne connaissant pas `@nodefony/security`.
165
165
 
166
+ Les deux sont fournis par `@nodefony/security/testing` — le verrou est celui du pare-feu, pas
167
+ une copie :
168
+
166
169
  ```ts
167
- const h = createRealtimeHarness((ctx) => new ChatController(ctx), {
168
- identity: monToken, // IRealtimeToken
169
- frameAuthorizer: buildFrameAuthorizer(firewall, {
170
- // @nodefony/security
171
- channelResolver: getRealtimeHub(),
172
- systemRules: DEFAULT_SYSTEM_RULES,
173
- }),
170
+ import {
171
+ createTestFrameAuthorizer,
172
+ createTestIdentity,
173
+ } from "@nodefony/security/testing";
174
+
175
+ // `chat:room` exige une connexion authentifiée. Sans `identity`, le harnais
176
+ // pose le jeton anonyme, comme le repli du runtime : le canal est refusé.
177
+ const anonyme = createRealtimeHarness((ctx) => new ChatController(ctx), {
178
+ frameAuthorizer: createTestFrameAuthorizer(getRealtimeHub()),
174
179
  });
175
- await h.connect();
176
- await h.subscribe("chat:admin");
177
- expect(h.denials().map((d) => d.channel)).toEqual(["chat:admin"]); // refusé
180
+ await anonyme.connect();
181
+ await anonyme.subscribe("chat:room");
182
+ expect(anonyme.denials().map((d) => d.channel)).toEqual(["chat:room"]);
183
+ anonyme.dispose();
184
+
185
+ // Avec une identité, le même canal est accordé.
186
+ const alice = createRealtimeHarness((ctx) => new ChatController(ctx), {
187
+ identity: createTestIdentity({ identifier: "alice", roles: ["ROLE_USER"] }),
188
+ frameAuthorizer: createTestFrameAuthorizer(getRealtimeHub()),
189
+ });
190
+ await alice.connect();
191
+ await alice.subscribe("chat:room");
192
+ expect(alice.denials()).toHaveLength(0);
193
+ alice.dispose();
178
194
  ```
179
195
 
196
+ Les deux cas vont ensemble : le refus seul passerait aussi avec un canal fermé à tous, l'accord
197
+ seul avec un canal ouvert à tous.
198
+
180
199
  | Membre | Rôle |
181
200
  | ------------------------------------------- | --------------------------------------------------------------- |
182
201
  | `connect()` | Handshake ; rend le `realtime:welcome`, jette s'il n'arrive pas |
@@ -193,7 +193,7 @@ var RealtimeController = class extends Controller {
193
193
  return {};
194
194
  }
195
195
  /**
196
- * Opt-in du **pont API** (« API souveraine », POC Ph.3) : `true` → la
196
+ * Opt-in du **pont API** (une action, deux transports) : `true` → la
197
197
  * connexion expose la méthode RPC `api.request {path}` qui re-route le path
198
198
  * porté par le message vers l'action controller correspondante — la MÊME
199
199
  * action que le GET REST (`socket.request("/nodefony/kernel/api/modules")`
@@ -602,7 +602,7 @@ var RealtimeController = class extends Controller {
602
602
  };
603
603
  }
604
604
  /**
605
- * Handler de la méthode RPC `api.request {path}` — le **pont API souverain**
605
+ * Handler de la méthode RPC `api.request {path}` — le **pont socket**
606
606
  * (POC Ph.3) : résout le path porté par le message via le Router
607
607
  * (`cleanPathOverride` — l'URL de la connexion n'est jamais mutée) puis
608
608
  * exécute l'action SANS la rendre (`executeAction`) : la valeur nue est
@@ -713,11 +713,11 @@ var RealtimeController = class extends Controller {
713
713
  }
714
714
  });
715
715
  frame?.finish(200);
716
- return frame ? new RpcEnvelope(result, { requestId: frame.requestId }) : result;
716
+ return frame?.profiled ? new RpcEnvelope(result, { requestId: frame.requestId }) : result;
717
717
  } catch (e) {
718
718
  const status = httpStatusOfFrameError(e);
719
719
  frame?.finish(status ?? 500, e);
720
- throw toFrameRpcError(e, status, frame?.requestId);
720
+ throw toFrameRpcError(e, status, frame?.profiled ? frame.requestId : void 0);
721
721
  } finally {
722
722
  if (store !== null) RequestContext.release(store);
723
723
  ctx.collectFrame(frame);
@@ -91,7 +91,7 @@ export declare abstract class RealtimeController<Emit extends EventsMap = Defaul
91
91
  */
92
92
  protected realtimeInbound(): Record<string, RealtimeInboundHandler>;
93
93
  /**
94
- * Opt-in du **pont API** (« API souveraine », POC Ph.3) : `true` → la
94
+ * Opt-in du **pont API** (une action, deux transports) : `true` → la
95
95
  * connexion expose la méthode RPC `api.request {path}` qui re-route le path
96
96
  * porté par le message vers l'action controller correspondante — la MÊME
97
97
  * action que le GET REST (`socket.request("/nodefony/kernel/api/modules")`
@@ -245,7 +245,7 @@ export declare abstract class RealtimeController<Emit extends EventsMap = Defaul
245
245
  */
246
246
  private actionParamsWrapper;
247
247
  /**
248
- * Handler de la méthode RPC `api.request {path}` — le **pont API souverain**
248
+ * Handler de la méthode RPC `api.request {path}` — le **pont socket**
249
249
  * (POC Ph.3) : résout le path porté par le message via le Router
250
250
  * (`cleanPathOverride` — l'URL de la connexion n'est jamais mutée) puis
251
251
  * exécute l'action SANS la rendre (`executeAction`) : la valeur nue est
@@ -88,9 +88,11 @@ export interface IRealtimeHarnessOptions {
88
88
  /**
89
89
  * Verrou de frame (seam #1). Neutre par construction : `@nodefony/realtime`
90
90
  * ne connaît pas `@nodefony/security`. Une application qui charge la sécurité
91
- * passe ici son `buildFrameAuthorizer(...)` pour éprouver une politique de
92
- * canal ou une action gardée par rôle — sans lui, une `policy` déclarée n'est
93
- * appliquée par personne, exactement comme au runtime.
91
+ * passe ici `createTestFrameAuthorizer(getRealtimeHub())` de
92
+ * `@nodefony/security/testing` — le verrou du pare-feu, jamais une copie —
93
+ * pour éprouver une politique de canal ou une action gardée par rôle. Sans
94
+ * lui, une `policy` déclarée n'est appliquée par personne, exactement comme
95
+ * au runtime.
94
96
  */
95
97
  readonly frameAuthorizer?: FrameAuthorizer;
96
98
  /**
package/docs/actions.md CHANGED
@@ -638,7 +638,28 @@ describe("ChatController — actions", () => {
638
638
  C'est l'attribut `user` du jeton que `@CurrentUser()` rend. Pour éprouver un **refus** (`@IsGranted`,
639
639
  `policy` de canal), il faut en plus le verrou de frame de `@nodefony/security`, passé en
640
640
  `frameAuthorizer` : `@nodefony/realtime` ne connaît pas la sécurité, et sans ce verrou une règle
641
- déclarée n'est appliquée par personne — en test comme en production. La table des membres du
641
+ déclarée n'est appliquée par personne — en test comme en production.
642
+
643
+ `@nodefony/security/testing` fournit les deux pièces, et c'est la forme à préférer au jeton écrit à
644
+ la main ci-dessus : `createTestIdentity({ identifier, roles })`
645
+ (`security/nodefony/testing/index.ts#createTestIdentity`) rend l'identité authentifiée, et
646
+ `createTestFrameAuthorizer(getRealtimeHub())` (`security/nodefony/testing/index.ts#createTestFrameAuthorizer`) le **vrai**
647
+ verrou du pare-feu — même fabrique, même hiérarchie de rôles, mêmes canaux système qu'au runtime.
648
+ Un verrou factice qui ne regarde que `isAuthenticated()` laisse passer au vert un canal mal protégé.
649
+
650
+ ````ts ignore
651
+ import { getRealtimeHub } from "@nodefony/realtime";
652
+ import { createRealtimeHarness } from "@nodefony/realtime/testing";
653
+ import {
654
+ createTestFrameAuthorizer,
655
+ createTestIdentity,
656
+ } from "@nodefony/security/testing";
657
+
658
+ const h = createRealtimeHarness((ctx) => new ChatController(ctx), {
659
+ identity: createTestIdentity({ identifier: "bob", roles: ["ROLE_USER"] }),
660
+ frameAuthorizer: createTestFrameAuthorizer(getRealtimeHub()),
661
+ });
662
+ ``` La table des membres du
642
663
  harnais et l'exemple de refus sont dans le `README.md` du paquet, section « Tester un controller ».
643
664
  `nodefony create controller --kind realtime` génère ce test, identité comprise, quand l'application
644
665
  porte la sécurité.
@@ -656,3 +677,4 @@ Couverture : `npm run coverage` dans `@nodefony/realtime`.
656
677
  - La garde qui décide, côté HTTP comme WebSocket → [firewall](../../security/docs/firewall.md)
657
678
  - Les signatures exactes ne sont pas recopiées ici : elles vivent dans le graphe symbolique
658
679
  `.ai/symbols.json`, régénéré depuis les TSDoc du code.
680
+ ````
@@ -35,6 +35,25 @@ coverageModule: realtime
35
35
 
36
36
  📍 [Documentation](../../../../../docs/index.md) › [@nodefony/realtime](index.md) › **Cookbook — un chat**
37
37
 
38
+ ## ⚡ Le geste — deux commandes avant d'écrire une ligne
39
+
40
+ Une ressource REST et un canal temps réel ne s'écrivent pas à la main : les générateurs de
41
+ l'application les posent, câblés et testés. Depuis la racine de l'application :
42
+
43
+ ```bash
44
+ npx nodefony create entity Message body:text room:string:index --route /api/messages
45
+ npx nodefony create controller room --kind realtime
46
+ ```
47
+
48
+ | Commande | Ce qu'elle pose |
49
+ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
50
+ | `create entity` | la table (`nodefony/entity/Message.ts`), ses schémas de validation, un service CRUD, `MessageController` — REST sur `/api/messages`, et ses lectures répondent aussi par la socket —, un test unitaire et un test e2e, le tout inscrit dans `index.ts` — puis, si l'application versionne ses migrations (toute application complète), la migration de la table, écrite et appliquée à la base de développement ; sinon la table naît au prochain démarrage |
51
+ | `create controller --kind realtime` | `RoomController` : la socket `/api/room/realtime`, le canal `room:events`, ses actions, et un test qui la pilote sans serveur ni navigateur |
52
+
53
+ La suite de ce cookbook construit le même salon À LA MAIN, dans un seul contrôleur : c'est ce qui
54
+ permet de comprendre ce que les générateurs écrivent, et de le retoucher. Pour avancer, partir des
55
+ générateurs ; pour comprendre, lire la suite.
56
+
38
57
  ## 🎯 Ce qu'on construit
39
58
 
40
59
  **En une phrase** : un salon de discussion où plusieurs personnes échangent des messages et des
package/docs/securite.md CHANGED
@@ -644,11 +644,20 @@ La source de vérité côté security est `buildSessionRevalidator()`
644
644
  et vérifie qu'elle est toujours vivante **et toujours celle du même utilisateur** — ce second point
645
645
  attrape le changement de compte sur un navigateur partagé.
646
646
 
647
+ **Un changement de rôles coupe sur-le-champ, sans attendre le tick.** Quand une mise à jour du compte
648
+ touche ses rôles, le hub ferme toutes les sockets de cette identité avec le code **4005**
649
+ (`realtime/closeCodes.ts#IDENTITY_REFRESH_CLOSE_CODE`, `RealtimeHub.ts#RealtimeHub.#revocation`). À la
650
+ différence de 4001 (identité morte, définitif), 4005 est **reconnectable** : le client se reconnecte
651
+ seul et reçoit ses nouveaux rôles dans l'accueil. Les flux SSE du même compte suivent la même règle.
652
+ Aucune lecture de base par tick ni par frame : le coût n'existe qu'à la mise à jour qui touche
653
+ `roles`.
654
+
647
655
  > [!CAUTION]
648
656
  > **Trois limites à connaître.**
649
657
  >
650
658
  > 1. **Fenêtre de 30 s.** Entre le logout et le tick suivant, les flux continuent. Pour une coupure
651
- > immédiate, fermer la socket explicitement côté serveur.
659
+ > immédiate, fermer la socket explicitement côté serveur. (Un changement de **rôles**, lui, coupe
660
+ > immédiatement — ci-dessus.)
652
661
  > 2. **Best-effort si la session est illisible au handshake.** Si l'id ou le store ne sont pas
653
662
  > accessibles, `buildSessionRevalidator()` renvoie `null` ; `UserRealtimeToken.isValid()`
654
663
  > (`UserRealtimeToken.ts#UserRealtimeToken.isValid`) répond alors toujours `true`. La connexion **est** inscrite au
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/realtime",
3
- "version": "10.0.0-beta.4",
3
+ "version": "10.0.0-beta.5",
4
4
  "nodefony": {
5
5
  "configSchema": "./dist/nodefony/config/configSchema.js"
6
6
  },
@@ -44,18 +44,18 @@
44
44
  "esm"
45
45
  ],
46
46
  "peerDependencies": {
47
- "@nodefony/framework": "^10.0.0-beta.4",
48
- "@nodefony/http": "^10.0.0-beta.4",
49
- "nodefony": "^10.0.0-beta.4",
47
+ "@nodefony/framework": "^10.0.0-beta.5",
48
+ "@nodefony/http": "^10.0.0-beta.5",
49
+ "nodefony": "^10.0.0-beta.5",
50
50
  "reflect-metadata": "0.2.2",
51
51
  "zod": "^4.6.7"
52
52
  },
53
53
  "devDependencies": {
54
- "@nodefony/framework": "^10.0.0-beta.4",
55
- "@nodefony/http": "^10.0.0-beta.4",
54
+ "@nodefony/framework": "^10.0.0-beta.5",
55
+ "@nodefony/http": "^10.0.0-beta.5",
56
56
  "@types/node": "26.6.5",
57
57
  "@vitest/coverage-v8": "5.0.5",
58
- "nodefony": "^10.0.0-beta.4",
58
+ "nodefony": "^10.0.0-beta.5",
59
59
  "reflect-metadata": "0.2.2",
60
60
  "vitest": "5.0.5"
61
61
  },