@nodefony/framework 10.0.0-beta.1 → 10.0.0-beta.3
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 +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +9 -5
- package/dist/nodefony/controller/AdminApiController.js +7 -4
- package/dist/nodefony/controller/ApiKeyController.js +5 -4
- package/dist/nodefony/controller/LoginPageController.js +124 -0
- package/dist/nodefony/controller/OAuth2Controller.js +131 -14
- package/dist/nodefony/controller/SessionAuthController.js +37 -9
- package/dist/nodefony/controller/TokenAuthController.js +13 -6
- package/dist/nodefony/controller/TotpController.js +7 -6
- package/dist/nodefony/controller/WebAuthnController.js +26 -9
- package/dist/nodefony/decorators/routerDecorators.js +114 -13
- package/dist/nodefony/service/AdminBroker.js +2 -2
- package/dist/nodefony/service/IdempotencyStore.js +2 -2
- package/dist/nodefony/service/router.js +54 -23
- package/dist/nodefony/src/Controller.js +49 -4
- package/dist/nodefony/src/FrameworkAdminApi.js +2 -1
- package/dist/nodefony/src/KernelAdminApi.js +76 -10
- package/dist/nodefony/src/Resolver.js +41 -4
- package/dist/nodefony/src/Route.js +76 -6
- package/dist/nodefony/src/SecurityApiController.js +24 -0
- package/dist/nodefony/src/docsReader.js +64 -41
- package/dist/nodefony/src/idempotency.js +13 -20
- package/dist/nodefony/src/loginPage.js +431 -0
- package/dist/nodefony/src/requireAuthentication.js +36 -0
- package/dist/types/index.d.ts +4 -3
- package/dist/types/nodefony/controller/ApiKeyController.d.ts +4 -3
- package/dist/types/nodefony/controller/LoginPageController.d.ts +32 -0
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +67 -8
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +39 -8
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +8 -6
- package/dist/types/nodefony/controller/TotpController.d.ts +4 -3
- package/dist/types/nodefony/controller/WebAuthnController.d.ts +12 -4
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +88 -3
- package/dist/types/nodefony/interfaces/IRoute.d.ts +1 -2
- package/dist/types/nodefony/service/router.d.ts +13 -0
- package/dist/types/nodefony/src/Controller.d.ts +40 -1
- package/dist/types/nodefony/src/Resolver.d.ts +20 -1
- package/dist/types/nodefony/src/Route.d.ts +33 -2
- package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -0
- package/dist/types/nodefony/src/docsReader.d.ts +26 -12
- package/dist/types/nodefony/src/idempotency.d.ts +12 -10
- package/dist/types/nodefony/src/loginPage.d.ts +144 -0
- package/dist/types/nodefony/src/requireAuthentication.d.ts +29 -0
- package/docs/admin.md +28 -23
- package/docs/controller.md +84 -82
- package/docs/decorateurs.md +77 -74
- package/docs/idempotence.md +31 -29
- package/docs/index.md +1 -1
- package/docs/routing.md +58 -57
- package/docs/templates.md +12 -12
- package/package.json +11 -12
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OutgoingHttpHeaders } from "node:http";
|
|
2
|
+
import Controller from "./Controller.js";
|
|
3
|
+
/**
|
|
4
|
+
* Base des contrôleurs d'authentification (`/nodefony/security/api/*`) : toute
|
|
5
|
+
* réponse JSON part en `Cache-Control: no-store`.
|
|
6
|
+
*
|
|
7
|
+
* Ces réponses portent une identité, un jeton, un secret affiché une seule
|
|
8
|
+
* fois (clé API, secret TOTP, codes de récupération) ou leur refus. Un cookie
|
|
9
|
+
* ou un en-tête dans la requête n'empêche pas un cache partagé de STOCKER la
|
|
10
|
+
* réponse (RFC 9111 §3) ; RFC 6749 §5.1 exige `no-store` sur un jeton. La règle
|
|
11
|
+
* vit ici, une fois : une action ajoutée à un contrôleur héritier ne peut pas
|
|
12
|
+
* l'oublier. Un en-tête passé par l'action garde la priorité.
|
|
13
|
+
*/
|
|
14
|
+
declare abstract class SecurityApiController extends Controller {
|
|
15
|
+
renderJson(obj: unknown, status?: string | number, headers?: OutgoingHttpHeaders): ReturnType<Controller["renderJson"]>;
|
|
16
|
+
}
|
|
17
|
+
export default SecurityApiController;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { IPackageManagerResolution } from "nodefony";
|
|
1
2
|
/**
|
|
2
3
|
* Frontmatter d'un fichier de doc module. Tous les champs sont optionnels —
|
|
3
4
|
* un `.md` sans frontmatter reste lisible (titre dérivé du premier `# H1`).
|
|
@@ -73,15 +74,18 @@ export declare function parseFrontmatter(raw: string): {
|
|
|
73
74
|
* @param modulePath - chemin disque du module (`Module.path`).
|
|
74
75
|
* @returns liste triée (vide si le dossier `docs/` est absent).
|
|
75
76
|
*/
|
|
76
|
-
export declare function listModuleDocs(modulePath: string, withGit?: boolean): Promise<DocSummary[]>;
|
|
77
|
+
export declare function listModuleDocs(modulePath: string, withGit?: boolean, frameworkRepository?: boolean): Promise<DocSummary[]>;
|
|
77
78
|
/**
|
|
78
|
-
* Comptage
|
|
79
|
-
*
|
|
79
|
+
* Comptage des docs SERVIES d'un module, sans `git` — pour les KPI/overview
|
|
80
|
+
* (`docsCount`) où seul le nombre importe. Le frontmatter est lu : le compte
|
|
81
|
+
* suit la même portée (`publish`) que la liste, sinon le KPI annonce des pages
|
|
82
|
+
* que l'onglet Docs refuse d'ouvrir.
|
|
80
83
|
*
|
|
81
84
|
* @param modulePath - chemin disque du module (`Module.path`).
|
|
82
|
-
* @
|
|
85
|
+
* @param frameworkRepository - vrai dans le dépôt du framework (pages `core` servies).
|
|
86
|
+
* @returns nombre de pages servies dans `<modulePath>/docs/` (0 si absent).
|
|
83
87
|
*/
|
|
84
|
-
export declare function countModuleDocs(modulePath: string): Promise<number>;
|
|
88
|
+
export declare function countModuleDocs(modulePath: string, frameworkRepository?: boolean): Promise<number>;
|
|
85
89
|
/**
|
|
86
90
|
* Lit une doc module par slug : frontmatter + corps markdown brut.
|
|
87
91
|
*
|
|
@@ -90,7 +94,7 @@ export declare function countModuleDocs(modulePath: string): Promise<number>;
|
|
|
90
94
|
*
|
|
91
95
|
* @returns `null` si slug invalide ou fichier absent.
|
|
92
96
|
*/
|
|
93
|
-
export declare function readModuleDoc(modulePath: string, slug: string): Promise<DocContent | null>;
|
|
97
|
+
export declare function readModuleDoc(modulePath: string, slug: string, frameworkRepository?: boolean): Promise<DocContent | null>;
|
|
94
98
|
/** Où un terme cherché apparaît, et la ligne qui le porte. */
|
|
95
99
|
export interface DocSearchMatch {
|
|
96
100
|
/** Ligne (1-indexée) dans le CORPS markdown, frontmatter retiré. */
|
|
@@ -166,6 +170,7 @@ export interface DocSearchResult {
|
|
|
166
170
|
export declare function searchModuleDocs(targets: readonly DocSearchTarget[], query: string, options?: {
|
|
167
171
|
limit?: number | undefined;
|
|
168
172
|
perDoc?: number;
|
|
173
|
+
frameworkRepository?: boolean;
|
|
169
174
|
}): Promise<DocSearchResult>;
|
|
170
175
|
/**
|
|
171
176
|
* Symboles TS exportés d'un module + descriptions TSDoc, lus depuis
|
|
@@ -297,21 +302,30 @@ export interface TestRunCommand {
|
|
|
297
302
|
* config du module (la liste `reporter` dupliquée par module divergeait).
|
|
298
303
|
* - sinon (cœur : monocart, pas de `vitest.config.ts`) → `npm run coverage`.
|
|
299
304
|
*
|
|
305
|
+
* Le binaire et le script passent par le gestionnaire de paquets du PROJET :
|
|
306
|
+
* `npx` n'existe pas chez bun, et sous pnpm il chercherait hors de l'arbre que
|
|
307
|
+
* le verrou décrit.
|
|
308
|
+
*
|
|
300
309
|
* @param modulePath - racine du module (où vit `vitest.config.ts`)
|
|
301
310
|
* @param file - chemin relatif d'UN fichier de test, déjà validé par l'appelant
|
|
311
|
+
* @param manager - gestionnaire du projet ; défaut : résolu depuis la racine
|
|
312
|
+
* de l'application qui contient le module
|
|
302
313
|
* @returns commande, arguments en tableau (spawn sans shell) et libellé du mode
|
|
303
314
|
*/
|
|
304
|
-
export declare function testRunCommand(modulePath: string, file?: string): TestRunCommand;
|
|
315
|
+
export declare function testRunCommand(modulePath: string, file?: string, manager?: IPackageManagerResolution["name"]): TestRunCommand;
|
|
305
316
|
/**
|
|
306
317
|
* Lance les tests d'un module et renvoie un résumé (pass/fail/durée + tail).
|
|
307
318
|
*
|
|
308
|
-
* - 1 fichier (module vitest) → `
|
|
309
|
-
* - sinon →
|
|
310
|
-
* pour vitest comme pour
|
|
319
|
+
* - 1 fichier (module vitest) → `vitest run <file>` (rapide, pass/fail).
|
|
320
|
+
* - sinon → le script `coverage` (suite complète + refresh coverage ; marche
|
|
321
|
+
* pour vitest comme pour le cœur).
|
|
322
|
+
* Les deux par le gestionnaire du projet (cf {@link testRunCommand}).
|
|
311
323
|
*
|
|
312
324
|
* ⚠️ EXÉCUTE un process — appelé UNIQUEMENT derrière le garde dev-only de
|
|
313
|
-
* l'endpoint (cf KernelAdminApi).
|
|
314
|
-
* d'injection
|
|
325
|
+
* l'endpoint (cf KernelAdminApi). `portableSpawn` : jamais de `shell`, args
|
|
326
|
+
* en tableau (pas d'injection), et le `.cmd` du gestionnaire se lance sous
|
|
327
|
+
* Windows — un `spawn("npx")` nu y rendait `ENOENT`. `file` validé en amont
|
|
328
|
+
* (suffixe .test.ts, pas de `..`).
|
|
315
329
|
*/
|
|
316
330
|
export declare function runModuleTests(modulePath: string, file?: string): Promise<TestRunResult>;
|
|
317
331
|
/**
|
|
@@ -42,18 +42,20 @@ export type IdempotencyVerdict = {
|
|
|
42
42
|
export declare function resolveIdempotencyKey(alsKey: unknown, header: unknown): string | undefined;
|
|
43
43
|
/**
|
|
44
44
|
* Identité stable pour **scoper** le cache d'idempotence (anti-IDOR : un
|
|
45
|
-
* utilisateur ne doit jamais rejouer la clé d'un autre)
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* n'utilise PAS le cache (jamais de partage cross-identité).
|
|
45
|
+
* utilisateur ne doit jamais rejouer la clé d'un autre) : l'`identifier` de
|
|
46
|
+
* l'utilisateur AUTHENTIFIÉ de la requête. `null` = pas d'identité fiable →
|
|
47
|
+
* l'appelant n'utilise PAS le cache (jamais de partage cross-identité).
|
|
49
48
|
*
|
|
50
|
-
*
|
|
51
|
-
* (
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
49
|
+
* La décision « authentifié ? » est celle du cœur
|
|
50
|
+
* (`RequestContext.getAuthenticatedUser`) : l'anonyme d'une zone `anonymous`
|
|
51
|
+
* rend `null`. Lire l'`identifier` de l'utilisateur BRUT faisait partager à
|
|
52
|
+
* tous les visiteurs anonymes la même identité (`anon.`), donc le même cache.
|
|
53
|
+
*
|
|
54
|
+
* Même valeur quel que soit le transport pour un même compte : le pare-feu HTTP
|
|
55
|
+
* et le pont realtime posent tous deux le jeton et l'utilisateur dans l'ALS —
|
|
56
|
+
* une mutation tentée en WS puis rejouée en HTTP se dédoublonne.
|
|
55
57
|
*/
|
|
56
|
-
export declare function resolveIdentity(
|
|
58
|
+
export declare function resolveIdentity(): string | null;
|
|
57
59
|
/**
|
|
58
60
|
* Empreinte SHA-256 du **payload** d'une requête (parties sérialisables : nom de
|
|
59
61
|
* route + params + corps). Comparée par le store à l'empreinte mémorisée pour une
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { type ILoginPageDescription, type ILoginPageHero, type LoginPageLayout, type LoginPageSkin } from "nodefony";
|
|
2
|
+
/** Fournisseur prêt à afficher : libellé, adresse d'autorisation, icône. */
|
|
3
|
+
export interface ILoginPageViewProvider {
|
|
4
|
+
/** Ce que l'utilisateur lit : la marque d'un compte grand public, sinon « Compte de l'organisation ». */
|
|
5
|
+
readonly label: string;
|
|
6
|
+
/** Petite ligne sous le libellé (`SSO · Keycloak`), ou `null` pour une marque grand public. */
|
|
7
|
+
readonly caption: string | null;
|
|
8
|
+
readonly href: string;
|
|
9
|
+
/** Symbole du sprite d'icônes (`i-key`, `m-github`), quand `image` est nulle. */
|
|
10
|
+
readonly icon: string;
|
|
11
|
+
/** Image réglée par l'application (`oauth2.providers.<nom>.icon`), ou `null`. */
|
|
12
|
+
readonly image: string | null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Données du gabarit de la page de connexion — tout ce qu'il affiche, déjà
|
|
16
|
+
* validé : aucune valeur de la requête n'y entre sans être filtrée.
|
|
17
|
+
*/
|
|
18
|
+
export interface ILoginPageView {
|
|
19
|
+
/** Nom affiché de l'application. */
|
|
20
|
+
readonly brand: string;
|
|
21
|
+
/** Titre de la carte et de l'onglet. */
|
|
22
|
+
readonly title: string;
|
|
23
|
+
/** Ligne sous le titre, ou `null` pour la phrase déduite des moyens proposés. */
|
|
24
|
+
readonly subtitle: string | null;
|
|
25
|
+
/** Panneau d'illustration : `null` = vitrine Nodefony, `false` = marque seule. */
|
|
26
|
+
readonly hero: ILoginPageHero | false | null;
|
|
27
|
+
/** Pied de page affiché ? */
|
|
28
|
+
readonly footer: boolean;
|
|
29
|
+
readonly logo: string;
|
|
30
|
+
readonly skin: LoginPageSkin;
|
|
31
|
+
readonly layout: LoginPageLayout;
|
|
32
|
+
/** Fond de page décoré par l'habillage (`<body data-backdrop>`). */
|
|
33
|
+
readonly backdrop: boolean;
|
|
34
|
+
/** Thème imposé par `?theme=`, `null` = celui du système. */
|
|
35
|
+
readonly theme: "light" | "dark" | null;
|
|
36
|
+
/** Fournisseurs et passkey avant le formulaire (au moins l'un des deux proposé). */
|
|
37
|
+
readonly ssoFirst: boolean;
|
|
38
|
+
readonly password: boolean;
|
|
39
|
+
/** Bouton passkey rendu (masqué tant que le script n'a pas constaté le navigateur). */
|
|
40
|
+
readonly passkey: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Aucun moyen de connexion : ni formulaire, ni passkey, ni fournisseur
|
|
43
|
+
* opérationnel. La page le dit au lieu de rendre une carte vide.
|
|
44
|
+
*/
|
|
45
|
+
readonly empty: boolean;
|
|
46
|
+
/** Ligne sous le titre : celle de la configuration, sinon déduite des moyens proposés. */
|
|
47
|
+
readonly lead: string;
|
|
48
|
+
readonly providers: readonly ILoginPageViewProvider[];
|
|
49
|
+
/** Destination après connexion, déjà passée par `safeRedirectPath`. */
|
|
50
|
+
readonly from: string;
|
|
51
|
+
/**
|
|
52
|
+
* Sélecteur d'aperçu (habillage, mise en page), ou `null` : rendu seulement
|
|
53
|
+
* en développement ET quand `loginPage.previewPicker` le demande.
|
|
54
|
+
*/
|
|
55
|
+
readonly picker: ILoginPagePicker | null;
|
|
56
|
+
/**
|
|
57
|
+
* Message d'échec d'une connexion par fournisseur (`?reason=` posé par le
|
|
58
|
+
* callback OAuth), ou `null`. Texte FIXE : la raison reçue n'est jamais
|
|
59
|
+
* affichée, et une annulation (`access_denied`) n'en produit aucun.
|
|
60
|
+
*/
|
|
61
|
+
readonly notice: string | null;
|
|
62
|
+
readonly styleHref: string;
|
|
63
|
+
/** Feuille de l'habillage, chargée après {@link styleHref}, ou `null`. */
|
|
64
|
+
readonly skinHref: string | null;
|
|
65
|
+
/** Feuille de l'application, chargée après {@link styleHref}, ou `null`. */
|
|
66
|
+
readonly stylesheetHref: string | null;
|
|
67
|
+
readonly scriptHref: string;
|
|
68
|
+
readonly logoHref: string;
|
|
69
|
+
/** Nonce CSP de la requête, posé sur le `<script>`. */
|
|
70
|
+
readonly nonce: string;
|
|
71
|
+
}
|
|
72
|
+
/** Le sélecteur d'aperçu : les choix possibles et ceux affichés. */
|
|
73
|
+
export interface ILoginPagePicker {
|
|
74
|
+
readonly skins: readonly LoginPageSkin[];
|
|
75
|
+
readonly layouts: readonly LoginPageLayout[];
|
|
76
|
+
/** Chemin prévenu avant chaque rechargement (`loginPage.previewHook`), ou `null`. */
|
|
77
|
+
readonly hook: string | null;
|
|
78
|
+
}
|
|
79
|
+
/** Ce que la requête apporte à la page. */
|
|
80
|
+
export interface ILoginPageRequest {
|
|
81
|
+
/** Valeur brute de `?from=`. */
|
|
82
|
+
readonly from: unknown;
|
|
83
|
+
/** Valeur brute de `?theme=`. */
|
|
84
|
+
readonly theme: unknown;
|
|
85
|
+
/** Valeur brute de `?reason=`, posée par le callback OAuth sur un échec. */
|
|
86
|
+
readonly reason?: unknown;
|
|
87
|
+
/** Valeur brute de `?skin=`, lue seulement quand {@link preview} est vrai. */
|
|
88
|
+
readonly skin?: unknown;
|
|
89
|
+
/** Valeur brute de `?layout=`, lue seulement quand {@link preview} est vrai. */
|
|
90
|
+
readonly layout?: unknown;
|
|
91
|
+
/**
|
|
92
|
+
* Aperçu autorisé (développement) : `?skin=` et `?layout=` remplacent la
|
|
93
|
+
* configuration le temps de la requête. Faux en production, où ils sont ignorés.
|
|
94
|
+
*/
|
|
95
|
+
readonly preview?: boolean;
|
|
96
|
+
readonly nonce: string;
|
|
97
|
+
/** Nom de l'application, quand la configuration ne donne pas de titre. */
|
|
98
|
+
readonly projectName: string;
|
|
99
|
+
/** Empreinte des fichiers servis, ajoutée à leur adresse (cache). */
|
|
100
|
+
readonly assetsVersion: string;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Mémoïse une ressource chargée de façon asynchrone : le SUCCÈS est gardé, un
|
|
104
|
+
* ÉCHEC est oublié. Garder la promesse rejetée figerait la panne — la page
|
|
105
|
+
* répondrait 500 jusqu'au redémarrage, même une fois le fichier corrigé.
|
|
106
|
+
*
|
|
107
|
+
* @param load - charge la ressource
|
|
108
|
+
* @returns la fonction qui la rend, chargée une seule fois tant qu'elle réussit
|
|
109
|
+
*/
|
|
110
|
+
export declare function keepSuccess<T>(load: () => Promise<T>): () => Promise<T>;
|
|
111
|
+
/** Feuille, script et logo de la page, servis sous {@link LOGIN_PAGE_ASSETS_BASE}. */
|
|
112
|
+
export declare const LOGIN_PAGE_ASSET_FILES: {
|
|
113
|
+
readonly style: "login.css";
|
|
114
|
+
readonly script: "login.js";
|
|
115
|
+
readonly logo: "nodefony-logo.svg";
|
|
116
|
+
};
|
|
117
|
+
/** Libellé d'un fournisseur d'organisation : ce qu'il ouvre, pas son logiciel. */
|
|
118
|
+
export declare const ORGANIZATION_ACCOUNT_LABEL = "Compte de l'organisation";
|
|
119
|
+
/** Annonce d'une page sans aucun moyen de connexion. */
|
|
120
|
+
export declare const LOGIN_PAGE_EMPTY_NOTICE = "Aucun moyen de connexion n'est disponible pour le moment. Pr\u00E9venez l'administrateur du site.";
|
|
121
|
+
/**
|
|
122
|
+
* Prépare les données du gabarit à partir de la description de la page
|
|
123
|
+
* (security) et de la requête.
|
|
124
|
+
*
|
|
125
|
+
* Fonction pure : `from` repasse par `safeRedirectPath` (un chemin hors de
|
|
126
|
+
* l'origine retombe sur `/`), `theme` n'accepte que `light` ou `dark`,
|
|
127
|
+
* `skin` et `layout` ne sont lus qu'en aperçu et contre leur liste fermée.
|
|
128
|
+
*
|
|
129
|
+
* @param page - description rendue par `authFlow.describeLoginPage()`
|
|
130
|
+
* @param request - valeurs de la requête, non filtrées
|
|
131
|
+
* @returns les données du gabarit
|
|
132
|
+
*/
|
|
133
|
+
export declare function buildLoginPageView(page: ILoginPageDescription, request: ILoginPageRequest): ILoginPageView;
|
|
134
|
+
/**
|
|
135
|
+
* Gabarit Eta de la page de connexion par défaut (balisage F03, feuille
|
|
136
|
+
* `nodefony/login.css`, script `nodefony/login.js`).
|
|
137
|
+
*
|
|
138
|
+
* En chaîne dans le code : un `.eta` posé à côté ne serait pas publié
|
|
139
|
+
* (`files` du paquet = `dist`, `docs`). Compilé une fois par processus.
|
|
140
|
+
* Tout passe par `<%= %>` (échappé) ; aucune sortie brute. L'icône
|
|
141
|
+
* d'empreinte (`i-finger`) reprend `IconFingerprint` de Tabler Icons (MIT),
|
|
142
|
+
* celle du bouton de Studio : les deux écrans montrent le même geste.
|
|
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";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { IAuthenticationRequirement } from "../decorators/routerDecorators.js";
|
|
2
|
+
/** Vue minimale d'une session : ses métadonnées (contrat de `readSessionAuthentication`). */
|
|
3
|
+
interface IMetaBagReader {
|
|
4
|
+
getMetaBag(key: string): unknown;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Ce qui manque à une session pour satisfaire un `@RequireAuthentication` —
|
|
8
|
+
* fonction PURE : la session, l'exigence et l'heure lui sont données.
|
|
9
|
+
*
|
|
10
|
+
* Lit `authAt`/`amr` par `readSessionAuthentication` (contrat du cœur), jamais
|
|
11
|
+
* le sac de métadonnées à la main. Une session absente, ou qui ne porte pas ces
|
|
12
|
+
* métadonnées, est REFUSÉE : ne pas savoir n'est pas une preuve.
|
|
13
|
+
*
|
|
14
|
+
* @param session - la session de la requête, ou `null`.
|
|
15
|
+
* @param requirement - l'exigence figée de la route.
|
|
16
|
+
* @param now - l'heure de la décision, en millisecondes epoch.
|
|
17
|
+
* @returns la cause du refus en clair, ou `null` quand la session suffit.
|
|
18
|
+
*/
|
|
19
|
+
export declare function authenticationShortfall(session: IMetaBagReader | null, requirement: IAuthenticationRequirement, now: number): string | null;
|
|
20
|
+
/**
|
|
21
|
+
* Le défi d'un refus « step-up » (RFC 9470 §3) : le schéma de session du
|
|
22
|
+
* pare-feu, l'erreur normée et l'âge maximal quand la route en fixe un.
|
|
23
|
+
* `error_description` reste en ASCII, sans guillemet (RFC 6750 §3).
|
|
24
|
+
*
|
|
25
|
+
* @param requirement - l'exigence de la route.
|
|
26
|
+
* @returns la valeur de `WWW-Authenticate`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function stepUpChallenge(requirement: IAuthenticationRequirement): string;
|
|
29
|
+
export {};
|
package/docs/admin.md
CHANGED
|
@@ -63,7 +63,7 @@ Trois idées à retenir :
|
|
|
63
63
|
1. **Le producteur ne connaît pas le transport** — un handler lit un `IAdminRequest`
|
|
64
64
|
(`IAdminApi.ts:33`) 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:65`). 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.
|
|
@@ -122,7 +122,7 @@ minimale** `IAdminRegistry` (`IAdminApi.ts:243`) — juste `register()` — depu
|
|
|
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:36`) 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:84`) : 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
|
|
@@ -268,8 +268,8 @@ sequenceDiagram
|
|
|
268
268
|
| 2 | Le framework monte tout | `AdminBroker.mountAll()` (`AdminBroker.ts:112`) |
|
|
269
269
|
| 3 | Une route par endpoint (nom déterministe) | `Router.createRoute()` (`AdminBroker.ts:124`) |
|
|
270
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:
|
|
272
|
-
| 6 | Projection du contexte en requête admin | `buildRequest()` (`AdminApiController.ts:
|
|
271
|
+
| 5 | Dispatch : lookup de la route | `AdminBroker.resolve()` (`AdminApiController.ts:102`) |
|
|
272
|
+
| 6 | Projection du contexte en requête admin | `buildRequest()` (`AdminApiController.ts:199`) |
|
|
273
273
|
| 7 | Normalisation du retour | `normalizeAdminResult()` (`executeAdmin.ts:90`) |
|
|
274
274
|
|
|
275
275
|
Points de conception saillants :
|
|
@@ -314,8 +314,13 @@ laissez-passer.
|
|
|
314
314
|
- **Endpoint public** : `public: true` (`IAdminApi.ts:175`) → le RBAC du broker est court-circuité
|
|
315
315
|
(`role === ""`, `adminRbac.ts:26`). À 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:
|
|
317
|
+
`GET /nodefony/kernel/api/livez` (`KernelAdminApi.ts:513`), sorti de `nodefony-admin` par la zone
|
|
318
318
|
publique `nodefony-liveness` (`config.ts:132`).
|
|
319
|
+
- **Endpoint qui reçoit un mot de passe** : `credentials: true` (`IAdminApi.ts:189`). En
|
|
320
|
+
production, une requête arrivée en clair est refusée en **403 avant le rôle et le handler**
|
|
321
|
+
(`executeAdmin.ts:144`) — la décision est celle de security (`credentialTransport`), le
|
|
322
|
+
contrôleur la fournit pour HTTP et pour le pont WS. La CLI n'a pas de transport : rien n'est
|
|
323
|
+
jugé. Exemples : `POST me/password`, `POST users/{id}/password`, `POST users` (module `user`).
|
|
319
324
|
|
|
320
325
|
> [!TIP]
|
|
321
326
|
> `ROLE_NODEFONY_*` = rôles de la **plateforme** (administrer le framework), distincts des rôles
|
|
@@ -328,15 +333,15 @@ Toute action admin déclare **aussi** le transport `WEBSOCKET` (`AdminBroker.ts:
|
|
|
328
333
|
montée avec `[method, "WEBSOCKET"]`. Elle devient donc invocable par le pont WS-RPC `api.request`
|
|
329
334
|
(`WebsocketContext.ts:354`) — même action, même handler, même réponse. Seul l'emballage diffère :
|
|
330
335
|
|
|
331
|
-
- **HTTP** : `renderJson` + statut + en-tête `x-nodefony-instance` (`AdminApiController.ts:
|
|
336
|
+
- **HTTP** : `renderJson` + statut + en-tête `x-nodefony-instance` (`AdminApiController.ts:82`).
|
|
332
337
|
- **WS-RPC** : la valeur **nue** (le pont l'enveloppe `{id, result}`) ; un statut ≥ 400 devient un
|
|
333
338
|
`RpcError` avec `data.status`/`data.body` (`AdminApiController.ts:66`), symétrie d'un `fetch` qui
|
|
334
339
|
expose son statut.
|
|
335
340
|
|
|
336
341
|
Les **mutations** sont pontables par socket. La sécurité d'écriture repose alors sur l'**idempotence**
|
|
337
|
-
(`idempotencyGate()`, `AdminApiController.ts:
|
|
342
|
+
(`idempotencyGate()`, `AdminApiController.ts:155`) : la clé `Idempotency-Key` est **obligatoire en
|
|
338
343
|
WS** (une socket reconnecte et rejoue), **optionnelle en HTTP** (`required: false`,
|
|
339
|
-
`AdminApiController.ts:
|
|
344
|
+
`AdminApiController.ts:157`). Un `GET` n'est jamais idempotenté (`AdminApiController.ts:143`) ; la
|
|
340
345
|
porte est évaluée **après** le RBAC (un 403 ne consomme aucune entrée). Le helper est le **même** que
|
|
341
346
|
le seam `@Idempotent` des controllers userland — voir [Idempotence](idempotence.md).
|
|
342
347
|
|
|
@@ -345,10 +350,10 @@ le seam `@Idempotent` des controllers userland — voir [Idempotence](idempotenc
|
|
|
345
350
|
Trois pas, du point de vue d'un module :
|
|
346
351
|
|
|
347
352
|
1. **Écrire un `IAdminApi`** : `adminNamespace` (url-safe, stable), `adminDescriptor()` (sidebar
|
|
348
|
-
Studio, `IAdminApi.ts:
|
|
353
|
+
Studio, `IAdminApi.ts:227`) et `adminEndpoints()` (`IAdminApi.ts:229`). Un endpoint peut renvoyer
|
|
349
354
|
la donnée brute (assumée `{status:200, body}`) ou une `IAdminResponse` pour piloter statut/en-têtes
|
|
350
355
|
(`IAdminApi.ts:67`).
|
|
351
|
-
2. **S'enregistrer au `onKernelBoot`** via `IAdminRegistry.register()` (`IAdminApi.ts:
|
|
356
|
+
2. **S'enregistrer au `onKernelBoot`** via `IAdminRegistry.register()` (`IAdminApi.ts:256`), récupéré
|
|
352
357
|
par `container.get("adminBroker")`. Rendre l'appel **idempotent** (`registry.has(ns)` avant
|
|
353
358
|
`register`) — modèle de tous les producteurs.
|
|
354
359
|
3. **Laisser le framework monter** : à `onKernelReady`, `Framework.onKernelReady()` enregistre les
|
|
@@ -364,7 +369,7 @@ sont enregistrés au `onKernelReady` du framework :
|
|
|
364
369
|
|
|
365
370
|
| Namespace | Producteur | Rôle |
|
|
366
371
|
| ----------- | ----------------------------------------------------- | ------------------------------------------------- |
|
|
367
|
-
| `kernel` | `createKernelAdminApi` (`KernelAdminApi.ts:
|
|
372
|
+
| `kernel` | `createKernelAdminApi` (`KernelAdminApi.ts:646`) | modules, process, uptime, `livez` |
|
|
368
373
|
| `framework` | `createFrameworkAdminApi` (`FrameworkAdminApi.ts:86`) | dump du Router + **catalogue** + Playground (dev) |
|
|
369
374
|
| `syslog` | `createSyslogAdminApi` (`SyslogAdminApi.ts:95`) | viewer de logs (dev) |
|
|
370
375
|
|
|
@@ -373,8 +378,8 @@ Les modules externes s'enregistrent depuis leur propre `onKernelBoot` :
|
|
|
373
378
|
| Namespace | Module | Enregistrement |
|
|
374
379
|
| ---------- | -------------------- | ---------------------------------------------------- |
|
|
375
380
|
| `http` | `@nodefony/http` | `createHttpAdminApi` (`http/index.ts:121`) |
|
|
376
|
-
| `security` | `@nodefony/security` | `registerSecurityAdminApi` (`security/index.ts:
|
|
377
|
-
| `user` | `@nodefony/user` | `adminNamespace` (`UserAdminApi.ts:
|
|
381
|
+
| `security` | `@nodefony/security` | `registerSecurityAdminApi` (`security/index.ts:114`) |
|
|
382
|
+
| `user` | `@nodefony/user` | `adminNamespace` (`UserAdminApi.ts:900`) |
|
|
378
383
|
| `orm` | `@nodefony/orm-core` | `adminNamespace` (`OrmAdminApi.ts:709`) |
|
|
379
384
|
|
|
380
385
|
Pour le détail de chacun, se reporter à la doc de son module — le broker reste agnostique de leur
|
|
@@ -414,15 +419,15 @@ recopiées ici (elles s'y périmeraient).
|
|
|
414
419
|
|
|
415
420
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
416
421
|
|
|
417
|
-
| Symptôme | Cause (dans le code)
|
|
418
|
-
| ------------------------------------------------ |
|
|
419
|
-
| `register()` throw « routes figées » | Appel **après** `mountAll()` (`AdminBroker.ts:112`)
|
|
420
|
-
| `register()` throw « namespace déjà enregistré » | Deux producteurs sur le même `adminNamespace` (`AdminBroker.ts:51`)
|
|
421
|
-
| 401 sur toute route `/nodefony/<ns>/api/*` | Zone `nodefony-admin` : pas de session BFF (`config.ts:141`)
|
|
422
|
-
| 403 alors qu'on est connecté | Rôle manquant, `isAdminGranted` fail-closed (`adminRbac.ts:24`)
|
|
423
|
-
| WS : mutation refusée `400` clé requise | Idempotence : clé obligatoire par socket (`AdminApiController.ts:
|
|
424
|
-
| Route admin injoignable / collision Studio | Endpoint mono-segment `/nodefony/<module>` (`IAdminBroker.ts:42`)
|
|
425
|
-
| 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:112`) | Enregistrer au `onKernelBoot`, pas plus tard |
|
|
425
|
+
| `register()` throw « namespace déjà enregistré » | Deux producteurs sur le même `adminNamespace` (`AdminBroker.ts:51`) | 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:141`) | 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:24`) | 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:154`) | Fournir `Idempotency-Key` sur la mutation WS |
|
|
429
|
+
| Route admin injoignable / collision Studio | Endpoint mono-segment `/nodefony/<module>` (`IAdminBroker.ts:42`) | Toujours `≥ 3` segments `/nodefony/<ns>/api/<path>` |
|
|
430
|
+
| 500 « Admin endpoint not registered » | `adminRoute` absent du registre — incohérence interne (`AdminApiController.ts:109`) | Vérifier que le producteur a bien été enregistré avant `mountAll()` |
|
|
426
431
|
|
|
427
432
|
## 🧪 Tests & couverture
|
|
428
433
|
|