@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.
- package/README.md +5 -2
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +4 -4
- package/dist/nodefony/controller/AdminApiController.js +4 -3
- package/dist/nodefony/controller/ApiKeyController.js +3 -2
- package/dist/nodefony/controller/OAuth2Controller.js +11 -6
- package/dist/nodefony/controller/SessionAuthController.js +3 -3
- package/dist/nodefony/controller/TokenAuthController.js +4 -4
- package/dist/nodefony/controller/TotpController.js +7 -5
- package/dist/nodefony/controller/WebAuthnController.js +4 -4
- package/dist/nodefony/decorators/routerDecorators.js +41 -35
- package/dist/nodefony/service/AdminBroker.js +2 -2
- package/dist/nodefony/service/IdempotencyStore.js +4 -5
- package/dist/nodefony/service/router.js +78 -26
- package/dist/nodefony/src/Controller.js +184 -78
- package/dist/nodefony/src/FrameworkAdminApi.js +19 -6
- package/dist/nodefony/src/KernelAdminApi.js +49 -49
- package/dist/nodefony/src/PlaygroundAdminApi.js +3 -3
- package/dist/nodefony/src/RedisIdempotencyStore.js +2 -2
- package/dist/nodefony/src/Resolver.js +221 -65
- package/dist/nodefony/src/Route.js +62 -39
- package/dist/nodefony/src/SyslogAdminApi.js +4 -4
- package/dist/nodefony/src/docsReader.js +49 -39
- package/dist/nodefony/src/scopeCatalog.js +1 -1
- package/dist/nodefony/src/singletonGuard.js +76 -0
- package/dist/types/nodefony/controller/ApiKeyController.d.ts +2 -1
- package/dist/types/nodefony/controller/BenchController.d.ts +2 -2
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +3 -3
- package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +1 -1
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +2 -1
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +2 -2
- package/dist/types/nodefony/controller/TotpController.d.ts +2 -1
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +49 -43
- package/dist/types/nodefony/interfaces/IController.d.ts +9 -7
- package/dist/types/nodefony/interfaces/IResolver.d.ts +10 -10
- package/dist/types/nodefony/interfaces/IRoute.d.ts +13 -13
- package/dist/types/nodefony/service/AdminBroker.d.ts +3 -3
- package/dist/types/nodefony/service/router.d.ts +33 -7
- package/dist/types/nodefony/src/Controller.d.ts +97 -24
- package/dist/types/nodefony/src/Resolver.d.ts +155 -16
- package/dist/types/nodefony/src/ResourceController.d.ts +6 -6
- package/dist/types/nodefony/src/Route.d.ts +39 -23
- package/dist/types/nodefony/src/docsReader.d.ts +19 -9
- package/dist/types/nodefony/src/singletonGuard.d.ts +53 -0
- package/docs/admin.md +1 -1
- package/docs/controller.md +139 -121
- package/docs/decorateurs.md +102 -98
- package/docs/idempotence.md +23 -21
- package/docs/index.md +1 -1
- package/docs/routing.md +60 -53
- package/docs/templates.md +14 -14
- 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
|
|
74
|
-
return
|
|
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 (
|
|
301
|
-
const carried = terms.filter((t) =>
|
|
302
|
-
|
|
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
|
|
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(
|
|
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 (
|
|
420
|
+
if (lines[start - 1]?.trim().endsWith("*/")) {
|
|
418
421
|
let i = start - 1;
|
|
419
|
-
while (i > 0 && !lines[i]
|
|
420
|
-
if (lines[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 (
|
|
461
|
-
|
|
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 &&
|
|
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:
|
|
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:
|
|
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:
|
|
686
|
+
cmd: manager,
|
|
682
687
|
args: ["run", "coverage"],
|
|
683
|
-
mode: "
|
|
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) → `
|
|
690
|
-
* - sinon →
|
|
691
|
-
* pour vitest comme pour
|
|
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).
|
|
695
|
-
* d'injection
|
|
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
|
-
|
|
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
|
|
724
|
-
child.stderr
|
|
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
|
|
732
|
-
child.stderr
|
|
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
|
|
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 {
|
|
@@ -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é
|
|
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():
|
|
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():
|
|
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():
|
|
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<
|
|
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<
|
|
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
|
|
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
|
-
|
|
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é
|
|
27
|
-
*
|
|
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é
|
|
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:
|
|
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:
|
|
291
|
+
declare function BypassFirewall(target: object, propertyKey?: string, descriptor?: PropertyDescriptor): any;
|
|
292
292
|
/**
|
|
293
|
-
* Déclare
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
* `@Scope("
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
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("
|
|
310
|
-
* \@controller("/
|
|
311
|
-
* class
|
|
315
|
+
* \@Scope("request")
|
|
316
|
+
* \@controller("/me")
|
|
317
|
+
* class MeController extends Controller { ... }
|
|
312
318
|
*/
|
|
313
|
-
declare function Scope(scope: ControllerScope):
|
|
319
|
+
declare function Scope(scope: ControllerScope): (target: {
|
|
314
320
|
scope?: ControllerScope;
|
|
315
|
-
}
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
22
|
-
|
|
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):
|
|
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;
|