@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,5 +1,6 @@
1
- import Controller from "../src/Controller.js";
2
1
  import router_default from "../service/router.js";
2
+ import SecurityApiController from "../src/SecurityApiController.js";
3
+ import { WEBAUTHN_API_BASE, WEBAUTHN_STATUS_PATH } from "nodefony";
3
4
  //#region nodefony/controller/WebAuthnController.ts
4
5
  const REG_CHALLENGE = "webauthn:reg:challenge";
5
6
  const AUTH_CHALLENGE = "webauthn:auth:challenge";
@@ -31,10 +32,18 @@ let mounted = false;
31
32
  * firewall (qui, sur l'aire data plane, déclencherait un deadlock identique au
32
33
  * login BFF).
33
34
  */
34
- var WebAuthnController = class extends Controller {
35
+ var WebAuthnController = class extends SecurityApiController {
35
36
  constructor(context) {
36
37
  super("WebAuthnController", context);
37
38
  }
39
+ /**
40
+ * Dit si la connexion par passkey répondra : `{ enabled }`, toujours `200`.
41
+ * Un écran de connexion le lit pour ne pas proposer un bouton dont la
42
+ * cérémonie répondrait `503` (passkeys coupées).
43
+ */
44
+ status() {
45
+ return this.renderJson({ enabled: this.#service() !== null });
46
+ }
38
47
  /** Défi d'enregistrement — l'utilisateur connecté ajoute un passkey. */
39
48
  async registerOptions() {
40
49
  const svc = this.#service();
@@ -43,8 +52,9 @@ var WebAuthnController = class extends Controller {
43
52
  const me = await flow.me(this.context);
44
53
  if (!me) return this.renderJson({ error: "Unauthorized" }, 401);
45
54
  const options = await svc.generateRegistrationOptions({
46
- id: me.username,
47
- name: me.username
55
+ id: me.identifier,
56
+ name: me.identifier,
57
+ handle: me.id
48
58
  });
49
59
  const session = await flow.ensureSession(this.context);
50
60
  session?.set(REG_CHALLENGE, options.challenge);
@@ -60,7 +70,7 @@ var WebAuthnController = class extends Controller {
60
70
  const challenge = await this.#takeChallenge(REG_CHALLENGE);
61
71
  if (!challenge) return this.renderJson({ error: "No challenge" }, 400);
62
72
  try {
63
- const credential = await svc.verifyRegistration(this.#body().response, challenge, me.username, this.#origin());
73
+ const credential = await svc.verifyRegistration(this.#body().response, challenge, me.identifier, this.#origin());
64
74
  return await this.renderJson({
65
75
  verified: true,
66
76
  credentialId: credential.id
@@ -90,7 +100,7 @@ var WebAuthnController = class extends Controller {
90
100
  const flow = this.#flow();
91
101
  if (!svc || !flow) return this.renderJson({ error: "WebAuthn unavailable" }, 503);
92
102
  const me = await flow.me(this.context);
93
- const options = await svc.generateAuthenticationOptions(me?.username);
103
+ const options = await svc.generateAuthenticationOptions(me?.identifier);
94
104
  const session = await flow.ensureSession(this.context);
95
105
  session?.set(AUTH_CHALLENGE, options.challenge);
96
106
  await session?.save();
@@ -121,7 +131,7 @@ var WebAuthnController = class extends Controller {
121
131
  if (!svc || !flow) return this.renderJson({ error: "WebAuthn unavailable" }, 503);
122
132
  const me = await flow.me(this.context);
123
133
  if (!me) return this.renderJson({ error: "Unauthorized" }, 401);
124
- const creds = await svc.listUserCredentials(me.username);
134
+ const creds = await svc.listUserCredentials(me.identifier);
125
135
  return this.renderJson({ credentials: creds.map((c) => ({
126
136
  id: c.id,
127
137
  transports: c.transports,
@@ -138,7 +148,7 @@ var WebAuthnController = class extends Controller {
138
148
  const me = await flow.me(this.context);
139
149
  if (!me) return this.renderJson({ error: "Unauthorized" }, 401);
140
150
  if (typeof id !== "string" || id.length === 0) return this.renderJson({ error: "Not found" }, 404);
141
- if (!await svc.removeUserCredential(me.username, id)) return this.renderJson({ error: "Not found" }, 404);
151
+ if (!await svc.removeUserCredential(me.identifier, id)) return this.renderJson({ error: "Not found" }, 404);
142
152
  return this.renderJson({ ok: true });
143
153
  }
144
154
  #service() {
@@ -183,7 +193,7 @@ var WebAuthnController = class extends Controller {
183
193
  */
184
194
  function mountWebAuthnRoutes(frameworkModule) {
185
195
  if (mounted) return;
186
- const base = "/nodefony/security/api/webauthn";
196
+ const base = WEBAUTHN_API_BASE;
187
197
  const routes = [
188
198
  [
189
199
  "security.webauthn.register.options",
@@ -199,6 +209,13 @@ function mountWebAuthnRoutes(frameworkModule) {
199
209
  "registerVerify",
200
210
  true
201
211
  ],
212
+ [
213
+ "security.webauthn.status",
214
+ WEBAUTHN_STATUS_PATH,
215
+ "GET",
216
+ "status",
217
+ true
218
+ ],
202
219
  [
203
220
  "security.webauthn.login.options",
204
221
  `${base}/login/options`,
@@ -183,7 +183,7 @@ function route(name, options) {
183
183
  } catch (error) {
184
184
  filePath = error;
185
185
  }
186
- const metadata = Reflect.getMetadata(metadataKey, target.constructor) ?? {};
186
+ const metadata = { ...Reflect.getMetadata(metadataKey, target.constructor) };
187
187
  metadata[name] = {
188
188
  path,
189
189
  filePath,
@@ -195,7 +195,8 @@ function route(name, options) {
195
195
  host: options.host,
196
196
  defaults: options.defaults,
197
197
  requirements: options.requirements,
198
- bypassFirewall: options.bypassFirewall
198
+ bypassFirewall: options.bypassFirewall,
199
+ fallback: options.fallback
199
200
  };
200
201
  Reflect.defineMetadata(metadataKey, metadata, target.constructor);
201
202
  return descriptor;
@@ -312,7 +313,7 @@ function HttpCode(statusCode) {
312
313
  */
313
314
  function Header(key, value) {
314
315
  return function(target, propertyKey, descriptor) {
315
- const existing = Reflect.getMetadata("route:responseHeaders", target, propertyKey) ?? {};
316
+ const existing = { ...Reflect.getMetadata(HEADERS_METADATA, target, propertyKey) };
316
317
  existing[key] = value;
317
318
  Reflect.defineMetadata(HEADERS_METADATA, existing, target, propertyKey);
318
319
  return descriptor;
@@ -522,12 +523,12 @@ function IsGranted(attribute, options) {
522
523
  };
523
524
  return function(target, propertyKey, descriptor) {
524
525
  if (propertyKey === void 0) {
525
- const existing = Reflect.getMetadata(SECURITY_CLAUSES_METADATA, target) ?? [];
526
+ const existing = [...Reflect.getMetadata(SECURITY_CLAUSES_METADATA, target) ?? []];
526
527
  existing.push(clause);
527
528
  Reflect.defineMetadata(SECURITY_CLAUSES_METADATA, existing, target);
528
529
  return target;
529
530
  }
530
- const existing = Reflect.getMetadata(SECURITY_CLAUSES_METADATA, target, propertyKey) ?? [];
531
+ const existing = [...Reflect.getMetadata(SECURITY_CLAUSES_METADATA, target, propertyKey) ?? []];
531
532
  existing.push(clause);
532
533
  Reflect.defineMetadata(SECURITY_CLAUSES_METADATA, existing, target, propertyKey);
533
534
  return descriptor;
@@ -578,12 +579,12 @@ function RequireScope(scope) {
578
579
  const clause = { anyOf: Array.isArray(scope) ? [...scope] : [scope] };
579
580
  return function(target, propertyKey, descriptor) {
580
581
  if (propertyKey === void 0) {
581
- const existing = Reflect.getMetadata(SECURITY_SCOPES_METADATA, target) ?? [];
582
+ const existing = [...Reflect.getMetadata(SECURITY_SCOPES_METADATA, target) ?? []];
582
583
  existing.push(clause);
583
584
  Reflect.defineMetadata(SECURITY_SCOPES_METADATA, existing, target);
584
585
  return target;
585
586
  }
586
- const existing = Reflect.getMetadata(SECURITY_SCOPES_METADATA, target, propertyKey) ?? [];
587
+ const existing = [...Reflect.getMetadata(SECURITY_SCOPES_METADATA, target, propertyKey) ?? []];
587
588
  existing.push(clause);
588
589
  Reflect.defineMetadata(SECURITY_SCOPES_METADATA, existing, target, propertyKey);
589
590
  return descriptor;
@@ -700,7 +701,7 @@ function Idempotent(options) {
700
701
  function paramDecoratorFactory(source) {
701
702
  return function(key) {
702
703
  return function(target, propertyKey, parameterIndex) {
703
- const existing = Reflect.getMetadata("route:paramArgs", target, propertyKey) ?? [];
704
+ const existing = Reflect.getOwnMetadata("route:paramArgs", target, propertyKey) ?? [];
704
705
  existing.push({
705
706
  source,
706
707
  key,
@@ -756,7 +757,7 @@ function Body(keyOrOptions) {
756
757
  const key = isOptions ? void 0 : raw ?? void 0;
757
758
  const stream = isOptions ? raw.stream === true : false;
758
759
  return function(target, propertyKey, parameterIndex) {
759
- const existing = Reflect.getMetadata("route:paramArgs", target, propertyKey) ?? [];
760
+ const existing = Reflect.getOwnMetadata("route:paramArgs", target, propertyKey) ?? [];
760
761
  const meta = {
761
762
  source: "body",
762
763
  key,
@@ -807,7 +808,7 @@ function resolveParamArg(meta, ctx) {
807
808
  case "res": return ctx.response;
808
809
  case "file": return ctx.request?.queryFile?.[0];
809
810
  case "files": return ctx.request?.queryFile;
810
- case "user": return RequestContext.getUser();
811
+ case "user": return RequestContext.isAnonymous() ? void 0 : RequestContext.getUser();
811
812
  default: return;
812
813
  }
813
814
  }
@@ -826,6 +827,45 @@ function buildParamArgs(metas, ctx) {
826
827
  return result;
827
828
  }
828
829
  /**
830
+ * Résout les paramètres décorés d'une action depuis un `Context` — la mise en
831
+ * forme UNIQUE, appelée par le Resolver (routes HTTP et WS) comme par les
832
+ * actions `@RealtimeAction`, pour qu'un décorateur ne puisse pas prendre deux
833
+ * sens selon le transport.
834
+ *
835
+ * Le corps d'un appel WebSocket voyage dans l'ALS (`RequestContext` clé
836
+ * `body`) et `@CurrentUser` y lit l'utilisateur : l'appelant pose la bulle,
837
+ * cette fonction n'en crée aucune.
838
+ *
839
+ * @param metas - paramètres décorés de l'action ({@link getParamArgsMeta})
840
+ * @param context - le contexte de la requête ou de la connexion
841
+ * @param paramsMap - variables de route nommées (vide hors route)
842
+ * @param queryOverride - query de l'invocation quand elle ne vient pas de l'URL
843
+ * @returns arguments positionnels à passer à l'action
844
+ */
845
+ function buildContextParamArgs(metas, context, paramsMap, queryOverride) {
846
+ return buildParamArgs(metas, {
847
+ paramsMap,
848
+ request: context.request,
849
+ response: context.response,
850
+ session: context.session,
851
+ queryOverride,
852
+ getRequestCookies: (name) => context.getRequestCookies(name)
853
+ });
854
+ }
855
+ /**
856
+ * Les paramètres décorés d'une méthode (`@Body`, `@CurrentUser`…), ou `null`
857
+ * si elle n'en porte aucun. Lecture `Reflect` : à faire une fois, hors du
858
+ * chemin de chaque requête.
859
+ *
860
+ * @param proto - le prototype de la classe
861
+ * @param method - le nom de la méthode
862
+ * @returns les métadonnées, ou `null` — une action sans décorateur ne paie rien
863
+ */
864
+ function getParamArgsMeta(proto, method) {
865
+ const metas = Reflect.getMetadata(PARAM_ARGS_METADATA, proto, method);
866
+ return metas !== void 0 && metas.length > 0 ? metas : null;
867
+ }
868
+ /**
829
869
  * P2.9 — Indique si l'action d'une route attend le **flux brut** du body
830
870
  * (un paramètre `@Body({ stream:true })`). Le résultat est **mémoïsé** sur
831
871
  * `route.bodyStream` : lecture `Reflect` au 1er appel, O(1) ensuite → 0 coût
@@ -852,6 +892,7 @@ const EMPTY_ACTION_META = {
852
892
  headerEntries: null,
853
893
  sessionIntent: null,
854
894
  security: null,
895
+ authentication: null,
855
896
  cspDirectives: null,
856
897
  csrfProtect: false,
857
898
  csrfExempt: false,
@@ -942,17 +983,17 @@ function computeIdempotent(ctor, method) {
942
983
  function computeActionMeta(ctor, method) {
943
984
  if (!ctor || !method) return EMPTY_ACTION_META;
944
985
  const proto = ctor.prototype;
945
- const params = Reflect.getMetadata(PARAM_ARGS_METADATA, proto, method);
946
986
  const redirect = Reflect.getMetadata(REDIRECT_METADATA, proto, method);
947
987
  const httpCode = Reflect.getMetadata(HTTP_CODE_METADATA, proto, method);
948
988
  const headers = Reflect.getMetadata(HEADERS_METADATA, proto, method);
949
989
  return {
950
- paramsMeta: params && params.length > 0 ? params : null,
990
+ paramsMeta: getParamArgsMeta(proto, method),
951
991
  redirectMeta: redirect ?? null,
952
992
  httpCode: httpCode ?? null,
953
993
  headerEntries: headers ? Object.entries(headers) : null,
954
994
  sessionIntent: resolveSessionIntent(ctor, method),
955
995
  security: computeSecurityRequirement(ctor, method),
996
+ authentication: computeAuthenticationRequirement(ctor, method),
956
997
  cspDirectives: computeCspDirectives(ctor, method),
957
998
  csrfProtect: Reflect.getMetadata(CSRF_PROTECT_METADATA, proto, method) === true || Reflect.getMetadata(CSRF_PROTECT_METADATA, ctor) === true,
958
999
  csrfExempt: Reflect.getMetadata(CSRF_EXEMPT_METADATA, proto, method) === true || Reflect.getMetadata(CSRF_EXEMPT_METADATA, ctor) === true,
@@ -972,5 +1013,65 @@ function resolveActionMeta(routeDef) {
972
1013
  routeDef.actionMeta ??= computeActionMeta(routeDef.controller, routeDef.classMethod);
973
1014
  return routeDef.actionMeta;
974
1015
  }
1016
+ const AUTHENTICATION_REQUIREMENT_METADATA = "nodefony:security:authentication";
1017
+ /**
1018
+ * Exige une connexion RÉCENTE et/ou faite avec certains moyens pour l'action
1019
+ * (décorateur de méthode) ou tout le contrôleur (décorateur de classe).
1020
+ *
1021
+ * - `@RequireAuthentication({ maxAge: 300 })` — connexion de moins de 5 minutes.
1022
+ * - `@RequireAuthentication({ amr: ["otp"] })` — second facteur exigé.
1023
+ * - les deux se combinent ; classe + méthode fusionnent au plus strict.
1024
+ *
1025
+ * Lit ce que la session a retenu à son ouverture (`readSessionAuthentication`,
1026
+ * ADR-0015) et s'évalue dans le `Resolver` APRÈS `@IsGranted` : un rôle absent
1027
+ * reste un 403 définitif, une connexion trop ancienne ou trop faible devient un
1028
+ * **401** dont le `WWW-Authenticate` porte
1029
+ * `error="insufficient_user_authentication"` (RFC 9470) — le client sait qu'il
1030
+ * doit redemander une connexion, pas que l'accès lui est fermé. Une session qui
1031
+ * ne porte pas ces métadonnées est refusée, jamais acceptée.
1032
+ *
1033
+ * @param options - âge maximal et moyens exigés.
1034
+ * @throws TypeError si `maxAge` n'est pas un entier positif ou si un moyen est vide.
1035
+ */
1036
+ function RequireAuthentication(options = {}) {
1037
+ const { maxAge, amr = [] } = options;
1038
+ if (maxAge !== void 0 && (!Number.isInteger(maxAge) || maxAge <= 0)) throw new TypeError(`@RequireAuthentication : maxAge doit être un entier de secondes > 0, reçu ${String(maxAge)}`);
1039
+ if (amr.some((m) => typeof m !== "string" || m.length === 0)) throw new TypeError("@RequireAuthentication : chaque moyen de `amr` est une chaîne non vide (RFC 8176 : pwd, otp, pop)");
1040
+ const requirement = Object.freeze({
1041
+ maxAge: maxAge ?? null,
1042
+ amr: Object.freeze([...amr])
1043
+ });
1044
+ return function(target, propertyKey, descriptor) {
1045
+ if (propertyKey === void 0) {
1046
+ Reflect.defineMetadata(AUTHENTICATION_REQUIREMENT_METADATA, requirement, target);
1047
+ return target;
1048
+ }
1049
+ Reflect.defineMetadata(AUTHENTICATION_REQUIREMENT_METADATA, requirement, target, propertyKey);
1050
+ return descriptor;
1051
+ };
1052
+ }
1053
+ /** Une métadonnée lue par `Reflect` qui a bien la forme d'une exigence, sinon `undefined`. */
1054
+ function asAuthenticationRequirement(value) {
1055
+ return typeof value === "object" && value !== null && "maxAge" in value && "amr" in value && (value.maxAge === null || typeof value.maxAge === "number") && Array.isArray(value.amr) ? {
1056
+ maxAge: value.maxAge,
1057
+ amr: value.amr.filter((m) => typeof m === "string")
1058
+ } : void 0;
1059
+ }
1060
+ /**
1061
+ * Fusionne les `@RequireAuthentication` de classe et de méthode au plus strict,
1062
+ * ou `null` si aucune — `@Anonymous` (méthode) rend l'action publique.
1063
+ */
1064
+ function computeAuthenticationRequirement(ctor, method) {
1065
+ if (Reflect.getMetadata(SECURITY_ANONYMOUS_METADATA, ctor, method) === true) return null;
1066
+ const onMethod = asAuthenticationRequirement(Reflect.getMetadata(AUTHENTICATION_REQUIREMENT_METADATA, ctor.prototype, method));
1067
+ const onClass = Reflect.getMetadata(SECURITY_ANONYMOUS_METADATA, ctor) === true ? void 0 : asAuthenticationRequirement(Reflect.getMetadata(AUTHENTICATION_REQUIREMENT_METADATA, ctor));
1068
+ if (onMethod === void 0) return onClass ?? null;
1069
+ if (onClass === void 0) return onMethod;
1070
+ const ages = [onClass.maxAge, onMethod.maxAge].filter((age) => age !== null);
1071
+ return Object.freeze({
1072
+ maxAge: ages.length > 0 ? Math.min(...ages) : null,
1073
+ amr: Object.freeze([.../* @__PURE__ */ new Set([...onClass.amr, ...onMethod.amr])])
1074
+ });
1075
+ }
975
1076
  //#endregion
976
- export { All, Anonymous, BYPASS_FIREWALL_CLASS_METADATA, BYPASS_FIREWALL_METHOD_METADATA, Body, BypassFirewall, Cookie, Csp, CsrfExempt, CsrfProtect, CurrentUser, DOMAIN_CLASS_METADATA, DOMAIN_METHOD_METADATA, Delete, Domain, Get, HEADERS_METADATA, HTTP_CODE_METADATA, Head, Header, Headers, HttpCode, Idempotent, IsGranted, Options, PARAM_ARGS_METADATA, Param, Patch, Post, Put, Query, REDIRECT_METADATA, Redirect, Req, RequireScope, Res, Scope, Session, USE_SESSION_CLASS_METADATA, USE_SESSION_METHOD_METADATA, UploadedFile, UploadedFiles, UseSession, buildParamArgs, computeActionMeta, controller, controllers, extractActionScopes, resolveActionMeta, resolveParamArg, resolveSessionIntent, route, routeExpectsBodyStream };
1077
+ export { All, Anonymous, BYPASS_FIREWALL_CLASS_METADATA, BYPASS_FIREWALL_METHOD_METADATA, Body, BypassFirewall, Cookie, Csp, CsrfExempt, CsrfProtect, CurrentUser, DOMAIN_CLASS_METADATA, DOMAIN_METHOD_METADATA, Delete, Domain, Get, HEADERS_METADATA, HTTP_CODE_METADATA, Head, Header, Headers, HttpCode, Idempotent, IsGranted, Options, PARAM_ARGS_METADATA, Param, Patch, Post, Put, Query, REDIRECT_METADATA, Redirect, Req, RequireAuthentication, RequireScope, Res, Scope, Session, USE_SESSION_CLASS_METADATA, USE_SESSION_METHOD_METADATA, UploadedFile, UploadedFiles, UseSession, buildContextParamArgs, buildParamArgs, computeActionMeta, controller, controllers, extractActionScopes, getParamArgsMeta, resolveActionMeta, resolveParamArg, resolveSessionIntent, route, routeExpectsBodyStream };
@@ -1,5 +1,5 @@
1
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js";
2
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorate.js";
1
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
2
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
3
3
  import router_default from "./router.js";
4
4
  import AdminApiController from "../controller/AdminApiController.js";
5
5
  import { ADMIN_DEFAULT_ROLE, Module, Service, injectable, resolveAdminRole } from "nodefony";
@@ -1,5 +1,5 @@
1
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js";
2
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorate.js";
1
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
2
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
3
3
  import { Module, Service, assertPageQuery, injectable } from "nodefony";
4
4
  //#region nodefony/service/IdempotencyStore.ts
5
5
  const serviceName = "idempotencyStore";
@@ -1,9 +1,9 @@
1
1
  import Route from "../src/Route.js";
2
2
  import { routeExpectsBodyStream } from "../decorators/routerDecorators.js";
3
3
  import Resolver from "../src/Resolver.js";
4
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js";
5
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorate.js";
6
- import { Injector, Module, Service, injectable, isPromise } from "nodefony";
4
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
5
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
6
+ import { Injector, Module, Service, injectable, isPromise, stripTrailingSlashes } from "nodefony";
7
7
  import { HttpError, isDomainAllowed } from "@nodefony/http";
8
8
  //#region nodefony/service/router.ts
9
9
  var _Router;
@@ -52,25 +52,13 @@ function invalidateRouteIndex() {
52
52
  function buildRouteIndex() {
53
53
  const statics = /* @__PURE__ */ new Map();
54
54
  const dynamics = [];
55
- for (const [i, route] of routes.entries()) {
56
- const path = route.path;
57
- if (path !== void 0 && route.variables.length === 0 && !REG_NON_LITERAL.test(path)) {
58
- const key = path.toLowerCase();
59
- let list = statics.get(key);
60
- if (list === void 0) {
61
- list = [];
62
- statics.set(key, list);
63
- }
64
- list.push({
65
- route,
66
- pos: i,
67
- prefix: ""
68
- });
69
- } else dynamics.push({
70
- route,
71
- pos: i,
72
- prefix: literalPrefix(path)
73
- });
55
+ const total = routes.length;
56
+ for (let pass = 0; pass < 2; pass++) {
57
+ const fallbackPass = pass === 1;
58
+ for (const [rank, route] of routes.entries()) {
59
+ if (route.fallback !== fallbackPass) continue;
60
+ indexRoute(route, fallbackPass ? total + rank : rank, statics, dynamics);
61
+ }
74
62
  }
75
63
  return routeIndex = {
76
64
  statics,
@@ -80,6 +68,30 @@ function buildRouteIndex() {
80
68
  last: routes[routes.length - 1]
81
69
  };
82
70
  }
71
+ /**
72
+ * Range une route dans l'index — littérale par son chemin exact, dynamique
73
+ * sinon — à la position `i` qui décide de son rang dans la fusion de `resolve`.
74
+ */
75
+ function indexRoute(route, i, statics, dynamics) {
76
+ const path = route.path;
77
+ if (path !== void 0 && route.variables.length === 0 && !REG_NON_LITERAL.test(path)) {
78
+ const key = path.toLowerCase();
79
+ let list = statics.get(key);
80
+ if (list === void 0) {
81
+ list = [];
82
+ statics.set(key, list);
83
+ }
84
+ list.push({
85
+ route,
86
+ pos: i,
87
+ prefix: ""
88
+ });
89
+ } else dynamics.push({
90
+ route,
91
+ pos: i,
92
+ prefix: literalPrefix(path)
93
+ });
94
+ }
83
95
  let Router = class Router extends Service {
84
96
  static {
85
97
  _Router = this;
@@ -127,6 +139,23 @@ let Router = class Router extends Service {
127
139
  return pending;
128
140
  }
129
141
  /**
142
+ * Dit si au moins une route déclare le transport `WEBSOCKET` pour ce chemin
143
+ * — contrôle d'avant `101` du pipeline WebSocket (cf `IRequestRouter`).
144
+ *
145
+ * Parcours de la table entière, sans l'index de `resolve` : il est lu une
146
+ * fois par CONNEXION (pas par message), et une route dont les méthodes
147
+ * changent après coup (`addRequirement`) ne laisse ainsi aucun index périmé.
148
+ * Même normalisation que `resolve` (`stripTrailingSlashes`), même motif.
149
+ *
150
+ * @param pathname - chemin de l'URL d'upgrade
151
+ * @returns `true` si une route WebSocket a un motif qui couvre ce chemin
152
+ */
153
+ servesWebsocket(pathname) {
154
+ const path = stripTrailingSlashes(pathname);
155
+ for (const route of routes) if (route.methodsSet?.has("WEBSOCKET") === true && route.pattern?.test(path) === true) return true;
156
+ return false;
157
+ }
158
+ /**
130
159
  * Résout une route pour un contexte donné.
131
160
  *
132
161
  * @param context - le contexte HTTP/WS courant (porte container, méthode, URL…).
@@ -1,6 +1,6 @@
1
1
  import { isDevelopment, singletonWriteMessage } from "./singletonGuard.js";
2
2
  import { FileClass, RequestContext, Service } from "nodefony";
3
- import { HttpError } from "@nodefony/http";
3
+ import { HttpContext, HttpError, acceptsEventStream } from "@nodefony/http";
4
4
  import fs, { createReadStream } from "node:fs";
5
5
  import { promisify } from "node:util";
6
6
  //#region nodefony/src/Controller.ts
@@ -167,6 +167,15 @@ var Controller = class extends Service {
167
167
  this.#assertPerRequest("response");
168
168
  this.#response = response;
169
169
  }
170
+ /**
171
+ * L'utilisateur CONNECTÉ, ou `null` — anonyme, ou route hors de toute zone
172
+ * du pare-feu. C'est l'identité que le pare-feu a établie pour cette
173
+ * requête : aucun appel de plus. Une page publique qui veut savoir qui la
174
+ * visite se place sous une zone `authenticators: ["session", "anonymous"]`.
175
+ */
176
+ get user() {
177
+ return RequestContext.getAuthenticatedUser();
178
+ }
170
179
  get method() {
171
180
  return this.#method ?? this.context?.method ?? void 0;
172
181
  }
@@ -345,14 +354,13 @@ var Controller = class extends Service {
345
354
  const fe = this.get("frontend");
346
355
  if (!fe?.renderTags) return param;
347
356
  const nonce = this.context?.cspNonce;
348
- const host = this.context?.domain;
349
357
  const renderTags = fe.renderTags;
350
358
  const renderDocument = fe.renderDocument;
351
359
  return {
352
- frontendTags: (entry) => renderTags(entry, nonce, host),
360
+ frontendTags: (entry) => renderTags(entry, nonce),
353
361
  frontendDocument: (entry) => {
354
362
  if (!renderDocument) throw new Error("frontend service does not expose renderDocument");
355
- return renderDocument(entry, nonce, host);
363
+ return renderDocument(entry, nonce);
356
364
  },
357
365
  asset: (p) => fe.assetUrl ? fe.assetUrl(p) : p,
358
366
  ...param
@@ -370,6 +378,43 @@ var Controller = class extends Service {
370
378
  this.setContextJson();
371
379
  return this.renderResponse(data, "utf8", status, headers);
372
380
  }
381
+ /**
382
+ * Le client demande-t-il un flux d'événements (`Accept: text/event-stream`) ?
383
+ *
384
+ * La route ne change pas de méthode pour autant : un même `POST` répond en
385
+ * JSON ou en flux selon cette réponse. Toujours `false` sur WebSocket.
386
+ *
387
+ * @returns `true` quand `text/event-stream` est accepté avec un poids non nul.
388
+ */
389
+ acceptsSse() {
390
+ if (!(this.context instanceof HttpContext)) return false;
391
+ return acceptsEventStream(this.context.request.headers.accept);
392
+ }
393
+ /**
394
+ * Répond par un flux d'événements serveur, en HTTP/1.1 comme en HTTP/2.
395
+ *
396
+ * L'action garde la main : elle `send()` à son rythme et `close()` quand elle
397
+ * a fini ; le départ du client ferme le flux (`sse.closed`, `sse.onClose`).
398
+ * Rendre le flux depuis l'action est sans effet — la réponse est déjà prise.
399
+ *
400
+ * @example
401
+ * ```ts
402
+ * @Get("/progress")
403
+ * async progress() {
404
+ * const sse = await this.renderSse();
405
+ * for (const step of steps) await sse.send(step, { event: "step" });
406
+ * return sse.close();
407
+ * }
408
+ * ```
409
+ *
410
+ * @param options - battement de cœur, délai `retry:` initial.
411
+ * @returns le flux, ou sa promesse quand une session est à sauver d'abord.
412
+ * @throws HttpError 500 hors HTTP (WebSocket) ou quand la réponse est partie.
413
+ */
414
+ renderSse(options) {
415
+ if (!(this.context instanceof HttpContext)) throw new HttpError("SSE : un flux d'événements exige une requête HTTP", 500, this.context);
416
+ return this.context.openSse(options);
417
+ }
373
418
  setRoute(route) {
374
419
  this.#assertPerRequest("route");
375
420
  this.#route = route;
@@ -68,7 +68,8 @@ function createFrameworkAdminApi(broker, opts) {
68
68
  scope: route.controller?.scope ?? null,
69
69
  module: route.module?.name ?? null,
70
70
  host: route.host ?? null,
71
- bypassFirewall: route.bypassFirewall
71
+ bypassFirewall: route.bypassFirewall,
72
+ fallback: route.fallback
72
73
  });
73
74
  const descriptor = {
74
75
  label: "Routes",