@nodefony/framework 10.0.0-beta.5 → 10.0.0-beta.6

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 (47) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorateParam.js +7 -0
  3. package/dist/index.js +14 -10
  4. package/dist/nodefony/config/config.js +16 -2
  5. package/dist/nodefony/controller/AdminApiController.js +18 -5
  6. package/dist/nodefony/controller/BenchController.js +1 -1
  7. package/dist/nodefony/controller/CspReportController.js +70 -0
  8. package/dist/nodefony/controller/IssuerMetadataController.js +1 -1
  9. package/dist/nodefony/controller/LoginPageController.js +5 -7
  10. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -1
  11. package/dist/nodefony/controller/SessionAuthController.js +6 -7
  12. package/dist/nodefony/controller/TokenAuthController.js +3 -3
  13. package/dist/nodefony/decorators/routerDecorators.js +2 -2
  14. package/dist/nodefony/service/AdminBroker.js +1 -1
  15. package/dist/nodefony/service/IdempotencyStore.js +1 -1
  16. package/dist/nodefony/service/router.js +5 -4
  17. package/dist/nodefony/src/Controller.js +311 -114
  18. package/dist/nodefony/src/PlaygroundAdminApi.js +1 -1
  19. package/dist/nodefony/src/Resolver.js +3 -9
  20. package/dist/nodefony/src/SecurityApiController.js +30 -7
  21. package/dist/nodefony/src/conditionalRequest.js +103 -0
  22. package/dist/nodefony/src/docsReader.js +1 -1
  23. package/dist/nodefony/src/idempotency.js +1 -1
  24. package/dist/nodefony/src/loginPage.js +2 -1
  25. package/dist/nodefony/src/scopeCatalog.js +1 -1
  26. package/dist/nodefony/src/viewNonce.js +58 -0
  27. package/dist/types/index.d.ts +3 -2
  28. package/dist/types/nodefony/config/config.d.ts +20 -0
  29. package/dist/types/nodefony/controller/AdminApiController.d.ts +4 -2
  30. package/dist/types/nodefony/controller/CspReportController.d.ts +35 -0
  31. package/dist/types/nodefony/controller/LoginPageController.d.ts +3 -2
  32. package/dist/types/nodefony/controller/SessionAuthController.d.ts +5 -6
  33. package/dist/types/nodefony/controller/TokenAuthController.d.ts +3 -3
  34. package/dist/types/nodefony/interfaces/IController.d.ts +2 -2
  35. package/dist/types/nodefony/service/router.d.ts +3 -2
  36. package/dist/types/nodefony/src/Controller.d.ts +110 -12
  37. package/dist/types/nodefony/src/Resolver.d.ts +1 -1
  38. package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -4
  39. package/dist/types/nodefony/src/conditionalRequest.d.ts +82 -0
  40. package/dist/types/nodefony/src/loginPage.d.ts +1 -1
  41. package/dist/types/nodefony/src/viewNonce.d.ts +33 -0
  42. package/docs/controller.md +145 -26
  43. package/docs/decorateurs.md +1 -1
  44. package/docs/idempotence.md +2 -2
  45. package/docs/index.md +4 -2
  46. package/docs/templates.md +15 -0
  47. package/package.json +10 -9
@@ -1,23 +1,46 @@
1
1
  import Controller from "./Controller.js";
2
+ import { SESSION_AUTH_CHALLENGE } from "nodefony";
2
3
  // nodefony/src/SecurityApiController.ts
3
4
  const NO_STORE = { "cache-control": "no-store" };
4
5
  /**
6
+ * En-têtes d'un 401 : ceux reçus, plus le défi par défaut
7
+ * ({@link SESSION_AUTH_CHALLENGE}) s'ils n'en portent aucun — RFC 9110 §15.5.2
8
+ * exige un `WWW-Authenticate` sur TOUT 401. Un défi posé par l'appelant (un
9
+ * `Bearer` de ressource protégée, par exemple) garde la main.
10
+ *
11
+ * Chemin froid : n'est appelé que pour un 401.
12
+ *
13
+ * @param headers - les en-têtes déjà prévus pour la réponse.
14
+ * @returns les mêmes en-têtes, défi compris.
15
+ */
16
+ function withAuthChallenge(headers) {
17
+ if (headers !== void 0) {
18
+ for (const name in headers) if (name.toLowerCase() === "www-authenticate") return { ...headers };
19
+ }
20
+ return {
21
+ ...headers,
22
+ "www-authenticate": SESSION_AUTH_CHALLENGE
23
+ };
24
+ }
25
+ /**
5
26
  * Base des contrôleurs d'authentification (`/nodefony/security/api/*`) : toute
6
- * réponse JSON part en `Cache-Control: no-store`.
27
+ * réponse JSON part en `Cache-Control: no-store`, et tout 401 porte un défi.
7
28
  *
8
29
  * Ces réponses portent une identité, un jeton, un secret affiché une seule
9
30
  * fois (clé API, secret TOTP, codes de récupération) ou leur refus. Un cookie
10
31
  * ou un en-tête dans la requête n'empêche pas un cache partagé de STOCKER la
11
- * réponse (RFC 9111 §3) ; RFC 6749 §5.1 exige `no-store` sur un jeton. La règle
12
- * vit ici, une fois : une action ajoutée à un contrôleur héritier ne peut pas
13
- * l'oublier. Un en-tête passé par l'action garde la priorité.
32
+ * réponse (RFC 9111 §3) ; RFC 6749 §5.1 exige `no-store` sur un jeton. Un 401
33
+ * reçoit le défi par défaut ({@link withAuthChallenge}, RFC 9110 §15.5.2). Ces
34
+ * règles vivent ici, une fois : une action ajoutée à un contrôleur héritier ne
35
+ * peut pas les oublier. Un en-tête passé par l'action garde la priorité.
14
36
  */
15
37
  var SecurityApiController = class extends Controller {
16
38
  renderJson(obj, status, headers) {
17
- return super.renderJson(obj, status, {
39
+ const merged = {
18
40
  ...NO_STORE,
19
41
  ...headers
20
- });
42
+ };
43
+ return super.renderJson(obj, status, Number(status) === 401 ? withAuthChallenge(merged) : merged);
21
44
  }
22
45
  };
23
- export { SecurityApiController as default };
46
+ export { SecurityApiController as default, withAuthChallenge };
@@ -0,0 +1,103 @@
1
+ // nodefony/src/conditionalRequest.ts
2
+ /** Une `entity-tag` (RFC 9110 §8.8.3) : `W/` facultatif, puis une chaîne entre guillemets. */
3
+ const ENTITY_TAG = /(?:W\/)?"[^"]*"/g;
4
+ /**
5
+ * Validateurs d'un fichier, tirés de ses métadonnées sans le lire.
6
+ *
7
+ * L'`ETag` est FORTE : taille et date de modification à la microseconde, deux
8
+ * grandeurs qu'une écriture change. C'est la forme que posent les serveurs de
9
+ * fichiers courants, et la seule qui autorise une reprise (§13.1.5).
10
+ *
11
+ * @param stats - taille et date de modification du fichier
12
+ * @param now - instant de la réponse (ms)
13
+ * @returns les validateurs du fichier
14
+ */
15
+ function fileValidators(stats, now) {
16
+ const micros = Math.floor(stats.mtimeMs * 1e3);
17
+ const seconds = Math.floor(Math.min(stats.mtimeMs, now) / 1e3) * 1e3;
18
+ return {
19
+ etag: `"${stats.size.toString(16)}-${micros.toString(16)}"`,
20
+ lastModified: seconds,
21
+ lastModifiedStrong: stats.mtimeMs + 1e3 <= now
22
+ };
23
+ }
24
+ /**
25
+ * Date HTTP (RFC 9110 §5.6.7) lue en ms, ou `null` si la valeur n'en est pas
26
+ * une — une condition sur une date invalide DOIT être ignorée (§13.1.3, §13.1.4).
27
+ *
28
+ * @param value - valeur brute de l'en-tête
29
+ */
30
+ function parseHttpDate(value) {
31
+ if (value === void 0) return null;
32
+ const ms = Date.parse(value);
33
+ return Number.isNaN(ms) ? null : ms;
34
+ }
35
+ /** Les `entity-tag` d'une liste, ou `"*"`. */
36
+ function entityTags(value) {
37
+ if (value.trim() === "*") return "*";
38
+ return value.match(ENTITY_TAG) ?? [];
39
+ }
40
+ /** Partie opaque d'une `entity-tag` (sans `W/`). */
41
+ function opaque(tag) {
42
+ return tag.startsWith("W/") ? tag.slice(2) : tag;
43
+ }
44
+ /** Comparaison FORTE (§8.8.3.2) : aucune des deux n'est faible, et elles sont identiques. */
45
+ function strongMatch(a, b) {
46
+ return !a.startsWith("W/") && !b.startsWith("W/") && a === b;
47
+ }
48
+ /** Comparaison FAIBLE (§8.8.3.2) : parties opaques identiques. */
49
+ function weakMatch(a, b) {
50
+ return opaque(a) === opaque(b);
51
+ }
52
+ /**
53
+ * Évalue les préconditions d'une requête sur une représentation qui EXISTE,
54
+ * dans l'ordre de RFC 9110 §13.2.2 — `If-Match`, sinon `If-Unmodified-Since` ;
55
+ * puis `If-None-Match`, sinon `If-Modified-Since`. `Range` et `If-Range`
56
+ * viennent après, s'il faut servir (voir {@link ifRangeHolds}).
57
+ *
58
+ * @param method - méthode de la requête
59
+ * @param headers - en-têtes de la requête
60
+ * @param validators - validateurs de la représentation
61
+ * @returns 200 pour servir, 304 si rien n'a changé (GET et HEAD seulement),
62
+ * 412 si une condition est fausse
63
+ */
64
+ function evaluatePreconditions(method, headers, validators) {
65
+ const read = method === "GET" || method === "HEAD";
66
+ const ifMatch = headers["if-match"];
67
+ if (ifMatch !== void 0) {
68
+ const tags = entityTags(ifMatch);
69
+ const { etag } = validators;
70
+ if (!(tags === "*" || etag !== null && tags.some((tag) => strongMatch(tag, etag)))) return 412;
71
+ } else {
72
+ const since = parseHttpDate(headers["if-unmodified-since"]);
73
+ if (since !== null && validators.lastModified !== null && validators.lastModified > since) return 412;
74
+ }
75
+ const ifNoneMatch = headers["if-none-match"];
76
+ if (ifNoneMatch !== void 0) {
77
+ const tags = entityTags(ifNoneMatch);
78
+ const { etag } = validators;
79
+ if (tags === "*" || etag !== null && tags.some((tag) => weakMatch(tag, etag))) return read ? 304 : 412;
80
+ } else if (read) {
81
+ const since = parseHttpDate(headers["if-modified-since"]);
82
+ if (since !== null && validators.lastModified !== null && validators.lastModified <= since) return 304;
83
+ }
84
+ return 200;
85
+ }
86
+ /**
87
+ * Une condition `If-Range` tient-elle (RFC 9110 §13.1.5) ? Vraie, la plage
88
+ * demandée est servie (206) ; fausse, `Range` est ignoré et la représentation
89
+ * part entière (200) — un client ne recolle jamais deux versions.
90
+ *
91
+ * Une étiquette tient si elle est FORTEMENT égale ; une date, si elle égale
92
+ * exactement `Last-Modified` ET que celle-ci est un validateur fort.
93
+ *
94
+ * @param value - valeur brute de `If-Range`
95
+ * @param validators - validateurs de la représentation
96
+ */
97
+ function ifRangeHolds(value, validators) {
98
+ const trimmed = value.trim();
99
+ if (trimmed.startsWith("\"") || trimmed.startsWith("W/")) return validators.etag !== null && strongMatch(trimmed, validators.etag);
100
+ const date = parseHttpDate(trimmed);
101
+ return date !== null && validators.lastModifiedStrong && validators.lastModified === date;
102
+ }
103
+ export { evaluatePreconditions, fileValidators, ifRangeHolds, parseHttpDate };
@@ -1,10 +1,10 @@
1
1
  import { basename, dirname, extname, join } from "node:path";
2
2
  import { findProjectRoot, isDocVisible, packageManagerCommandLines, packageManagerExecArgs, portableSpawn, readDocScope, readSymbolsGraph, resolvePackageManager } from "nodefony";
3
3
  import { existsSync } from "node:fs";
4
- import { promisify } from "node:util";
5
4
  import { readFile, readdir, stat } from "node:fs/promises";
6
5
  import { fileURLToPath } from "node:url";
7
6
  import { execFile, spawn } from "node:child_process";
7
+ import { promisify } from "node:util";
8
8
  // nodefony/src/docsReader.ts
9
9
  const execFileAsync = promisify(execFile);
10
10
  /**
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  // nodefony/src/idempotency.ts
4
4
  /**
5
5
  * Logique **pure et partagée** de la porte d'idempotence des mutations, conforme à
6
- * `draft-ietf-httpapi-idempotency-key-header-06`. Source unique de la sémantique
6
+ * `draft-ietf-httpapi-idempotency-key-header-08`. Source unique de la sémantique
7
7
  * (statuts normatifs, scope de clé, fingerprint, bornage) consommée par les DEUX
8
8
  * call-sites qui mutent :
9
9
  * - le **data plane admin** (`AdminApiController` — réponse `{status,headers,body}`) ;
@@ -1,4 +1,4 @@
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";
1
+ import { CSP_NONCE_META_PROPERTY, 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
2
  // nodefony/src/loginPage.ts
3
3
  /**
4
4
  * Mémoïse une ressource chargée de façon asynchrone : le SUCCÈS est gardé, un
@@ -158,6 +158,7 @@ const LOGIN_PAGE_TEMPLATE = `<!doctype html>
158
158
  <meta name="viewport" content="width=device-width, initial-scale=1" />
159
159
  <meta name="color-scheme" content="dark light" />
160
160
  <meta name="robots" content="noindex" />
161
+ <% if (it.nonce) { %><meta property="${CSP_NONCE_META_PROPERTY}" nonce="<%= it.nonce %>"><% } %>
161
162
  <title><%= it.title %> — <%= it.brand %></title>
162
163
  <link rel="icon" href="<%= it.logoHref %>" type="image/svg+xml" />
163
164
  <% if (!it.theme) { %>
@@ -1,5 +1,5 @@
1
- import { extractActionScopes } from "../decorators/routerDecorators.js";
2
1
  import router_default from "../service/router.js";
2
+ import { extractActionScopes } from "../decorators/routerDecorators.js";
3
3
  // nodefony/src/scopeCatalog.ts
4
4
  /**
5
5
  * P6.8 — **Découverte au boot** : scanne TOUTES les routes montées (`Router.routes`)
@@ -0,0 +1,58 @@
1
+ // nodefony/src/viewNonce.ts
2
+ /**
3
+ * Signature CSP des vues : chaque `<script>` et `<style>` ÉCRIT dans un
4
+ * gabarit reçoit le nonce de la requête, sans geste de l'auteur.
5
+ *
6
+ * La politique par défaut exige un nonce sur les scripts (`'strict-dynamic'`)
7
+ * et sur les styles (ADR-0024 §C1, §C2). Une page écrite vite, par quelqu'un
8
+ * qui ignore tout de la CSP, ne doit pas être bloquée (§C0) : la signature se
9
+ * pose donc au rendu, par le framework.
10
+ *
11
+ * Elle porte sur le TEXTE DU GABARIT, avant que les données y soient insérées :
12
+ * une balise que les données apporteraient (une valeur insérée brute par
13
+ * `<%~ %>`) n'est jamais signée — c'est ce qui garde au nonce sa valeur contre
14
+ * l'injection. Même principe que l'`index.html` d'un front bâti
15
+ * (`prebuiltUi.ts#splitAroundNonce`).
16
+ */
17
+ /** Variable de vue qui porte le nonce CSP de la requête (`<%= cspNonce %>`). */
18
+ const VIEW_NONCE_LOCAL = "cspNonce";
19
+ /** Ouverture d'une balise `<script` ou `<style` qui ne porte pas de `nonce`. */
20
+ const UNSIGNED_TAG_OPEN = /<(script|style)\b(?![^>]*\bnonce\s*=)/giu;
21
+ /** Une balise Eta (`<% %>`, `<%= %>`, `<%~ %>`) : du code, jamais du balisage. */
22
+ const ETA_TAG = /<%[\s\S]*?%>/gu;
23
+ /** Attribut inséré : le nonce, lu dans les variables de la vue à chaque rendu. */
24
+ const NONCE_ATTRIBUTE = ` nonce="<%= it.${VIEW_NONCE_LOCAL} %>"`;
25
+ /**
26
+ * Ajoute le nonce de la requête à chaque `<script>` et `<style>` littéral du
27
+ * gabarit qui n'en porte pas.
28
+ *
29
+ * Une balise déjà signée par l'auteur est laissée telle quelle ; un `<script`
30
+ * écrit À L'INTÉRIEUR d'une balise Eta (une chaîne de code) n'est jamais
31
+ * touché. Un `<script>` dont un attribut vient des données
32
+ * (`src="<%= it.url %>"`) n'est pas signé non plus : le signer exécuterait
33
+ * l'URL qu'elles apportent. L'auteur qui la tient pour sûre signe lui-même
34
+ * (`nonce="<%= it.cspNonce %>"`). Le rendu doit fournir la variable
35
+ * {@link VIEW_NONCE_LOCAL}.
36
+ *
37
+ * @param source - texte du gabarit Eta, tel que lu sur le disque.
38
+ * @returns le gabarit signé — la MÊME chaîne quand il n'y a rien à signer.
39
+ */
40
+ function signTemplateTags(source) {
41
+ UNSIGNED_TAG_OPEN.lastIndex = 0;
42
+ if (!UNSIGNED_TAG_OPEN.test(source)) return source;
43
+ let etaRanges = null;
44
+ ETA_TAG.lastIndex = 0;
45
+ for (let m = ETA_TAG.exec(source); m !== null; m = ETA_TAG.exec(source)) (etaRanges ??= []).push(m.index, m.index + m[0].length);
46
+ return source.replace(UNSIGNED_TAG_OPEN, (tag, name, offset) => {
47
+ if (etaRanges !== null) for (let i = 0; i < etaRanges.length; i += 2) {
48
+ const start = etaRanges[i] ?? 0;
49
+ if (start > offset) {
50
+ if (name.toLowerCase() === "script" && start < source.indexOf(">", offset)) return tag;
51
+ break;
52
+ }
53
+ if (offset < (etaRanges[i + 1] ?? 0)) return tag;
54
+ }
55
+ return tag + NONCE_ATTRIBUTE;
56
+ });
57
+ }
58
+ export { VIEW_NONCE_LOCAL, signTemplateTags };
@@ -16,6 +16,7 @@ import IssuerMetadataController, { mountIssuerMetadataRoutes } from "./nodefony/
16
16
  import ProtectedResourceMetadataController, { mountProtectedResourceRoutes, protectedResourceRoutePaths, collectProtectedResources } from "./nodefony/controller/ProtectedResourceMetadataController.js";
17
17
  import WebAuthnController, { mountWebAuthnRoutes } from "./nodefony/controller/WebAuthnController.js";
18
18
  import OAuth2Controller, { mountOAuth2Routes } from "./nodefony/controller/OAuth2Controller.js";
19
+ import CspReportController, { mountCspReportRoute } from "./nodefony/controller/CspReportController.js";
19
20
  import BenchController, { mountBenchRoutes } from "./nodefony/controller/BenchController.js";
20
21
  import ApiKeyController, { mountApiKeyRoutes } from "./nodefony/controller/ApiKeyController.js";
21
22
  import TotpController, { mountTotpRoutes } from "./nodefony/controller/TotpController.js";
@@ -24,7 +25,7 @@ import { createFrameworkAdminApi } from "./nodefony/src/FrameworkAdminApi.js";
24
25
  import { buildPlaygroundSnapshot } from "./nodefony/src/PlaygroundAdminApi.js";
25
26
  import { createSyslogAdminApi } from "./nodefony/src/SyslogAdminApi.js";
26
27
  import Eta from "./nodefony/service/Eta.js";
27
- 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";
28
+ 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, getRouteSchema } from "./nodefony/decorators/routerDecorators.js";
28
29
  export type { IHttpMethodDecorator } from "./nodefony/decorators/routerDecorators.js";
29
30
  export { validateRouteInput } from "./nodefony/src/routeSchema.js";
30
31
  export type { IRouteSchema, RouteInput, RoutePart, RawRouteInput, RawRouteParams, RawRouteQuery, RawRouteHeaders, } from "./nodefony/src/routeSchema.js";
@@ -79,7 +80,7 @@ declare class Framework extends Module<IFrameworkConfig> {
79
80
  onKernelReady(): Promise<this>;
80
81
  }
81
82
  export default Framework;
82
- export { Controller, ResourceController, Route, Router, FRAMEWORK_ROUTER, isFrameworkRouter, 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, registerIdempotencyStore, getIdempotencyStoreFactory, listIdempotencyStores, };
83
+ export { Controller, ResourceController, Route, Router, FRAMEWORK_ROUTER, isFrameworkRouter, Resolver, AdminBroker, MemoryIdempotencyStore, AdminApiController, SessionAuthController, mountSessionAuthRoutes, LoginPageController, mountLoginPage, TokenAuthController, mountTokenAuthRoutes, IssuerMetadataController, mountIssuerMetadataRoutes, ProtectedResourceMetadataController, mountProtectedResourceRoutes, protectedResourceRoutePaths, collectProtectedResources, WebAuthnController, mountWebAuthnRoutes, OAuth2Controller, mountOAuth2Routes, CspReportController, mountCspReportRoute, 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, getRouteSchema, registerIdempotencyStore, getIdempotencyStoreFactory, listIdempotencyStores, };
83
84
  export { frameworkConfigSchema } from "./nodefony/config/config.js";
84
85
  export type { IdempotencyStoreFactory, IIdempotencyStoreFactoryContext, } from "./nodefony/src/idempotencyStoreRegistry.js";
85
86
  export type { SecurityClause, SecurityRequirement, IAuthenticationRequirement, IRequireAuthenticationOptions, CspDirectives, IParamArgSource, ParamMeta, } from "./nodefony/decorators/routerDecorators.js";
@@ -1,4 +1,16 @@
1
1
  import { z } from "zod";
2
+ /**
3
+ * Requêtes conditionnelles des fichiers servis par une action (`streamFile`,
4
+ * `renderFileDownload`, `renderMediaStream`). Le défaut de TOUTE
5
+ * l'application ; un appel le remplace par ses options (`validators`) ou ses
6
+ * en-têtes (`Cache-Control`, `ETag`).
7
+ */
8
+ export declare const fileServingSchema: z.ZodObject<{
9
+ validators: z.ZodDefault<z.ZodBoolean>;
10
+ cacheControl: z.ZodDefault<z.ZodNullable<z.ZodString>>;
11
+ }, z.core.$strict>;
12
+ /** Réglages des fichiers servis, défauts appliqués. */
13
+ export type IFileServingConfig = z.infer<typeof fileServingSchema>;
2
14
  export declare const frameworkConfigSchema: z.ZodObject<{
3
15
  router: z.ZodOptional<z.ZodObject<{}, z.core.$loose>>;
4
16
  adminBroker: z.ZodOptional<z.ZodObject<{}, z.core.$loose>>;
@@ -7,6 +19,10 @@ export declare const frameworkConfigSchema: z.ZodObject<{
7
19
  gcIntervalS: z.ZodDefault<z.ZodNumber>;
8
20
  gcJitter: z.ZodDefault<z.ZodBoolean>;
9
21
  }, z.core.$strict>>;
22
+ fileServing: z.ZodDefault<z.ZodObject<{
23
+ validators: z.ZodDefault<z.ZodBoolean>;
24
+ cacheControl: z.ZodDefault<z.ZodNullable<z.ZodString>>;
25
+ }, z.core.$strict>>;
10
26
  }, z.core.$strict>;
11
27
  /** Type de sortie (config normalisée + défauts appliqués). */
12
28
  export type IFrameworkConfig = z.infer<typeof frameworkConfigSchema>;
@@ -24,6 +40,10 @@ declare const _default: {
24
40
  gcIntervalS: number;
25
41
  gcJitter: boolean;
26
42
  };
43
+ fileServing: {
44
+ validators: boolean;
45
+ cacheControl: string | null;
46
+ };
27
47
  "module-security": {
28
48
  areas: {
29
49
  "nodefony-liveness": {
@@ -38,8 +38,10 @@ declare class AdminApiController extends Controller {
38
38
  * (une action, deux transports) : même exécution sur les deux transports, seul
39
39
  * l'emballage diffère.
40
40
  *
41
- * - **HTTP** : rendu historique inchangé (`renderJson` + status + header
42
- * `x-nodefony-instance`).
41
+ * - **HTTP** : `renderJson` + status + header `x-nodefony-instance`. Quand
42
+ * l'application active un codec binaire, le corps passe par le codec texte
43
+ * de la socket (convention `$bytes`, annoncée par `x-nodefony-bytes`) : des
44
+ * octets se relisent alors à l'identique sur les deux transports.
43
45
  * - **Pont WS-RPC `api.request`** : valeur **nue** (le pont l'enveloppe
44
46
  * `{id, result}` — snapshot ≡ GET par construction) ; statut ≥ 400 →
45
47
  * {@link RpcError} (`data.status` + `data.body`), symétrie d'un `fetch`
@@ -0,0 +1,35 @@
1
+ import { type Module } from "nodefony";
2
+ import { type ContextType } from "@nodefony/http";
3
+ import Controller from "../src/Controller.js";
4
+ /**
5
+ * Réception PUBLIQUE des rapports de violation CSP (ADR-0024 §C7) —
6
+ * `POST /nodefony/security/api/csp-report`, adaptateur MINCE au-dessus du
7
+ * service `cspReports`.
8
+ *
9
+ * Le navigateur poste sans identifiant : la route contourne le pare-feu, et la
10
+ * défense CSRF (`@CsrfExempt`) — elle ne change l'état de personne, et un
11
+ * rapport n'a aucune session à détourner. Le corps arrive en FLUX
12
+ * (`@Body({ stream: true })`) pour être borné PENDANT la lecture : au-delà de
13
+ * {@link CSP_REPORT_MAX_BYTES}, 413 sans rien garder. Tout le reste — quota,
14
+ * type de contenu, forme, expurgation — appartient au collecteur, et la
15
+ * réponse est 204 quoi qu'il en pense : l'émetteur n'apprend rien.
16
+ */
17
+ declare class CspReportController extends Controller {
18
+ constructor(context: ContextType);
19
+ /**
20
+ * Reçoit un rapport, ou un lot de rapports.
21
+ *
22
+ * @param stream - le corps brut, jamais lu par le pipeline.
23
+ * @throws HttpError 413 quand le corps dépasse le plafond.
24
+ */
25
+ report(stream: AsyncIterable<Uint8Array>): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
26
+ }
27
+ /**
28
+ * Monte la route de réception — appelé par le module framework à
29
+ * `onKernelReady`, seulement si le service `cspReports` est présent (rapports
30
+ * demandés vers la route du framework).
31
+ *
32
+ * @param frameworkModule - module qui porte le contrôleur.
33
+ */
34
+ export declare function mountCspReportRoute(frameworkModule: Module): void;
35
+ export default CspReportController;
@@ -6,8 +6,9 @@ import Controller from "../src/Controller.js";
6
6
  * feuille `nodefony/login.css`, script `nodefony/login.js`. Aucun front, aucun
7
7
  * build côté application.
8
8
  *
9
- * Joignable sans session ; jamais mise en cache (`no-store`) ni affichable
10
- * dans un cadre (`frame-ancestors 'none'`).
9
+ * Joignable sans session ; jamais mise en cache (`no-store`). L’encadrement
10
+ * suit `http.securityHeaders.frameOptions`, qui pose `X-Frame-Options` ET
11
+ * `frame-ancestors` sur toute la politique (ADR-0024 §C5).
11
12
  */
12
13
  declare class LoginPageController extends Controller {
13
14
  constructor(context: ContextType);
@@ -62,12 +62,11 @@ export interface ISessionAuthFlow {
62
62
  * (Auth.js, better-auth). Le `401` y écrivait une erreur réseau dans la console
63
63
  * de chaque visiteur anonyme, sans rien protéger : la réponse ne révèle rien.
64
64
  *
65
- * @remarks Dérogation RFC 9110 §15.5.2 DÉLIBÉRÉE : les 401 de `login` ne
66
- * portent PAS de `WWW-Authenticate` — aucun scheme HTTP ne décrit un cookie de
67
- * session, et un challenge `Basic` mensonger déclencherait le popup natif du
68
- * navigateur (l'anti-pattern que le BFF élimine). Les zones du firewall, elles,
69
- * restent strictement conformes (challenge du premier authenticator qui en
70
- * déclare un).
65
+ * @remarks Les 401 de `login` portent le défi `Session realm="nodefony"`
66
+ * (RFC 9110 §15.5.2, posé par `SecurityApiController`), celui du pare-feu :
67
+ * aucun schéma HTTP ne décrit un cookie de session, et un `Basic` ouvrirait la
68
+ * fenêtre de connexion native du navigateur — l'anti-pattern que le BFF
69
+ * élimine. Un schéma inconnu des navigateurs n'en ouvre aucune.
71
70
  */
72
71
  declare class SessionAuthController extends SecurityApiController {
73
72
  #private;
@@ -33,9 +33,9 @@ export interface ITokenIssuer {
33
33
  * framework ne peut pas importer les classes d'erreur de security ; un 429
34
34
  * reporte le `Retry-After` (RFC 6585) du throttler NIST.
35
35
  *
36
- * @remarks Dérogation RFC 9110 §15.5.2 (comme `SessionAuthController`) : ces 401 ne
37
- * portent pas de `WWW-Authenticate` — l'endpoint d'émission n'est pas une
38
- * ressource protégée par Bearer, il DÉLIVRE le Bearer.
36
+ * @remarks Ces 401 portent le défi `Session realm="nodefony"` (RFC 9110
37
+ * §15.5.2, posé par `SecurityApiController`) et non un `Bearer` : l'endpoint
38
+ * d'émission n'est pas une ressource protégée par Bearer, il DÉLIVRE le Bearer.
39
39
  */
40
40
  declare class TokenAuthController extends SecurityApiController {
41
41
  #private;
@@ -34,6 +34,6 @@ export interface IController {
34
34
  getFile(file: FileClass | string): FileClass;
35
35
  /** Variante async de `getFile` (stat non bloquant via `FileClass.from`). */
36
36
  getFileAsync(file: FileClass | string): Promise<FileClass>;
37
- renderFileDownload(file: unknown, options?: unknown, headers?: OutgoingHttpHeaders): Promise<ReadStream>;
38
- streamFile(file: FileClass | string, headers?: OutgoingHttpHeaders, options?: Record<string, unknown>): Promise<ReadStream>;
37
+ renderFileDownload(file: unknown, options?: unknown, headers?: OutgoingHttpHeaders): Promise<ReadStream | null>;
38
+ streamFile(file: FileClass | string, headers?: OutgoingHttpHeaders, options?: Record<string, unknown>): Promise<ReadStream | null>;
39
39
  }
@@ -111,8 +111,9 @@ declare class Router extends Service implements IRequestRouter {
111
111
  * installées du framework feraient échouer `instanceof` en silence. Une
112
112
  * lecture de propriété.
113
113
  *
114
- * @param router - le routeur lu sur le contexte (`context.router`).
114
+ * @param router - le routeur lu sur le contexte (`context.router`) ou le
115
+ * service `router` du conteneur.
115
116
  * @returns `true` si c'est le Router du framework.
116
117
  */
117
- export declare function isFrameworkRouter(router: IRequestRouter | null | undefined): router is Router;
118
+ export declare function isFrameworkRouter(router: unknown): router is Router;
118
119
  export default Router;
@@ -1,12 +1,13 @@
1
1
  import { Service, Module, type Pci, type Severity, type Msgid, type Message, FileClass } from "nodefony";
2
- import type { IRequestUser, MaybePromise } from "nodefony";
2
+ import type { DefaultEventsMap, EventsMap, IRequestUser, MaybePromise } from "nodefony";
3
3
  import type { IController } from "../interfaces/index.js";
4
4
  import Route from "./Route.js";
5
5
  import { contextRequest, HTTPMethod, HttpResponse, Session, ContextType, Http2Response, WebsocketResponse } from "@nodefony/http";
6
- import type { ISseStreamOptions, SseStream } from "@nodefony/http";
6
+ import type { ByteStreamSource, IByteStreamOptions, IByteStreamResult, ISseStreamOptions, SseStream } from "@nodefony/http";
7
7
  import { OutgoingHttpHeaders } from "node:http";
8
8
  import { ReadStream } from "node:fs";
9
9
  import Eta from "../service/Eta.js";
10
+ import type { IFileServingConfig } from "../config/config.js";
10
11
  type ReadStreamOptions = {
11
12
  flags?: string;
12
13
  encoding?: BufferEncoding;
@@ -17,6 +18,17 @@ type ReadStreamOptions = {
17
18
  start?: number;
18
19
  end?: number;
19
20
  highWaterMark?: number;
21
+ /**
22
+ * `false` coupe les requêtes conditionnelles : ni `ETag`, ni
23
+ * `Last-Modified`, ni 304/412, ni `Cache-Control` par défaut, et
24
+ * `If-Range` n'autorise aucune reprise. Pour des fichiers dont la date
25
+ * n'est pas fiable (archive décompressée, build reproductible : même taille
26
+ * et même date pour deux contenus) — passer plutôt sa propre `ETag`
27
+ * (hachage du contenu) garde le 304. Défaut : `fileServing.validators` de
28
+ * la config du module (`true` d'usine) ; `true` ici le rétablit pour un
29
+ * appel quand la config l'a coupé.
30
+ */
31
+ validators?: boolean;
20
32
  };
21
33
  /**
22
34
  * Parse un header `Range` mono-plage en octets (RFC 9110 §14.1.2).
@@ -50,6 +62,13 @@ export declare function parseByteRange(range: string, length: number): {
50
62
  * dans `initialize()`, ou garde un état sur `this`.
51
63
  */
52
64
  export type ControllerScope = "request" | "singleton";
65
+ /**
66
+ * Fixe le défaut des fichiers servis (validateurs, `Cache-Control`) pour
67
+ * toute l'application. Appelé par le module au boot avec sa config validée.
68
+ *
69
+ * @param config - section `fileServing` validée
70
+ */
71
+ export declare function configureFileServing(config: IFileServingConfig): void;
53
72
  declare class Controller extends Service implements IController {
54
73
  #private;
55
74
  static prefix: string;
@@ -176,6 +195,13 @@ declare class Controller extends Service implements IController {
176
195
  */
177
196
  renderResponse(data: unknown, encoding?: BufferEncoding, status?: string | number, headers?: OutgoingHttpHeaders): MaybePromise<Http2Response | HttpResponse | WebsocketResponse>;
178
197
  renderView(path: string | FileClass, param?: Record<string, unknown>, status?: string | number, headers?: Record<string, string | number>): Promise<Http2Response | HttpResponse | WebsocketResponse>;
198
+ /**
199
+ * Expose le nonce CSP de la requête à la vue (`cspNonce`), lu seulement si
200
+ * le gabarit s'en sert : un accesseur, pas une valeur — une vue sans
201
+ * `<script>` ni `<style>` ne tire aucun nonce. Une valeur `cspNonce`
202
+ * fournie par l'action est gardée.
203
+ */
204
+ private withNonceLocal;
179
205
  /**
180
206
  * Injecte les helpers frontend (`frontendTags`/`frontendDocument`) dans les
181
207
  * locals du template (passés en data, pas de registre global de fonctions).
@@ -217,11 +243,61 @@ declare class Controller extends Service implements IController {
217
243
  * }
218
244
  * ```
219
245
  *
246
+ * Typé par le contrat temps réel de l'application, le flux lit la MÊME carte
247
+ * que la socket : `this.renderSse<ContractServerEmit<ChatContract>>()`, puis
248
+ * `sse.send(m, { event: "chat:room" })`. Sans paramètre de type, il reste
249
+ * libre — un flux hors contrat est valide.
250
+ *
251
+ * @typeParam Events - carte des événements du flux (défaut : libre).
220
252
  * @param options - battement de cœur, délai `retry:` initial.
221
253
  * @returns le flux, ou sa promesse quand une session est à sauver d'abord.
222
254
  * @throws HttpError 500 hors HTTP (WebSocket) ou quand la réponse est partie.
223
255
  */
224
- renderSse(options?: ISseStreamOptions): SseStream | Promise<SseStream>;
256
+ renderSse<Events extends EventsMap = DefaultEventsMap>(options?: ISseStreamOptions): SseStream<Events> | Promise<SseStream<Events>>;
257
+ /**
258
+ * Répond par un FLUX D'OCTETS : un `<video src>`, un `fetch()`, un
259
+ * téléchargement ou un outil tiers (curl) le consomment comme n'importe quelle
260
+ * réponse HTTP, une réponse par consommateur. Pour des ÉVÉNEMENTS texte lus
261
+ * par `EventSource`, voir {@link Controller.renderSse}. Pour UNE source
262
+ * d'octets diffusée à N lecteurs par la socket, publier un `Uint8Array` sur
263
+ * un canal temps réel (`@nodefony/realtime`), pas une réponse HTTP.
264
+ *
265
+ * La réponse est prise à l'appel ; les en-têtes partent au premier morceau.
266
+ * Une erreur de la source AVANT lui rend la page d'erreur ordinaire (500, ou
267
+ * le code d'une `HttpError`) ; APRÈS, le client voit une coupure. La source
268
+ * appartient au flux dès l'appel : détruite à la fin, quelle qu'en soit la
269
+ * raison, et détruite aussi quand l'appel lève (un flux web est annulé). La
270
+ * promesse ne rejette jamais : elle rend la raison de la fin et les octets
271
+ * envoyés.
272
+ *
273
+ * @example
274
+ * ```ts
275
+ * const ff = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
276
+ * try {
277
+ * await this.renderStream(ff.stdout, { headers: { "content-type": "video/mp4" } });
278
+ * } finally {
279
+ * ff.kill("SIGKILL"); // le flux est fini, quelle qu'en soit la raison
280
+ * }
281
+ * ```
282
+ *
283
+ * Relais d'un autre service — le signal coupe l'amont au départ du client ;
284
+ * ne recopier que `content-type` (`fetch` a déjà décompressé le corps) :
285
+ * ```ts
286
+ * const r = await fetch(url, { signal: this.context.signal });
287
+ * if (!r.ok || r.body === null) throw new HttpError(502);
288
+ * return this.renderStream(r.body, {
289
+ * headers: { "content-type": r.headers.get("content-type") ?? "application/octet-stream" },
290
+ * });
291
+ * ```
292
+ *
293
+ * @param source - flux Node, ou flux web ; jamais en mode objet.
294
+ * @param options - statut, en-têtes, `idleTimeout`, `stallTimeout`,
295
+ * `maxDuration`, `onClose(reason)`.
296
+ * @returns la raison de la fin et le nombre d'octets envoyés.
297
+ * @throws HttpError 500 hors HTTP (WebSocket) ou quand la réponse est partie ;
298
+ * 429 au-delà du plafond de connexions par IP. La source est alors détruite.
299
+ */
300
+ renderStream(source: ByteStreamSource, options?: IByteStreamOptions): Promise<IByteStreamResult>;
225
301
  /**
226
302
  * Pose la route de la requête courante sur l'instance (per-request).
227
303
  *
@@ -281,9 +357,10 @@ declare class Controller extends Service implements IController {
281
357
  * @param file - chemin ou `FileClass` du fichier à servir.
282
358
  * @param options - options de `createReadStream`.
283
359
  * @param headers - en-têtes ajoutés (écrasent ceux posés par défaut).
284
- * @returns le flux de lecture, une fois la réponse écoulée.
360
+ * @returns le flux de lecture, une fois la réponse écoulée — `null` quand
361
+ * les préconditions ont répondu 304 ou 412 (cf {@link Controller.streamFile}).
285
362
  */
286
- renderFileDownload(file: FileClass | string, options?: ReadStreamOptions, headers?: OutgoingHttpHeaders): Promise<ReadStream>;
363
+ renderFileDownload(file: FileClass | string, options?: ReadStreamOptions, headers?: OutgoingHttpHeaders): Promise<ReadStream | null>;
287
364
  /**
288
365
  * Envoie un fichier en FLUX, du disque vers la réponse, sans jamais le charger
289
366
  * en mémoire.
@@ -295,17 +372,38 @@ declare class Controller extends Service implements IController {
295
372
  * `Content-Range`). Annoncer `Accept-Ranges: bytes` depuis ici est un piège :
296
373
  * le client croit pouvoir se déplacer et reçoit tout le fichier.
297
374
  *
298
- * Ce que cette méthode garantit et qui justifie son existence : le NETTOYAGE.
299
- * Le flux est ouvert en `autoClose: false` ; un client qui raccroche en plein
300
- * téléchargement laisserait sinon un descripteur ouvert et une promesse pendue.
375
+ * Le cycle de vie est celui de toute réponse en flux
376
+ * ({@link Controller.renderStream}) : la réponse est prise à l'appel, les
377
+ * en-têtes partent au premier morceau, le flux est DÉTRUIT (descripteur
378
+ * fermé) quand le client raccroche. Un fichier illisible à l'ouverture
379
+ * (`EACCES`) rend la page d'erreur ordinaire, 500 ; une lecture qui échoue
380
+ * en cours de route coupe la connexion. Sans délai d'inactivité (un disque
381
+ * n'est pas une source muette).
382
+ *
383
+ * **Requêtes conditionnelles** (RFC 9110 §13) : le fichier ENTIER porte une
384
+ * `ETag` forte et un `Last-Modified`, tirés de sa taille et de sa date de
385
+ * modification ; `If-Match`, `If-Unmodified-Since`, `If-None-Match` et
386
+ * `If-Modified-Since` y sont évalués dans l'ordre de §13.2.2 → 304 ou 412,
387
+ * sans ouvrir le fichier. Une `ETag` ou un `Last-Modified` passés dans
388
+ * `headers` (ou déjà posés sur la réponse) l'emportent, et servent à la
389
+ * comparaison. `Cache-Control: private, no-cache` est posé si l'action n'en
390
+ * fixe aucun — en passer un dans `headers` le remplace. Un EXTRAIT
391
+ * (`start`/`end`) n'est pas la représentation entière : il ne reçoit ni
392
+ * validateur ni évaluation.
393
+ * `{ validators: false }` dans `options` coupe le tout — validateurs,
394
+ * évaluation et `Cache-Control` par défaut — pour des fichiers dont la date
395
+ * n'est pas fiable ; une `ETag` tirée du contenu garde, elle, le 304.
301
396
  *
302
397
  * @param file - chemin ou `FileClass` du fichier à servir.
303
398
  * @param headers - en-têtes ajoutés à la réponse (le type MIME est déduit si absent).
304
399
  * @param options - options de `createReadStream` (`start`/`end` pour un extrait).
305
- * @returns le flux de lecture, une fois la réponse écoulée.
306
- * @throws Si la réponse n'est pas disponible, ou si le fichier est illisible.
400
+ * @returns le flux de lecture, une fois la réponse écoulée — détruit, quelle
401
+ * que soit la fin ; `null` quand les préconditions ont répondu 304 ou 412 :
402
+ * aucun fichier n'a alors été ouvert.
403
+ * @throws HttpError 404 si le fichier n'existe pas ; 500 hors HTTP
404
+ * (WebSocket) ou quand la réponse est déjà partie.
307
405
  */
308
- streamFile(file: FileClass | string, headers?: OutgoingHttpHeaders, options?: ReadStreamOptions | undefined): Promise<ReadStream>;
309
- renderMediaStream(file: FileClass | string, headers?: OutgoingHttpHeaders, options?: ReadStreamOptions | undefined): Promise<HttpResponse | ReadStream | WebsocketResponse>;
406
+ streamFile(file: FileClass | string, headers?: OutgoingHttpHeaders, options?: ReadStreamOptions): Promise<ReadStream | null>;
407
+ renderMediaStream(file: FileClass | string, headers?: OutgoingHttpHeaders, options?: ReadStreamOptions | undefined): Promise<HttpResponse | ReadStream | WebsocketResponse | null>;
310
408
  }
311
409
  export default Controller;