@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.
Files changed (53) hide show
  1. package/README.md +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +9 -5
  5. package/dist/nodefony/controller/AdminApiController.js +7 -4
  6. package/dist/nodefony/controller/ApiKeyController.js +5 -4
  7. package/dist/nodefony/controller/LoginPageController.js +124 -0
  8. package/dist/nodefony/controller/OAuth2Controller.js +131 -14
  9. package/dist/nodefony/controller/SessionAuthController.js +37 -9
  10. package/dist/nodefony/controller/TokenAuthController.js +13 -6
  11. package/dist/nodefony/controller/TotpController.js +7 -6
  12. package/dist/nodefony/controller/WebAuthnController.js +26 -9
  13. package/dist/nodefony/decorators/routerDecorators.js +114 -13
  14. package/dist/nodefony/service/AdminBroker.js +2 -2
  15. package/dist/nodefony/service/IdempotencyStore.js +2 -2
  16. package/dist/nodefony/service/router.js +54 -23
  17. package/dist/nodefony/src/Controller.js +49 -4
  18. package/dist/nodefony/src/FrameworkAdminApi.js +2 -1
  19. package/dist/nodefony/src/KernelAdminApi.js +76 -10
  20. package/dist/nodefony/src/Resolver.js +41 -4
  21. package/dist/nodefony/src/Route.js +76 -6
  22. package/dist/nodefony/src/SecurityApiController.js +24 -0
  23. package/dist/nodefony/src/docsReader.js +64 -41
  24. package/dist/nodefony/src/idempotency.js +13 -20
  25. package/dist/nodefony/src/loginPage.js +431 -0
  26. package/dist/nodefony/src/requireAuthentication.js +36 -0
  27. package/dist/types/index.d.ts +4 -3
  28. package/dist/types/nodefony/controller/ApiKeyController.d.ts +4 -3
  29. package/dist/types/nodefony/controller/LoginPageController.d.ts +32 -0
  30. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +67 -8
  31. package/dist/types/nodefony/controller/SessionAuthController.d.ts +39 -8
  32. package/dist/types/nodefony/controller/TokenAuthController.d.ts +8 -6
  33. package/dist/types/nodefony/controller/TotpController.d.ts +4 -3
  34. package/dist/types/nodefony/controller/WebAuthnController.d.ts +12 -4
  35. package/dist/types/nodefony/decorators/routerDecorators.d.ts +88 -3
  36. package/dist/types/nodefony/interfaces/IRoute.d.ts +1 -2
  37. package/dist/types/nodefony/service/router.d.ts +13 -0
  38. package/dist/types/nodefony/src/Controller.d.ts +40 -1
  39. package/dist/types/nodefony/src/Resolver.d.ts +20 -1
  40. package/dist/types/nodefony/src/Route.d.ts +33 -2
  41. package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -0
  42. package/dist/types/nodefony/src/docsReader.d.ts +26 -12
  43. package/dist/types/nodefony/src/idempotency.d.ts +12 -10
  44. package/dist/types/nodefony/src/loginPage.d.ts +144 -0
  45. package/dist/types/nodefony/src/requireAuthentication.d.ts +29 -0
  46. package/docs/admin.md +28 -23
  47. package/docs/controller.md +84 -82
  48. package/docs/decorateurs.md +77 -74
  49. package/docs/idempotence.md +31 -29
  50. package/docs/index.md +1 -1
  51. package/docs/routing.md +58 -57
  52. package/docs/templates.md +12 -12
  53. package/package.json +11 -12
@@ -51,28 +51,21 @@ function resolveIdempotencyKey(alsKey, header) {
51
51
  }
52
52
  /**
53
53
  * Identité stable pour **scoper** le cache d'idempotence (anti-IDOR : un
54
- * utilisateur ne doit jamais rejouer la clé d'un autre). Dérivée de l'utilisateur
55
- * (`username` / `identifier` / `id`, sans coupler le framework au contrat `IUser`),
56
- * avec fallback sur l'`userId` de l'ALS. `null` = pas d'identité fiable → l'appelant
57
- * n'utilise PAS le cache (jamais de partage cross-identité).
54
+ * utilisateur ne doit jamais rejouer la clé d'un autre) : l'`identifier` de
55
+ * l'utilisateur AUTHENTIFIÉ de la requête. `null` = pas d'identité fiable →
56
+ * l'appelant n'utilise PAS le cache (jamais de partage cross-identité).
58
57
  *
59
- * ⚠️ Doit renvoyer la MÊME valeur quel que soit le transport pour un même compte
60
- * (sinon une mutation tentée en WS puis rejouée en HTTP ne dédoublonnerait pas).
61
- * D'où la dérivation de `request.user` (posé UNIFORMÉMENT dans l'ALS par les deux
62
- * transports), et NON de `getUserId()` seul (le firewall HTTP ne le pose pas
63
- * toujours — vécu S4).
58
+ * La décision « authentifié ? » est celle du cœur
59
+ * (`RequestContext.getAuthenticatedUser`) : l'anonyme d'une zone `anonymous`
60
+ * rend `null`. Lire l'`identifier` de l'utilisateur BRUT faisait partager à
61
+ * tous les visiteurs anonymes la même identité (`anon.`), donc le même cache.
62
+ *
63
+ * Même valeur quel que soit le transport pour un même compte : le pare-feu HTTP
64
+ * et le pont realtime posent tous deux le jeton et l'utilisateur dans l'ALS —
65
+ * une mutation tentée en WS puis rejouée en HTTP se dédoublonne.
64
66
  */
65
- function resolveIdentity(user) {
66
- if (user && typeof user === "object") {
67
- const o = user;
68
- for (const v of [
69
- o.username,
70
- o.identifier,
71
- o.id
72
- ]) if (typeof v === "string" && v) return v;
73
- }
74
- const uid = RequestContext.getUserId();
75
- return typeof uid === "string" && uid ? uid : null;
67
+ function resolveIdentity() {
68
+ return RequestContext.getAuthenticatedUser()?.identifier ?? null;
76
69
  }
77
70
  /**
78
71
  * Empreinte SHA-256 du **payload** d'une requête (parties sérialisables : nom de
@@ -0,0 +1,431 @@
1
+ import { LOGIN_PAGE_ASSETS_BASE, LOGIN_PAGE_LAYOUTS, LOGIN_PAGE_SKINS, LOGIN_PAGE_SKIN_SPECS, LOGIN_PAGE_THEME_KEY, OAUTH2_CANCELLED_REASON, isLoginPageSkin, loginPageSkinFile, oauth2AuthorizePath, safeRedirectPath } from "nodefony";
2
+ //#region nodefony/src/loginPage.ts
3
+ /**
4
+ * Mémoïse une ressource chargée de façon asynchrone : le SUCCÈS est gardé, un
5
+ * ÉCHEC est oublié. Garder la promesse rejetée figerait la panne — la page
6
+ * répondrait 500 jusqu'au redémarrage, même une fois le fichier corrigé.
7
+ *
8
+ * @param load - charge la ressource
9
+ * @returns la fonction qui la rend, chargée une seule fois tant qu'elle réussit
10
+ */
11
+ function keepSuccess(load) {
12
+ let pending = null;
13
+ return () => pending ??= load().catch((error) => {
14
+ pending = null;
15
+ throw error;
16
+ });
17
+ }
18
+ /** Feuille, script et logo de la page, servis sous {@link LOGIN_PAGE_ASSETS_BASE}. */
19
+ const LOGIN_PAGE_ASSET_FILES = {
20
+ style: "login.css",
21
+ script: "login.js",
22
+ logo: "nodefony-logo.svg"
23
+ };
24
+ const DEFAULT_TITLE = "Se connecter";
25
+ /**
26
+ * Fournisseurs dont la MARQUE est le mot que l'utilisateur connaît : il a un
27
+ * compte GitHub, pas un « fournisseur OpenID Connect ». Tout autre fournisseur
28
+ * est un serveur d'identité d'organisation (Keycloak, Okta, un OIDC
29
+ * d'entreprise) dont le nom ne dit rien à qui se connecte.
30
+ */
31
+ const PUBLIC_ACCOUNT_PROVIDERS = /* @__PURE__ */ new Set([
32
+ "apple",
33
+ "bitbucket",
34
+ "discord",
35
+ "facebook",
36
+ "github",
37
+ "gitlab",
38
+ "google",
39
+ "linkedin",
40
+ "microsoft",
41
+ "slack"
42
+ ]);
43
+ /** Libellé d'un fournisseur d'organisation : ce qu'il ouvre, pas son logiciel. */
44
+ const ORGANIZATION_ACCOUNT_LABEL = "Compte de l'organisation";
45
+ /** Annonce d'une page sans aucun moyen de connexion. */
46
+ const LOGIN_PAGE_EMPTY_NOTICE = "Aucun moyen de connexion n'est disponible pour le moment. Prévenez l'administrateur du site.";
47
+ /**
48
+ * Ligne sous le titre, déduite des moyens proposés quand la configuration n'en
49
+ * donne pas : elle annonce ce que la carte contient, jamais autre chose.
50
+ */
51
+ function leadFor(password, passkey, sso) {
52
+ if (password && sso) return "Compte de l'organisation ou identifiant local";
53
+ if (password && passkey) return "Identifiant et mot de passe, ou passkey";
54
+ if (password) return "Identifiant et mot de passe";
55
+ if (sso) return "Avec le compte de votre organisation";
56
+ if (passkey) return "Avec votre passkey";
57
+ return "Aucun moyen de connexion disponible";
58
+ }
59
+ /** Annonce d'un retour du fournisseur sans connexion, hors annulation. */
60
+ const PROVIDER_FAILED_NOTICE = "La connexion par le fournisseur n'a pas abouti. Réessayez, ou choisissez une autre méthode.";
61
+ /** Dit si une valeur de requête nomme une mise en page connue. */
62
+ function isLayout(value) {
63
+ return LOGIN_PAGE_LAYOUTS.includes(value);
64
+ }
65
+ /**
66
+ * Habillage et mise en page servis : la configuration, sauf aperçu demandé en
67
+ * développement. Un `?skin=` seul prend la mise en page de son habillage ; une
68
+ * valeur inconnue est ignorée.
69
+ */
70
+ function resolveSkin(page, request) {
71
+ if (request.preview !== true) return {
72
+ skin: page.skin,
73
+ layout: page.layout
74
+ };
75
+ const skin = isLoginPageSkin(request.skin) ? request.skin : page.skin;
76
+ return {
77
+ skin,
78
+ layout: isLayout(request.layout) ? request.layout : skin === page.skin ? page.layout : LOGIN_PAGE_SKIN_SPECS[skin].layout
79
+ };
80
+ }
81
+ /**
82
+ * Prépare les données du gabarit à partir de la description de la page
83
+ * (security) et de la requête.
84
+ *
85
+ * Fonction pure : `from` repasse par `safeRedirectPath` (un chemin hors de
86
+ * l'origine retombe sur `/`), `theme` n'accepte que `light` ou `dark`,
87
+ * `skin` et `layout` ne sont lus qu'en aperçu et contre leur liste fermée.
88
+ *
89
+ * @param page - description rendue par `authFlow.describeLoginPage()`
90
+ * @param request - valeurs de la requête, non filtrées
91
+ * @returns les données du gabarit
92
+ */
93
+ function buildLoginPageView(page, request) {
94
+ const from = safeRedirectPath(request.from);
95
+ const asset = (file) => `${LOGIN_PAGE_ASSETS_BASE}/${file}?v=${encodeURIComponent(request.assetsVersion)}`;
96
+ const logoHref = asset(LOGIN_PAGE_ASSET_FILES.logo);
97
+ const providerFrom = from === "/" ? void 0 : from;
98
+ const { skin, layout } = resolveSkin(page, request);
99
+ const skinFile = loginPageSkinFile(skin);
100
+ const sso = page.providers.length > 0;
101
+ return {
102
+ brand: page.title ?? request.projectName,
103
+ title: page.heading ?? DEFAULT_TITLE,
104
+ subtitle: page.subtitle,
105
+ hero: page.hero,
106
+ footer: page.footer,
107
+ logo: page.logo ?? logoHref,
108
+ skin,
109
+ layout,
110
+ backdrop: LOGIN_PAGE_SKIN_SPECS[skin].backdrop,
111
+ theme: request.theme === "light" || request.theme === "dark" ? request.theme : null,
112
+ ssoFirst: page.providersFirst && (sso || page.passkey),
113
+ password: page.password,
114
+ passkey: page.passkey,
115
+ empty: !page.password && !page.passkey && !sso,
116
+ lead: page.subtitle ?? leadFor(page.password, page.passkey, sso),
117
+ providers: page.providers.map(({ name, label, icon }) => ({
118
+ ...PUBLIC_ACCOUNT_PROVIDERS.has(name.toLowerCase()) ? {
119
+ label,
120
+ caption: null
121
+ } : {
122
+ label: ORGANIZATION_ACCOUNT_LABEL,
123
+ caption: `SSO · ${label}`
124
+ },
125
+ href: oauth2AuthorizePath(name, providerFrom, page.path),
126
+ icon: name === "github" ? "m-github" : "i-key",
127
+ image: icon
128
+ })),
129
+ from,
130
+ picker: request.preview === true && page.previewPicker ? {
131
+ skins: LOGIN_PAGE_SKINS,
132
+ layouts: LOGIN_PAGE_LAYOUTS,
133
+ hook: page.previewHook ?? null
134
+ } : null,
135
+ notice: typeof request.reason === "string" && request.reason.length > 0 && request.reason !== OAUTH2_CANCELLED_REASON ? PROVIDER_FAILED_NOTICE : null,
136
+ styleHref: asset(LOGIN_PAGE_ASSET_FILES.style),
137
+ skinHref: skinFile === null ? null : asset(skinFile),
138
+ stylesheetHref: page.stylesheet,
139
+ scriptHref: asset(LOGIN_PAGE_ASSET_FILES.script),
140
+ logoHref,
141
+ nonce: request.nonce
142
+ };
143
+ }
144
+ /**
145
+ * Gabarit Eta de la page de connexion par défaut (balisage F03, feuille
146
+ * `nodefony/login.css`, script `nodefony/login.js`).
147
+ *
148
+ * En chaîne dans le code : un `.eta` posé à côté ne serait pas publié
149
+ * (`files` du paquet = `dist`, `docs`). Compilé une fois par processus.
150
+ * Tout passe par `<%= %>` (échappé) ; aucune sortie brute. L'icône
151
+ * d'empreinte (`i-finger`) reprend `IconFingerprint` de Tabler Icons (MIT),
152
+ * celle du bouton de Studio : les deux écrans montrent le même geste.
153
+ */
154
+ const LOGIN_PAGE_TEMPLATE = `<!doctype html>
155
+ <html lang="fr"<% if (it.theme) { %> data-theme="<%= it.theme %>"<% } %>>
156
+ <head>
157
+ <meta charset="utf-8" />
158
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
159
+ <meta name="color-scheme" content="dark light" />
160
+ <meta name="robots" content="noindex" />
161
+ <title><%= it.title %> — <%= it.brand %></title>
162
+ <link rel="icon" href="<%= it.logoHref %>" type="image/svg+xml" />
163
+ <% if (!it.theme) { %>
164
+ <script nonce="<%= it.nonce %>">try{var t=localStorage.getItem("${LOGIN_PAGE_THEME_KEY}");if(t==="light"||t==="dark")document.documentElement.dataset.theme=t}catch(e){}<\/script>
165
+ <% } %>
166
+ <link rel="stylesheet" href="<%= it.styleHref %>" />
167
+ <% if (it.skinHref) { %>
168
+ <link rel="stylesheet" href="<%= it.skinHref %>" />
169
+ <% } %>
170
+ <% if (it.stylesheetHref) { %>
171
+ <link rel="stylesheet" href="<%= it.stylesheetHref %>" />
172
+ <% } %>
173
+ <script type="module" src="<%= it.scriptHref %>" nonce="<%= it.nonce %>"><\/script>
174
+ </head>
175
+ <body data-skin="<%= it.skin %>" data-layout="<%= it.layout %>"<% if (it.backdrop) { %> data-backdrop<% } %><% if (it.ssoFirst) { %> data-sso-first<% } %>>
176
+ <svg width="0" height="0" style="position: absolute" aria-hidden="true">
177
+ <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>
178
+ <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>
179
+ <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>
180
+ <symbol id="i-arrow" viewBox="0 0 24 24"><path d="M5 12h14M13 6l6 6-6 6" /></symbol>
181
+ <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>
182
+ <symbol id="i-check" viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7.5" /></symbol>
183
+ <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>
184
+ <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>
185
+ <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>
186
+ <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>
187
+ <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>
188
+ </svg>
189
+ <header class="nf-strip">
190
+ <div class="nf-strip-brand"><img class="nf-logo" src="<%= it.logo %>" alt="" /><%= it.brand %></div>
191
+ </header>
192
+ <div class="nf-layout">
193
+ <aside class="nf-hero" aria-hidden="true">
194
+ <div class="nf-hero-brand"><img class="nf-logo" src="<%= it.logo %>" alt="" /><%= it.brand %></div>
195
+ <% if (it.hero === null) { %>
196
+ <div class="nf-home">
197
+ <img
198
+ class="nf-home-logo"
199
+ src="<%= it.logoHref %>"
200
+ alt=""
201
+ />
202
+ <h2>Le framework Node.js fullstack</h2>
203
+ <p class="sub">
204
+ Temps réel natif · développement agentic-ready · socle TypeScript
205
+ isomorphe
206
+ </p>
207
+ <p class="sig">
208
+ Une action de contrôleur. Deux transports. La même session, la même
209
+ sécurité, le même code.
210
+ </p>
211
+ <ul class="badges">
212
+ <li class="agentic">Agentic ready</li>
213
+ <li>Node.js ≥ 24</li>
214
+ <li>TypeScript strict</li>
215
+ <li>ESM only</li>
216
+ <li>Licence Apache 2.0</li>
217
+ </ul>
218
+ <div
219
+ class="install"
220
+ role="img"
221
+ aria-label="Commande d'installation : npx nodefony create app"
222
+ >
223
+ <span class="p">$</span><span>npx nodefony create app</span>
224
+ </div>
225
+ <div class="features">
226
+ <div class="feature">
227
+ <span class="ic"
228
+ ><svg
229
+ viewBox="0 0 24 24"
230
+ fill="none"
231
+ stroke="currentColor"
232
+ stroke-width="2"
233
+ stroke-linecap="round"
234
+ stroke-linejoin="round"
235
+ aria-hidden="true"
236
+ >
237
+ <polygon
238
+ points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"
239
+ /></svg
240
+ ></span>
241
+ <h3>Temps réel natif</h3>
242
+ <p>
243
+ Le WebSocket n'est pas un ajout : même pipeline, même table de
244
+ routes, même sécurité que le HTTP.
245
+ </p>
246
+ </div>
247
+ <div class="feature">
248
+ <span class="ic"
249
+ ><svg
250
+ viewBox="0 0 24 24"
251
+ fill="none"
252
+ stroke="currentColor"
253
+ stroke-width="2"
254
+ stroke-linecap="round"
255
+ stroke-linejoin="round"
256
+ aria-hidden="true"
257
+ >
258
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
259
+ <path d="m9 12 2 2 4-4" /></svg
260
+ ></span>
261
+ <h3>Sécurité par conception</h3>
262
+ <p>
263
+ Pare-feu applicatif par zones, Zero Trust, CSRF Fetch Metadata,
264
+ sessions hybrides, WebAuthn.
265
+ </p>
266
+ </div>
267
+ <div class="feature">
268
+ <span class="ic"
269
+ ><svg
270
+ viewBox="0 0 24 24"
271
+ fill="none"
272
+ stroke="currentColor"
273
+ stroke-width="2"
274
+ stroke-linecap="round"
275
+ stroke-linejoin="round"
276
+ aria-hidden="true"
277
+ >
278
+ <rect x="3" y="3" width="7" height="7" rx="1" />
279
+ <rect x="14" y="3" width="7" height="7" rx="1" />
280
+ <rect x="3" y="14" width="7" height="7" rx="1" />
281
+ <rect x="14" y="14" width="7" height="7" rx="1" /></svg
282
+ ></span>
283
+ <h3>DI & modules</h3>
284
+ <p>
285
+ Injection de dépendances par décorateurs, modules autonomes,
286
+ configuration validée au boot.
287
+ </p>
288
+ </div>
289
+ <div class="feature">
290
+ <span class="ic"
291
+ ><svg
292
+ viewBox="0 0 24 24"
293
+ fill="none"
294
+ stroke="currentColor"
295
+ stroke-width="2"
296
+ stroke-linecap="round"
297
+ stroke-linejoin="round"
298
+ aria-hidden="true"
299
+ >
300
+ <circle cx="12" cy="12" r="9" />
301
+ <path
302
+ 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"
303
+ /></svg
304
+ ></span>
305
+ <h3>Socle isomorphe</h3>
306
+ <p>
307
+ Le même paquet côté serveur et côté navigateur : client temps
308
+ réel, règles et types écrits une fois.
309
+ </p>
310
+ </div>
311
+ </div>
312
+ </div>
313
+ <small>Propulsé par Nodefony</small>
314
+ <% } else if (it.hero) { %>
315
+ <div class="nf-home">
316
+ <h2><%= it.hero.heading %></h2>
317
+ <% if (it.hero.text) { %>
318
+ <p class="sub"><%= it.hero.text %></p>
319
+ <% } %>
320
+ </div>
321
+ <% } %>
322
+ </aside>
323
+ <main class="nf-stage" data-nf-login data-from="<%= it.from %>">
324
+ <button type="button" class="nf-theme" data-theme-toggle aria-label="Basculer entre thème clair et thème 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>
325
+ <section class="nf-card" aria-labelledby="nf-title">
326
+ <div class="nf-head">
327
+ <div class="nf-brand"><img class="nf-logo" src="<%= it.logo %>" alt="" /><%= it.brand %></div>
328
+ <h1 id="nf-title"><%= it.title %></h1>
329
+ <p class="nf-sub" id="nf-sub"><%= it.lead %></p>
330
+ </div>
331
+ <div class="nf-body">
332
+ <% if (it.password) { %>
333
+ <ol class="nf-steps" aria-label="Étapes">
334
+ <li data-tab="identifier" aria-current="step">Identifiant</li>
335
+ <li data-tab="password">Mot de passe</li>
336
+ <li data-tab="mfa" hidden>Vérification</li>
337
+ </ol>
338
+ <form data-step="identifier">
339
+ <div class="nf-field">
340
+ <label for="nf-username">Identifiant ou adresse e-mail</label>
341
+ <div class="nf-input">
342
+ <svg class="nf-icon" aria-hidden="true"><use href="#i-user" /></svg>
343
+ <input id="nf-username" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required />
344
+ </div>
345
+ </div>
346
+ <button class="nf-btn primary" type="submit">Continuer<svg class="nf-icon" aria-hidden="true"><use href="#i-arrow" /></svg></button>
347
+ </form>
348
+ <form data-step="password" hidden>
349
+ <!-- Compte de l'étape précédente, masqué : le gestionnaire de mots de passe
350
+ associe le mot de passe à CE compte (HTML, jeton « username »). -->
351
+ <input name="username" autocomplete="username" hidden readonly tabindex="-1" />
352
+ <div class="nf-account">
353
+ <span class="nf-avatar" aria-hidden="true"></span><span class="who"></span>
354
+ <button type="button" class="nf-link" data-go="identifier">Changer</button>
355
+ </div>
356
+ <div class="nf-field">
357
+ <div class="nf-label-row"><label for="nf-password">Mot de passe</label></div>
358
+ <div class="nf-input">
359
+ <svg class="nf-icon" aria-hidden="true"><use href="#i-lock" /></svg>
360
+ <input id="nf-password" name="password" type="password" autocomplete="current-password" required />
361
+ <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>
362
+ </div>
363
+ </div>
364
+ <button class="nf-btn primary" type="submit">Se connecter<svg class="nf-icon" aria-hidden="true"><use href="#i-arrow" /></svg></button>
365
+ </form>
366
+ <form data-step="mfa" hidden>
367
+ <div class="nf-field">
368
+ <label id="nf-otp-label">Code à 6 chiffres</label>
369
+ <div class="nf-otp" role="group" aria-labelledby="nf-otp-label">
370
+ <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" />
371
+ </div>
372
+ </div>
373
+ <p class="nf-hint">Le code affiché par votre application d'authentification.</p>
374
+ <button class="nf-btn primary" type="submit">Vérifier</button>
375
+ </form>
376
+ <% } %>
377
+ <% if (it.password || it.passkey) { %>
378
+ <div data-step="authenticated" hidden>
379
+ <div class="nf-done" role="status">
380
+ <span class="ring" aria-hidden="true"><svg class="nf-icon"><use href="#i-check" /></svg></span>
381
+ <strong>Session ouverte</strong><span class="dest"></span>
382
+ <span class="nf-progress" aria-hidden="true"><span></span></span>
383
+ </div>
384
+ </div>
385
+ <% } %>
386
+ <% if (it.providers.length > 0 || it.passkey) { %>
387
+ <div data-alt>
388
+ <% if (it.password) { %>
389
+ <div class="nf-or">ou</div>
390
+ <% } %>
391
+ <div class="nf-alt">
392
+ <% if (it.passkey) { %>
393
+ <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>
394
+ <% } %>
395
+ <% it.providers.forEach(function (provider) { %>
396
+ <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>
397
+ <% }) %>
398
+ </div>
399
+ </div>
400
+ <% } %>
401
+ <div class="nf-message" aria-live="polite" data-message><% if (it.empty) { %><p class="nf-alert" role="alert"><span class="code">—</span><span>${LOGIN_PAGE_EMPTY_NOTICE}</span></p><% } else if (it.notice) { %><p class="nf-alert" role="alert"><span class="code">SSO</span><span><%= it.notice %></span></p><% } %></div>
402
+ <% if (it.password || it.passkey) { %>
403
+ <noscript><p class="nf-hint">Cette page a besoin de JavaScript pour vous connecter.</p></noscript>
404
+ <% } %>
405
+ </div>
406
+ </section>
407
+ <% if (it.picker) { %>
408
+ <form class="nf-picker" method="get" data-preview-picker<% if (it.picker.hook) { %> data-preview-hook="<%= it.picker.hook %>"<% } %> aria-label="Aperçu de la page (développement)">
409
+ <input type="hidden" name="from" value="<%= it.from %>" />
410
+ <label>Habillage <select name="skin"><% it.picker.skins.forEach(function (skin) { %><option value="<%= skin %>"<% if (skin === it.skin) { %> selected<% } %>><%= skin %></option><% }) %></select></label>
411
+ <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>
412
+ <noscript><button type="submit">Appliquer</button></noscript>
413
+ </form>
414
+ <% } %>
415
+ <% if (it.footer) { %>
416
+ <div class="nf-foot">
417
+ <ul class="nf-facts" aria-label="Protection de la session">
418
+ <li><svg class="nf-icon" aria-hidden="true"><use href="#i-shield" /></svg>cookie HttpOnly</li>
419
+ <li><svg class="nf-icon" aria-hidden="true"><use href="#i-shield" /></svg>anti-CSRF</li>
420
+ <li><svg class="nf-icon" aria-hidden="true"><use href="#i-shield" /></svg>CSP stricte</li>
421
+ </ul>
422
+ <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é par Nodefony</a>
423
+ </div>
424
+ <% } %>
425
+ </main>
426
+ </div>
427
+ </body>
428
+ </html>
429
+ `;
430
+ //#endregion
431
+ export { LOGIN_PAGE_ASSET_FILES, LOGIN_PAGE_EMPTY_NOTICE, LOGIN_PAGE_TEMPLATE, ORGANIZATION_ACCOUNT_LABEL, buildLoginPageView, keepSuccess };
@@ -0,0 +1,36 @@
1
+ import { readSessionAuthentication } from "nodefony";
2
+ //#region nodefony/src/requireAuthentication.ts
3
+ /**
4
+ * Ce qui manque à une session pour satisfaire un `@RequireAuthentication` —
5
+ * fonction PURE : la session, l'exigence et l'heure lui sont données.
6
+ *
7
+ * Lit `authAt`/`amr` par `readSessionAuthentication` (contrat du cœur), jamais
8
+ * le sac de métadonnées à la main. Une session absente, ou qui ne porte pas ces
9
+ * métadonnées, est REFUSÉE : ne pas savoir n'est pas une preuve.
10
+ *
11
+ * @param session - la session de la requête, ou `null`.
12
+ * @param requirement - l'exigence figée de la route.
13
+ * @param now - l'heure de la décision, en millisecondes epoch.
14
+ * @returns la cause du refus en clair, ou `null` quand la session suffit.
15
+ */
16
+ function authenticationShortfall(session, requirement, now) {
17
+ const auth = readSessionAuthentication(session);
18
+ if (auth === null) return "la session de cette requête ne dit ni quand ni comment elle a été ouverte (aucune connexion, ou route hors d'une zone à session)";
19
+ if (requirement.maxAge !== null && now - auth.at > requirement.maxAge * 1e3) return `la connexion date de plus de ${requirement.maxAge} s`;
20
+ const missing = requirement.amr.filter((m) => !auth.amr.includes(m));
21
+ if (missing.length > 0) return `la connexion n'a pas employé ${missing.join(", ")} (employés : ${auth.amr.join(", ") || "aucun moyen déclaré"})`;
22
+ return null;
23
+ }
24
+ /**
25
+ * Le défi d'un refus « step-up » (RFC 9470 §3) : le schéma de session du
26
+ * pare-feu, l'erreur normée et l'âge maximal quand la route en fixe un.
27
+ * `error_description` reste en ASCII, sans guillemet (RFC 6750 §3).
28
+ *
29
+ * @param requirement - l'exigence de la route.
30
+ * @returns la valeur de `WWW-Authenticate`.
31
+ */
32
+ function stepUpChallenge(requirement) {
33
+ return "Session realm=\"nodefony\", error=\"insufficient_user_authentication\", error_description=\"A more recent or stronger authentication is required\"" + (requirement.maxAge === null ? "" : `, max_age="${requirement.maxAge}"`);
34
+ }
35
+ //#endregion
36
+ export { authenticationShortfall, stepUpChallenge };
@@ -10,6 +10,7 @@ import AdminBroker from "./nodefony/service/AdminBroker.js";
10
10
  import MemoryIdempotencyStore from "./nodefony/service/IdempotencyStore.js";
11
11
  import AdminApiController from "./nodefony/controller/AdminApiController.js";
12
12
  import SessionAuthController, { mountSessionAuthRoutes } from "./nodefony/controller/SessionAuthController.js";
13
+ import LoginPageController, { mountLoginPage } from "./nodefony/controller/LoginPageController.js";
13
14
  import TokenAuthController, { mountTokenAuthRoutes } from "./nodefony/controller/TokenAuthController.js";
14
15
  import IssuerMetadataController, { mountIssuerMetadataRoutes } from "./nodefony/controller/IssuerMetadataController.js";
15
16
  import ProtectedResourceMetadataController, { mountProtectedResourceRoutes, protectedResourceRoutePaths, collectProtectedResources } from "./nodefony/controller/ProtectedResourceMetadataController.js";
@@ -25,7 +26,7 @@ import { createSyslogAdminApi } from "./nodefony/src/SyslogAdminApi.js";
25
26
  import Eta from "./nodefony/service/Eta.js";
26
27
  import { mergeResolvers, mergeTypeDefs } from "@graphql-tools/merge";
27
28
  import { mergeSchemas, makeExecutableSchema } from "@graphql-tools/schema";
28
- import { controllers, route, controller, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, routeExpectsBodyStream } from "./nodefony/decorators/routerDecorators.js";
29
+ import { controllers, route, controller, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, RequireAuthentication, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, routeExpectsBodyStream, buildContextParamArgs, getParamArgsMeta } from "./nodefony/decorators/routerDecorators.js";
29
30
  declare module "nodefony" {
30
31
  interface NodefonyModuleConfig {
31
32
  "@nodefony/framework": IFrameworkConfigInput;
@@ -83,10 +84,10 @@ declare const graphql: {
83
84
  mergeTypeDefs: typeof mergeTypeDefs;
84
85
  };
85
86
  export default Framework;
86
- export { Controller, ResourceController, Route, Router, Resolver, AdminBroker, MemoryIdempotencyStore, AdminApiController, SessionAuthController, mountSessionAuthRoutes, TokenAuthController, mountTokenAuthRoutes, IssuerMetadataController, mountIssuerMetadataRoutes, ProtectedResourceMetadataController, mountProtectedResourceRoutes, protectedResourceRoutePaths, collectProtectedResources, WebAuthnController, mountWebAuthnRoutes, OAuth2Controller, mountOAuth2Routes, BenchController, mountBenchRoutes, ApiKeyController, mountApiKeyRoutes, TotpController, mountTotpRoutes, createKernelAdminApi, createFrameworkAdminApi, createSyslogAdminApi, buildPlaygroundSnapshot, Eta, route, controller, controllers, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, routeExpectsBodyStream, graphql, registerIdempotencyStore, getIdempotencyStoreFactory, listIdempotencyStores, };
87
+ export { Controller, ResourceController, Route, Router, Resolver, AdminBroker, MemoryIdempotencyStore, AdminApiController, SessionAuthController, mountSessionAuthRoutes, LoginPageController, mountLoginPage, TokenAuthController, mountTokenAuthRoutes, IssuerMetadataController, mountIssuerMetadataRoutes, ProtectedResourceMetadataController, mountProtectedResourceRoutes, protectedResourceRoutePaths, collectProtectedResources, WebAuthnController, mountWebAuthnRoutes, OAuth2Controller, mountOAuth2Routes, BenchController, mountBenchRoutes, ApiKeyController, mountApiKeyRoutes, TotpController, mountTotpRoutes, createKernelAdminApi, createFrameworkAdminApi, createSyslogAdminApi, buildPlaygroundSnapshot, Eta, route, controller, controllers, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, RequireAuthentication, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, routeExpectsBodyStream, buildContextParamArgs, getParamArgsMeta, graphql, registerIdempotencyStore, getIdempotencyStoreFactory, listIdempotencyStores, };
87
88
  export { frameworkConfigSchema } from "./nodefony/config/config.js";
88
89
  export type { IdempotencyStoreFactory, IIdempotencyStoreFactoryContext, } from "./nodefony/src/idempotencyStoreRegistry.js";
89
- export type { SecurityClause, SecurityRequirement, CspDirectives, } from "./nodefony/decorators/routerDecorators.js";
90
+ export type { SecurityClause, SecurityRequirement, IAuthenticationRequirement, IRequireAuthenticationOptions, CspDirectives, IParamArgSource, ParamMeta, } from "./nodefony/decorators/routerDecorators.js";
90
91
  export type { ControllerScope } from "./nodefony/src/Controller.js";
91
92
  export type { FrameworkAdminApiOptions } from "./nodefony/src/FrameworkAdminApi.js";
92
93
  export type { PlaygroundAction, PlaygroundController, PlaygroundGuards, PlaygroundParam, } from "./nodefony/src/PlaygroundAdminApi.js";
@@ -1,6 +1,6 @@
1
1
  import type { Module } from "nodefony";
2
2
  import type { ContextType } from "@nodefony/http";
3
- import Controller from "../src/Controller.js";
3
+ import SecurityApiController from "../src/SecurityApiController.js";
4
4
  /**
5
5
  * Endpoints HTTP de **gestion des clés API personnelles (PAT, P6.12)** — console
6
6
  * « mes clés » façon GitHub, adaptateurs MINCES au-dessus du service `apiKeys`
@@ -16,12 +16,13 @@ import Controller from "../src/Controller.js";
16
16
  * zone data plane `^/nodefony/[^/]+/api(/|$)` → **session BFF requise**. Le porteur
17
17
  * est TOUJOURS l'utilisateur courant (`authFlow.me`), jamais un paramètre — on ne
18
18
  * crée/révoque jamais une clé pour autrui. Montés seulement si le service `apiKeys`
19
- * existe (security chargé + clés activées) → 404, zéro surface, sinon.
19
+ * existe (security chargé) — sans security, 404 et zéro surface ; clés
20
+ * désactivées, les routes répondent 503.
20
21
  *
21
22
  * Erreurs mappées par DUCK-TYPING sur `code` (400/409/503) — framework ne peut pas
22
23
  * importer les classes d'erreur de security.
23
24
  */
24
- declare class ApiKeyController extends Controller {
25
+ declare class ApiKeyController extends SecurityApiController {
25
26
  #private;
26
27
  constructor(context: ContextType);
27
28
  /** Émission : crée une clé pour le porteur courant → token clair (1×). */
@@ -0,0 +1,32 @@
1
+ import { type Module } from "nodefony";
2
+ import type { ContextType } from "@nodefony/http";
3
+ import Controller from "../src/Controller.js";
4
+ /**
5
+ * Page de connexion par défaut (ADR-0015) : gabarit F03 rendu côté serveur,
6
+ * feuille `nodefony/login.css`, script `nodefony/login.js`. Aucun front, aucun
7
+ * build côté application.
8
+ *
9
+ * Joignable sans session ; jamais mise en cache (`no-store`) ni affichable
10
+ * dans un cadre (`frame-ancestors 'none'`).
11
+ */
12
+ declare class LoginPageController extends Controller {
13
+ constructor(context: ContextType);
14
+ /**
15
+ * Rend la page de connexion.
16
+ *
17
+ * @returns la page HTML, ou 404 quand security ne la décrit plus
18
+ */
19
+ page(): Promise<import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
20
+ }
21
+ /**
22
+ * Monte la page de connexion par défaut quand security la décrit : la route
23
+ * sur `loginPage.path`, et ses fichiers sous `LOGIN_PAGE_ASSETS_BASE`.
24
+ *
25
+ * La page est le chemin vers la session : elle échappe au pare-feu, comme les
26
+ * routes de connexion — sinon une application qui protège `^/` exigerait
27
+ * d'être connecté pour se connecter.
28
+ *
29
+ * @param frameworkModule - module framework, qui porte les contrôleurs
30
+ */
31
+ export declare function mountLoginPage(frameworkModule: Module): void;
32
+ export default LoginPageController;