@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
@@ -306,7 +306,7 @@ class ChatController extends Controller {
306
306
  | Nombre d'appels d'action | 1 | 1 au handshake (`WebsocketContext.handle()`, `WebsocketContext.ts#WebsocketContext.handle`) + 1 par frame (`handleMessage()`, `WebsocketContext.ts#WebsocketContext.handleMessage`) |
307
307
  | Argument de l'action | Variables de route (ou paramètres décorés) | Idem + **le message** en dernier argument (`WebsocketContext.ts#WebsocketContext.handleMessage`) |
308
308
  | Rendu d'un `return` | Corps de la réponse | Frame envoyée sur la socket |
309
- | Échec | Statut HTTP + corps d'erreur | **Code de fermeture** RFC 6455 (401/403 → 1008, 5xx → 1011, autre → 4004) ; chemin sans route WebSocket → **404 HTTP avant l'ouverture** |
309
+ | Échec | Statut HTTP + corps d'erreur | **Au handshake** : une garde refusée répond par un **statut HTTP** (401/403) **avant l'ouverture** (`Resolver.authorizeAction()`, `Resolver.ts#Resolver.authorizeAction`), chemin sans route WebSocket → **404** ; **après l'ouverture** : un **code de fermeture** RFC 6455 (401/403 → 1008, 5xx → 1011, autre → 4004) |
310
310
  | `initialize()` | Singleton : **une fois**, à la création · `@Scope("request")` : à chaque requête | Singleton : une fois, à la création · `@Scope("request")` : **une fois par connexion**, au handshake |
311
311
 
312
312
  La réutilisation de l'instance vient du cache posé sur le container du contexte
@@ -415,14 +415,14 @@ de ce que tu as retourné :
415
415
  | Un `number` / un `boolean` | Auto-JSON scalaire (RFC 8259 §2 : `42`, `true` sont des documents valides) | `Resolver.ts#Resolver._callWithIdempotency` |
416
416
  | Un `Buffer` | Envoyé brut | `Resolver.ts#Resolver.returnController` |
417
417
  | Une `Response` (via un `render*`) | Retournée telle quelle — l'envoi a déjà eu lieu | `Resolver.ts#Resolver._invokeAction` |
418
- | `void`/`null` **et** statut 204/205/304 | Réponse **vide envoyée** (RFC 9110 : ces statuts n'ont pas de corps) | `NO_BODY_STATUS` (`Resolver.ts#NO_BODY_STATUS`) |
418
+ | `void`/`null` **et** statut 204/205/304 | Réponse **vide envoyée** (RFC 9110 : ces statuts n'ont pas de corps) | `isContentlessStatus` (`Response.ts#isContentlessStatus`, `@nodefony/http`) |
419
419
  | `void`/`null` avec tout autre statut | `waitAsync` : « l'action enverra plus tard » | `Resolver.ts#Resolver.returnController` |
420
420
  | Une instance de classe (entité ORM, DTO) | **Non sérialisée** → `waitAsync` (le teardown avertit du blocage) | `Resolver.ts#Resolver.returnController` |
421
421
 
422
422
  > [!WARNING]
423
423
  > **Le piège n° 1 : `return null` sur un statut à corps.** Le framework l'interprète comme « je
424
424
  > répondrai moi-même » et attend — jusqu'au timeout. La distinction se fait sur le **statut** :
425
- > `NO_BODY_STATUS` (`Resolver.ts#NO_BODY_STATUS`) contient 204, 205 et 304. Donc un `@Delete` qui fait
425
+ > `isContentlessStatus` (`Response.ts#isContentlessStatus`, liste unique de `@nodefony/http`) rend vrai pour 204, 205 et 304. Donc un `@Delete` qui fait
426
426
  > `@HttpCode(204)` puis `return null` répond bien 204 vide ; le même `return null` sans `@HttpCode`
427
427
  > laisse la requête pendue.
428
428
 
@@ -463,8 +463,10 @@ Deux besoins distincts, deux helpers.
463
463
  ### Téléchargement — `renderFileDownload()`
464
464
 
465
465
  `renderFileDownload(file, options?, headers?)` (`Controller.ts#Controller.renderFileDownload`) pose
466
- `Content-Disposition: attachment`, `Content-Length`, le type MIME du fichier, puis délègue au moteur
467
- de flux. Le fichier est résolu **sans bloquer l'event loop** (`getFileAsync()`, `Controller.ts#Controller.getFileAsync`) ;
466
+ `Content-Disposition: attachment`, `Content-Length`, le type MIME du fichier et un `Expires` déjà
467
+ passé, puis délègue au moteur de flux. Un nom de fichier accentué ou porteur de `"` part en
468
+ `filename*` UTF-8, précédé d'un repli ASCII (RFC 6266 §4.3, RFC 8187) — un nom quelconque ne fait
469
+ jamais lever la réponse. Le fichier est résolu **sans bloquer l'event loop** (`getFileAsync()`, `Controller.ts#Controller.getFileAsync`) ;
468
470
  la variante synchrone `getFile()` existe encore mais est marquée obsolète — elle appelle `lstatSync`
469
471
  et gèle le process le temps du stat.
470
472
 
@@ -473,25 +475,136 @@ et gèle le process le temps du stat.
473
475
  `renderMediaStream(file, headers?, options?)` (`Controller.ts#Controller.renderMediaStream`) implémente les **requêtes par
474
476
  plage** (RFC 9110 §14), ce qui permet à un lecteur vidéo de sauter dans le flux :
475
477
 
476
- | Le client envoie… | Réponse |
477
- | --------------------------------------------- | --------------------------------------------------------------- |
478
- | Pas de `Range` | 200 + fichier complet, `Accept-Ranges: bytes` |
479
- | `Range: bytes=0-499` | **206** + `Content-Range`, bornes clampées à la taille réelle |
480
- | `Range: bytes=-500` (suffixe) | 206 sur les 500 derniers octets |
481
- | Plage hors fichier | **416** + `Content-Range: bytes */<taille>` (RFC 9110 §15.5.17) |
482
- | Syntaxe invalide, multi-plage, unité inconnue | En-tête **ignoré** → 200 complet (jamais un 500) |
478
+ | Le client envoie… | Réponse |
479
+ | ------------------------------------------------ | --------------------------------------------------------------- |
480
+ | Pas de `Range` | 200 + fichier complet, `Accept-Ranges: bytes` |
481
+ | `Range: bytes=0-499` | **206** + `Content-Range`, bornes clampées à la taille réelle |
482
+ | `Range: bytes=-500` (suffixe) | 206 sur les 500 derniers octets |
483
+ | Plage hors fichier | **416** + `Content-Range: bytes */<taille>` (RFC 9110 §15.5.17) |
484
+ | Syntaxe invalide, multi-plage, unité inconnue | En-tête **ignoré** → 200 complet (jamais un 500) |
485
+ | `HEAD` avec `Range` | `Range` **ignoré** : en-têtes du GET complet (RFC 9110 §14.2) |
486
+ | `If-Range` égal à l'`ETag` ou au `Last-Modified` | 206 : la reprise est sûre, le fichier n'a pas changé |
487
+ | `If-Range` différent, ou étiquette faible | `Range` **ignoré** → 200 complet (RFC 9110 §13.1.5) |
488
+
489
+ Une condition `If-Range` fausse sert le fichier entier — plutôt qu'une plage d'un fichier qui a
490
+ changé, que le client recollerait à l'ancienne. Seuls un `ETag` FORTEMENT égal, ou une date égale
491
+ à un `Last-Modified` vieux d'au moins une seconde, autorisent la reprise.
483
492
 
484
493
  La logique est isolée dans une fonction pure exportée, `parseByteRange()` (`Controller.ts#parseByteRange`) —
485
494
  donc testable sans serveur.
486
495
 
487
496
  ### Ce que `streamFile()` garantit
488
497
 
489
- `streamFile()` (`Controller.ts#Controller.streamFile`) est le moteur commun. Sa subtilité n'est pas le pipe, c'est le
490
- **nettoyage** : le flux est ouvert avec `autoClose: false`, et un client qui raccroche en plein
491
- téléchargement laisserait sinon un descripteur de fichier ouvert et une promesse pendue à jamais. Un
492
- écouteur `close` sur la réponse détruit le flux, ce qui déclenche la fermeture du descripteur et
493
- résout la promesse (`Controller.ts#Controller.streamFile`), puis se retire lui-même (`Controller.ts#Controller.streamFile`). Un
494
- téléchargement interrompu ne coûte donc **rien** en ressource retenue.
498
+ `streamFile()` (`Controller.ts#Controller.streamFile`) est le moteur commun, et il repose sur le
499
+ même cycle de vie que `renderStream()` ci-dessous (`HttpContext.openStream`) :
500
+
501
+ - **la réponse est prise à l'appel**, les en-têtes partent au premier morceau ;
502
+ - **un client qui raccroche détruit le flux**, donc ferme le descripteur : un téléchargement
503
+ interrompu ne retient rien ;
504
+ - **un fichier illisible à l'ouverture** (`EACCES`) rend la page d'erreur ordinaire, 500 ; une
505
+ lecture qui échoue en route coupe la connexion, jamais une fin propre ;
506
+ - **la longueur annoncée est tenue** : la lecture est bornée à la taille relevée (un fichier qui
507
+ grossit pendant l'envoi n'envoie pas plus que `Content-Length`), et un extrait `start`/`end`
508
+ annonce sa propre longueur ;
509
+ - **un fichier n'est pas un flux vivant** : ni `X-Accel-Buffering: no`, ni délai d'inactivité.
510
+
511
+ ### Requêtes conditionnelles — 304, 412, reprise
512
+
513
+ Les trois helpers posent sur le fichier ENTIER une `ETag` forte et un `Last-Modified`, tirés de sa
514
+ taille et de sa date de modification, sans le lire. Les préconditions sont évaluées dans l'ordre de
515
+ RFC 9110 §13.2.2, AVANT d'ouvrir le fichier (`conditionalRequest.ts#evaluatePreconditions`) :
516
+
517
+ | Le client envoie… | Réponse |
518
+ | ------------------------------------------------------------ | ------------------------------------------- |
519
+ | `If-None-Match` égal (comparaison faible), ou `*` | **304** en GET/HEAD, sans corps ; 412 sinon |
520
+ | `If-Modified-Since` ≥ date du fichier (sans `If-None-Match`) | **304** en GET/HEAD |
521
+ | `If-Match` différent (comparaison forte) | **412** `Precondition Failed` |
522
+ | `If-Unmodified-Since` < date du fichier (sans `If-Match`) | **412** |
523
+
524
+ Le 304 porte ce qu'aurait porté le 200 : `ETag`, `Last-Modified`, `Cache-Control`, `Expires`,
525
+ `Vary` (RFC 9110 §15.4.5). Le helper rend alors `null` : aucun fichier n'a été ouvert.
526
+
527
+ **`Cache-Control: private, no-cache` par défaut.** Sans lui, `Last-Modified` rendrait la réponse
528
+ « heuristiquement cacheable » (RFC 9111 §4.2.2) : un cache partagé pourrait garder le fichier et le
529
+ resservir sans redemander — or un fichier servi par une action est le plus souvent protégé.
530
+ `private` ferme les caches partagés, `no-cache` fait revalider avant chaque réutilisation, ce qui
531
+ ne coûte qu'un 304. Un fichier public fixe le sien :
532
+
533
+ ```ts
534
+ return this.renderMediaStream(file, {
535
+ "Cache-Control": "public, max-age=3600",
536
+ });
537
+ ```
538
+
539
+ Une `ETag` ou un `Last-Modified` passés dans les en-têtes (un hachage du contenu, une version)
540
+ remplacent ceux du fichier ET servent à la comparaison. Un extrait `streamFile(f, h, { start, end })`
541
+ n'est pas la représentation entière : il ne reçoit ni validateur ni évaluation.
542
+
543
+ **Ce que l'application peut changer** :
544
+
545
+ | Besoin | Geste |
546
+ | ----------------------------------------------------- | -------------------------------------------------------------------------- |
547
+ | Un autre `Cache-Control` (fichier public, `no-store`) | le passer dans les en-têtes, ou `@Header("Cache-Control", …)` sur l'action |
548
+ | Sa propre `ETag` (hachage du contenu, version) | la passer dans les en-têtes, ou `@Header("ETag", …)` — elle sert au 304 |
549
+ | Aucun validateur, aucun 304, aucun `Cache-Control` | `{ validators: false }` dans les options |
550
+ | Le défaut de toute l'application | la section `fileServing` de la config du module (ci-dessous) |
551
+
552
+ Un en-tête posé AVANT l'appel (`@Header`, `this.response.setHeader`) est respecté comme s'il était
553
+ passé en argument.
554
+
555
+ **Pour toute l'application**, la section `fileServing` de la config du module fixe le défaut
556
+ (`config.ts#fileServingSchema`) ; un appel le remplace toujours :
557
+
558
+ ```ts
559
+ use("@nodefony/framework", {
560
+ fileServing: {
561
+ validators: true, // false : aucun validateur, aucun 304, aucun Cache-Control par défaut
562
+ cacheControl: "private, no-cache", // null : n'en poser aucun
563
+ },
564
+ });
565
+ ```
566
+
567
+ `cacheControl` est validé au démarrage : une valeur porteuse d'un retour à la ligne (qui forgerait
568
+ un en-tête de plus) l'interrompt en nommant la clé. `{ validators: true }` dans les options d'un
569
+ appel rétablit les validateurs quand la config les a coupés.
570
+
571
+ ⚠️ **La date d'un fichier n'est pas toujours fiable.** Une archive décompressée ou un build
572
+ reproductible donne la même date à tous les fichiers : deux contenus de même taille portent alors
573
+ la même `ETag`, et le client garde l'ancien sur un 304 sans que rien ne le signale. Pour ces
574
+ fichiers, passer une `ETag` tirée du contenu — le 304 reste juste — ou couper les validateurs :
575
+
576
+ ```ts
577
+ // le 304 garde son sens : l'étiquette suit le contenu
578
+ return this.renderMediaStream(file, { ETag: `"${hash}"` });
579
+ // ou : aucune requête conditionnelle, le fichier part toujours entier
580
+ return this.renderMediaStream(file, {}, { validators: false });
581
+ ```
582
+
583
+ ### Une source qui n'est pas un fichier — `renderStream()`
584
+
585
+ La sortie d'un processus (ffmpeg, `tar`), le corps d'un `fetch()` relayé, une archive construite à
586
+ la volée : `renderStream(source, options?)` (`Controller.ts#Controller.renderStream`) répond par
587
+ ce flux d'octets, en HTTP/1.1 comme en HTTP/2. La réponse est prise à l'appel, ses en-têtes partent
588
+ au premier morceau, et la source est **détruite** à la fin quelle qu'en soit la raison. La promesse
589
+ ne rejette jamais : elle rend `{ reason, bytes }`.
590
+
591
+ ```typescript
592
+ const ff = spawn("ffmpeg", args, {
593
+ stdio: ["ignore", "pipe", "ignore"],
594
+ windowsHide: true,
595
+ });
596
+ try {
597
+ await this.renderStream(ff.stdout, {
598
+ headers: { "content-type": "video/mp4" },
599
+ });
600
+ } finally {
601
+ ff.kill("SIGKILL"); // le flux est fini, quelle qu'en soit la raison
602
+ }
603
+ ```
604
+
605
+ Page complète — options, raisons de fin, erreurs avant et après le premier octet, relais `fetch`,
606
+ pièges : [Flux d'octets](../../http/docs/byte-stream.md). Pour des **événements texte** lus par
607
+ `EventSource`, voir [Flux SSE](../../http/docs/sse.md).
495
608
 
496
609
  ## ⚠️ Erreurs — lever, rendre, observer
497
610
 
@@ -594,15 +707,21 @@ code du framework applique — et attend de toi — les règles suivantes :
594
707
 
595
708
  ## 📜 Normes appliquées
596
709
 
597
- | Domaine | Norme | Comment le code s'y conforme |
598
- | -------------------------------- | ------------------------ | ------------------------------------------------------------------------------------ |
599
- | Statuts sans corps (204/205/304) | RFC 9110 §15.3.5/§15.4.5 | `NO_BODY_STATUS` (`Resolver.ts#NO_BODY_STATUS`) |
600
- | Requêtes par plage | RFC 9110 §14.1.2, §14.2 | `parseByteRange()` (`Controller.ts#parseByteRange`) |
601
- | Plage insatisfiable → 416 | RFC 9110 §15.5.17 | `renderResponse()` avec 416 (`Controller.ts#Controller.renderResponse`) |
602
- | Redirections | RFC 9110 §15.4 | Liste blanche + repli 302 (`Response.ts#HttpResponse.send`) |
603
- | Média JSON sans `charset` | RFC 8259 §11 | Auto-JSON (`Resolver.ts#Resolver.returnController`), vérifié par le banc `auto-json` |
604
- | Scalaire JSON de premier niveau | RFC 8259 §2 | `number`/`boolean` rendus (`Resolver.ts#Resolver._callWithIdempotency`) |
605
- | Codes de fermeture WebSocket | RFC 6455 §7.4 | `renderWebsocket()` (`error-renderer.ts#DefaultErrorRenderer.renderWebsocket`) |
710
+ | Domaine | Norme | Comment le code s'y conforme |
711
+ | --------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------- |
712
+ | Statuts sans corps (204/205/304) | RFC 9110 §15.3.5/§15.3.6/§15.4.5 | `isContentlessStatus` (`Response.ts#isContentlessStatus`) — aucun corps ne part |
713
+ | Requêtes par plage | RFC 9110 §14.1.2, §14.2 | `parseByteRange()` (`Controller.ts#parseByteRange`) |
714
+ | Plage insatisfiable → 416 | RFC 9110 §15.5.17 | `renderResponse()` avec 416 (`Controller.ts#Controller.renderResponse`) |
715
+ | `Range` hors GET, sous `If-Range` | RFC 9110 §14.2, §13.1.5 | `renderMediaStream()` (`Controller.ts#Controller.renderMediaStream`) |
716
+ | Validateurs d'un fichier | RFC 9110 §8.8.2, §8.8.3 | `fileValidators()` (`conditionalRequest.ts#fileValidators`) |
717
+ | Ordre des préconditions → 304/412 | RFC 9110 §13.1, §13.2.2 | `evaluatePreconditions()` (`conditionalRequest.ts#evaluatePreconditions`) |
718
+ | Cache d'un fichier protégé | RFC 9111 §4.2.2, §5.2.2 | `Cache-Control: private, no-cache` par défaut (`Controller.ts#Controller.streamFile`) |
719
+ | Nom de fichier téléchargé | RFC 6266 §4.3, RFC 8187 | `contentDisposition()` (`Controller.ts#contentDisposition`) |
720
+ | Longueur du corps tenue | RFC 9112 §6.3, RFC 9113 §8.1.1 | `streamFile()` (`Controller.ts#Controller.streamFile`) + `ByteStream.ts#ByteStream` |
721
+ | Redirections | RFC 9110 §15.4 | Liste blanche + repli 302 (`Response.ts#HttpResponse.send`) |
722
+ | Média JSON sans `charset` | RFC 8259 §11 | Auto-JSON (`Resolver.ts#Resolver.returnController`), vérifié par le banc `auto-json` |
723
+ | Scalaire JSON de premier niveau | RFC 8259 §2 | `number`/`boolean` rendus (`Resolver.ts#Resolver._callWithIdempotency`) |
724
+ | Codes de fermeture WebSocket | RFC 6455 §7.4 | `renderWebsocket()` (`error-renderer.ts#DefaultErrorRenderer.renderWebsocket`) |
606
725
 
607
726
  ## 📡 Observabilité — Studio
608
727
 
@@ -334,7 +334,7 @@ Deux points qu'un dev découvre sinon à ses dépens :
334
334
  méthodes, ne serait-ce que pour l'en-tête `Allow`.
335
335
 
336
336
  Le second argument accepte les options de route non redondantes — `Omit<RouteOptions, "path" | "method">`
337
- (`routerDecorators.ts#route`), soit `defaults`, `requirements`, `host`, `bypassFirewall`
337
+ (`routerDecorators.ts#route`), notamment `defaults`, `requirements`, `host`, `bypassFirewall` et `fallback`
338
338
  (`RouteOptions`, `Route.ts#RouteOptions`) :
339
339
 
340
340
  ```typescript
@@ -342,6 +342,12 @@ Le second argument accepte les options de route non redondantes — `Omit<RouteO
342
342
  async index(@Param("page") page: string) { /* … */ }
343
343
  ```
344
344
 
345
+ `requirements.scheme` se compare au scheme **effectif** de la requête — celui qu'annonce un proxy de
346
+ confiance compris : hors exigence, la route répond **403**, jamais une redirection
347
+ (`Route.ts#Route.matchRequirements`). Rediriger `http` vers `https` relève de la bordure (HSTS, proxy
348
+ inverse), pas du routage. `fallback: true` fait de la route un **repli**, servi seulement si aucune
349
+ autre ne correspond — voir [le routage](routing.md#-ordre-de-résolution--trois-situations).
350
+
345
351
  ### Paramètres — ce que l'action reçoit
346
352
 
347
353
  Onze décorateurs, tous produits par `paramDecoratorFactory()` (`routerDecorators.ts#paramDecoratorFactory`) sauf
@@ -425,7 +431,7 @@ update(input: RouteInput<{ params: typeof postParams; body: typeof postBody }>)
425
431
  **Ce que fait le framework, dans l'ordre :**
426
432
 
427
433
  - chaque partie déclarée est un [Standard Schema](https://standardschema.dev) — Zod, Valibot,
428
- ArkType… ; l'interface est recopiée au cœur (`runtime/standardSchema.ts#IStandardSchemaV1`), aucune
434
+ ArkType… ; l'interface est recopiée au cœur (`runtime/standardSchemaTypes.ts#IStandardSchemaV1`), aucune
429
435
  dépendance n'est imposée ;
430
436
  - la validation a lieu **après** les gardes (`@IsGranted` : un 403 ne révèle pas le contrat) et
431
437
  **avant** l'instanciation du contrôleur : une entrée invalide ne construit rien
@@ -263,7 +263,7 @@ Deux mécanismes rendent ça possible côté socket :
263
263
  `resolveIdempotencyKey()` (`idempotency.ts#resolveIdempotencyKey`) donne la **priorité à l'ALS** sur l'en-tête HTTP.
264
264
  - **La méthode logique voyage par `methodOverride`** — sur une socket, `context.method` vaut
265
265
  `WEBSOCKET`, qui n'est **pas** une mutation. Sans override, la porte serait sautée et un rejeu de
266
- frame `socket.mutate` créerait un doublon. Le Resolver teste donc
266
+ frame `socket.post` créerait un doublon. Le Resolver teste donc
267
267
  `isMutationMethod(this.methodOverride ?? context.method)` (`Resolver.ts#Resolver.action`).
268
268
 
269
269
  Le pont utilise `executeActionGuarded()` (`Resolver.ts#Resolver.executeActionGuarded`) : porte d'idempotence **sans** rendu HTTP
@@ -619,14 +619,32 @@ registerIdempotencyStore("mon-backend", (ctx) => {
619
619
  });
620
620
  ```
621
621
 
622
- Trois règles héritées du code existant, à respecter sous peine de double-effet :
622
+ Quatre règles héritées du code existant, à respecter sous peine de double-effet :
623
623
 
624
624
  1. **`begin` doit être atomique côté backend.** Un `GET` puis `SET` séparés laissent deux retries
625
625
  concurrents obtenir `fresh` — précisément ce que l'idempotence doit empêcher.
626
626
  2. **Ne jamais rendre `fresh` hors réservation gagnée.** En cas de doute (course, état illisible),
627
- rendre `in-flight` : le client réessaiera, c'est sans danger.
627
+ rendre `in-flight` : le client réessaiera, c'est sans danger. La seule exception est la coupure
628
+ du backend, et elle se décide (règle 4).
628
629
  3. **`complete` doit préserver l'empreinte** de l'entrée in-flight, sinon un rejeu avec un autre
629
630
  payload après complétion ne produit plus de 422.
631
+ 4. **Une fabrique qui ne peut pas construire son store LÈVE — elle ne rend jamais un store qui se
632
+ tait.** Module manquant, client du mauvais type, configuration incohérente : tout ce qui est
633
+ certain dès le démarrage se refuse dans la fabrique. Le framework en fait un arrêt de boot en
634
+ production, et un `WARNING` suivi d'un repli sur la mémoire en développement
635
+ ([contrat de dégradation](#le-contrat-de-dégradation--fail-loud-jamais-silencieux)). Un store
636
+ construit malgré tout, qui garde chaque appel par `if (!connected) return`, échappe à ce
637
+ contrat : le registre affiche ton backend pendant que plus rien n'est dédoublonné. C'est un
638
+ échec **ouvert** — la garde a disparu, et rien ne le dit.
639
+
640
+ La connexion, elle, ne peut pas être exigée là : la fabrique est appelée à `onKernelBoot`
641
+ (`index.ts#Framework.onKernelBoot`), avant que l'ORM ne soit connecté. Elle se résout donc à
642
+ chaque appel, comme le fait `DrizzleIdempotencyStore.from()`
643
+ (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.from`), et la conduite pendant une coupure
644
+ est un **choix à écrire**. Les stores livrés rendent `fresh` — exécuter sans dédoublonnage
645
+ plutôt que bloquer — et l'annoncent comme un compromis
646
+ ([`redis`](#redis--le-choix-cluster)). Si ton domaine ne tolère aucun double effet, rends
647
+ `in-flight` (le client réessaiera) ou lève : la requête échoue, rien n'est exécuté deux fois.
630
648
 
631
649
  Fonctions du registre : `registerIdempotencyStore()` (`idempotencyStoreRegistry.ts#registerIdempotencyStore`),
632
650
  `getIdempotencyStoreFactory()` (`idempotencyStoreRegistry.ts#getIdempotencyStoreFactory`), `listIdempotencyStores()`
@@ -638,7 +656,7 @@ pour l'affichage Studio, `idempotencyStoreRegistry.ts#listIdempotencyBackends`).
638
656
  <!-- prettier-ignore -->
639
657
  | Sujet | Norme | Ancrage |
640
658
  | --- | --- | --- |
641
- | En-tête `Idempotency-Key`, statuts, rejeu | `draft-ietf-httpapi-idempotency-key-header-06` | `evaluateIdempotency()` (`idempotency.ts#evaluateIdempotency`) |
659
+ | En-tête `Idempotency-Key`, statuts, rejeu | `draft-ietf-httpapi-idempotency-key-header-08` | `evaluateIdempotency()` (`idempotency.ts#evaluateIdempotency`) |
642
660
  | Clé réutilisée avec un autre payload → 422 | draft §2.2 / §2.7 | `idempotency.ts#evaluateIdempotency` |
643
661
  | Exécution concurrente identique → 409 | draft §2.6 | `idempotency.ts#evaluateIdempotency` |
644
662
  | Clé requise absente → 400 | draft §2.7 | `idempotency.ts#evaluateIdempotency` |
package/docs/index.md CHANGED
@@ -108,8 +108,10 @@ dans `.ai/symbols.json` et les types générés — jamais recopiées ici, où e
108
108
  ## ⚙️ Configuration
109
109
 
110
110
  Bloc Zod (`nodefony/config/config.ts`), déclaré depuis l'application via
111
- `use("@nodefony/framework", { … })` : `router`, `adminBroker`, et `idempotency` (choix du store et
112
- réglages du GC — détaillé dans la page [Idempotence](idempotence.md)).
111
+ `use("@nodefony/framework", { … })` : `router`, `adminBroker`, `idempotency` (choix du store et
112
+ réglages du GC — détaillé dans la page [Idempotence](idempotence.md)), et `fileServing` (validateurs
113
+ et `Cache-Control` des fichiers servis par une action — détaillé dans la page
114
+ [Contrôleur](controller.md#requêtes-conditionnelles--304-412-reprise)).
113
115
 
114
116
  ## 📜 Normes appliquées
115
117
 
package/docs/routing.md CHANGED
@@ -172,7 +172,7 @@ class CatalogController extends Controller {
172
172
  path: "/files/*",
173
173
  requirements: { methods: ["GET"] },
174
174
  })
175
- async files(rest: string) {
175
+ async download(rest: string) {
176
176
  return this.renderJson({ rest });
177
177
  }
178
178
 
@@ -363,6 +363,20 @@ le suit — un `@All("*")` déclaré tôt masque le reste du contrôleur.
363
363
  > ses propres routes, quel que soit l'ordre d'écriture (`hasMagic`, `routerDecorators.ts#controller`). Ça ne
364
364
  > vaut **que** pour `"*"` seul : `/files/*` reste ordonné comme les autres.
365
365
 
366
+ Pour un repli qui doit céder **entre modules** — la page d'une application monopage, servie sur
367
+ `/app/{page}` ou `/app/*` —, l'option `fallback: true` range la route après toutes les autres, dans
368
+ leur ordre relatif, au moment de construire l'index (`Route.ts#RouteOptions.fallback`) : une route
369
+ littérale ou dynamique de même forme, déclarée par un module chargé après, garde la main. Aucun coût
370
+ par requête. Sans ce drapeau, un repli déclaré tôt capture en silence les routes des modules
371
+ suivants.
372
+
373
+ ```ts ignore
374
+ @Get("/app/{page}", { fallback: true })
375
+ renderSpa(): unknown {
376
+ return this.renderApp();
377
+ }
378
+ ```
379
+
366
380
  ### Situation 2 — le même chemin, deux méthodes
367
381
 
368
382
  Deux routes peuvent partager un chemin et se distinguer par la méthode. La passe 1 essaie la première,
package/docs/templates.md CHANGED
@@ -249,6 +249,21 @@ inerte `&lt;script&gt;alert(1)&lt;/script&gt;`.
249
249
  > par un utilisateur (pseudo, commentaire, champ de formulaire, paramètre d'URL). Réserve-le à des
250
250
  > fragments HTML que ton propre code a construits.
251
251
 
252
+ **La politique de sécurité du contenu (CSP) est signée pour toi.** La politique par défaut n'exécute
253
+ un `<script>` et n'applique un `<style>` que s'ils portent le nonce de la requête. `renderView` le
254
+ pose seul sur chaque balise ÉCRITE dans le gabarit (`viewNonce.ts#signTemplateTags`) — rien à
255
+ ajouter. Il ne signe jamais :
256
+
257
+ - une balise que les données apportent (`<%~ %>`) : c'est ce qui laisse au nonce sa valeur contre
258
+ l'injection ;
259
+ - un `<script>` dont un attribut vient des données (`src="<%= it.url %>"`) : le signer exécuterait
260
+ l'URL qu'elles apportent. Si tu la tiens pour sûre, écris le nonce toi-même :
261
+ `<script nonce="<%= it.cspNonce %>" src="<%= it.url %>">` ;
262
+ - un gestionnaire en attribut (`onclick="…"`, `onload="…"`) : aucun nonce ne s'y pose, la politique
263
+ le refuse. Attache l'écouteur depuis un `<script>` du gabarit (`addEventListener`).
264
+
265
+ Le détail de la politique et ses réglages : [En-têtes de sécurité](../../security/docs/headers.md).
266
+
252
267
  ## 🧰 API publique
253
268
 
254
269
  Deux niveaux : ce que le **service Eta** expose, et ce que le **contrôleur** t'offre au-dessus.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/framework",
3
- "version": "10.0.0-beta.4",
3
+ "version": "10.0.0-beta.6",
4
4
  "description": "Le modèle de programmation Nodefony : routeur, contrôleurs, décorateurs et vues — HTTP et WebSocket dans le même contexte",
5
5
  "author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
6
6
  "main": "./dist/index.js",
@@ -46,23 +46,24 @@
46
46
  "dependencies": {
47
47
  "eta": "4.6.0",
48
48
  "reflect-metadata": "0.2.2",
49
- "tslib": "2.8.1"
49
+ "tslib": "2.9.1"
50
50
  },
51
51
  "devDependencies": {
52
- "@nodefony/http": "^10.0.0-beta.4",
52
+ "@nodefony/http": "^10.0.0-beta.6",
53
53
  "@types/chai": "5.2.3",
54
- "@types/node": "26.6.5",
55
- "@vitest/coverage-v8": "5.0.5",
56
- "nodefony": "^10.0.0-beta.4",
54
+ "@types/node": "26.6.6",
55
+ "@vitest/coverage-v8": "5.1.0",
56
+ "nodefony": "^10.0.0-beta.6",
57
57
  "tsx": "4.23.15",
58
- "vitest": "5.0.5"
58
+ "vitest": "5.1.0",
59
+ "ws": "8.22.1"
59
60
  },
60
61
  "license": "Apache-2.0",
61
62
  "readmeFilename": "README.md",
62
63
  "contributors": [],
63
64
  "peerDependencies": {
64
- "@nodefony/http": "^10.0.0-beta.4",
65
- "nodefony": "^10.0.0-beta.4",
65
+ "@nodefony/http": "^10.0.0-beta.6",
66
+ "nodefony": "^10.0.0-beta.6",
66
67
  "zod": "^4.6.7"
67
68
  },
68
69
  "files": [