@nodefony/framework 10.0.0-beta.5 → 10.0.0-beta.7

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 (61) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorateParam.js +7 -0
  3. package/dist/index.js +14 -10
  4. package/dist/nodefony/config/config.js +16 -2
  5. package/dist/nodefony/controller/AdminApiController.js +18 -5
  6. package/dist/nodefony/controller/BenchController.js +1 -1
  7. package/dist/nodefony/controller/CspReportController.js +70 -0
  8. package/dist/nodefony/controller/IssuerMetadataController.js +3 -3
  9. package/dist/nodefony/controller/LoginPageController.js +5 -7
  10. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +75 -10
  11. package/dist/nodefony/controller/SessionAuthController.js +6 -7
  12. package/dist/nodefony/controller/TokenAuthController.js +3 -3
  13. package/dist/nodefony/decorators/routerDecorators.js +103 -15
  14. package/dist/nodefony/grpc/GrpcController.js +138 -0
  15. package/dist/nodefony/grpc/GrpcService.js +75 -0
  16. package/dist/nodefony/grpc/declarations.js +33 -0
  17. package/dist/nodefony/grpc/index.js +3 -0
  18. package/dist/nodefony/service/AdminBroker.js +1 -1
  19. package/dist/nodefony/service/IdempotencyStore.js +1 -1
  20. package/dist/nodefony/service/router.js +15 -12
  21. package/dist/nodefony/src/Controller.js +330 -115
  22. package/dist/nodefony/src/PlaygroundAdminApi.js +1 -1
  23. package/dist/nodefony/src/Resolver.js +85 -51
  24. package/dist/nodefony/src/Route.js +45 -45
  25. package/dist/nodefony/src/SecurityApiController.js +30 -7
  26. package/dist/nodefony/src/conditionalRequest.js +103 -0
  27. package/dist/nodefony/src/docsReader.js +1 -1
  28. package/dist/nodefony/src/idempotency.js +1 -1
  29. package/dist/nodefony/src/loginPage.js +3 -2
  30. package/dist/nodefony/src/scopeCatalog.js +1 -1
  31. package/dist/nodefony/src/viewNonce.js +58 -0
  32. package/dist/types/index.d.ts +4 -2
  33. package/dist/types/nodefony/config/config.d.ts +20 -0
  34. package/dist/types/nodefony/controller/AdminApiController.d.ts +4 -2
  35. package/dist/types/nodefony/controller/CspReportController.d.ts +35 -0
  36. package/dist/types/nodefony/controller/LoginPageController.d.ts +3 -2
  37. package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +21 -1
  38. package/dist/types/nodefony/controller/SessionAuthController.d.ts +5 -6
  39. package/dist/types/nodefony/controller/TokenAuthController.d.ts +3 -3
  40. package/dist/types/nodefony/decorators/routerDecorators.d.ts +2 -1
  41. package/dist/types/nodefony/grpc/GrpcController.d.ts +75 -0
  42. package/dist/types/nodefony/grpc/GrpcService.d.ts +51 -0
  43. package/dist/types/nodefony/grpc/declarations.d.ts +12 -0
  44. package/dist/types/nodefony/grpc/index.d.ts +13 -0
  45. package/dist/types/nodefony/interfaces/IController.d.ts +4 -4
  46. package/dist/types/nodefony/service/router.d.ts +8 -6
  47. package/dist/types/nodefony/src/Controller.d.ts +124 -14
  48. package/dist/types/nodefony/src/Resolver.d.ts +35 -8
  49. package/dist/types/nodefony/src/Route.d.ts +35 -0
  50. package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -4
  51. package/dist/types/nodefony/src/conditionalRequest.d.ts +82 -0
  52. package/dist/types/nodefony/src/loginPage.d.ts +1 -1
  53. package/dist/types/nodefony/src/viewNonce.d.ts +33 -0
  54. package/docs/controller.md +234 -35
  55. package/docs/decorateurs.md +1 -1
  56. package/docs/grpc.md +690 -0
  57. package/docs/idempotence.md +13 -8
  58. package/docs/index.md +16 -10
  59. package/docs/routing.md +30 -2
  60. package/docs/templates.md +15 -0
  61. package/package.json +28 -11
@@ -3,9 +3,13 @@ import { validateRouteInput } from "./routeSchema.js";
3
3
  import { buildParamArgs, computeActionMeta, readInvocationBody, readRouteInput, resolveActionMeta, resolveSessionIntent } from "../decorators/routerDecorators.js";
4
4
  import { computeFingerprint, evaluateIdempotency, isMutationMethod, resolveIdempotencyKey, resolveIdentity } from "./idempotency.js";
5
5
  import { authenticationShortfall, stepUpChallenge } from "./requireAuthentication.js";
6
- import { RequestContext, finallyMaybe, identityHint, isArray, isPlainObject, isPromise, nodefonyError, thenMaybe, typeOf } from "nodefony";
7
- import { Http2Response, HttpResponse, WebsocketResponse } from "@nodefony/http";
6
+ import { RequestContext, finallyMaybe, identityHint, isArray, isPlainObject, isPromise, nodefonyError, thenMaybe } from "nodefony";
7
+ import { Http2Response, HttpResponse, WebsocketResponse, isContentlessStatus } from "@nodefony/http";
8
+ import { Buffer } from "node:buffer";
9
+ import { Readable } from "node:stream";
8
10
  // nodefony/src/Resolver.ts
11
+ /** Une annulation qui échoue (flux déjà fermé) n'a plus personne à prévenir. */
12
+ const IGNORE_CANCEL_ERROR = () => void 0;
9
13
  /**
10
14
  * Résout une route vers son couple controller/action et exécute l'action —
11
15
  * UN Resolver est alloué par requête HTTP (et par connexion WS, réutilisé
@@ -56,12 +60,12 @@ var Resolver = class {
56
60
  */
57
61
  queryOverride = null;
58
62
  /**
59
- * Méthode HTTP **logique** d'une invocation par le pont WS-RPC `api.request`
60
- * quand c'est une MUTATION (POST/PUT/PATCH/DELETE). Posée par
61
- * `Router.resolve(ctx, cleanPath, methodOverride)` → consommée par `match()`
62
- * pour désambiguïser, sur le transport WEBSOCKET unique, la route logique
63
- * visée (cf `Route.matchRequirements`). `null` (GET/HTTP) = match historique
64
- * sur `context.method`.
63
+ * Méthode HTTP **logique** d'une invocation par le pont WS-RPC `api.request`,
64
+ * GET compris. Posée par `Router.resolve(ctx, cleanPath, methodOverride)` →
65
+ * consommée par `match()` pour désambiguïser, sur le transport WEBSOCKET
66
+ * unique, la route logique visée (règle `bridgeServesVerb` du cœur, cf
67
+ * `Route.matchRequirements`). `null` (requête HTTP, ouverture de socket) =
68
+ * match sur `context.method`.
65
69
  */
66
70
  methodOverride = null;
67
71
  /**
@@ -396,7 +400,7 @@ var Resolver = class {
396
400
  * (`returnController`) — le chemin du **pont `api.request`** (WS) : la valeur
397
401
  * nue est enveloppée `{id, result}` par le peer, jamais rendue sur le
398
402
  * transport. La méthode HTTP logique d'une mutation du pont voyage dans
399
- * {@link methodOverride} ; sans porte ici, un rejeu `socket.mutate` (socket
403
+ * {@link methodOverride} ; sans porte ici, un rejeu `socket.post` (socket
400
404
  * qui reconnecte) ré-exécuterait la mutation (doublon — vécu au banc duplex).
401
405
  *
402
406
  * @returns `{ result }` — la valeur retournée par l'action (ou la réponse
@@ -431,6 +435,8 @@ var Resolver = class {
431
435
  async _callWithIdempotency(meta, data, reload = false) {
432
436
  const context = this.context;
433
437
  if (!isMutationMethod(this.methodOverride ?? context.method)) return this.executeAction(data, reload, meta);
438
+ if (this.preAuthorized) this.preAuthorized = false;
439
+ else await this._enforceActionSecurity(meta);
434
440
  const als = RequestContext.get();
435
441
  const httpReq = context.request;
436
442
  const params = this._zipRouteParams();
@@ -463,6 +469,7 @@ var Resolver = class {
463
469
  redirectMeta: void 0
464
470
  };
465
471
  }
472
+ this.preAuthorized = true;
466
473
  if (verdict.kind === "execute") return this.executeAction(data, reload, meta);
467
474
  try {
468
475
  const { result, redirectMeta } = await this.executeAction(data, reload, meta);
@@ -631,7 +638,15 @@ var Resolver = class {
631
638
  /**
632
639
  * Rend la valeur d'une action sur le transport, selon sa nature : promesse
633
640
  * (démêlée puis re-rendue), texte, Buffer, réponse déjà construite, scalaire
634
- * ou objet (auto-JSON), rien (l'action envoie elle-même).
641
+ * ou objet (auto-JSON), rien (l'action envoie elle-même) — et, en HTTP, une
642
+ * `Response` Web standard (`fetch()`, `new Response(…)`, `Response.json(…)`)
643
+ * ou un flux d'octets nu (`Readable` Node, `ReadableStream` Web), envoyés EN
644
+ * FLUX par `HttpContext.openWebResponse` / `HttpContext.openStream`.
645
+ *
646
+ * Ces deux-là sont reconnus APRÈS l'objet simple et le tableau : l'auto-JSON,
647
+ * le cas courant, ne paie aucun test de plus. Une réponse déjà partie
648
+ * (`sended`) n'est jamais réécrite ; une `Response` renvoyée en plus voit
649
+ * alors son corps annulé, pour ne pas retenir l'amont.
635
650
  *
636
651
  * Pas `async` : l'envelopper dans une promesse de plus ne changeait rien au
637
652
  * rendu, et coûtait une suspension à chaque requête (#505). La valeur n'est
@@ -643,45 +658,70 @@ var Resolver = class {
643
658
  * @throws de façon SYNCHRONE ce que lève un envoi synchrone.
644
659
  */
645
660
  returnController(result) {
646
- const type = typeOf(result);
647
- switch (true) {
648
- case result instanceof Promise:
649
- case isPromise(result): return result.then((myresult) => this.returnController(myresult));
650
- case type === "string":
651
- case result instanceof String: return this.context.send(result);
652
- case result instanceof Http2Response:
653
- case result instanceof HttpResponse:
654
- case result instanceof WebsocketResponse: return result;
655
- case type === "buffer":
656
- if (this.context.sended) return;
657
- return this.context.send(result);
658
- case type === "number":
659
- case type === "boolean":
661
+ if (isPromise(result)) return result.then((myresult) => this.returnController(myresult));
662
+ switch (typeof result) {
663
+ case "object":
664
+ if (result === null) break;
665
+ return this.returnObject(result);
666
+ case "string": return this.context.send(result);
667
+ case "number":
668
+ case "boolean":
660
669
  if (this.context.sended) return;
661
670
  this.context.setContextJson();
662
671
  return this.context.render(result);
663
- case type === "array":
664
- case type === "object":
665
- if (this.context.sended) return;
666
- if (isPlainObject(result) || isArray(result)) {
667
- this.context.setContextJson();
668
- return this.context.render(result);
669
- }
670
- switch (this.context.type) {
671
- case "http":
672
- this.context.waitAsync = true;
673
- this.unrendered = `une instance de ${(typeof result === "object" && result !== null ? result.constructor.name : void 0) ?? "classe"}`;
674
- }
675
- return;
676
- default: switch (this.context.type) {
677
- case "http":
678
- if (this.context.sended) return;
679
- if (this.context.isRedirect) return this.context.send();
680
- if (NO_BODY_STATUS.has(this.getResponseStatus())) return this.context.send();
681
- this.context.waitAsync = true;
682
- this.unrendered = result === null ? "null" : typeof result;
683
- }
684
672
  }
673
+ return this.returnNothing(result);
674
+ }
675
+ /**
676
+ * Aiguille une valeur OBJET rendue par l'action — dans l'ordre de fréquence :
677
+ * le tableau et l'objet simple (auto-JSON) d'abord, les instances rares
678
+ * ensuite. Partie de {@link Resolver.returnController}.
679
+ *
680
+ * @param result - un objet non nul.
681
+ * @returns ce que rend l'envoi, ou `undefined` quand il n'y a rien à envoyer.
682
+ */
683
+ returnObject(result) {
684
+ const context = this.context;
685
+ if (isArray(result) || isPlainObject(result)) {
686
+ if (context.sended) return;
687
+ context.setContextJson();
688
+ return context.render(result);
689
+ }
690
+ if (Buffer.isBuffer(result)) {
691
+ if (context.sended) return;
692
+ return context.send(result);
693
+ }
694
+ if (result instanceof String) return context.send(result.valueOf());
695
+ if (result instanceof HttpResponse || result instanceof Http2Response || result instanceof WebsocketResponse) return result;
696
+ if (context.sended) {
697
+ if (result instanceof Response) result.body?.cancel().catch(IGNORE_CANCEL_ERROR);
698
+ return;
699
+ }
700
+ if (result instanceof Date || result instanceof RegExp || result instanceof Error) return this.returnNothing(result);
701
+ if (context.type === "http") {
702
+ if (result instanceof Response) return context.openWebResponse(result);
703
+ if (result instanceof Readable || result instanceof ReadableStream) return context.openStream(result);
704
+ context.waitAsync = true;
705
+ this.unrendered = `une instance de ${result.constructor.name || "classe"}`;
706
+ }
707
+ }
708
+ /**
709
+ * Ce qui arrive quand l'action ne rend RIEN d'envoyable (`undefined`,
710
+ * `null`, une fonction…) : en HTTP, la redirection posée ou le statut sans
711
+ * corps part ; sinon la requête est marquée non rendue (`waitAsync`). Partie
712
+ * de {@link Resolver.returnController}.
713
+ *
714
+ * @param result - la valeur rendue par l'action.
715
+ * @returns l'envoi d'une redirection ou d'un statut sans corps, sinon `undefined`.
716
+ */
717
+ returnNothing(result) {
718
+ if (this.context.type !== "http") return;
719
+ const context = this.context;
720
+ if (context.sended) return;
721
+ if (context.isRedirect) return context.send();
722
+ if (isContentlessStatus(this.getResponseStatus())) return context.send();
723
+ context.waitAsync = true;
724
+ this.unrendered = result === null ? "null" : typeof result;
685
725
  }
686
726
  /** Statut courant de la réponse (0 si le transport n'en porte pas). */
687
727
  getResponseStatus() {
@@ -713,10 +753,4 @@ var Resolver = class {
713
753
  throw new nodefonyError(identityHint(this.context.kernel) === null ? "Authentication required" : `Authentication required — ${cause}. Reconnectez-vous (\`@RequireAuthentication\` de la route).`, 401);
714
754
  }
715
755
  };
716
- /** Statuts dont la réponse n'a, par définition, pas de corps (RFC 9110). */
717
- const NO_BODY_STATUS = /* @__PURE__ */ new Set([
718
- 204,
719
- 205,
720
- 304
721
- ]);
722
756
  export { Resolver as default };
@@ -1,4 +1,4 @@
1
- import { escapeRegExp, stripTrailingSlashes, typeOf } from "nodefony";
1
+ import { bridgeServesVerb, escapeRegExp, stripTrailingSlashes, typeOf } from "nodefony";
2
2
  import { HttpError, compileDomainPatterns, isDomainAllowed } from "@nodefony/http";
3
3
  import { createHash } from "node:crypto";
4
4
  // nodefony/src/Route.ts
@@ -125,6 +125,24 @@ function withDeclaredMethod(requirements, method) {
125
125
  function isSecureScheme(scheme) {
126
126
  return scheme === "https" || scheme === "wss";
127
127
  }
128
+ /**
129
+ * Un refus de route (méthode, domaine, schéma, protocole) SANS pile : c'est un
130
+ * verdict que le routeur RATTRAPE pour essayer la route suivante — deux routes
131
+ * sur un même chemin (`GET` et `POST /x`) le lèvent à chaque requête qui
132
+ * croise la voisine. La pile, toujours la même, coûterait ~3,6 µs par refus
133
+ * (`micro-error-path.mjs`) pour ne rien dire de la requête.
134
+ *
135
+ * @param message - le motif lisible.
136
+ * @param code - statut HTTP (ou code de fermeture WebSocket).
137
+ * @param type - la famille du refus (`method`, `domain`, `scheme`, `protocol`).
138
+ * @param allow - les méthodes servies, pour l'en-tête `Allow` d'un 405.
139
+ */
140
+ function routeRefusal(message, code, type, allow) {
141
+ const error = HttpError.withoutStack(message, code);
142
+ error.type = type;
143
+ if (allow !== void 0) error.allow = allow;
144
+ return error;
145
+ }
128
146
  var Route = class Route {
129
147
  name;
130
148
  path;
@@ -172,6 +190,13 @@ var Route = class Route {
172
190
  /** Route de repli, résolue après toutes les autres. Cf {@link RouteOptions.fallback}. */
173
191
  fallback = false;
174
192
  /**
193
+ * Délai de réponse PROPRE à la route (ms), `null` = celui du serveur.
194
+ * Cf {@link RouteOptions.responseTimeout}.
195
+ */
196
+ responseTimeout = null;
197
+ /** Bornes d'envoi propres à la route. Cf {@link RouteOptions.upload}. */
198
+ upload = null;
199
+ /**
175
200
  * P2.9 — Cache mémoïsé : l'action attend-elle le **flux brut** du body
176
201
  * (`@Body({ stream:true })`) ? `undefined` = pas encore calculé (résolu au 1er
177
202
  * `routeExpectsBodyStream(route)` via lecture `Reflect` des `ParamMeta`, O(1)
@@ -179,6 +204,13 @@ var Route = class Route {
179
204
  */
180
205
  bodyStream;
181
206
  /**
207
+ * Route servie par un point d'entrée gRPC — posé au démarrage par
208
+ * `markGrpcRoute` (`@nodefony/http/grpc`), jamais à la main.
209
+ *
210
+ * @experimental Surface gRPC, ADR-0026 D11.
211
+ */
212
+ grpc;
213
+ /**
182
214
  * P5 — Metadata d'action figées (`@HttpCode`/`@Header`/`@Redirect`/params/
183
215
  * session), memo au 1er hit comme {@link bodyStream} : `undefined` = pas
184
216
  * encore résolu (via `resolveActionMeta`, 1 lecture Reflect par route, O(1)
@@ -208,6 +240,8 @@ var Route = class Route {
208
240
  this.bypassFirewall = obj.bypassFirewall ?? false;
209
241
  this.areaRoleExempt = obj.areaRoleExempt ?? false;
210
242
  this.fallback = obj.fallback ?? false;
243
+ this.responseTimeout = obj.responseTimeout ?? null;
244
+ this.upload = obj.upload ?? null;
211
245
  this.compile();
212
246
  }
213
247
  this.generateId();
@@ -409,7 +443,9 @@ var Route = class Route {
409
443
  variables: this.variables,
410
444
  bypassFirewall: this.bypassFirewall,
411
445
  areaRoleExempt: this.areaRoleExempt,
412
- fallback: this.fallback
446
+ fallback: this.fallback,
447
+ responseTimeout: this.responseTimeout,
448
+ upload: this.upload
413
449
  };
414
450
  }
415
451
  setDefaults(arg) {
@@ -449,10 +485,7 @@ var Route = class Route {
449
485
  matchHostname(context) {
450
486
  if (this.hostRegexp) {
451
487
  if (isDomainAllowed(this.hostRegexp, context.domain)) return true;
452
- const error = new HttpError(`Domain ${context.domain} Forbidden for this route`);
453
- error.code = 403;
454
- error.type = "domain";
455
- throw error;
488
+ throw routeRefusal(`Domain ${context.domain} Forbidden for this route`, 403, "domain");
456
489
  }
457
490
  return true;
458
491
  }
@@ -489,42 +522,19 @@ var Route = class Route {
489
522
  return Object.keys(this.requirements).length;
490
523
  }
491
524
  matchRequirements(context, methodOverride) {
492
- if (this.requirements.methods === void 0 && (methodOverride !== void 0 || context.method === "WEBSOCKET")) {
493
- const error = new HttpError(`Method ${methodOverride ?? "WEBSOCKET"} Not Allowed`);
494
- error.code = 405;
495
- error.type = "method";
496
- error.allow = "";
497
- throw error;
498
- }
525
+ if (this.requirements.methods === void 0 && (methodOverride !== void 0 || context.method === "WEBSOCKET")) throw routeRefusal(`Method ${methodOverride ?? "WEBSOCKET"} Not Allowed`, 405, "method", "");
499
526
  if (this.hasRequirements()) for (const i in this.requirements) switch (i) {
500
527
  case "methods":
501
528
  if (!this.methodsSet) throw new Error(`Bad config route method : ${String(this.requirements.methods)}`);
502
529
  if (methodOverride !== void 0) {
503
- if (!this.methodsSet.has("WEBSOCKET") || !this.methodsSet.has(methodOverride)) {
504
- const error = new HttpError(`Method ${methodOverride} Not Allowed`);
505
- error.code = 405;
506
- error.type = "method";
507
- error.allow = this.methodsAllow ?? "";
508
- throw error;
509
- }
530
+ if (!bridgeServesVerb(this.methodsSet, methodOverride)) throw routeRefusal(`Method ${methodOverride} Not Allowed`, 405, "method", this.methodsAllow ?? "");
510
531
  break;
511
532
  }
512
- if (!this.methodsSet.has(context.method)) {
513
- const error = new HttpError(`Method ${context.method} Not Allowed`);
514
- error.code = 405;
515
- error.type = "method";
516
- error.allow = this.methodsAllow ?? "";
517
- throw error;
518
- }
533
+ if (!this.methodsSet.has(context.method)) throw routeRefusal(`Method ${context.method} Not Allowed`, 405, "method", this.methodsAllow ?? "");
519
534
  break;
520
535
  case "scheme": {
521
536
  const required = this.requirements.scheme;
522
- if (required !== void 0 && isSecureScheme(required) !== isSecureScheme(context.scheme)) {
523
- const error = new HttpError(`Scheme ${context.scheme} Forbidden for this route : ${required} required`);
524
- error.code = 403;
525
- error.type = "scheme";
526
- throw error;
527
- }
537
+ if (required !== void 0 && isSecureScheme(required) !== isSecureScheme(context.scheme)) throw routeRefusal(`Scheme ${context.scheme} Forbidden for this route : ${required} required`, 403, "scheme");
528
538
  break;
529
539
  }
530
540
  case "domain": break;
@@ -532,18 +542,8 @@ var Route = class Route {
532
542
  const requirement = this.requirements[i];
533
543
  if (!requirement) return true;
534
544
  if (typeof requirement === "string") {
535
- if (context.acceptedProtocol !== requirement) {
536
- const error = new HttpError(`Protocol ${context.acceptedProtocol} Not Supported`);
537
- error.code = 1002;
538
- error.type = "protocol";
539
- throw error;
540
- }
541
- } else {
542
- const error = new HttpError(`Protocol ${context.acceptedProtocol} Not Supported`);
543
- error.code = 1002;
544
- error.type = "protocol";
545
- throw error;
546
- }
545
+ if (context.acceptedProtocol !== requirement) throw routeRefusal(`Protocol ${context.acceptedProtocol} Not Supported`, 1002, "protocol");
546
+ } else throw routeRefusal(`Protocol ${context.acceptedProtocol} Not Supported`, 1002, "protocol");
547
547
  }
548
548
  }
549
549
  return true;
@@ -1,23 +1,46 @@
1
1
  import Controller from "./Controller.js";
2
+ import { SESSION_AUTH_CHALLENGE } from "nodefony";
2
3
  // nodefony/src/SecurityApiController.ts
3
4
  const NO_STORE = { "cache-control": "no-store" };
4
5
  /**
6
+ * En-têtes d'un 401 : ceux reçus, plus le défi par défaut
7
+ * ({@link SESSION_AUTH_CHALLENGE}) s'ils n'en portent aucun — RFC 9110 §15.5.2
8
+ * exige un `WWW-Authenticate` sur TOUT 401. Un défi posé par l'appelant (un
9
+ * `Bearer` de ressource protégée, par exemple) garde la main.
10
+ *
11
+ * Chemin froid : n'est appelé que pour un 401.
12
+ *
13
+ * @param headers - les en-têtes déjà prévus pour la réponse.
14
+ * @returns les mêmes en-têtes, défi compris.
15
+ */
16
+ function withAuthChallenge(headers) {
17
+ if (headers !== void 0) {
18
+ for (const name in headers) if (name.toLowerCase() === "www-authenticate") return { ...headers };
19
+ }
20
+ return {
21
+ ...headers,
22
+ "www-authenticate": SESSION_AUTH_CHALLENGE
23
+ };
24
+ }
25
+ /**
5
26
  * Base des contrôleurs d'authentification (`/nodefony/security/api/*`) : toute
6
- * réponse JSON part en `Cache-Control: no-store`.
27
+ * réponse JSON part en `Cache-Control: no-store`, et tout 401 porte un défi.
7
28
  *
8
29
  * Ces réponses portent une identité, un jeton, un secret affiché une seule
9
30
  * fois (clé API, secret TOTP, codes de récupération) ou leur refus. Un cookie
10
31
  * ou un en-tête dans la requête n'empêche pas un cache partagé de STOCKER la
11
- * réponse (RFC 9111 §3) ; RFC 6749 §5.1 exige `no-store` sur un jeton. La règle
12
- * vit ici, une fois : une action ajoutée à un contrôleur héritier ne peut pas
13
- * l'oublier. Un en-tête passé par l'action garde la priorité.
32
+ * réponse (RFC 9111 §3) ; RFC 6749 §5.1 exige `no-store` sur un jeton. Un 401
33
+ * reçoit le défi par défaut ({@link withAuthChallenge}, RFC 9110 §15.5.2). Ces
34
+ * règles vivent ici, une fois : une action ajoutée à un contrôleur héritier ne
35
+ * peut pas les oublier. Un en-tête passé par l'action garde la priorité.
14
36
  */
15
37
  var SecurityApiController = class extends Controller {
16
38
  renderJson(obj, status, headers) {
17
- return super.renderJson(obj, status, {
39
+ const merged = {
18
40
  ...NO_STORE,
19
41
  ...headers
20
- });
42
+ };
43
+ return super.renderJson(obj, status, Number(status) === 401 ? withAuthChallenge(merged) : merged);
21
44
  }
22
45
  };
23
- export { SecurityApiController as default };
46
+ export { SecurityApiController as default, withAuthChallenge };
@@ -0,0 +1,103 @@
1
+ // nodefony/src/conditionalRequest.ts
2
+ /** Une `entity-tag` (RFC 9110 §8.8.3) : `W/` facultatif, puis une chaîne entre guillemets. */
3
+ const ENTITY_TAG = /(?:W\/)?"[^"]*"/g;
4
+ /**
5
+ * Validateurs d'un fichier, tirés de ses métadonnées sans le lire.
6
+ *
7
+ * L'`ETag` est FORTE : taille et date de modification à la microseconde, deux
8
+ * grandeurs qu'une écriture change. C'est la forme que posent les serveurs de
9
+ * fichiers courants, et la seule qui autorise une reprise (§13.1.5).
10
+ *
11
+ * @param stats - taille et date de modification du fichier
12
+ * @param now - instant de la réponse (ms)
13
+ * @returns les validateurs du fichier
14
+ */
15
+ function fileValidators(stats, now) {
16
+ const micros = Math.floor(stats.mtimeMs * 1e3);
17
+ const seconds = Math.floor(Math.min(stats.mtimeMs, now) / 1e3) * 1e3;
18
+ return {
19
+ etag: `"${stats.size.toString(16)}-${micros.toString(16)}"`,
20
+ lastModified: seconds,
21
+ lastModifiedStrong: stats.mtimeMs + 1e3 <= now
22
+ };
23
+ }
24
+ /**
25
+ * Date HTTP (RFC 9110 §5.6.7) lue en ms, ou `null` si la valeur n'en est pas
26
+ * une — une condition sur une date invalide DOIT être ignorée (§13.1.3, §13.1.4).
27
+ *
28
+ * @param value - valeur brute de l'en-tête
29
+ */
30
+ function parseHttpDate(value) {
31
+ if (value === void 0) return null;
32
+ const ms = Date.parse(value);
33
+ return Number.isNaN(ms) ? null : ms;
34
+ }
35
+ /** Les `entity-tag` d'une liste, ou `"*"`. */
36
+ function entityTags(value) {
37
+ if (value.trim() === "*") return "*";
38
+ return value.match(ENTITY_TAG) ?? [];
39
+ }
40
+ /** Partie opaque d'une `entity-tag` (sans `W/`). */
41
+ function opaque(tag) {
42
+ return tag.startsWith("W/") ? tag.slice(2) : tag;
43
+ }
44
+ /** Comparaison FORTE (§8.8.3.2) : aucune des deux n'est faible, et elles sont identiques. */
45
+ function strongMatch(a, b) {
46
+ return !a.startsWith("W/") && !b.startsWith("W/") && a === b;
47
+ }
48
+ /** Comparaison FAIBLE (§8.8.3.2) : parties opaques identiques. */
49
+ function weakMatch(a, b) {
50
+ return opaque(a) === opaque(b);
51
+ }
52
+ /**
53
+ * Évalue les préconditions d'une requête sur une représentation qui EXISTE,
54
+ * dans l'ordre de RFC 9110 §13.2.2 — `If-Match`, sinon `If-Unmodified-Since` ;
55
+ * puis `If-None-Match`, sinon `If-Modified-Since`. `Range` et `If-Range`
56
+ * viennent après, s'il faut servir (voir {@link ifRangeHolds}).
57
+ *
58
+ * @param method - méthode de la requête
59
+ * @param headers - en-têtes de la requête
60
+ * @param validators - validateurs de la représentation
61
+ * @returns 200 pour servir, 304 si rien n'a changé (GET et HEAD seulement),
62
+ * 412 si une condition est fausse
63
+ */
64
+ function evaluatePreconditions(method, headers, validators) {
65
+ const read = method === "GET" || method === "HEAD";
66
+ const ifMatch = headers["if-match"];
67
+ if (ifMatch !== void 0) {
68
+ const tags = entityTags(ifMatch);
69
+ const { etag } = validators;
70
+ if (!(tags === "*" || etag !== null && tags.some((tag) => strongMatch(tag, etag)))) return 412;
71
+ } else {
72
+ const since = parseHttpDate(headers["if-unmodified-since"]);
73
+ if (since !== null && validators.lastModified !== null && validators.lastModified > since) return 412;
74
+ }
75
+ const ifNoneMatch = headers["if-none-match"];
76
+ if (ifNoneMatch !== void 0) {
77
+ const tags = entityTags(ifNoneMatch);
78
+ const { etag } = validators;
79
+ if (tags === "*" || etag !== null && tags.some((tag) => weakMatch(tag, etag))) return read ? 304 : 412;
80
+ } else if (read) {
81
+ const since = parseHttpDate(headers["if-modified-since"]);
82
+ if (since !== null && validators.lastModified !== null && validators.lastModified <= since) return 304;
83
+ }
84
+ return 200;
85
+ }
86
+ /**
87
+ * Une condition `If-Range` tient-elle (RFC 9110 §13.1.5) ? Vraie, la plage
88
+ * demandée est servie (206) ; fausse, `Range` est ignoré et la représentation
89
+ * part entière (200) — un client ne recolle jamais deux versions.
90
+ *
91
+ * Une étiquette tient si elle est FORTEMENT égale ; une date, si elle égale
92
+ * exactement `Last-Modified` ET que celle-ci est un validateur fort.
93
+ *
94
+ * @param value - valeur brute de `If-Range`
95
+ * @param validators - validateurs de la représentation
96
+ */
97
+ function ifRangeHolds(value, validators) {
98
+ const trimmed = value.trim();
99
+ if (trimmed.startsWith("\"") || trimmed.startsWith("W/")) return validators.etag !== null && strongMatch(trimmed, validators.etag);
100
+ const date = parseHttpDate(trimmed);
101
+ return date !== null && validators.lastModifiedStrong && validators.lastModified === date;
102
+ }
103
+ export { evaluatePreconditions, fileValidators, ifRangeHolds, parseHttpDate };
@@ -1,10 +1,10 @@
1
1
  import { basename, dirname, extname, join } from "node:path";
2
2
  import { findProjectRoot, isDocVisible, packageManagerCommandLines, packageManagerExecArgs, portableSpawn, readDocScope, readSymbolsGraph, resolvePackageManager } from "nodefony";
3
3
  import { existsSync } from "node:fs";
4
- import { promisify } from "node:util";
5
4
  import { readFile, readdir, stat } from "node:fs/promises";
6
5
  import { fileURLToPath } from "node:url";
7
6
  import { execFile, spawn } from "node:child_process";
7
+ import { promisify } from "node:util";
8
8
  // nodefony/src/docsReader.ts
9
9
  const execFileAsync = promisify(execFile);
10
10
  /**
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  // nodefony/src/idempotency.ts
4
4
  /**
5
5
  * Logique **pure et partagée** de la porte d'idempotence des mutations, conforme à
6
- * `draft-ietf-httpapi-idempotency-key-header-06`. Source unique de la sémantique
6
+ * `draft-ietf-httpapi-idempotency-key-header-08`. Source unique de la sémantique
7
7
  * (statuts normatifs, scope de clé, fingerprint, bornage) consommée par les DEUX
8
8
  * call-sites qui mutent :
9
9
  * - le **data plane admin** (`AdminApiController` — réponse `{status,headers,body}`) ;
@@ -1,4 +1,4 @@
1
- import { LOGIN_PAGE_ASSETS_BASE, LOGIN_PAGE_LAYOUTS, LOGIN_PAGE_SKINS, LOGIN_PAGE_SKIN_SPECS, LOGIN_PAGE_THEME_KEY, OAUTH2_CANCELLED_REASON, isLoginPageSkin, loginPageSkinFile, oauth2AuthorizePath, safeRedirectPath } from "nodefony";
1
+ import { CSP_NONCE_META_PROPERTY, LOGIN_PAGE_ASSETS_BASE, LOGIN_PAGE_LAYOUTS, LOGIN_PAGE_SKINS, LOGIN_PAGE_SKIN_SPECS, LOGIN_PAGE_THEME_KEY, OAUTH2_CANCELLED_REASON, isLoginPageSkin, loginPageSkinFile, oauth2AuthorizePath, safeRedirectPath } from "nodefony";
2
2
  // nodefony/src/loginPage.ts
3
3
  /**
4
4
  * Mémoïse une ressource chargée de façon asynchrone : le SUCCÈS est gardé, un
@@ -158,6 +158,7 @@ const LOGIN_PAGE_TEMPLATE = `<!doctype html>
158
158
  <meta name="viewport" content="width=device-width, initial-scale=1" />
159
159
  <meta name="color-scheme" content="dark light" />
160
160
  <meta name="robots" content="noindex" />
161
+ <% if (it.nonce) { %><meta property="${CSP_NONCE_META_PROPERTY}" nonce="<%= it.nonce %>"><% } %>
161
162
  <title><%= it.title %> — <%= it.brand %></title>
162
163
  <link rel="icon" href="<%= it.logoHref %>" type="image/svg+xml" />
163
164
  <% if (!it.theme) { %>
@@ -210,7 +211,7 @@ const LOGIN_PAGE_TEMPLATE = `<!doctype html>
210
211
  </p>
211
212
  <ul class="badges">
212
213
  <li class="agentic">Agentic ready</li>
213
- <li>Node.js ≥ 24.14</li>
214
+ <li>Node.js 24.16+ ou 26+</li>
214
215
  <li>TypeScript strict</li>
215
216
  <li>ESM only</li>
216
217
  <li>Licence Apache 2.0</li>
@@ -1,5 +1,5 @@
1
- import { extractActionScopes } from "../decorators/routerDecorators.js";
2
1
  import router_default from "../service/router.js";
2
+ import { extractActionScopes } from "../decorators/routerDecorators.js";
3
3
  // nodefony/src/scopeCatalog.ts
4
4
  /**
5
5
  * P6.8 — **Découverte au boot** : scanne TOUTES les routes montées (`Router.routes`)
@@ -0,0 +1,58 @@
1
+ // nodefony/src/viewNonce.ts
2
+ /**
3
+ * Signature CSP des vues : chaque `<script>` et `<style>` ÉCRIT dans un
4
+ * gabarit reçoit le nonce de la requête, sans geste de l'auteur.
5
+ *
6
+ * La politique par défaut exige un nonce sur les scripts (`'strict-dynamic'`)
7
+ * et sur les styles (ADR-0024 §C1, §C2). Une page écrite vite, par quelqu'un
8
+ * qui ignore tout de la CSP, ne doit pas être bloquée (§C0) : la signature se
9
+ * pose donc au rendu, par le framework.
10
+ *
11
+ * Elle porte sur le TEXTE DU GABARIT, avant que les données y soient insérées :
12
+ * une balise que les données apporteraient (une valeur insérée brute par
13
+ * `<%~ %>`) n'est jamais signée — c'est ce qui garde au nonce sa valeur contre
14
+ * l'injection. Même principe que l'`index.html` d'un front bâti
15
+ * (`prebuiltUi.ts#splitAroundNonce`).
16
+ */
17
+ /** Variable de vue qui porte le nonce CSP de la requête (`<%= cspNonce %>`). */
18
+ const VIEW_NONCE_LOCAL = "cspNonce";
19
+ /** Ouverture d'une balise `<script` ou `<style` qui ne porte pas de `nonce`. */
20
+ const UNSIGNED_TAG_OPEN = /<(script|style)\b(?![^>]*\bnonce\s*=)/giu;
21
+ /** Une balise Eta (`<% %>`, `<%= %>`, `<%~ %>`) : du code, jamais du balisage. */
22
+ const ETA_TAG = /<%[\s\S]*?%>/gu;
23
+ /** Attribut inséré : le nonce, lu dans les variables de la vue à chaque rendu. */
24
+ const NONCE_ATTRIBUTE = ` nonce="<%= it.${VIEW_NONCE_LOCAL} %>"`;
25
+ /**
26
+ * Ajoute le nonce de la requête à chaque `<script>` et `<style>` littéral du
27
+ * gabarit qui n'en porte pas.
28
+ *
29
+ * Une balise déjà signée par l'auteur est laissée telle quelle ; un `<script`
30
+ * écrit À L'INTÉRIEUR d'une balise Eta (une chaîne de code) n'est jamais
31
+ * touché. Un `<script>` dont un attribut vient des données
32
+ * (`src="<%= it.url %>"`) n'est pas signé non plus : le signer exécuterait
33
+ * l'URL qu'elles apportent. L'auteur qui la tient pour sûre signe lui-même
34
+ * (`nonce="<%= it.cspNonce %>"`). Le rendu doit fournir la variable
35
+ * {@link VIEW_NONCE_LOCAL}.
36
+ *
37
+ * @param source - texte du gabarit Eta, tel que lu sur le disque.
38
+ * @returns le gabarit signé — la MÊME chaîne quand il n'y a rien à signer.
39
+ */
40
+ function signTemplateTags(source) {
41
+ UNSIGNED_TAG_OPEN.lastIndex = 0;
42
+ if (!UNSIGNED_TAG_OPEN.test(source)) return source;
43
+ let etaRanges = null;
44
+ ETA_TAG.lastIndex = 0;
45
+ for (let m = ETA_TAG.exec(source); m !== null; m = ETA_TAG.exec(source)) (etaRanges ??= []).push(m.index, m.index + m[0].length);
46
+ return source.replace(UNSIGNED_TAG_OPEN, (tag, name, offset) => {
47
+ if (etaRanges !== null) for (let i = 0; i < etaRanges.length; i += 2) {
48
+ const start = etaRanges[i] ?? 0;
49
+ if (start > offset) {
50
+ if (name.toLowerCase() === "script" && start < source.indexOf(">", offset)) return tag;
51
+ break;
52
+ }
53
+ if (offset < (etaRanges[i + 1] ?? 0)) return tag;
54
+ }
55
+ return tag + NONCE_ATTRIBUTE;
56
+ });
57
+ }
58
+ export { VIEW_NONCE_LOCAL, signTemplateTags };
@@ -16,6 +16,7 @@ import IssuerMetadataController, { mountIssuerMetadataRoutes } from "./nodefony/
16
16
  import ProtectedResourceMetadataController, { mountProtectedResourceRoutes, protectedResourceRoutePaths, collectProtectedResources } from "./nodefony/controller/ProtectedResourceMetadataController.js";
17
17
  import WebAuthnController, { mountWebAuthnRoutes } from "./nodefony/controller/WebAuthnController.js";
18
18
  import OAuth2Controller, { mountOAuth2Routes } from "./nodefony/controller/OAuth2Controller.js";
19
+ import CspReportController, { mountCspReportRoute } from "./nodefony/controller/CspReportController.js";
19
20
  import BenchController, { mountBenchRoutes } from "./nodefony/controller/BenchController.js";
20
21
  import ApiKeyController, { mountApiKeyRoutes } from "./nodefony/controller/ApiKeyController.js";
21
22
  import TotpController, { mountTotpRoutes } from "./nodefony/controller/TotpController.js";
@@ -24,9 +25,10 @@ import { createFrameworkAdminApi } from "./nodefony/src/FrameworkAdminApi.js";
24
25
  import { buildPlaygroundSnapshot } from "./nodefony/src/PlaygroundAdminApi.js";
25
26
  import { createSyslogAdminApi } from "./nodefony/src/SyslogAdminApi.js";
26
27
  import Eta from "./nodefony/service/Eta.js";
27
- import { controllers, route, controller, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, RequireAuthentication, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, routeExpectsBodyStream, buildContextParamArgs, getParamArgsMeta } from "./nodefony/decorators/routerDecorators.js";
28
+ import { controllers, route, controller, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, RequireAuthentication, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, routeExpectsBodyStream, buildContextParamArgs, getParamArgsMeta, getRouteSchema } from "./nodefony/decorators/routerDecorators.js";
28
29
  export type { IHttpMethodDecorator } from "./nodefony/decorators/routerDecorators.js";
29
30
  export { validateRouteInput } from "./nodefony/src/routeSchema.js";
31
+ export { contentDisposition } from "./nodefony/src/Controller.js";
30
32
  export type { IRouteSchema, RouteInput, RoutePart, RawRouteInput, RawRouteParams, RawRouteQuery, RawRouteHeaders, } from "./nodefony/src/routeSchema.js";
31
33
  declare module "nodefony" {
32
34
  interface NodefonyModuleConfig {
@@ -79,7 +81,7 @@ declare class Framework extends Module<IFrameworkConfig> {
79
81
  onKernelReady(): Promise<this>;
80
82
  }
81
83
  export default Framework;
82
- export { Controller, ResourceController, Route, Router, FRAMEWORK_ROUTER, isFrameworkRouter, Resolver, AdminBroker, MemoryIdempotencyStore, AdminApiController, SessionAuthController, mountSessionAuthRoutes, LoginPageController, mountLoginPage, TokenAuthController, mountTokenAuthRoutes, IssuerMetadataController, mountIssuerMetadataRoutes, ProtectedResourceMetadataController, mountProtectedResourceRoutes, protectedResourceRoutePaths, collectProtectedResources, WebAuthnController, mountWebAuthnRoutes, OAuth2Controller, mountOAuth2Routes, BenchController, mountBenchRoutes, ApiKeyController, mountApiKeyRoutes, TotpController, mountTotpRoutes, createKernelAdminApi, createFrameworkAdminApi, createSyslogAdminApi, buildPlaygroundSnapshot, Eta, route, controller, controllers, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, RequireAuthentication, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, routeExpectsBodyStream, buildContextParamArgs, getParamArgsMeta, registerIdempotencyStore, getIdempotencyStoreFactory, listIdempotencyStores, };
84
+ export { Controller, ResourceController, Route, Router, FRAMEWORK_ROUTER, isFrameworkRouter, Resolver, AdminBroker, MemoryIdempotencyStore, AdminApiController, SessionAuthController, mountSessionAuthRoutes, LoginPageController, mountLoginPage, TokenAuthController, mountTokenAuthRoutes, IssuerMetadataController, mountIssuerMetadataRoutes, ProtectedResourceMetadataController, mountProtectedResourceRoutes, protectedResourceRoutePaths, collectProtectedResources, WebAuthnController, mountWebAuthnRoutes, OAuth2Controller, mountOAuth2Routes, CspReportController, mountCspReportRoute, BenchController, mountBenchRoutes, ApiKeyController, mountApiKeyRoutes, TotpController, mountTotpRoutes, createKernelAdminApi, createFrameworkAdminApi, createSyslogAdminApi, buildPlaygroundSnapshot, Eta, route, controller, controllers, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, RequireAuthentication, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, routeExpectsBodyStream, buildContextParamArgs, getParamArgsMeta, getRouteSchema, registerIdempotencyStore, getIdempotencyStoreFactory, listIdempotencyStores, };
83
85
  export { frameworkConfigSchema } from "./nodefony/config/config.js";
84
86
  export type { IdempotencyStoreFactory, IIdempotencyStoreFactoryContext, } from "./nodefony/src/idempotencyStoreRegistry.js";
85
87
  export type { SecurityClause, SecurityRequirement, IAuthenticationRequirement, IRequireAuthenticationOptions, CspDirectives, IParamArgSource, ParamMeta, } from "./nodefony/decorators/routerDecorators.js";