@nodefony/framework 10.0.0-beta.3 → 10.0.0-beta.4
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 +4 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +9 -18
- package/dist/nodefony/config/config.js +1 -2
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/controller/AdminApiController.js +38 -7
- package/dist/nodefony/controller/ApiKeyController.js +16 -8
- package/dist/nodefony/controller/BenchController.js +1 -2
- package/dist/nodefony/controller/IssuerMetadataController.js +1 -2
- package/dist/nodefony/controller/LoginPageController.js +4 -11
- package/dist/nodefony/controller/OAuth2Controller.js +3 -4
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -2
- package/dist/nodefony/controller/SessionAuthController.js +13 -4
- package/dist/nodefony/controller/TokenAuthController.js +3 -4
- package/dist/nodefony/controller/TotpController.js +2 -3
- package/dist/nodefony/controller/WebAuthnController.js +2 -3
- package/dist/nodefony/controller/oauthAuthority.js +1 -2
- package/dist/nodefony/decorators/routerDecorators.js +155 -53
- package/dist/nodefony/service/AdminBroker.js +4 -6
- package/dist/nodefony/service/Eta.js +1 -2
- package/dist/nodefony/service/IdempotencyStore.js +4 -6
- package/dist/nodefony/service/router.js +49 -15
- package/dist/nodefony/src/Controller.js +22 -39
- package/dist/nodefony/src/FrameworkAdminApi.js +31 -10
- package/dist/nodefony/src/KernelAdminApi.js +7 -9
- package/dist/nodefony/src/PlaygroundAdminApi.js +72 -3
- package/dist/nodefony/src/RedisIdempotencyStore.js +46 -4
- package/dist/nodefony/src/Resolver.js +66 -34
- package/dist/nodefony/src/ResourceController.js +2 -3
- package/dist/nodefony/src/Route.js +2 -3
- package/dist/nodefony/src/SecurityApiController.js +1 -2
- package/dist/nodefony/src/SyslogAdminApi.js +1 -2
- package/dist/nodefony/src/Template.js +1 -2
- package/dist/nodefony/src/configMutation.js +1 -2
- package/dist/nodefony/src/docsReader.js +1 -2
- package/dist/nodefony/src/idempotency.js +1 -2
- package/dist/nodefony/src/idempotencyGc.js +1 -2
- package/dist/nodefony/src/idempotencyStoreRegistry.js +1 -2
- package/dist/nodefony/src/loginPage.js +2 -3
- package/dist/nodefony/src/requireAuthentication.js +1 -2
- package/dist/nodefony/src/routeSchema.js +81 -0
- package/dist/nodefony/src/scopeCatalog.js +1 -2
- package/dist/nodefony/src/singletonGuard.js +1 -2
- package/dist/nodefony/src/syslogFilters.js +1 -2
- package/dist/types/index.d.ts +5 -10
- package/dist/types/nodefony/controller/AdminApiController.d.ts +12 -0
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +7 -0
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +133 -32
- package/dist/types/nodefony/interfaces/IController.d.ts +2 -3
- package/dist/types/nodefony/service/router.d.ts +27 -1
- package/dist/types/nodefony/src/Controller.d.ts +14 -16
- package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +12 -0
- package/dist/types/nodefony/src/Resolver.d.ts +25 -1
- package/dist/types/nodefony/src/ResourceController.d.ts +1 -1
- package/dist/types/nodefony/src/Route.d.ts +3 -0
- package/dist/types/nodefony/src/loginPage.d.ts +1 -1
- package/dist/types/nodefony/src/routeSchema.d.ts +112 -0
- package/docs/admin.md +80 -80
- package/docs/controller.md +108 -109
- package/docs/decorateurs.md +208 -161
- package/docs/idempotence.md +133 -133
- package/docs/routing.md +90 -89
- package/docs/templates.md +55 -55
- package/package.json +8 -11
- package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
|
@@ -42,7 +42,7 @@ declare class Resolver implements IResolver {
|
|
|
42
42
|
/**
|
|
43
43
|
* Query d'une invocation **par message** (pont WS-RPC `api.request`) — pendant
|
|
44
44
|
* de `cleanPathOverride` pour le `?…` du path invoqué. Le contexte WS étant
|
|
45
|
-
* PARTAGÉ par la connexion (sa `
|
|
45
|
+
* PARTAGÉ par la connexion (sa `query` = celle du handshake), la query
|
|
46
46
|
* per-invocation vit ici (le Resolver est per-invocation → zéro bleed entre
|
|
47
47
|
* requêtes concurrentes d'une même socket). `null` (hot path HTTP) = ignoré.
|
|
48
48
|
* Consommé par `@Query` (`_buildParamArgs`) et copié sur le controller
|
|
@@ -70,6 +70,11 @@ declare class Resolver implements IResolver {
|
|
|
70
70
|
* administrateurs.
|
|
71
71
|
*/
|
|
72
72
|
messageInvocation: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Les gardes de l'action ont été jugées favorables par {@link authorizeAction}
|
|
75
|
+
* et ne se rejugent pas à la prochaine exécution — consommé par elle.
|
|
76
|
+
*/
|
|
77
|
+
private preAuthorized;
|
|
73
78
|
constructor(context: ContextType);
|
|
74
79
|
match(route: Route, context: ContextType, cleanPath?: string, literal?: boolean): ((string | null)[] & Record<string, unknown>) | null | undefined;
|
|
75
80
|
/**
|
|
@@ -169,6 +174,25 @@ declare class Resolver implements IResolver {
|
|
|
169
174
|
* d'action) ; un refus d'autorisation rompt la promesse rendue.
|
|
170
175
|
*/
|
|
171
176
|
executeAction(data?: unknown[], reload?: boolean, metaArg?: RouteActionMeta): MaybePromise<ActionOutcome>;
|
|
177
|
+
/**
|
|
178
|
+
* Valide l'entrée d'une action à schéma, puis l'exécute avec cette entrée
|
|
179
|
+
* pour argument unique. Synchrone tant que le validateur l'est.
|
|
180
|
+
*
|
|
181
|
+
* @throws SchemaValidationError (422) quand l'entrée viole le schéma.
|
|
182
|
+
*/
|
|
183
|
+
private _validateAndInvoke;
|
|
184
|
+
/**
|
|
185
|
+
* Juge les gardes de l'action résolue SANS l'exécuter — appelé par le
|
|
186
|
+
* pipeline WebSocket AVANT d'accepter le handshake (#596), pour qu'un
|
|
187
|
+
* connecté sans le rôle reçoive son 403 en statut HTTP au lieu d'une
|
|
188
|
+
* ouverture suivie d'une fermeture. Un verdict favorable vaut pour la
|
|
189
|
+
* PROCHAINE exécution (l'action de handshake), qui ne le rejuge pas ; les
|
|
190
|
+
* messages suivants, eux, sont jugés chacun.
|
|
191
|
+
*
|
|
192
|
+
* @returns `undefined` quand l'action n'est pas gardée ; sinon la promesse du
|
|
193
|
+
* verdict, rompue par un refus
|
|
194
|
+
*/
|
|
195
|
+
authorizeAction(): Promise<void> | undefined;
|
|
172
196
|
/**
|
|
173
197
|
* Les gardes d'autorisation d'une action : `@IsGranted`/`@RequireScope`, puis
|
|
174
198
|
* le filet de la ZONE quand aucune garde n'a décidé du RÔLE.
|
|
@@ -86,7 +86,7 @@ export interface IResourceService<T = unknown> {
|
|
|
86
86
|
* sur `this` doit rétrograder : `static scope = "request"`.
|
|
87
87
|
*
|
|
88
88
|
* Sécurité : aucun critère de requête n'est passé AUTOMATIQUEMENT au service
|
|
89
|
-
* (pas de `find(this.
|
|
89
|
+
* (pas de `find(this.query)` implicite) — exposer un filtrage est une
|
|
90
90
|
* décision EXPLICITE de la sous-classe (deny-by-default ; le scope de
|
|
91
91
|
* sécurité des données se branche au niveau service/criteria, P6).
|
|
92
92
|
*
|
|
@@ -2,10 +2,13 @@ import { HTTPMethod, SchemeType, ContextType } from "@nodefony/http";
|
|
|
2
2
|
import type { IRoute } from "../interfaces/index.js";
|
|
3
3
|
import type { RouteActionMeta } from "../decorators/routerDecorators.js";
|
|
4
4
|
import Controller from "./Controller.js";
|
|
5
|
+
import type { ControllerScope } from "./Controller.js";
|
|
5
6
|
export interface ControllerConstructor {
|
|
6
7
|
new (...args: any[]): Controller;
|
|
7
8
|
/** Prototype de la classe — porte les métadonnées des actions (`@Param`…). */
|
|
8
9
|
readonly prototype: Controller;
|
|
10
|
+
/** Portée de l'instance (`Controller.scope`, statique hérité). */
|
|
11
|
+
readonly scope?: ControllerScope;
|
|
9
12
|
}
|
|
10
13
|
/**
|
|
11
14
|
* Options pour la configuration d'une route.
|
|
@@ -141,4 +141,4 @@ export declare function buildLoginPageView(page: ILoginPageDescription, request:
|
|
|
141
141
|
* d'empreinte (`i-finger`) reprend `IconFingerprint` de Tabler Icons (MIT),
|
|
142
142
|
* celle du bouton de Studio : les deux écrans montrent le même geste.
|
|
143
143
|
*/
|
|
144
|
-
export declare const LOGIN_PAGE_TEMPLATE = "<!doctype html>\n<html lang=\"fr\"<% if (it.theme) { %> data-theme=\"<%= it.theme %>\"<% } %>>\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"color-scheme\" content=\"dark light\" />\n <meta name=\"robots\" content=\"noindex\" />\n <title><%= it.title %> \u2014 <%= it.brand %></title>\n <link rel=\"icon\" href=\"<%= it.logoHref %>\" type=\"image/svg+xml\" />\n<% if (!it.theme) { %>\n <script nonce=\"<%= it.nonce %>\">try{var t=localStorage.getItem(\"nf-login-theme\");if(t===\"light\"||t===\"dark\")document.documentElement.dataset.theme=t}catch(e){}</script>\n<% } %>\n <link rel=\"stylesheet\" href=\"<%= it.styleHref %>\" />\n<% if (it.skinHref) { %>\n <link rel=\"stylesheet\" href=\"<%= it.skinHref %>\" />\n<% } %>\n<% if (it.stylesheetHref) { %>\n <link rel=\"stylesheet\" href=\"<%= it.stylesheetHref %>\" />\n<% } %>\n <script type=\"module\" src=\"<%= it.scriptHref %>\" nonce=\"<%= it.nonce %>\"></script>\n </head>\n <body data-skin=\"<%= it.skin %>\" data-layout=\"<%= it.layout %>\"<% if (it.backdrop) { %> data-backdrop<% } %><% if (it.ssoFirst) { %> data-sso-first<% } %>>\n <svg width=\"0\" height=\"0\" style=\"position: absolute\" aria-hidden=\"true\">\n <symbol id=\"i-user\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"8\" r=\"3.5\" /><path d=\"M5.5 20a6.5 6.5 0 0 1 13 0\" /></symbol>\n <symbol id=\"i-lock\" viewBox=\"0 0 24 24\"><rect x=\"5\" y=\"10.5\" width=\"14\" height=\"10\" rx=\"2\" /><path d=\"M8.5 10.5V7.5a3.5 3.5 0 0 1 7 0v3\" /></symbol>\n <symbol id=\"i-eye\" viewBox=\"0 0 24 24\"><path d=\"M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z\" /><circle cx=\"12\" cy=\"12\" r=\"2.8\" /></symbol>\n <symbol id=\"i-arrow\" viewBox=\"0 0 24 24\"><path d=\"M5 12h14M13 6l6 6-6 6\" /></symbol>\n <symbol id=\"i-key\" viewBox=\"0 0 24 24\"><circle cx=\"8\" cy=\"15\" r=\"3.8\" /><path d=\"M10.8 12.2 19.5 3.5M16.5 6.5l2.5 2.5M14 9l2 2\" /></symbol>\n <symbol id=\"i-check\" viewBox=\"0 0 24 24\"><path d=\"M5 12.5l4.5 4.5L19 7.5\" /></symbol>\n <symbol id=\"i-finger\" viewBox=\"0 0 24 24\"><path d=\"M18.9 7a8 8 0 0 1 1.1 5v1a6 6 0 0 0 .8 3\" /><path d=\"M8 11a4 4 0 0 1 8 0v1a10 10 0 0 0 2 6\" /><path d=\"M12 11v2a14 14 0 0 0 2.5 8\" /><path d=\"M8 15a18 18 0 0 0 1.8 6\" /><path d=\"M4.9 19a22 22 0 0 1-.9-7v-1a8 8 0 0 1 12-6.95\" /></symbol>\n <symbol id=\"i-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4\" /><path d=\"M12 2.5v2M12 19.5v2M4.6 4.6l1.4 1.4M18 18l1.4 1.4M2.5 12h2M19.5 12h2M4.6 19.4 6 18M18 6l1.4-1.4\" /></symbol>\n <symbol id=\"i-moon\" viewBox=\"0 0 24 24\"><path d=\"M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5Z\" /></symbol>\n <symbol id=\"i-shield\" viewBox=\"0 0 24 24\"><path d=\"M12 3l7.5 3v5.5c0 4.6-3.2 8-7.5 9.5-4.3-1.5-7.5-4.9-7.5-9.5V6z\" /></symbol>\n <symbol id=\"m-github\" viewBox=\"0 0 16 16\"><path d=\"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z\" /></symbol>\n </svg>\n <header class=\"nf-strip\">\n <div class=\"nf-strip-brand\"><img class=\"nf-logo\" src=\"<%= it.logo %>\" alt=\"\" /><%= it.brand %></div>\n </header>\n <div class=\"nf-layout\">\n <aside class=\"nf-hero\" aria-hidden=\"true\">\n <div class=\"nf-hero-brand\"><img class=\"nf-logo\" src=\"<%= it.logo %>\" alt=\"\" /><%= it.brand %></div>\n<% if (it.hero === null) { %>\n <div class=\"nf-home\">\n <img\n class=\"nf-home-logo\"\n src=\"<%= it.logoHref %>\"\n alt=\"\"\n />\n <h2>Le framework Node.js fullstack</h2>\n <p class=\"sub\">\n Temps r\u00E9el natif \u00B7 d\u00E9veloppement agentic-ready \u00B7 socle TypeScript\n isomorphe\n </p>\n <p class=\"sig\">\n Une action de contr\u00F4leur. Deux transports. La m\u00EAme session, la m\u00EAme\n s\u00E9curit\u00E9, le m\u00EAme code.\n </p>\n <ul class=\"badges\">\n <li class=\"agentic\">Agentic ready</li>\n <li>Node.js \u2265 24</li>\n <li>TypeScript strict</li>\n <li>ESM only</li>\n <li>Licence Apache 2.0</li>\n </ul>\n <div\n class=\"install\"\n role=\"img\"\n aria-label=\"Commande d'installation : npx nodefony create app\"\n >\n <span class=\"p\">$</span><span>npx nodefony create app</span>\n </div>\n <div class=\"features\">\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <polygon\n points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"\n /></svg\n ></span>\n <h3>Temps r\u00E9el natif</h3>\n <p>\n Le WebSocket n'est pas un ajout : m\u00EAme pipeline, m\u00EAme table de\n routes, m\u00EAme s\u00E9curit\u00E9 que le HTTP.\n </p>\n </div>\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\" />\n <path d=\"m9 12 2 2 4-4\" /></svg\n ></span>\n <h3>S\u00E9curit\u00E9 par conception</h3>\n <p>\n Pare-feu applicatif par zones, Zero Trust, CSRF Fetch Metadata,\n sessions hybrides, WebAuthn.\n </p>\n </div>\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\" />\n <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\" />\n <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\" />\n <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\" /></svg\n ></span>\n <h3>DI & modules</h3>\n <p>\n Injection de d\u00E9pendances par d\u00E9corateurs, modules autonomes,\n configuration valid\u00E9e au boot.\n </p>\n </div>\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <path\n d=\"M3 12h18M12 3c2.5 2.6 4 5.7 4 9s-1.5 6.4-4 9c-2.5-2.6-4-5.7-4-9s1.5-6.4 4-9z\"\n /></svg\n ></span>\n <h3>Socle isomorphe</h3>\n <p>\n Le m\u00EAme paquet c\u00F4t\u00E9 serveur et c\u00F4t\u00E9 navigateur : client temps\n r\u00E9el, r\u00E8gles et types \u00E9crits une fois.\n </p>\n </div>\n </div>\n </div>\n <small>Propuls\u00E9 par Nodefony</small>\n<% } else if (it.hero) { %>\n <div class=\"nf-home\">\n <h2><%= it.hero.heading %></h2>\n<% if (it.hero.text) { %>\n <p class=\"sub\"><%= it.hero.text %></p>\n<% } %>\n </div>\n<% } %>\n </aside>\n <main class=\"nf-stage\" data-nf-login data-from=\"<%= it.from %>\">\n <button type=\"button\" class=\"nf-theme\" data-theme-toggle aria-label=\"Basculer entre th\u00E8me clair et th\u00E8me sombre\"><svg class=\"nf-icon t-sun\" aria-hidden=\"true\"><use href=\"#i-sun\" /></svg><svg class=\"nf-icon t-moon\" aria-hidden=\"true\"><use href=\"#i-moon\" /></svg></button>\n <section class=\"nf-card\" aria-labelledby=\"nf-title\">\n <div class=\"nf-head\">\n <div class=\"nf-brand\"><img class=\"nf-logo\" src=\"<%= it.logo %>\" alt=\"\" /><%= it.brand %></div>\n <h1 id=\"nf-title\"><%= it.title %></h1>\n <p class=\"nf-sub\" id=\"nf-sub\"><%= it.lead %></p>\n </div>\n <div class=\"nf-body\">\n<% if (it.password) { %>\n <ol class=\"nf-steps\" aria-label=\"\u00C9tapes\">\n <li data-tab=\"identifier\" aria-current=\"step\">Identifiant</li>\n <li data-tab=\"password\">Mot de passe</li>\n <li data-tab=\"mfa\" hidden>V\u00E9rification</li>\n </ol>\n <form data-step=\"identifier\">\n <div class=\"nf-field\">\n <label for=\"nf-username\">Identifiant ou adresse e-mail</label>\n <div class=\"nf-input\">\n <svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-user\" /></svg>\n <input id=\"nf-username\" name=\"username\" autocomplete=\"username\" autocapitalize=\"none\" spellcheck=\"false\" required />\n </div>\n </div>\n <button class=\"nf-btn primary\" type=\"submit\">Continuer<svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-arrow\" /></svg></button>\n </form>\n <form data-step=\"password\" hidden>\n <!-- Compte de l'\u00E9tape pr\u00E9c\u00E9dente, masqu\u00E9 : le gestionnaire de mots de passe\n associe le mot de passe \u00E0 CE compte (HTML, jeton \u00AB username \u00BB). -->\n <input name=\"username\" autocomplete=\"username\" hidden readonly tabindex=\"-1\" />\n <div class=\"nf-account\">\n <span class=\"nf-avatar\" aria-hidden=\"true\"></span><span class=\"who\"></span>\n <button type=\"button\" class=\"nf-link\" data-go=\"identifier\">Changer</button>\n </div>\n <div class=\"nf-field\">\n <div class=\"nf-label-row\"><label for=\"nf-password\">Mot de passe</label></div>\n <div class=\"nf-input\">\n <svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-lock\" /></svg>\n <input id=\"nf-password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" required />\n <button type=\"button\" class=\"nf-reveal\" data-reveal aria-label=\"Afficher le mot de passe\" aria-pressed=\"false\"><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-eye\" /></svg></button>\n </div>\n </div>\n <button class=\"nf-btn primary\" type=\"submit\">Se connecter<svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-arrow\" /></svg></button>\n </form>\n <form data-step=\"mfa\" hidden>\n <div class=\"nf-field\">\n <label id=\"nf-otp-label\">Code \u00E0 6 chiffres</label>\n <div class=\"nf-otp\" role=\"group\" aria-labelledby=\"nf-otp-label\">\n <input inputmode=\"numeric\" maxlength=\"1\" autocomplete=\"one-time-code\" aria-label=\"Chiffre 1\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 2\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 3\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 4\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 5\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 6\" />\n </div>\n </div>\n <p class=\"nf-hint\">Le code affich\u00E9 par votre application d'authentification.</p>\n <button class=\"nf-btn primary\" type=\"submit\">V\u00E9rifier</button>\n </form>\n<% } %>\n<% if (it.password || it.passkey) { %>\n <div data-step=\"authenticated\" hidden>\n <div class=\"nf-done\" role=\"status\">\n <span class=\"ring\" aria-hidden=\"true\"><svg class=\"nf-icon\"><use href=\"#i-check\" /></svg></span>\n <strong>Session ouverte</strong><span class=\"dest\"></span>\n <span class=\"nf-progress\" aria-hidden=\"true\"><span></span></span>\n </div>\n </div>\n<% } %>\n<% if (it.providers.length > 0 || it.passkey) { %>\n <div data-alt>\n<% if (it.password) { %>\n <div class=\"nf-or\">ou</div>\n<% } %>\n <div class=\"nf-alt\">\n<% if (it.passkey) { %>\n <button type=\"button\" class=\"nf-btn span\" data-passkey hidden><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-finger\" /></svg>Passkey / empreinte digitale</button>\n<% } %>\n<% it.providers.forEach(function (provider) { %>\n <a class=\"nf-btn\" href=\"<%= provider.href %>\"><% if (provider.image) { %><img class=\"nf-icon\" src=\"<%= provider.image %>\" alt=\"\" /><% } else { %><svg class=\"<%= provider.icon === 'm-github' ? 'nf-mark' : 'nf-icon' %>\" aria-hidden=\"true\"><use href=\"#<%= provider.icon %>\" /></svg><% } %><% if (provider.caption) { %><span class=\"nf-label\"><%= provider.label %><small><%= provider.caption %></small></span><% } else { %><%= provider.label %><% } %></a>\n<% }) %>\n </div>\n </div>\n<% } %>\n <div class=\"nf-message\" aria-live=\"polite\" data-message><% if (it.empty) { %><p class=\"nf-alert\" role=\"alert\"><span class=\"code\">\u2014</span><span>Aucun moyen de connexion n'est disponible pour le moment. Pr\u00E9venez l'administrateur du site.</span></p><% } else if (it.notice) { %><p class=\"nf-alert\" role=\"alert\"><span class=\"code\">SSO</span><span><%= it.notice %></span></p><% } %></div>\n<% if (it.password || it.passkey) { %>\n <noscript><p class=\"nf-hint\">Cette page a besoin de JavaScript pour vous connecter.</p></noscript>\n<% } %>\n </div>\n </section>\n<% if (it.picker) { %>\n <form class=\"nf-picker\" method=\"get\" data-preview-picker<% if (it.picker.hook) { %> data-preview-hook=\"<%= it.picker.hook %>\"<% } %> aria-label=\"Aper\u00E7u de la page (d\u00E9veloppement)\">\n <input type=\"hidden\" name=\"from\" value=\"<%= it.from %>\" />\n <label>Habillage <select name=\"skin\"><% it.picker.skins.forEach(function (skin) { %><option value=\"<%= skin %>\"<% if (skin === it.skin) { %> selected<% } %>><%= skin %></option><% }) %></select></label>\n <label>Mise en page <select name=\"layout\"><% it.picker.layouts.forEach(function (layout) { %><option value=\"<%= layout %>\"<% if (layout === it.layout) { %> selected<% } %>><%= layout %></option><% }) %></select></label>\n <noscript><button type=\"submit\">Appliquer</button></noscript>\n </form>\n<% } %>\n<% if (it.footer) { %>\n <div class=\"nf-foot\">\n <ul class=\"nf-facts\" aria-label=\"Protection de la session\">\n <li><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-shield\" /></svg>cookie HttpOnly</li>\n <li><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-shield\" /></svg>anti-CSRF</li>\n <li><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-shield\" /></svg>CSP stricte</li>\n </ul>\n <a class=\"nf-powered\" href=\"https://github.com/nodefony/nodefony-core\" rel=\"noopener noreferrer\"><img class=\"nf-logo nf-logo-mono\" src=\"<%= it.logoHref %>\" alt=\"\" />Propuls\u00E9 par Nodefony</a>\n </div>\n<% } %>\n </main>\n </div>\n </body>\n</html>\n";
|
|
144
|
+
export declare const LOGIN_PAGE_TEMPLATE = "<!doctype html>\n<html lang=\"fr\"<% if (it.theme) { %> data-theme=\"<%= it.theme %>\"<% } %>>\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"color-scheme\" content=\"dark light\" />\n <meta name=\"robots\" content=\"noindex\" />\n <title><%= it.title %> \u2014 <%= it.brand %></title>\n <link rel=\"icon\" href=\"<%= it.logoHref %>\" type=\"image/svg+xml\" />\n<% if (!it.theme) { %>\n <script nonce=\"<%= it.nonce %>\">try{var t=localStorage.getItem(\"nf-login-theme\");if(t===\"light\"||t===\"dark\")document.documentElement.dataset.theme=t}catch(e){}</script>\n<% } %>\n <link rel=\"stylesheet\" href=\"<%= it.styleHref %>\" />\n<% if (it.skinHref) { %>\n <link rel=\"stylesheet\" href=\"<%= it.skinHref %>\" />\n<% } %>\n<% if (it.stylesheetHref) { %>\n <link rel=\"stylesheet\" href=\"<%= it.stylesheetHref %>\" />\n<% } %>\n <script type=\"module\" src=\"<%= it.scriptHref %>\" nonce=\"<%= it.nonce %>\"></script>\n </head>\n <body data-skin=\"<%= it.skin %>\" data-layout=\"<%= it.layout %>\"<% if (it.backdrop) { %> data-backdrop<% } %><% if (it.ssoFirst) { %> data-sso-first<% } %>>\n <svg width=\"0\" height=\"0\" style=\"position: absolute\" aria-hidden=\"true\">\n <symbol id=\"i-user\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"8\" r=\"3.5\" /><path d=\"M5.5 20a6.5 6.5 0 0 1 13 0\" /></symbol>\n <symbol id=\"i-lock\" viewBox=\"0 0 24 24\"><rect x=\"5\" y=\"10.5\" width=\"14\" height=\"10\" rx=\"2\" /><path d=\"M8.5 10.5V7.5a3.5 3.5 0 0 1 7 0v3\" /></symbol>\n <symbol id=\"i-eye\" viewBox=\"0 0 24 24\"><path d=\"M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z\" /><circle cx=\"12\" cy=\"12\" r=\"2.8\" /></symbol>\n <symbol id=\"i-arrow\" viewBox=\"0 0 24 24\"><path d=\"M5 12h14M13 6l6 6-6 6\" /></symbol>\n <symbol id=\"i-key\" viewBox=\"0 0 24 24\"><circle cx=\"8\" cy=\"15\" r=\"3.8\" /><path d=\"M10.8 12.2 19.5 3.5M16.5 6.5l2.5 2.5M14 9l2 2\" /></symbol>\n <symbol id=\"i-check\" viewBox=\"0 0 24 24\"><path d=\"M5 12.5l4.5 4.5L19 7.5\" /></symbol>\n <symbol id=\"i-finger\" viewBox=\"0 0 24 24\"><path d=\"M18.9 7a8 8 0 0 1 1.1 5v1a6 6 0 0 0 .8 3\" /><path d=\"M8 11a4 4 0 0 1 8 0v1a10 10 0 0 0 2 6\" /><path d=\"M12 11v2a14 14 0 0 0 2.5 8\" /><path d=\"M8 15a18 18 0 0 0 1.8 6\" /><path d=\"M4.9 19a22 22 0 0 1-.9-7v-1a8 8 0 0 1 12-6.95\" /></symbol>\n <symbol id=\"i-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4\" /><path d=\"M12 2.5v2M12 19.5v2M4.6 4.6l1.4 1.4M18 18l1.4 1.4M2.5 12h2M19.5 12h2M4.6 19.4 6 18M18 6l1.4-1.4\" /></symbol>\n <symbol id=\"i-moon\" viewBox=\"0 0 24 24\"><path d=\"M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5Z\" /></symbol>\n <symbol id=\"i-shield\" viewBox=\"0 0 24 24\"><path d=\"M12 3l7.5 3v5.5c0 4.6-3.2 8-7.5 9.5-4.3-1.5-7.5-4.9-7.5-9.5V6z\" /></symbol>\n <symbol id=\"m-github\" viewBox=\"0 0 16 16\"><path d=\"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z\" /></symbol>\n </svg>\n <header class=\"nf-strip\">\n <div class=\"nf-strip-brand\"><img class=\"nf-logo\" src=\"<%= it.logo %>\" alt=\"\" /><%= it.brand %></div>\n </header>\n <div class=\"nf-layout\">\n <aside class=\"nf-hero\" aria-hidden=\"true\">\n <div class=\"nf-hero-brand\"><img class=\"nf-logo\" src=\"<%= it.logo %>\" alt=\"\" /><%= it.brand %></div>\n<% if (it.hero === null) { %>\n <div class=\"nf-home\">\n <img\n class=\"nf-home-logo\"\n src=\"<%= it.logoHref %>\"\n alt=\"\"\n />\n <h2>Le framework Node.js fullstack</h2>\n <p class=\"sub\">\n Temps r\u00E9el natif \u00B7 d\u00E9veloppement agentic-ready \u00B7 socle TypeScript\n isomorphe\n </p>\n <p class=\"sig\">\n Une action de contr\u00F4leur. Deux transports. La m\u00EAme session, la m\u00EAme\n s\u00E9curit\u00E9, le m\u00EAme code.\n </p>\n <ul class=\"badges\">\n <li class=\"agentic\">Agentic ready</li>\n <li>Node.js \u2265 24.14</li>\n <li>TypeScript strict</li>\n <li>ESM only</li>\n <li>Licence Apache 2.0</li>\n </ul>\n <div\n class=\"install\"\n role=\"img\"\n aria-label=\"Commande d'installation : npx nodefony create app\"\n >\n <span class=\"p\">$</span><span>npx nodefony create app</span>\n </div>\n <div class=\"features\">\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <polygon\n points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"\n /></svg\n ></span>\n <h3>Temps r\u00E9el natif</h3>\n <p>\n Le WebSocket n'est pas un ajout : m\u00EAme pipeline, m\u00EAme table de\n routes, m\u00EAme s\u00E9curit\u00E9 que le HTTP.\n </p>\n </div>\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\" />\n <path d=\"m9 12 2 2 4-4\" /></svg\n ></span>\n <h3>S\u00E9curit\u00E9 par conception</h3>\n <p>\n Pare-feu applicatif par zones, Zero Trust, CSRF Fetch Metadata,\n sessions hybrides, WebAuthn.\n </p>\n </div>\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\" />\n <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\" />\n <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\" />\n <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\" /></svg\n ></span>\n <h3>DI & modules</h3>\n <p>\n Injection de d\u00E9pendances par d\u00E9corateurs, modules autonomes,\n configuration valid\u00E9e au boot.\n </p>\n </div>\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <path\n d=\"M3 12h18M12 3c2.5 2.6 4 5.7 4 9s-1.5 6.4-4 9c-2.5-2.6-4-5.7-4-9s1.5-6.4 4-9z\"\n /></svg\n ></span>\n <h3>Socle isomorphe</h3>\n <p>\n Le m\u00EAme paquet c\u00F4t\u00E9 serveur et c\u00F4t\u00E9 navigateur : client temps\n r\u00E9el, r\u00E8gles et types \u00E9crits une fois.\n </p>\n </div>\n </div>\n </div>\n <small>Propuls\u00E9 par Nodefony</small>\n<% } else if (it.hero) { %>\n <div class=\"nf-home\">\n <h2><%= it.hero.heading %></h2>\n<% if (it.hero.text) { %>\n <p class=\"sub\"><%= it.hero.text %></p>\n<% } %>\n </div>\n<% } %>\n </aside>\n <main class=\"nf-stage\" data-nf-login data-from=\"<%= it.from %>\">\n <button type=\"button\" class=\"nf-theme\" data-theme-toggle aria-label=\"Basculer entre th\u00E8me clair et th\u00E8me sombre\"><svg class=\"nf-icon t-sun\" aria-hidden=\"true\"><use href=\"#i-sun\" /></svg><svg class=\"nf-icon t-moon\" aria-hidden=\"true\"><use href=\"#i-moon\" /></svg></button>\n <section class=\"nf-card\" aria-labelledby=\"nf-title\">\n <div class=\"nf-head\">\n <div class=\"nf-brand\"><img class=\"nf-logo\" src=\"<%= it.logo %>\" alt=\"\" /><%= it.brand %></div>\n <h1 id=\"nf-title\"><%= it.title %></h1>\n <p class=\"nf-sub\" id=\"nf-sub\"><%= it.lead %></p>\n </div>\n <div class=\"nf-body\">\n<% if (it.password) { %>\n <ol class=\"nf-steps\" aria-label=\"\u00C9tapes\">\n <li data-tab=\"identifier\" aria-current=\"step\">Identifiant</li>\n <li data-tab=\"password\">Mot de passe</li>\n <li data-tab=\"mfa\" hidden>V\u00E9rification</li>\n </ol>\n <form data-step=\"identifier\">\n <div class=\"nf-field\">\n <label for=\"nf-username\">Identifiant ou adresse e-mail</label>\n <div class=\"nf-input\">\n <svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-user\" /></svg>\n <input id=\"nf-username\" name=\"username\" autocomplete=\"username\" autocapitalize=\"none\" spellcheck=\"false\" required />\n </div>\n </div>\n <button class=\"nf-btn primary\" type=\"submit\">Continuer<svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-arrow\" /></svg></button>\n </form>\n <form data-step=\"password\" hidden>\n <!-- Compte de l'\u00E9tape pr\u00E9c\u00E9dente, masqu\u00E9 : le gestionnaire de mots de passe\n associe le mot de passe \u00E0 CE compte (HTML, jeton \u00AB username \u00BB). -->\n <input name=\"username\" autocomplete=\"username\" hidden readonly tabindex=\"-1\" />\n <div class=\"nf-account\">\n <span class=\"nf-avatar\" aria-hidden=\"true\"></span><span class=\"who\"></span>\n <button type=\"button\" class=\"nf-link\" data-go=\"identifier\">Changer</button>\n </div>\n <div class=\"nf-field\">\n <div class=\"nf-label-row\"><label for=\"nf-password\">Mot de passe</label></div>\n <div class=\"nf-input\">\n <svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-lock\" /></svg>\n <input id=\"nf-password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" required />\n <button type=\"button\" class=\"nf-reveal\" data-reveal aria-label=\"Afficher le mot de passe\" aria-pressed=\"false\"><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-eye\" /></svg></button>\n </div>\n </div>\n <button class=\"nf-btn primary\" type=\"submit\">Se connecter<svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-arrow\" /></svg></button>\n </form>\n <form data-step=\"mfa\" hidden>\n <div class=\"nf-field\">\n <label id=\"nf-otp-label\">Code \u00E0 6 chiffres</label>\n <div class=\"nf-otp\" role=\"group\" aria-labelledby=\"nf-otp-label\">\n <input inputmode=\"numeric\" maxlength=\"1\" autocomplete=\"one-time-code\" aria-label=\"Chiffre 1\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 2\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 3\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 4\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 5\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 6\" />\n </div>\n </div>\n <p class=\"nf-hint\">Le code affich\u00E9 par votre application d'authentification.</p>\n <button class=\"nf-btn primary\" type=\"submit\">V\u00E9rifier</button>\n </form>\n<% } %>\n<% if (it.password || it.passkey) { %>\n <div data-step=\"authenticated\" hidden>\n <div class=\"nf-done\" role=\"status\">\n <span class=\"ring\" aria-hidden=\"true\"><svg class=\"nf-icon\"><use href=\"#i-check\" /></svg></span>\n <strong>Session ouverte</strong><span class=\"dest\"></span>\n <span class=\"nf-progress\" aria-hidden=\"true\"><span></span></span>\n </div>\n </div>\n<% } %>\n<% if (it.providers.length > 0 || it.passkey) { %>\n <div data-alt>\n<% if (it.password) { %>\n <div class=\"nf-or\">ou</div>\n<% } %>\n <div class=\"nf-alt\">\n<% if (it.passkey) { %>\n <button type=\"button\" class=\"nf-btn span\" data-passkey hidden><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-finger\" /></svg>Passkey / empreinte digitale</button>\n<% } %>\n<% it.providers.forEach(function (provider) { %>\n <a class=\"nf-btn\" href=\"<%= provider.href %>\"><% if (provider.image) { %><img class=\"nf-icon\" src=\"<%= provider.image %>\" alt=\"\" /><% } else { %><svg class=\"<%= provider.icon === 'm-github' ? 'nf-mark' : 'nf-icon' %>\" aria-hidden=\"true\"><use href=\"#<%= provider.icon %>\" /></svg><% } %><% if (provider.caption) { %><span class=\"nf-label\"><%= provider.label %><small><%= provider.caption %></small></span><% } else { %><%= provider.label %><% } %></a>\n<% }) %>\n </div>\n </div>\n<% } %>\n <div class=\"nf-message\" aria-live=\"polite\" data-message><% if (it.empty) { %><p class=\"nf-alert\" role=\"alert\"><span class=\"code\">\u2014</span><span>Aucun moyen de connexion n'est disponible pour le moment. Pr\u00E9venez l'administrateur du site.</span></p><% } else if (it.notice) { %><p class=\"nf-alert\" role=\"alert\"><span class=\"code\">SSO</span><span><%= it.notice %></span></p><% } %></div>\n<% if (it.password || it.passkey) { %>\n <noscript><p class=\"nf-hint\">Cette page a besoin de JavaScript pour vous connecter.</p></noscript>\n<% } %>\n </div>\n </section>\n<% if (it.picker) { %>\n <form class=\"nf-picker\" method=\"get\" data-preview-picker<% if (it.picker.hook) { %> data-preview-hook=\"<%= it.picker.hook %>\"<% } %> aria-label=\"Aper\u00E7u de la page (d\u00E9veloppement)\">\n <input type=\"hidden\" name=\"from\" value=\"<%= it.from %>\" />\n <label>Habillage <select name=\"skin\"><% it.picker.skins.forEach(function (skin) { %><option value=\"<%= skin %>\"<% if (skin === it.skin) { %> selected<% } %>><%= skin %></option><% }) %></select></label>\n <label>Mise en page <select name=\"layout\"><% it.picker.layouts.forEach(function (layout) { %><option value=\"<%= layout %>\"<% if (layout === it.layout) { %> selected<% } %>><%= layout %></option><% }) %></select></label>\n <noscript><button type=\"submit\">Appliquer</button></noscript>\n </form>\n<% } %>\n<% if (it.footer) { %>\n <div class=\"nf-foot\">\n <ul class=\"nf-facts\" aria-label=\"Protection de la session\">\n <li><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-shield\" /></svg>cookie HttpOnly</li>\n <li><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-shield\" /></svg>anti-CSRF</li>\n <li><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-shield\" /></svg>CSP stricte</li>\n </ul>\n <a class=\"nf-powered\" href=\"https://github.com/nodefony/nodefony-core\" rel=\"noopener noreferrer\"><img class=\"nf-logo nf-logo-mono\" src=\"<%= it.logoHref %>\" alt=\"\" />Propuls\u00E9 par Nodefony</a>\n </div>\n<% } %>\n </main>\n </div>\n </body>\n</html>\n";
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
2
|
+
import type { IStandardSchemaV1, InferSchemaOutput, MaybePromise } from "nodefony";
|
|
3
|
+
/**
|
|
4
|
+
* Le schéma d'une route : un Standard Schema par partie de l'entrée (Zod,
|
|
5
|
+
* Valibot, ArkType…). Chaque partie est facultative ; une partie déclarée est
|
|
6
|
+
* validée AVANT l'action, une partie omise est servie brute.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* \@Post("/posts", { schema: { body: z.object({ title: z.string() }) } })
|
|
11
|
+
* create(input: RouteInput<{ body: typeof createPost }>) {
|
|
12
|
+
* return this.createResource(input.body);
|
|
13
|
+
* }
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export interface IRouteSchema {
|
|
17
|
+
/** Variables de route (`/posts/{id}`). */
|
|
18
|
+
readonly params?: IStandardSchemaV1 | undefined;
|
|
19
|
+
/** Query (`?page=2`) — celle du chemin INVOQUÉ sur le pont WebSocket. */
|
|
20
|
+
readonly query?: IStandardSchemaV1 | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Corps DÉCODÉ par le transport : JSON, formulaire, ou octets (`Uint8Array`
|
|
23
|
+
* — un `Buffer` HTTP en est un). Jamais re-sérialisé pour être validé.
|
|
24
|
+
*/
|
|
25
|
+
readonly body?: IStandardSchemaV1 | undefined;
|
|
26
|
+
/** En-têtes (clés en minuscules) ; sur WebSocket, ceux du handshake. */
|
|
27
|
+
readonly headers?: IStandardSchemaV1 | undefined;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Options de route propres aux DÉCORATEURS (`@route`, `@Get`, `@Post`…) : elles
|
|
31
|
+
* décrivent l'entrée de l'action et se rangent sur sa méthode. Une route créée
|
|
32
|
+
* par programme (`Router.createRoute`) ne les accepte pas — elle n'a pas de
|
|
33
|
+
* décorateur pour les ranger, et les ignorer en silence mentirait.
|
|
34
|
+
*/
|
|
35
|
+
export interface IRouteSchemaOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Schéma de l'entrée de l'action. Les parties déclarées sont validées AVANT
|
|
38
|
+
* l'action (422 sinon), et l'action reçoit un argument unique, `RouteInput`.
|
|
39
|
+
* Exclusif des décorateurs de paramètre (`@Body`…) sur la même action : le
|
|
40
|
+
* mélange est refusé au démarrage.
|
|
41
|
+
*/
|
|
42
|
+
schema?: IRouteSchema | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* L'action lit le corps en FLUX (`input.body` est le flux brut de la
|
|
45
|
+
* requête) : le pipeline ne le lit pas en mémoire — gros envoi sans pic de
|
|
46
|
+
* mémoire. Remplace `@Body({ stream: true })` pour une action à schéma : il
|
|
47
|
+
* EXIGE un `schema` (sans lui, le flux ne serait livré à personne) et exclut
|
|
48
|
+
* `schema.body`. Défaut `false`.
|
|
49
|
+
*/
|
|
50
|
+
bodyStream?: boolean | undefined;
|
|
51
|
+
}
|
|
52
|
+
/** Parties de l'entrée d'une route, dans l'ordre où elles sont validées. */
|
|
53
|
+
export type RoutePart = keyof IRouteSchema;
|
|
54
|
+
/** Variables de route brutes (une valeur par défaut n'est pas forcément une chaîne). */
|
|
55
|
+
export type RawRouteParams = Readonly<Record<string, unknown>>;
|
|
56
|
+
/** Query brute. */
|
|
57
|
+
export type RawRouteQuery = Readonly<Record<string, unknown>>;
|
|
58
|
+
/** En-têtes bruts, clés en minuscules. */
|
|
59
|
+
export type RawRouteHeaders = Readonly<Record<string, string | string[] | undefined>>;
|
|
60
|
+
/** Type d'une partie : la sortie de son schéma s'il est déclaré, la forme brute sinon. */
|
|
61
|
+
type RoutePartOf<S extends IRouteSchema, K extends RoutePart, Raw> = S[K] extends IStandardSchemaV1 ? InferSchemaOutput<S[K]> : Raw;
|
|
62
|
+
/**
|
|
63
|
+
* L'argument UNIQUE d'une action à schéma : les quatre apports de la
|
|
64
|
+
* requête, toujours présents — validés et typés par le schéma quand il les
|
|
65
|
+
* déclare, servis bruts et typés large sinon.
|
|
66
|
+
*
|
|
67
|
+
* @typeParam S - le schéma de la route.
|
|
68
|
+
* @typeParam Stream - `true` quand la route déclare `bodyStream: true` :
|
|
69
|
+
* `body` est alors le flux brut de la requête, jamais lu en mémoire.
|
|
70
|
+
*/
|
|
71
|
+
export type RouteInput<S extends IRouteSchema = IRouteSchema, Stream extends boolean = false> = {
|
|
72
|
+
params: RoutePartOf<S, "params", RawRouteParams>;
|
|
73
|
+
query: RoutePartOf<S, "query", RawRouteQuery>;
|
|
74
|
+
body: Stream extends true ? Readable | undefined : RoutePartOf<S, "body", unknown>;
|
|
75
|
+
headers: RoutePartOf<S, "headers", RawRouteHeaders>;
|
|
76
|
+
};
|
|
77
|
+
/** L'entrée brute d'une action, avant validation. */
|
|
78
|
+
export type RawRouteInput = {
|
|
79
|
+
params: unknown;
|
|
80
|
+
query: unknown;
|
|
81
|
+
body: unknown;
|
|
82
|
+
headers: unknown;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Valide l'entrée d'une action contre son schéma — synchrone tant que chaque
|
|
86
|
+
* validateur l'est, asynchrone seulement si l'un d'eux rend une promesse.
|
|
87
|
+
*
|
|
88
|
+
* Les quatre parties sont toutes validées avant de conclure : un échec rend
|
|
89
|
+
* en une fois TOUTES les anomalies, chacune rattachée à sa partie. La valeur
|
|
90
|
+
* d'une partie validée est remplacée par la SORTIE de son schéma (valeurs par
|
|
91
|
+
* défaut, coercitions) ; la valeur reçue n'est ni copiée ni sérialisée — des
|
|
92
|
+
* octets décodés par le transport arrivent tels quels au validateur.
|
|
93
|
+
*
|
|
94
|
+
* @param schema - le schéma de la route.
|
|
95
|
+
* @param raw - l'entrée brute, mutée en place (objet propre à la requête).
|
|
96
|
+
* @returns l'entrée validée, ou sa promesse.
|
|
97
|
+
* @throws SchemaValidationError (statut 422) quand une partie est invalide —
|
|
98
|
+
* de façon synchrone, ou par la promesse rendue.
|
|
99
|
+
*/
|
|
100
|
+
export declare function validateRouteInput(schema: IRouteSchema, raw: RawRouteInput): MaybePromise<RawRouteInput>;
|
|
101
|
+
/**
|
|
102
|
+
* Refuse, au démarrage, un schéma de route mal formé : partie inconnue (une
|
|
103
|
+
* faute de frappe — `bodies` — serait sinon ignorée sans un mot), ou valeur
|
|
104
|
+
* qui n'implémente pas Standard Schema (un appelant JavaScript peut passer
|
|
105
|
+
* n'importe quoi).
|
|
106
|
+
*
|
|
107
|
+
* @param schema - le schéma déclaré.
|
|
108
|
+
* @param routeName - la route, nommée dans l'erreur.
|
|
109
|
+
* @throws BootConfigurationError sur la première faute trouvée.
|
|
110
|
+
*/
|
|
111
|
+
export declare function assertRouteSchemaShape(schema: unknown, routeName: string): asserts schema is IRouteSchema;
|
|
112
|
+
export {};
|
package/docs/admin.md
CHANGED
|
@@ -61,9 +61,9 @@ flowchart TD
|
|
|
61
61
|
Trois idées à retenir :
|
|
62
62
|
|
|
63
63
|
1. **Le producteur ne connaît pas le transport** — un handler lit un `IAdminRequest`
|
|
64
|
-
(`IAdminApi.ts
|
|
64
|
+
(`IAdminApi.ts#IAdminRequest`) et rend du JSON. Il ne touche jamais au socket ni à la `Response`.
|
|
65
65
|
2. **Un seul controller pont** — toutes les routes admin pointent vers
|
|
66
|
-
`AdminApiController.dispatch()` (`AdminApiController.ts
|
|
66
|
+
`AdminApiController.dispatch()` (`AdminApiController.ts#AdminApiController.dispatch`). Pas de génération dynamique de
|
|
67
67
|
classes ; chaque route reste une vraie `Route` (404/405 du Router intacts).
|
|
68
68
|
3. **Le broker possède le Router, pas le kernel** — c'est pourquoi il vit dans `@nodefony/framework`,
|
|
69
69
|
niveau qui monte les routes, alors que le contrat producteur vit dans le core.
|
|
@@ -110,19 +110,19 @@ le broker la monte.
|
|
|
110
110
|
|
|
111
111
|
Nodefony sépare **deux rôles** par inversion de dépendance :
|
|
112
112
|
|
|
113
|
-
- **Producteur** (`IAdminApi`, dans le core `IAdminApi.ts
|
|
113
|
+
- **Producteur** (`IAdminApi`, dans le core `IAdminApi.ts#IAdminApi`) : un module dit _quoi_ il expose —
|
|
114
114
|
son `adminNamespace` et ses `adminEndpoints()` — **sans importer `@nodefony/framework`**. Le
|
|
115
115
|
contrat vit au plus bas niveau commun pour qu'un adapter ORM, un service IA ou le kernel lui-même
|
|
116
116
|
puissent l'implémenter.
|
|
117
|
-
- **Transporteur** (`AdminBroker`, dans le framework `AdminBroker.ts
|
|
117
|
+
- **Transporteur** (`AdminBroker`, dans le framework `AdminBroker.ts#AdminBroker`) : lui seul possède le
|
|
118
118
|
Router. Il collecte les producteurs et monte `/nodefony/<ns>/api/*`.
|
|
119
119
|
|
|
120
120
|
Pour s'enregistrer sans dépendre du framework, un producteur récupère le broker via sa **vue
|
|
121
|
-
minimale** `IAdminRegistry` (`IAdminApi.ts
|
|
121
|
+
minimale** `IAdminRegistry` (`IAdminApi.ts#IAdminRegistry`) — juste `register()` — depuis le container. Le
|
|
122
122
|
kernel n'étant **pas** un `Module`, c'est le framework qui construit et enregistre l'`IAdminApi` du
|
|
123
123
|
kernel à sa place (`createKernelAdminApi()`, cité plus bas).
|
|
124
124
|
|
|
125
|
-
Le compromis assumé : **un seul controller pont** (`AdminApiController.ts
|
|
125
|
+
Le compromis assumé : **un seul controller pont** (`AdminApiController.ts#AdminApiController`) sert les N endpoints.
|
|
126
126
|
On y gagne zéro génération de classe, un dispatch O(1), et une garde RBAC + idempotence appliquée au
|
|
127
127
|
même endroit pour tout le monde.
|
|
128
128
|
|
|
@@ -237,7 +237,7 @@ curl -s -b /tmp/jar http://localhost:5151/nodefony/framework/api/admin | head -c
|
|
|
237
237
|
```
|
|
238
238
|
|
|
239
239
|
> [!NOTE]
|
|
240
|
-
> Chaque réponse HTTP porte un en-tête `x-nodefony-instance` (`AdminApiController.ts
|
|
240
|
+
> Chaque réponse HTTP porte un en-tête `x-nodefony-instance` (`AdminApiController.ts#AdminApiController.dispatch`) : en
|
|
241
241
|
> multi-pod, il dit **quel process** a répondu (le data plane est per-instance).
|
|
242
242
|
|
|
243
243
|
## 🏗️ Architecture interne — register → mountAll → dispatch
|
|
@@ -262,63 +262,63 @@ sequenceDiagram
|
|
|
262
262
|
AC-->>RT: handler(IAdminRequest) → JSON / RpcError
|
|
263
263
|
```
|
|
264
264
|
|
|
265
|
-
| # | Étape | Où
|
|
266
|
-
| --- | ----------------------------------------- |
|
|
267
|
-
| 1 | Le producteur s'enregistre | `AdminBroker.register()` (`AdminBroker.ts
|
|
268
|
-
| 2 | Le framework monte tout | `AdminBroker.mountAll()` (`AdminBroker.ts
|
|
269
|
-
| 3 | Une route par endpoint (nom déterministe) | `Router.createRoute()` (`AdminBroker.ts
|
|
270
|
-
| 4 | Le controller pont estampillé une fois | `Router.setController()` idempotent (`AdminBroker.ts
|
|
271
|
-
| 5 | Dispatch : lookup de la route | `AdminBroker.resolve()` (`AdminApiController.ts
|
|
272
|
-
| 6 | Projection du contexte en requête admin | `buildRequest()` (`AdminApiController.ts
|
|
273
|
-
| 7 | Normalisation du retour | `normalizeAdminResult()` (`executeAdmin.ts
|
|
265
|
+
| # | Étape | Où |
|
|
266
|
+
| --- | ----------------------------------------- | ----------------------------------------------------------------------------- |
|
|
267
|
+
| 1 | Le producteur s'enregistre | `AdminBroker.register()` (`AdminBroker.ts#AdminBroker.register`) |
|
|
268
|
+
| 2 | Le framework monte tout | `AdminBroker.mountAll()` (`AdminBroker.ts#AdminBroker.mountAll`) |
|
|
269
|
+
| 3 | Une route par endpoint (nom déterministe) | `Router.createRoute()` (`AdminBroker.ts#AdminBroker.mountAll`) |
|
|
270
|
+
| 4 | Le controller pont estampillé une fois | `Router.setController()` idempotent (`AdminBroker.ts#AdminBroker.mountAll`) |
|
|
271
|
+
| 5 | Dispatch : lookup de la route | `AdminBroker.resolve()` (`AdminApiController.ts#AdminApiController.runAdmin`) |
|
|
272
|
+
| 6 | Projection du contexte en requête admin | `buildRequest()` (`AdminApiController.ts#AdminApiController.buildRequest`) |
|
|
273
|
+
| 7 | Normalisation du retour | `normalizeAdminResult()` (`executeAdmin.ts#normalizeAdminResult`) |
|
|
274
274
|
|
|
275
275
|
Points de conception saillants :
|
|
276
276
|
|
|
277
277
|
- **Le montage FIGE les routes.** Après `mountAll()`, tout `register()` lève
|
|
278
|
-
(`AdminBroker.ts
|
|
278
|
+
(`AdminBroker.ts#AdminBroker.register`) : on ne monte pas une route à chaud (Zero surprise en prod). Le broker garde
|
|
279
279
|
la trace via son drapeau `mounted`.
|
|
280
|
-
- **Le nom de route est déterministe** : `admin.<ns>.<method>.<path>` (`AdminBroker.ts
|
|
280
|
+
- **Le nom de route est déterministe** : `admin.<ns>.<method>.<path>` (`AdminBroker.ts#AdminBroker.mountAll`) — c'est
|
|
281
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
|
|
282
|
+
- **`Router.setController` n'est appelé qu'une fois** par process (`AdminBroker.ts#AdminBroker.mountAll`) : il pose
|
|
283
283
|
une propriété non réinscriptible sur le prototype ; une garde `hasOwnProperty` rend l'appel
|
|
284
284
|
idempotent (multi-broker en test, re-boot).
|
|
285
285
|
- **Le catalogue se construit à la volée** depuis `AdminBroker.list()` + `AdminBroker.routes()`
|
|
286
|
-
(`AdminBroker.ts
|
|
286
|
+
(`AdminBroker.ts#AdminBroker.routes`) — jamais un état retenu.
|
|
287
287
|
|
|
288
288
|
> [!IMPORTANT]
|
|
289
289
|
> Convention de route **figée** : le data plane est toujours en **≥ 3 segments**
|
|
290
|
-
> `/nodefony/<module>/api/*` (`IAdminBroker.ts
|
|
290
|
+
> `/nodefony/<module>/api/*` (`IAdminBroker.ts#IAdminBroker`). Jamais une route admin mono-segment
|
|
291
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
|
|
292
|
+
> relatif d'un endpoint a **≥ 1 segment** (`types/IAdminApi.ts#IAdminEndpoint.path`) : la racine `/nodefony/<ns>/api` est
|
|
293
293
|
> réservée.
|
|
294
294
|
|
|
295
295
|
## 🔐 RBAC — autorisation du data plane
|
|
296
296
|
|
|
297
297
|
Deux gardes se succèdent, dans cet ordre :
|
|
298
298
|
|
|
299
|
-
1. **Le firewall AUTHENTIFIE en amont.** La zone `nodefony-admin` (`config.ts
|
|
300
|
-
`^/nodefony/[^/]+/api(/|$)` (`config.ts
|
|
299
|
+
1. **Le firewall AUTHENTIFIE en amont.** La zone `nodefony-admin` (`config.ts`) couvre
|
|
300
|
+
`^/nodefony/[^/]+/api(/|$)` (`config.ts`) avec l'authenticator `session` (cookie BFF) : sans
|
|
301
301
|
session, c'est **401** avant même le controller.
|
|
302
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
|
|
303
|
+
l'appelant via la fonction pure `isAdminGranted()` (`adminRbac.ts#isAdminGranted`). Rôle absent → **403**.
|
|
304
304
|
Le refus est prononcé au CŒUR (`executeAdmin.ts`), pas dans le controller HTTP : c'est ce
|
|
305
305
|
qui fait que les deux chemins d'appel — la route et le pont MCP — refusent à l'identique.
|
|
306
306
|
|
|
307
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
|
|
308
|
+
(compte non doté) — est **rejeté** (`adminRbac.ts#isAdminGranted`). C'était l'ex-fail-open historique (un
|
|
309
309
|
`roles.length > 0 &&` héritait du « mode mock » d'avant l'auth) : l'absence de rôle ne vaut pas
|
|
310
310
|
laissez-passer.
|
|
311
311
|
|
|
312
312
|
- **Rôle par défaut** : sans `role` explicite, un endpoint exige `ROLE_NODEFONY_ADMIN`
|
|
313
|
-
(`AdminBroker.ts
|
|
314
|
-
- **Endpoint public** : `public: true` (`IAdminApi.ts
|
|
315
|
-
(`role === ""`, `adminRbac.ts
|
|
313
|
+
(`AdminBroker.ts#AdminBroker.mountAll` ; défaut du champ `IAdminEndpoint.role`, `IAdminApi.ts#IAdminEndpoint.role`).
|
|
314
|
+
- **Endpoint public** : `public: true` (`IAdminApi.ts#IAdminEndpoint.public`) → le RBAC du broker est court-circuité
|
|
315
|
+
(`role === ""`, `adminRbac.ts#isAdminGranted`). À réserver aux sondes cloud-native (liveness/readiness) et à
|
|
316
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
|
|
318
|
-
publique `nodefony-liveness` (`config.ts
|
|
319
|
-
- **Endpoint qui reçoit un mot de passe** : `credentials: true` (`IAdminApi.ts
|
|
317
|
+
`GET /nodefony/kernel/api/livez` (`KernelAdminApi.ts#auditLogLevelChange`), sorti de `nodefony-admin` par la zone
|
|
318
|
+
publique `nodefony-liveness` (`config.ts`).
|
|
319
|
+
- **Endpoint qui reçoit un mot de passe** : `credentials: true` (`IAdminApi.ts#IAdminEndpoint.credentials`). En
|
|
320
320
|
production, une requête arrivée en clair est refusée en **403 avant le rôle et le handler**
|
|
321
|
-
(`executeAdmin.ts
|
|
321
|
+
(`executeAdmin.ts#executeAdminEndpoint`) — la décision est celle de security (`credentialTransport`), le
|
|
322
322
|
contrôleur la fournit pour HTTP et pour le pont WS. La CLI n'a pas de transport : rien n'est
|
|
323
323
|
jugé. Exemples : `POST me/password`, `POST users/{id}/password`, `POST users` (module `user`).
|
|
324
324
|
|
|
@@ -329,19 +329,19 @@ laissez-passer.
|
|
|
329
329
|
|
|
330
330
|
## 🔌 HTTP et WebSocket — la même action (API souveraine)
|
|
331
331
|
|
|
332
|
-
Toute action admin déclare **aussi** le transport `WEBSOCKET` (`AdminBroker.ts
|
|
332
|
+
Toute action admin déclare **aussi** le transport `WEBSOCKET` (`AdminBroker.ts#AdminBroker.mountAll`) : la route est
|
|
333
333
|
montée avec `[method, "WEBSOCKET"]`. Elle devient donc invocable par le pont WS-RPC `api.request`
|
|
334
|
-
(`WebsocketContext.ts
|
|
334
|
+
(`WebsocketContext.ts#WebsocketContext.request`) — même action, même handler, même réponse. Seul l'emballage diffère :
|
|
335
335
|
|
|
336
|
-
- **HTTP** : `renderJson` + statut + en-tête `x-nodefony-instance` (`AdminApiController.ts
|
|
336
|
+
- **HTTP** : `renderJson` + statut + en-tête `x-nodefony-instance` (`AdminApiController.ts#AdminApiController.dispatch`).
|
|
337
337
|
- **WS-RPC** : la valeur **nue** (le pont l'enveloppe `{id, result}`) ; un statut ≥ 400 devient un
|
|
338
|
-
`RpcError` avec `data.status`/`data.body` (`AdminApiController.ts
|
|
338
|
+
`RpcError` avec `data.status`/`data.body` (`AdminApiController.ts#AdminApiController.dispatch`), symétrie d'un `fetch` qui
|
|
339
339
|
expose son statut.
|
|
340
340
|
|
|
341
341
|
Les **mutations** sont pontables par socket. La sécurité d'écriture repose alors sur l'**idempotence**
|
|
342
|
-
(`idempotencyGate()`, `AdminApiController.ts
|
|
342
|
+
(`idempotencyGate()`, `AdminApiController.ts#AdminApiController.idempotencyGate`) : la clé `Idempotency-Key` est **obligatoire en
|
|
343
343
|
WS** (une socket reconnecte et rejoue), **optionnelle en HTTP** (`required: false`,
|
|
344
|
-
`AdminApiController.ts
|
|
344
|
+
`AdminApiController.ts#AdminApiController.idempotencyGate`). Un `GET` n'est jamais idempotenté (`AdminApiController.ts#AdminApiController.idempotencyGate`) ; la
|
|
345
345
|
porte est évaluée **après** le RBAC (un 403 ne consomme aucune entrée). Le helper est le **même** que
|
|
346
346
|
le seam `@Idempotent` des controllers userland — voir [Idempotence](idempotence.md).
|
|
347
347
|
|
|
@@ -350,37 +350,37 @@ le seam `@Idempotent` des controllers userland — voir [Idempotence](idempotenc
|
|
|
350
350
|
Trois pas, du point de vue d'un module :
|
|
351
351
|
|
|
352
352
|
1. **Écrire un `IAdminApi`** : `adminNamespace` (url-safe, stable), `adminDescriptor()` (sidebar
|
|
353
|
-
Studio, `IAdminApi.ts
|
|
353
|
+
Studio, `IAdminApi.ts#IAdminApi.adminDescriptor`) et `adminEndpoints()` (`IAdminApi.ts#IAdminApi.adminEndpoints`). Un endpoint peut renvoyer
|
|
354
354
|
la donnée brute (assumée `{status:200, body}`) ou une `IAdminResponse` pour piloter statut/en-têtes
|
|
355
|
-
(`IAdminApi.ts
|
|
356
|
-
2. **S'enregistrer au `onKernelBoot`** via `IAdminRegistry.register()` (`IAdminApi.ts
|
|
355
|
+
(`IAdminApi.ts#IAdminResponse`).
|
|
356
|
+
2. **S'enregistrer au `onKernelBoot`** via `IAdminRegistry.register()` (`IAdminApi.ts#IAdminRegistry.register`), récupéré
|
|
357
357
|
par `container.get("adminBroker")`. Rendre l'appel **idempotent** (`registry.has(ns)` avant
|
|
358
358
|
`register`) — modèle de tous les producteurs.
|
|
359
359
|
3. **Laisser le framework monter** : à `onKernelReady`, `Framework.onKernelReady()` enregistre les
|
|
360
|
-
producteurs internes puis appelle `broker.mountAll()` (`index.ts
|
|
360
|
+
producteurs internes puis appelle `broker.mountAll()` (`index.ts#Framework.onKernelReady`).
|
|
361
361
|
|
|
362
362
|
**Handlers lazy** : résous tes services **dans** le handler (à la requête), jamais au montage — un
|
|
363
363
|
service désactivable renvoie alors `503` proprement au lieu de casser le boot.
|
|
364
364
|
|
|
365
365
|
### Les producteurs réels (à imiter, sans les redocumenter)
|
|
366
366
|
|
|
367
|
-
Le broker lui-même est déclaré comme service du framework (`index.ts
|
|
367
|
+
Le broker lui-même est déclaré comme service du framework (`index.ts`). Les producteurs internes
|
|
368
368
|
sont enregistrés au `onKernelReady` du framework :
|
|
369
369
|
|
|
370
|
-
| Namespace | Producteur
|
|
371
|
-
| ----------- |
|
|
372
|
-
| `kernel` | `createKernelAdminApi` (`KernelAdminApi.ts
|
|
373
|
-
| `framework` | `createFrameworkAdminApi` (`FrameworkAdminApi.ts
|
|
374
|
-
| `syslog` | `createSyslogAdminApi` (`SyslogAdminApi.ts
|
|
370
|
+
| Namespace | Producteur | Rôle |
|
|
371
|
+
| ----------- | -------------------------------------------------------------------------- | ------------------------------------------------- |
|
|
372
|
+
| `kernel` | `createKernelAdminApi` (`KernelAdminApi.ts#createKernelAdminApi`) | modules, process, uptime, `livez` |
|
|
373
|
+
| `framework` | `createFrameworkAdminApi` (`FrameworkAdminApi.ts#createFrameworkAdminApi`) | dump du Router + **catalogue** + Playground (dev) |
|
|
374
|
+
| `syslog` | `createSyslogAdminApi` (`SyslogAdminApi.ts#createSyslogAdminApi`) | viewer de logs (dev) |
|
|
375
375
|
|
|
376
376
|
Les modules externes s'enregistrent depuis leur propre `onKernelBoot` :
|
|
377
377
|
|
|
378
|
-
| Namespace | Module | Enregistrement
|
|
379
|
-
| ---------- | -------------------- |
|
|
380
|
-
| `http` | `@nodefony/http` | `createHttpAdminApi` (`http/index.ts
|
|
381
|
-
| `security` | `@nodefony/security` | `registerSecurityAdminApi` (`security/index.ts
|
|
382
|
-
| `user` | `@nodefony/user` | `adminNamespace` (`UserAdminApi.ts
|
|
383
|
-
| `orm` | `@nodefony/orm-core` | `adminNamespace` (`OrmAdminApi.ts
|
|
378
|
+
| Namespace | Module | Enregistrement |
|
|
379
|
+
| ---------- | -------------------- | ---------------------------------------------------------------------- |
|
|
380
|
+
| `http` | `@nodefony/http` | `createHttpAdminApi` (`http/index.ts#Http.onKernelRegister`) |
|
|
381
|
+
| `security` | `@nodefony/security` | `registerSecurityAdminApi` (`security/index.ts#Security.onKernelBoot`) |
|
|
382
|
+
| `user` | `@nodefony/user` | `adminNamespace` (`UserAdminApi.ts#createUserAdminApi`) |
|
|
383
|
+
| `orm` | `@nodefony/orm-core` | `adminNamespace` (`OrmAdminApi.ts#createOrmAdminApi`) |
|
|
384
384
|
|
|
385
385
|
Pour le détail de chacun, se reporter à la doc de son module — le broker reste agnostique de leur
|
|
386
386
|
contenu.
|
|
@@ -392,42 +392,42 @@ Depuis une app : `AdminBroker` (le service) et les types `IAdminApi`, `IAdminEnd
|
|
|
392
392
|
`@nodefony/framework` et `nodefony`. Les signatures exactes vivent dans `.ai/symbols.json` — jamais
|
|
393
393
|
recopiées ici (elles s'y périmeraient).
|
|
394
394
|
|
|
395
|
-
| Membre (`IAdminBroker`) | Rôle | Ancre
|
|
396
|
-
| ------------------------ | -------------------------------------------------------- |
|
|
397
|
-
| `register(api)` | Enregistre un producteur (throw si namespace pris/monté) | `AdminBroker.ts
|
|
398
|
-
| `unregister(ns)` | Retire un producteur (et ses routes si montées) | `AdminBroker.ts
|
|
399
|
-
| `has(ns)` / `getApi(ns)` | Interrogation du registre | `AdminBroker.ts
|
|
400
|
-
| `list()` | Producteurs enregistrés (immuable) | `AdminBroker.ts
|
|
401
|
-
| `resolvePath(ns, path)` | Chemin absolu d'un endpoint sans le monter | `AdminBroker.ts
|
|
402
|
-
| `mountAll()` | Monte toutes les routes (idempotent) | `AdminBroker.ts
|
|
403
|
-
| `resolve(routeName)` | Lookup O(1) d'une route montée (utilisé par le pont) | `AdminBroker.ts
|
|
404
|
-
| `routes()` | Introspection des routes montées (source du catalogue) | `AdminBroker.ts
|
|
395
|
+
| Membre (`IAdminBroker`) | Rôle | Ancre |
|
|
396
|
+
| ------------------------ | -------------------------------------------------------- | ---------------------------------------- |
|
|
397
|
+
| `register(api)` | Enregistre un producteur (throw si namespace pris/monté) | `AdminBroker.ts#AdminBroker.register` |
|
|
398
|
+
| `unregister(ns)` | Retire un producteur (et ses routes si montées) | `AdminBroker.ts#AdminBroker.unregister` |
|
|
399
|
+
| `has(ns)` / `getApi(ns)` | Interrogation du registre | `AdminBroker.ts#AdminBroker.getApi` |
|
|
400
|
+
| `list()` | Producteurs enregistrés (immuable) | `AdminBroker.ts#AdminBroker.list` |
|
|
401
|
+
| `resolvePath(ns, path)` | Chemin absolu d'un endpoint sans le monter | `AdminBroker.ts#AdminBroker.resolvePath` |
|
|
402
|
+
| `mountAll()` | Monte toutes les routes (idempotent) | `AdminBroker.ts#AdminBroker.mountAll` |
|
|
403
|
+
| `resolve(routeName)` | Lookup O(1) d'une route montée (utilisé par le pont) | `AdminBroker.ts#AdminBroker.resolve` |
|
|
404
|
+
| `routes()` | Introspection des routes montées (source du catalogue) | `AdminBroker.ts#AdminBroker.routes` |
|
|
405
405
|
|
|
406
406
|
## 📡 Observabilité — Studio
|
|
407
407
|
|
|
408
|
-
- **Catalogue / discovery** : `GET /nodefony/framework/api/admin` (`FrameworkAdminApi.ts
|
|
408
|
+
- **Catalogue / discovery** : `GET /nodefony/framework/api/admin` (`FrameworkAdminApi.ts#createFrameworkAdminApi`) —
|
|
409
409
|
producteurs + descriptors + endpoints. C'est ce que Studio lit pour générer sa navigation admin.
|
|
410
|
-
- **Routes** : `GET /nodefony/framework/api/routes` (`FrameworkAdminApi.ts
|
|
411
|
-
de `nodefony router:dump` ; une variante paginée serveur `routes/page` (`FrameworkAdminApi.ts
|
|
410
|
+
- **Routes** : `GET /nodefony/framework/api/routes` (`FrameworkAdminApi.ts#createFrameworkAdminApi`) — l'équivalent web
|
|
411
|
+
de `nodefony router:dump` ; une variante paginée serveur `routes/page` (`FrameworkAdminApi.ts#createFrameworkAdminApi`).
|
|
412
412
|
- **Playground** (`/nodefony/playground`, **dev only**) : `buildPlaygroundSnapshot()`
|
|
413
|
-
(`PlaygroundAdminApi.ts
|
|
413
|
+
(`PlaygroundAdminApi.ts#buildPlaygroundSnapshot`) sérialise controllers + actions + transports + params + gardes — la
|
|
414
414
|
page Studio bâtit ses formulaires depuis ces métadonnées, **sans code généré**. Le montage est
|
|
415
|
-
conditionné à l'environnement (`index.ts
|
|
416
|
-
(`PlaygroundAdminApi.ts
|
|
415
|
+
conditionné à l'environnement (`index.ts#Framework.onKernelBoot`). Le pont admin lui-même est **exclu** du snapshot
|
|
416
|
+
(`PlaygroundAdminApi.ts#buildPlaygroundSnapshot`) pour ne pas noyer les controllers applicatifs.
|
|
417
417
|
- Les écrans admin de Studio (Routes, Sessions, Firewall, Users…) sont des vues qui consomment ces
|
|
418
418
|
data planes — voir le [module Studio](../../studio/docs/index.md).
|
|
419
419
|
|
|
420
420
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
421
421
|
|
|
422
|
-
| Symptôme | Cause (dans le code)
|
|
423
|
-
| ------------------------------------------------ |
|
|
424
|
-
| `register()` throw « routes figées » | Appel **après** `mountAll()` (`AdminBroker.ts
|
|
425
|
-
| `register()` throw « namespace déjà enregistré » | Deux producteurs sur le même `adminNamespace` (`AdminBroker.ts
|
|
426
|
-
| 401 sur toute route `/nodefony/<ns>/api/*` | Zone `nodefony-admin` : pas de session BFF (`config.ts
|
|
427
|
-
| 403 alors qu'on est connecté | Rôle manquant, `isAdminGranted` fail-closed (`adminRbac.ts
|
|
428
|
-
| WS : mutation refusée `400` clé requise | Idempotence : clé obligatoire par socket (`AdminApiController.ts
|
|
429
|
-
| Route admin injoignable / collision Studio | Endpoint mono-segment `/nodefony/<module>` (`IAdminBroker.ts
|
|
430
|
-
| 500 « Admin endpoint not registered » | `adminRoute` absent du registre — incohérence interne (`AdminApiController.ts
|
|
422
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
423
|
+
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
|
424
|
+
| `register()` throw « routes figées » | Appel **après** `mountAll()` (`AdminBroker.ts#AdminBroker.mountAll`) | Enregistrer au `onKernelBoot`, pas plus tard |
|
|
425
|
+
| `register()` throw « namespace déjà enregistré » | Deux producteurs sur le même `adminNamespace` (`AdminBroker.ts#AdminBroker.register`) | Namespace unique ; garder `register()` idempotent (`has(ns)` avant) |
|
|
426
|
+
| 401 sur toute route `/nodefony/<ns>/api/*` | Zone `nodefony-admin` : pas de session BFF (`config.ts`) | S'authentifier (login BFF) ; pour une sonde publique → `public: true` + zone anonyme |
|
|
427
|
+
| 403 alors qu'on est connecté | Rôle manquant, `isAdminGranted` fail-closed (`adminRbac.ts#isAdminGranted`) | Doter le compte du rôle requis (défaut `ROLE_NODEFONY_ADMIN`) |
|
|
428
|
+
| WS : mutation refusée `400` clé requise | Idempotence : clé obligatoire par socket (`AdminApiController.ts#AdminApiController.idempotencyGate`) | Fournir `Idempotency-Key` sur la mutation WS |
|
|
429
|
+
| Route admin injoignable / collision Studio | Endpoint mono-segment `/nodefony/<module>` (`IAdminBroker.ts#IAdminBroker`) | Toujours `≥ 3` segments `/nodefony/<ns>/api/<path>` |
|
|
430
|
+
| 500 « Admin endpoint not registered » | `adminRoute` absent du registre — incohérence interne (`AdminApiController.ts#AdminApiController.runAdmin`) | Vérifier que le producteur a bien été enregistré avant `mountAll()` |
|
|
431
431
|
|
|
432
432
|
## 🧪 Tests & couverture
|
|
433
433
|
|