@nodefony/framework 10.0.0-beta.2 → 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/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.152.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 +3 -3
- 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 +11 -4
- package/dist/nodefony/controller/TotpController.js +5 -5
- 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 +51 -22
- 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 +39 -2
- package/dist/nodefony/src/Route.js +52 -5
- package/dist/nodefony/src/SecurityApiController.js +24 -0
- package/dist/nodefony/src/docsReader.js +38 -21
- 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 +2 -2
- 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 +6 -4
- package/dist/types/nodefony/controller/TotpController.d.ts +2 -2
- 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 +19 -0
- package/dist/types/nodefony/src/Route.d.ts +18 -1
- package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -0
- package/dist/types/nodefony/src/docsReader.d.ts +10 -6
- 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 +76 -74
- package/docs/decorateurs.md +69 -67
- package/docs/idempotence.md +30 -28
- package/docs/routing.md +52 -52
- package/docs/templates.md +11 -11
- package/package.json +11 -11
|
@@ -2,7 +2,7 @@ import { CORE_PACKAGE, checkOutdated, countModuleDocs, listModuleDocs, listModul
|
|
|
2
2
|
import { getResolvedPath, navigateSchemaNode, nodeFlags, notEditableReason, recipeFor, validateLeafValue } from "./configMutation.js";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { GitService, Syslog, aggregateProvenance, collectDevStatus, computeConfigProvenance, defaultAppConfig, extractJsonSchemaDefaults, extractMarkdownSection, flattenConfigSchema, getActiveLogDriver, listLogDrivers, outlineMarkdown, parseNfEnvOverrides, parseStoreManifest, readResolvedPath, withResolvedPath } from "nodefony";
|
|
5
|
+
import { GitService, Syslog, aggregateProvenance, collectDevStatus, collectEnvShortcuts, computeConfigProvenance, defaultAppConfig, extractJsonSchemaDefaults, extractMarkdownSection, flattenConfigSchema, getActiveLogDriver, isFrameworkRepository, listLogDrivers, outlineMarkdown, parseNfEnvOverrides, parseStoreManifest, readResolvedPath, resumeSupervisor, runAppContributions, suspendSupervisor, withResolvedPath } from "nodefony";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import { existsSync, readFileSync } from "node:fs";
|
|
8
8
|
//#region nodefony/src/KernelAdminApi.ts
|
|
@@ -204,13 +204,23 @@ function computeConfigEntry(key, mod, runtimePaths) {
|
|
|
204
204
|
const isApp = mod.isApp ?? false;
|
|
205
205
|
const seg = computeSeg(pkg, isApp);
|
|
206
206
|
const envKeys = {};
|
|
207
|
+
const shortcutPaths = [];
|
|
208
|
+
if (!isApp) for (const s of collectEnvShortcuts(schema)) {
|
|
209
|
+
const raw = process.env[s.env];
|
|
210
|
+
const file = process.env[`${s.env}_FILE`];
|
|
211
|
+
const fromFile = file !== void 0 && file !== "";
|
|
212
|
+
if ((raw === void 0 || raw === "") && !fromFile) continue;
|
|
213
|
+
const dotted = s.path.join(".").toLowerCase();
|
|
214
|
+
envKeys[dotted] = fromFile ? `${s.env}_FILE` : s.env;
|
|
215
|
+
shortcutPaths.push(dotted);
|
|
216
|
+
}
|
|
207
217
|
for (const o of parseNfEnvOverrides(process.env)) if (o.moduleSeg === seg) envKeys[o.path.join(".")] = o.envKey;
|
|
208
218
|
let provenance = null;
|
|
209
219
|
if (isApp) {
|
|
210
220
|
const envPaths = new Set(parseNfEnvOverrides(process.env).filter((o) => o.moduleSeg === "app").map((o) => o.path.join(".")));
|
|
211
221
|
provenance = computeConfigProvenance(opts, defaultAppConfig, envPaths);
|
|
212
222
|
} else if (schema) {
|
|
213
|
-
const envPaths = new Set(parseNfEnvOverrides(process.env).filter((o) => o.moduleSeg === seg).map((o) => o.path.join(".")));
|
|
223
|
+
const envPaths = /* @__PURE__ */ new Set([...shortcutPaths, ...parseNfEnvOverrides(process.env).filter((o) => o.moduleSeg === seg).map((o) => o.path.join("."))]);
|
|
214
224
|
provenance = computeConfigProvenance(opts, extractJsonSchemaDefaults(schema), envPaths);
|
|
215
225
|
}
|
|
216
226
|
if (provenance && runtimePaths?.size) {
|
|
@@ -235,7 +245,7 @@ function actorLabel(user) {
|
|
|
235
245
|
if (typeof u.getUserIdentifier === "function") try {
|
|
236
246
|
return u.getUserIdentifier();
|
|
237
247
|
} catch {}
|
|
238
|
-
return u.
|
|
248
|
+
return u.identifier ?? u.email ?? (u.id != null ? String(u.id) : null);
|
|
239
249
|
}
|
|
240
250
|
/**
|
|
241
251
|
* Journalise une mutation de config (catégorie `config`) **si** le service d'audit
|
|
@@ -337,6 +347,33 @@ function nodefonyPackageCandidates(name) {
|
|
|
337
347
|
const inScope = (pkg) => pkg === "nodefony" || pkg === "@nodefony/core" || pkg.startsWith("@nodefony/");
|
|
338
348
|
return (name.includes("/") ? [name] : [`@nodefony/${name}`, name]).filter(inScope);
|
|
339
349
|
}
|
|
350
|
+
/** Refus de `scaffold` hors développement. */
|
|
351
|
+
const scaffoldRefused = () => ({
|
|
352
|
+
status: 403,
|
|
353
|
+
body: { error: "scaffold:sync is disabled outside development" }
|
|
354
|
+
});
|
|
355
|
+
/**
|
|
356
|
+
* La réponse de `scaffold` : les rapports tels quels, et les totaux qu'un écran
|
|
357
|
+
* affiche sans les recompter.
|
|
358
|
+
*
|
|
359
|
+
* @param reports - le rendu de `runAppContributions`
|
|
360
|
+
* @param dryRun - `true` pour un plan
|
|
361
|
+
* @returns le corps de la réponse
|
|
362
|
+
*/
|
|
363
|
+
function scaffoldPayload(reports, dryRun) {
|
|
364
|
+
const sum = (pick) => reports.reduce((n, r) => n + pick(r), 0);
|
|
365
|
+
return {
|
|
366
|
+
dryRun,
|
|
367
|
+
reports,
|
|
368
|
+
totals: {
|
|
369
|
+
written: sum((r) => r.written.length),
|
|
370
|
+
updated: sum((r) => r.updated.length),
|
|
371
|
+
outdated: sum((r) => r.outdated.length),
|
|
372
|
+
modified: sum((r) => r.modified.length),
|
|
373
|
+
failed: sum((r) => r.error === null ? 0 : 1)
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
}
|
|
340
377
|
function createKernelAdminApi(kernel) {
|
|
341
378
|
const descriptor = {
|
|
342
379
|
label: "Kernel",
|
|
@@ -376,6 +413,8 @@ function createKernelAdminApi(kernel) {
|
|
|
376
413
|
* @param key - clé courte (`http`) ou nom de paquet (`@nodefony/redis`).
|
|
377
414
|
* @returns le dossier à lire, ou `null`.
|
|
378
415
|
*/
|
|
416
|
+
let frameworkRepository = null;
|
|
417
|
+
const inFrameworkRepository = () => frameworkRepository ??= isFrameworkRepository(kernel.path);
|
|
379
418
|
const resolveDocDir = (key) => resolveTarget(key)?.path ?? resolvePackageDir(key);
|
|
380
419
|
/**
|
|
381
420
|
* Ce qu'on peut proposer à qui s'est trompé de nom — les clés qui répondent.
|
|
@@ -538,6 +577,30 @@ function createKernelAdminApi(kernel) {
|
|
|
538
577
|
git: GitService.read()
|
|
539
578
|
})
|
|
540
579
|
},
|
|
580
|
+
{
|
|
581
|
+
path: "scaffold",
|
|
582
|
+
summary: "Files delivered by installed packages (dev only) — what `scaffold:sync --update` would do",
|
|
583
|
+
handler: async () => {
|
|
584
|
+
if (!devGuard()) return scaffoldRefused();
|
|
585
|
+
return scaffoldPayload(await runAppContributions(REPO_ROOT, true, true), true);
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
path: "scaffold/sync",
|
|
590
|
+
method: "POST",
|
|
591
|
+
summary: "Run `scaffold:sync` (dev only) — body `{ update?: boolean }`",
|
|
592
|
+
handler: async (request) => {
|
|
593
|
+
if (!devGuard()) return scaffoldRefused();
|
|
594
|
+
const body = request.body;
|
|
595
|
+
const update = typeof body === "object" && body !== null && "update" in body && body.update === true;
|
|
596
|
+
suspendSupervisor(REPO_ROOT, "synchronisation des fichiers livrés", update ? "scaffold:sync --update" : "scaffold:sync");
|
|
597
|
+
try {
|
|
598
|
+
return scaffoldPayload(await runAppContributions(REPO_ROOT, false, update), false);
|
|
599
|
+
} finally {
|
|
600
|
+
resumeSupervisor(REPO_ROOT);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
},
|
|
541
604
|
{
|
|
542
605
|
path: "processes",
|
|
543
606
|
summary: "Dev process topology (supervisor → server → Vite) + server ports — `nodefony status` over the data plane",
|
|
@@ -927,7 +990,7 @@ function createKernelAdminApi(kernel) {
|
|
|
927
990
|
services: [],
|
|
928
991
|
config: {},
|
|
929
992
|
configSchema: null,
|
|
930
|
-
docsCount: await countModuleDocs(core.path),
|
|
993
|
+
docsCount: await countModuleDocs(core.path, inFrameworkRepository()),
|
|
931
994
|
symbolsCount: (await listModuleSymbols(CORE_PACKAGE)).length,
|
|
932
995
|
coverageLines: (await readCoverage(core.path)).total?.lines ?? null
|
|
933
996
|
};
|
|
@@ -959,7 +1022,7 @@ function createKernelAdminApi(kernel) {
|
|
|
959
1022
|
provenance: cfg.provenance,
|
|
960
1023
|
seg: cfg.seg,
|
|
961
1024
|
envKeys: cfg.envKeys,
|
|
962
|
-
docsCount: await countModuleDocs(mod.path),
|
|
1025
|
+
docsCount: await countModuleDocs(mod.path, inFrameworkRepository()),
|
|
963
1026
|
symbolsCount: (await listModuleSymbols(cfg.name)).length,
|
|
964
1027
|
coverageLines: (await readCoverage(mod.path)).total?.lines ?? null
|
|
965
1028
|
};
|
|
@@ -1020,7 +1083,7 @@ function createKernelAdminApi(kernel) {
|
|
|
1020
1083
|
};
|
|
1021
1084
|
return {
|
|
1022
1085
|
key,
|
|
1023
|
-
docs: await listModuleDocs(dir)
|
|
1086
|
+
docs: await listModuleDocs(dir, false, inFrameworkRepository())
|
|
1024
1087
|
};
|
|
1025
1088
|
}
|
|
1026
1089
|
},
|
|
@@ -1038,14 +1101,14 @@ function createKernelAdminApi(kernel) {
|
|
|
1038
1101
|
available: docKeys()
|
|
1039
1102
|
}
|
|
1040
1103
|
};
|
|
1041
|
-
const doc = await readModuleDoc(dir, request.params.slug ?? "");
|
|
1104
|
+
const doc = await readModuleDoc(dir, request.params.slug ?? "", inFrameworkRepository());
|
|
1042
1105
|
if (!doc) return {
|
|
1043
1106
|
status: 404,
|
|
1044
1107
|
body: {
|
|
1045
1108
|
error: "Doc not found",
|
|
1046
1109
|
key,
|
|
1047
1110
|
slug: request.params.slug,
|
|
1048
|
-
available: (await listModuleDocs(dir)).map((d) => d.slug)
|
|
1111
|
+
available: (await listModuleDocs(dir, false, inFrameworkRepository())).map((d) => d.slug)
|
|
1049
1112
|
}
|
|
1050
1113
|
};
|
|
1051
1114
|
const wanted = typeof request.query.section === "string" ? request.query.section : "";
|
|
@@ -1085,7 +1148,7 @@ function createKernelAdminApi(kernel) {
|
|
|
1085
1148
|
handler: async () => {
|
|
1086
1149
|
const modules = [];
|
|
1087
1150
|
for (const target of docTargets()) {
|
|
1088
|
-
const docs = await listModuleDocs(target.path);
|
|
1151
|
+
const docs = await listModuleDocs(target.path, false, inFrameworkRepository());
|
|
1089
1152
|
if (docs.length > 0) modules.push({
|
|
1090
1153
|
key: target.key,
|
|
1091
1154
|
docs
|
|
@@ -1111,7 +1174,10 @@ function createKernelAdminApi(kernel) {
|
|
|
1111
1174
|
}
|
|
1112
1175
|
};
|
|
1113
1176
|
const limit = Number.parseInt(String(request.query.limit ?? ""), 10);
|
|
1114
|
-
return searchModuleDocs(docTargets(), q, {
|
|
1177
|
+
return searchModuleDocs(docTargets(), q, {
|
|
1178
|
+
limit: Number.isFinite(limit) && limit > 0 ? limit : void 0,
|
|
1179
|
+
frameworkRepository: inFrameworkRepository()
|
|
1180
|
+
});
|
|
1115
1181
|
}
|
|
1116
1182
|
},
|
|
1117
1183
|
{
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { guardSingletonState, isDevelopment } from "./singletonGuard.js";
|
|
2
2
|
import { buildParamArgs, computeActionMeta, resolveActionMeta, resolveSessionIntent } from "../decorators/routerDecorators.js";
|
|
3
3
|
import { computeFingerprint, evaluateIdempotency, isMutationMethod, resolveIdempotencyKey, resolveIdentity } from "./idempotency.js";
|
|
4
|
+
import { authenticationShortfall, stepUpChallenge } from "./requireAuthentication.js";
|
|
4
5
|
import { RequestContext, finallyMaybe, identityHint, isArray, isPlainObject, isPromise, nodefonyError, thenMaybe, typeOf } from "nodefony";
|
|
5
6
|
import { Http2Response, HttpResponse, WebsocketResponse } from "@nodefony/http";
|
|
6
7
|
//#region nodefony/src/Resolver.ts
|
|
@@ -180,10 +181,20 @@ var Resolver = class {
|
|
|
180
181
|
* `instanceof` au message suivant — le hub était RÉINSTANCIÉ (DI +
|
|
181
182
|
* `initialize()`) à chaque frame qui suivait un `api.request`.
|
|
182
183
|
*
|
|
184
|
+
* Ni pour un `forward` sur une connexion WebSocket : son résolveur est neuf,
|
|
185
|
+
* pas une invocation par message, et réécrivait le pointeur que la poignée
|
|
186
|
+
* de main a posé — même réinstanciation à la frame suivante (#574). Une
|
|
187
|
+
* connexion garde le contrôleur qui l'a ouverte. Chemin froid : seulement
|
|
188
|
+
* quand un contrôleur est créé.
|
|
189
|
+
*
|
|
183
190
|
* @returns le controller reçu, pour enchaîner.
|
|
184
191
|
*/
|
|
185
192
|
_pinController(controller) {
|
|
186
|
-
if (
|
|
193
|
+
if (this.messageInvocation) return controller;
|
|
194
|
+
const container = this.context.container;
|
|
195
|
+
if (!container) return controller;
|
|
196
|
+
if (this.context.method === "WEBSOCKET" && container.get("controller") !== null) return controller;
|
|
197
|
+
container.set("controller", controller);
|
|
187
198
|
return controller;
|
|
188
199
|
}
|
|
189
200
|
/**
|
|
@@ -263,6 +274,7 @@ var Resolver = class {
|
|
|
263
274
|
* lookup, ni attente) ; sinon la promesse du verdict, rompue par un refus.
|
|
264
275
|
*/
|
|
265
276
|
_enforceActionSecurity(meta) {
|
|
277
|
+
if (meta.authentication !== null) return this._enforceStepUp(meta);
|
|
266
278
|
if (meta.security === null) return this._enforceAreaSecurity();
|
|
267
279
|
const granted = this._enforceSecurity(meta.security);
|
|
268
280
|
return meta.security.hasRoleClause ? granted : granted.then(() => this._enforceAreaSecurity());
|
|
@@ -385,7 +397,7 @@ var Resolver = class {
|
|
|
385
397
|
const store = context.container?.get("idempotencyStore");
|
|
386
398
|
const verdict = await evaluateIdempotency({
|
|
387
399
|
store,
|
|
388
|
-
identity: resolveIdentity(
|
|
400
|
+
identity: resolveIdentity(),
|
|
389
401
|
clientKey: resolveIdempotencyKey(als?.idempotencyKey, httpReq?.headers?.["idempotency-key"]),
|
|
390
402
|
fingerprint: computeFingerprint([
|
|
391
403
|
this.route?.name ?? this.actionName,
|
|
@@ -639,6 +651,31 @@ var Resolver = class {
|
|
|
639
651
|
getResponseStatus() {
|
|
640
652
|
return this.context.response?.statusCode ?? 0;
|
|
641
653
|
}
|
|
654
|
+
/**
|
|
655
|
+
* `@RequireAuthentication` — les gardes d'autorisation d'abord (un rôle
|
|
656
|
+
* absent reste un 403 définitif : inutile de faire redemander une connexion à
|
|
657
|
+
* qui n'aura pas le droit), PUIS l'âge et les moyens de la connexion.
|
|
658
|
+
*
|
|
659
|
+
* Le refus est un 401 « step-up » (RFC 9470) : le `WWW-Authenticate` porte
|
|
660
|
+
* `error="insufficient_user_authentication"` et, quand la route fixe un âge,
|
|
661
|
+
* `max_age` — le client redemande une connexion au lieu de conclure à un
|
|
662
|
+
* accès fermé. Cold path : seules les routes décorées y passent.
|
|
663
|
+
*
|
|
664
|
+
* @throws nodefonyError 401 si la connexion est trop ancienne ou trop faible.
|
|
665
|
+
*/
|
|
666
|
+
async _enforceStepUp(meta) {
|
|
667
|
+
const requirement = meta.authentication;
|
|
668
|
+
if (requirement === null) return;
|
|
669
|
+
await this._enforceActionSecurity({
|
|
670
|
+
...meta,
|
|
671
|
+
authentication: null
|
|
672
|
+
});
|
|
673
|
+
const cause = authenticationShortfall(this.context.session ?? null, requirement, Date.now());
|
|
674
|
+
if (cause === null) return;
|
|
675
|
+
const response = this.context.response;
|
|
676
|
+
if (response !== null && "setHeader" in response) response.setHeader("WWW-Authenticate", stepUpChallenge(requirement));
|
|
677
|
+
throw new nodefonyError(identityHint(this.context.kernel) === null ? "Authentication required" : `Authentication required — ${cause}. Reconnectez-vous (\`@RequireAuthentication\` de la route).`, 401);
|
|
678
|
+
}
|
|
642
679
|
};
|
|
643
680
|
/** Statuts dont la réponse n'a, par définition, pas de corps (RFC 9110). */
|
|
644
681
|
const NO_BODY_STATUS = /* @__PURE__ */ new Set([
|
|
@@ -99,6 +99,32 @@ function replaceCallback(_match, slash, dot, key, capture, opt, _offset) {
|
|
|
99
99
|
}
|
|
100
100
|
throw new Error(`Bad path `);
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Exigences d'une route, l'option `method` versée dans `requirements.methods`.
|
|
104
|
+
*
|
|
105
|
+
* Seul `requirements.methods` restreint le match ; l'option `method` n'était
|
|
106
|
+
* lue que pour l'affichage, si bien qu'une route « POST » servait toutes les
|
|
107
|
+
* méthodes. Les deux déclarations s'UNISSENT, comme le font les décorateurs
|
|
108
|
+
* `@Get`/`@Post` : déclarer n'élargit jamais en silence ni n'écrase.
|
|
109
|
+
*
|
|
110
|
+
* @param requirements - exigences déclarées (jamais mutées)
|
|
111
|
+
* @param method - méthode(s) de l'option `method`
|
|
112
|
+
* @returns les exigences à poser sur la route
|
|
113
|
+
*/
|
|
114
|
+
function withDeclaredMethod(requirements, method) {
|
|
115
|
+
if (method === void 0) return requirements ?? {};
|
|
116
|
+
const own = requirements?.methods;
|
|
117
|
+
const union = new Set(Array.isArray(method) ? method : [method]);
|
|
118
|
+
if (own !== void 0) for (const m of Array.isArray(own) ? own : [own]) union.add(m);
|
|
119
|
+
return {
|
|
120
|
+
...requirements,
|
|
121
|
+
methods: [...union]
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Canal chiffré : `https` (HTTP) ou `wss` (WebSocket). */
|
|
125
|
+
function isSecureScheme(scheme) {
|
|
126
|
+
return scheme === "https" || scheme === "wss";
|
|
127
|
+
}
|
|
102
128
|
var Route = class Route {
|
|
103
129
|
name;
|
|
104
130
|
path;
|
|
@@ -106,7 +132,6 @@ var Route = class Route {
|
|
|
106
132
|
classMethod;
|
|
107
133
|
prefix;
|
|
108
134
|
method;
|
|
109
|
-
schemes;
|
|
110
135
|
pattern;
|
|
111
136
|
variables = [];
|
|
112
137
|
/**
|
|
@@ -144,6 +169,8 @@ var Route = class Route {
|
|
|
144
169
|
* pas son rôle par défaut. Cf {@link RouteOptions.areaRoleExempt}.
|
|
145
170
|
*/
|
|
146
171
|
areaRoleExempt = false;
|
|
172
|
+
/** Route de repli, résolue après toutes les autres. Cf {@link RouteOptions.fallback}. */
|
|
173
|
+
fallback = false;
|
|
147
174
|
/**
|
|
148
175
|
* P2.9 — Cache mémoïsé : l'action attend-elle le **flux brut** du body
|
|
149
176
|
* (`@Body({ stream:true })`) ? `undefined` = pas encore calculé (résolu au 1er
|
|
@@ -177,9 +204,10 @@ var Route = class Route {
|
|
|
177
204
|
this.method = obj.method;
|
|
178
205
|
this.setHostname(obj.host);
|
|
179
206
|
this.setDefaults(obj.defaults);
|
|
180
|
-
this.requirements = obj.requirements
|
|
207
|
+
this.requirements = withDeclaredMethod(obj.requirements, obj.method);
|
|
181
208
|
this.bypassFirewall = obj.bypassFirewall ?? false;
|
|
182
209
|
this.areaRoleExempt = obj.areaRoleExempt ?? false;
|
|
210
|
+
this.fallback = obj.fallback ?? false;
|
|
183
211
|
this.compile();
|
|
184
212
|
}
|
|
185
213
|
this.generateId();
|
|
@@ -366,7 +394,8 @@ var Route = class Route {
|
|
|
366
394
|
const action = this.classMethod || this.name;
|
|
367
395
|
const mod = this.module?.name ? `@${this.module.name}/` : "";
|
|
368
396
|
const auth = this.bypassFirewall ? " (no auth)" : this.areaRoleExempt ? " (hors rôle de zone)" : "";
|
|
369
|
-
|
|
397
|
+
const fallback = this.fallback ? " (repli)" : "";
|
|
398
|
+
return `${method} ${this.path} → ${mod}${ctrl}.${action}${auth}${fallback}`;
|
|
370
399
|
}
|
|
371
400
|
toObject() {
|
|
372
401
|
return {
|
|
@@ -376,10 +405,11 @@ var Route = class Route {
|
|
|
376
405
|
host: this.host,
|
|
377
406
|
controller: this.defaults.controller,
|
|
378
407
|
filePath: this.filePath,
|
|
379
|
-
schemes: this.
|
|
408
|
+
schemes: this.requirements.scheme,
|
|
380
409
|
variables: this.variables,
|
|
381
410
|
bypassFirewall: this.bypassFirewall,
|
|
382
|
-
areaRoleExempt: this.areaRoleExempt
|
|
411
|
+
areaRoleExempt: this.areaRoleExempt,
|
|
412
|
+
fallback: this.fallback
|
|
383
413
|
};
|
|
384
414
|
}
|
|
385
415
|
setDefaults(arg) {
|
|
@@ -459,6 +489,13 @@ var Route = class Route {
|
|
|
459
489
|
return Object.keys(this.requirements).length;
|
|
460
490
|
}
|
|
461
491
|
matchRequirements(context, methodOverride) {
|
|
492
|
+
if (this.requirements.methods === void 0 && (methodOverride !== void 0 || context.method === "WEBSOCKET")) {
|
|
493
|
+
const error = new HttpError(`Method ${methodOverride ?? "WEBSOCKET"} Not Allowed`);
|
|
494
|
+
error.code = 405;
|
|
495
|
+
error.type = "method";
|
|
496
|
+
error.allow = "";
|
|
497
|
+
throw error;
|
|
498
|
+
}
|
|
462
499
|
if (this.hasRequirements()) for (const i in this.requirements) switch (i) {
|
|
463
500
|
case "methods":
|
|
464
501
|
if (!this.methodsSet) throw new Error(`Bad config route method : ${String(this.requirements.methods)}`);
|
|
@@ -480,6 +517,16 @@ var Route = class Route {
|
|
|
480
517
|
throw error;
|
|
481
518
|
}
|
|
482
519
|
break;
|
|
520
|
+
case "scheme": {
|
|
521
|
+
const required = this.requirements.scheme;
|
|
522
|
+
if (required !== void 0 && isSecureScheme(required) !== isSecureScheme(context.scheme)) {
|
|
523
|
+
const error = new HttpError(`Scheme ${context.scheme} Forbidden for this route : ${required} required`);
|
|
524
|
+
error.code = 403;
|
|
525
|
+
error.type = "scheme";
|
|
526
|
+
throw error;
|
|
527
|
+
}
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
483
530
|
case "domain": break;
|
|
484
531
|
case "protocol": if (context.method === "WEBSOCKET") {
|
|
485
532
|
const requirement = this.requirements[i];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Controller from "./Controller.js";
|
|
2
|
+
//#region nodefony/src/SecurityApiController.ts
|
|
3
|
+
const NO_STORE = { "cache-control": "no-store" };
|
|
4
|
+
/**
|
|
5
|
+
* Base des contrôleurs d'authentification (`/nodefony/security/api/*`) : toute
|
|
6
|
+
* réponse JSON part en `Cache-Control: no-store`.
|
|
7
|
+
*
|
|
8
|
+
* Ces réponses portent une identité, un jeton, un secret affiché une seule
|
|
9
|
+
* fois (clé API, secret TOTP, codes de récupération) ou leur refus. Un cookie
|
|
10
|
+
* ou un en-tête dans la requête n'empêche pas un cache partagé de STOCKER la
|
|
11
|
+
* réponse (RFC 9111 §3) ; RFC 6749 §5.1 exige `no-store` sur un jeton. La règle
|
|
12
|
+
* vit ici, une fois : une action ajoutée à un contrôleur héritier ne peut pas
|
|
13
|
+
* l'oublier. Un en-tête passé par l'action garde la priorité.
|
|
14
|
+
*/
|
|
15
|
+
var SecurityApiController = class extends Controller {
|
|
16
|
+
renderJson(obj, status, headers) {
|
|
17
|
+
return super.renderJson(obj, status, {
|
|
18
|
+
...NO_STORE,
|
|
19
|
+
...headers
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
//#endregion
|
|
24
|
+
export { SecurityApiController as default };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { basename, dirname, extname, join } from "node:path";
|
|
2
|
-
import { findProjectRoot, packageManagerCommandLines, packageManagerExecArgs, portableSpawn,
|
|
2
|
+
import { findProjectRoot, isDocVisible, packageManagerCommandLines, packageManagerExecArgs, portableSpawn, readDocScope, readSymbolsGraph, resolvePackageManager } from "nodefony";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
@@ -97,11 +97,19 @@ async function gitLastUpdated(file) {
|
|
|
97
97
|
return null;
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
|
-
/**
|
|
101
|
-
|
|
100
|
+
/**
|
|
101
|
+
* La page se sert-elle dans ce décor ? Portée lue dans `publish` par la règle
|
|
102
|
+
* du cœur (`readDocScope`/`isDocVisible`) — la même que le portail et le site.
|
|
103
|
+
*/
|
|
104
|
+
function servable(data, frameworkRepository) {
|
|
105
|
+
return isDocVisible(readDocScope(data.publish), frameworkRepository);
|
|
106
|
+
}
|
|
107
|
+
/** Construit un `DocSummary` à partir du contenu brut d'un `.md` — `null` si la page n'est pas servie ici. */
|
|
108
|
+
async function summarize(docsDir, fileName, withGit, frameworkRepository) {
|
|
102
109
|
const slug = basename(fileName, extname(fileName));
|
|
103
110
|
const full = join(docsDir, fileName);
|
|
104
111
|
const { data, body } = parseFrontmatter(await readFile(full, "utf8"));
|
|
112
|
+
if (!servable(data, frameworkRepository)) return null;
|
|
105
113
|
const title = docTitle(data, body, slug);
|
|
106
114
|
return {
|
|
107
115
|
slug,
|
|
@@ -121,7 +129,7 @@ async function summarize(docsDir, fileName, withGit) {
|
|
|
121
129
|
* @param modulePath - chemin disque du module (`Module.path`).
|
|
122
130
|
* @returns liste triée (vide si le dossier `docs/` est absent).
|
|
123
131
|
*/
|
|
124
|
-
async function listModuleDocs(modulePath, withGit = false) {
|
|
132
|
+
async function listModuleDocs(modulePath, withGit = false, frameworkRepository = false) {
|
|
125
133
|
const docsDir = join(modulePath, "docs");
|
|
126
134
|
let entries;
|
|
127
135
|
try {
|
|
@@ -130,21 +138,34 @@ async function listModuleDocs(modulePath, withGit = false) {
|
|
|
130
138
|
return [];
|
|
131
139
|
}
|
|
132
140
|
const mdFiles = entries.filter((f) => f.toLowerCase().endsWith(".md"));
|
|
133
|
-
return (await Promise.all(mdFiles.map((f) => summarize(docsDir, f, withGit)))).sort((a, b) => a.order - b.order || a.slug.localeCompare(b.slug));
|
|
141
|
+
return (await Promise.all(mdFiles.map((f) => summarize(docsDir, f, withGit, frameworkRepository)))).filter((d) => d !== null).sort((a, b) => a.order - b.order || a.slug.localeCompare(b.slug));
|
|
134
142
|
}
|
|
135
143
|
/**
|
|
136
|
-
* Comptage
|
|
137
|
-
*
|
|
144
|
+
* Comptage des docs SERVIES d'un module, sans `git` — pour les KPI/overview
|
|
145
|
+
* (`docsCount`) où seul le nombre importe. Le frontmatter est lu : le compte
|
|
146
|
+
* suit la même portée (`publish`) que la liste, sinon le KPI annonce des pages
|
|
147
|
+
* que l'onglet Docs refuse d'ouvrir.
|
|
138
148
|
*
|
|
139
149
|
* @param modulePath - chemin disque du module (`Module.path`).
|
|
140
|
-
* @
|
|
150
|
+
* @param frameworkRepository - vrai dans le dépôt du framework (pages `core` servies).
|
|
151
|
+
* @returns nombre de pages servies dans `<modulePath>/docs/` (0 si absent).
|
|
141
152
|
*/
|
|
142
|
-
async function countModuleDocs(modulePath) {
|
|
153
|
+
async function countModuleDocs(modulePath, frameworkRepository = false) {
|
|
154
|
+
const docsDir = join(modulePath, "docs");
|
|
155
|
+
let entries;
|
|
143
156
|
try {
|
|
144
|
-
|
|
157
|
+
entries = await readdir(docsDir);
|
|
145
158
|
} catch {
|
|
146
159
|
return 0;
|
|
147
160
|
}
|
|
161
|
+
return (await Promise.all(entries.filter((f) => f.toLowerCase().endsWith(".md")).map(async (f) => {
|
|
162
|
+
try {
|
|
163
|
+
const { data } = parseFrontmatter(await readFile(join(docsDir, f), "utf8"));
|
|
164
|
+
return servable(data, frameworkRepository);
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}))).filter(Boolean).length;
|
|
148
169
|
}
|
|
149
170
|
/**
|
|
150
171
|
* Lit une doc module par slug : frontmatter + corps markdown brut.
|
|
@@ -154,7 +175,7 @@ async function countModuleDocs(modulePath) {
|
|
|
154
175
|
*
|
|
155
176
|
* @returns `null` si slug invalide ou fichier absent.
|
|
156
177
|
*/
|
|
157
|
-
async function readModuleDoc(modulePath, slug) {
|
|
178
|
+
async function readModuleDoc(modulePath, slug, frameworkRepository = false) {
|
|
158
179
|
if (!SLUG_RE.test(slug)) return null;
|
|
159
180
|
const full = join(modulePath, "docs", `${slug}.md`);
|
|
160
181
|
let raw;
|
|
@@ -164,6 +185,7 @@ async function readModuleDoc(modulePath, slug) {
|
|
|
164
185
|
return null;
|
|
165
186
|
}
|
|
166
187
|
const { data, body } = parseFrontmatter(raw);
|
|
188
|
+
if (!servable(data, frameworkRepository)) return null;
|
|
167
189
|
return {
|
|
168
190
|
slug,
|
|
169
191
|
frontmatter: data,
|
|
@@ -276,9 +298,10 @@ async function searchModuleDocs(targets, query, options = {}) {
|
|
|
276
298
|
} catch {
|
|
277
299
|
continue;
|
|
278
300
|
}
|
|
279
|
-
scanned += 1;
|
|
280
301
|
const slug = basename(fileName, extname(fileName));
|
|
281
302
|
const { data, body } = parseFrontmatter(raw);
|
|
303
|
+
if (!servable(data, options.frameworkRepository ?? false)) continue;
|
|
304
|
+
scanned += 1;
|
|
282
305
|
const title = docTitle(data, body, slug);
|
|
283
306
|
const foldedTitle = fold(`${title} ${typeof data.navTitle === "string" && data.navTitle.trim() ? data.navTitle.trim() : ""} ${slug}`);
|
|
284
307
|
const lines = body.split("\n");
|
|
@@ -347,15 +370,9 @@ async function searchModuleDocs(targets, query, options = {}) {
|
|
|
347
370
|
* @returns symboles exportés triés par kind puis nom (vide si fichier absent).
|
|
348
371
|
*/
|
|
349
372
|
async function listModuleSymbols(packageName) {
|
|
350
|
-
const
|
|
351
|
-
if (
|
|
352
|
-
|
|
353
|
-
try {
|
|
354
|
-
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
355
|
-
} catch {
|
|
356
|
-
return [];
|
|
357
|
-
}
|
|
358
|
-
const symbols = parsed.symbols ?? {};
|
|
373
|
+
const graph = readSymbolsGraph(process.cwd());
|
|
374
|
+
if (graph === null) return [];
|
|
375
|
+
const symbols = graph.symbols;
|
|
359
376
|
const out = [];
|
|
360
377
|
for (const sym of Object.values(symbols)) {
|
|
361
378
|
if (sym.module !== packageName || sym.exported !== true) continue;
|
|
@@ -51,28 +51,21 @@ function resolveIdempotencyKey(alsKey, header) {
|
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
53
53
|
* Identité stable pour **scoper** le cache d'idempotence (anti-IDOR : un
|
|
54
|
-
* utilisateur ne doit jamais rejouer la clé d'un autre)
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* n'utilise PAS le cache (jamais de partage cross-identité).
|
|
54
|
+
* utilisateur ne doit jamais rejouer la clé d'un autre) : l'`identifier` de
|
|
55
|
+
* l'utilisateur AUTHENTIFIÉ de la requête. `null` = pas d'identité fiable →
|
|
56
|
+
* l'appelant n'utilise PAS le cache (jamais de partage cross-identité).
|
|
58
57
|
*
|
|
59
|
-
*
|
|
60
|
-
* (
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
58
|
+
* La décision « authentifié ? » est celle du cœur
|
|
59
|
+
* (`RequestContext.getAuthenticatedUser`) : l'anonyme d'une zone `anonymous`
|
|
60
|
+
* rend `null`. Lire l'`identifier` de l'utilisateur BRUT faisait partager à
|
|
61
|
+
* tous les visiteurs anonymes la même identité (`anon.`), donc le même cache.
|
|
62
|
+
*
|
|
63
|
+
* Même valeur quel que soit le transport pour un même compte : le pare-feu HTTP
|
|
64
|
+
* et le pont realtime posent tous deux le jeton et l'utilisateur dans l'ALS —
|
|
65
|
+
* une mutation tentée en WS puis rejouée en HTTP se dédoublonne.
|
|
64
66
|
*/
|
|
65
|
-
function resolveIdentity(
|
|
66
|
-
|
|
67
|
-
const o = user;
|
|
68
|
-
for (const v of [
|
|
69
|
-
o.username,
|
|
70
|
-
o.identifier,
|
|
71
|
-
o.id
|
|
72
|
-
]) if (typeof v === "string" && v) return v;
|
|
73
|
-
}
|
|
74
|
-
const uid = RequestContext.getUserId();
|
|
75
|
-
return typeof uid === "string" && uid ? uid : null;
|
|
67
|
+
function resolveIdentity() {
|
|
68
|
+
return RequestContext.getAuthenticatedUser()?.identifier ?? null;
|
|
76
69
|
}
|
|
77
70
|
/**
|
|
78
71
|
* Empreinte SHA-256 du **payload** d'une requête (parties sérialisables : nom de
|