@nodefony/framework 10.0.0-beta.4 → 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 (54) hide show
  1. package/README.md +15 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorateParam.js +7 -0
  4. package/dist/index.js +14 -10
  5. package/dist/nodefony/config/config.js +16 -2
  6. package/dist/nodefony/controller/AdminApiController.js +19 -6
  7. package/dist/nodefony/controller/BenchController.js +1 -1
  8. package/dist/nodefony/controller/CspReportController.js +70 -0
  9. package/dist/nodefony/controller/IssuerMetadataController.js +1 -1
  10. package/dist/nodefony/controller/LoginPageController.js +5 -7
  11. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -1
  12. package/dist/nodefony/controller/SessionAuthController.js +6 -7
  13. package/dist/nodefony/controller/TokenAuthController.js +3 -3
  14. package/dist/nodefony/decorators/routerDecorators.js +2 -2
  15. package/dist/nodefony/service/AdminBroker.js +1 -1
  16. package/dist/nodefony/service/IdempotencyStore.js +1 -1
  17. package/dist/nodefony/service/router.js +5 -4
  18. package/dist/nodefony/src/Controller.js +321 -114
  19. package/dist/nodefony/src/PlaygroundAdminApi.js +1 -1
  20. package/dist/nodefony/src/Resolver.js +7 -10
  21. package/dist/nodefony/src/ResourceController.js +11 -3
  22. package/dist/nodefony/src/SecurityApiController.js +30 -7
  23. package/dist/nodefony/src/conditionalRequest.js +103 -0
  24. package/dist/nodefony/src/docsReader.js +1 -1
  25. package/dist/nodefony/src/idempotency.js +1 -1
  26. package/dist/nodefony/src/loginPage.js +2 -1
  27. package/dist/nodefony/src/scopeCatalog.js +1 -1
  28. package/dist/nodefony/src/syslogFilters.js +6 -0
  29. package/dist/nodefony/src/viewNonce.js +58 -0
  30. package/dist/types/index.d.ts +3 -2
  31. package/dist/types/nodefony/config/config.d.ts +20 -0
  32. package/dist/types/nodefony/controller/AdminApiController.d.ts +5 -3
  33. package/dist/types/nodefony/controller/CspReportController.d.ts +35 -0
  34. package/dist/types/nodefony/controller/LoginPageController.d.ts +3 -2
  35. package/dist/types/nodefony/controller/SessionAuthController.d.ts +5 -6
  36. package/dist/types/nodefony/controller/TokenAuthController.d.ts +3 -3
  37. package/dist/types/nodefony/interfaces/IController.d.ts +2 -2
  38. package/dist/types/nodefony/service/router.d.ts +3 -2
  39. package/dist/types/nodefony/src/Controller.d.ts +119 -13
  40. package/dist/types/nodefony/src/Resolver.d.ts +9 -1
  41. package/dist/types/nodefony/src/ResourceController.d.ts +11 -3
  42. package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -4
  43. package/dist/types/nodefony/src/conditionalRequest.d.ts +82 -0
  44. package/dist/types/nodefony/src/loginPage.d.ts +1 -1
  45. package/dist/types/nodefony/src/syslogFilters.d.ts +6 -0
  46. package/dist/types/nodefony/src/viewNonce.d.ts +33 -0
  47. package/docs/admin.md +51 -2
  48. package/docs/controller.md +146 -27
  49. package/docs/decorateurs.md +8 -2
  50. package/docs/idempotence.md +22 -4
  51. package/docs/index.md +4 -2
  52. package/docs/routing.md +15 -1
  53. package/docs/templates.md +15 -0
  54. package/package.json +10 -9
@@ -1,12 +1,13 @@
1
- import { Service, Module, FileClass } from "nodefony";
2
- import type { IRequestUser, MaybePromise } from "nodefony";
1
+ import { Service, Module, type Pci, type Severity, type Msgid, type Message, FileClass } 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;
@@ -83,6 +102,14 @@ declare class Controller extends Service implements IController {
83
102
  */
84
103
  get context(): ContextType | undefined;
85
104
  set context(context: ContextType | undefined);
105
+ /**
106
+ * Journalise au nom du module du contrôleur, pas du noyau : `moduleName`
107
+ * explicite, sinon le module posé sur la requête (`context.logModule`),
108
+ * sinon le module propriétaire du contrôleur.
109
+ *
110
+ * @param moduleName - module qui signe CETTE ligne
111
+ */
112
+ log(pci: Pci, severity?: Severity, msgid?: Msgid, msg?: Message, moduleName?: string): import("nodefony").Pdu;
86
113
  /**
87
114
  * Route de l'appel en cours. Par requête : posée par le Resolver
88
115
  * (`setRoute`). Singleton : celle du résolveur de l'APPEL quand le pont
@@ -168,6 +195,13 @@ declare class Controller extends Service implements IController {
168
195
  */
169
196
  renderResponse(data: unknown, encoding?: BufferEncoding, status?: string | number, headers?: OutgoingHttpHeaders): MaybePromise<Http2Response | HttpResponse | WebsocketResponse>;
170
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;
171
205
  /**
172
206
  * Injecte les helpers frontend (`frontendTags`/`frontendDocument`) dans les
173
207
  * locals du template (passés en data, pas de registre global de fonctions).
@@ -209,11 +243,61 @@ declare class Controller extends Service implements IController {
209
243
  * }
210
244
  * ```
211
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).
212
252
  * @param options - battement de cœur, délai `retry:` initial.
213
253
  * @returns le flux, ou sa promesse quand une session est à sauver d'abord.
214
254
  * @throws HttpError 500 hors HTTP (WebSocket) ou quand la réponse est partie.
215
255
  */
216
- 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>;
217
301
  /**
218
302
  * Pose la route de la requête courante sur l'instance (per-request).
219
303
  *
@@ -273,9 +357,10 @@ declare class Controller extends Service implements IController {
273
357
  * @param file - chemin ou `FileClass` du fichier à servir.
274
358
  * @param options - options de `createReadStream`.
275
359
  * @param headers - en-têtes ajoutés (écrasent ceux posés par défaut).
276
- * @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}).
277
362
  */
278
- renderFileDownload(file: FileClass | string, options?: ReadStreamOptions, headers?: OutgoingHttpHeaders): Promise<ReadStream>;
363
+ renderFileDownload(file: FileClass | string, options?: ReadStreamOptions, headers?: OutgoingHttpHeaders): Promise<ReadStream | null>;
279
364
  /**
280
365
  * Envoie un fichier en FLUX, du disque vers la réponse, sans jamais le charger
281
366
  * en mémoire.
@@ -287,17 +372,38 @@ declare class Controller extends Service implements IController {
287
372
  * `Content-Range`). Annoncer `Accept-Ranges: bytes` depuis ici est un piège :
288
373
  * le client croit pouvoir se déplacer et reçoit tout le fichier.
289
374
  *
290
- * Ce que cette méthode garantit et qui justifie son existence : le NETTOYAGE.
291
- * Le flux est ouvert en `autoClose: false` ; un client qui raccroche en plein
292
- * 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.
293
396
  *
294
397
  * @param file - chemin ou `FileClass` du fichier à servir.
295
398
  * @param headers - en-têtes ajoutés à la réponse (le type MIME est déduit si absent).
296
399
  * @param options - options de `createReadStream` (`start`/`end` pour un extrait).
297
- * @returns le flux de lecture, une fois la réponse écoulée.
298
- * @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.
299
405
  */
300
- streamFile(file: FileClass | string, headers?: OutgoingHttpHeaders, options?: ReadStreamOptions | undefined): Promise<ReadStream>;
301
- 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>;
302
408
  }
303
409
  export default Controller;
@@ -37,6 +37,14 @@ declare class Resolver implements IResolver {
37
37
  resolve: boolean;
38
38
  variables: unknown[];
39
39
  exception?: HttpError | Error | null | undefined;
40
+ /**
41
+ * Ce que l'action a rendu quand le Resolver n'a rien pu envoyer — absent
42
+ * sinon. Lu par l'avertissement de développement
43
+ * (`IRouteResolver.unrendered`). `declare` : AUCUN champ émis — un Resolver
44
+ * naît à chaque requête, et la propriété n'apparaît que sur la branche qui
45
+ * attend déjà son délai d'expiration (`micro-resolver-field.mjs`).
46
+ */
47
+ unrendered?: string;
40
48
  acceptedProtocol: string | null;
41
49
  bypassFirewall: boolean;
42
50
  /**
@@ -248,7 +256,7 @@ declare class Resolver implements IResolver {
248
256
  * (`returnController`) — le chemin du **pont `api.request`** (WS) : la valeur
249
257
  * nue est enveloppée `{id, result}` par le peer, jamais rendue sur le
250
258
  * transport. La méthode HTTP logique d'une mutation du pont voyage dans
251
- * {@link methodOverride} ; sans porte ici, un rejeu `socket.mutate` (socket
259
+ * {@link methodOverride} ; sans porte ici, un rejeu `socket.post` (socket
252
260
  * qui reconnecte) ré-exécuterait la mutation (doublon — vécu au banc duplex).
253
261
  *
254
262
  * @returns `{ result }` — la valeur retournée par l'action (ou la réponse
@@ -67,7 +67,7 @@ export interface IResourceService<T = unknown> {
67
67
  delete?(criteria: Record<string, unknown>): Promise<number> | number;
68
68
  }
69
69
  /**
70
- * Controller de ressource **souverain** (V4.2 — POC API souveraine, Phase 2) :
70
+ * Controller de ressource **duplex** (V4.2 — une action, deux transports) :
71
71
  * la logique métier est écrite UNE fois dans le service de ressource, les
72
72
  * actions de la sous-classe ne sont que des portes (REST, WS-RPC `invoke`,
73
73
  * GraphQL à venir) qui retournent la **valeur brute** — `returnController`
@@ -102,8 +102,12 @@ export interface IResourceService<T = unknown> {
102
102
  * return this.listResource();
103
103
  * }
104
104
  * \@route("books-get", { path: "/{id}", requirements: { methods: ["GET", "WEBSOCKET"] } })
105
- * detail(\@Param("id") id: string) {
106
- * return this.getResource(id);
105
+ * async detail(\@Param("id") id: string) {
106
+ * // `null` rendu tel quel = « l'action répond elle-même » : la requête
107
+ * // pendrait jusqu'au délai d'expiration. La porte décide du 404.
108
+ * const found = await this.getResource(id);
109
+ * if (!found) throw new HttpError(`Book ${id} not found`, 404);
110
+ * return found;
107
111
  * }
108
112
  * }
109
113
  * ```
@@ -155,6 +159,10 @@ declare class ResourceController<T = unknown> extends Controller {
155
159
  /**
156
160
  * Lit une entité par id — `null` si absente (la porte décide du 404).
157
161
  *
162
+ * ⚠️ Ne pas rendre ce `null` tel quel depuis une action : pour le Resolver,
163
+ * `null` veut dire « l'action répond elle-même », et la requête attend
164
+ * alors son délai d'expiration. Lever un `HttpError` 404.
165
+ *
158
166
  * `options.relations` charge les associations dans la foulée. La porte doit
159
167
  * n'y laisser passer que des relations qu'elle a DÉCLARÉES : un `include`
160
168
  * libre laisse le client nommer n'importe quelle association, donc lire des
@@ -1,15 +1,28 @@
1
1
  import type { OutgoingHttpHeaders } from "node:http";
2
2
  import Controller from "./Controller.js";
3
+ /**
4
+ * En-têtes d'un 401 : ceux reçus, plus le défi par défaut
5
+ * ({@link SESSION_AUTH_CHALLENGE}) s'ils n'en portent aucun — RFC 9110 §15.5.2
6
+ * exige un `WWW-Authenticate` sur TOUT 401. Un défi posé par l'appelant (un
7
+ * `Bearer` de ressource protégée, par exemple) garde la main.
8
+ *
9
+ * Chemin froid : n'est appelé que pour un 401.
10
+ *
11
+ * @param headers - les en-têtes déjà prévus pour la réponse.
12
+ * @returns les mêmes en-têtes, défi compris.
13
+ */
14
+ export declare function withAuthChallenge(headers: Readonly<OutgoingHttpHeaders> | undefined): OutgoingHttpHeaders;
3
15
  /**
4
16
  * Base des contrôleurs d'authentification (`/nodefony/security/api/*`) : toute
5
- * réponse JSON part en `Cache-Control: no-store`.
17
+ * réponse JSON part en `Cache-Control: no-store`, et tout 401 porte un défi.
6
18
  *
7
19
  * Ces réponses portent une identité, un jeton, un secret affiché une seule
8
20
  * fois (clé API, secret TOTP, codes de récupération) ou leur refus. Un cookie
9
21
  * 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é.
22
+ * réponse (RFC 9111 §3) ; RFC 6749 §5.1 exige `no-store` sur un jeton. Un 401
23
+ * reçoit le défi par défaut ({@link withAuthChallenge}, RFC 9110 §15.5.2). Ces
24
+ * règles vivent ici, une fois : une action ajoutée à un contrôleur héritier ne
25
+ * peut pas les oublier. Un en-tête passé par l'action garde la priorité.
13
26
  */
14
27
  declare abstract class SecurityApiController extends Controller {
15
28
  renderJson(obj: unknown, status?: string | number, headers?: OutgoingHttpHeaders): ReturnType<Controller["renderJson"]>;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Requêtes conditionnelles sur une représentation servie par un contrôleur —
3
+ * validateurs (RFC 9110 §8.8) et évaluation des préconditions (§13).
4
+ *
5
+ * Fonctions PURES : l'instant courant est un paramètre, aucune lecture
6
+ * d'horloge ni d'en-tête n'a lieu ici. Le contrôleur rassemble les entrées,
7
+ * ce module rend le verdict.
8
+ */
9
+ /** Validateurs d'une représentation (RFC 9110 §8.8). */
10
+ export interface IValidators {
11
+ /** Étiquette d'entité, guillemets compris (`"…"` forte, `W/"…"` faible) ; `null` si aucune. */
12
+ readonly etag: string | null;
13
+ /**
14
+ * Dernière modification (ms depuis l'époque), à la seconde — la précision
15
+ * d'une date HTTP — et jamais postérieure à l'instant de la réponse
16
+ * (§8.8.2.1) ; `null` si aucune.
17
+ */
18
+ readonly lastModified: number | null;
19
+ /**
20
+ * La date est-elle un validateur FORT (§8.8.2.2) : modifiée au moins une
21
+ * seconde avant la réponse, elle ne peut plus changer dans la même seconde.
22
+ * Seul un validateur fort autorise une reprise par `If-Range` (§13.1.5).
23
+ */
24
+ readonly lastModifiedStrong: boolean;
25
+ }
26
+ /** Issue des préconditions : servir (200), rien n'a changé (304), condition fausse (412). */
27
+ export type PreconditionOutcome = 200 | 304 | 412;
28
+ /** En-têtes de requête lus par l'évaluation (noms en minuscules, comme Node les rend). */
29
+ export interface IConditionalHeaders {
30
+ readonly "if-match"?: string | undefined;
31
+ readonly "if-none-match"?: string | undefined;
32
+ readonly "if-modified-since"?: string | undefined;
33
+ readonly "if-unmodified-since"?: string | undefined;
34
+ readonly "if-range"?: string | undefined;
35
+ }
36
+ /**
37
+ * Validateurs d'un fichier, tirés de ses métadonnées sans le lire.
38
+ *
39
+ * L'`ETag` est FORTE : taille et date de modification à la microseconde, deux
40
+ * grandeurs qu'une écriture change. C'est la forme que posent les serveurs de
41
+ * fichiers courants, et la seule qui autorise une reprise (§13.1.5).
42
+ *
43
+ * @param stats - taille et date de modification du fichier
44
+ * @param now - instant de la réponse (ms)
45
+ * @returns les validateurs du fichier
46
+ */
47
+ export declare function fileValidators(stats: {
48
+ readonly size: number;
49
+ readonly mtimeMs: number;
50
+ }, now: number): IValidators;
51
+ /**
52
+ * Date HTTP (RFC 9110 §5.6.7) lue en ms, ou `null` si la valeur n'en est pas
53
+ * une — une condition sur une date invalide DOIT être ignorée (§13.1.3, §13.1.4).
54
+ *
55
+ * @param value - valeur brute de l'en-tête
56
+ */
57
+ export declare function parseHttpDate(value: string | undefined): number | null;
58
+ /**
59
+ * Évalue les préconditions d'une requête sur une représentation qui EXISTE,
60
+ * dans l'ordre de RFC 9110 §13.2.2 — `If-Match`, sinon `If-Unmodified-Since` ;
61
+ * puis `If-None-Match`, sinon `If-Modified-Since`. `Range` et `If-Range`
62
+ * viennent après, s'il faut servir (voir {@link ifRangeHolds}).
63
+ *
64
+ * @param method - méthode de la requête
65
+ * @param headers - en-têtes de la requête
66
+ * @param validators - validateurs de la représentation
67
+ * @returns 200 pour servir, 304 si rien n'a changé (GET et HEAD seulement),
68
+ * 412 si une condition est fausse
69
+ */
70
+ export declare function evaluatePreconditions(method: string, headers: IConditionalHeaders, validators: IValidators): PreconditionOutcome;
71
+ /**
72
+ * Une condition `If-Range` tient-elle (RFC 9110 §13.1.5) ? Vraie, la plage
73
+ * demandée est servie (206) ; fausse, `Range` est ignoré et la représentation
74
+ * part entière (200) — un client ne recolle jamais deux versions.
75
+ *
76
+ * Une étiquette tient si elle est FORTEMENT égale ; une date, si elle égale
77
+ * exactement `Last-Modified` ET que celle-ci est un validateur fort.
78
+ *
79
+ * @param value - valeur brute de `If-Range`
80
+ * @param validators - validateurs de la représentation
81
+ */
82
+ export declare function ifRangeHolds(value: string, validators: IValidators): boolean;
@@ -141,4 +141,4 @@ export declare function buildLoginPageView(page: ILoginPageDescription, request:
141
141
  * d'empreinte (`i-finger`) reprend `IconFingerprint` de Tabler Icons (MIT),
142
142
  * celle du bouton de Studio : les deux écrans montrent le même geste.
143
143
  */
144
- export declare const LOGIN_PAGE_TEMPLATE = "<!doctype html>\n<html lang=\"fr\"<% if (it.theme) { %> data-theme=\"<%= it.theme %>\"<% } %>>\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"color-scheme\" content=\"dark light\" />\n <meta name=\"robots\" content=\"noindex\" />\n <title><%= it.title %> \u2014 <%= it.brand %></title>\n <link rel=\"icon\" href=\"<%= it.logoHref %>\" type=\"image/svg+xml\" />\n<% if (!it.theme) { %>\n <script nonce=\"<%= it.nonce %>\">try{var t=localStorage.getItem(\"nf-login-theme\");if(t===\"light\"||t===\"dark\")document.documentElement.dataset.theme=t}catch(e){}</script>\n<% } %>\n <link rel=\"stylesheet\" href=\"<%= it.styleHref %>\" />\n<% if (it.skinHref) { %>\n <link rel=\"stylesheet\" href=\"<%= it.skinHref %>\" />\n<% } %>\n<% if (it.stylesheetHref) { %>\n <link rel=\"stylesheet\" href=\"<%= it.stylesheetHref %>\" />\n<% } %>\n <script type=\"module\" src=\"<%= it.scriptHref %>\" nonce=\"<%= it.nonce %>\"></script>\n </head>\n <body data-skin=\"<%= it.skin %>\" data-layout=\"<%= it.layout %>\"<% if (it.backdrop) { %> data-backdrop<% } %><% if (it.ssoFirst) { %> data-sso-first<% } %>>\n <svg width=\"0\" height=\"0\" style=\"position: absolute\" aria-hidden=\"true\">\n <symbol id=\"i-user\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"8\" r=\"3.5\" /><path d=\"M5.5 20a6.5 6.5 0 0 1 13 0\" /></symbol>\n <symbol id=\"i-lock\" viewBox=\"0 0 24 24\"><rect x=\"5\" y=\"10.5\" width=\"14\" height=\"10\" rx=\"2\" /><path d=\"M8.5 10.5V7.5a3.5 3.5 0 0 1 7 0v3\" /></symbol>\n <symbol id=\"i-eye\" viewBox=\"0 0 24 24\"><path d=\"M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z\" /><circle cx=\"12\" cy=\"12\" r=\"2.8\" /></symbol>\n <symbol id=\"i-arrow\" viewBox=\"0 0 24 24\"><path d=\"M5 12h14M13 6l6 6-6 6\" /></symbol>\n <symbol id=\"i-key\" viewBox=\"0 0 24 24\"><circle cx=\"8\" cy=\"15\" r=\"3.8\" /><path d=\"M10.8 12.2 19.5 3.5M16.5 6.5l2.5 2.5M14 9l2 2\" /></symbol>\n <symbol id=\"i-check\" viewBox=\"0 0 24 24\"><path d=\"M5 12.5l4.5 4.5L19 7.5\" /></symbol>\n <symbol id=\"i-finger\" viewBox=\"0 0 24 24\"><path d=\"M18.9 7a8 8 0 0 1 1.1 5v1a6 6 0 0 0 .8 3\" /><path d=\"M8 11a4 4 0 0 1 8 0v1a10 10 0 0 0 2 6\" /><path d=\"M12 11v2a14 14 0 0 0 2.5 8\" /><path d=\"M8 15a18 18 0 0 0 1.8 6\" /><path d=\"M4.9 19a22 22 0 0 1-.9-7v-1a8 8 0 0 1 12-6.95\" /></symbol>\n <symbol id=\"i-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4\" /><path d=\"M12 2.5v2M12 19.5v2M4.6 4.6l1.4 1.4M18 18l1.4 1.4M2.5 12h2M19.5 12h2M4.6 19.4 6 18M18 6l1.4-1.4\" /></symbol>\n <symbol id=\"i-moon\" viewBox=\"0 0 24 24\"><path d=\"M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5Z\" /></symbol>\n <symbol id=\"i-shield\" viewBox=\"0 0 24 24\"><path d=\"M12 3l7.5 3v5.5c0 4.6-3.2 8-7.5 9.5-4.3-1.5-7.5-4.9-7.5-9.5V6z\" /></symbol>\n <symbol id=\"m-github\" viewBox=\"0 0 16 16\"><path d=\"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z\" /></symbol>\n </svg>\n <header class=\"nf-strip\">\n <div class=\"nf-strip-brand\"><img class=\"nf-logo\" src=\"<%= it.logo %>\" alt=\"\" /><%= it.brand %></div>\n </header>\n <div class=\"nf-layout\">\n <aside class=\"nf-hero\" aria-hidden=\"true\">\n <div class=\"nf-hero-brand\"><img class=\"nf-logo\" src=\"<%= it.logo %>\" alt=\"\" /><%= it.brand %></div>\n<% if (it.hero === null) { %>\n <div class=\"nf-home\">\n <img\n class=\"nf-home-logo\"\n src=\"<%= it.logoHref %>\"\n alt=\"\"\n />\n <h2>Le framework Node.js fullstack</h2>\n <p class=\"sub\">\n Temps r\u00E9el natif \u00B7 d\u00E9veloppement agentic-ready \u00B7 socle TypeScript\n isomorphe\n </p>\n <p class=\"sig\">\n Une action de contr\u00F4leur. Deux transports. La m\u00EAme session, la m\u00EAme\n s\u00E9curit\u00E9, le m\u00EAme code.\n </p>\n <ul class=\"badges\">\n <li class=\"agentic\">Agentic ready</li>\n <li>Node.js \u2265 24.14</li>\n <li>TypeScript strict</li>\n <li>ESM only</li>\n <li>Licence Apache 2.0</li>\n </ul>\n <div\n class=\"install\"\n role=\"img\"\n aria-label=\"Commande d'installation : npx nodefony create app\"\n >\n <span class=\"p\">$</span><span>npx nodefony create app</span>\n </div>\n <div class=\"features\">\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <polygon\n points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"\n /></svg\n ></span>\n <h3>Temps r\u00E9el natif</h3>\n <p>\n Le WebSocket n'est pas un ajout : m\u00EAme pipeline, m\u00EAme table de\n routes, m\u00EAme s\u00E9curit\u00E9 que le HTTP.\n </p>\n </div>\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\" />\n <path d=\"m9 12 2 2 4-4\" /></svg\n ></span>\n <h3>S\u00E9curit\u00E9 par conception</h3>\n <p>\n Pare-feu applicatif par zones, Zero Trust, CSRF Fetch Metadata,\n sessions hybrides, WebAuthn.\n </p>\n </div>\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\" />\n <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\" />\n <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\" />\n <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\" /></svg\n ></span>\n <h3>DI & modules</h3>\n <p>\n Injection de d\u00E9pendances par d\u00E9corateurs, modules autonomes,\n configuration valid\u00E9e au boot.\n </p>\n </div>\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <path\n d=\"M3 12h18M12 3c2.5 2.6 4 5.7 4 9s-1.5 6.4-4 9c-2.5-2.6-4-5.7-4-9s1.5-6.4 4-9z\"\n /></svg\n ></span>\n <h3>Socle isomorphe</h3>\n <p>\n Le m\u00EAme paquet c\u00F4t\u00E9 serveur et c\u00F4t\u00E9 navigateur : client temps\n r\u00E9el, r\u00E8gles et types \u00E9crits une fois.\n </p>\n </div>\n </div>\n </div>\n <small>Propuls\u00E9 par Nodefony</small>\n<% } else if (it.hero) { %>\n <div class=\"nf-home\">\n <h2><%= it.hero.heading %></h2>\n<% if (it.hero.text) { %>\n <p class=\"sub\"><%= it.hero.text %></p>\n<% } %>\n </div>\n<% } %>\n </aside>\n <main class=\"nf-stage\" data-nf-login data-from=\"<%= it.from %>\">\n <button type=\"button\" class=\"nf-theme\" data-theme-toggle aria-label=\"Basculer entre th\u00E8me clair et th\u00E8me sombre\"><svg class=\"nf-icon t-sun\" aria-hidden=\"true\"><use href=\"#i-sun\" /></svg><svg class=\"nf-icon t-moon\" aria-hidden=\"true\"><use href=\"#i-moon\" /></svg></button>\n <section class=\"nf-card\" aria-labelledby=\"nf-title\">\n <div class=\"nf-head\">\n <div class=\"nf-brand\"><img class=\"nf-logo\" src=\"<%= it.logo %>\" alt=\"\" /><%= it.brand %></div>\n <h1 id=\"nf-title\"><%= it.title %></h1>\n <p class=\"nf-sub\" id=\"nf-sub\"><%= it.lead %></p>\n </div>\n <div class=\"nf-body\">\n<% if (it.password) { %>\n <ol class=\"nf-steps\" aria-label=\"\u00C9tapes\">\n <li data-tab=\"identifier\" aria-current=\"step\">Identifiant</li>\n <li data-tab=\"password\">Mot de passe</li>\n <li data-tab=\"mfa\" hidden>V\u00E9rification</li>\n </ol>\n <form data-step=\"identifier\">\n <div class=\"nf-field\">\n <label for=\"nf-username\">Identifiant ou adresse e-mail</label>\n <div class=\"nf-input\">\n <svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-user\" /></svg>\n <input id=\"nf-username\" name=\"username\" autocomplete=\"username\" autocapitalize=\"none\" spellcheck=\"false\" required />\n </div>\n </div>\n <button class=\"nf-btn primary\" type=\"submit\">Continuer<svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-arrow\" /></svg></button>\n </form>\n <form data-step=\"password\" hidden>\n <!-- Compte de l'\u00E9tape pr\u00E9c\u00E9dente, masqu\u00E9 : le gestionnaire de mots de passe\n associe le mot de passe \u00E0 CE compte (HTML, jeton \u00AB username \u00BB). -->\n <input name=\"username\" autocomplete=\"username\" hidden readonly tabindex=\"-1\" />\n <div class=\"nf-account\">\n <span class=\"nf-avatar\" aria-hidden=\"true\"></span><span class=\"who\"></span>\n <button type=\"button\" class=\"nf-link\" data-go=\"identifier\">Changer</button>\n </div>\n <div class=\"nf-field\">\n <div class=\"nf-label-row\"><label for=\"nf-password\">Mot de passe</label></div>\n <div class=\"nf-input\">\n <svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-lock\" /></svg>\n <input id=\"nf-password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" required />\n <button type=\"button\" class=\"nf-reveal\" data-reveal aria-label=\"Afficher le mot de passe\" aria-pressed=\"false\"><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-eye\" /></svg></button>\n </div>\n </div>\n <button class=\"nf-btn primary\" type=\"submit\">Se connecter<svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-arrow\" /></svg></button>\n </form>\n <form data-step=\"mfa\" hidden>\n <div class=\"nf-field\">\n <label id=\"nf-otp-label\">Code \u00E0 6 chiffres</label>\n <div class=\"nf-otp\" role=\"group\" aria-labelledby=\"nf-otp-label\">\n <input inputmode=\"numeric\" maxlength=\"1\" autocomplete=\"one-time-code\" aria-label=\"Chiffre 1\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 2\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 3\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 4\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 5\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 6\" />\n </div>\n </div>\n <p class=\"nf-hint\">Le code affich\u00E9 par votre application d'authentification.</p>\n <button class=\"nf-btn primary\" type=\"submit\">V\u00E9rifier</button>\n </form>\n<% } %>\n<% if (it.password || it.passkey) { %>\n <div data-step=\"authenticated\" hidden>\n <div class=\"nf-done\" role=\"status\">\n <span class=\"ring\" aria-hidden=\"true\"><svg class=\"nf-icon\"><use href=\"#i-check\" /></svg></span>\n <strong>Session ouverte</strong><span class=\"dest\"></span>\n <span class=\"nf-progress\" aria-hidden=\"true\"><span></span></span>\n </div>\n </div>\n<% } %>\n<% if (it.providers.length > 0 || it.passkey) { %>\n <div data-alt>\n<% if (it.password) { %>\n <div class=\"nf-or\">ou</div>\n<% } %>\n <div class=\"nf-alt\">\n<% if (it.passkey) { %>\n <button type=\"button\" class=\"nf-btn span\" data-passkey hidden><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-finger\" /></svg>Passkey / empreinte digitale</button>\n<% } %>\n<% it.providers.forEach(function (provider) { %>\n <a class=\"nf-btn\" href=\"<%= provider.href %>\"><% if (provider.image) { %><img class=\"nf-icon\" src=\"<%= provider.image %>\" alt=\"\" /><% } else { %><svg class=\"<%= provider.icon === 'm-github' ? 'nf-mark' : 'nf-icon' %>\" aria-hidden=\"true\"><use href=\"#<%= provider.icon %>\" /></svg><% } %><% if (provider.caption) { %><span class=\"nf-label\"><%= provider.label %><small><%= provider.caption %></small></span><% } else { %><%= provider.label %><% } %></a>\n<% }) %>\n </div>\n </div>\n<% } %>\n <div class=\"nf-message\" aria-live=\"polite\" data-message><% if (it.empty) { %><p class=\"nf-alert\" role=\"alert\"><span class=\"code\">\u2014</span><span>Aucun moyen de connexion n'est disponible pour le moment. Pr\u00E9venez l'administrateur du site.</span></p><% } else if (it.notice) { %><p class=\"nf-alert\" role=\"alert\"><span class=\"code\">SSO</span><span><%= it.notice %></span></p><% } %></div>\n<% if (it.password || it.passkey) { %>\n <noscript><p class=\"nf-hint\">Cette page a besoin de JavaScript pour vous connecter.</p></noscript>\n<% } %>\n </div>\n </section>\n<% if (it.picker) { %>\n <form class=\"nf-picker\" method=\"get\" data-preview-picker<% if (it.picker.hook) { %> data-preview-hook=\"<%= it.picker.hook %>\"<% } %> aria-label=\"Aper\u00E7u de la page (d\u00E9veloppement)\">\n <input type=\"hidden\" name=\"from\" value=\"<%= it.from %>\" />\n <label>Habillage <select name=\"skin\"><% it.picker.skins.forEach(function (skin) { %><option value=\"<%= skin %>\"<% if (skin === it.skin) { %> selected<% } %>><%= skin %></option><% }) %></select></label>\n <label>Mise en page <select name=\"layout\"><% it.picker.layouts.forEach(function (layout) { %><option value=\"<%= layout %>\"<% if (layout === it.layout) { %> selected<% } %>><%= layout %></option><% }) %></select></label>\n <noscript><button type=\"submit\">Appliquer</button></noscript>\n </form>\n<% } %>\n<% if (it.footer) { %>\n <div class=\"nf-foot\">\n <ul class=\"nf-facts\" aria-label=\"Protection de la session\">\n <li><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-shield\" /></svg>cookie HttpOnly</li>\n <li><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-shield\" /></svg>anti-CSRF</li>\n <li><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-shield\" /></svg>CSP stricte</li>\n </ul>\n <a class=\"nf-powered\" href=\"https://github.com/nodefony/nodefony-core\" rel=\"noopener noreferrer\"><img class=\"nf-logo nf-logo-mono\" src=\"<%= it.logoHref %>\" alt=\"\" />Propuls\u00E9 par Nodefony</a>\n </div>\n<% } %>\n </main>\n </div>\n </body>\n</html>\n";
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 <% if (it.nonce) { %><meta property=\"csp-nonce\" nonce=\"<%= it.nonce %>\"><% } %>\n <title><%= it.title %> \u2014 <%= it.brand %></title>\n <link rel=\"icon\" href=\"<%= it.logoHref %>\" type=\"image/svg+xml\" />\n<% if (!it.theme) { %>\n <script nonce=\"<%= it.nonce %>\">try{var t=localStorage.getItem(\"nf-login-theme\");if(t===\"light\"||t===\"dark\")document.documentElement.dataset.theme=t}catch(e){}</script>\n<% } %>\n <link rel=\"stylesheet\" href=\"<%= it.styleHref %>\" />\n<% if (it.skinHref) { %>\n <link rel=\"stylesheet\" href=\"<%= it.skinHref %>\" />\n<% } %>\n<% if (it.stylesheetHref) { %>\n <link rel=\"stylesheet\" href=\"<%= it.stylesheetHref %>\" />\n<% } %>\n <script type=\"module\" src=\"<%= it.scriptHref %>\" nonce=\"<%= it.nonce %>\"></script>\n </head>\n <body data-skin=\"<%= it.skin %>\" data-layout=\"<%= it.layout %>\"<% if (it.backdrop) { %> data-backdrop<% } %><% if (it.ssoFirst) { %> data-sso-first<% } %>>\n <svg width=\"0\" height=\"0\" style=\"position: absolute\" aria-hidden=\"true\">\n <symbol id=\"i-user\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"8\" r=\"3.5\" /><path d=\"M5.5 20a6.5 6.5 0 0 1 13 0\" /></symbol>\n <symbol id=\"i-lock\" viewBox=\"0 0 24 24\"><rect x=\"5\" y=\"10.5\" width=\"14\" height=\"10\" rx=\"2\" /><path d=\"M8.5 10.5V7.5a3.5 3.5 0 0 1 7 0v3\" /></symbol>\n <symbol id=\"i-eye\" viewBox=\"0 0 24 24\"><path d=\"M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z\" /><circle cx=\"12\" cy=\"12\" r=\"2.8\" /></symbol>\n <symbol id=\"i-arrow\" viewBox=\"0 0 24 24\"><path d=\"M5 12h14M13 6l6 6-6 6\" /></symbol>\n <symbol id=\"i-key\" viewBox=\"0 0 24 24\"><circle cx=\"8\" cy=\"15\" r=\"3.8\" /><path d=\"M10.8 12.2 19.5 3.5M16.5 6.5l2.5 2.5M14 9l2 2\" /></symbol>\n <symbol id=\"i-check\" viewBox=\"0 0 24 24\"><path d=\"M5 12.5l4.5 4.5L19 7.5\" /></symbol>\n <symbol id=\"i-finger\" viewBox=\"0 0 24 24\"><path d=\"M18.9 7a8 8 0 0 1 1.1 5v1a6 6 0 0 0 .8 3\" /><path d=\"M8 11a4 4 0 0 1 8 0v1a10 10 0 0 0 2 6\" /><path d=\"M12 11v2a14 14 0 0 0 2.5 8\" /><path d=\"M8 15a18 18 0 0 0 1.8 6\" /><path d=\"M4.9 19a22 22 0 0 1-.9-7v-1a8 8 0 0 1 12-6.95\" /></symbol>\n <symbol id=\"i-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4\" /><path d=\"M12 2.5v2M12 19.5v2M4.6 4.6l1.4 1.4M18 18l1.4 1.4M2.5 12h2M19.5 12h2M4.6 19.4 6 18M18 6l1.4-1.4\" /></symbol>\n <symbol id=\"i-moon\" viewBox=\"0 0 24 24\"><path d=\"M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5Z\" /></symbol>\n <symbol id=\"i-shield\" viewBox=\"0 0 24 24\"><path d=\"M12 3l7.5 3v5.5c0 4.6-3.2 8-7.5 9.5-4.3-1.5-7.5-4.9-7.5-9.5V6z\" /></symbol>\n <symbol id=\"m-github\" viewBox=\"0 0 16 16\"><path d=\"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z\" /></symbol>\n </svg>\n <header class=\"nf-strip\">\n <div class=\"nf-strip-brand\"><img class=\"nf-logo\" src=\"<%= it.logo %>\" alt=\"\" /><%= it.brand %></div>\n </header>\n <div class=\"nf-layout\">\n <aside class=\"nf-hero\" aria-hidden=\"true\">\n <div class=\"nf-hero-brand\"><img class=\"nf-logo\" src=\"<%= it.logo %>\" alt=\"\" /><%= it.brand %></div>\n<% if (it.hero === null) { %>\n <div class=\"nf-home\">\n <img\n class=\"nf-home-logo\"\n src=\"<%= it.logoHref %>\"\n alt=\"\"\n />\n <h2>Le framework Node.js fullstack</h2>\n <p class=\"sub\">\n Temps r\u00E9el natif \u00B7 d\u00E9veloppement agentic-ready \u00B7 socle TypeScript\n isomorphe\n </p>\n <p class=\"sig\">\n Une action de contr\u00F4leur. Deux transports. La m\u00EAme session, la m\u00EAme\n s\u00E9curit\u00E9, le m\u00EAme code.\n </p>\n <ul class=\"badges\">\n <li class=\"agentic\">Agentic ready</li>\n <li>Node.js \u2265 24.14</li>\n <li>TypeScript strict</li>\n <li>ESM only</li>\n <li>Licence Apache 2.0</li>\n </ul>\n <div\n class=\"install\"\n role=\"img\"\n aria-label=\"Commande d'installation : npx nodefony create app\"\n >\n <span class=\"p\">$</span><span>npx nodefony create app</span>\n </div>\n <div class=\"features\">\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <polygon\n points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"\n /></svg\n ></span>\n <h3>Temps r\u00E9el natif</h3>\n <p>\n Le WebSocket n'est pas un ajout : m\u00EAme pipeline, m\u00EAme table de\n routes, m\u00EAme s\u00E9curit\u00E9 que le HTTP.\n </p>\n </div>\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\" />\n <path d=\"m9 12 2 2 4-4\" /></svg\n ></span>\n <h3>S\u00E9curit\u00E9 par conception</h3>\n <p>\n Pare-feu applicatif par zones, Zero Trust, CSRF Fetch Metadata,\n sessions hybrides, WebAuthn.\n </p>\n </div>\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\" />\n <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\" />\n <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\" />\n <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\" /></svg\n ></span>\n <h3>DI & modules</h3>\n <p>\n Injection de d\u00E9pendances par d\u00E9corateurs, modules autonomes,\n configuration valid\u00E9e au boot.\n </p>\n </div>\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <path\n d=\"M3 12h18M12 3c2.5 2.6 4 5.7 4 9s-1.5 6.4-4 9c-2.5-2.6-4-5.7-4-9s1.5-6.4 4-9z\"\n /></svg\n ></span>\n <h3>Socle isomorphe</h3>\n <p>\n Le m\u00EAme paquet c\u00F4t\u00E9 serveur et c\u00F4t\u00E9 navigateur : client temps\n r\u00E9el, r\u00E8gles et types \u00E9crits une fois.\n </p>\n </div>\n </div>\n </div>\n <small>Propuls\u00E9 par Nodefony</small>\n<% } else if (it.hero) { %>\n <div class=\"nf-home\">\n <h2><%= it.hero.heading %></h2>\n<% if (it.hero.text) { %>\n <p class=\"sub\"><%= it.hero.text %></p>\n<% } %>\n </div>\n<% } %>\n </aside>\n <main class=\"nf-stage\" data-nf-login data-from=\"<%= it.from %>\">\n <button type=\"button\" class=\"nf-theme\" data-theme-toggle aria-label=\"Basculer entre th\u00E8me clair et th\u00E8me sombre\"><svg class=\"nf-icon t-sun\" aria-hidden=\"true\"><use href=\"#i-sun\" /></svg><svg class=\"nf-icon t-moon\" aria-hidden=\"true\"><use href=\"#i-moon\" /></svg></button>\n <section class=\"nf-card\" aria-labelledby=\"nf-title\">\n <div class=\"nf-head\">\n <div class=\"nf-brand\"><img class=\"nf-logo\" src=\"<%= it.logo %>\" alt=\"\" /><%= it.brand %></div>\n <h1 id=\"nf-title\"><%= it.title %></h1>\n <p class=\"nf-sub\" id=\"nf-sub\"><%= it.lead %></p>\n </div>\n <div class=\"nf-body\">\n<% if (it.password) { %>\n <ol class=\"nf-steps\" aria-label=\"\u00C9tapes\">\n <li data-tab=\"identifier\" aria-current=\"step\">Identifiant</li>\n <li data-tab=\"password\">Mot de passe</li>\n <li data-tab=\"mfa\" hidden>V\u00E9rification</li>\n </ol>\n <form data-step=\"identifier\">\n <div class=\"nf-field\">\n <label for=\"nf-username\">Identifiant ou adresse e-mail</label>\n <div class=\"nf-input\">\n <svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-user\" /></svg>\n <input id=\"nf-username\" name=\"username\" autocomplete=\"username\" autocapitalize=\"none\" spellcheck=\"false\" required />\n </div>\n </div>\n <button class=\"nf-btn primary\" type=\"submit\">Continuer<svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-arrow\" /></svg></button>\n </form>\n <form data-step=\"password\" hidden>\n <!-- Compte de l'\u00E9tape pr\u00E9c\u00E9dente, masqu\u00E9 : le gestionnaire de mots de passe\n associe le mot de passe \u00E0 CE compte (HTML, jeton \u00AB username \u00BB). -->\n <input name=\"username\" autocomplete=\"username\" hidden readonly tabindex=\"-1\" />\n <div class=\"nf-account\">\n <span class=\"nf-avatar\" aria-hidden=\"true\"></span><span class=\"who\"></span>\n <button type=\"button\" class=\"nf-link\" data-go=\"identifier\">Changer</button>\n </div>\n <div class=\"nf-field\">\n <div class=\"nf-label-row\"><label for=\"nf-password\">Mot de passe</label></div>\n <div class=\"nf-input\">\n <svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-lock\" /></svg>\n <input id=\"nf-password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" required />\n <button type=\"button\" class=\"nf-reveal\" data-reveal aria-label=\"Afficher le mot de passe\" aria-pressed=\"false\"><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-eye\" /></svg></button>\n </div>\n </div>\n <button class=\"nf-btn primary\" type=\"submit\">Se connecter<svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-arrow\" /></svg></button>\n </form>\n <form data-step=\"mfa\" hidden>\n <div class=\"nf-field\">\n <label id=\"nf-otp-label\">Code \u00E0 6 chiffres</label>\n <div class=\"nf-otp\" role=\"group\" aria-labelledby=\"nf-otp-label\">\n <input inputmode=\"numeric\" maxlength=\"1\" autocomplete=\"one-time-code\" aria-label=\"Chiffre 1\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 2\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 3\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 4\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 5\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 6\" />\n </div>\n </div>\n <p class=\"nf-hint\">Le code affich\u00E9 par votre application d'authentification.</p>\n <button class=\"nf-btn primary\" type=\"submit\">V\u00E9rifier</button>\n </form>\n<% } %>\n<% if (it.password || it.passkey) { %>\n <div data-step=\"authenticated\" hidden>\n <div class=\"nf-done\" role=\"status\">\n <span class=\"ring\" aria-hidden=\"true\"><svg class=\"nf-icon\"><use href=\"#i-check\" /></svg></span>\n <strong>Session ouverte</strong><span class=\"dest\"></span>\n <span class=\"nf-progress\" aria-hidden=\"true\"><span></span></span>\n </div>\n </div>\n<% } %>\n<% if (it.providers.length > 0 || it.passkey) { %>\n <div data-alt>\n<% if (it.password) { %>\n <div class=\"nf-or\">ou</div>\n<% } %>\n <div class=\"nf-alt\">\n<% if (it.passkey) { %>\n <button type=\"button\" class=\"nf-btn span\" data-passkey hidden><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-finger\" /></svg>Passkey / empreinte digitale</button>\n<% } %>\n<% it.providers.forEach(function (provider) { %>\n <a class=\"nf-btn\" href=\"<%= provider.href %>\"><% if (provider.image) { %><img class=\"nf-icon\" src=\"<%= provider.image %>\" alt=\"\" /><% } else { %><svg class=\"<%= provider.icon === 'm-github' ? 'nf-mark' : 'nf-icon' %>\" aria-hidden=\"true\"><use href=\"#<%= provider.icon %>\" /></svg><% } %><% if (provider.caption) { %><span class=\"nf-label\"><%= provider.label %><small><%= provider.caption %></small></span><% } else { %><%= provider.label %><% } %></a>\n<% }) %>\n </div>\n </div>\n<% } %>\n <div class=\"nf-message\" aria-live=\"polite\" data-message><% if (it.empty) { %><p class=\"nf-alert\" role=\"alert\"><span class=\"code\">\u2014</span><span>Aucun moyen de connexion n'est disponible pour le moment. Pr\u00E9venez l'administrateur du site.</span></p><% } else if (it.notice) { %><p class=\"nf-alert\" role=\"alert\"><span class=\"code\">SSO</span><span><%= it.notice %></span></p><% } %></div>\n<% if (it.password || it.passkey) { %>\n <noscript><p class=\"nf-hint\">Cette page a besoin de JavaScript pour vous connecter.</p></noscript>\n<% } %>\n </div>\n </section>\n<% if (it.picker) { %>\n <form class=\"nf-picker\" method=\"get\" data-preview-picker<% if (it.picker.hook) { %> data-preview-hook=\"<%= it.picker.hook %>\"<% } %> aria-label=\"Aper\u00E7u de la page (d\u00E9veloppement)\">\n <input type=\"hidden\" name=\"from\" value=\"<%= it.from %>\" />\n <label>Habillage <select name=\"skin\"><% it.picker.skins.forEach(function (skin) { %><option value=\"<%= skin %>\"<% if (skin === it.skin) { %> selected<% } %>><%= skin %></option><% }) %></select></label>\n <label>Mise en page <select name=\"layout\"><% it.picker.layouts.forEach(function (layout) { %><option value=\"<%= layout %>\"<% if (layout === it.layout) { %> selected<% } %>><%= layout %></option><% }) %></select></label>\n <noscript><button type=\"submit\">Appliquer</button></noscript>\n </form>\n<% } %>\n<% if (it.footer) { %>\n <div class=\"nf-foot\">\n <ul class=\"nf-facts\" aria-label=\"Protection de la session\">\n <li><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-shield\" /></svg>cookie HttpOnly</li>\n <li><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-shield\" /></svg>anti-CSRF</li>\n <li><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-shield\" /></svg>CSP stricte</li>\n </ul>\n <a class=\"nf-powered\" href=\"https://github.com/nodefony/nodefony-core\" rel=\"noopener noreferrer\"><img class=\"nf-logo nf-logo-mono\" src=\"<%= it.logoHref %>\" alt=\"\" />Propuls\u00E9 par Nodefony</a>\n </div>\n<% } %>\n </main>\n </div>\n </body>\n</html>\n";
@@ -28,6 +28,8 @@ export declare const SYSLOG_FILTERS: {
28
28
  readonly module: "string";
29
29
  /** Catégorie de message (msgid) — inclusion insensible à la casse. */
30
30
  readonly msgid: "string";
31
+ /** Module exclu — égalité insensible à la casse (`NODEFONY` = le framework). */
32
+ readonly excludeModule: "string";
31
33
  /** Protocole d'origine ; absent = les deux. */
32
34
  readonly protocol: readonly ["ws", "http"];
33
35
  /**
@@ -45,6 +47,10 @@ export declare const SYSLOG_FILTERS: {
45
47
  readonly flow: {
46
48
  readonly each: FlowStepId[];
47
49
  };
50
+ /** Écarte les lignes d'accès de la console d'administration elle-même. */
51
+ readonly hideConsole: "boolean";
52
+ /** Une ligne par requête (sa ligne d'accès), avec le nombre de lignes regroupées. */
53
+ readonly groupByRequest: "boolean";
48
54
  /** Borne basse d'horodatage (epoch ms, incluse). */
49
55
  readonly from: "int";
50
56
  /** Borne haute d'horodatage (epoch ms, incluse). */
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Signature CSP des vues : chaque `<script>` et `<style>` ÉCRIT dans un
3
+ * gabarit reçoit le nonce de la requête, sans geste de l'auteur.
4
+ *
5
+ * La politique par défaut exige un nonce sur les scripts (`'strict-dynamic'`)
6
+ * et sur les styles (ADR-0024 §C1, §C2). Une page écrite vite, par quelqu'un
7
+ * qui ignore tout de la CSP, ne doit pas être bloquée (§C0) : la signature se
8
+ * pose donc au rendu, par le framework.
9
+ *
10
+ * Elle porte sur le TEXTE DU GABARIT, avant que les données y soient insérées :
11
+ * une balise que les données apporteraient (une valeur insérée brute par
12
+ * `<%~ %>`) n'est jamais signée — c'est ce qui garde au nonce sa valeur contre
13
+ * l'injection. Même principe que l'`index.html` d'un front bâti
14
+ * (`prebuiltUi.ts#splitAroundNonce`).
15
+ */
16
+ /** Variable de vue qui porte le nonce CSP de la requête (`<%= cspNonce %>`). */
17
+ export declare const VIEW_NONCE_LOCAL = "cspNonce";
18
+ /**
19
+ * Ajoute le nonce de la requête à chaque `<script>` et `<style>` littéral du
20
+ * gabarit qui n'en porte pas.
21
+ *
22
+ * Une balise déjà signée par l'auteur est laissée telle quelle ; un `<script`
23
+ * écrit À L'INTÉRIEUR d'une balise Eta (une chaîne de code) n'est jamais
24
+ * touché. Un `<script>` dont un attribut vient des données
25
+ * (`src="<%= it.url %>"`) n'est pas signé non plus : le signer exécuterait
26
+ * l'URL qu'elles apportent. L'auteur qui la tient pour sûre signe lui-même
27
+ * (`nonce="<%= it.cspNonce %>"`). Le rendu doit fournir la variable
28
+ * {@link VIEW_NONCE_LOCAL}.
29
+ *
30
+ * @param source - texte du gabarit Eta, tel que lu sur le disque.
31
+ * @returns le gabarit signé — la MÊME chaîne quand il n'y a rien à signer.
32
+ */
33
+ export declare function signTemplateTags(source: string): string;
package/docs/admin.md CHANGED
@@ -82,7 +82,7 @@ Trois idées à retenir :
82
82
  | RBAC | _Role-Based Access Control_ : l'accès dépend des rôles de l'appelant. |
83
83
  | `ROLE_NODEFONY_*` | Rôles de la **plateforme** (admin du framework), distincts des rôles applicatifs `ROLE_*` d'un tenant. |
84
84
  | Pont / dispatch | Le controller unique qui, à chaque requête, retrouve l'endpoint et exécute son handler. |
85
- | API souveraine | Toute action admin déclare AUSSI le transport WebSocket → invocable par le pont WS-RPC `api.request`. |
85
+ | Deux transports | Toute action admin déclare AUSSI le transport WebSocket → invocable par le pont WS-RPC `api.request`. |
86
86
  | Duplex | Une même action servie sur HTTP **et** WebSocket (le différenciateur Nodefony). |
87
87
  | Catalogue / discovery | La liste des producteurs + endpoints, exposée en JSON pour que Studio bâtisse sa navigation. |
88
88
  | Playground | La console dev qui joue n'importe quel controller depuis le navigateur (`/nodefony/playground`). |
@@ -327,7 +327,7 @@ laissez-passer.
327
327
  > applicatifs `ROLE_*` d'un tenant. Un endpoint peut exiger un rôle plus fin (`role: "ROLE_…"`) ou
328
328
  > graduer l'information **dans** son handler en lisant `request.roles`.
329
329
 
330
- ## 🔌 HTTP et WebSocket — la même action (API souveraine)
330
+ ## 🔌 HTTP et WebSocket — la même action
331
331
 
332
332
  Toute action admin déclare **aussi** le transport `WEBSOCKET` (`AdminBroker.ts#AdminBroker.mountAll`) : la route est
333
333
  montée avec `[method, "WEBSOCKET"]`. Elle devient donc invocable par le pont WS-RPC `api.request`
@@ -362,6 +362,55 @@ Trois pas, du point de vue d'un module :
362
362
  **Handlers lazy** : résous tes services **dans** le handler (à la requête), jamais au montage — un
363
363
  service désactivable renvoie alors `503` proprement au lieu de casser le boot.
364
364
 
365
+ ### Ce que publie un endpoint — l'état réel, aucun secret, ses capacités déclarées
366
+
367
+ Tout ce que renvoie un handler part tel quel vers la console, le pont socket et les agents. Trois
368
+ règles en découlent.
369
+
370
+ **1. Un DTO ne porte aucun secret : il en dit la présence.** Un mot de passe, une clé ou un jeton
371
+ se décrivent par un booléen. Le résumé d'un utilisateur expose `hasPassword`, jamais le hash
372
+ (`UserAdminApi.ts#IUserSummary`). La description du pare-feu expose `synchronizerToken: boolean`
373
+ (`IFirewallDescription.ts#IFirewallDescription`). La redaction se fait **en construisant** le DTO,
374
+ champ par champ. Copier l'objet puis retirer les clés sensibles laisserait passer la prochaine qui
375
+ sera ajoutée. Un test le prouve : `JSON.stringify(dto)` ne contient pas la valeur sentinelle posée
376
+ dans la configuration.
377
+
378
+ **2. Une description montre ce qui TOURNE, pas la configuration relue.** Une méthode `describe()`
379
+ lit les structures que le service a bâties au démarrage
380
+ (`IFirewall.ts#IFirewall.describe`, `IBackplane.ts#IBackplane.describe`). Un authenticator mal
381
+ orthographié dans la configuration n'y figure pas, puisqu'il n'est pas monté. Relire la
382
+ configuration montrerait l'intention, et la console afficherait comme active une défense qui ne
383
+ tourne pas.
384
+
385
+ **3. Une capacité de liste non déclarée est refusée en `400`, jamais ignorée.** Tri, filtres,
386
+ recherche et facettes se déclarent dans `page` (`IAdminApi.ts#IAdminPageCapabilities`), et le
387
+ handler lit **la même donnée** : il passe ses champs triables à `parsePageQuery()`
388
+ (`pageQuery.ts#parsePageQuery`) et son vocabulaire de filtres à `parseFilters()`
389
+ (`pageFilters.ts#parseFilters`). Ces deux lecteurs refusent en `400` un tri hors liste, un `?q=`
390
+ sur un endpoint qui ne cherche pas, une valeur mal formée et un paramètre que personne ne
391
+ reconnaît. Lire `request.query.order` à la main renvoie une page non triée que le client prend pour
392
+ triée. Avec un filtre mal orthographié, c'est la collection entière qui revient.
393
+
394
+ ```typescript ignore
395
+ import { parseFilters, parsePageQuery, type IAdminEndpoint } from "nodefony";
396
+
397
+ // Une constante, deux lectures : le catalogue et le handler ne peuvent pas diverger.
398
+ const SORTABLE = ["createdAt", "total"] as const;
399
+ const FILTERS = { status: ["paid", "pending", "refunded"] } as const;
400
+
401
+ const listOrders: IAdminEndpoint = {
402
+ path: "orders",
403
+ method: "GET",
404
+ summary: "Commandes, paginées.",
405
+ page: { sortable: () => SORTABLE, filters: FILTERS },
406
+ handler: async (request) => {
407
+ const query = parsePageQuery(request.query, { sortable: SORTABLE }); // ?order=x → 400
408
+ const filters = parseFilters(request.query, FILTERS); // ?status=zzz, ?stauts= → 400
409
+ return orders.listPage({ ...query, ...filters });
410
+ },
411
+ };
412
+ ```
413
+
365
414
  ### Les producteurs réels (à imiter, sans les redocumenter)
366
415
 
367
416
  Le broker lui-même est déclaré comme service du framework (`index.ts`). Les producteurs internes