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