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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/index.js +9 -5
  4. package/dist/nodefony/controller/AdminApiController.js +7 -4
  5. package/dist/nodefony/controller/ApiKeyController.js +3 -3
  6. package/dist/nodefony/controller/LoginPageController.js +124 -0
  7. package/dist/nodefony/controller/OAuth2Controller.js +131 -14
  8. package/dist/nodefony/controller/SessionAuthController.js +37 -9
  9. package/dist/nodefony/controller/TokenAuthController.js +11 -4
  10. package/dist/nodefony/controller/TotpController.js +5 -5
  11. package/dist/nodefony/controller/WebAuthnController.js +26 -9
  12. package/dist/nodefony/decorators/routerDecorators.js +114 -13
  13. package/dist/nodefony/service/AdminBroker.js +2 -2
  14. package/dist/nodefony/service/IdempotencyStore.js +2 -2
  15. package/dist/nodefony/service/router.js +51 -22
  16. package/dist/nodefony/src/Controller.js +49 -4
  17. package/dist/nodefony/src/FrameworkAdminApi.js +2 -1
  18. package/dist/nodefony/src/KernelAdminApi.js +76 -10
  19. package/dist/nodefony/src/Resolver.js +39 -2
  20. package/dist/nodefony/src/Route.js +52 -5
  21. package/dist/nodefony/src/SecurityApiController.js +24 -0
  22. package/dist/nodefony/src/docsReader.js +38 -21
  23. package/dist/nodefony/src/idempotency.js +13 -20
  24. package/dist/nodefony/src/loginPage.js +431 -0
  25. package/dist/nodefony/src/requireAuthentication.js +36 -0
  26. package/dist/types/index.d.ts +4 -3
  27. package/dist/types/nodefony/controller/ApiKeyController.d.ts +2 -2
  28. package/dist/types/nodefony/controller/LoginPageController.d.ts +32 -0
  29. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +67 -8
  30. package/dist/types/nodefony/controller/SessionAuthController.d.ts +39 -8
  31. package/dist/types/nodefony/controller/TokenAuthController.d.ts +6 -4
  32. package/dist/types/nodefony/controller/TotpController.d.ts +2 -2
  33. package/dist/types/nodefony/controller/WebAuthnController.d.ts +12 -4
  34. package/dist/types/nodefony/decorators/routerDecorators.d.ts +88 -3
  35. package/dist/types/nodefony/interfaces/IRoute.d.ts +1 -2
  36. package/dist/types/nodefony/service/router.d.ts +13 -0
  37. package/dist/types/nodefony/src/Controller.d.ts +40 -1
  38. package/dist/types/nodefony/src/Resolver.d.ts +19 -0
  39. package/dist/types/nodefony/src/Route.d.ts +18 -1
  40. package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -0
  41. package/dist/types/nodefony/src/docsReader.d.ts +10 -6
  42. package/dist/types/nodefony/src/idempotency.d.ts +12 -10
  43. package/dist/types/nodefony/src/loginPage.d.ts +144 -0
  44. package/dist/types/nodefony/src/requireAuthentication.d.ts +29 -0
  45. package/docs/admin.md +28 -23
  46. package/docs/controller.md +76 -74
  47. package/docs/decorateurs.md +69 -67
  48. package/docs/idempotence.md +30 -28
  49. package/docs/routing.md +52 -52
  50. package/docs/templates.md +11 -11
  51. package/package.json +11 -11
@@ -1,6 +1,6 @@
1
- import type { Module } from "nodefony";
1
+ import { type Module } from "nodefony";
2
2
  import type { ContextType } from "@nodefony/http";
3
- import Controller from "../src/Controller.js";
3
+ import SecurityApiController from "../src/SecurityApiController.js";
4
4
  /**
5
5
  * Vue MINIMALE du service `oauth2` (`@nodefony/security`) — couplage par NOM
6
6
  * (framework ne dépend pas de security). Contrat structurel imposé par
@@ -24,13 +24,31 @@ export interface IOAuth2Service {
24
24
  success: string;
25
25
  failure: string;
26
26
  };
27
- createAuthorization(provider: string): Promise<{
27
+ /** `options` : une version de security qui ne le lit pas l'ignore. */
28
+ createAuthorization(provider: string, options?: {
29
+ theme?: "light" | "dark";
30
+ }): Promise<{
28
31
  url: string;
29
32
  state: string;
30
33
  codeVerifier: string | null;
31
34
  }>;
32
35
  exchangeAndProvision(provider: string, code: string, codeVerifier: string | null, returnedIss: string | null): Promise<{
33
36
  identifier: string;
37
+ logoutHint?: unknown;
38
+ }>;
39
+ /**
40
+ * Retient en session de quoi fermer plus tard la session du fournisseur
41
+ * (RP-Initiated Logout). Optionnel : la clé et la forme appartiennent au
42
+ * service, ce contrôleur ne fait que transmettre.
43
+ */
44
+ rememberLogout?(session: IOAuth2Session, hint: unknown): void;
45
+ /**
46
+ * Déconnexion demandée par le fournisseur sur le canal arrière (OpenID
47
+ * Connect Back-Channel Logout). Optionnel, même raison que
48
+ * {@link listDisplayProviders} : absent, la route répond 501.
49
+ */
50
+ backchannelLogout?(provider: string, logoutToken: string): Promise<{
51
+ outcome: "unsupported" | "refused" | "done";
34
52
  }>;
35
53
  }
36
54
  /** Vue minimale d'une session — porte l'état du flux OAuth (anti-CSRF/anti-replay). */
@@ -50,16 +68,45 @@ export interface IOAuth2BffFlow {
50
68
  /** Garantit une session (anonyme) pour porter `state`/`code_verifier`. */
51
69
  ensureSession(context: ContextType): Promise<IOAuth2Session | null>;
52
70
  }
71
+ /**
72
+ * Thème que la page appelante a rendu (`?theme=`), à transmettre à l'écran du
73
+ * fournisseur. Liste FERMÉE : la chaîne vient de l'URL, toute autre valeur est
74
+ * ignorée sans un mot — rien d'arbitraire n'atteint la requête d'autorisation.
75
+ *
76
+ * @param value - paramètre `theme` de la requête, ou `null`
77
+ * @returns l'option à passer à `createAuthorization`, vide hors liste
78
+ */
79
+ export declare function providerThemeOption(value: string | null): {
80
+ theme?: "light" | "dark";
81
+ };
82
+ /**
83
+ * Compose l'adresse de l'écran d'échec : la raison de l'échec, et la page où
84
+ * aller une fois connecté, pour que l'écran la garde à la tentative suivante.
85
+ *
86
+ * `reason` vaut le code rendu par le fournisseur s'il appartient à la liste
87
+ * normalisée (`access_denied` = l'utilisateur a choisi de revenir), sinon
88
+ * {@link OAUTH2_FAILED_REASON} — jamais la chaîne reçue, qui vient de l'URL.
89
+ * Une `reason` ou un `from` déjà présents sont REMPLACÉS (l'écran qui a lancé
90
+ * le flux peut porter ceux d'une tentative précédente) ; le reste de la
91
+ * requête et le fragment sont conservés, et une adresse relative reste relative.
92
+ *
93
+ * @param failure - l'écran d'échec : `back` revalidé, ou `failureRedirect`
94
+ * @param error - le paramètre `error` reçu au callback, ou `null`
95
+ * @param from - la page où aller une fois connecté (revalidée), ou `null`
96
+ * @returns l'adresse de redirection
97
+ */
98
+ export declare function oauth2FailureTarget(failure: string, error: string | null, from: string | null): string;
53
99
  /**
54
100
  * Endpoints HTTP du **social login OAuth 2.0** (P6 J9) — adaptateurs MINCES
55
101
  * au-dessus du service `oauth2` (`@nodefony/security`) :
56
102
  *
57
- * - `GET /nodefony/security/api/oauth2/{provider}/authorize` — démarre le flux :
58
- * pose `state`+`code_verifier` en session (anonyme), redirige (302) vers le
59
- * fournisseur.
103
+ * - `GET /nodefony/security/api/oauth2/{provider}/authorize?from=&back=` —
104
+ * démarre le flux : pose `state`+`code_verifier` en session (anonyme), avec
105
+ * les retours revalidés en chemins locaux, redirige (302) vers le fournisseur.
60
106
  * - `GET /nodefony/security/api/oauth2/{provider}/callback` — valide le `state`
61
107
  * (anti-CSRF), échange le `code`, provisionne le Shadow User et OUVRE la
62
- * session BFF (302 vers `successRedirect`).
108
+ * session BFF (302 vers `from`, sinon `successRedirect`). Un échec mène à
109
+ * `back`, sinon à `failureRedirect`, avec `reason` et `from`.
63
110
  *
64
111
  * Montés UNIQUEMENT si le service `oauth2` existe (social login activé) — 404
65
112
  * sinon, zéro surface.
@@ -70,7 +117,7 @@ export interface IOAuth2BffFlow {
70
117
  * La session anonyme ne porte que `state`/`verifier` ; `establishSessionFor`
71
118
  * régénère l'ID (anti-fixation) à la promotion.
72
119
  */
73
- declare class OAuth2Controller extends Controller {
120
+ declare class OAuth2Controller extends SecurityApiController {
74
121
  #private;
75
122
  constructor(context: ContextType);
76
123
  /**
@@ -87,6 +134,18 @@ declare class OAuth2Controller extends Controller {
87
134
  authorize(provider: string): Promise<import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse | undefined>;
88
135
  /** Valide `state`, échange le `code`, ouvre la session BFF (302). */
89
136
  callback(provider: string): Promise<import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse | undefined>;
137
+ /**
138
+ * Canal arrière : le fournisseur demande de fermer les sessions qu'il
139
+ * désigne (OpenID Connect Back-Channel Logout 1.0 §2.5-2.8).
140
+ *
141
+ * Appelé serveur à serveur — ni cookie, ni session, ni jeton CSRF : la
142
+ * SIGNATURE du `logout_token` est la seule authentification. Réponses de la
143
+ * norme (§2.8) : 200 si la déconnexion a eu lieu (ou n'avait plus lieu
144
+ * d'être), 400 sinon — jeton refusé (`invalid_request`) comme déconnexion
145
+ * impossible (la cause part au journal, jamais au fournisseur). Toujours
146
+ * `Cache-Control: no-store`.
147
+ */
148
+ backchannelLogout(provider: string): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
90
149
  }
91
150
  /**
92
151
  * Monte les routes du social login OAuth — appelé par le module framework à
@@ -1,6 +1,6 @@
1
- import type { Module } from "nodefony";
1
+ import { type ILoginPageDescription, type Module } from "nodefony";
2
2
  import type { ContextType } from "@nodefony/http";
3
- import Controller from "../src/Controller.js";
3
+ import SecurityApiController from "../src/SecurityApiController.js";
4
4
  /**
5
5
  * Vue MINIMALE du flux de session BFF que consomme ce controller — le service
6
6
  * `authFlow` est posé au container par `@nodefony/security` (P6 J3). Contrat
@@ -14,14 +14,29 @@ type ILoginOutcome = {
14
14
  user: unknown;
15
15
  } | {
16
16
  status: "mfa_required";
17
- methods: string[];
17
+ methods: readonly string[];
18
18
  };
19
19
  export interface ISessionAuthFlow {
20
20
  login(context: ContextType, identifier: unknown, password: unknown): Promise<ILoginOutcome>;
21
21
  completeMfaLogin(context: ContextType, code: unknown): Promise<unknown>;
22
22
  logout(context: ContextType): Promise<boolean>;
23
+ /**
24
+ * Déconnexion qui ferme AUSSI la session du fournisseur d'identité. Optionnelle :
25
+ * une version de `@nodefony/security` qui ne l'expose pas encore dégrade sur
26
+ * {@link logout}.
27
+ */
28
+ logoutWithProvider?(context: ContextType): Promise<{
29
+ destroyed: boolean;
30
+ logoutUrl: string | null;
31
+ }>;
23
32
  /** Identité courante, ou `null` sans session authentifiée. */
24
33
  me(context: ContextType): Promise<unknown>;
34
+ /**
35
+ * Ce que la page de connexion par défaut doit afficher, ou `null` quand
36
+ * elle est désactivée. Optionnelle : une version de `@nodefony/security`
37
+ * qui ne l'expose pas encore laisse la page absente (404).
38
+ */
39
+ describeLoginPage?(): ILoginPageDescription | null;
25
40
  }
26
41
  /**
27
42
  * Endpoints HTTP du flux de session BFF — adaptateurs MINCES au-dessus du
@@ -29,7 +44,8 @@ export interface ISessionAuthFlow {
29
44
  *
30
45
  * - `POST /nodefony/security/api/auth/login` — body JSON `{username, password}`
31
46
  * - `POST /nodefony/security/api/auth/logout` — idempotent
32
- * - `GET /nodefony/security/api/auth/me` — identité de la session courante
47
+ * - `GET /nodefony/security/api/auth/me` — identité de la session courante :
48
+ * TOUJOURS `200`, `{ user }` ou `{ user: null }` sans session
33
49
  *
34
50
  * Montés par le module framework UNIQUEMENT si le service `authFlow` existe
35
51
  * (module security chargé) — sans lui, les routes n'existent pas (404, zéro
@@ -40,14 +56,20 @@ export interface ISessionAuthFlow {
40
56
  * `Retry-After` (RFC 6585) posé par le throttler NIST ; tout le reste remonte
41
57
  * au pipeline d'erreurs standard (500, détail loggé jamais fuité).
42
58
  *
43
- * @remarks Dérogation RFC 7235 §3.1 DÉLIBÉRÉE : les 401 de `login`/`me` ne
59
+ * `me` est une SONDE d'état, pas une ressource protégée : « pas de session »
60
+ * est un état normal du navigateur, pas une faute — d'où `200` + `{ user:
61
+ * null }`, comme les bibliothèques qui livrent aussi le client de connexion
62
+ * (Auth.js, better-auth). Le `401` y écrivait une erreur réseau dans la console
63
+ * de chaque visiteur anonyme, sans rien protéger : la réponse ne révèle rien.
64
+ *
65
+ * @remarks Dérogation RFC 9110 §15.5.2 DÉLIBÉRÉE : les 401 de `login` ne
44
66
  * portent PAS de `WWW-Authenticate` — aucun scheme HTTP ne décrit un cookie de
45
67
  * session, et un challenge `Basic` mensonger déclencherait le popup natif du
46
68
  * navigateur (l'anti-pattern que le BFF élimine). Les zones du firewall, elles,
47
69
  * restent strictement conformes (challenge du premier authenticator qui en
48
70
  * déclare un).
49
71
  */
50
- declare class SessionAuthController extends Controller {
72
+ declare class SessionAuthController extends SecurityApiController {
51
73
  #private;
52
74
  constructor(context: ContextType);
53
75
  /** Login BFF : credential JSON présenté UNE fois → cookie de session opaque. */
@@ -58,9 +80,18 @@ declare class SessionAuthController extends Controller {
58
80
  * absent/invalide → 401 (uniforme) ; trop de tentatives → 429 (Retry-After).
59
81
  */
60
82
  loginTotp(): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
61
- /** Détruit la session (storage + cookie). Toujours 200 (idempotent). */
83
+ /**
84
+ * Détruit la session (storage + cookie). Toujours 200 (idempotent). Quand la
85
+ * session venait d'un fournisseur qui publie un point de déconnexion, la
86
+ * réponse porte `logoutUrl` : le client y envoie le navigateur pour fermer
87
+ * aussi la session du fournisseur.
88
+ */
62
89
  logout(): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
63
- /** Identité de la session courante, re-résolue (rôles frais), ou 401. */
90
+ /**
91
+ * Identité de la session courante, re-résolue (rôles frais). Toujours `200` :
92
+ * `{ user: null }` quand il n'y a pas de session (ou qu'elle ne mène plus à
93
+ * un compte actif) — l'absence de session est un état, pas une erreur.
94
+ */
64
95
  me(): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
65
96
  }
66
97
  /**
@@ -1,6 +1,6 @@
1
1
  import type { Module } from "nodefony";
2
2
  import type { ContextType } from "@nodefony/http";
3
- import Controller from "../src/Controller.js";
3
+ import SecurityApiController from "../src/SecurityApiController.js";
4
4
  /**
5
5
  * Vue MINIMALE du service d'émission de jetons (`tokenService`, posé au container
6
6
  * par `@nodefony/security` P6 J4). Contrat structurel local : framework ne dépend
@@ -10,7 +10,9 @@ export interface ITokenIssuer {
10
10
  /** `true` si l'émission JWT est opérationnelle (JWT activé + store prêt). */
11
11
  isEnabled(): boolean;
12
12
  /** Émet access+refresh après vérification d'un credential (grant M2M/CLI). */
13
- issueForCredentials(identifier: unknown, password: unknown, scopes?: string[], resource?: unknown): Promise<unknown>;
13
+ issueForCredentials(identifier: unknown, password: unknown, scopes?: string[], resource?: unknown,
14
+ /** Requête porteuse — soumet le credential au refus du clair en production. */
15
+ context?: ContextType): Promise<unknown>;
14
16
  /** Rotation d'un refresh token (nouveau couple, ancien révoqué). */
15
17
  refresh(rawRefresh: unknown, resource?: unknown): Promise<unknown>;
16
18
  }
@@ -31,11 +33,11 @@ export interface ITokenIssuer {
31
33
  * framework ne peut pas importer les classes d'erreur de security ; un 429
32
34
  * reporte le `Retry-After` (RFC 6585) du throttler NIST.
33
35
  *
34
- * @remarks Dérogation RFC 7235 §3.1 (comme `SessionAuthController`) : ces 401 ne
36
+ * @remarks Dérogation RFC 9110 §15.5.2 (comme `SessionAuthController`) : ces 401 ne
35
37
  * portent pas de `WWW-Authenticate` — l'endpoint d'émission n'est pas une
36
38
  * ressource protégée par Bearer, il DÉLIVRE le Bearer.
37
39
  */
38
- declare class TokenAuthController extends Controller {
40
+ declare class TokenAuthController extends SecurityApiController {
39
41
  #private;
40
42
  constructor(context: ContextType);
41
43
  /** Émission : credential présenté → couple access/refresh. */
@@ -1,6 +1,6 @@
1
1
  import type { Module } from "nodefony";
2
2
  import type { ContextType } from "@nodefony/http";
3
- import Controller from "../src/Controller.js";
3
+ import SecurityApiController from "../src/SecurityApiController.js";
4
4
  /**
5
5
  * Endpoints HTTP **self-service 2FA TOTP (P6.17)** — console « ma sécurité »,
6
6
  * adaptateurs MINCES au-dessus du service `totp` (`@nodefony/security`) :
@@ -19,7 +19,7 @@ import Controller from "../src/Controller.js";
19
19
  * `totp` existe (security chargé) — sans security, 404 et zéro surface ; 2FA
20
20
  * désactivé, les routes répondent 503.
21
21
  */
22
- declare class TotpController extends Controller {
22
+ declare class TotpController extends SecurityApiController {
23
23
  #private;
24
24
  constructor(context: ContextType);
25
25
  /** Démarre l'enrôlement : secret + URI otpauth affichés une seule fois. */
@@ -1,6 +1,6 @@
1
- import type { Module } from "nodefony";
1
+ import { type Module } from "nodefony";
2
2
  import type { ContextType } from "@nodefony/http";
3
- import Controller from "../src/Controller.js";
3
+ import SecurityApiController from "../src/SecurityApiController.js";
4
4
  /**
5
5
  * Options de cérémonie renvoyées au navigateur (forme JSON WebAuthn) — seul le
6
6
  * `challenge` nous intéresse côté serveur (stocké en session, anti-replay) ; le
@@ -20,6 +20,7 @@ export interface IWebAuthnService {
20
20
  id: string;
21
21
  name: string;
22
22
  displayName?: string;
23
+ handle?: string;
23
24
  }): Promise<CeremonyOptions>;
24
25
  verifyRegistration(response: unknown, expectedChallenge: string, userId: string, requestOrigin?: string): Promise<{
25
26
  id: string;
@@ -46,7 +47,8 @@ export interface IWebAuthnSession {
46
47
  /** Vue minimale du flux de session BFF (`authFlow`) consommée ici. */
47
48
  export interface IWebAuthnBffFlow {
48
49
  me(context: ContextType): Promise<{
49
- username: string;
50
+ id: string;
51
+ identifier: string;
50
52
  } | null>;
51
53
  /**
52
54
  * @param reason - facteur d'authentification journalisé par l'audit
@@ -84,9 +86,15 @@ export interface IWebAuthnBffFlow {
84
86
  * firewall (qui, sur l'aire data plane, déclencherait un deadlock identique au
85
87
  * login BFF).
86
88
  */
87
- declare class WebAuthnController extends Controller {
89
+ declare class WebAuthnController extends SecurityApiController {
88
90
  #private;
89
91
  constructor(context: ContextType);
92
+ /**
93
+ * Dit si la connexion par passkey répondra : `{ enabled }`, toujours `200`.
94
+ * Un écran de connexion le lit pour ne pas proposer un bouton dont la
95
+ * cérémonie répondrait `503` (passkeys coupées).
96
+ */
97
+ status(): import("nodefony").MaybePromise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
90
98
  /** Défi d'enregistrement — l'utilisateur connecté ajoute un passkey. */
91
99
  registerOptions(): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
92
100
  /** Vérifie la réponse d'enregistrement et persiste le credential. */
@@ -562,7 +562,49 @@ declare function resolveParamArg(meta: ParamMeta, ctx: IParamArgContext): unknow
562
562
  * @param ctx - contexte de requête (forme structurelle minimale)
563
563
  * @returns arguments positionnels à spread dans l'action
564
564
  */
565
- declare function buildParamArgs(metas: ParamMeta[], ctx: IParamArgContext): unknown[];
565
+ declare function buildParamArgs(metas: readonly ParamMeta[], ctx: IParamArgContext): unknown[];
566
+ /**
567
+ * Ce qu'un `Context` — HTTP comme WebSocket — offre à la résolution des
568
+ * paramètres décorés. Forme structurelle : le framework n'importe pas les
569
+ * classes de contexte pour la lire.
570
+ */
571
+ export interface IParamArgSource {
572
+ /** La requête du contexte (HTTP, ou l'upgrade d'une connexion WS). */
573
+ readonly request?: unknown;
574
+ /** La réponse du contexte. */
575
+ readonly response?: unknown;
576
+ /** La session, quand le contexte en porte une. */
577
+ readonly session?: unknown;
578
+ /** Lit un cookie de la requête, ou tous sans argument. */
579
+ getRequestCookies(name?: string): unknown;
580
+ }
581
+ /**
582
+ * Résout les paramètres décorés d'une action depuis un `Context` — la mise en
583
+ * forme UNIQUE, appelée par le Resolver (routes HTTP et WS) comme par les
584
+ * actions `@RealtimeAction`, pour qu'un décorateur ne puisse pas prendre deux
585
+ * sens selon le transport.
586
+ *
587
+ * Le corps d'un appel WebSocket voyage dans l'ALS (`RequestContext` clé
588
+ * `body`) et `@CurrentUser` y lit l'utilisateur : l'appelant pose la bulle,
589
+ * cette fonction n'en crée aucune.
590
+ *
591
+ * @param metas - paramètres décorés de l'action ({@link getParamArgsMeta})
592
+ * @param context - le contexte de la requête ou de la connexion
593
+ * @param paramsMap - variables de route nommées (vide hors route)
594
+ * @param queryOverride - query de l'invocation quand elle ne vient pas de l'URL
595
+ * @returns arguments positionnels à passer à l'action
596
+ */
597
+ export declare function buildContextParamArgs(metas: readonly ParamMeta[], context: IParamArgSource, paramsMap: Record<string, unknown>, queryOverride?: Record<string, unknown>): unknown[];
598
+ /**
599
+ * Les paramètres décorés d'une méthode (`@Body`, `@CurrentUser`…), ou `null`
600
+ * si elle n'en porte aucun. Lecture `Reflect` : à faire une fois, hors du
601
+ * chemin de chaque requête.
602
+ *
603
+ * @param proto - le prototype de la classe
604
+ * @param method - le nom de la méthode
605
+ * @returns les métadonnées, ou `null` — une action sans décorateur ne paie rien
606
+ */
607
+ export declare function getParamArgsMeta(proto: object, method: string | symbol): readonly ParamMeta[] | null;
566
608
  /**
567
609
  * P2.9 — Indique si l'action d'une route attend le **flux brut** du body
568
610
  * (un paramètre `@Body({ stream:true })`). Le résultat est **mémoïsé** sur
@@ -588,7 +630,7 @@ declare function routeExpectsBodyStream(routeDef: {
588
630
  */
589
631
  export interface RouteActionMeta {
590
632
  /** Paramètres décorés (`@Param`/`@Body`/`@Query`…) — `null` si aucun. */
591
- paramsMeta: ParamMeta[] | null;
633
+ paramsMeta: readonly ParamMeta[] | null;
592
634
  /** `@Redirect` de l'action — `null` si absent. */
593
635
  redirectMeta: RedirectMeta | null;
594
636
  /** `@HttpCode` de l'action — `null` si absent. */
@@ -603,6 +645,8 @@ export interface RouteActionMeta {
603
645
  * path (ni résolution de service, ni `decide`, ni await). Frozen, partagé.
604
646
  */
605
647
  security: SecurityRequirement | null;
648
+ /** `@RequireAuthentication` (fusion au plus strict) — `null` = 0 coût, frozen. */
649
+ authentication: IAuthenticationRequirement | null;
606
650
  /**
607
651
  * Directives CSP additionnelles (`@Csp`, fusion classe+méthode) — `null` si
608
652
  * l'action n'en déclare pas (cas courant → 0 composition CSP). Frozen, partagé.
@@ -649,4 +693,45 @@ declare function resolveActionMeta(routeDef: {
649
693
  classMethod?: string | undefined;
650
694
  actionMeta?: RouteActionMeta | undefined;
651
695
  }): 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, };
696
+ 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, };
697
+ /** Options de {@link RequireAuthentication}. */
698
+ export interface IRequireAuthenticationOptions {
699
+ /** Âge maximal de la connexion, en secondes. Omis = aucune limite d'âge. */
700
+ readonly maxAge?: number;
701
+ /**
702
+ * Moyens d'authentification EXIGÉS, tous présents (RFC 8176) : `"pwd"` mot
703
+ * de passe, `"otp"` code à usage unique, `"pop"` clé d'accès. Omis = aucun.
704
+ */
705
+ readonly amr?: readonly string[];
706
+ }
707
+ /**
708
+ * Exigence d'authentification **figée** d'une action — fusion classe + méthode
709
+ * au plus strict (âge le plus court, moyens réunis), calculée UNE fois par
710
+ * route. Objet partagé entre requêtes : ne jamais muter.
711
+ */
712
+ export interface IAuthenticationRequirement {
713
+ /** Âge maximal en secondes, `null` = sans limite. */
714
+ readonly maxAge: number | null;
715
+ /** Moyens exigés, tous présents. */
716
+ readonly amr: readonly string[];
717
+ }
718
+ /**
719
+ * Exige une connexion RÉCENTE et/ou faite avec certains moyens pour l'action
720
+ * (décorateur de méthode) ou tout le contrôleur (décorateur de classe).
721
+ *
722
+ * - `@RequireAuthentication({ maxAge: 300 })` — connexion de moins de 5 minutes.
723
+ * - `@RequireAuthentication({ amr: ["otp"] })` — second facteur exigé.
724
+ * - les deux se combinent ; classe + méthode fusionnent au plus strict.
725
+ *
726
+ * Lit ce que la session a retenu à son ouverture (`readSessionAuthentication`,
727
+ * ADR-0015) et s'évalue dans le `Resolver` APRÈS `@IsGranted` : un rôle absent
728
+ * reste un 403 définitif, une connexion trop ancienne ou trop faible devient un
729
+ * **401** dont le `WWW-Authenticate` porte
730
+ * `error="insufficient_user_authentication"` (RFC 9470) — le client sait qu'il
731
+ * doit redemander une connexion, pas que l'accès lui est fermé. Une session qui
732
+ * ne porte pas ces métadonnées est refusée, jamais acceptée.
733
+ *
734
+ * @param options - âge maximal et moyens exigés.
735
+ * @throws TypeError si `maxAge` n'est pas un entier positif ou si un moyen est vide.
736
+ */
737
+ declare function RequireAuthentication(options?: IRequireAuthenticationOptions): (target: object, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
@@ -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>>;
@@ -30,6 +30,19 @@ declare class Router extends Service implements IRequestRouter {
30
30
  * cache, la requête suivante recrée.
31
31
  */
32
32
  getSingletonController<T extends object>(ctor: new (...args: never[]) => T, create: () => MaybePromise<T>): MaybePromise<T>;
33
+ /**
34
+ * Dit si au moins une route déclare le transport `WEBSOCKET` pour ce chemin
35
+ * — contrôle d'avant `101` du pipeline WebSocket (cf `IRequestRouter`).
36
+ *
37
+ * Parcours de la table entière, sans l'index de `resolve` : il est lu une
38
+ * fois par CONNEXION (pas par message), et une route dont les méthodes
39
+ * changent après coup (`addRequirement`) ne laisse ainsi aucun index périmé.
40
+ * Même normalisation que `resolve` (`stripTrailingSlashes`), même motif.
41
+ *
42
+ * @param pathname - chemin de l'URL d'upgrade
43
+ * @returns `true` si une route WebSocket a un motif qui couvre ce chemin
44
+ */
45
+ servesWebsocket(pathname: string): boolean;
33
46
  /**
34
47
  * Résout une route pour un contexte donné.
35
48
  *
@@ -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,6 +95,13 @@ 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
  /**
@@ -177,6 +185,37 @@ declare class Controller extends Service implements IController {
177
185
  * @throws de façon SYNCHRONE ce que lève `JSON.stringify` (cycle, BigInt).
178
186
  */
179
187
  renderJson(obj: unknown, status?: string | number, headers?: OutgoingHttpHeaders): MaybePromise<Http2Response | HttpResponse | WebsocketResponse>;
188
+ /**
189
+ * Le client demande-t-il un flux d'événements (`Accept: text/event-stream`) ?
190
+ *
191
+ * La route ne change pas de méthode pour autant : un même `POST` répond en
192
+ * JSON ou en flux selon cette réponse. Toujours `false` sur WebSocket.
193
+ *
194
+ * @returns `true` quand `text/event-stream` est accepté avec un poids non nul.
195
+ */
196
+ acceptsSse(): boolean;
197
+ /**
198
+ * Répond par un flux d'événements serveur, en HTTP/1.1 comme en HTTP/2.
199
+ *
200
+ * L'action garde la main : elle `send()` à son rythme et `close()` quand elle
201
+ * a fini ; le départ du client ferme le flux (`sse.closed`, `sse.onClose`).
202
+ * Rendre le flux depuis l'action est sans effet — la réponse est déjà prise.
203
+ *
204
+ * @example
205
+ * ```ts
206
+ * @Get("/progress")
207
+ * async progress() {
208
+ * const sse = await this.renderSse();
209
+ * for (const step of steps) await sse.send(step, { event: "step" });
210
+ * return sse.close();
211
+ * }
212
+ * ```
213
+ *
214
+ * @param options - battement de cœur, délai `retry:` initial.
215
+ * @returns le flux, ou sa promesse quand une session est à sauver d'abord.
216
+ * @throws HttpError 500 hors HTTP (WebSocket) ou quand la réponse est partie.
217
+ */
218
+ renderSse(options?: ISseStreamOptions): SseStream | Promise<SseStream>;
180
219
  /**
181
220
  * Pose la route de la requête courante sur l'instance (per-request).
182
221
  *
@@ -115,6 +115,12 @@ declare class Resolver implements IResolver {
115
115
  * `instanceof` au message suivant — le hub était RÉINSTANCIÉ (DI +
116
116
  * `initialize()`) à chaque frame qui suivait un `api.request`.
117
117
  *
118
+ * Ni pour un `forward` sur une connexion WebSocket : son résolveur est neuf,
119
+ * pas une invocation par message, et réécrivait le pointeur que la poignée
120
+ * de main a posé — même réinstanciation à la frame suivante (#574). Une
121
+ * connexion garde le contrôleur qui l'a ouverte. Chemin froid : seulement
122
+ * quand un contrôleur est créé.
123
+ *
118
124
  * @returns le controller reçu, pour enchaîner.
119
125
  */
120
126
  private _pinController;
@@ -339,5 +345,18 @@ declare class Resolver implements IResolver {
339
345
  returnController(result: unknown): MaybePromise<unknown>;
340
346
  /** Statut courant de la réponse (0 si le transport n'en porte pas). */
341
347
  private getResponseStatus;
348
+ /**
349
+ * `@RequireAuthentication` — les gardes d'autorisation d'abord (un rôle
350
+ * absent reste un 403 définitif : inutile de faire redemander une connexion à
351
+ * qui n'aura pas le droit), PUIS l'âge et les moyens de la connexion.
352
+ *
353
+ * Le refus est un 401 « step-up » (RFC 9470) : le `WWW-Authenticate` porte
354
+ * `error="insufficient_user_authentication"` et, quand la route fixe un âge,
355
+ * `max_age` — le client redemande une connexion au lieu de conclure à un
356
+ * accès fermé. Cold path : seules les routes décorées y passent.
357
+ *
358
+ * @throws nodefonyError 401 si la connexion est trop ancienne ou trop faible.
359
+ */
360
+ private _enforceStepUp;
342
361
  }
343
362
  export default Resolver;
@@ -62,9 +62,25 @@ export interface RouteOptions {
62
62
  * exception qui se justifie. Défaut `false`.
63
63
  */
64
64
  areaRoleExempt?: boolean;
65
+ /**
66
+ * Route de REPLI : elle ne sert un chemin que si aucune autre route ne le
67
+ * sert, quel que soit l'ordre de déclaration.
68
+ *
69
+ * Le routeur retient la première route déclarée qui correspond ; un repli à
70
+ * variable (`/nodefony/{page}`, la page d'une application monopage) déclaré
71
+ * tôt capturerait donc les routes de même forme des modules chargés après
72
+ * lui — sans erreur ni avertissement. Les replis sont rangés après toutes les
73
+ * autres routes, dans leur ordre relatif, à la construction de l'index :
74
+ * aucun coût par requête. Défaut `false`.
75
+ */
76
+ fallback?: boolean;
65
77
  }
66
78
  export interface RouteRequirements {
67
79
  domain?: string | string[] | undefined;
80
+ /**
81
+ * Transport exigé : `https`/`wss` refuse un canal clair, `http`/`ws` un
82
+ * canal chiffré — 403 sinon. Proto transmis par un proxy de confiance compris.
83
+ */
68
84
  scheme?: SchemeType | undefined;
69
85
  methods?: HTTPMethod[] | HTTPMethod | undefined;
70
86
  protocol?: string | undefined;
@@ -77,7 +93,6 @@ declare class Route implements IRoute {
77
93
  classMethod?: string | undefined;
78
94
  prefix?: string | undefined;
79
95
  method?: HTTPMethod | undefined;
80
- schemes?: SchemeType | undefined;
81
96
  pattern?: RegExp | undefined;
82
97
  variables: string[];
83
98
  /**
@@ -115,6 +130,8 @@ declare class Route implements IRoute {
115
130
  * pas son rôle par défaut. Cf {@link RouteOptions.areaRoleExempt}.
116
131
  */
117
132
  areaRoleExempt: boolean;
133
+ /** Route de repli, résolue après toutes les autres. Cf {@link RouteOptions.fallback}. */
134
+ fallback: boolean;
118
135
  /**
119
136
  * P2.9 — Cache mémoïsé : l'action attend-elle le **flux brut** du body
120
137
  * (`@Body({ stream:true })`) ? `undefined` = pas encore calculé (résolu au 1er
@@ -0,0 +1,17 @@
1
+ import type { OutgoingHttpHeaders } from "node:http";
2
+ import Controller from "./Controller.js";
3
+ /**
4
+ * Base des contrôleurs d'authentification (`/nodefony/security/api/*`) : toute
5
+ * réponse JSON part en `Cache-Control: no-store`.
6
+ *
7
+ * Ces réponses portent une identité, un jeton, un secret affiché une seule
8
+ * fois (clé API, secret TOTP, codes de récupération) ou leur refus. Un cookie
9
+ * 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é.
13
+ */
14
+ declare abstract class SecurityApiController extends Controller {
15
+ renderJson(obj: unknown, status?: string | number, headers?: OutgoingHttpHeaders): ReturnType<Controller["renderJson"]>;
16
+ }
17
+ export default SecurityApiController;