@nodefony/framework 10.0.0-beta.2 → 10.0.0-beta.4

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 (76) hide show
  1. package/README.md +4 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/index.js +15 -20
  4. package/dist/nodefony/config/config.js +1 -2
  5. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  6. package/dist/nodefony/controller/AdminApiController.js +45 -11
  7. package/dist/nodefony/controller/ApiKeyController.js +18 -10
  8. package/dist/nodefony/controller/BenchController.js +1 -2
  9. package/dist/nodefony/controller/IssuerMetadataController.js +1 -2
  10. package/dist/nodefony/controller/LoginPageController.js +117 -0
  11. package/dist/nodefony/controller/OAuth2Controller.js +133 -17
  12. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -2
  13. package/dist/nodefony/controller/SessionAuthController.js +50 -13
  14. package/dist/nodefony/controller/TokenAuthController.js +14 -8
  15. package/dist/nodefony/controller/TotpController.js +7 -8
  16. package/dist/nodefony/controller/WebAuthnController.js +28 -12
  17. package/dist/nodefony/controller/oauthAuthority.js +1 -2
  18. package/dist/nodefony/decorators/routerDecorators.js +268 -65
  19. package/dist/nodefony/service/AdminBroker.js +4 -6
  20. package/dist/nodefony/service/Eta.js +1 -2
  21. package/dist/nodefony/service/IdempotencyStore.js +4 -6
  22. package/dist/nodefony/service/router.js +97 -34
  23. package/dist/nodefony/src/Controller.js +71 -43
  24. package/dist/nodefony/src/FrameworkAdminApi.js +33 -11
  25. package/dist/nodefony/src/KernelAdminApi.js +82 -18
  26. package/dist/nodefony/src/PlaygroundAdminApi.js +72 -3
  27. package/dist/nodefony/src/RedisIdempotencyStore.js +46 -4
  28. package/dist/nodefony/src/Resolver.js +105 -36
  29. package/dist/nodefony/src/ResourceController.js +2 -3
  30. package/dist/nodefony/src/Route.js +54 -8
  31. package/dist/nodefony/src/SecurityApiController.js +23 -0
  32. package/dist/nodefony/src/SyslogAdminApi.js +1 -2
  33. package/dist/nodefony/src/Template.js +1 -2
  34. package/dist/nodefony/src/configMutation.js +1 -2
  35. package/dist/nodefony/src/docsReader.js +39 -23
  36. package/dist/nodefony/src/idempotency.js +14 -22
  37. package/dist/nodefony/src/idempotencyGc.js +1 -2
  38. package/dist/nodefony/src/idempotencyStoreRegistry.js +1 -2
  39. package/dist/nodefony/src/loginPage.js +430 -0
  40. package/dist/nodefony/src/requireAuthentication.js +35 -0
  41. package/dist/nodefony/src/routeSchema.js +81 -0
  42. package/dist/nodefony/src/scopeCatalog.js +1 -2
  43. package/dist/nodefony/src/singletonGuard.js +1 -2
  44. package/dist/nodefony/src/syslogFilters.js +1 -2
  45. package/dist/types/index.d.ts +8 -12
  46. package/dist/types/nodefony/controller/AdminApiController.d.ts +12 -0
  47. package/dist/types/nodefony/controller/ApiKeyController.d.ts +2 -2
  48. package/dist/types/nodefony/controller/LoginPageController.d.ts +32 -0
  49. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +67 -8
  50. package/dist/types/nodefony/controller/SessionAuthController.d.ts +46 -8
  51. package/dist/types/nodefony/controller/TokenAuthController.d.ts +6 -4
  52. package/dist/types/nodefony/controller/TotpController.d.ts +2 -2
  53. package/dist/types/nodefony/controller/WebAuthnController.d.ts +12 -4
  54. package/dist/types/nodefony/decorators/routerDecorators.d.ts +221 -35
  55. package/dist/types/nodefony/interfaces/IController.d.ts +2 -3
  56. package/dist/types/nodefony/interfaces/IRoute.d.ts +1 -2
  57. package/dist/types/nodefony/service/router.d.ts +40 -1
  58. package/dist/types/nodefony/src/Controller.d.ts +54 -17
  59. package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +12 -0
  60. package/dist/types/nodefony/src/Resolver.d.ts +44 -1
  61. package/dist/types/nodefony/src/ResourceController.d.ts +1 -1
  62. package/dist/types/nodefony/src/Route.d.ts +21 -1
  63. package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -0
  64. package/dist/types/nodefony/src/docsReader.d.ts +10 -6
  65. package/dist/types/nodefony/src/idempotency.d.ts +12 -10
  66. package/dist/types/nodefony/src/loginPage.d.ts +144 -0
  67. package/dist/types/nodefony/src/requireAuthentication.d.ts +29 -0
  68. package/dist/types/nodefony/src/routeSchema.d.ts +112 -0
  69. package/docs/admin.md +83 -78
  70. package/docs/controller.md +112 -111
  71. package/docs/decorateurs.md +212 -163
  72. package/docs/idempotence.md +137 -135
  73. package/docs/routing.md +96 -95
  74. package/docs/templates.md +55 -55
  75. package/package.json +10 -13
  76. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
@@ -1,10 +1,12 @@
1
1
  import "reflect-metadata";
2
+ import type { Readable } from "node:stream";
2
3
  import { TypeController } from "../service/router.js";
3
4
  import { RouteOptions } from "../src/Route.js";
4
5
  import Controller from "../src/Controller.js";
5
6
  import type { ControllerScope } from "../src/Controller.js";
6
7
  import { Module } from "nodefony";
7
8
  import { ControllerConstructor } from "../src/Route.js";
9
+ import type { IRouteSchema, RawRouteInput, RouteInput } from "../src/routeSchema.js";
8
10
  import type { SessionIntent } from "@nodefony/http";
9
11
  type Constructor<T = {}> = new (...args: any[]) => T;
10
12
  /**
@@ -66,7 +68,30 @@ declare function controller(prefix: string): <T extends ControllerConstructor &
66
68
  * return this.renderJson({ name });
67
69
  * }
68
70
  */
69
- declare function route(name: string, options: RouteOptions): (target: object, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor;
71
+ declare function route(name: string, options: RouteOptions & {
72
+ schema?: undefined;
73
+ bodyStream?: false;
74
+ }): RouteMethodDecorator;
75
+ declare function route<S extends IRouteSchema>(name: string, options: RouteOptionsWithSchema<S> & {
76
+ bodyStream?: false;
77
+ }): SchemaRouteMethodDecorator<S, false>;
78
+ declare function route<S extends IRouteSchema & {
79
+ readonly body?: undefined;
80
+ }>(name: string, options: RouteOptionsWithSchema<S> & {
81
+ bodyStream: true;
82
+ }): SchemaRouteMethodDecorator<S, true>;
83
+ /** Options d'une route qui déclare un schéma. */
84
+ type RouteOptionsWithSchema<S extends IRouteSchema> = RouteOptions & {
85
+ schema: S;
86
+ };
87
+ /** Décorateur de méthode d'une route sans schéma : la signature de l'action est libre. */
88
+ type RouteMethodDecorator = (target: object, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor;
89
+ /**
90
+ * Décorateur d'une action à schéma : la compilation refuse une action dont
91
+ * l'argument ne reçoit pas `RouteInput<S, Stream>` (champ absent du schéma,
92
+ * type divergent, second argument).
93
+ */
94
+ type SchemaRouteMethodDecorator<S extends IRouteSchema, Stream extends boolean> = <T extends (input: RouteInput<S, Stream>) => unknown>(target: object, propertyKey: string, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T>;
70
95
  export declare const HTTP_CODE_METADATA = "route:httpCode";
71
96
  export declare const HEADERS_METADATA = "route:responseHeaders";
72
97
  export declare const REDIRECT_METADATA = "route:redirect";
@@ -158,19 +183,40 @@ export interface IdempotentMeta {
158
183
  readonly required: boolean;
159
184
  }
160
185
  type MethodDecoratorOptions = Omit<RouteOptions, "path" | "method">;
161
- declare const Get: (path?: string, options?: MethodDecoratorOptions) => (target: object, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor;
162
- declare const Post: (path?: string, options?: MethodDecoratorOptions) => (target: object, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor;
163
- declare const Put: (path?: string, options?: MethodDecoratorOptions) => (target: object, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor;
164
- declare const Delete: (path?: string, options?: MethodDecoratorOptions) => (target: object, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor;
165
- declare const Patch: (path?: string, options?: MethodDecoratorOptions) => (target: object, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor;
166
- declare const Options: (path?: string, options?: MethodDecoratorOptions) => (target: object, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor;
167
- declare const Head: (path?: string, options?: MethodDecoratorOptions) => (target: object, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor;
186
+ /**
187
+ * Un décorateur de méthode HTTP (`@Get`, `@Post`…). Sans schéma, la signature
188
+ * de l'action est libre (décorateurs de paramètre) ; avec `schema`, l'action
189
+ * reçoit un argument unique `RouteInput<S>`, vérifié à la compilation.
190
+ */
191
+ export interface IHttpMethodDecorator {
192
+ (path?: string, options?: MethodDecoratorOptions & {
193
+ schema?: undefined;
194
+ bodyStream?: false;
195
+ }): RouteMethodDecorator;
196
+ <S extends IRouteSchema>(path: string, options: MethodDecoratorOptions & {
197
+ schema: S;
198
+ bodyStream?: false;
199
+ }): SchemaRouteMethodDecorator<S, false>;
200
+ <S extends IRouteSchema & {
201
+ readonly body?: undefined;
202
+ }>(path: string, options: MethodDecoratorOptions & {
203
+ schema: S;
204
+ bodyStream: true;
205
+ }): SchemaRouteMethodDecorator<S, true>;
206
+ }
207
+ declare const Get: IHttpMethodDecorator;
208
+ declare const Post: IHttpMethodDecorator;
209
+ declare const Put: IHttpMethodDecorator;
210
+ declare const Delete: IHttpMethodDecorator;
211
+ declare const Patch: IHttpMethodDecorator;
212
+ declare const Options: IHttpMethodDecorator;
213
+ declare const Head: IHttpMethodDecorator;
168
214
  /**
169
215
  * `@All` — route sans restriction de méthode : matche **toutes** les méthodes
170
216
  * HTTP (équivalent NestJS `@All()`). N'émet aucun requirement `methods`, donc
171
217
  * `Route.matchRequirements` ne lève jamais 405 sur la méthode.
172
218
  */
173
- declare function All(path?: string, options?: MethodDecoratorOptions): (target: object, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor;
219
+ declare const All: IHttpMethodDecorator;
174
220
  /**
175
221
  * Fixe le code de statut HTTP de la réponse d'une action.
176
222
  *
@@ -470,26 +516,18 @@ declare const Query: (key?: string) => (target: object, propertyKey: string, par
470
516
  *
471
517
  * ## Le corps n'est PAS validé — le type écrit ici ne promet rien
472
518
  *
473
- * `@Body()` injecte le corps **tel qu'il a été parsé** : `@Body() dto: CreateOrder`
474
- * compile, mais rien ne garantit qu'un `CreateOrder` soit arrivé. C'est un choix
475
- * assumé, pas un oubli — valider ici ne garderait que la porte HTTP, et devrait
476
- * rester **synchrone** ({@link resolveParamArg} l'est, et le rendre asynchrone
477
- * taxerait toute requête à paramètres décorés).
478
- *
479
- * Où valider, donc :
519
+ * `@Body()` injecte le corps **tel qu'il a été décodé** (objet JSON ou de
520
+ * formulaire, chaîne pour `text/*`, octets `Uint8Array` pour tout autre type) :
521
+ * `@Body() dto: CreateOrder` compile, mais rien ne garantit qu'un `CreateOrder`
522
+ * soit arrivé.
480
523
  *
481
- * - **Une entité** → les hooks `beforeCreate` / `beforeUpdate` d'
482
- * `AbstractCrudService` : ils sont `await`és — donc une règle asynchrone
483
- * (unicité en base) y est possible — et ils gardent REST, WebSocket **et** la
484
- * CLI d'un seul geste. C'est ce que génère `nodefony create entity`.
485
- * - **Un cas isolé** → `schema.parse(body)` en première ligne de l'action, sur le
486
- * modèle d'`assertPageQuery`. Rien d'autre à écrire : une `ZodError` qui remonte
487
- * devient un **422** (RFC 9110 §15.5.21) portant `error.fields` — quel champ,
488
- * quel message, quelle règle.
524
+ * Pour un contrat vérifié, déclarer l'option de route `schema` (ADR-0018) : le
525
+ * corps est validé AVANT l'action — 422 qui nomme chaque champ —, sur HTTP comme
526
+ * sur le pont WebSocket, et l'action le reçoit typé dans `input.body`. Une action
527
+ * à schéma ne prend aucun décorateur de paramètre.
489
528
  *
490
- * Et typer le paramètre avec `z.infer<typeof createXSchema>` plutôt qu'avec
491
- * `Partial<XRow>` : le premier décrit le contrat d'**entrée**, le second promet la
492
- * ligne de **table** (`id`, horodatages) que le schéma effacera de toute façon.
529
+ * Sans schéma, `schema.parse(body)` en première ligne de l'action reste le geste
530
+ * d'un cas isolé : une `ZodError` qui remonte devient le même **422**.
493
531
  *
494
532
  * @example
495
533
  * ```ts
@@ -524,20 +562,21 @@ export interface IParamArgContext {
524
562
  /**
525
563
  * Query de l'invocation courante quand elle ne vient PAS de l'URL du
526
564
  * transport — pont WS-RPC `api.request` (`Resolver.queryOverride`). Prime
527
- * sur `request.queryGet` pour `@Query` uniquement (`@Req` reste le brut).
565
+ * sur `request.query` pour `@Query` uniquement (`@Req` reste le brut).
528
566
  */
529
567
  queryOverride?: Record<string, unknown> | undefined;
530
568
  request?: {
531
- queryGet?: Record<string, unknown> | undefined;
532
- queryPost?: Record<string, unknown> | undefined;
533
- queryFile?: unknown[] | undefined;
569
+ query?: Record<string, unknown> | undefined;
570
+ /** Corps décodé : objet JSON, champs de formulaire, octets… */
571
+ body?: unknown;
572
+ files?: readonly unknown[] | undefined;
534
573
  headers?: Record<string, unknown> | undefined;
535
574
  /**
536
575
  * P2.9 — `IncomingMessage` brut (un `Readable`) sous-jacent au wrapper
537
576
  * `HttpRequest`. Injecté tel quel par `@Body({ stream: true })` (le pipeline
538
577
  * n'a pas consommé/parsé ce flux). `undefined` pour les contextes WS.
539
578
  */
540
- request?: NodeJS.ReadableStream | undefined;
579
+ request?: Readable | undefined;
541
580
  } | null | undefined;
542
581
  response?: unknown;
543
582
  session?: {
@@ -545,6 +584,31 @@ export interface IParamArgContext {
545
584
  } | null | undefined;
546
585
  getRequestCookies(name?: string): unknown;
547
586
  }
587
+ /**
588
+ * Le corps DÉCODÉ d'une invocation, depuis la requête seule — la règle de
589
+ * {@link readBody}, pour qui n'a pas de contexte de paramètres (porte
590
+ * d'idempotence).
591
+ *
592
+ * @param request - la requête du contexte.
593
+ * @returns le corps posé par le pont WebSocket, sinon le corps HTTP lu.
594
+ */
595
+ export declare function readInvocationBody(request: IParamArgContext["request"]): unknown;
596
+ /**
597
+ * L'entrée BRUTE d'une action à schéma, lue aux MÊMES sources que les
598
+ * décorateurs de paramètre ({@link readQuery}, {@link readBody}) : pont
599
+ * WebSocket compris. Seule allocation de la voie à schéma : cet objet, propre
600
+ * à l'invocation, que la validation complète en place et que l'action reçoit.
601
+ *
602
+ * Arguments POSITIONNELS, pas un objet de contexte : la voie à schéma n'en
603
+ * alloue aucun de plus.
604
+ *
605
+ * @param paramsMap - variables de route nommées.
606
+ * @param request - la requête du contexte.
607
+ * @param queryOverride - query du chemin invoqué (pont WebSocket), sinon rien.
608
+ * @param bodyStream - `true` : `body` est le flux brut, jamais lu.
609
+ * @returns les quatre parties, brutes.
610
+ */
611
+ export declare function readRouteInput(paramsMap: Record<string, unknown>, request: IParamArgContext["request"], queryOverride: Record<string, unknown> | undefined, bodyStream: boolean): RawRouteInput;
548
612
  /**
549
613
  * Résout la valeur d'un unique paramètre décoré depuis le contexte de requête.
550
614
  *
@@ -562,7 +626,79 @@ declare function resolveParamArg(meta: ParamMeta, ctx: IParamArgContext): unknow
562
626
  * @param ctx - contexte de requête (forme structurelle minimale)
563
627
  * @returns arguments positionnels à spread dans l'action
564
628
  */
565
- declare function buildParamArgs(metas: ParamMeta[], ctx: IParamArgContext): unknown[];
629
+ declare function buildParamArgs(metas: readonly ParamMeta[], ctx: IParamArgContext): unknown[];
630
+ /**
631
+ * Ce qu'un `Context` — HTTP comme WebSocket — offre à la résolution des
632
+ * paramètres décorés. Forme structurelle : le framework n'importe pas les
633
+ * classes de contexte pour la lire.
634
+ */
635
+ export interface IParamArgSource {
636
+ /** La requête du contexte (HTTP, ou l'upgrade d'une connexion WS). */
637
+ readonly request?: unknown;
638
+ /** La réponse du contexte. */
639
+ readonly response?: unknown;
640
+ /** La session, quand le contexte en porte une. */
641
+ readonly session?: unknown;
642
+ /** Lit un cookie de la requête, ou tous sans argument. */
643
+ getRequestCookies(name?: string): unknown;
644
+ }
645
+ /**
646
+ * Résout les paramètres décorés d'une action depuis un `Context` — la mise en
647
+ * forme UNIQUE, appelée par le Resolver (routes HTTP et WS) comme par les
648
+ * actions `@RealtimeAction`, pour qu'un décorateur ne puisse pas prendre deux
649
+ * sens selon le transport.
650
+ *
651
+ * Le corps d'un appel WebSocket voyage dans l'ALS (`RequestContext` clé
652
+ * `body`) et `@CurrentUser` y lit l'utilisateur : l'appelant pose la bulle,
653
+ * cette fonction n'en crée aucune.
654
+ *
655
+ * @param metas - paramètres décorés de l'action ({@link getParamArgsMeta})
656
+ * @param context - le contexte de la requête ou de la connexion
657
+ * @param paramsMap - variables de route nommées (vide hors route)
658
+ * @param queryOverride - query de l'invocation quand elle ne vient pas de l'URL
659
+ * @returns arguments positionnels à passer à l'action
660
+ */
661
+ export declare function buildContextParamArgs(metas: readonly ParamMeta[], context: IParamArgSource, paramsMap: Record<string, unknown>, queryOverride?: Record<string, unknown>): unknown[];
662
+ /**
663
+ * Les paramètres décorés d'une méthode (`@Body`, `@CurrentUser`…), ou `null`
664
+ * si elle n'en porte aucun. Lecture `Reflect` : à faire une fois, hors du
665
+ * chemin de chaque requête.
666
+ *
667
+ * @param proto - le prototype de la classe
668
+ * @param method - le nom de la méthode
669
+ * @returns les métadonnées, ou `null` — une action sans décorateur ne paie rien
670
+ */
671
+ export declare function getParamArgsMeta(proto: object, method: string | symbol): readonly ParamMeta[] | null;
672
+ /**
673
+ * Le schéma d'entrée d'une méthode (option de route `schema`), ou `null`.
674
+ * Lecture `Reflect` : à faire une fois, hors du chemin de chaque requête.
675
+ *
676
+ * @param proto - le prototype de la classe.
677
+ * @param method - le nom de la méthode.
678
+ * @returns le schéma, ou `null` — une action sans schéma ne paie rien.
679
+ * @throws BootConfigurationError si le schéma rangé est mal formé.
680
+ */
681
+ export declare function getRouteSchema(proto: object, method: string): IRouteSchema | null;
682
+ /**
683
+ * Refuse, au DÉMARRAGE, une action à schéma mal déclarée — appelé par
684
+ * `Router.setController` pour chaque route du contrôleur, pour qu'une faute
685
+ * ne se révèle pas à la première requête :
686
+ *
687
+ * - schéma mal formé (partie inconnue, valeur qui n'est pas un Standard Schema) ;
688
+ * - schéma ET décorateurs de paramètre (`@Body`…) : une action à schéma reçoit
689
+ * un argument UNIQUE, les deux formes ne se combinent pas ;
690
+ * - `bodyStream: true` ET `schema.body` : un flux ne se valide pas.
691
+ *
692
+ * @param routeDef - la route (nom, contrôleur, méthode).
693
+ * @throws BootConfigurationError qui nomme la route.
694
+ */
695
+ export declare function assertRouteSchemaDeclaration(routeDef: {
696
+ name: string;
697
+ controller?: {
698
+ prototype: object;
699
+ } | null | undefined;
700
+ classMethod?: string | undefined;
701
+ }): void;
566
702
  /**
567
703
  * P2.9 — Indique si l'action d'une route attend le **flux brut** du body
568
704
  * (un paramètre `@Body({ stream:true })`). Le résultat est **mémoïsé** sur
@@ -588,7 +724,14 @@ declare function routeExpectsBodyStream(routeDef: {
588
724
  */
589
725
  export interface RouteActionMeta {
590
726
  /** Paramètres décorés (`@Param`/`@Body`/`@Query`…) — `null` si aucun. */
591
- paramsMeta: ParamMeta[] | null;
727
+ paramsMeta: readonly ParamMeta[] | null;
728
+ /**
729
+ * Schéma d'entrée (`schema` de l'option de route) — `null` si l'action n'en
730
+ * déclare pas (cas courant → une comparaison, aucune allocation).
731
+ */
732
+ schema: IRouteSchema | null;
733
+ /** `bodyStream: true` déclaré en option de route. */
734
+ bodyStream: boolean;
592
735
  /** `@Redirect` de l'action — `null` si absent. */
593
736
  redirectMeta: RedirectMeta | null;
594
737
  /** `@HttpCode` de l'action — `null` si absent. */
@@ -603,6 +746,8 @@ export interface RouteActionMeta {
603
746
  * path (ni résolution de service, ni `decide`, ni await). Frozen, partagé.
604
747
  */
605
748
  security: SecurityRequirement | null;
749
+ /** `@RequireAuthentication` (fusion au plus strict) — `null` = 0 coût, frozen. */
750
+ authentication: IAuthenticationRequirement | null;
606
751
  /**
607
752
  * Directives CSP additionnelles (`@Csp`, fusion classe+méthode) — `null` si
608
753
  * l'action n'en déclare pas (cas courant → 0 composition CSP). Frozen, partagé.
@@ -649,4 +794,45 @@ declare function resolveActionMeta(routeDef: {
649
794
  classMethod?: string | undefined;
650
795
  actionMeta?: RouteActionMeta | undefined;
651
796
  }): RouteActionMeta;
652
- export { route, controller, controllers, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, resolveSessionIntent, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, resolveParamArg, buildParamArgs, routeExpectsBodyStream, computeActionMeta, resolveActionMeta, extractActionScopes, };
797
+ export { route, controller, controllers, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, RequireAuthentication, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, resolveSessionIntent, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, resolveParamArg, buildParamArgs, routeExpectsBodyStream, computeActionMeta, resolveActionMeta, extractActionScopes, };
798
+ /** Options de {@link RequireAuthentication}. */
799
+ export interface IRequireAuthenticationOptions {
800
+ /** Âge maximal de la connexion, en secondes. Omis = aucune limite d'âge. */
801
+ readonly maxAge?: number;
802
+ /**
803
+ * Moyens d'authentification EXIGÉS, tous présents (RFC 8176) : `"pwd"` mot
804
+ * de passe, `"otp"` code à usage unique, `"pop"` clé d'accès. Omis = aucun.
805
+ */
806
+ readonly amr?: readonly string[];
807
+ }
808
+ /**
809
+ * Exigence d'authentification **figée** d'une action — fusion classe + méthode
810
+ * au plus strict (âge le plus court, moyens réunis), calculée UNE fois par
811
+ * route. Objet partagé entre requêtes : ne jamais muter.
812
+ */
813
+ export interface IAuthenticationRequirement {
814
+ /** Âge maximal en secondes, `null` = sans limite. */
815
+ readonly maxAge: number | null;
816
+ /** Moyens exigés, tous présents. */
817
+ readonly amr: readonly string[];
818
+ }
819
+ /**
820
+ * Exige une connexion RÉCENTE et/ou faite avec certains moyens pour l'action
821
+ * (décorateur de méthode) ou tout le contrôleur (décorateur de classe).
822
+ *
823
+ * - `@RequireAuthentication({ maxAge: 300 })` — connexion de moins de 5 minutes.
824
+ * - `@RequireAuthentication({ amr: ["otp"] })` — second facteur exigé.
825
+ * - les deux se combinent ; classe + méthode fusionnent au plus strict.
826
+ *
827
+ * Lit ce que la session a retenu à son ouverture (`readSessionAuthentication`,
828
+ * ADR-0015) et s'évalue dans le `Resolver` APRÈS `@IsGranted` : un rôle absent
829
+ * reste un 403 définitif, une connexion trop ancienne ou trop faible devient un
830
+ * **401** dont le `WWW-Authenticate` porte
831
+ * `error="insufficient_user_authentication"` (RFC 9470) — le client sait qu'il
832
+ * doit redemander une connexion, pas que l'accès lui est fermé. Une session qui
833
+ * ne porte pas ces métadonnées est refusée, jamais acceptée.
834
+ *
835
+ * @param options - âge maximal et moyens exigés.
836
+ * @throws TypeError si `maxAge` n'est pas un entier positif ou si un moyen est vide.
837
+ */
838
+ declare function RequireAuthentication(options?: IRequireAuthenticationOptions): (target: object, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
@@ -10,10 +10,9 @@ export interface IController {
10
10
  context?: ContextType | undefined;
11
11
  session?: Session | null;
12
12
  method?: HTTPMethod | undefined;
13
- queryGet: Record<string, unknown>;
14
13
  query: Record<string, unknown>;
15
- queryFile: unknown[];
16
- queryPost: Record<string, unknown>;
14
+ files: readonly unknown[];
15
+ body: Record<string, unknown>;
17
16
  module?: Module | undefined;
18
17
  setContext(context: ContextType): void;
19
18
  setContextJson(encoding?: BufferEncoding): unknown;
@@ -1,4 +1,4 @@
1
- import type { HTTPMethod, SchemeType, ContextType, IResolvedRoute } from "@nodefony/http";
1
+ import type { HTTPMethod, ContextType, IResolvedRoute } from "@nodefony/http";
2
2
  import type { ControllerConstructor, RouteRequirements } from "../src/Route.js";
3
3
  export interface IRoute extends IResolvedRoute {
4
4
  name: string;
@@ -7,7 +7,6 @@ export interface IRoute extends IResolvedRoute {
7
7
  classMethod?: string | undefined;
8
8
  prefix?: string | undefined;
9
9
  method?: HTTPMethod | undefined;
10
- schemes?: SchemeType | undefined;
11
10
  pattern?: RegExp | undefined;
12
11
  variables: unknown[];
13
12
  defaults: Partial<Record<string, unknown>>;
@@ -6,7 +6,16 @@ import type { IRequestRouter } from "@nodefony/http";
6
6
  import Resolver from "../src/Resolver.js";
7
7
  import Controller from "../src/Controller.js";
8
8
  export type TypeController<T> = new (...args: any[]) => T;
9
+ /**
10
+ * Marque du Router de `@nodefony/framework` — ce que {@link isFrameworkRouter}
11
+ * lit. `Symbol.for` : la même marque pour deux copies installées du framework,
12
+ * là où `instanceof` échouerait en silence. Un faux routeur de test s'en
13
+ * réclame explicitement.
14
+ */
15
+ export declare const FRAMEWORK_ROUTER: unique symbol;
9
16
  declare class Router extends Service implements IRequestRouter {
17
+ /** Marque {@link FRAMEWORK_ROUTER} — sur le prototype, aucun champ par instance. */
18
+ get [FRAMEWORK_ROUTER](): true;
10
19
  static routes: Route[];
11
20
  routes: Route[];
12
21
  private singletonControllers;
@@ -30,6 +39,19 @@ declare class Router extends Service implements IRequestRouter {
30
39
  * cache, la requête suivante recrée.
31
40
  */
32
41
  getSingletonController<T extends object>(ctor: new (...args: never[]) => T, create: () => MaybePromise<T>): MaybePromise<T>;
42
+ /**
43
+ * Dit si au moins une route déclare le transport `WEBSOCKET` pour ce chemin
44
+ * — contrôle d'avant `101` du pipeline WebSocket (cf `IRequestRouter`).
45
+ *
46
+ * Parcours de la table entière, sans l'index de `resolve` : il est lu une
47
+ * fois par CONNEXION (pas par message), et une route dont les méthodes
48
+ * changent après coup (`addRequirement`) ne laisse ainsi aucun index périmé.
49
+ * Même normalisation que `resolve` (`stripTrailingSlashes`), même motif.
50
+ *
51
+ * @param pathname - chemin de l'URL d'upgrade
52
+ * @returns `true` si une route WebSocket a un motif qui couvre ce chemin
53
+ */
54
+ servesWebsocket(pathname: string): boolean;
33
55
  /**
34
56
  * Résout une route pour un contexte donné.
35
57
  *
@@ -42,7 +64,10 @@ declare class Router extends Service implements IRequestRouter {
42
64
  * WEBSOCKET (pont WS-RPC `api.request` d'une MUTATION) : lève l'ambiguïté
43
65
  * GET-via-WS / POST-via-WS sur un même chemin (`context.method` = "WEBSOCKET").
44
66
  * `undefined` (GET/HTTP) → match historique sur `context.method`.
45
- * @returns un `Resolver` (`.resolve === true` si une route a matché).
67
+ * @returns un `Resolver` (`.resolve === true` si une route a matché) — ou,
68
+ * pour un chemin servi en WebSocket SEULEMENT et demandé en HTTP, un
69
+ * `Resolver` dont `exception` porte le `426` (en-têtes `Upgrade` posés) :
70
+ * le pipeline le lève après le pare-feu.
46
71
  */
47
72
  resolve(context: ContextType, cleanPathOverride?: string, methodOverride?: string): Resolver;
48
73
  resolveController(contex: ContextType, name: string): Resolver;
@@ -76,4 +101,18 @@ declare class Router extends Service implements IRequestRouter {
76
101
  */
77
102
  static getRoutesForController(myconstructor: TypeController<Controller>): Route[];
78
103
  }
104
+ /**
105
+ * Dit si le service `router` d'un contexte est le Router de
106
+ * `@nodefony/framework` — la garde qui rétrécit le contrat `IRequestRouter` de
107
+ * `@nodefony/http` sans conversion, pour qui lit des membres propres au
108
+ * framework (le Resolver complet que rend `resolve`).
109
+ *
110
+ * Lit la marque {@link FRAMEWORK_ROUTER}, jamais `instanceof` : deux copies
111
+ * installées du framework feraient échouer `instanceof` en silence. Une
112
+ * lecture de propriété.
113
+ *
114
+ * @param router - le routeur lu sur le contexte (`context.router`).
115
+ * @returns `true` si c'est le Router du framework.
116
+ */
117
+ export declare function isFrameworkRouter(router: IRequestRouter | null | undefined): router is Router;
79
118
  export default Router;
@@ -1,8 +1,9 @@
1
1
  import { Service, Module, FileClass } from "nodefony";
2
- import type { MaybePromise } from "nodefony";
2
+ import type { 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
7
  import { OutgoingHttpHeaders } from "node:http";
7
8
  import { ReadStream } from "node:fs";
8
9
  import Eta from "../service/Eta.js";
@@ -94,30 +95,35 @@ declare class Controller extends Service implements IController {
94
95
  set request(request: contextRequest);
95
96
  get response(): HttpResponse | Http2Response | WebsocketResponse | null;
96
97
  set response(response: HttpResponse | Http2Response | WebsocketResponse | null);
98
+ /**
99
+ * L'utilisateur CONNECTÉ, ou `null` — anonyme, ou route hors de toute zone
100
+ * du pare-feu. C'est l'identité que le pare-feu a établie pour cette
101
+ * requête : aucun appel de plus. Une page publique qui veut savoir qui la
102
+ * visite se place sous une zone `authenticators: ["session", "anonymous"]`.
103
+ */
104
+ get user(): IRequestUser | null;
97
105
  get method(): HTTPMethod | undefined;
98
106
  set method(method: HTTPMethod | undefined);
99
107
  /**
100
- * Query string de l'appel en cours — pour un singleton invoqué par le pont
101
- * `api.request`, celle du chemin INVOQUÉ (résolveur d'appel dans l'ALS), pas
102
- * celle du handshake de la connexion. Une seule lecture de l'ALS.
108
+ * Paramètres d'URL de l'appel en cours — jamais le corps, qui reste dans
109
+ * {@link body}. Pour un singleton invoqué par le pont `api.request`,
110
+ * ceux du chemin INVOQUÉ (résolveur d'appel dans l'ALS), pas ceux du
111
+ * handshake de la connexion. Une seule lecture de l'ALS.
103
112
  */
104
- get queryGet(): Record<string, unknown>;
105
- set queryGet(value: Record<string, unknown>);
106
- /** Comme {@link queryGet}, pour la query fusionnée (`query`). */
107
113
  get query(): Record<string, unknown>;
108
114
  set query(value: Record<string, unknown>);
109
- get queryFile(): unknown[];
110
- set queryFile(value: unknown[]);
111
- get queryPost(): Record<string, unknown>;
112
- set queryPost(value: Record<string, unknown>);
115
+ /**
116
+ * Les fichiers reçus dans un corps multipart (`UploadedFile`) ; les champs
117
+ * texte du même corps sont dans {@link body}. Pour une action typée,
118
+ * préférer `@UploadedFiles()`.
119
+ */
120
+ get files(): readonly unknown[];
121
+ set files(value: readonly unknown[]);
113
122
  /**
114
123
  * Le CORPS de la requête, parsé — nom universel de l'écosystème (Express,
115
- * Fastify, NestJS), alias de {@link queryPost}.
116
- *
117
- * Ne pas confondre avec {@link query}, qui FUSIONNE la query string et le
118
- * corps. Pour une action typée, préférer le décorateur `@Body()`.
119
- *
120
- * Getter (aucune allocation) : `queryPost` reste la source unique.
124
+ * Fastify, NestJS). Jamais un paramètre d'URL : ceux-là sont dans
125
+ * {@link query}, et les deux ne fusionnent pas. Pour une action typée,
126
+ * préférer le décorateur `@Body()`.
121
127
  */
122
128
  get body(): Record<string, unknown>;
123
129
  set body(value: Record<string, unknown>);
@@ -177,6 +183,37 @@ declare class Controller extends Service implements IController {
177
183
  * @throws de façon SYNCHRONE ce que lève `JSON.stringify` (cycle, BigInt).
178
184
  */
179
185
  renderJson(obj: unknown, status?: string | number, headers?: OutgoingHttpHeaders): MaybePromise<Http2Response | HttpResponse | WebsocketResponse>;
186
+ /**
187
+ * Le client demande-t-il un flux d'événements (`Accept: text/event-stream`) ?
188
+ *
189
+ * La route ne change pas de méthode pour autant : un même `POST` répond en
190
+ * JSON ou en flux selon cette réponse. Toujours `false` sur WebSocket.
191
+ *
192
+ * @returns `true` quand `text/event-stream` est accepté avec un poids non nul.
193
+ */
194
+ acceptsSse(): boolean;
195
+ /**
196
+ * Répond par un flux d'événements serveur, en HTTP/1.1 comme en HTTP/2.
197
+ *
198
+ * L'action garde la main : elle `send()` à son rythme et `close()` quand elle
199
+ * a fini ; le départ du client ferme le flux (`sse.closed`, `sse.onClose`).
200
+ * Rendre le flux depuis l'action est sans effet — la réponse est déjà prise.
201
+ *
202
+ * @example
203
+ * ```ts
204
+ * @Get("/progress")
205
+ * async progress() {
206
+ * const sse = await this.renderSse();
207
+ * for (const step of steps) await sse.send(step, { event: "step" });
208
+ * return sse.close();
209
+ * }
210
+ * ```
211
+ *
212
+ * @param options - battement de cœur, délai `retry:` initial.
213
+ * @returns le flux, ou sa promesse quand une session est à sauver d'abord.
214
+ * @throws HttpError 500 hors HTTP (WebSocket) ou quand la réponse est partie.
215
+ */
216
+ renderSse(options?: ISseStreamOptions): SseStream | Promise<SseStream>;
180
217
  /**
181
218
  * Pose la route de la requête courante sur l'instance (per-request).
182
219
  *
@@ -1,4 +1,5 @@
1
1
  import type { IAdminEndpoint } from "nodefony";
2
+ import type { RoutePart } from "./routeSchema.js";
2
3
  /**
3
4
  * Endpoints « Playground » du data plane framework — la source de données de la
4
5
  * console vivante des controllers dans Studio (`/nodefony/playground`).
@@ -65,7 +66,18 @@ export interface PlaygroundAction {
65
66
  variables: string[];
66
67
  /** Défauts de variables (hors clé interne `controller`). */
67
68
  defaults: Record<string, unknown>;
69
+ /**
70
+ * Entrées de l'action. Pour une action à schéma, déduites du schéma (une
71
+ * entrée par champ de `query`/`headers`/`params`, une pour `body`) : le
72
+ * formulaire se construit pareil, quelle que soit la forme de l'action.
73
+ */
68
74
  params: PlaygroundParam[];
75
+ /**
76
+ * Le contrat d'une action à schéma — JSON Schema d'entrée de chaque partie
77
+ * déclarée (`null` quand la bibliothèque ne sait pas le décrire). `null`
78
+ * pour une action sans schéma.
79
+ */
80
+ schema: Partial<Record<RoutePart, Record<string, unknown> | null>> | null;
69
81
  guards: PlaygroundGuards;
70
82
  }
71
83
  /** Un controller et ses actions, groupés pour la navigation Studio. */
@@ -42,7 +42,7 @@ declare class Resolver implements IResolver {
42
42
  /**
43
43
  * Query d'une invocation **par message** (pont WS-RPC `api.request`) — pendant
44
44
  * de `cleanPathOverride` pour le `?…` du path invoqué. Le contexte WS étant
45
- * PARTAGÉ par la connexion (sa `queryGet` = celle du handshake), la query
45
+ * PARTAGÉ par la connexion (sa `query` = celle du handshake), la query
46
46
  * per-invocation vit ici (le Resolver est per-invocation → zéro bleed entre
47
47
  * requêtes concurrentes d'une même socket). `null` (hot path HTTP) = ignoré.
48
48
  * Consommé par `@Query` (`_buildParamArgs`) et copié sur le controller
@@ -70,6 +70,11 @@ declare class Resolver implements IResolver {
70
70
  * administrateurs.
71
71
  */
72
72
  messageInvocation: boolean;
73
+ /**
74
+ * Les gardes de l'action ont été jugées favorables par {@link authorizeAction}
75
+ * et ne se rejugent pas à la prochaine exécution — consommé par elle.
76
+ */
77
+ private preAuthorized;
73
78
  constructor(context: ContextType);
74
79
  match(route: Route, context: ContextType, cleanPath?: string, literal?: boolean): ((string | null)[] & Record<string, unknown>) | null | undefined;
75
80
  /**
@@ -115,6 +120,12 @@ declare class Resolver implements IResolver {
115
120
  * `instanceof` au message suivant — le hub était RÉINSTANCIÉ (DI +
116
121
  * `initialize()`) à chaque frame qui suivait un `api.request`.
117
122
  *
123
+ * Ni pour un `forward` sur une connexion WebSocket : son résolveur est neuf,
124
+ * pas une invocation par message, et réécrivait le pointeur que la poignée
125
+ * de main a posé — même réinstanciation à la frame suivante (#574). Une
126
+ * connexion garde le contrôleur qui l'a ouverte. Chemin froid : seulement
127
+ * quand un contrôleur est créé.
128
+ *
118
129
  * @returns le controller reçu, pour enchaîner.
119
130
  */
120
131
  private _pinController;
@@ -163,6 +174,25 @@ declare class Resolver implements IResolver {
163
174
  * d'action) ; un refus d'autorisation rompt la promesse rendue.
164
175
  */
165
176
  executeAction(data?: unknown[], reload?: boolean, metaArg?: RouteActionMeta): MaybePromise<ActionOutcome>;
177
+ /**
178
+ * Valide l'entrée d'une action à schéma, puis l'exécute avec cette entrée
179
+ * pour argument unique. Synchrone tant que le validateur l'est.
180
+ *
181
+ * @throws SchemaValidationError (422) quand l'entrée viole le schéma.
182
+ */
183
+ private _validateAndInvoke;
184
+ /**
185
+ * Juge les gardes de l'action résolue SANS l'exécuter — appelé par le
186
+ * pipeline WebSocket AVANT d'accepter le handshake (#596), pour qu'un
187
+ * connecté sans le rôle reçoive son 403 en statut HTTP au lieu d'une
188
+ * ouverture suivie d'une fermeture. Un verdict favorable vaut pour la
189
+ * PROCHAINE exécution (l'action de handshake), qui ne le rejuge pas ; les
190
+ * messages suivants, eux, sont jugés chacun.
191
+ *
192
+ * @returns `undefined` quand l'action n'est pas gardée ; sinon la promesse du
193
+ * verdict, rompue par un refus
194
+ */
195
+ authorizeAction(): Promise<void> | undefined;
166
196
  /**
167
197
  * Les gardes d'autorisation d'une action : `@IsGranted`/`@RequireScope`, puis
168
198
  * le filet de la ZONE quand aucune garde n'a décidé du RÔLE.
@@ -339,5 +369,18 @@ declare class Resolver implements IResolver {
339
369
  returnController(result: unknown): MaybePromise<unknown>;
340
370
  /** Statut courant de la réponse (0 si le transport n'en porte pas). */
341
371
  private getResponseStatus;
372
+ /**
373
+ * `@RequireAuthentication` — les gardes d'autorisation d'abord (un rôle
374
+ * absent reste un 403 définitif : inutile de faire redemander une connexion à
375
+ * qui n'aura pas le droit), PUIS l'âge et les moyens de la connexion.
376
+ *
377
+ * Le refus est un 401 « step-up » (RFC 9470) : le `WWW-Authenticate` porte
378
+ * `error="insufficient_user_authentication"` et, quand la route fixe un âge,
379
+ * `max_age` — le client redemande une connexion au lieu de conclure à un
380
+ * accès fermé. Cold path : seules les routes décorées y passent.
381
+ *
382
+ * @throws nodefonyError 401 si la connexion est trop ancienne ou trop faible.
383
+ */
384
+ private _enforceStepUp;
342
385
  }
343
386
  export default Resolver;
@@ -86,7 +86,7 @@ export interface IResourceService<T = unknown> {
86
86
  * sur `this` doit rétrograder : `static scope = "request"`.
87
87
  *
88
88
  * Sécurité : aucun critère de requête n'est passé AUTOMATIQUEMENT au service
89
- * (pas de `find(this.queryGet)` implicite) — exposer un filtrage est une
89
+ * (pas de `find(this.query)` implicite) — exposer un filtrage est une
90
90
  * décision EXPLICITE de la sous-classe (deny-by-default ; le scope de
91
91
  * sécurité des données se branche au niveau service/criteria, P6).
92
92
  *