@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.
Files changed (53) hide show
  1. package/README.md +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +9 -5
  5. package/dist/nodefony/controller/AdminApiController.js +7 -4
  6. package/dist/nodefony/controller/ApiKeyController.js +5 -4
  7. package/dist/nodefony/controller/LoginPageController.js +124 -0
  8. package/dist/nodefony/controller/OAuth2Controller.js +131 -14
  9. package/dist/nodefony/controller/SessionAuthController.js +37 -9
  10. package/dist/nodefony/controller/TokenAuthController.js +13 -6
  11. package/dist/nodefony/controller/TotpController.js +7 -6
  12. package/dist/nodefony/controller/WebAuthnController.js +26 -9
  13. package/dist/nodefony/decorators/routerDecorators.js +114 -13
  14. package/dist/nodefony/service/AdminBroker.js +2 -2
  15. package/dist/nodefony/service/IdempotencyStore.js +2 -2
  16. package/dist/nodefony/service/router.js +54 -23
  17. package/dist/nodefony/src/Controller.js +49 -4
  18. package/dist/nodefony/src/FrameworkAdminApi.js +2 -1
  19. package/dist/nodefony/src/KernelAdminApi.js +76 -10
  20. package/dist/nodefony/src/Resolver.js +41 -4
  21. package/dist/nodefony/src/Route.js +76 -6
  22. package/dist/nodefony/src/SecurityApiController.js +24 -0
  23. package/dist/nodefony/src/docsReader.js +64 -41
  24. package/dist/nodefony/src/idempotency.js +13 -20
  25. package/dist/nodefony/src/loginPage.js +431 -0
  26. package/dist/nodefony/src/requireAuthentication.js +36 -0
  27. package/dist/types/index.d.ts +4 -3
  28. package/dist/types/nodefony/controller/ApiKeyController.d.ts +4 -3
  29. package/dist/types/nodefony/controller/LoginPageController.d.ts +32 -0
  30. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +67 -8
  31. package/dist/types/nodefony/controller/SessionAuthController.d.ts +39 -8
  32. package/dist/types/nodefony/controller/TokenAuthController.d.ts +8 -6
  33. package/dist/types/nodefony/controller/TotpController.d.ts +4 -3
  34. package/dist/types/nodefony/controller/WebAuthnController.d.ts +12 -4
  35. package/dist/types/nodefony/decorators/routerDecorators.d.ts +88 -3
  36. package/dist/types/nodefony/interfaces/IRoute.d.ts +1 -2
  37. package/dist/types/nodefony/service/router.d.ts +13 -0
  38. package/dist/types/nodefony/src/Controller.d.ts +40 -1
  39. package/dist/types/nodefony/src/Resolver.d.ts +20 -1
  40. package/dist/types/nodefony/src/Route.d.ts +33 -2
  41. package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -0
  42. package/dist/types/nodefony/src/docsReader.d.ts +26 -12
  43. package/dist/types/nodefony/src/idempotency.d.ts +12 -10
  44. package/dist/types/nodefony/src/loginPage.d.ts +144 -0
  45. package/dist/types/nodefony/src/requireAuthentication.d.ts +29 -0
  46. package/docs/admin.md +28 -23
  47. package/docs/controller.md +84 -82
  48. package/docs/decorateurs.md +77 -74
  49. package/docs/idempotence.md +31 -29
  50. package/docs/index.md +1 -1
  51. package/docs/routing.md +58 -57
  52. package/docs/templates.md +12 -12
  53. package/package.json +11 -12
@@ -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.username ?? u.email ?? (u.id != null ? String(u.id) : null);
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, { limit: Number.isFinite(limit) && limit > 0 ? limit : void 0 });
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
@@ -78,8 +79,8 @@ var Resolver = class {
78
79
  this.context = context;
79
80
  this.injector = context.container?.get("injector") ?? null;
80
81
  }
81
- match(route, context, cleanPath) {
82
- const match = route.match(context, cleanPath, this.methodOverride ?? void 0);
82
+ match(route, context, cleanPath, literal) {
83
+ const match = route.match(context, cleanPath, this.methodOverride ?? void 0, literal);
83
84
  if (match) {
84
85
  this.variables = match;
85
86
  this.route = route;
@@ -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 (!this.messageInvocation) this.context.container?.set("controller", controller);
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(als?.user),
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();
@@ -203,10 +231,29 @@ var Route = class Route {
203
231
  if (!reqUrl) return;
204
232
  return stripTrailingSlashes(reqUrl.pathname);
205
233
  }
206
- match(context, cleanPath, methodOverride) {
234
+ /**
235
+ * Confronte la requête à cette route : chemin, hôte, puis exigences.
236
+ *
237
+ * @param context - contexte HTTP/WS courant.
238
+ * @param cleanPath - pathname déjà normalisé par l'appelant (sinon recalculé).
239
+ * @param methodOverride - méthode logique du pont WS-RPC (cf `Resolver`).
240
+ * @param literal - l'appelant GARANTIT que le chemin de la route ne porte
241
+ * aucune syntaxe de motif (`{}`, `*`…) — c'est le cas d'une route trouvée
242
+ * par l'index des littérales du `Router`. Le motif n'est alors pas exécuté
243
+ * quand le chemin est identique à la lettre.
244
+ * @returns les valeurs capturées (tableau + accès par nom), ou `null`/`undefined`
245
+ * quand le chemin ne correspond pas.
246
+ * @throws 403/405/1002 quand le chemin correspond mais pas l'hôte ou les exigences.
247
+ */
248
+ match(context, cleanPath, methodOverride, literal) {
207
249
  let res;
208
250
  if (context.request && this.pattern) {
209
251
  const url = cleanPath ?? Route.cleanPathname(context);
252
+ if (literal === true && url === this.path && !this.#hasPositionalDefaults()) {
253
+ this.matchHostname(context);
254
+ this.matchRequirements(context, methodOverride);
255
+ return [];
256
+ }
210
257
  if (url !== void 0) res = url.match(this.pattern);
211
258
  }
212
259
  if (!res) return res;
@@ -307,6 +354,10 @@ var Route = class Route {
307
354
  if (compiled) (this.varRegexp ??= Object.create(null))[k] = compiled;
308
355
  }
309
356
  }
357
+ #hasPositionalDefaults() {
358
+ for (const def in this.defaults) if (def !== "controller") return true;
359
+ return false;
360
+ }
310
361
  hydrateDefaultParameters(res) {
311
362
  if (this.variables.length) for (let i = 0; i < this.variables.length; i++) {
312
363
  const name = this.variables[i];
@@ -343,7 +394,8 @@ var Route = class Route {
343
394
  const action = this.classMethod || this.name;
344
395
  const mod = this.module?.name ? `@${this.module.name}/` : "";
345
396
  const auth = this.bypassFirewall ? " (no auth)" : this.areaRoleExempt ? " (hors rôle de zone)" : "";
346
- return `${method} ${this.path} → ${mod}${ctrl}.${action}${auth}`;
397
+ const fallback = this.fallback ? " (repli)" : "";
398
+ return `${method} ${this.path} → ${mod}${ctrl}.${action}${auth}${fallback}`;
347
399
  }
348
400
  toObject() {
349
401
  return {
@@ -353,10 +405,11 @@ var Route = class Route {
353
405
  host: this.host,
354
406
  controller: this.defaults.controller,
355
407
  filePath: this.filePath,
356
- schemes: this.schemes,
408
+ schemes: this.requirements.scheme,
357
409
  variables: this.variables,
358
410
  bypassFirewall: this.bypassFirewall,
359
- areaRoleExempt: this.areaRoleExempt
411
+ areaRoleExempt: this.areaRoleExempt,
412
+ fallback: this.fallback
360
413
  };
361
414
  }
362
415
  setDefaults(arg) {
@@ -436,6 +489,13 @@ var Route = class Route {
436
489
  return Object.keys(this.requirements).length;
437
490
  }
438
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
+ }
439
499
  if (this.hasRequirements()) for (const i in this.requirements) switch (i) {
440
500
  case "methods":
441
501
  if (!this.methodsSet) throw new Error(`Bad config route method : ${String(this.requirements.methods)}`);
@@ -457,6 +517,16 @@ var Route = class Route {
457
517
  throw error;
458
518
  }
459
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
+ }
460
530
  case "domain": break;
461
531
  case "protocol": if (context.method === "WEBSOCKET") {
462
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, resolveSymbolsFile } from "nodefony";
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
- /** Construit un `DocSummary` à partir du contenu brut d'un `.md`. */
101
- async function summarize(docsDir, fileName, withGit) {
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 rapide des docs (`*.md`) d'un module — readdir seul, sans lire/parser
137
- * ni `git`. Pour les KPI/overview (`docsCount`) où seul le nombre importe.
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
- * @returns nombre de fichiers `.md` dans `<modulePath>/docs/` (0 si absent).
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
- return (await readdir(join(modulePath, "docs"))).filter((f) => f.toLowerCase().endsWith(".md")).length;
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 file = resolveSymbolsFile(process.cwd());
351
- if (file === null) return [];
352
- let parsed;
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;
@@ -653,50 +670,54 @@ async function listTestGroups(modulePath) {
653
670
  * config du module (la liste `reporter` dupliquée par module divergeait).
654
671
  * - sinon (cœur : monocart, pas de `vitest.config.ts`) → `npm run coverage`.
655
672
  *
673
+ * Le binaire et le script passent par le gestionnaire de paquets du PROJET :
674
+ * `npx` n'existe pas chez bun, et sous pnpm il chercherait hors de l'arbre que
675
+ * le verrou décrit.
676
+ *
656
677
  * @param modulePath - racine du module (où vit `vitest.config.ts`)
657
678
  * @param file - chemin relatif d'UN fichier de test, déjà validé par l'appelant
679
+ * @param manager - gestionnaire du projet ; défaut : résolu depuis la racine
680
+ * de l'application qui contient le module
658
681
  * @returns commande, arguments en tableau (spawn sans shell) et libellé du mode
659
682
  */
660
- function testRunCommand(modulePath, file) {
683
+ function testRunCommand(modulePath, file, manager = resolvePackageManager({ dir: findProjectRoot(modulePath) ?? modulePath }).name) {
661
684
  const hasVitest = existsSync(join(modulePath, "vitest.config.ts"));
662
685
  if (file && hasVitest) return {
663
- cmd: "npx",
664
- args: [
665
- "vitest",
666
- "run",
667
- file
668
- ],
686
+ cmd: manager,
687
+ args: packageManagerExecArgs(manager, "vitest", ["run", file]),
669
688
  mode: `vitest run ${file}`
670
689
  };
671
690
  if (hasVitest) return {
672
- cmd: "npx",
673
- args: [
674
- "vitest",
691
+ cmd: manager,
692
+ args: packageManagerExecArgs(manager, "vitest", [
675
693
  "run",
676
694
  "--coverage",
677
695
  "--coverage.reporter=text-summary",
678
696
  "--coverage.reporter=json-summary",
679
697
  "--coverage.reporter=lcov",
680
698
  "--coverage.reportsDirectory=.coverage"
681
- ],
699
+ ]),
682
700
  mode: "vitest run --coverage (reporters forcés)"
683
701
  };
684
702
  return {
685
- cmd: "npm",
703
+ cmd: manager,
686
704
  args: ["run", "coverage"],
687
- mode: "npm run coverage (suite complète)"
705
+ mode: `${packageManagerCommandLines(manager).run("coverage")} (suite complète)`
688
706
  };
689
707
  }
690
708
  /**
691
709
  * Lance les tests d'un module et renvoie un résumé (pass/fail/durée + tail).
692
710
  *
693
- * - 1 fichier (module vitest) → `npx vitest run <file>` (rapide, pass/fail).
694
- * - sinon → `npm run coverage` (suite complète + refresh coverage ; marche
695
- * pour vitest comme pour monocart/core).
711
+ * - 1 fichier (module vitest) → `vitest run <file>` (rapide, pass/fail).
712
+ * - sinon → le script `coverage` (suite complète + refresh coverage ; marche
713
+ * pour vitest comme pour le cœur).
714
+ * Les deux par le gestionnaire du projet (cf {@link testRunCommand}).
696
715
  *
697
716
  * ⚠️ EXÉCUTE un process — appelé UNIQUEMENT derrière le garde dev-only de
698
- * l'endpoint (cf KernelAdminApi). spawn sans shell + args en tableau (pas
699
- * d'injection shell). `file` validé en amont (suffixe .test.ts, pas de `..`).
717
+ * l'endpoint (cf KernelAdminApi). `portableSpawn` : jamais de `shell`, args
718
+ * en tableau (pas d'injection), et le `.cmd` du gestionnaire se lance sous
719
+ * Windows — un `spawn("npx")` nu y rendait `ENOENT`. `file` validé en amont
720
+ * (suffixe .test.ts, pas de `..`).
700
721
  */
701
722
  function runModuleTests(modulePath, file) {
702
723
  const { cmd, args, mode } = testRunCommand(modulePath, file);
@@ -709,9 +730,11 @@ function runModuleTests(modulePath, file) {
709
730
  };
710
731
  let child;
711
732
  try {
712
- child = spawn(cmd, args, {
733
+ const run = portableSpawn(cmd, args);
734
+ child = spawn(run.file, run.args, {
713
735
  cwd: modulePath,
714
- env: process.env
736
+ env: process.env,
737
+ windowsVerbatimArguments: run.windowsVerbatimArguments
715
738
  });
716
739
  } catch (e) {
717
740
  return resolve({