@nodefony/framework 10.0.0-alpha.1
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/LICENSE +544 -0
- package/README.md +50 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +211 -0
- package/dist/nodefony/config/config.js +61 -0
- package/dist/nodefony/config/defineModuleConfig.js +36 -0
- package/dist/nodefony/controller/AdminApiController.js +163 -0
- package/dist/nodefony/controller/ApiKeyController.js +151 -0
- package/dist/nodefony/controller/BenchController.js +132 -0
- package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
- package/dist/nodefony/controller/OAuth2Controller.js +133 -0
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
- package/dist/nodefony/controller/SessionAuthController.js +141 -0
- package/dist/nodefony/controller/TokenAuthController.js +113 -0
- package/dist/nodefony/controller/TotpController.js +129 -0
- package/dist/nodefony/controller/WebAuthnController.js +242 -0
- package/dist/nodefony/controller/oauthAuthority.js +74 -0
- package/dist/nodefony/decorators/routerDecorators.js +967 -0
- package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
- package/dist/nodefony/interfaces/IController.js +1 -0
- package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
- package/dist/nodefony/interfaces/IResolver.js +1 -0
- package/dist/nodefony/interfaces/IRoute.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/AdminBroker.js +106 -0
- package/dist/nodefony/service/Eta.js +68 -0
- package/dist/nodefony/service/IdempotencyStore.js +136 -0
- package/dist/nodefony/service/router.js +243 -0
- package/dist/nodefony/src/Controller.js +515 -0
- package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
- package/dist/nodefony/src/KernelAdminApi.js +1243 -0
- package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
- package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
- package/dist/nodefony/src/Resolver.js +416 -0
- package/dist/nodefony/src/ResourceController.js +148 -0
- package/dist/nodefony/src/Route.js +476 -0
- package/dist/nodefony/src/SyslogAdminApi.js +466 -0
- package/dist/nodefony/src/Template.js +15 -0
- package/dist/nodefony/src/configMutation.js +186 -0
- package/dist/nodefony/src/docsReader.js +929 -0
- package/dist/nodefony/src/idempotency.js +137 -0
- package/dist/nodefony/src/idempotencyGc.js +32 -0
- package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
- package/dist/nodefony/src/scopeCatalog.js +40 -0
- package/dist/nodefony/src/syslogFilters.js +51 -0
- package/dist/types/index.d.ts +96 -0
- package/dist/types/nodefony/config/config.d.ts +41 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
- package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
- package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
- package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
- package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
- package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
- package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
- package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
- package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
- package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
- package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
- package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
- package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
- package/dist/types/nodefony/interfaces/index.d.ts +5 -0
- package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
- package/dist/types/nodefony/service/Eta.d.ts +25 -0
- package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
- package/dist/types/nodefony/service/router.d.ts +53 -0
- package/dist/types/nodefony/src/Controller.d.ts +193 -0
- package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
- package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
- package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
- package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
- package/dist/types/nodefony/src/Resolver.d.ts +165 -0
- package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
- package/dist/types/nodefony/src/Route.d.ts +192 -0
- package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
- package/dist/types/nodefony/src/Template.d.ts +8 -0
- package/dist/types/nodefony/src/configMutation.d.ts +109 -0
- package/dist/types/nodefony/src/docsReader.d.ts +369 -0
- package/dist/types/nodefony/src/idempotency.d.ts +96 -0
- package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
- package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
- package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
- package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
- package/docs/admin.md +451 -0
- package/docs/controller.md +645 -0
- package/docs/decorateurs.md +845 -0
- package/docs/idempotence.md +741 -0
- package/docs/index.md +151 -0
- package/docs/routing.md +648 -0
- package/docs/templates.md +380 -0
- package/package.json +83 -0
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
import Controller from "../src/Controller.js";
|
|
2
|
+
import router_default from "../service/router.js";
|
|
3
|
+
import { RequestContext } from "nodefony";
|
|
4
|
+
import "reflect-metadata";
|
|
5
|
+
//#region nodefony/decorators/routerDecorators.ts
|
|
6
|
+
const metadataKey = "routes:definitions";
|
|
7
|
+
/**
|
|
8
|
+
* Noms déjà portés par `Controller` — méthodes ET accesseurs, les siens comme
|
|
9
|
+
* ceux hérités de `Service`. Une action qui en reprend un est refusée à la
|
|
10
|
+
* déclaration (cf `assertActionNameFree`).
|
|
11
|
+
*
|
|
12
|
+
* Résolu PARESSEUSEMENT, et une seule fois : `Controller` participe à un cycle
|
|
13
|
+
* d'import (`Controller` → `Router` → ce module), le lire au chargement du
|
|
14
|
+
* module tomberait dans sa zone morte temporelle. Au premier décorateur d'un
|
|
15
|
+
* controller userland, la classe de base est forcément déjà initialisée.
|
|
16
|
+
*
|
|
17
|
+
* @returns Map `nom → classe qui le porte` (la plus DÉRIVÉE des deux gagne).
|
|
18
|
+
*/
|
|
19
|
+
let reservedActionNames = null;
|
|
20
|
+
const getReservedActionNames = () => {
|
|
21
|
+
if (reservedActionNames) return reservedActionNames;
|
|
22
|
+
const reserved = /* @__PURE__ */ new Map();
|
|
23
|
+
let proto = Controller.prototype;
|
|
24
|
+
while (proto && proto !== Object.prototype) {
|
|
25
|
+
const owner = proto.constructor?.name ?? "Controller";
|
|
26
|
+
for (const key of Object.getOwnPropertyNames(proto)) if (key !== "constructor" && !reserved.has(key)) reserved.set(key, owner);
|
|
27
|
+
proto = Object.getPrototypeOf(proto);
|
|
28
|
+
}
|
|
29
|
+
reservedActionNames = reserved;
|
|
30
|
+
return reserved;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Refuse, AU MOMENT DE LA DÉCLARATION, une action dont le nom est déjà celui
|
|
34
|
+
* d'un membre de `Controller`.
|
|
35
|
+
*
|
|
36
|
+
* Le langage sanctionne déjà ce conflit, mais tard et sans le nommer : un
|
|
37
|
+
* `remove()` de controller produit un `TS2416` sur une incompatibilité de
|
|
38
|
+
* signature (`Service.remove(name): boolean`), et un `session()` ne produit
|
|
39
|
+
* rien du tout — l'accesseur de la classe de base masque simplement l'action à
|
|
40
|
+
* l'instanciation. La règle posée ici est volontairement plus stricte que
|
|
41
|
+
* TypeScript (elle refuse le nom, sans regarder les signatures) : une règle
|
|
42
|
+
* qu'on peut énoncer en une phrase vaut mieux qu'une règle exacte que
|
|
43
|
+
* personne ne peut anticiper.
|
|
44
|
+
*
|
|
45
|
+
* @param target - Le prototype de la classe qui déclare l'action.
|
|
46
|
+
* @param propertyKey - Le nom de la méthode décorée.
|
|
47
|
+
* @throws Error nommant le membre en conflit et la sortie (renommer l'action).
|
|
48
|
+
*/
|
|
49
|
+
function assertActionNameFree(target, propertyKey) {
|
|
50
|
+
if (!Object.prototype.isPrototypeOf.call(Controller.prototype, target)) return;
|
|
51
|
+
const owner = getReservedActionNames().get(propertyKey);
|
|
52
|
+
if (!owner) return;
|
|
53
|
+
const className = target.constructor?.name ?? "(anonyme)";
|
|
54
|
+
throw new Error(`Action « ${propertyKey} » de ${className} : ce nom est RÉSERVÉ par le framework — ${owner} porte déjà un membre « ${propertyKey} », dont tout controller hérite. Le conflit casse la compilation (TS2416) ou masque silencieusement l'action. Renommez la méthode (« ${propertyKey}Action », « ${propertyKey}One »…) : l'URL vient du décorateur, pas du nom de la méthode — seul le nom généré de la route suit (« ${className}::${propertyKey} »).`);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Rattache un ou plusieurs contrôleurs à un module, dont ils suivent le cycle de vie.
|
|
58
|
+
*
|
|
59
|
+
* Décorateur de **classe de module**. L'enregistrement n'a pas lieu à
|
|
60
|
+
* l'évaluation du décorateur mais au hook `onBoot` du kernel : tant que le boot
|
|
61
|
+
* n'a pas eu lieu, les routes déclarées par `@route` sur ces classes n'existent
|
|
62
|
+
* pas encore dans le routeur — un test qui interroge le routeur sans booter ne
|
|
63
|
+
* verra rien. L'enregistrement est tagué au nom du module, de sorte qu'un échec
|
|
64
|
+
* désigne le module fautif au lieu d'un contrôleur anonyme.
|
|
65
|
+
*
|
|
66
|
+
* @param controller - Un contrôleur, ou un tableau de contrôleurs, à rattacher.
|
|
67
|
+
* @returns Le décorateur de classe, qui renvoie le module enrichi du hook.
|
|
68
|
+
* @example
|
|
69
|
+
* ```typescript
|
|
70
|
+
* @controllers([DefaultController, RestController])
|
|
71
|
+
* class TestModule extends Module {}
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
function controllers(targets) {
|
|
75
|
+
return function(constructor) {
|
|
76
|
+
class NewConstructorControllers extends constructor {
|
|
77
|
+
constructor(...args) {
|
|
78
|
+
super(...args);
|
|
79
|
+
this.hookKernel("onBoot", async () => {
|
|
80
|
+
return this.initDecoratorControllers();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
async initDecoratorControllers() {
|
|
84
|
+
const log = (contr) => {
|
|
85
|
+
router_default.setController(contr, this);
|
|
86
|
+
this.log(`ADD CONTROLLER : ${contr.name}`, "DEBUG");
|
|
87
|
+
const declared = router_default.getRoutesForController(contr);
|
|
88
|
+
if (this.kernel?.debug) for (const r of declared) this.log(`route + ${r.toLogLine()}`, "DEBUG");
|
|
89
|
+
for (const r of declared) if (r.unreachableChars) this.log(`route INATTEIGNABLE : ${r.name} — le chemin « ${r.path} » contient ${r.unreachableChars.map((c) => `« ${c} »`).join(", ")}, qu'une requête ne porte jamais (encodé par l'analyseur d'URL, ou pris pour un délimiteur). Cette route ne répondra à rien.`, "WARNING");
|
|
90
|
+
};
|
|
91
|
+
if (Array.isArray(targets)) for (const contr of targets) log(contr);
|
|
92
|
+
else log(targets);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return NewConstructorControllers;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Declaration Controller
|
|
100
|
+
*
|
|
101
|
+
* @param prefix - prefixage du router du controller.
|
|
102
|
+
* @param options - Les options .
|
|
103
|
+
* @returns Un décorateur de méthode qui peut être utilisé pour annoter une méthode de contrôleur.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* \@controller("/openapi")
|
|
107
|
+
* \@UseSession()
|
|
108
|
+
* class OpenApiController extends Controller {
|
|
109
|
+
* constructor(context: Context) {
|
|
110
|
+
* super("OpenApiController", context);
|
|
111
|
+
* }
|
|
112
|
+
* \@route("index-openapi", { path: "" })
|
|
113
|
+
* index() {
|
|
114
|
+
* this.render({});
|
|
115
|
+
* }
|
|
116
|
+
* }
|
|
117
|
+
*/
|
|
118
|
+
function controller(prefix) {
|
|
119
|
+
return function(mycontroller) {
|
|
120
|
+
mycontroller.prefix = prefix;
|
|
121
|
+
const metadata = Reflect.getMetadata(metadataKey, mycontroller) || {};
|
|
122
|
+
if (metadata && Object.keys(metadata).length !== 0) {
|
|
123
|
+
let hasMagic = false;
|
|
124
|
+
for (const name in metadata) {
|
|
125
|
+
const options = metadata[name];
|
|
126
|
+
options.prefix = prefix;
|
|
127
|
+
if (options.host === void 0) {
|
|
128
|
+
const methodDomain = Reflect.getMetadata(DOMAIN_METHOD_METADATA, mycontroller, options.classMethod);
|
|
129
|
+
const classDomain = Reflect.getMetadata(DOMAIN_CLASS_METADATA, mycontroller);
|
|
130
|
+
const domain = methodDomain ?? classDomain;
|
|
131
|
+
if (domain) options.host = domain;
|
|
132
|
+
}
|
|
133
|
+
if (options.bypassFirewall !== true) {
|
|
134
|
+
const methodBypass = Reflect.getMetadata(BYPASS_FIREWALL_METHOD_METADATA, mycontroller, options.classMethod);
|
|
135
|
+
const classBypass = Reflect.getMetadata(BYPASS_FIREWALL_CLASS_METADATA, mycontroller);
|
|
136
|
+
if (methodBypass === true || classBypass === true) options.bypassFirewall = true;
|
|
137
|
+
}
|
|
138
|
+
if (options.path == "*") {
|
|
139
|
+
hasMagic = {
|
|
140
|
+
options,
|
|
141
|
+
name
|
|
142
|
+
};
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
router_default.createRoute(name, options);
|
|
146
|
+
}
|
|
147
|
+
if (hasMagic) router_default.createRoute(hasMagic.name, hasMagic.options);
|
|
148
|
+
}
|
|
149
|
+
Reflect.deleteMetadata(metadataKey, mycontroller);
|
|
150
|
+
return mycontroller;
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Crée une route avec le nom et les options spécifiés.
|
|
155
|
+
*
|
|
156
|
+
* @param name - Le nom de la route.
|
|
157
|
+
* @param options - Les options de la route.
|
|
158
|
+
* @returns Un décorateur de méthode qui peut être utilisé pour annoter une méthode de contrôleur.
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* \@route("myroute", {
|
|
162
|
+
* path: "/add/{name}",
|
|
163
|
+
* method: ["GET", "POST"],
|
|
164
|
+
* defaults: { name: "john" },
|
|
165
|
+
* })
|
|
166
|
+
* method(name: string) {
|
|
167
|
+
* return this.renderJson({ name });
|
|
168
|
+
* }
|
|
169
|
+
*/
|
|
170
|
+
function route(name, options) {
|
|
171
|
+
return function(target, propertyKey, descriptor) {
|
|
172
|
+
assertActionNameFree(target, propertyKey);
|
|
173
|
+
const className = target.constructor.name;
|
|
174
|
+
const classMethod = propertyKey;
|
|
175
|
+
const prefix = options.prefix || null;
|
|
176
|
+
const path = options.path || "";
|
|
177
|
+
let filePath;
|
|
178
|
+
try {
|
|
179
|
+
const stackTrace = (/* @__PURE__ */ new Error()).stack?.split("\n").slice(2);
|
|
180
|
+
if (!stackTrace) throw new Error("Erreur lors de l'obtention de la pile d'appels.");
|
|
181
|
+
const controllerFilePath = extractControllerFilePath(stackTrace);
|
|
182
|
+
if (!controllerFilePath) throw new Error("Fichier de contrôleur non trouvé dans la pile d'appels.");
|
|
183
|
+
filePath = controllerFilePath;
|
|
184
|
+
} catch (error) {
|
|
185
|
+
filePath = error;
|
|
186
|
+
}
|
|
187
|
+
const metadata = Reflect.getMetadata(metadataKey, target.constructor) || {};
|
|
188
|
+
metadata[name] = {
|
|
189
|
+
path,
|
|
190
|
+
filePath,
|
|
191
|
+
constructor: target.constructor,
|
|
192
|
+
prefix,
|
|
193
|
+
className,
|
|
194
|
+
classMethod,
|
|
195
|
+
method: options.method,
|
|
196
|
+
host: options.host,
|
|
197
|
+
defaults: options.defaults,
|
|
198
|
+
requirements: options.requirements,
|
|
199
|
+
bypassFirewall: options.bypassFirewall
|
|
200
|
+
};
|
|
201
|
+
Reflect.defineMetadata(metadataKey, metadata, target.constructor);
|
|
202
|
+
return descriptor;
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function extractControllerFilePath(stackTrace) {
|
|
206
|
+
for (const line of stackTrace) {
|
|
207
|
+
const match = line.match(/\s+at file:\/\/(.*\/controllers?\/.*\.js)/);
|
|
208
|
+
if (match && match[1]) return match[1];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const HTTP_CODE_METADATA = "route:httpCode";
|
|
212
|
+
const HEADERS_METADATA = "route:responseHeaders";
|
|
213
|
+
const REDIRECT_METADATA = "route:redirect";
|
|
214
|
+
const PARAM_ARGS_METADATA = "route:paramArgs";
|
|
215
|
+
const DOMAIN_CLASS_METADATA = "route:domainClass";
|
|
216
|
+
const DOMAIN_METHOD_METADATA = "route:domainMethod";
|
|
217
|
+
const BYPASS_FIREWALL_CLASS_METADATA = "route:bypassFirewallClass";
|
|
218
|
+
const BYPASS_FIREWALL_METHOD_METADATA = "route:bypassFirewallMethod";
|
|
219
|
+
const SECURITY_CLAUSES_METADATA = "nodefony:security:clauses";
|
|
220
|
+
const SECURITY_ANONYMOUS_METADATA = "nodefony:security:anonymous";
|
|
221
|
+
const SECURITY_SCOPES_METADATA = "nodefony:security:scopes";
|
|
222
|
+
const CSP_DIRECTIVES_METADATA = "nodefony:csp:directives";
|
|
223
|
+
const CSRF_PROTECT_METADATA = "nodefony:csrf:protect";
|
|
224
|
+
const CSRF_EXEMPT_METADATA = "nodefony:csrf:exempt";
|
|
225
|
+
const IDEMPOTENT_METADATA = "nodefony:idempotent";
|
|
226
|
+
const USE_SESSION_CLASS_METADATA = "session:useClass";
|
|
227
|
+
const USE_SESSION_METHOD_METADATA = "session:useMethod";
|
|
228
|
+
function httpMethodDecorator(methods) {
|
|
229
|
+
return function(path = "", options = {}) {
|
|
230
|
+
return function(target, propertyKey, descriptor) {
|
|
231
|
+
const name = `${target.constructor.name}::${propertyKey}`;
|
|
232
|
+
const declaredMethods = options.requirements?.methods;
|
|
233
|
+
const added = declaredMethods === void 0 ? [] : Array.isArray(declaredMethods) ? declaredMethods : [declaredMethods];
|
|
234
|
+
const fusion = added.length > 0 ? [.../* @__PURE__ */ new Set([...methods, ...added])] : methods;
|
|
235
|
+
return route(name, {
|
|
236
|
+
...options,
|
|
237
|
+
path,
|
|
238
|
+
requirements: {
|
|
239
|
+
...options.requirements,
|
|
240
|
+
methods: fusion
|
|
241
|
+
}
|
|
242
|
+
})(target, propertyKey, descriptor);
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const Get = httpMethodDecorator(["GET"]);
|
|
247
|
+
const Post = httpMethodDecorator(["POST"]);
|
|
248
|
+
const Put = httpMethodDecorator(["PUT"]);
|
|
249
|
+
const Delete = httpMethodDecorator(["DELETE"]);
|
|
250
|
+
const Patch = httpMethodDecorator(["PATCH"]);
|
|
251
|
+
const Options = httpMethodDecorator(["OPTIONS"]);
|
|
252
|
+
const Head = httpMethodDecorator(["HEAD"]);
|
|
253
|
+
/**
|
|
254
|
+
* `@All` — route sans restriction de méthode : matche **toutes** les méthodes
|
|
255
|
+
* HTTP (équivalent NestJS `@All()`). N'émet aucun requirement `methods`, donc
|
|
256
|
+
* `Route.matchRequirements` ne lève jamais 405 sur la méthode.
|
|
257
|
+
*/
|
|
258
|
+
function All(path = "", options = {}) {
|
|
259
|
+
return function(target, propertyKey, descriptor) {
|
|
260
|
+
return route(`${target.constructor.name}::${propertyKey}`, {
|
|
261
|
+
...options,
|
|
262
|
+
path
|
|
263
|
+
})(target, propertyKey, descriptor);
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Fixe le code de statut HTTP de la réponse d'une action.
|
|
268
|
+
*
|
|
269
|
+
* Décorateur de **méthode**. Le code est posé sur la réponse **avant** que le
|
|
270
|
+
* corps de l'action ne s'exécute (`Resolver._applyResponseMeta`) : l'action
|
|
271
|
+
* garde donc le dernier mot et peut encore le remplacer. Emploie-le pour le
|
|
272
|
+
* statut nominal d'une action — un 201 sur une création — et non pour un statut
|
|
273
|
+
* qui dépend du résultat. La métadonnée n'est lue qu'une fois par route, puis
|
|
274
|
+
* mémorisée : le décorateur ne coûte rien par requête.
|
|
275
|
+
*
|
|
276
|
+
* @param statusCode - Code HTTP appliqué à la réponse (201, 204, 202…).
|
|
277
|
+
* @returns Le décorateur de méthode.
|
|
278
|
+
* @example
|
|
279
|
+
* ```typescript
|
|
280
|
+
* @route("item-create", { path: "/items", method: "POST" })
|
|
281
|
+
* @HttpCode(201)
|
|
282
|
+
* async create() {
|
|
283
|
+
* return this.renderJson({ id: 42 });
|
|
284
|
+
* }
|
|
285
|
+
* ```
|
|
286
|
+
*/
|
|
287
|
+
function HttpCode(statusCode) {
|
|
288
|
+
return function(target, propertyKey, descriptor) {
|
|
289
|
+
Reflect.defineMetadata(HTTP_CODE_METADATA, statusCode, target, propertyKey);
|
|
290
|
+
return descriptor;
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Ajoute un en-tête à la réponse d'une action.
|
|
295
|
+
*
|
|
296
|
+
* Décorateur de **méthode**, empilable : chaque application ajoute une entrée,
|
|
297
|
+
* la dernière l'emportant sur un même nom d'en-tête. Les en-têtes sont posés
|
|
298
|
+
* avant l'exécution du corps de l'action, qui peut donc encore les modifier.
|
|
299
|
+
* Réserve-le aux en-têtes constants d'une action ; ce qui dépend de la requête
|
|
300
|
+
* s'écrit dans le corps.
|
|
301
|
+
*
|
|
302
|
+
* @param key - Nom de l'en-tête.
|
|
303
|
+
* @param value - Valeur de l'en-tête.
|
|
304
|
+
* @returns Le décorateur de méthode.
|
|
305
|
+
* @example
|
|
306
|
+
* ```typescript
|
|
307
|
+
* @route("feed", { path: "/feed", method: "GET" })
|
|
308
|
+
* @Header("Cache-Control", "public, max-age=3600")
|
|
309
|
+
* async feed() {
|
|
310
|
+
* return this.renderJson(items);
|
|
311
|
+
* }
|
|
312
|
+
* ```
|
|
313
|
+
*/
|
|
314
|
+
function Header(key, value) {
|
|
315
|
+
return function(target, propertyKey, descriptor) {
|
|
316
|
+
const existing = Reflect.getMetadata("route:responseHeaders", target, propertyKey) || {};
|
|
317
|
+
existing[key] = value;
|
|
318
|
+
Reflect.defineMetadata(HEADERS_METADATA, existing, target, propertyKey);
|
|
319
|
+
return descriptor;
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Redirige la réponse d'une action vers une autre URL.
|
|
324
|
+
*
|
|
325
|
+
* Décorateur de **méthode**. ⚠️ Le corps de l'action **est exécuté** : la
|
|
326
|
+
* redirection est portée à côté du résultat et appliquée après coup par le
|
|
327
|
+
* `Resolver`. Ce n'est donc pas un court-circuit — tout effet de bord écrit dans
|
|
328
|
+
* l'action a bien lieu. L'action peut d'ailleurs surcharger la cible ou le code
|
|
329
|
+
* en renvoyant sa propre redirection.
|
|
330
|
+
*
|
|
331
|
+
* @param url - URL cible, absolue ou relative à l'application.
|
|
332
|
+
* @param statusCode - Code HTTP de redirection. Défaut `302` (temporaire) ;
|
|
333
|
+
* `301` pour un déplacement permanent, `307` pour conserver la méthode.
|
|
334
|
+
* @returns Le décorateur de méthode.
|
|
335
|
+
* @example
|
|
336
|
+
* ```typescript
|
|
337
|
+
* @route("legacy", { path: "/old-path", method: "GET" })
|
|
338
|
+
* @Redirect("/new-path", 301)
|
|
339
|
+
* async oldPath() {}
|
|
340
|
+
* ```
|
|
341
|
+
*/
|
|
342
|
+
function Redirect(url, statusCode = 302) {
|
|
343
|
+
return function(target, propertyKey, descriptor) {
|
|
344
|
+
const meta = {
|
|
345
|
+
url,
|
|
346
|
+
statusCode
|
|
347
|
+
};
|
|
348
|
+
Reflect.defineMetadata(REDIRECT_METADATA, meta, target, propertyKey);
|
|
349
|
+
return descriptor;
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Restreint une route (décorateur de **méthode**) ou tout un contrôleur
|
|
354
|
+
* (décorateur de **classe**) à un ou plusieurs vhosts. Source de vérité du
|
|
355
|
+
* domaine de routing — le `host` posé ici alimente `Route.host`, compilé en
|
|
356
|
+
* RegExp ancrée/wildcard (matcher partagé `@nodefony/http`). Domaine non servi
|
|
357
|
+
* par la route → 403.
|
|
358
|
+
*
|
|
359
|
+
* Précédence : `@route({ host })` > `@Domain` méthode > `@Domain` classe.
|
|
360
|
+
*
|
|
361
|
+
* Pattern : exact (`"marseille.fr"`) ou wildcard un-label (`"*.cdn.nodefony.com"`).
|
|
362
|
+
*
|
|
363
|
+
* ⚠️ En décorateur de **classe**, placer `@Domain` SOUS `@controller` : les
|
|
364
|
+
* décorateurs de classe s'appliquent de bas en haut, et `@controller` construit
|
|
365
|
+
* les routes — il doit voir le domaine de classe déjà posé.
|
|
366
|
+
*
|
|
367
|
+
* @example
|
|
368
|
+
* \@controller("/")
|
|
369
|
+
* \@Domain("marseille.fr")
|
|
370
|
+
* class MarseilleController extends Controller {
|
|
371
|
+
* \@Get("/") home() {} // marseille.fr/ → OK ; nodefony.com/ → 403
|
|
372
|
+
* }
|
|
373
|
+
*/
|
|
374
|
+
function Domain(patterns) {
|
|
375
|
+
const list = Array.isArray(patterns) ? patterns : [patterns];
|
|
376
|
+
return function(target, propertyKey, descriptor) {
|
|
377
|
+
if (propertyKey === void 0) {
|
|
378
|
+
Reflect.defineMetadata(DOMAIN_CLASS_METADATA, list, target);
|
|
379
|
+
return target;
|
|
380
|
+
}
|
|
381
|
+
Reflect.defineMetadata(DOMAIN_METHOD_METADATA, list, target.constructor, propertyKey);
|
|
382
|
+
return descriptor;
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Déclare une route (décorateur de **méthode**) ou tout un contrôleur
|
|
387
|
+
* (décorateur de **classe**) comme **PUBLIQUE** : le firewall ne s'exécute pas
|
|
388
|
+
* (`Route.bypassFirewall`). Pour la **liveness** (`/health`, `/info` — sondes
|
|
389
|
+
* k8s/monitoring NON authentifiées, ping pré-login), les **webhooks signés**, ou
|
|
390
|
+
* un endpoint d'auth (login). Sucre déclaratif sur l'option
|
|
391
|
+
* `RouteOptions.bypassFirewall` (les deux coexistent ; l'option l'emporte).
|
|
392
|
+
*
|
|
393
|
+
* Précédence : `@Get({ bypassFirewall })` > `@BypassFirewall` méthode > classe.
|
|
394
|
+
* Lu au montage `@controller` (ordre des décorateurs indifférent). **Fail-closed** :
|
|
395
|
+
* un oubli laisse la route GATÉE (401), jamais ouverte par erreur.
|
|
396
|
+
*
|
|
397
|
+
* ⚠️ En décorateur de **classe**, placer `@BypassFirewall` SOUS `@controller`
|
|
398
|
+
* (décorateurs de classe appliqués de bas en haut). Préfigure `@Public`/
|
|
399
|
+
* `@Anonymous` (P6.8b) — sémantique « pas d'auth », qui s'appuiera sur ce primitif.
|
|
400
|
+
*
|
|
401
|
+
* Décorateur SANS argument → **simple, SANS parenthèses** (`@BypassFirewall`,
|
|
402
|
+
* pas `@BypassFirewall()`) : c'est un DRAPEAU, pas une option paramétrée. Une
|
|
403
|
+
* factory (`()`) ne se justifie que pour passer des arguments (cf `@Get("/x")`,
|
|
404
|
+
* `@Domain("host")`).
|
|
405
|
+
*
|
|
406
|
+
* @example
|
|
407
|
+
* \@controller("/nodefony")
|
|
408
|
+
* class StudioController extends Controller {
|
|
409
|
+
* \@BypassFirewall
|
|
410
|
+
* \@Get("/studio/api/health") health() {} // public (liveness)
|
|
411
|
+
* \@Get("/studio/api/stats") stats() {} // gaté par l'aire data plane
|
|
412
|
+
* }
|
|
413
|
+
*/
|
|
414
|
+
function BypassFirewall(target, propertyKey, descriptor) {
|
|
415
|
+
if (propertyKey === void 0) {
|
|
416
|
+
Reflect.defineMetadata(BYPASS_FIREWALL_CLASS_METADATA, true, target);
|
|
417
|
+
return target;
|
|
418
|
+
}
|
|
419
|
+
Reflect.defineMetadata(BYPASS_FIREWALL_METHOD_METADATA, true, target.constructor, propertyKey);
|
|
420
|
+
return descriptor;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Déclare le scope d'instanciation d'un controller (V4.3) — pose le statique
|
|
424
|
+
* `scope` de la classe (hérité de `Controller`, défaut `"request"`). Lu par le
|
|
425
|
+
* constructor de `Controller` (`new.target`) et par le `Resolver` : 0 Reflect.
|
|
426
|
+
*
|
|
427
|
+
* `@Scope("singleton")` : UNE instance partagée par toutes les requêtes
|
|
428
|
+
* (cache kernel-scoped sur le Router, `initialize()` appelé 1× à la création).
|
|
429
|
+
* **Contrat stateless strict** : l'action ne lit/n'écrit AUCUN état par requête
|
|
430
|
+
* sur `this` — tout passe par les arguments décorés (`@Param`/`@Body`…) et les
|
|
431
|
+
* helpers, qui retrouvent la requête courante via l'ALS (V4.1). Un champ muté
|
|
432
|
+
* par requête sur un singleton = data race silencieuse entre deux requêtes
|
|
433
|
+
* concurrentes. Le défaut per-request reste inchangé (0 breaking legacy).
|
|
434
|
+
*
|
|
435
|
+
* ⚠️ Homonyme : le core `nodefony` exporte aussi `Scope` (le scope DI du
|
|
436
|
+
* `Container`) — celui-ci s'importe depuis `@nodefony/framework`.
|
|
437
|
+
*
|
|
438
|
+
* @example
|
|
439
|
+
* \@Scope("singleton")
|
|
440
|
+
* \@controller("/api/books")
|
|
441
|
+
* class BookController extends ResourceController { ... }
|
|
442
|
+
*/
|
|
443
|
+
function Scope(scope) {
|
|
444
|
+
return function(target) {
|
|
445
|
+
target.scope = scope;
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Déclare qu'une route (décorateur de **méthode**) ou tout un contrôleur
|
|
450
|
+
* (décorateur de **classe**) a besoin d'une **session serveur**. C'est l'unique
|
|
451
|
+
* façon d'activer une session (avec la reprise auto d'un cookie existant — L1) :
|
|
452
|
+
* il n'y a plus de `sessionAutoStart` global « démarre partout » (le moteur du
|
|
453
|
+
* ×23). Lazy par défaut — aucune session pour une route qui n'en déclare pas.
|
|
454
|
+
*
|
|
455
|
+
* Précédence : `@UseSession` méthode > `@UseSession` classe. La simple présence
|
|
456
|
+
* d'un paramètre `@Session` sur l'action suffit aussi (intent implicite).
|
|
457
|
+
*
|
|
458
|
+
* - `{ readOnly }` : session lue/reprise mais **jamais persistée** (0 write storage).
|
|
459
|
+
*
|
|
460
|
+
* ⚠️ En décorateur de **classe**, placer `@UseSession` SOUS `@controller`.
|
|
461
|
+
*
|
|
462
|
+
* @example
|
|
463
|
+
* \@controller("/account")
|
|
464
|
+
* \@UseSession()
|
|
465
|
+
* class AccountController extends Controller {
|
|
466
|
+
* \@Get("/me") @UseSession({ readOnly: true }) me() {} // lecture seule → 0 write
|
|
467
|
+
* }
|
|
468
|
+
*/
|
|
469
|
+
function UseSession(options = {}) {
|
|
470
|
+
return function(target, propertyKey, descriptor) {
|
|
471
|
+
if (propertyKey === void 0) {
|
|
472
|
+
Reflect.defineMetadata(USE_SESSION_CLASS_METADATA, options, target);
|
|
473
|
+
return target;
|
|
474
|
+
}
|
|
475
|
+
Reflect.defineMetadata(USE_SESSION_METHOD_METADATA, options, target.constructor, propertyKey);
|
|
476
|
+
return descriptor;
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Résout l'intent de session effectif d'une action — lu par le `Resolver` au
|
|
481
|
+
* match, posé sur `context.sessionIntent`, consommé au point d'activation unique
|
|
482
|
+
* (`HttpKernel.startSession`). Combine `@UseSession` classe + méthode (méthode
|
|
483
|
+
* prioritaire) ; à défaut, un paramètre `@Session` déclare un intent implicite.
|
|
484
|
+
*
|
|
485
|
+
* @returns l'intent, ou `null` si la route ne requiert aucune session.
|
|
486
|
+
*/
|
|
487
|
+
function resolveSessionIntent(ctor, actionName) {
|
|
488
|
+
const classMeta = Reflect.getMetadata(USE_SESSION_CLASS_METADATA, ctor);
|
|
489
|
+
const methodMeta = Reflect.getMetadata(USE_SESSION_METHOD_METADATA, ctor, actionName);
|
|
490
|
+
if (classMeta || methodMeta) return {
|
|
491
|
+
...classMeta,
|
|
492
|
+
...methodMeta
|
|
493
|
+
};
|
|
494
|
+
if (Reflect.getMetadata("route:paramArgs", ctor.prototype, actionName)?.some((p) => p.source === "session")) return {};
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Exige une autorisation pour l'action (décorateur de **méthode**) ou tout le
|
|
499
|
+
* contrôleur (décorateur de **classe**).
|
|
500
|
+
*
|
|
501
|
+
* - `@IsGranted("ROLE_ADMIN")` — un attribut (rôle `ROLE_*`, permission, ou
|
|
502
|
+
* attribut métier résolu par un voter).
|
|
503
|
+
* - `@IsGranted(["ROLE_ADMIN", "ROLE_AUDITOR"])` — **OR** : un seul suffit.
|
|
504
|
+
* - empiler plusieurs `@IsGranted` — **AND** : toutes les clauses doivent passer.
|
|
505
|
+
* - `@IsGranted("doc.edit", { subject: "id" })` — le paramètre de route `id` est
|
|
506
|
+
* passé comme `subject` au voter (ownership, multi-tenant).
|
|
507
|
+
*
|
|
508
|
+
* Classe + méthode fusionnent en AND. L'évaluation a lieu dans le `Resolver`
|
|
509
|
+
* AVANT l'instanciation du controller (403 court-circuite — Zero Trust). N'écrit
|
|
510
|
+
* QUE des métadonnées (zéro logique sécu ici → 0 import `@nodefony/security`,
|
|
511
|
+
* 0 cycle ; le moteur `authorization` est appelé par nom au runtime).
|
|
512
|
+
*/
|
|
513
|
+
function IsGranted(attribute, options) {
|
|
514
|
+
const clause = {
|
|
515
|
+
anyOf: Array.isArray(attribute) ? [...attribute] : [attribute],
|
|
516
|
+
...options?.subject !== void 0 ? { subjectParam: options.subject } : {}
|
|
517
|
+
};
|
|
518
|
+
return function(target, propertyKey, descriptor) {
|
|
519
|
+
if (propertyKey === void 0) {
|
|
520
|
+
const existing = Reflect.getMetadata(SECURITY_CLAUSES_METADATA, target) || [];
|
|
521
|
+
existing.push(clause);
|
|
522
|
+
Reflect.defineMetadata(SECURITY_CLAUSES_METADATA, existing, target);
|
|
523
|
+
return target;
|
|
524
|
+
}
|
|
525
|
+
const existing = Reflect.getMetadata(SECURITY_CLAUSES_METADATA, target, propertyKey) || [];
|
|
526
|
+
existing.push(clause);
|
|
527
|
+
Reflect.defineMetadata(SECURITY_CLAUSES_METADATA, existing, target, propertyKey);
|
|
528
|
+
return descriptor;
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Déclare une action (méthode) ou un contrôleur (classe) **publique** : skip
|
|
533
|
+
* l'autorisation (override un `@IsGranted` de classe sur cette méthode) ET skip
|
|
534
|
+
* l'authentification (réutilise le mécanisme `@BypassFirewall` → pas de 401 en
|
|
535
|
+
* zone protégée). L'alias lisible de « permitAll » (mental model Spring). Pour un
|
|
536
|
+
* login, une sonde de liveness, une page publique d'un contrôleur par ailleurs
|
|
537
|
+
* protégé.
|
|
538
|
+
*/
|
|
539
|
+
function Anonymous() {
|
|
540
|
+
return function(target, propertyKey, descriptor) {
|
|
541
|
+
if (propertyKey === void 0) {
|
|
542
|
+
Reflect.defineMetadata(SECURITY_ANONYMOUS_METADATA, true, target);
|
|
543
|
+
Reflect.defineMetadata(BYPASS_FIREWALL_CLASS_METADATA, true, target);
|
|
544
|
+
return target;
|
|
545
|
+
}
|
|
546
|
+
Reflect.defineMetadata(SECURITY_ANONYMOUS_METADATA, true, target.constructor, propertyKey);
|
|
547
|
+
Reflect.defineMetadata(BYPASS_FIREWALL_METHOD_METADATA, true, target.constructor, propertyKey);
|
|
548
|
+
return descriptor;
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Exige un **scope** (`api:action`) pour l'action (décorateur de **méthode**) ou
|
|
553
|
+
* tout le contrôleur (décorateur de **classe**). Axe d'autorisation **distinct des
|
|
554
|
+
* rôles** (`@IsGranted`) : un scope **downscope** un jeton MACHINE délégué (clé
|
|
555
|
+
* API, JWT d'agent, OAuth) — il est un **no-op** pour une session humaine, dont
|
|
556
|
+
* les droits sont portés par ses rôles (cf {@link ScopeVoter}).
|
|
557
|
+
*
|
|
558
|
+
* - `@RequireScope("orders:read")` — le jeton doit porter ce scope.
|
|
559
|
+
* - `@RequireScope(["orders:read", "orders:admin"])` — **OR** : un seul suffit.
|
|
560
|
+
* - empiler plusieurs `@RequireScope` — **AND** : tous les scopes requis.
|
|
561
|
+
*
|
|
562
|
+
* Convention d'espace **plat** `api:action` (modèle GitHub PAT classic) : le
|
|
563
|
+
* préfixe avant `:` EST l'API → la découverte au boot regroupe les scopes par API
|
|
564
|
+
* sans catalogue séparé. Classe + méthode fusionnent en AND, et fusionnent AUSSI
|
|
565
|
+
* avec les clauses `@IsGranted` dans le même `SecurityRequirement` (rôle ET scope).
|
|
566
|
+
*
|
|
567
|
+
* N'écrit QUE des métadonnées (zéro logique sécu ici → 0 import `@nodefony/security`,
|
|
568
|
+
* 0 cycle) : la décision est rendue par le `ScopeVoter` au runtime (par nom). La
|
|
569
|
+
* metadata est **dédiée** (≠ `@IsGranted`) pour que la découverte au boot puisse
|
|
570
|
+
* lister les scopes déclarés par route sans les confondre avec les rôles.
|
|
571
|
+
*/
|
|
572
|
+
function RequireScope(scope) {
|
|
573
|
+
const clause = { anyOf: Array.isArray(scope) ? [...scope] : [scope] };
|
|
574
|
+
return function(target, propertyKey, descriptor) {
|
|
575
|
+
if (propertyKey === void 0) {
|
|
576
|
+
const existing = Reflect.getMetadata(SECURITY_SCOPES_METADATA, target) || [];
|
|
577
|
+
existing.push(clause);
|
|
578
|
+
Reflect.defineMetadata(SECURITY_SCOPES_METADATA, existing, target);
|
|
579
|
+
return target;
|
|
580
|
+
}
|
|
581
|
+
const existing = Reflect.getMetadata(SECURITY_SCOPES_METADATA, target, propertyKey) || [];
|
|
582
|
+
existing.push(clause);
|
|
583
|
+
Reflect.defineMetadata(SECURITY_SCOPES_METADATA, existing, target, propertyKey);
|
|
584
|
+
return descriptor;
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Fusion ADDITIVE de deux jeux de directives CSP : les sources d'une même
|
|
589
|
+
* directive sont concaténées (dédupliquées, ordre `a` puis `b`). Pure, sans
|
|
590
|
+
* mutation des entrées. Sert au stacking de `@Csp` et à la fusion classe+méthode.
|
|
591
|
+
*/
|
|
592
|
+
function mergeCspDirectives(a, b) {
|
|
593
|
+
const out = {};
|
|
594
|
+
for (const src of [a, b]) {
|
|
595
|
+
if (!src) continue;
|
|
596
|
+
for (const name in src) {
|
|
597
|
+
const list = out[name] ??= [];
|
|
598
|
+
for (const v of src[name]) if (!list.includes(v)) list.push(v);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return out;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* `@Csp({ "frame-src": [...] })` — déclare des directives CSP **additionnelles**
|
|
605
|
+
* pour l'action (méthode) ou tout le contrôleur (classe). Distinct de
|
|
606
|
+
* `registerCspOrigins` (besoins PERMANENTS d'un module, ex. Vite) : ici c'est le
|
|
607
|
+
* besoin ponctuel d'UNE réponse (embarquer une iframe YouTube, autoriser une CDN).
|
|
608
|
+
*
|
|
609
|
+
* Classe + méthode fusionnent additivement (sources concaténées par directive).
|
|
610
|
+
* Empiler plusieurs `@Csp` fusionne aussi. N'écrit QUE des métadonnées : le merge
|
|
611
|
+
* dans le CSP de la réponse est fait par le firewall, hors hot-path, UNIQUEMENT
|
|
612
|
+
* sur les routes décorées. Calque `@IsGranted` (0 import `@nodefony/security`).
|
|
613
|
+
*/
|
|
614
|
+
function Csp(directives) {
|
|
615
|
+
return function(target, propertyKey, descriptor) {
|
|
616
|
+
if (propertyKey === void 0) {
|
|
617
|
+
const existing = Reflect.getMetadata(CSP_DIRECTIVES_METADATA, target);
|
|
618
|
+
Reflect.defineMetadata(CSP_DIRECTIVES_METADATA, mergeCspDirectives(existing, directives), target);
|
|
619
|
+
return target;
|
|
620
|
+
}
|
|
621
|
+
const existing = Reflect.getMetadata(CSP_DIRECTIVES_METADATA, target, propertyKey);
|
|
622
|
+
Reflect.defineMetadata(CSP_DIRECTIVES_METADATA, mergeCspDirectives(existing, directives), target, propertyKey);
|
|
623
|
+
return descriptor;
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Fabrique d'un marqueur booléen dual classe+méthode (idiome `any` du module).
|
|
628
|
+
* Pose `true` sur le ctor (classe) ou le prototype keyé par nom (méthode).
|
|
629
|
+
*/
|
|
630
|
+
function booleanMarkerDecorator(markerKey) {
|
|
631
|
+
return function() {
|
|
632
|
+
return function(target, propertyKey, descriptor) {
|
|
633
|
+
if (propertyKey === void 0) {
|
|
634
|
+
Reflect.defineMetadata(markerKey, true, target);
|
|
635
|
+
return target;
|
|
636
|
+
}
|
|
637
|
+
Reflect.defineMetadata(markerKey, true, target, propertyKey);
|
|
638
|
+
return descriptor;
|
|
639
|
+
};
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* `@CsrfProtect()` — opt-IN à la défense CSRF **synchronizer token** (double-submit
|
|
644
|
+
* signé HMAC) EN PLUS de la défense globale Fetch Metadata/Origin (étape 1, toujours
|
|
645
|
+
* active). Pour les mutations à haute valeur (changement de mot de passe, virement) :
|
|
646
|
+
* une requête sûre vers la route SÈME le cookie lisible `csrf-token` ; la mutation
|
|
647
|
+
* DOIT rejouer ce token dans l'en-tête `x-csrf-token` (sinon 403). Classe = toutes
|
|
648
|
+
* les actions. N'écrit qu'un marqueur (0 import `@nodefony/security`, 0 cycle).
|
|
649
|
+
*/
|
|
650
|
+
const CsrfProtect = booleanMarkerDecorator(CSRF_PROTECT_METADATA);
|
|
651
|
+
/**
|
|
652
|
+
* `@CsrfExempt()` — opt-OUT de la défense CSRF pour une route, **en conservant
|
|
653
|
+
* l'authentification et l'autorisation** (≠ `@Anonymous`/`@BypassFirewall` qui
|
|
654
|
+
* coupent l'auth). Pour un webhook ou une API recevant un POST cross-origin
|
|
655
|
+
* légitime, dont la requête est authentifiée autrement (signature HMAC du provider,
|
|
656
|
+
* clé API). Classe = toutes les actions. Marqueur seul (0 logique sécu ici).
|
|
657
|
+
*/
|
|
658
|
+
const CsrfExempt = booleanMarkerDecorator(CSRF_EXEMPT_METADATA);
|
|
659
|
+
/**
|
|
660
|
+
* `@Idempotent()` — protège une **mutation** (POST/PUT/PATCH/DELETE) d'un
|
|
661
|
+
* controller userland contre le double-effet d'un rejeu (double-clic, reconnexion
|
|
662
|
+
* socket, retry réseau), via une `Idempotency-Key` cliente (modèle Stripe, conforme
|
|
663
|
+
* `draft-ietf-httpapi-idempotency-key-header`). No-op sur les méthodes sûres (GET…).
|
|
664
|
+
*
|
|
665
|
+
* - **STRICT par défaut** : une mutation SANS clé est rejetée **400** (draft §2.7).
|
|
666
|
+
* - `@Idempotent({ required: false })` : mode **souple** — honore la clé si fournie,
|
|
667
|
+
* exécute sinon. (Une mutation par **socket** reste toujours strict : le WS rejoue.)
|
|
668
|
+
* - clé fournie → dédup complète : rejeu complété → réponse **mémorisée** ; rejeu
|
|
669
|
+
* concurrent → **409** ; même clé + payload différent → **422**.
|
|
670
|
+
*
|
|
671
|
+
* Décorateur de **méthode** (une action) ou de **classe** (toutes les mutations du
|
|
672
|
+
* controller). Précédence : méthode > classe (comme `@UseSession`). N'écrit QUE des
|
|
673
|
+
* métadonnées (0 import `@nodefony/security`, 0 cycle) ; la porte est appliquée par
|
|
674
|
+
* le `Resolver` (helper partagé `idempotency.ts`, le MÊME que le data plane admin),
|
|
675
|
+
* sur le `idempotencyStore` DI. Coût nul sur une route non décorée (`security: null`).
|
|
676
|
+
*
|
|
677
|
+
* @example
|
|
678
|
+
* \@controller("/api/orders")
|
|
679
|
+
* class OrderController extends Controller {
|
|
680
|
+
* \@Post("/") @Idempotent() create(@Body() dto: CreateOrder) { ... } // clé obligatoire
|
|
681
|
+
* \@Patch("/{id}") @Idempotent({ required: false }) update() { ... } // clé optionnelle (HTTP)
|
|
682
|
+
* }
|
|
683
|
+
*/
|
|
684
|
+
function Idempotent(options) {
|
|
685
|
+
const meta = { required: options?.required ?? true };
|
|
686
|
+
return function(target, propertyKey, descriptor) {
|
|
687
|
+
if (propertyKey === void 0) {
|
|
688
|
+
Reflect.defineMetadata(IDEMPOTENT_METADATA, meta, target);
|
|
689
|
+
return target;
|
|
690
|
+
}
|
|
691
|
+
Reflect.defineMetadata(IDEMPOTENT_METADATA, meta, target, propertyKey);
|
|
692
|
+
return descriptor;
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
function paramDecoratorFactory(source) {
|
|
696
|
+
return function(key) {
|
|
697
|
+
return function(target, propertyKey, parameterIndex) {
|
|
698
|
+
const existing = Reflect.getMetadata("route:paramArgs", target, propertyKey) || [];
|
|
699
|
+
existing.push({
|
|
700
|
+
source,
|
|
701
|
+
key,
|
|
702
|
+
index: parameterIndex
|
|
703
|
+
});
|
|
704
|
+
Reflect.defineMetadata(PARAM_ARGS_METADATA, existing, target, propertyKey);
|
|
705
|
+
};
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
const Param = paramDecoratorFactory("param");
|
|
709
|
+
const Query = paramDecoratorFactory("query");
|
|
710
|
+
/**
|
|
711
|
+
* Décorateur de paramètre `@Body` :
|
|
712
|
+
* - `@Body()` → body parsé entier · `@Body("field")` → un champ du body parsé.
|
|
713
|
+
* - `@Body({ stream: true })` → **flux brut** de la requête (`Readable`), sans
|
|
714
|
+
* parse en mémoire (P2.9 — gros uploads sans pic RAM ; le pipeline saute le
|
|
715
|
+
* parse busboy/JSON pour cette route).
|
|
716
|
+
*
|
|
717
|
+
* ## Le corps n'est PAS validé — le type écrit ici ne promet rien
|
|
718
|
+
*
|
|
719
|
+
* `@Body()` injecte le corps **tel qu'il a été parsé** : `@Body() dto: CreateOrder`
|
|
720
|
+
* compile, mais rien ne garantit qu'un `CreateOrder` soit arrivé. C'est un choix
|
|
721
|
+
* assumé, pas un oubli — valider ici ne garderait que la porte HTTP, et devrait
|
|
722
|
+
* rester **synchrone** ({@link resolveParamArg} l'est, et le rendre asynchrone
|
|
723
|
+
* taxerait toute requête à paramètres décorés).
|
|
724
|
+
*
|
|
725
|
+
* Où valider, donc :
|
|
726
|
+
*
|
|
727
|
+
* - **Une entité** → les hooks `beforeCreate` / `beforeUpdate` d'
|
|
728
|
+
* `AbstractCrudService` : ils sont `await`és — donc une règle asynchrone
|
|
729
|
+
* (unicité en base) y est possible — et ils gardent REST, WebSocket **et** la
|
|
730
|
+
* CLI d'un seul geste. C'est ce que génère `nodefony create entity`.
|
|
731
|
+
* - **Un cas isolé** → `schema.parse(body)` en première ligne de l'action, sur le
|
|
732
|
+
* modèle d'`assertPageQuery`. Rien d'autre à écrire : une `ZodError` qui remonte
|
|
733
|
+
* devient un **422** (RFC 9110 §15.5.21) portant `error.fields` — quel champ,
|
|
734
|
+
* quel message, quelle règle.
|
|
735
|
+
*
|
|
736
|
+
* Et typer le paramètre avec `z.infer<typeof createXSchema>` plutôt qu'avec
|
|
737
|
+
* `Partial<XRow>` : le premier décrit le contrat d'**entrée**, le second promet la
|
|
738
|
+
* ligne de **table** (`id`, horodatages) que le schéma effacera de toute façon.
|
|
739
|
+
*
|
|
740
|
+
* @example
|
|
741
|
+
* ```ts
|
|
742
|
+
* \@Post("/")
|
|
743
|
+
* async create(\@Body() payload: CreatePost) {
|
|
744
|
+
* return this.createResource(payload); // le service valide dans beforeCreate
|
|
745
|
+
* }
|
|
746
|
+
* ```
|
|
747
|
+
*/
|
|
748
|
+
function Body(keyOrOptions) {
|
|
749
|
+
const isOptions = typeof keyOrOptions === "object" && keyOrOptions !== null;
|
|
750
|
+
const key = isOptions ? void 0 : keyOrOptions;
|
|
751
|
+
const stream = isOptions ? keyOrOptions.stream === true : false;
|
|
752
|
+
return function(target, propertyKey, parameterIndex) {
|
|
753
|
+
const existing = Reflect.getMetadata("route:paramArgs", target, propertyKey) || [];
|
|
754
|
+
const meta = {
|
|
755
|
+
source: "body",
|
|
756
|
+
key,
|
|
757
|
+
index: parameterIndex
|
|
758
|
+
};
|
|
759
|
+
if (stream) meta.stream = true;
|
|
760
|
+
existing.push(meta);
|
|
761
|
+
Reflect.defineMetadata(PARAM_ARGS_METADATA, existing, target, propertyKey);
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
const Headers = paramDecoratorFactory("headers");
|
|
765
|
+
const Cookie = paramDecoratorFactory("cookie");
|
|
766
|
+
const Session = paramDecoratorFactory("session");
|
|
767
|
+
/** `@CurrentUser() user: IUser` — injecte l'utilisateur de l'ALS (jamais le credential). */
|
|
768
|
+
const CurrentUser = paramDecoratorFactory("user");
|
|
769
|
+
const Req = paramDecoratorFactory("req");
|
|
770
|
+
const Res = paramDecoratorFactory("res");
|
|
771
|
+
const UploadedFile = paramDecoratorFactory("file");
|
|
772
|
+
const UploadedFiles = paramDecoratorFactory("files");
|
|
773
|
+
/**
|
|
774
|
+
* Résout la valeur d'un unique paramètre décoré depuis le contexte de requête.
|
|
775
|
+
*
|
|
776
|
+
* @param meta - métadonnée posée par le décorateur (source + clé optionnelle)
|
|
777
|
+
* @param ctx - contexte de requête (forme structurelle minimale)
|
|
778
|
+
* @returns la valeur à injecter dans l'argument `meta.index` de l'action
|
|
779
|
+
*/
|
|
780
|
+
function resolveParamArg(meta, ctx) {
|
|
781
|
+
switch (meta.source) {
|
|
782
|
+
case "param": return meta.key !== void 0 ? ctx.paramsMap[meta.key] : ctx.paramsMap;
|
|
783
|
+
case "query": {
|
|
784
|
+
const qg = ctx.queryOverride ?? ctx.request?.queryGet;
|
|
785
|
+
return meta.key !== void 0 ? qg?.[meta.key] : qg;
|
|
786
|
+
}
|
|
787
|
+
case "body": {
|
|
788
|
+
if (meta.stream) return ctx.request?.request;
|
|
789
|
+
const alsBody = RequestContext.get()?.body;
|
|
790
|
+
if (alsBody !== void 0) return meta.key !== void 0 ? alsBody?.[meta.key] : alsBody;
|
|
791
|
+
const qp = ctx.request?.queryPost;
|
|
792
|
+
return meta.key !== void 0 ? qp?.[meta.key] : qp;
|
|
793
|
+
}
|
|
794
|
+
case "headers": {
|
|
795
|
+
const h = ctx.request?.headers;
|
|
796
|
+
return meta.key !== void 0 ? h?.[meta.key.toLowerCase()] : h;
|
|
797
|
+
}
|
|
798
|
+
case "cookie": return ctx.getRequestCookies(meta.key);
|
|
799
|
+
case "session": return meta.key !== void 0 ? ctx.session?.get(meta.key) : ctx.session;
|
|
800
|
+
case "req": return ctx.request;
|
|
801
|
+
case "res": return ctx.response;
|
|
802
|
+
case "file": return ctx.request?.queryFile?.[0];
|
|
803
|
+
case "files": return ctx.request?.queryFile;
|
|
804
|
+
case "user": return RequestContext.getUser();
|
|
805
|
+
default: return;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Construit le tableau d'arguments d'une action à partir des métadonnées de
|
|
810
|
+
* paramètres décorés. Chaque valeur est placée à son `index` déclaré (les trous
|
|
811
|
+
* restent `undefined`). Fonction pure — aucun effet de bord, aucune I/O.
|
|
812
|
+
*
|
|
813
|
+
* @param metas - métadonnées de tous les paramètres décorés de l'action
|
|
814
|
+
* @param ctx - contexte de requête (forme structurelle minimale)
|
|
815
|
+
* @returns arguments positionnels à spread dans l'action
|
|
816
|
+
*/
|
|
817
|
+
function buildParamArgs(metas, ctx) {
|
|
818
|
+
const result = [];
|
|
819
|
+
for (const meta of metas) result[meta.index] = resolveParamArg(meta, ctx);
|
|
820
|
+
return result;
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* P2.9 — Indique si l'action d'une route attend le **flux brut** du body
|
|
824
|
+
* (un paramètre `@Body({ stream:true })`). Le résultat est **mémoïsé** sur
|
|
825
|
+
* `route.bodyStream` : lecture `Reflect` au 1er appel, O(1) ensuite → 0 coût
|
|
826
|
+
* hot-path. Lu **en amont** par `handleHttp` (avant le parse) pour décider de
|
|
827
|
+
* sauter le parse busboy/JSON. Typage structurel (pas d'import `Route` → 0 cycle).
|
|
828
|
+
*
|
|
829
|
+
* @param routeDef - route résolue (porte `controller` + `classMethod` à `onBoot`).
|
|
830
|
+
* @returns `true` si l'action déclare un `@Body({ stream:true })`.
|
|
831
|
+
*/
|
|
832
|
+
function routeExpectsBodyStream(routeDef) {
|
|
833
|
+
if (routeDef.bodyStream === void 0) {
|
|
834
|
+
let flag = false;
|
|
835
|
+
const ctor = routeDef.controller;
|
|
836
|
+
const method = routeDef.classMethod;
|
|
837
|
+
if (ctor && method) flag = (Reflect.getMetadata("route:paramArgs", ctor.prototype, method) || []).some((m) => m.source === "body" && m.stream === true);
|
|
838
|
+
routeDef.bodyStream = flag;
|
|
839
|
+
}
|
|
840
|
+
return routeDef.bodyStream;
|
|
841
|
+
}
|
|
842
|
+
const EMPTY_ACTION_META = {
|
|
843
|
+
paramsMeta: null,
|
|
844
|
+
redirectMeta: null,
|
|
845
|
+
httpCode: null,
|
|
846
|
+
headerEntries: null,
|
|
847
|
+
sessionIntent: null,
|
|
848
|
+
security: null,
|
|
849
|
+
cspDirectives: null,
|
|
850
|
+
csrfProtect: false,
|
|
851
|
+
csrfExempt: false,
|
|
852
|
+
idempotent: null
|
|
853
|
+
};
|
|
854
|
+
/**
|
|
855
|
+
* P5 — Calcule le {@link RouteActionMeta} d'un couple (controller, action) par
|
|
856
|
+
* lecture `Reflect`. Fonction PURE (pas de memo) : utilisée par
|
|
857
|
+
* {@link resolveActionMeta} (routes, mémoïsé) et par le `Resolver` pour le
|
|
858
|
+
* chemin froid du forward (`parsePathernController`, pas de route).
|
|
859
|
+
*/
|
|
860
|
+
/** Lit un tableau de clauses (`@IsGranted`/`@RequireScope`) posé sur une cible Reflect — `[]` si absent. */
|
|
861
|
+
function readClauses(target, propertyKey) {
|
|
862
|
+
return (propertyKey === void 0 ? Reflect.getMetadata(SECURITY_CLAUSES_METADATA, target) : Reflect.getMetadata(SECURITY_CLAUSES_METADATA, target, propertyKey)) ?? [];
|
|
863
|
+
}
|
|
864
|
+
/** Idem pour les clauses de scope (`@RequireScope`, metadata dédiée) — `[]` si absent. */
|
|
865
|
+
function readScopeClauses(target, propertyKey) {
|
|
866
|
+
return (propertyKey === void 0 ? Reflect.getMetadata(SECURITY_SCOPES_METADATA, target) : Reflect.getMetadata(SECURITY_SCOPES_METADATA, target, propertyKey)) ?? [];
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* P6.8 — Liste PLATE des scopes `api:action` déclarés par une action
|
|
870
|
+
* (`@RequireScope`, classe + méthode), **dédupliqués**. Source de la **découverte
|
|
871
|
+
* au boot** : le catalogue de scopes du formulaire de clés API se construit en
|
|
872
|
+
* scannant les routes (cf `collectDeclaredApiScopes`), au lieu d'une config plate
|
|
873
|
+
* qui dérive du code. Lecture `Reflect` directe — **cold path** (introspection à la
|
|
874
|
+
* demande, jamais sur le hot path requête). `[]` si l'action ne déclare aucun scope.
|
|
875
|
+
*/
|
|
876
|
+
function extractActionScopes(ctor, method) {
|
|
877
|
+
const out = /* @__PURE__ */ new Set();
|
|
878
|
+
for (const clause of readScopeClauses(ctor.prototype, method)) for (const s of clause.anyOf) out.add(s);
|
|
879
|
+
for (const clause of readScopeClauses(ctor)) for (const s of clause.anyOf) out.add(s);
|
|
880
|
+
return [...out];
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* P6 J7 / P6.8 — Fusionne les clauses `@IsGranted` (rôles) ET `@RequireScope`
|
|
884
|
+
* (scopes) — classe + méthode — en une exigence d'autorisation **figée**, ou
|
|
885
|
+
* `null` si l'action n'est pas gardée. Les deux axes cohabitent dans le même
|
|
886
|
+
* `SecurityRequirement` (clauses en **AND**) → un seul chemin d'enforcement dans
|
|
887
|
+
* le `Resolver`, le bon voter (`RoleVoter`/`ScopeVoter`) répond par attribut.
|
|
888
|
+
* `@Anonymous` (méthode) rend l'action publique (override `@IsGranted`/
|
|
889
|
+
* `@RequireScope` de classe → `null`). Lecture `Reflect` faite UNE fois (via
|
|
890
|
+
* {@link resolveActionMeta} mémoïsé).
|
|
891
|
+
*/
|
|
892
|
+
function computeSecurityRequirement(ctor, method) {
|
|
893
|
+
if (Reflect.getMetadata(SECURITY_ANONYMOUS_METADATA, ctor, method) === true) return null;
|
|
894
|
+
const proto = ctor.prototype;
|
|
895
|
+
const methodClauses = readClauses(proto, method);
|
|
896
|
+
const methodScopes = readScopeClauses(proto, method);
|
|
897
|
+
const classAnon = Reflect.getMetadata(SECURITY_ANONYMOUS_METADATA, ctor) === true;
|
|
898
|
+
const classClauses = classAnon ? [] : readClauses(ctor);
|
|
899
|
+
const classScopes = classAnon ? [] : readScopeClauses(ctor);
|
|
900
|
+
const all = [
|
|
901
|
+
...classClauses,
|
|
902
|
+
...methodClauses,
|
|
903
|
+
...classScopes,
|
|
904
|
+
...methodScopes
|
|
905
|
+
];
|
|
906
|
+
if (all.length === 0) return null;
|
|
907
|
+
return Object.freeze({ clauses: Object.freeze(all) });
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* P6 — Fusionne les directives `@Csp` (classe + méthode) en un jeu figé, ou
|
|
911
|
+
* `null` si l'action n'en déclare aucune (cas courant → 0 alloc/composition).
|
|
912
|
+
* Lecture `Reflect` faite UNE fois (via {@link resolveActionMeta} mémoïsé).
|
|
913
|
+
*/
|
|
914
|
+
function computeCspDirectives(ctor, method) {
|
|
915
|
+
const methodDirectives = Reflect.getMetadata(CSP_DIRECTIVES_METADATA, ctor.prototype, method);
|
|
916
|
+
const classDirectives = Reflect.getMetadata(CSP_DIRECTIVES_METADATA, ctor);
|
|
917
|
+
if (!classDirectives && !methodDirectives) return null;
|
|
918
|
+
return Object.freeze(mergeCspDirectives(classDirectives, methodDirectives));
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* P6.8 — Résout la config `@Idempotent` figée d'une action (méthode prime sur
|
|
922
|
+
* classe, comme `@UseSession`), ou `null` si non décorée. `@Idempotent()` pose
|
|
923
|
+
* `{ required: true }` explicite → la précédence méthode > classe est non ambiguë
|
|
924
|
+
* (une méthode `@Idempotent()` force le mode strict même sous une classe souple).
|
|
925
|
+
* Lecture `Reflect` faite UNE fois (via {@link resolveActionMeta} mémoïsé).
|
|
926
|
+
*/
|
|
927
|
+
function computeIdempotent(ctor, method) {
|
|
928
|
+
const methodMeta = Reflect.getMetadata(IDEMPOTENT_METADATA, ctor.prototype, method);
|
|
929
|
+
const classMeta = Reflect.getMetadata(IDEMPOTENT_METADATA, ctor);
|
|
930
|
+
if (methodMeta === void 0 && classMeta === void 0) return null;
|
|
931
|
+
return Object.freeze({ required: methodMeta?.required ?? classMeta?.required ?? true });
|
|
932
|
+
}
|
|
933
|
+
function computeActionMeta(ctor, method) {
|
|
934
|
+
if (!ctor || !method) return EMPTY_ACTION_META;
|
|
935
|
+
const proto = ctor.prototype;
|
|
936
|
+
const params = Reflect.getMetadata(PARAM_ARGS_METADATA, proto, method);
|
|
937
|
+
const redirect = Reflect.getMetadata(REDIRECT_METADATA, proto, method);
|
|
938
|
+
const httpCode = Reflect.getMetadata(HTTP_CODE_METADATA, proto, method);
|
|
939
|
+
const headers = Reflect.getMetadata(HEADERS_METADATA, proto, method);
|
|
940
|
+
return {
|
|
941
|
+
paramsMeta: params && params.length > 0 ? params : null,
|
|
942
|
+
redirectMeta: redirect ?? null,
|
|
943
|
+
httpCode: httpCode ?? null,
|
|
944
|
+
headerEntries: headers ? Object.entries(headers) : null,
|
|
945
|
+
sessionIntent: resolveSessionIntent(ctor, method),
|
|
946
|
+
security: computeSecurityRequirement(ctor, method),
|
|
947
|
+
cspDirectives: computeCspDirectives(ctor, method),
|
|
948
|
+
csrfProtect: Reflect.getMetadata(CSRF_PROTECT_METADATA, proto, method) === true || Reflect.getMetadata(CSRF_PROTECT_METADATA, ctor) === true,
|
|
949
|
+
csrfExempt: Reflect.getMetadata(CSRF_EXEMPT_METADATA, proto, method) === true || Reflect.getMetadata(CSRF_EXEMPT_METADATA, ctor) === true,
|
|
950
|
+
idempotent: computeIdempotent(ctor, method)
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* P5 — Metadata d'action d'une route, **mémoïsées au 1er hit** sur
|
|
955
|
+
* `route.actionMeta` (pattern frère de {@link routeExpectsBodyStream}) :
|
|
956
|
+
* `undefined` = pas encore résolu → 1 lecture `Reflect` par route pour la vie
|
|
957
|
+
* du process, O(1) ensuite. Sort `Reflect.getMetadata` (~6 appels/req) du hot
|
|
958
|
+
* path `match`/`executeAction`. Posé APRÈS `generateId()` (1ʳᵉ requête) → le
|
|
959
|
+
* hash de route et l'introspection Studio restent stables. Typage structurel
|
|
960
|
+
* (pas d'import `Route` → 0 cycle).
|
|
961
|
+
*/
|
|
962
|
+
function resolveActionMeta(routeDef) {
|
|
963
|
+
if (routeDef.actionMeta === void 0) routeDef.actionMeta = computeActionMeta(routeDef.controller, routeDef.classMethod);
|
|
964
|
+
return routeDef.actionMeta;
|
|
965
|
+
}
|
|
966
|
+
//#endregion
|
|
967
|
+
export { All, Anonymous, BYPASS_FIREWALL_CLASS_METADATA, BYPASS_FIREWALL_METHOD_METADATA, Body, BypassFirewall, Cookie, Csp, CsrfExempt, CsrfProtect, CurrentUser, DOMAIN_CLASS_METADATA, DOMAIN_METHOD_METADATA, Delete, Domain, Get, HEADERS_METADATA, HTTP_CODE_METADATA, Head, Header, Headers, HttpCode, Idempotent, IsGranted, Options, PARAM_ARGS_METADATA, Param, Patch, Post, Put, Query, REDIRECT_METADATA, Redirect, Req, RequireScope, Res, Scope, Session, USE_SESSION_CLASS_METADATA, USE_SESSION_METHOD_METADATA, UploadedFile, UploadedFiles, UseSession, buildParamArgs, computeActionMeta, controller, controllers, extractActionScopes, resolveActionMeta, resolveParamArg, resolveSessionIntent, route, routeExpectsBodyStream };
|