@nodefony/framework 10.0.0-alpha.9 → 10.0.0-beta.2

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 (53) hide show
  1. package/README.md +5 -2
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +4 -4
  5. package/dist/nodefony/controller/AdminApiController.js +4 -3
  6. package/dist/nodefony/controller/ApiKeyController.js +3 -2
  7. package/dist/nodefony/controller/OAuth2Controller.js +11 -6
  8. package/dist/nodefony/controller/SessionAuthController.js +3 -3
  9. package/dist/nodefony/controller/TokenAuthController.js +4 -4
  10. package/dist/nodefony/controller/TotpController.js +7 -5
  11. package/dist/nodefony/controller/WebAuthnController.js +4 -4
  12. package/dist/nodefony/decorators/routerDecorators.js +41 -35
  13. package/dist/nodefony/service/AdminBroker.js +2 -2
  14. package/dist/nodefony/service/IdempotencyStore.js +4 -5
  15. package/dist/nodefony/service/router.js +78 -26
  16. package/dist/nodefony/src/Controller.js +184 -78
  17. package/dist/nodefony/src/FrameworkAdminApi.js +19 -6
  18. package/dist/nodefony/src/KernelAdminApi.js +49 -49
  19. package/dist/nodefony/src/PlaygroundAdminApi.js +3 -3
  20. package/dist/nodefony/src/RedisIdempotencyStore.js +2 -2
  21. package/dist/nodefony/src/Resolver.js +221 -65
  22. package/dist/nodefony/src/Route.js +62 -39
  23. package/dist/nodefony/src/SyslogAdminApi.js +4 -4
  24. package/dist/nodefony/src/docsReader.js +49 -39
  25. package/dist/nodefony/src/scopeCatalog.js +1 -1
  26. package/dist/nodefony/src/singletonGuard.js +76 -0
  27. package/dist/types/nodefony/controller/ApiKeyController.d.ts +2 -1
  28. package/dist/types/nodefony/controller/BenchController.d.ts +2 -2
  29. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +3 -3
  30. package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +1 -1
  31. package/dist/types/nodefony/controller/SessionAuthController.d.ts +2 -1
  32. package/dist/types/nodefony/controller/TokenAuthController.d.ts +2 -2
  33. package/dist/types/nodefony/controller/TotpController.d.ts +2 -1
  34. package/dist/types/nodefony/decorators/routerDecorators.d.ts +49 -43
  35. package/dist/types/nodefony/interfaces/IController.d.ts +9 -7
  36. package/dist/types/nodefony/interfaces/IResolver.d.ts +10 -10
  37. package/dist/types/nodefony/interfaces/IRoute.d.ts +13 -13
  38. package/dist/types/nodefony/service/AdminBroker.d.ts +3 -3
  39. package/dist/types/nodefony/service/router.d.ts +33 -7
  40. package/dist/types/nodefony/src/Controller.d.ts +97 -24
  41. package/dist/types/nodefony/src/Resolver.d.ts +155 -16
  42. package/dist/types/nodefony/src/ResourceController.d.ts +6 -6
  43. package/dist/types/nodefony/src/Route.d.ts +39 -23
  44. package/dist/types/nodefony/src/docsReader.d.ts +19 -9
  45. package/dist/types/nodefony/src/singletonGuard.d.ts +53 -0
  46. package/docs/admin.md +1 -1
  47. package/docs/controller.md +139 -121
  48. package/docs/decorateurs.md +102 -98
  49. package/docs/idempotence.md +23 -21
  50. package/docs/index.md +1 -1
  51. package/docs/routing.md +60 -53
  52. package/docs/templates.md +14 -14
  53. package/package.json +10 -11
@@ -1,5 +1,5 @@
1
1
  import { basename, dirname, extname, join } from "node:path";
2
- import { findProjectRoot, resolveSymbolsFile } from "nodefony";
2
+ import { findProjectRoot, packageManagerCommandLines, packageManagerExecArgs, portableSpawn, resolvePackageManager, resolveSymbolsFile } from "nodefony";
3
3
  import { existsSync } from "node:fs";
4
4
  import { promisify } from "node:util";
5
5
  import { readFile, readdir, stat } from "node:fs/promises";
@@ -70,8 +70,8 @@ function parseFrontmatter(raw) {
70
70
  }
71
71
  /** Premier titre `# H1` du corps markdown, ou `null`. */
72
72
  function firstHeading(body) {
73
- const m = body.match(/^#\s+(.+)$/m);
74
- return m ? m[1].trim() : null;
73
+ const title = body.match(/^#\s+(.+)$/m)?.[1];
74
+ return title === void 0 ? null : title.trim();
75
75
  }
76
76
  /**
77
77
  * Date ISO du dernier commit git d'un fichier (détecte la dérive doc↔code).
@@ -297,19 +297,22 @@ async function searchModuleDocs(targets, query, options = {}) {
297
297
  }
298
298
  score += occurrences / Math.max(1, foldedBody.length / DOC_LENGTH_PIVOT);
299
299
  const candidates = [];
300
- for (let i = 0; i < lines.length; i += 1) {
301
- const carried = terms.filter((t) => foldedLines[i].includes(t));
302
- if (carried.length === 0) continue;
300
+ for (const [i, folded] of foldedLines.entries()) {
301
+ const carried = terms.filter((t) => folded.includes(t));
302
+ const [matchedTerm] = carried;
303
+ if (matchedTerm === void 0) continue;
303
304
  candidates.push({
304
305
  lineNumber: i,
306
+ line: lines[i] ?? "",
307
+ folded,
305
308
  coverage: carried.length,
306
- matchedTerm: carried[0]
309
+ matchedTerm
307
310
  });
308
311
  }
309
312
  candidates.sort((a, b) => b.coverage - a.coverage || a.lineNumber - b.lineNumber);
310
313
  const matches = candidates.slice(0, perDoc).sort((a, b) => a.lineNumber - b.lineNumber).map((c) => ({
311
314
  line: c.lineNumber + 1,
312
- text: snippet(lines[c.lineNumber], foldedLines[c.lineNumber], c.matchedTerm)
315
+ text: snippet(c.line, c.folded, c.matchedTerm)
313
316
  }));
314
317
  hits.push({
315
318
  module: target.key,
@@ -414,10 +417,10 @@ async function typeFiles(modulePath) {
414
417
  */
415
418
  function declarationStart(lines, at) {
416
419
  let start = at;
417
- if (start > 0 && lines[start - 1].trim().endsWith("*/")) {
420
+ if (lines[start - 1]?.trim().endsWith("*/")) {
418
421
  let i = start - 1;
419
- while (i > 0 && !lines[i].trim().startsWith("/**")) i -= 1;
420
- if (lines[i].trim().startsWith("/**")) start = i;
422
+ while (i > 0 && !lines[i]?.trim().startsWith("/**")) i -= 1;
423
+ if (lines[i]?.trim().startsWith("/**")) start = i;
421
424
  }
422
425
  return start;
423
426
  }
@@ -457,14 +460,15 @@ async function readSymbolDeclaration(modulePath, symbolName) {
457
460
  let depth = 0;
458
461
  let opened = false;
459
462
  let end = at;
460
- for (let i = at; i < lines.length; i += 1) {
461
- for (const ch of lines[i]) if (ch === "{") {
463
+ for (const [offset, line] of lines.slice(at).entries()) {
464
+ const i = at + offset;
465
+ for (const ch of line) if (ch === "{") {
462
466
  depth += 1;
463
467
  opened = true;
464
468
  } else if (ch === "}") depth -= 1;
465
469
  end = i;
466
470
  if (opened && depth <= 0) break;
467
- if (!opened && lines[i].trimEnd().endsWith(";")) break;
471
+ if (!opened && line.trimEnd().endsWith(";")) break;
468
472
  }
469
473
  const declaration = lines.slice(start, end + 1).join("\n");
470
474
  return declaration.length > DECLARATION_MAX_CHARS ? {
@@ -649,50 +653,54 @@ async function listTestGroups(modulePath) {
649
653
  * config du module (la liste `reporter` dupliquée par module divergeait).
650
654
  * - sinon (cœur : monocart, pas de `vitest.config.ts`) → `npm run coverage`.
651
655
  *
656
+ * Le binaire et le script passent par le gestionnaire de paquets du PROJET :
657
+ * `npx` n'existe pas chez bun, et sous pnpm il chercherait hors de l'arbre que
658
+ * le verrou décrit.
659
+ *
652
660
  * @param modulePath - racine du module (où vit `vitest.config.ts`)
653
661
  * @param file - chemin relatif d'UN fichier de test, déjà validé par l'appelant
662
+ * @param manager - gestionnaire du projet ; défaut : résolu depuis la racine
663
+ * de l'application qui contient le module
654
664
  * @returns commande, arguments en tableau (spawn sans shell) et libellé du mode
655
665
  */
656
- function testRunCommand(modulePath, file) {
666
+ function testRunCommand(modulePath, file, manager = resolvePackageManager({ dir: findProjectRoot(modulePath) ?? modulePath }).name) {
657
667
  const hasVitest = existsSync(join(modulePath, "vitest.config.ts"));
658
668
  if (file && hasVitest) return {
659
- cmd: "npx",
660
- args: [
661
- "vitest",
662
- "run",
663
- file
664
- ],
669
+ cmd: manager,
670
+ args: packageManagerExecArgs(manager, "vitest", ["run", file]),
665
671
  mode: `vitest run ${file}`
666
672
  };
667
673
  if (hasVitest) return {
668
- cmd: "npx",
669
- args: [
670
- "vitest",
674
+ cmd: manager,
675
+ args: packageManagerExecArgs(manager, "vitest", [
671
676
  "run",
672
677
  "--coverage",
673
678
  "--coverage.reporter=text-summary",
674
679
  "--coverage.reporter=json-summary",
675
680
  "--coverage.reporter=lcov",
676
681
  "--coverage.reportsDirectory=.coverage"
677
- ],
682
+ ]),
678
683
  mode: "vitest run --coverage (reporters forcés)"
679
684
  };
680
685
  return {
681
- cmd: "npm",
686
+ cmd: manager,
682
687
  args: ["run", "coverage"],
683
- mode: "npm run coverage (suite complète)"
688
+ mode: `${packageManagerCommandLines(manager).run("coverage")} (suite complète)`
684
689
  };
685
690
  }
686
691
  /**
687
692
  * Lance les tests d'un module et renvoie un résumé (pass/fail/durée + tail).
688
693
  *
689
- * - 1 fichier (module vitest) → `npx vitest run <file>` (rapide, pass/fail).
690
- * - sinon → `npm run coverage` (suite complète + refresh coverage ; marche
691
- * pour vitest comme pour monocart/core).
694
+ * - 1 fichier (module vitest) → `vitest run <file>` (rapide, pass/fail).
695
+ * - sinon → le script `coverage` (suite complète + refresh coverage ; marche
696
+ * pour vitest comme pour le cœur).
697
+ * Les deux par le gestionnaire du projet (cf {@link testRunCommand}).
692
698
  *
693
699
  * ⚠️ EXÉCUTE un process — appelé UNIQUEMENT derrière le garde dev-only de
694
- * l'endpoint (cf KernelAdminApi). spawn sans shell + args en tableau (pas
695
- * d'injection shell). `file` validé en amont (suffixe .test.ts, pas de `..`).
700
+ * l'endpoint (cf KernelAdminApi). `portableSpawn` : jamais de `shell`, args
701
+ * en tableau (pas d'injection), et le `.cmd` du gestionnaire se lance sous
702
+ * Windows — un `spawn("npx")` nu y rendait `ENOENT`. `file` validé en amont
703
+ * (suffixe .test.ts, pas de `..`).
696
704
  */
697
705
  function runModuleTests(modulePath, file) {
698
706
  const { cmd, args, mode } = testRunCommand(modulePath, file);
@@ -705,9 +713,11 @@ function runModuleTests(modulePath, file) {
705
713
  };
706
714
  let child;
707
715
  try {
708
- child = spawn(cmd, args, {
716
+ const run = portableSpawn(cmd, args);
717
+ child = spawn(run.file, run.args, {
709
718
  cwd: modulePath,
710
- env: process.env
719
+ env: process.env,
720
+ windowsVerbatimArguments: run.windowsVerbatimArguments
711
721
  });
712
722
  } catch (e) {
713
723
  return resolve({
@@ -720,16 +730,16 @@ function runModuleTests(modulePath, file) {
720
730
  mode
721
731
  });
722
732
  }
723
- child.stdout?.on("data", cap);
724
- child.stderr?.on("data", cap);
733
+ child.stdout.on("data", cap);
734
+ child.stderr.on("data", cap);
725
735
  const timer = setTimeout(() => child.kill("SIGKILL"), 18e4);
726
736
  let settled = false;
727
737
  const settle = (result) => {
728
738
  if (settled) return;
729
739
  settled = true;
730
740
  clearTimeout(timer);
731
- child.stdout?.off("data", cap);
732
- child.stderr?.off("data", cap);
741
+ child.stdout.off("data", cap);
742
+ child.stderr.off("data", cap);
733
743
  resolve(result);
734
744
  };
735
745
  child.on("error", (e) => {
@@ -894,7 +904,7 @@ async function readCoverage(modulePath) {
894
904
  } catch {}
895
905
  }
896
906
  if (!report) return { available: false };
897
- report.files.sort((a, b) => a.file.localeCompare(b.file));
907
+ report.files?.sort((a, b) => a.file.localeCompare(b.file));
898
908
  try {
899
909
  report.generated = usedFile ? (await stat(usedFile)).mtime.toISOString() : null;
900
910
  } catch {
@@ -33,7 +33,7 @@ function collectDeclaredApiScopes() {
33
33
  }
34
34
  return [...byApi.keys()].sort().map((api) => ({
35
35
  api,
36
- scopes: [...byApi.get(api)].sort()
36
+ scopes: [...byApi.get(api) ?? []].sort()
37
37
  }));
38
38
  }
39
39
  //#endregion
@@ -0,0 +1,76 @@
1
+ //#region nodefony/src/singletonGuard.ts
2
+ /**
3
+ * Filets d'un contrôleur **singleton** — le défaut : UNE instance par classe,
4
+ * partagée par toutes les requêtes, concurrentes comprises.
5
+ *
6
+ * Un singleton ne peut porter aucun état de requête sur `this` : la valeur
7
+ * écrite par une requête serait lue par la suivante — une fuite de données
8
+ * entre utilisateurs, sans la moindre erreur. Deux filets la rendent visible :
9
+ *
10
+ * - les accesseurs d'état par requête de `Controller` (`query`, `request`,
11
+ * `context`…) refusent l'écriture sur un singleton, dans tous les
12
+ * environnements (coût nul : ils ne sont jamais écrits sur le chemin chaud) ;
13
+ * - en développement seulement, {@link guardSingletonState} fait lever toute
14
+ * écriture sur un champ de l'instance, une fois sa construction et son
15
+ * `initialize()` terminés.
16
+ *
17
+ * @module
18
+ */
19
+ /**
20
+ * `true` si l'environnement du kernel est celui du développement.
21
+ *
22
+ * Une seule définition pour tout le paquet : la sonde CSP de `Controller` et le
23
+ * filet des singletons s'allument sur le même critère.
24
+ *
25
+ * @param environment - `kernel.environment`, tel quel.
26
+ * @returns `true` en développement (`"development"` ou son alias `"dev"`).
27
+ */
28
+ function isDevelopment(environment) {
29
+ return environment === "development" || environment === "dev";
30
+ }
31
+ /**
32
+ * Message d'une écriture refusée sur un contrôleur singleton : il nomme la
33
+ * classe, le membre, la raison du refus et les deux remèdes.
34
+ *
35
+ * @param controller - nom de la classe du contrôleur.
36
+ * @param member - membre écrit (`count`, `query`…).
37
+ * @returns le message, en une phrase par idée.
38
+ */
39
+ function singletonWriteMessage(controller, member) {
40
+ return `Contrôleur « ${controller} » : écriture de « this.${member} » refusée. Ce contrôleur est un singleton (le défaut) : une seule instance sert toutes les requêtes, concurrentes comprises, et cette valeur fuirait de l'une à l'autre. Remèdes : déclarer la classe @Scope("request") — une instance par requête, une par connexion en WebSocket — ou porter cet état par la requête : un argument décoré (@Param, @Query, @Body…), un service de portée request, ou le contexte.`;
41
+ }
42
+ /**
43
+ * En développement, fait lever toute écriture sur un champ d'un contrôleur
44
+ * singleton, avec un message qui nomme le contrôleur, le champ et le remède.
45
+ *
46
+ * Chaque propriété PROPRE inscriptible devient un accesseur : la lecture rend
47
+ * la valeur posée à la construction ou dans `initialize()`, l'écriture lève.
48
+ * L'instance est ensuite rendue non extensible, pour qu'un champ non déclaré
49
+ * ne puisse pas être ajouté en cours de requête.
50
+ *
51
+ * Ce que le filet n'attrape PAS, et que seule la relecture garde : les champs
52
+ * `#privés` (ce ne sont pas des propriétés), et les mutations internes d'un
53
+ * objet tenu par un champ (`this.cache.set(…)`, `this.list.push(…)`).
54
+ *
55
+ * @param controller - l'instance, construite et initialisée.
56
+ * @param name - nom de sa classe, pour le message.
57
+ */
58
+ function guardSingletonState(controller, name) {
59
+ for (const key of Reflect.ownKeys(controller)) {
60
+ const descriptor = Object.getOwnPropertyDescriptor(controller, key);
61
+ if (descriptor?.writable !== true) continue;
62
+ const value = descriptor.value;
63
+ const member = String(key);
64
+ Object.defineProperty(controller, key, {
65
+ configurable: false,
66
+ enumerable: descriptor.enumerable ?? false,
67
+ get: () => value,
68
+ set: () => {
69
+ throw new TypeError(singletonWriteMessage(name, member));
70
+ }
71
+ });
72
+ }
73
+ Object.preventExtensions(controller);
74
+ }
75
+ //#endregion
76
+ export { guardSingletonState, isDevelopment, singletonWriteMessage };
@@ -16,7 +16,8 @@ import Controller from "../src/Controller.js";
16
16
  * zone data plane `^/nodefony/[^/]+/api(/|$)` → **session BFF requise**. Le porteur
17
17
  * est TOUJOURS l'utilisateur courant (`authFlow.me`), jamais un paramètre — on ne
18
18
  * crée/révoque jamais une clé pour autrui. Montés seulement si le service `apiKeys`
19
- * existe (security chargé + clés activées) → 404, zéro surface, sinon.
19
+ * existe (security chargé) — sans security, 404 et zéro surface ; clés
20
+ * désactivées, les routes répondent 503.
20
21
  *
21
22
  * Erreurs mappées par DUCK-TYPING sur `code` (400/409/503) — framework ne peut pas
22
23
  * importer les classes d'erreur de security.
@@ -26,7 +26,7 @@ import Controller from "../src/Controller.js";
26
26
  declare class BenchController extends Controller {
27
27
  constructor(context: ContextType);
28
28
  /** Rend le corps figé. Aucune lecture de kernel, aucun I/O, aucune allocation. */
29
- index(): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
29
+ index(): import("nodefony").MaybePromise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
30
30
  /**
31
31
  * Dump de la sonde perf in-situ du http-kernel (`NF_PERF_PROBE=1`) : µs
32
32
  * moyens par requête des postes enterScope / new HttpContext / leaveScope.
@@ -35,7 +35,7 @@ declare class BenchController extends Controller {
35
35
  * pas. `?reset=1` remet les compteurs à zéro (à faire après le warmup, pour
36
36
  * ne pas diluer la mesure avec le code froid).
37
37
  */
38
- probe(): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
38
+ probe(): import("nodefony").MaybePromise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
39
39
  }
40
40
  /**
41
41
  * Monte la route de banc — appelée par le module framework, uniquement si
@@ -82,11 +82,11 @@ declare class OAuth2Controller extends Controller {
82
82
  * fournisseur déclaré `hidden` n'y figure pas, mais reste autorisable :
83
83
  * masquer n'est pas désactiver. Aucun secret n'est exposé.
84
84
  */
85
- providers(): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
85
+ providers(): import("nodefony").MaybePromise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
86
86
  /** Démarre le flux : URL d'autorisation + état anti-replay en session, 302. */
87
- authorize(provider: string): Promise<void | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
87
+ authorize(provider: string): Promise<import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse | undefined>;
88
88
  /** Valide `state`, échange le `code`, ouvre la session BFF (302). */
89
- callback(provider: string): Promise<void | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
89
+ callback(provider: string): Promise<import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse | undefined>;
90
90
  }
91
91
  /**
92
92
  * Monte les routes du social login OAuth — appelé par le module framework à
@@ -89,7 +89,7 @@ declare class ProtectedResourceMetadataController extends Controller {
89
89
  */
90
90
  export interface IServiceScan {
91
91
  keys(): string[];
92
- get<T = unknown>(name: string): T | null;
92
+ get(name: string): unknown;
93
93
  }
94
94
  /**
95
95
  * Rassemble ce que TOUS les services du conteneur déclarent protéger.
@@ -20,7 +20,8 @@ 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
- me(context: ContextType): Promise<unknown | null>;
23
+ /** Identité courante, ou `null` sans session authentifiée. */
24
+ me(context: ContextType): Promise<unknown>;
24
25
  }
25
26
  /**
26
27
  * Endpoints HTTP du flux de session BFF — adaptateurs MINCES au-dessus du
@@ -23,8 +23,8 @@ export interface ITokenIssuer {
23
23
  * - `POST /nodefony/security/api/token/refresh` — body `{refresh_token}` → rotation
24
24
  *
25
25
  * Montés par le module framework UNIQUEMENT si le service `tokenService` existe
26
- * (module security chargé + JWT activé) — sans lui, les routes n'existent pas
27
- * (404, zéro surface).
26
+ * (module security chargé) — sans lui, les routes n'existent pas (404, zéro
27
+ * surface). JWT désactivé ou store indisponible : les routes répondent 503.
28
28
  *
29
29
  * Le JWT part en **réponse JSON** (Bearer), JAMAIS en cookie ni en URL (anti
30
30
  * fuite / non-révocable). Erreurs mappées par DUCK-TYPING sur `code` (401/429) —
@@ -16,7 +16,8 @@ import Controller from "../src/Controller.js";
16
16
  * plane `^/nodefony/[^/]+/api(/|$)` → **session BFF requise**. Le sujet est
17
17
  * TOUJOURS l'utilisateur courant (`authFlow.me`), jamais un paramètre — on n'active
18
18
  * /ne désactive jamais le 2FA d'autrui (anti-IDOR). Montés seulement si le service
19
- * `totp` existe (security chargé + 2FA activé) → 404, zéro surface, sinon.
19
+ * `totp` existe (security chargé) — sans security, 404 et zéro surface ; 2FA
20
+ * désactivé, les routes répondent 503.
20
21
  */
21
22
  declare class TotpController extends Controller {
22
23
  #private;
@@ -80,7 +80,7 @@ export declare const USE_SESSION_METHOD_METADATA = "session:useMethod";
80
80
  export type ParamSource = "param" | "body" | "query" | "headers" | "cookie" | "session" | "req" | "res" | "file" | "files" | "user";
81
81
  export interface ParamMeta {
82
82
  source: ParamSource;
83
- key?: string;
83
+ key?: string | undefined;
84
84
  index: number;
85
85
  /**
86
86
  * P2.9 — `@Body({ stream: true })` : injecte le **flux brut** de la requête
@@ -89,7 +89,7 @@ export interface ParamMeta {
89
89
  * pipeline saute le parse busboy/JSON pour la route concernée (cf
90
90
  * `routeExpectsBodyStream` + `handleHttp`).
91
91
  */
92
- stream?: boolean;
92
+ stream?: boolean | undefined;
93
93
  }
94
94
  export interface RedirectMeta {
95
95
  url: string;
@@ -106,7 +106,7 @@ export interface SecurityClause {
106
106
  /** Attributs en OR — un seul accordé suffit pour valider la clause. */
107
107
  readonly anyOf: readonly string[];
108
108
  /** Nom du paramètre de route passé comme `subject` au voter (optionnel). */
109
- readonly subjectParam?: string;
109
+ readonly subjectParam?: string | undefined;
110
110
  }
111
111
  /**
112
112
  * Exigence d'autorisation **figée** d'une action — calculée UNE fois par route
@@ -258,7 +258,7 @@ declare function Redirect(url: string, statusCode?: number): (target: object, pr
258
258
  * \@Get("/") home() {} // marseille.fr/ → OK ; nodefony.com/ → 403
259
259
  * }
260
260
  */
261
- declare function Domain(patterns: string | string[]): (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
261
+ declare function Domain(patterns: string | string[]): (target: object, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
262
262
  /**
263
263
  * Déclare une route (décorateur de **méthode**) ou tout un contrôleur
264
264
  * (décorateur de **classe**) comme **PUBLIQUE** : le firewall ne s'exécute pas
@@ -288,31 +288,37 @@ declare function Domain(patterns: string | string[]): (target: any, propertyKey?
288
288
  * \@Get("/studio/api/stats") stats() {} // gaté par l'aire data plane
289
289
  * }
290
290
  */
291
- declare function BypassFirewall(target: any, propertyKey?: string, descriptor?: PropertyDescriptor): any;
291
+ declare function BypassFirewall(target: object, propertyKey?: string, descriptor?: PropertyDescriptor): any;
292
292
  /**
293
- * Déclare le scope d'instanciation d'un controller (V4.3) — pose le statique
294
- * `scope` de la classe (hérité de `Controller`, défaut `"request"`). Lu par le
295
- * constructor de `Controller` (`new.target`) et par le `Resolver` : 0 Reflect.
296
- *
297
- * `@Scope("singleton")` : UNE instance partagée par toutes les requêtes
298
- * (cache kernel-scoped sur le Router, `initialize()` appelé 1× à la création).
299
- * **Contrat stateless strict** : l'action ne lit/n'écrit AUCUN état par requête
300
- * sur `this` — tout passe par les arguments décorés (`@Param`/`@Body`…) et les
301
- * helpers, qui retrouvent la requête courante via l'ALS (V4.1). Un champ muté
302
- * par requête sur un singleton = data race silencieuse entre deux requêtes
303
- * concurrentes. Le défaut per-request reste inchangé (0 breaking legacy).
293
+ * Déclare la portée d'instanciation d'un contrôleur — pose le statique `scope`
294
+ * de la classe (hérité de `Controller`, défaut `"singleton"`). Lu par le
295
+ * constructeur de `Controller` (`new.target`) et par le `Resolver` : 0 Reflect.
296
+ *
297
+ * `@Scope("request")` : une instance par requête — par CONNEXION en
298
+ * WebSocket. À déclarer quand le contrôleur injecte un service de portée
299
+ * `request` à son constructeur (un singleton qui le tente est refusé au
300
+ * démarrage), prépare chaque requête dans `initialize()`, ou garde un état
301
+ * sur `this`.
302
+ *
303
+ * `@Scope("singleton")` redit le défaut : UNE instance partagée par toutes les
304
+ * requêtes (cache du Router, `initialize()` appelé une fois, à la création).
305
+ * **Contrat sans état** : l'action ne lit ni n'écrit aucun état de requête sur
306
+ * `this` — tout passe par les arguments décorés (`@Param`/`@Body`…) et les
307
+ * helpers, qui retrouvent la requête courante dans l'ALS. Les accesseurs
308
+ * d'état refusent l'écriture, et le développement refuse toute écriture sur
309
+ * un champ.
304
310
  *
305
311
  * ⚠️ Homonyme : le core `nodefony` exporte aussi `Scope` (le scope DI du
306
312
  * `Container`) — celui-ci s'importe depuis `@nodefony/framework`.
307
313
  *
308
314
  * @example
309
- * \@Scope("singleton")
310
- * \@controller("/api/books")
311
- * class BookController extends ResourceController { ... }
315
+ * \@Scope("request")
316
+ * \@controller("/me")
317
+ * class MeController extends Controller { ... }
312
318
  */
313
- declare function Scope(scope: ControllerScope): <T extends {
319
+ declare function Scope(scope: ControllerScope): (target: {
314
320
  scope?: ControllerScope;
315
- }>(target: T) => void;
321
+ }) => void;
316
322
  /** Options déclaratives de `@UseSession` (= forme de l'intent runtime). */
317
323
  export type UseSessionOptions = SessionIntent;
318
324
  /**
@@ -336,7 +342,7 @@ export type UseSessionOptions = SessionIntent;
336
342
  * \@Get("/me") @UseSession({ readOnly: true }) me() {} // lecture seule → 0 write
337
343
  * }
338
344
  */
339
- declare function UseSession(options?: UseSessionOptions): (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
345
+ declare function UseSession(options?: UseSessionOptions): (target: object, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
340
346
  /**
341
347
  * Résout l'intent de session effectif d'une action — lu par le `Resolver` au
342
348
  * match, posé sur `context.sessionIntent`, consommé au point d'activation unique
@@ -364,7 +370,7 @@ declare function resolveSessionIntent(ctor: ControllerConstructor, actionName: s
364
370
  */
365
371
  declare function IsGranted(attribute: string | readonly string[], options?: {
366
372
  subject?: string;
367
- }): (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
373
+ }): (target: object, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
368
374
  /**
369
375
  * Déclare une action (méthode) ou un contrôleur (classe) **publique** : skip
370
376
  * l'autorisation (override un `@IsGranted` de classe sur cette méthode) ET skip
@@ -373,7 +379,7 @@ declare function IsGranted(attribute: string | readonly string[], options?: {
373
379
  * login, une sonde de liveness, une page publique d'un contrôleur par ailleurs
374
380
  * protégé.
375
381
  */
376
- declare function Anonymous(): (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
382
+ declare function Anonymous(): (target: object, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
377
383
  /**
378
384
  * Exige un **scope** (`api:action`) pour l'action (décorateur de **méthode**) ou
379
385
  * tout le contrôleur (décorateur de **classe**). Axe d'autorisation **distinct des
@@ -395,7 +401,7 @@ declare function Anonymous(): (target: any, propertyKey?: string, descriptor?: P
395
401
  * metadata est **dédiée** (≠ `@IsGranted`) pour que la découverte au boot puisse
396
402
  * lister les scopes déclarés par route sans les confondre avec les rôles.
397
403
  */
398
- declare function RequireScope(scope: string | readonly string[]): (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
404
+ declare function RequireScope(scope: string | readonly string[]): (target: object, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
399
405
  /**
400
406
  * `@Csp({ "frame-src": [...] })` — déclare des directives CSP **additionnelles**
401
407
  * pour l'action (méthode) ou tout le contrôleur (classe). Distinct de
@@ -407,7 +413,7 @@ declare function RequireScope(scope: string | readonly string[]): (target: any,
407
413
  * dans le CSP de la réponse est fait par le firewall, hors hot-path, UNIQUEMENT
408
414
  * sur les routes décorées. Calque `@IsGranted` (0 import `@nodefony/security`).
409
415
  */
410
- declare function Csp(directives: CspDirectives): (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
416
+ declare function Csp(directives: CspDirectives): (target: object, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
411
417
  /**
412
418
  * `@CsrfProtect()` — opt-IN à la défense CSRF **synchronizer token** (double-submit
413
419
  * signé HMAC) EN PLUS de la défense globale Fetch Metadata/Origin (étape 1, toujours
@@ -416,7 +422,7 @@ declare function Csp(directives: CspDirectives): (target: any, propertyKey?: str
416
422
  * DOIT rejouer ce token dans l'en-tête `x-csrf-token` (sinon 403). Classe = toutes
417
423
  * les actions. N'écrit qu'un marqueur (0 import `@nodefony/security`, 0 cycle).
418
424
  */
419
- declare const CsrfProtect: () => (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
425
+ declare const CsrfProtect: () => (target: object, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
420
426
  /**
421
427
  * `@CsrfExempt()` — opt-OUT de la défense CSRF pour une route, **en conservant
422
428
  * l'authentification et l'autorisation** (≠ `@Anonymous`/`@BypassFirewall` qui
@@ -424,7 +430,7 @@ declare const CsrfProtect: () => (target: any, propertyKey?: string, descriptor?
424
430
  * légitime, dont la requête est authentifiée autrement (signature HMAC du provider,
425
431
  * clé API). Classe = toutes les actions. Marqueur seul (0 logique sécu ici).
426
432
  */
427
- declare const CsrfExempt: () => (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
433
+ declare const CsrfExempt: () => (target: object, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
428
434
  /**
429
435
  * `@Idempotent()` — protège une **mutation** (POST/PUT/PATCH/DELETE) d'un
430
436
  * controller userland contre le double-effet d'un rejeu (double-clic, reconnexion
@@ -452,7 +458,7 @@ declare const CsrfExempt: () => (target: any, propertyKey?: string, descriptor?:
452
458
  */
453
459
  declare function Idempotent(options?: {
454
460
  required?: boolean;
455
- }): (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
461
+ }): (target: object, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
456
462
  declare const Param: (key?: string) => (target: object, propertyKey: string, parameterIndex: number) => void;
457
463
  declare const Query: (key?: string) => (target: object, propertyKey: string, parameterIndex: number) => void;
458
464
  /**
@@ -520,23 +526,23 @@ export interface IParamArgContext {
520
526
  * transport — pont WS-RPC `api.request` (`Resolver.queryOverride`). Prime
521
527
  * sur `request.queryGet` pour `@Query` uniquement (`@Req` reste le brut).
522
528
  */
523
- queryOverride?: Record<string, unknown>;
529
+ queryOverride?: Record<string, unknown> | undefined;
524
530
  request?: {
525
- queryGet?: Record<string, unknown>;
526
- queryPost?: Record<string, unknown>;
527
- queryFile?: unknown[];
528
- headers?: Record<string, unknown>;
531
+ queryGet?: Record<string, unknown> | undefined;
532
+ queryPost?: Record<string, unknown> | undefined;
533
+ queryFile?: unknown[] | undefined;
534
+ headers?: Record<string, unknown> | undefined;
529
535
  /**
530
536
  * P2.9 — `IncomingMessage` brut (un `Readable`) sous-jacent au wrapper
531
537
  * `HttpRequest`. Injecté tel quel par `@Body({ stream: true })` (le pipeline
532
538
  * n'a pas consommé/parsé ce flux). `undefined` pour les contextes WS.
533
539
  */
534
- request?: NodeJS.ReadableStream;
535
- } | null;
540
+ request?: NodeJS.ReadableStream | undefined;
541
+ } | null | undefined;
536
542
  response?: unknown;
537
543
  session?: {
538
544
  get(key: string): unknown;
539
- } | null;
545
+ } | null | undefined;
540
546
  getRequestCookies(name?: string): unknown;
541
547
  }
542
548
  /**
@@ -570,9 +576,9 @@ declare function buildParamArgs(metas: ParamMeta[], ctx: IParamArgContext): unkn
570
576
  declare function routeExpectsBodyStream(routeDef: {
571
577
  controller?: {
572
578
  prototype: object;
573
- } | null;
574
- classMethod?: string;
575
- bodyStream?: boolean;
579
+ } | null | undefined;
580
+ classMethod?: string | undefined;
581
+ bodyStream?: boolean | undefined;
576
582
  }): boolean;
577
583
  /**
578
584
  * Snapshot des métadonnées d'action d'une route (`@HttpCode`/`@Header`/
@@ -639,8 +645,8 @@ declare function computeActionMeta(ctor?: {
639
645
  declare function resolveActionMeta(routeDef: {
640
646
  controller?: {
641
647
  prototype: object;
642
- } | null;
643
- classMethod?: string;
644
- actionMeta?: RouteActionMeta;
648
+ } | null | undefined;
649
+ classMethod?: string | undefined;
650
+ actionMeta?: RouteActionMeta | undefined;
645
651
  }): RouteActionMeta;
646
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, };
@@ -1,5 +1,5 @@
1
1
  import type { contextRequest, HTTPMethod, HttpResponse, Http2Response, WebsocketResponse, Session, ContextType } from "@nodefony/http";
2
- import type { Module, FileClass } from "nodefony";
2
+ import type { Module, FileClass, MaybePromise } from "nodefony";
3
3
  import type { OutgoingHttpHeaders } from "node:http";
4
4
  import type { ReadStream } from "node:fs";
5
5
  import type { IRoute } from "./IRoute.js";
@@ -7,21 +7,23 @@ export interface IController {
7
7
  readonly route?: IRoute | null;
8
8
  request: contextRequest;
9
9
  response: HttpResponse | Http2Response | WebsocketResponse | null;
10
- context?: ContextType;
10
+ context?: ContextType | undefined;
11
11
  session?: Session | null;
12
- method?: HTTPMethod;
12
+ method?: HTTPMethod | undefined;
13
13
  queryGet: Record<string, unknown>;
14
14
  query: Record<string, unknown>;
15
15
  queryFile: unknown[];
16
16
  queryPost: Record<string, unknown>;
17
- module?: Module;
17
+ module?: Module | undefined;
18
18
  setContext(context: ContextType): void;
19
19
  setContextJson(encoding?: BufferEncoding): unknown;
20
20
  setContextHtml(encoding?: BufferEncoding): unknown;
21
- render(data: unknown, encoding?: BufferEncoding, status?: string | number, headers?: Record<string, string | number>): Promise<unknown>;
22
- renderResponse(data: unknown, encoding?: BufferEncoding, status?: string | number, headers?: OutgoingHttpHeaders): Promise<HttpResponse | Http2Response | WebsocketResponse>;
21
+ /** Rend une donnée — la réponse, ou sa promesse quand l'envoi attend. */
22
+ render(data: unknown, encoding?: BufferEncoding, status?: string | number, headers?: Record<string, string | number>): MaybePromise<unknown>;
23
+ /** Envoie une donnée sérialisée — la réponse, ou sa promesse quand l'envoi attend. */
24
+ renderResponse(data: unknown, encoding?: BufferEncoding, status?: string | number, headers?: OutgoingHttpHeaders): MaybePromise<HttpResponse | Http2Response | WebsocketResponse>;
23
25
  renderView(path: string, param?: Record<string, unknown>, status?: string | number, headers?: Record<string, string | number>): Promise<HttpResponse | Http2Response | WebsocketResponse>;
24
- renderJson(obj: unknown, status?: string | number, headers?: OutgoingHttpHeaders): Promise<unknown>;
26
+ renderJson(obj: unknown, status?: string | number, headers?: OutgoingHttpHeaders): MaybePromise<unknown>;
25
27
  setRoute(route: IRoute): IRoute;
26
28
  getSession(): Session | undefined | null;
27
29
  redirect(url: string, status?: string | number, headers?: Record<string, string | number>): void;