@nodefony/framework 10.0.0-alpha.1

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/LICENSE +544 -0
  2. package/README.md +50 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +211 -0
  6. package/dist/nodefony/config/config.js +61 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +36 -0
  8. package/dist/nodefony/controller/AdminApiController.js +163 -0
  9. package/dist/nodefony/controller/ApiKeyController.js +151 -0
  10. package/dist/nodefony/controller/BenchController.js +132 -0
  11. package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
  12. package/dist/nodefony/controller/OAuth2Controller.js +133 -0
  13. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
  14. package/dist/nodefony/controller/SessionAuthController.js +141 -0
  15. package/dist/nodefony/controller/TokenAuthController.js +113 -0
  16. package/dist/nodefony/controller/TotpController.js +129 -0
  17. package/dist/nodefony/controller/WebAuthnController.js +242 -0
  18. package/dist/nodefony/controller/oauthAuthority.js +74 -0
  19. package/dist/nodefony/decorators/routerDecorators.js +967 -0
  20. package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
  21. package/dist/nodefony/interfaces/IController.js +1 -0
  22. package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
  23. package/dist/nodefony/interfaces/IResolver.js +1 -0
  24. package/dist/nodefony/interfaces/IRoute.js +1 -0
  25. package/dist/nodefony/interfaces/index.js +1 -0
  26. package/dist/nodefony/service/AdminBroker.js +106 -0
  27. package/dist/nodefony/service/Eta.js +68 -0
  28. package/dist/nodefony/service/IdempotencyStore.js +136 -0
  29. package/dist/nodefony/service/router.js +243 -0
  30. package/dist/nodefony/src/Controller.js +515 -0
  31. package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
  32. package/dist/nodefony/src/KernelAdminApi.js +1243 -0
  33. package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
  34. package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
  35. package/dist/nodefony/src/Resolver.js +416 -0
  36. package/dist/nodefony/src/ResourceController.js +148 -0
  37. package/dist/nodefony/src/Route.js +476 -0
  38. package/dist/nodefony/src/SyslogAdminApi.js +466 -0
  39. package/dist/nodefony/src/Template.js +15 -0
  40. package/dist/nodefony/src/configMutation.js +186 -0
  41. package/dist/nodefony/src/docsReader.js +929 -0
  42. package/dist/nodefony/src/idempotency.js +137 -0
  43. package/dist/nodefony/src/idempotencyGc.js +32 -0
  44. package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
  45. package/dist/nodefony/src/scopeCatalog.js +40 -0
  46. package/dist/nodefony/src/syslogFilters.js +51 -0
  47. package/dist/types/index.d.ts +96 -0
  48. package/dist/types/nodefony/config/config.d.ts +41 -0
  49. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  50. package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
  51. package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
  52. package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
  53. package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
  54. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
  55. package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
  56. package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
  57. package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
  58. package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
  59. package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
  60. package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
  61. package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
  62. package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
  63. package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
  64. package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
  65. package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
  66. package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
  67. package/dist/types/nodefony/interfaces/index.d.ts +5 -0
  68. package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
  69. package/dist/types/nodefony/service/Eta.d.ts +25 -0
  70. package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
  71. package/dist/types/nodefony/service/router.d.ts +53 -0
  72. package/dist/types/nodefony/src/Controller.d.ts +193 -0
  73. package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
  74. package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
  75. package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
  76. package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
  77. package/dist/types/nodefony/src/Resolver.d.ts +165 -0
  78. package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
  79. package/dist/types/nodefony/src/Route.d.ts +192 -0
  80. package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
  81. package/dist/types/nodefony/src/Template.d.ts +8 -0
  82. package/dist/types/nodefony/src/configMutation.d.ts +109 -0
  83. package/dist/types/nodefony/src/docsReader.d.ts +369 -0
  84. package/dist/types/nodefony/src/idempotency.d.ts +96 -0
  85. package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
  86. package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
  87. package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
  88. package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
  89. package/docs/admin.md +451 -0
  90. package/docs/controller.md +645 -0
  91. package/docs/decorateurs.md +845 -0
  92. package/docs/idempotence.md +741 -0
  93. package/docs/index.md +151 -0
  94. package/docs/routing.md +648 -0
  95. package/docs/templates.md +380 -0
  96. package/package.json +83 -0
package/docs/admin.md ADDED
@@ -0,0 +1,451 @@
1
+ ---
2
+ title: "Data plane d'administration — le pont /nodefony/<ns>/api/*"
3
+ navTitle: Data plane d'administration
4
+ lang: fr
5
+ module: "@nodefony/framework"
6
+ topic: admin
7
+ section: "Cœur runtime"
8
+ audience: [developer]
9
+ tags:
10
+ [
11
+ admin,
12
+ adminbroker,
13
+ data-plane,
14
+ studio,
15
+ rbac,
16
+ dataplane,
17
+ discovery,
18
+ websocket,
19
+ ]
20
+ version: "doc"
21
+ status: stable
22
+ updated: 2026-07-21
23
+ source: "src/packages/@nodefony/framework/docs/admin.md"
24
+ coverageModule: framework
25
+ coverageFiles: AdminBroker.ts,AdminApiController.ts,adminRbac.ts,FrameworkAdminApi.ts,PlaygroundAdminApi.ts,IAdminApi.ts,IAdminBroker.ts
26
+ ---
27
+
28
+ # Data plane d'administration — le pont `/nodefony/<ns>/api/*`
29
+
30
+ > N'importe quel module (et le kernel lui-même) peut exposer sa donnée d'admin — statistiques,
31
+ > introspection, actions — de façon **cohérente CLI ↔ Web**. Chaque module DÉCLARE ce qu'il expose
32
+ > (`IAdminApi`) ; un service unique, l'`AdminBroker`, COLLECTE ces déclarations au boot et MONTE les
33
+ > routes `/nodefony/<namespace>/api/*`. Studio n'est qu'un lecteur de ce plan de données : il ne le
34
+ > possède pas. Tout est ancré sur `nodefony/service/AdminBroker.ts` et le contrat core
35
+ > `IAdminApi.ts`.
36
+
37
+ 📍 [Documentation](../../../../../docs/index.md) › [Framework](index.md) › **Data plane admin**
38
+
39
+ ## 🧠 Le modèle mental — un annuaire qui monte des routes
40
+
41
+ Sépare **qui produit** la donnée (un module, sans rien savoir du transport) de **qui la transporte**
42
+ (le broker, seul à posséder le Router). C'est une **inversion de dépendance** : le contrat producteur
43
+ vit au plus bas niveau (le core), le montage vit dans le framework.
44
+
45
+ ```mermaid
46
+ flowchart TD
47
+ subgraph P["Producteurs — IAdminApi (n'importe quel niveau de la pile)"]
48
+ K["kernel"]
49
+ H["http"]
50
+ S["security"]
51
+ O["orm"]
52
+ end
53
+ P -->|"register()"| AB["AdminBroker<br/>@nodefony/framework"]
54
+ AB -->|"mountAll() → Router.createRoute"| RT["routes<br/>/nodefony/&lt;ns&gt;/api/*"]
55
+ RT --> AC["AdminApiController.dispatch<br/>(pont unique, N endpoints)"]
56
+ AC -->|"RBAC + idempotence"| HDL["handler(IAdminRequest)"]
57
+ HDL -->|"JSON sérialisable"| OUT["HTTP renderJson<br/>ou frame WS-RPC"]
58
+ ST["Studio (vue)"] -.->|"consomme, ne possède pas"| RT
59
+ ```
60
+
61
+ Trois idées à retenir :
62
+
63
+ 1. **Le producteur ne connaît pas le transport** — un handler lit un `IAdminRequest`
64
+ (`IAdminApi.ts:33`) et rend du JSON. Il ne touche jamais au socket ni à la `Response`.
65
+ 2. **Un seul controller pont** — toutes les routes admin pointent vers
66
+ `AdminApiController.dispatch()` (`AdminApiController.ts:60`). Pas de génération dynamique de
67
+ classes ; chaque route reste une vraie `Route` (404/405 du Router intacts).
68
+ 3. **Le broker possède le Router, pas le kernel** — c'est pourquoi il vit dans `@nodefony/framework`,
69
+ niveau qui monte les routes, alors que le contrat producteur vit dans le core.
70
+
71
+ ## 📖 Lexique
72
+
73
+ | Terme | Sens (dans cette page) |
74
+ | --------------------- | ---------------------------------------------------------------------------------------------------------- |
75
+ | Data plane (admin) | Le **plan de données** d'admin : l'ensemble des routes `/nodefony/<ns>/api/*` qui exposent l'état/actions. |
76
+ | `AdminBroker` | Le service qui collecte les `IAdminApi` et monte leurs routes. Le _transporteur_. |
77
+ | `IAdminApi` | Le contrat qu'un module implémente pour DÉCLARER son admin (namespace + endpoints). Le _producteur_. |
78
+ | Namespace | Segment d'identité d'un producteur → `/nodefony/<namespace>/api/*` (ex. `http`, `security`, `kernel`). |
79
+ | Endpoint | Une action déclarée (`IAdminEndpoint`) : chemin relatif, méthode, rôle, handler. |
80
+ | `IAdminRequest` | Projection normalisée du contexte HTTP/WS passée au handler (params, query, body, user, roles). |
81
+ | `IAdminResponse` | Enveloppe optionnelle du retour d'un handler (`status`, `headers`, `body`). |
82
+ | RBAC | _Role-Based Access Control_ : l'accès dépend des rôles de l'appelant. |
83
+ | `ROLE_NODEFONY_*` | Rôles de la **plateforme** (admin du framework), distincts des rôles applicatifs `ROLE_*` d'un tenant. |
84
+ | Pont / dispatch | Le controller unique qui, à chaque requête, retrouve l'endpoint et exécute son handler. |
85
+ | API souveraine | Toute action admin déclare AUSSI le transport WebSocket → invocable par le pont WS-RPC `api.request`. |
86
+ | Duplex | Une même action servie sur HTTP **et** WebSocket (le différenciateur Nodefony). |
87
+ | Catalogue / discovery | La liste des producteurs + endpoints, exposée en JSON pour que Studio bâtisse sa navigation. |
88
+ | Playground | La console dev qui joue n'importe quel controller depuis le navigateur (`/nodefony/playground`). |
89
+ | BFF | _Backend-For-Frontend_ : la session cookie opaque qui authentifie Studio en amont du RBAC. |
90
+ | Fail-closed | En cas de doute (rôle absent, endpoint non trouvé) → on REFUSE. Jamais d'ouverture par défaut. |
91
+
92
+ ## Qu'est-ce que c'est ? — un data plane, pas une vue
93
+
94
+ **Le problème.** Un framework doit s'administrer : lister ses routes, ses sessions, son firewall,
95
+ relancer une tâche. Sans convention, chaque module invente son URL, sa forme de réponse, sa garde —
96
+ et l'admin Web diverge de la CLI. Le résultat est une nébuleuse d'endpoints hétérogènes, impossibles
97
+ à découvrir automatiquement.
98
+
99
+ **La réponse.** Le data plane admin est une **convention unique** : tout ce qui s'administre s'expose
100
+ sous `/nodefony/<module>/api/*`, avec la même projection de requête, la même garde RBAC et la même
101
+ sérialisation JSON. Un module ne code jamais une route d'admin à la main — il **déclare** sa donnée,
102
+ le broker la monte.
103
+
104
+ > [!TIP]
105
+ > « Data plane » se lit **plan de données** : la couche qui transporte l'état et les actions d'admin,
106
+ > par opposition au **plan de contrôle** (l'UI de Studio qui décide _quoi_ afficher). Studio consomme
107
+ > le data plane ; il ne le contient pas — le même plan existe même si Studio n'est pas chargé.
108
+
109
+ ## La vision Nodefony — le contrat au plus bas, le montage au bon niveau
110
+
111
+ Nodefony sépare **deux rôles** par inversion de dépendance :
112
+
113
+ - **Producteur** (`IAdminApi`, dans le core `IAdminApi.ts:212`) : un module dit _quoi_ il expose —
114
+ son `adminNamespace` et ses `adminEndpoints()` — **sans importer `@nodefony/framework`**. Le
115
+ contrat vit au plus bas niveau commun pour qu'un adapter ORM, un service IA ou le kernel lui-même
116
+ puissent l'implémenter.
117
+ - **Transporteur** (`AdminBroker`, dans le framework `AdminBroker.ts:21`) : lui seul possède le
118
+ Router. Il collecte les producteurs et monte `/nodefony/<ns>/api/*`.
119
+
120
+ Pour s'enregistrer sans dépendre du framework, un producteur récupère le broker via sa **vue
121
+ minimale** `IAdminRegistry` (`IAdminApi.ts:243`) — juste `register()` — depuis le container. Le
122
+ kernel n'étant **pas** un `Module`, c'est le framework qui construit et enregistre l'`IAdminApi` du
123
+ kernel à sa place (`createKernelAdminApi()`, cité plus bas).
124
+
125
+ Le compromis assumé : **un seul controller pont** (`AdminApiController.ts:31`) sert les N endpoints.
126
+ On y gagne zéro génération de classe, un dispatch O(1), et une garde RBAC + idempotence appliquée au
127
+ même endroit pour tout le monde.
128
+
129
+ ## 🚀 Démarrage rapide
130
+
131
+ Objectif : exposer `GET /nodefony/shop/api/stats` et `POST /nodefony/shop/api/reindex` depuis un
132
+ module « shop » d'une app générée par `nodefony create app`. Le producteur déclare, le module
133
+ enregistre, le broker monte.
134
+
135
+ ```typescript
136
+ // modules/shop/index.ts — le producteur ET son enregistrement, vus d'une app.
137
+ import { Module, Kernel } from "nodefony";
138
+ import type {
139
+ Container,
140
+ IAdminApi,
141
+ IAdminEndpoint,
142
+ IAdminRequest,
143
+ IAdminResponse,
144
+ IAdminRegistry,
145
+ } from "nodefony";
146
+
147
+ /** Service métier « shop » — résolu du container, jamais importé par le broker. */
148
+ interface ShopService {
149
+ stats(full: boolean): Promise<{ orders: number; revenue?: number }>;
150
+ reindex(): Promise<{ jobId: string }>;
151
+ }
152
+
153
+ /**
154
+ * Producteur admin du module « shop » → monté sous `/nodefony/shop/api/*`.
155
+ * Un handler lit un `IAdminRequest` (projection du contexte) et rend du JSON :
156
+ * zéro socket, zéro `Response`. C'est ce découplage qui rend la même action
157
+ * invocable en HTTP ET par le pont WebSocket `api.request`.
158
+ */
159
+ function createShopAdminApi(shop: ShopService): IAdminApi {
160
+ const endpoints: IAdminEndpoint[] = [
161
+ {
162
+ // GET /nodefony/shop/api/stats — rôle par défaut ROLE_NODEFONY_ADMIN.
163
+ // Gradation par rôle : le détail (CA) n'est rendu qu'à un admin.
164
+ path: "stats",
165
+ summary: "Compteurs de la boutique (commandes, CA)",
166
+ handler: (req: IAdminRequest) =>
167
+ shop.stats(req.roles.includes("ROLE_NODEFONY_ADMIN")),
168
+ },
169
+ {
170
+ // POST /nodefony/shop/api/reindex — mutation : le broker impose une clé
171
+ // Idempotency-Key côté WebSocket (rejeu de socket), optionnelle en HTTP.
172
+ path: "reindex",
173
+ method: "POST",
174
+ role: "ROLE_NODEFONY_ADMIN",
175
+ summary: "Relance l'indexation du catalogue",
176
+ handler: async (): Promise<IAdminResponse<{ jobId: string }>> => {
177
+ const { jobId } = await shop.reindex();
178
+ return { status: 202, body: { jobId } };
179
+ },
180
+ },
181
+ ];
182
+ return {
183
+ adminNamespace: "shop",
184
+ adminDescriptor: () => ({
185
+ label: "Shop",
186
+ icon: "shopping-cart",
187
+ order: 50,
188
+ }),
189
+ adminEndpoints: () => endpoints,
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Le module enregistre son producteur au `onKernelBoot` — AVANT que le framework
195
+ * ne monte les routes (`onKernelReady` → `broker.mountAll()`). Le broker n'est
196
+ * présent que si `@nodefony/framework` est chargé : sinon, no-op silencieux
197
+ * (le module reste utilisable sans data plane admin).
198
+ */
199
+ class ShopModule extends Module {
200
+ constructor(kernel: Kernel) {
201
+ super("shop", kernel, import.meta.url, {});
202
+ }
203
+
204
+ override async onKernelBoot(): Promise<this> {
205
+ const container = this.kernel?.container as Container | undefined;
206
+ const registry = container?.get("adminBroker") as
207
+ IAdminRegistry | undefined;
208
+ if (registry && container && !registry.has("shop")) {
209
+ const shop = container.get("shop") as ShopService;
210
+ registry.register(createShopAdminApi(shop));
211
+ }
212
+ return this;
213
+ }
214
+ }
215
+
216
+ export default ShopModule;
217
+ ```
218
+
219
+ ### Ce qu'on observe
220
+
221
+ ```bash
222
+ # 1) Sans session Studio (BFF) : la zone firewall `nodefony-admin` verrouille → 401
223
+ curl -si http://localhost:5151/nodefony/shop/api/stats | head -1
224
+ # HTTP/1.1 401 Unauthorized
225
+
226
+ # 2) Authentifié en admin (cookie de session BFF) → 200 + l'identité du pod qui a répondu
227
+ curl -s -b /tmp/jar http://localhost:5151/nodefony/shop/api/stats
228
+ # {"orders":128,"revenue":48213}
229
+
230
+ # 3) La mutation → 202, corps porté par le `return` du handler
231
+ curl -si -b /tmp/jar -X POST http://localhost:5151/nodefony/shop/api/reindex | head -1
232
+ # HTTP/1.1 202 Accepted
233
+
234
+ # 4) Le catalogue : ce que Studio lit pour bâtir sa navigation admin
235
+ curl -s -b /tmp/jar http://localhost:5151/nodefony/framework/api/admin | head -c 160
236
+ # {"producers":[{"namespace":"kernel",…},{"namespace":"shop","label":"Shop",…}]}
237
+ ```
238
+
239
+ > [!NOTE]
240
+ > Chaque réponse HTTP porte un en-tête `x-nodefony-instance` (`AdminApiController.ts:76`) : en
241
+ > multi-pod, il dit **quel process** a répondu (le data plane est per-instance).
242
+
243
+ ## 🏗️ Architecture interne — register → mountAll → dispatch
244
+
245
+ Deux temps : un **montage** au boot (une fois), un **dispatch** par requête (O(1)).
246
+
247
+ ```mermaid
248
+ sequenceDiagram
249
+ participant M as Module producteur
250
+ participant B as AdminBroker
251
+ participant FW as Framework (onKernelReady)
252
+ participant RT as Router
253
+ participant AC as AdminApiController
254
+ M->>B: onKernelBoot → register(IAdminApi)
255
+ FW->>B: onKernelReady → mountAll()
256
+ B->>RT: createRoute(/nodefony/<ns>/api/*, dispatch, [method, WEBSOCKET])
257
+ Note over B,RT: routes figées — register() après mountAll → throw
258
+ RT->>AC: requête → dispatch(...args)
259
+ AC->>B: resolve(routeName) → IAdminRoute
260
+ AC->>AC: RBAC (isAdminGranted) → 403 sinon
261
+ AC->>AC: idempotence des mutations → gate
262
+ AC-->>RT: handler(IAdminRequest) → JSON / RpcError
263
+ ```
264
+
265
+ | # | Étape | Où |
266
+ | --- | ----------------------------------------- | ---------------------------------------------------------- |
267
+ | 1 | Le producteur s'enregistre | `AdminBroker.register()` (`AdminBroker.ts:45`) |
268
+ | 2 | Le framework monte tout | `AdminBroker.mountAll()` (`AdminBroker.ts:104`) |
269
+ | 3 | Une route par endpoint (nom déterministe) | `Router.createRoute()` (`AdminBroker.ts:124`) |
270
+ | 4 | Le controller pont estampillé une fois | `Router.setController()` idempotent (`AdminBroker.ts:146`) |
271
+ | 5 | Dispatch : lookup de la route | `AdminBroker.resolve()` (`AdminApiController.ts:94`) |
272
+ | 6 | Projection du contexte en requête admin | `buildRequest()` (`AdminApiController.ts:175`) |
273
+ | 7 | Normalisation du retour | `normalizeAdminResult()` (`executeAdmin.ts:90`) |
274
+
275
+ Points de conception saillants :
276
+
277
+ - **Le montage FIGE les routes.** Après `mountAll()`, tout `register()` lève
278
+ (`AdminBroker.ts:46`) : on ne monte pas une route à chaud (Zero surprise en prod). Le broker garde
279
+ la trace via son drapeau `mounted`.
280
+ - **Le nom de route est déterministe** : `admin.<ns>.<method>.<path>` (`AdminBroker.ts:114`) — c'est
281
+ la clé du lookup O(1) que le pont refait à chaque requête.
282
+ - **`Router.setController` n'est appelé qu'une fois** par process (`AdminBroker.ts:146`) : il pose
283
+ une propriété non réinscriptible sur le prototype ; une garde `hasOwnProperty` rend l'appel
284
+ idempotent (multi-broker en test, re-boot).
285
+ - **Le catalogue se construit à la volée** depuis `AdminBroker.list()` + `AdminBroker.routes()`
286
+ (`AdminBroker.ts:100`) — jamais un état retenu.
287
+
288
+ > [!IMPORTANT]
289
+ > Convention de route **figée** : le data plane est toujours en **≥ 3 segments**
290
+ > `/nodefony/<module>/api/*` (`IAdminBroker.ts:42`). Jamais une route admin mono-segment
291
+ > `/nodefony/<module>` — elle entrerait en collision avec le fallback SPA de Studio. Le chemin
292
+ > relatif d'un endpoint a **≥ 1 segment** (`types/IAdminApi.ts:155`) : la racine `/nodefony/<ns>/api` est
293
+ > réservée.
294
+
295
+ ## 🔐 RBAC — autorisation du data plane
296
+
297
+ Deux gardes se succèdent, dans cet ordre :
298
+
299
+ 1. **Le firewall AUTHENTIFIE en amont.** La zone `nodefony-admin` (`config.ts:137`) couvre
300
+ `^/nodefony/[^/]+/api(/|$)` (`config.ts:141`) avec l'authenticator `session` (cookie BFF) : sans
301
+ session, c'est **401** avant même le controller.
302
+ 2. **Le broker tranche le RÔLE.** À l'exécution, le pont compare le rôle exigé aux rôles de
303
+ l'appelant via la fonction pure `isAdminGranted()` (`adminRbac.ts:24`). Rôle absent → **403**.
304
+ Le refus est prononcé au CŒUR (`executeAdmin.ts`), pas dans le controller HTTP : c'est ce
305
+ qui fait que les deux chemins d'appel — la route et le pont MCP — refusent à l'identique.
306
+
307
+ La décision est **fail-closed** : un authentifié **sans** le rôle requis — y compris `roles=[]`
308
+ (compte non doté) — est **rejeté** (`adminRbac.ts:27`). C'était l'ex-fail-open historique (un
309
+ `roles.length > 0 &&` héritait du « mode mock » d'avant l'auth) : l'absence de rôle ne vaut pas
310
+ laissez-passer.
311
+
312
+ - **Rôle par défaut** : sans `role` explicite, un endpoint exige `ROLE_NODEFONY_ADMIN`
313
+ (`AdminBroker.ts:112` ; défaut du champ `IAdminEndpoint.role`, `IAdminApi.ts:152`).
314
+ - **Endpoint public** : `public: true` (`IAdminApi.ts:152`) → le RBAC du broker est court-circuité
315
+ (`role === ""`, `adminRbac.ts:26`). À réserver aux sondes cloud-native (liveness/readiness) et à
316
+ placer hors d'une zone fermée — sinon le firewall verrouille en amont. Exemple réel :
317
+ `GET /nodefony/kernel/api/livez` (`KernelAdminApi.ts:615`), sorti de `nodefony-admin` par la zone
318
+ publique `nodefony-liveness` (`config.ts:132`).
319
+
320
+ > [!TIP]
321
+ > `ROLE_NODEFONY_*` = rôles de la **plateforme** (administrer le framework), distincts des rôles
322
+ > applicatifs `ROLE_*` d'un tenant. Un endpoint peut exiger un rôle plus fin (`role: "ROLE_…"`) ou
323
+ > graduer l'information **dans** son handler en lisant `request.roles`.
324
+
325
+ ## 🔌 HTTP et WebSocket — la même action (API souveraine)
326
+
327
+ Toute action admin déclare **aussi** le transport `WEBSOCKET` (`AdminBroker.ts:123`) : la route est
328
+ montée avec `[method, "WEBSOCKET"]`. Elle devient donc invocable par le pont WS-RPC `api.request`
329
+ (`WebsocketContext.ts:354`) — même action, même handler, même réponse. Seul l'emballage diffère :
330
+
331
+ - **HTTP** : `renderJson` + statut + en-tête `x-nodefony-instance` (`AdminApiController.ts:74`).
332
+ - **WS-RPC** : la valeur **nue** (le pont l'enveloppe `{id, result}`) ; un statut ≥ 400 devient un
333
+ `RpcError` avec `data.status`/`data.body` (`AdminApiController.ts:66`), symétrie d'un `fetch` qui
334
+ expose son statut.
335
+
336
+ Les **mutations** sont pontables par socket. La sécurité d'écriture repose alors sur l'**idempotence**
337
+ (`idempotencyGate()`, `AdminApiController.ts:158`) : la clé `Idempotency-Key` est **obligatoire en
338
+ WS** (une socket reconnecte et rejoue), **optionnelle en HTTP** (`required: false`,
339
+ `AdminApiController.ts:184`). Un `GET` n'est jamais idempotenté (`AdminApiController.ts:149`) ; la
340
+ porte est évaluée **après** le RBAC (un 403 ne consomme aucune entrée). Le helper est le **même** que
341
+ le seam `@Idempotent` des controllers userland — voir [Idempotence](idempotence.md).
342
+
343
+ ## 🧩 Extension — déclarer l'API d'admin de son module
344
+
345
+ Trois pas, du point de vue d'un module :
346
+
347
+ 1. **Écrire un `IAdminApi`** : `adminNamespace` (url-safe, stable), `adminDescriptor()` (sidebar
348
+ Studio, `IAdminApi.ts:220`) et `adminEndpoints()` (`IAdminApi.ts:222`). Un endpoint peut renvoyer
349
+ la donnée brute (assumée `{status:200, body}`) ou une `IAdminResponse` pour piloter statut/en-têtes
350
+ (`IAdminApi.ts:67`).
351
+ 2. **S'enregistrer au `onKernelBoot`** via `IAdminRegistry.register()` (`IAdminApi.ts:249`), récupéré
352
+ par `container.get("adminBroker")`. Rendre l'appel **idempotent** (`registry.has(ns)` avant
353
+ `register`) — modèle de tous les producteurs.
354
+ 3. **Laisser le framework monter** : à `onKernelReady`, `Framework.onKernelReady()` enregistre les
355
+ producteurs internes puis appelle `broker.mountAll()` (`index.ts:369`).
356
+
357
+ **Handlers lazy** : résous tes services **dans** le handler (à la requête), jamais au montage — un
358
+ service désactivable renvoie alors `503` proprement au lieu de casser le boot.
359
+
360
+ ### Les producteurs réels (à imiter, sans les redocumenter)
361
+
362
+ Le broker lui-même est déclaré comme service du framework (`index.ts:144`). Les producteurs internes
363
+ sont enregistrés au `onKernelReady` du framework :
364
+
365
+ | Namespace | Producteur | Rôle |
366
+ | ----------- | ----------------------------------------------------- | ------------------------------------------------- |
367
+ | `kernel` | `createKernelAdminApi` (`KernelAdminApi.ts:468`) | modules, process, uptime, `livez` |
368
+ | `framework` | `createFrameworkAdminApi` (`FrameworkAdminApi.ts:40`) | dump du Router + **catalogue** + Playground (dev) |
369
+ | `syslog` | `createSyslogAdminApi` (`SyslogAdminApi.ts:95`) | viewer de logs (dev) |
370
+
371
+ Les modules externes s'enregistrent depuis leur propre `onKernelBoot` :
372
+
373
+ | Namespace | Module | Enregistrement |
374
+ | ---------- | -------------------- | --------------------------------------------------- |
375
+ | `http` | `@nodefony/http` | `createHttpAdminApi` (`http/index.ts:121`) |
376
+ | `security` | `@nodefony/security` | `registerSecurityAdminApi` (`security/index.ts:94`) |
377
+ | `user` | `@nodefony/user` | `adminNamespace` (`UserAdminApi.ts:879`) |
378
+ | `orm` | `@nodefony/orm-core` | `adminNamespace` (`OrmAdminApi.ts:537`) |
379
+
380
+ Pour le détail de chacun, se reporter à la doc de son module — le broker reste agnostique de leur
381
+ contenu.
382
+
383
+ ## 🧰 API publique
384
+
385
+ Depuis une app : `AdminBroker` (le service) et les types `IAdminApi`, `IAdminEndpoint`,
386
+ `IAdminRequest`, `IAdminResponse`, `IAdminRegistry`, `IAdminDescriptor` — tous exportés par
387
+ `@nodefony/framework` et `nodefony`. Les signatures exactes vivent dans `.ai/symbols.json` — jamais
388
+ recopiées ici (elles s'y périmeraient).
389
+
390
+ | Membre (`IAdminBroker`) | Rôle | Ancre |
391
+ | ------------------------ | -------------------------------------------------------- | -------------------- |
392
+ | `register(api)` | Enregistre un producteur (throw si namespace pris/monté) | `AdminBroker.ts:45` |
393
+ | `unregister(ns)` | Retire un producteur (et ses routes si montées) | `AdminBroker.ts:61` |
394
+ | `has(ns)` / `getApi(ns)` | Interrogation du registre | `AdminBroker.ts:79` |
395
+ | `list()` | Producteurs enregistrés (immuable) | `AdminBroker.ts:87` |
396
+ | `resolvePath(ns, path)` | Chemin absolu d'un endpoint sans le monter | `AdminBroker.ts:91` |
397
+ | `mountAll()` | Monte toutes les routes (idempotent) | `AdminBroker.ts:104` |
398
+ | `resolve(routeName)` | Lookup O(1) d'une route montée (utilisé par le pont) | `AdminBroker.ts:96` |
399
+ | `routes()` | Introspection des routes montées (source du catalogue) | `AdminBroker.ts:100` |
400
+
401
+ ## 📡 Observabilité — Studio
402
+
403
+ - **Catalogue / discovery** : `GET /nodefony/framework/api/admin` (`FrameworkAdminApi.ts:203`) —
404
+ producteurs + descriptors + endpoints. C'est ce que Studio lit pour générer sa navigation admin.
405
+ - **Routes** : `GET /nodefony/framework/api/routes` (`FrameworkAdminApi.ts:122`) — l'équivalent web
406
+ de `nodefony router:dump` ; une variante paginée serveur `routes/page` (`FrameworkAdminApi.ts:129`).
407
+ - **Playground** (`/nodefony/playground`, **dev only**) : `buildPlaygroundSnapshot()`
408
+ (`PlaygroundAdminApi.ts:167`) sérialise controllers + actions + transports + params + gardes — la
409
+ page Studio bâtit ses formulaires depuis ces métadonnées, **sans code généré**. Le montage est
410
+ conditionné à l'environnement (`index.ts:342`). Le pont admin lui-même est **exclu** du snapshot
411
+ (`PlaygroundAdminApi.ts:180`) pour ne pas noyer les controllers applicatifs.
412
+ - Les écrans admin de Studio (Routes, Sessions, Firewall, Users…) sont des vues qui consomment ces
413
+ data planes — voir le [module Studio](../../studio/docs/index.md).
414
+
415
+ ## ⚠️ Pièges (symptôme → cause → correction)
416
+
417
+ | Symptôme | Cause (dans le code) | Correction |
418
+ | ------------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
419
+ | `register()` throw « routes figées » | Appel **après** `mountAll()` (`AdminBroker.ts:46`) | Enregistrer au `onKernelBoot`, pas plus tard |
420
+ | `register()` throw « namespace déjà enregistré » | Deux producteurs sur le même `adminNamespace` (`AdminBroker.ts:51`) | Namespace unique ; garder `register()` idempotent (`has(ns)` avant) |
421
+ | 401 sur toute route `/nodefony/<ns>/api/*` | Zone `nodefony-admin` : pas de session BFF (`config.ts:141`) | S'authentifier (login BFF) ; pour une sonde publique → `public: true` + zone anonyme |
422
+ | 403 alors qu'on est connecté | Rôle manquant, `isAdminGranted` fail-closed (`adminRbac.ts:24`) | Doter le compte du rôle requis (défaut `ROLE_NODEFONY_ADMIN`) |
423
+ | WS : mutation refusée `400` clé requise | Idempotence : clé obligatoire par socket (`AdminApiController.ts:184`) | Fournir `Idempotency-Key` sur la mutation WS |
424
+ | Route admin injoignable / collision Studio | Endpoint mono-segment `/nodefony/<module>` (`IAdminBroker.ts:42`) | Toujours `≥ 3` segments `/nodefony/<ns>/api/<path>` |
425
+ | 500 « Admin endpoint not registered » | `adminRoute` absent du registre — incohérence interne (`AdminApiController.ts:96`) | Vérifier que le producteur a bien été enregistré avant `mountAll()` |
426
+
427
+ ## 🧪 Tests & couverture
428
+
429
+ Deux familles couvrent la brique — les **chiffres exacts vivent dans la carte de l'aperçu**
430
+ (régénérée depuis vitest, jamais figée ici) :
431
+
432
+ - **unit** — `AdminBroker.test.ts` (register/mount/resolve/unregister, idempotence du montage) ;
433
+ `adminRbac.test.ts` (la fonction pure `isAdminGranted` : fail-closed, endpoint public,
434
+ `roles=[]`) ; `PlaygroundAdminApi.test.ts` (sérialisation des métadonnées d'actions) ;
435
+ - **intégration** — `admin-dataplane.test.ts` : le data plane bout-en-bout (montage réel, RBAC,
436
+ catalogue, idempotence des mutations, duplex HTTP/WS).
437
+
438
+ Ce qui **manque** aujourd'hui : aucun banc de charge/mémoire dédié au broker seul — le coût est mesuré
439
+ au niveau du pipeline complet (`memory.test.ts` de `@nodefony/http` + suites de charge). Pour ces
440
+ axes, voir les skills `nodefony-load-test` et `nodefony-check-memory-health`.
441
+
442
+ Couverture : `npm run coverage` dans `@nodefony/framework`.
443
+
444
+ ## 🔗 Pour aller plus loin
445
+
446
+ - ⬆️ **Retour au hub** : [Framework — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
447
+ - 🧭 **Pages sœurs** : [Contrôleurs](controller.md) (ce dont hérite le pont) · [Routage](routing.md) (comment les routes admin sont montées) · [Idempotence](idempotence.md) (la porte des mutations)
448
+ - Qui authentifie en amont du RBAC → [Firewall](../../security/docs/firewall.md)
449
+ - Les écrans qui consomment ce data plane → [module Studio](../../studio/docs/index.md)
450
+ - Où le data plane s'insère dans le pipeline → [pipeline de requête](../../../../../docs/architecture/pipeline-requete.md)
451
+ - Signatures exactes des membres publics → graphe symbolique `.ai/symbols.json`