@nodefony/framework 10.0.0-beta.2 → 10.0.0-beta.4

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 (76) hide show
  1. package/README.md +4 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/index.js +15 -20
  4. package/dist/nodefony/config/config.js +1 -2
  5. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  6. package/dist/nodefony/controller/AdminApiController.js +45 -11
  7. package/dist/nodefony/controller/ApiKeyController.js +18 -10
  8. package/dist/nodefony/controller/BenchController.js +1 -2
  9. package/dist/nodefony/controller/IssuerMetadataController.js +1 -2
  10. package/dist/nodefony/controller/LoginPageController.js +117 -0
  11. package/dist/nodefony/controller/OAuth2Controller.js +133 -17
  12. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -2
  13. package/dist/nodefony/controller/SessionAuthController.js +50 -13
  14. package/dist/nodefony/controller/TokenAuthController.js +14 -8
  15. package/dist/nodefony/controller/TotpController.js +7 -8
  16. package/dist/nodefony/controller/WebAuthnController.js +28 -12
  17. package/dist/nodefony/controller/oauthAuthority.js +1 -2
  18. package/dist/nodefony/decorators/routerDecorators.js +268 -65
  19. package/dist/nodefony/service/AdminBroker.js +4 -6
  20. package/dist/nodefony/service/Eta.js +1 -2
  21. package/dist/nodefony/service/IdempotencyStore.js +4 -6
  22. package/dist/nodefony/service/router.js +97 -34
  23. package/dist/nodefony/src/Controller.js +71 -43
  24. package/dist/nodefony/src/FrameworkAdminApi.js +33 -11
  25. package/dist/nodefony/src/KernelAdminApi.js +82 -18
  26. package/dist/nodefony/src/PlaygroundAdminApi.js +72 -3
  27. package/dist/nodefony/src/RedisIdempotencyStore.js +46 -4
  28. package/dist/nodefony/src/Resolver.js +105 -36
  29. package/dist/nodefony/src/ResourceController.js +2 -3
  30. package/dist/nodefony/src/Route.js +54 -8
  31. package/dist/nodefony/src/SecurityApiController.js +23 -0
  32. package/dist/nodefony/src/SyslogAdminApi.js +1 -2
  33. package/dist/nodefony/src/Template.js +1 -2
  34. package/dist/nodefony/src/configMutation.js +1 -2
  35. package/dist/nodefony/src/docsReader.js +39 -23
  36. package/dist/nodefony/src/idempotency.js +14 -22
  37. package/dist/nodefony/src/idempotencyGc.js +1 -2
  38. package/dist/nodefony/src/idempotencyStoreRegistry.js +1 -2
  39. package/dist/nodefony/src/loginPage.js +430 -0
  40. package/dist/nodefony/src/requireAuthentication.js +35 -0
  41. package/dist/nodefony/src/routeSchema.js +81 -0
  42. package/dist/nodefony/src/scopeCatalog.js +1 -2
  43. package/dist/nodefony/src/singletonGuard.js +1 -2
  44. package/dist/nodefony/src/syslogFilters.js +1 -2
  45. package/dist/types/index.d.ts +8 -12
  46. package/dist/types/nodefony/controller/AdminApiController.d.ts +12 -0
  47. package/dist/types/nodefony/controller/ApiKeyController.d.ts +2 -2
  48. package/dist/types/nodefony/controller/LoginPageController.d.ts +32 -0
  49. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +67 -8
  50. package/dist/types/nodefony/controller/SessionAuthController.d.ts +46 -8
  51. package/dist/types/nodefony/controller/TokenAuthController.d.ts +6 -4
  52. package/dist/types/nodefony/controller/TotpController.d.ts +2 -2
  53. package/dist/types/nodefony/controller/WebAuthnController.d.ts +12 -4
  54. package/dist/types/nodefony/decorators/routerDecorators.d.ts +221 -35
  55. package/dist/types/nodefony/interfaces/IController.d.ts +2 -3
  56. package/dist/types/nodefony/interfaces/IRoute.d.ts +1 -2
  57. package/dist/types/nodefony/service/router.d.ts +40 -1
  58. package/dist/types/nodefony/src/Controller.d.ts +54 -17
  59. package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +12 -0
  60. package/dist/types/nodefony/src/Resolver.d.ts +44 -1
  61. package/dist/types/nodefony/src/ResourceController.d.ts +1 -1
  62. package/dist/types/nodefony/src/Route.d.ts +21 -1
  63. package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -0
  64. package/dist/types/nodefony/src/docsReader.d.ts +10 -6
  65. package/dist/types/nodefony/src/idempotency.d.ts +12 -10
  66. package/dist/types/nodefony/src/loginPage.d.ts +144 -0
  67. package/dist/types/nodefony/src/requireAuthentication.d.ts +29 -0
  68. package/dist/types/nodefony/src/routeSchema.d.ts +112 -0
  69. package/docs/admin.md +83 -78
  70. package/docs/controller.md +112 -111
  71. package/docs/decorateurs.md +212 -163
  72. package/docs/idempotence.md +137 -135
  73. package/docs/routing.md +96 -95
  74. package/docs/templates.md +55 -55
  75. package/package.json +10 -13
  76. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
@@ -2,10 +2,10 @@ 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, redactUrlCredentials, resumeSupervisor, runAppContributions, suspendSupervisor, withResolvedPath } from "nodefony";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { existsSync, readFileSync } from "node:fs";
8
- //#region nodefony/src/KernelAdminApi.ts
8
+ // nodefony/src/KernelAdminApi.ts
9
9
  /** Clé du pseudo-module core dans Studio (cf carte "Core" / `resolveCorePath`). */
10
10
  const CORE_KEY = "core";
11
11
  /** Racine projet — pour ne PAS exposer de chemin absolu (sécu). */
@@ -109,7 +109,7 @@ const SECRET_KEY = /(?:^|[a-z0-9])(secret|password|passwd|passphrase|credential|
109
109
  function safeConfig(value, depth = 0, seen = /* @__PURE__ */ new WeakSet()) {
110
110
  if (typeof value === "function") return "[Function]";
111
111
  if (typeof value === "bigint") return value.toString();
112
- if (typeof value === "string") return stripAbs(value);
112
+ if (typeof value === "string") return stripAbs(redactUrlCredentials(value));
113
113
  if (value === null || typeof value !== "object") return value;
114
114
  if (depth > 5) return "[depth limit]";
115
115
  if (seen.has(value)) return "[Circular]";
@@ -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.
@@ -402,7 +441,6 @@ function createKernelAdminApi(kernel) {
402
441
  const devGuard = () => kernel.environment === "development" || Boolean(kernel.debug);
403
442
  const repoRoot = process.cwd();
404
443
  const relPath = (p) => p?.startsWith(repoRoot) ? p.slice(repoRoot.length).replace(/^[/\\]+/, "") || "." : p ?? null;
405
- const redactInfraUrl = (url) => url.replace(/\/\/[^/@]*@/, "//***@");
406
444
  const buildConfigEntries = () => {
407
445
  const modules = kernel.getModules();
408
446
  const entries = [];
@@ -538,6 +576,30 @@ function createKernelAdminApi(kernel) {
538
576
  git: GitService.read()
539
577
  })
540
578
  },
579
+ {
580
+ path: "scaffold",
581
+ summary: "Files delivered by installed packages (dev only) — what `scaffold:sync --update` would do",
582
+ handler: async () => {
583
+ if (!devGuard()) return scaffoldRefused();
584
+ return scaffoldPayload(await runAppContributions(REPO_ROOT, true, true), true);
585
+ }
586
+ },
587
+ {
588
+ path: "scaffold/sync",
589
+ method: "POST",
590
+ summary: "Run `scaffold:sync` (dev only) — body `{ update?: boolean }`",
591
+ handler: async (request) => {
592
+ if (!devGuard()) return scaffoldRefused();
593
+ const body = request.body;
594
+ const update = typeof body === "object" && body !== null && "update" in body && body.update === true;
595
+ suspendSupervisor(REPO_ROOT, "synchronisation des fichiers livrés", update ? "scaffold:sync --update" : "scaffold:sync");
596
+ try {
597
+ return scaffoldPayload(await runAppContributions(REPO_ROOT, false, update), false);
598
+ } finally {
599
+ resumeSupervisor(REPO_ROOT);
600
+ }
601
+ }
602
+ },
541
603
  {
542
604
  path: "processes",
543
605
  summary: "Dev process topology (supervisor → server → Vite) + server ports — `nodefony status` over the data plane",
@@ -673,8 +735,8 @@ function createKernelAdminApi(kernel) {
673
735
  handler: () => {
674
736
  const infra = kernel.infra;
675
737
  const entriesBySeg = new Map(buildConfigEntries().map((e) => [e.seg, e]));
676
- const dbUrl = infra.database ? redactInfraUrl(infra.database.url) : null;
677
- const cacheUrl = infra.cache ? redactInfraUrl(infra.cache.url) : null;
738
+ const dbUrl = infra.database ? redactUrlCredentials(infra.database.url) : null;
739
+ const cacheUrl = infra.cache ? redactUrlCredentials(infra.cache.url) : null;
678
740
  const storeEndpoint = (res) => {
679
741
  if (res.location) return void 0;
680
742
  if (res.resolved === "redis") return cacheUrl ?? void 0;
@@ -713,8 +775,8 @@ function createKernelAdminApi(kernel) {
713
775
  } : null,
714
776
  cache: infra.cache ? { url: cacheUrl } : null,
715
777
  logs: infra.logs ? {
716
- lokiUrl: infra.logs.lokiUrl ? redactInfraUrl(infra.logs.lokiUrl) : null,
717
- opensearchUrl: infra.logs.opensearchUrl ? redactInfraUrl(infra.logs.opensearchUrl) : null
778
+ lokiUrl: infra.logs.lokiUrl ? redactUrlCredentials(infra.logs.lokiUrl) : null,
779
+ opensearchUrl: infra.logs.opensearchUrl ? redactUrlCredentials(infra.logs.opensearchUrl) : null
718
780
  } : null
719
781
  },
720
782
  stores: kernel.storeResolutions.map((res) => {
@@ -927,7 +989,7 @@ function createKernelAdminApi(kernel) {
927
989
  services: [],
928
990
  config: {},
929
991
  configSchema: null,
930
- docsCount: await countModuleDocs(core.path),
992
+ docsCount: await countModuleDocs(core.path, inFrameworkRepository()),
931
993
  symbolsCount: (await listModuleSymbols(CORE_PACKAGE)).length,
932
994
  coverageLines: (await readCoverage(core.path)).total?.lines ?? null
933
995
  };
@@ -959,7 +1021,7 @@ function createKernelAdminApi(kernel) {
959
1021
  provenance: cfg.provenance,
960
1022
  seg: cfg.seg,
961
1023
  envKeys: cfg.envKeys,
962
- docsCount: await countModuleDocs(mod.path),
1024
+ docsCount: await countModuleDocs(mod.path, inFrameworkRepository()),
963
1025
  symbolsCount: (await listModuleSymbols(cfg.name)).length,
964
1026
  coverageLines: (await readCoverage(mod.path)).total?.lines ?? null
965
1027
  };
@@ -1020,7 +1082,7 @@ function createKernelAdminApi(kernel) {
1020
1082
  };
1021
1083
  return {
1022
1084
  key,
1023
- docs: await listModuleDocs(dir)
1085
+ docs: await listModuleDocs(dir, false, inFrameworkRepository())
1024
1086
  };
1025
1087
  }
1026
1088
  },
@@ -1038,14 +1100,14 @@ function createKernelAdminApi(kernel) {
1038
1100
  available: docKeys()
1039
1101
  }
1040
1102
  };
1041
- const doc = await readModuleDoc(dir, request.params.slug ?? "");
1103
+ const doc = await readModuleDoc(dir, request.params.slug ?? "", inFrameworkRepository());
1042
1104
  if (!doc) return {
1043
1105
  status: 404,
1044
1106
  body: {
1045
1107
  error: "Doc not found",
1046
1108
  key,
1047
1109
  slug: request.params.slug,
1048
- available: (await listModuleDocs(dir)).map((d) => d.slug)
1110
+ available: (await listModuleDocs(dir, false, inFrameworkRepository())).map((d) => d.slug)
1049
1111
  }
1050
1112
  };
1051
1113
  const wanted = typeof request.query.section === "string" ? request.query.section : "";
@@ -1085,7 +1147,7 @@ function createKernelAdminApi(kernel) {
1085
1147
  handler: async () => {
1086
1148
  const modules = [];
1087
1149
  for (const target of docTargets()) {
1088
- const docs = await listModuleDocs(target.path);
1150
+ const docs = await listModuleDocs(target.path, false, inFrameworkRepository());
1089
1151
  if (docs.length > 0) modules.push({
1090
1152
  key: target.key,
1091
1153
  docs
@@ -1111,7 +1173,10 @@ function createKernelAdminApi(kernel) {
1111
1173
  }
1112
1174
  };
1113
1175
  const limit = Number.parseInt(String(request.query.limit ?? ""), 10);
1114
- return searchModuleDocs(docTargets(), q, { limit: Number.isFinite(limit) && limit > 0 ? limit : void 0 });
1176
+ return searchModuleDocs(docTargets(), q, {
1177
+ limit: Number.isFinite(limit) && limit > 0 ? limit : void 0,
1178
+ frameworkRepository: inFrameworkRepository()
1179
+ });
1115
1180
  }
1116
1181
  },
1117
1182
  {
@@ -1301,5 +1366,4 @@ function createKernelAdminApi(kernel) {
1301
1366
  adminEndpoints: () => endpoints
1302
1367
  };
1303
1368
  }
1304
- //#endregion
1305
1369
  export { createKernelAdminApi, nodefonyPackageCandidates, safeConfig };
@@ -1,7 +1,8 @@
1
1
  import { extractActionScopes, resolveActionMeta } from "../decorators/routerDecorators.js";
2
2
  import router_default from "../service/router.js";
3
3
  import AdminApiController from "../controller/AdminApiController.js";
4
- //#region nodefony/src/PlaygroundAdminApi.ts
4
+ import { standardJsonSchemaOf } from "nodefony";
5
+ // nodefony/src/PlaygroundAdminApi.ts
5
6
  /** Normalise `requirements.methods` (string | string[]) → tableau majuscule. */
6
7
  function methodsOf(route) {
7
8
  const m = route.requirements.methods ?? route.method;
@@ -15,11 +16,79 @@ function publicDefaults(route) {
15
16
  for (const [k, v] of Object.entries(route.defaults)) if (k !== "controller") out[k] = v;
16
17
  return out;
17
18
  }
19
+ /** Source du formulaire pour chaque partie d'un schéma de route. */
20
+ const PART_SOURCE = {
21
+ params: "param",
22
+ query: "query",
23
+ body: "body",
24
+ headers: "headers"
25
+ };
26
+ /** JSON Schema d'entrée d'un schéma, ou `null` s'il ne sait pas se décrire. */
27
+ function describeSchema(schema) {
28
+ const converter = standardJsonSchemaOf(schema);
29
+ if (converter === null) return null;
30
+ try {
31
+ return converter.input({ target: "draft-2020-12" });
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+ /** Les champs de premier niveau d'un JSON Schema objet. */
37
+ function propertiesOf(json) {
38
+ const properties = json?.properties;
39
+ return typeof properties === "object" && properties !== null ? Object.keys(properties) : [];
40
+ }
41
+ /**
42
+ * Les entrées d'une action à schéma, au format des paramètres décorés, et le
43
+ * contrat décrit partie par partie.
44
+ */
45
+ function schemaParams(schema, bodyStream) {
46
+ const params = [];
47
+ const described = {};
48
+ for (const part of [
49
+ "params",
50
+ "query",
51
+ "body",
52
+ "headers"
53
+ ]) {
54
+ const partSchema = schema[part];
55
+ if (partSchema === void 0) continue;
56
+ const json = describeSchema(partSchema);
57
+ described[part] = json;
58
+ const source = PART_SOURCE[part];
59
+ if (part === "body") {
60
+ params.push({
61
+ source,
62
+ key: null,
63
+ index: 0,
64
+ stream: false
65
+ });
66
+ continue;
67
+ }
68
+ for (const key of propertiesOf(json)) params.push({
69
+ source,
70
+ key,
71
+ index: 0,
72
+ stream: false
73
+ });
74
+ }
75
+ if (bodyStream) params.push({
76
+ source: "body",
77
+ key: null,
78
+ index: 0,
79
+ stream: true
80
+ });
81
+ return {
82
+ params,
83
+ described
84
+ };
85
+ }
18
86
  /** Sérialise une route en action playground (métadonnées figées, JSON-safe). */
19
87
  function serializeAction(route) {
20
88
  const meta = resolveActionMeta(route);
21
89
  const methods = methodsOf(route);
22
90
  const scopes = route.controller && route.classMethod ? extractActionScopes(route.controller, route.classMethod) : [];
91
+ const fromSchema = meta.schema !== null ? schemaParams(meta.schema, meta.bodyStream) : null;
23
92
  return {
24
93
  route: route.name,
25
94
  path: route.path ?? null,
@@ -28,12 +97,13 @@ function serializeAction(route) {
28
97
  action: route.classMethod ?? null,
29
98
  variables: [...route.variables],
30
99
  defaults: publicDefaults(route),
31
- params: meta.paramsMeta?.map((p) => ({
100
+ params: fromSchema?.params ?? meta.paramsMeta?.map((p) => ({
32
101
  source: p.source,
33
102
  key: p.key ?? null,
34
103
  index: p.index,
35
104
  stream: p.stream === true
36
105
  })).sort((a, b) => a.index - b.index) ?? [],
106
+ schema: fromSchema?.described ?? null,
37
107
  guards: {
38
108
  security: meta.security ? { clauses: meta.security.clauses.map((c) => ({
39
109
  anyOf: [...c.anyOf],
@@ -93,5 +163,4 @@ function createPlaygroundEndpoints() {
93
163
  handler: () => buildPlaygroundSnapshot()
94
164
  }];
95
165
  }
96
- //#endregion
97
166
  export { buildPlaygroundSnapshot, createPlaygroundEndpoints };
@@ -1,5 +1,5 @@
1
1
  import { assertPageQuery } from "nodefony";
2
- //#region nodefony/src/RedisIdempotencyStore.ts
2
+ // nodefony/src/RedisIdempotencyStore.ts
3
3
  /** Préfixe namespacé des clés d'idempotence dans Redis. */
4
4
  /**
5
5
  * Préfixe HISTORIQUE des clés d'idempotence. Utilisé tel quel par une application
@@ -42,6 +42,50 @@ function decodeCursor(cursor) {
42
42
  };
43
43
  }
44
44
  /**
45
+ * Reconstruit une entrée relue du store, ou `null` si sa forme est inattendue.
46
+ * La valeur vient de Redis, pas de ce processus : une entrée « terminée » sans
47
+ * réponse rejouerait `undefined` comme une réponse HTTP.
48
+ */
49
+ function toEntry(v) {
50
+ if (typeof v !== "object" || v === null || !("s" in v) || !("f" in v)) return null;
51
+ const f = v.f;
52
+ if (typeof f !== "string") return null;
53
+ if (v.s === "if") return {
54
+ s: "if",
55
+ f
56
+ };
57
+ if (v.s !== "d" || !("r" in v)) return null;
58
+ const r = toResponse(v.r);
59
+ return r === null ? null : {
60
+ s: "d",
61
+ f,
62
+ r
63
+ };
64
+ }
65
+ /** Reconstruit une réponse mémorisée, ou `null` si sa forme est inattendue. */
66
+ function toResponse(v) {
67
+ if (typeof v !== "object" || v === null || !("status" in v)) return null;
68
+ const status = v.status;
69
+ if (typeof status !== "number") return null;
70
+ const body = "body" in v ? v.body : void 0;
71
+ if (!("headers" in v) || v.headers === void 0) return {
72
+ status,
73
+ body
74
+ };
75
+ const headers = v.headers;
76
+ if (typeof headers !== "object" || headers === null) return null;
77
+ const copy = {};
78
+ for (const [name, value] of Object.entries(headers)) {
79
+ if (typeof value !== "string") return null;
80
+ copy[name] = value;
81
+ }
82
+ return {
83
+ status,
84
+ headers: copy,
85
+ body
86
+ };
87
+ }
88
+ /**
45
89
  * Store d'idempotence **Redis** (node-redis v6) — implémentation distribuée
46
90
  * d'{@link IIdempotencyStore} pour le cluster (dédup des mutations rejouées
47
91
  * PARTAGÉE cross-pod, là où l'impl mémoire par défaut reste affine à un pod).
@@ -183,8 +227,7 @@ var RedisIdempotencyStore = class {
183
227
  #parse(raw) {
184
228
  if (raw === null) return null;
185
229
  try {
186
- const v = JSON.parse(raw);
187
- return v && (v.s === "if" || v.s === "d") ? v : null;
230
+ return toEntry(JSON.parse(raw));
188
231
  } catch {
189
232
  return null;
190
233
  }
@@ -250,5 +293,4 @@ var RedisIdempotencyStore = class {
250
293
  if (this.#pending > 0) this.#pending--;
251
294
  }
252
295
  };
253
- //#endregion
254
296
  export { RedisIdempotencyStore };
@@ -1,9 +1,11 @@
1
1
  import { guardSingletonState, isDevelopment } from "./singletonGuard.js";
2
- import { buildParamArgs, computeActionMeta, resolveActionMeta, resolveSessionIntent } from "../decorators/routerDecorators.js";
2
+ import { validateRouteInput } from "./routeSchema.js";
3
+ import { buildParamArgs, computeActionMeta, readInvocationBody, readRouteInput, resolveActionMeta, resolveSessionIntent } from "../decorators/routerDecorators.js";
3
4
  import { computeFingerprint, evaluateIdempotency, isMutationMethod, resolveIdempotencyKey, resolveIdentity } from "./idempotency.js";
5
+ import { authenticationShortfall, stepUpChallenge } from "./requireAuthentication.js";
4
6
  import { RequestContext, finallyMaybe, identityHint, isArray, isPlainObject, isPromise, nodefonyError, thenMaybe, typeOf } from "nodefony";
5
7
  import { Http2Response, HttpResponse, WebsocketResponse } from "@nodefony/http";
6
- //#region nodefony/src/Resolver.ts
8
+ // nodefony/src/Resolver.ts
7
9
  /**
8
10
  * Résout une route vers son couple controller/action et exécute l'action —
9
11
  * UN Resolver est alloué par requête HTTP (et par connexion WS, réutilisé
@@ -46,7 +48,7 @@ var Resolver = class {
46
48
  /**
47
49
  * Query d'une invocation **par message** (pont WS-RPC `api.request`) — pendant
48
50
  * de `cleanPathOverride` pour le `?…` du path invoqué. Le contexte WS étant
49
- * PARTAGÉ par la connexion (sa `queryGet` = celle du handshake), la query
51
+ * PARTAGÉ par la connexion (sa `query` = celle du handshake), la query
50
52
  * per-invocation vit ici (le Resolver est per-invocation → zéro bleed entre
51
53
  * requêtes concurrentes d'une même socket). `null` (hot path HTTP) = ignoré.
52
54
  * Consommé par `@Query` (`_buildParamArgs`) et copié sur le controller
@@ -74,6 +76,11 @@ var Resolver = class {
74
76
  * administrateurs.
75
77
  */
76
78
  messageInvocation = false;
79
+ /**
80
+ * Les gardes de l'action ont été jugées favorables par {@link authorizeAction}
81
+ * et ne se rejugent pas à la prochaine exécution — consommé par elle.
82
+ */
83
+ preAuthorized = false;
77
84
  constructor(context) {
78
85
  this.context = context;
79
86
  this.injector = context.container?.get("injector") ?? null;
@@ -83,7 +90,7 @@ var Resolver = class {
83
90
  if (match) {
84
91
  this.variables = match;
85
92
  this.route = route;
86
- this.controller = route.controller;
93
+ this.controller = route.controller ?? null;
87
94
  this.actionName = route.classMethod;
88
95
  this.resolve = true;
89
96
  this.bypassFirewall = this.route.bypassFirewall;
@@ -180,10 +187,20 @@ var Resolver = class {
180
187
  * `instanceof` au message suivant — le hub était RÉINSTANCIÉ (DI +
181
188
  * `initialize()`) à chaque frame qui suivait un `api.request`.
182
189
  *
190
+ * Ni pour un `forward` sur une connexion WebSocket : son résolveur est neuf,
191
+ * pas une invocation par message, et réécrivait le pointeur que la poignée
192
+ * de main a posé — même réinstanciation à la frame suivante (#574). Une
193
+ * connexion garde le contrôleur qui l'a ouverte. Chemin froid : seulement
194
+ * quand un contrôleur est créé.
195
+ *
183
196
  * @returns le controller reçu, pour enchaîner.
184
197
  */
185
198
  _pinController(controller) {
186
- if (!this.messageInvocation) this.context.container?.set("controller", controller);
199
+ if (this.messageInvocation) return controller;
200
+ const container = this.context.container;
201
+ if (!container) return controller;
202
+ if (this.context.method === "WEBSOCKET" && container.get("controller") !== null) return controller;
203
+ container.set("controller", controller);
187
204
  return controller;
188
205
  }
189
206
  /**
@@ -220,7 +237,7 @@ var Resolver = class {
220
237
  * @returns le controller reçu, pour enchaîner.
221
238
  */
222
239
  _guardSingleton(controller, ctx) {
223
- if (this.controller.scope === "singleton" && isDevelopment(ctx.kernel?.environment)) guardSingletonState(controller, controller.constructor.name);
240
+ if (this.controller?.scope === "singleton" && isDevelopment(ctx.kernel?.environment)) guardSingletonState(controller, controller.constructor.name);
224
241
  return controller;
225
242
  }
226
243
  /**
@@ -245,9 +262,46 @@ var Resolver = class {
245
262
  */
246
263
  executeAction(data, reload = false, metaArg) {
247
264
  const meta = metaArg ?? (this.route ? resolveActionMeta(this.route) : computeActionMeta(this.controller, this.actionName));
265
+ let guarded;
266
+ if (this.preAuthorized) this.preAuthorized = false;
267
+ else guarded = this._enforceActionSecurity(meta);
268
+ const schema = meta.schema;
269
+ if (schema !== null) {
270
+ if (guarded !== void 0) return guarded.then(() => this._validateAndInvoke(schema, meta, data, reload));
271
+ return this._validateAndInvoke(schema, meta, data, reload);
272
+ }
273
+ if (guarded !== void 0) return guarded.then(() => this._acquireAndInvoke(meta, data, reload, null));
274
+ return this._acquireAndInvoke(meta, data, reload, null);
275
+ }
276
+ /**
277
+ * Valide l'entrée d'une action à schéma, puis l'exécute avec cette entrée
278
+ * pour argument unique. Synchrone tant que le validateur l'est.
279
+ *
280
+ * @throws SchemaValidationError (422) quand l'entrée viole le schéma.
281
+ */
282
+ _validateAndInvoke(schema, meta, data, reload) {
283
+ const validated = validateRouteInput(schema, readRouteInput(this._zipRouteParams(), this.context.request, this.queryOverride ?? void 0, meta.bodyStream));
284
+ if (validated instanceof Promise) return validated.then((input) => this._acquireAndInvoke(meta, data, reload, input));
285
+ return this._acquireAndInvoke(meta, data, reload, validated);
286
+ }
287
+ /**
288
+ * Juge les gardes de l'action résolue SANS l'exécuter — appelé par le
289
+ * pipeline WebSocket AVANT d'accepter le handshake (#596), pour qu'un
290
+ * connecté sans le rôle reçoive son 403 en statut HTTP au lieu d'une
291
+ * ouverture suivie d'une fermeture. Un verdict favorable vaut pour la
292
+ * PROCHAINE exécution (l'action de handshake), qui ne le rejuge pas ; les
293
+ * messages suivants, eux, sont jugés chacun.
294
+ *
295
+ * @returns `undefined` quand l'action n'est pas gardée ; sinon la promesse du
296
+ * verdict, rompue par un refus
297
+ */
298
+ authorizeAction() {
299
+ const meta = this.route ? resolveActionMeta(this.route) : computeActionMeta(this.controller, this.actionName);
248
300
  const guarded = this._enforceActionSecurity(meta);
249
- if (guarded !== void 0) return guarded.then(() => this._acquireAndInvoke(meta, data, reload));
250
- return this._acquireAndInvoke(meta, data, reload);
301
+ if (guarded === void 0) return void 0;
302
+ return guarded.then(() => {
303
+ this.preAuthorized = true;
304
+ });
251
305
  }
252
306
  /**
253
307
  * Les gardes d'autorisation d'une action : `@IsGranted`/`@RequireScope`, puis
@@ -263,6 +317,7 @@ var Resolver = class {
263
317
  * lookup, ni attente) ; sinon la promesse du verdict, rompue par un refus.
264
318
  */
265
319
  _enforceActionSecurity(meta) {
320
+ if (meta.authentication !== null) return this._enforceStepUp(meta);
266
321
  if (meta.security === null) return this._enforceAreaSecurity();
267
322
  const granted = this._enforceSecurity(meta.security);
268
323
  return meta.security.hasRoleClause ? granted : granted.then(() => this._enforceAreaSecurity());
@@ -284,10 +339,10 @@ var Resolver = class {
284
339
  * c'est la bonne classe, sinon une création —, puis l'action elle-même.
285
340
  * N'attend que la création, et seulement quand elle attend.
286
341
  */
287
- _acquireAndInvoke(meta, data, reload) {
342
+ _acquireAndInvoke(meta, data, reload, input) {
288
343
  const pinned = this.context.container?.get("controller");
289
- if (!pinned || reload || this.controller && !(pinned instanceof this.controller)) return thenMaybe(this.newController(), (controller) => this._invokeAction(controller, meta, data));
290
- return this._invokeAction(pinned, meta, data);
344
+ if (!pinned || reload || this.controller && !(pinned instanceof this.controller)) return thenMaybe(this.newController(), (controller) => this._invokeAction(controller, meta, data, input));
345
+ return this._invokeAction(pinned, meta, data, input);
291
346
  }
292
347
  /**
293
348
  * Appelle l'action sur l'instance : état de requête (portée `request`
@@ -296,17 +351,15 @@ var Resolver = class {
296
351
  *
297
352
  * @throws de façon SYNCHRONE ce que lève l'action, ou l'absence d'action.
298
353
  */
299
- _invokeAction(controller, meta, data) {
354
+ _invokeAction(controller, meta, data, input) {
300
355
  if (this.controller?.scope !== "singleton") {
301
356
  controller.setRoute(this.route);
302
- if (this.queryOverride !== null) {
303
- controller.queryGet = this.queryOverride;
304
- controller.query = this.queryOverride;
305
- }
357
+ if (this.queryOverride !== null) controller.query = this.queryOverride;
306
358
  }
307
359
  const methodKey = this.actionName;
308
360
  let args;
309
- if (meta.paramsMeta) args = this._buildParamArgs(meta.paramsMeta);
361
+ if (input !== null) args = [input];
362
+ else if (meta.paramsMeta) args = this._buildParamArgs(meta.paramsMeta);
310
363
  else if (data) args = [...this.variables, ...data];
311
364
  else args = [...this.variables];
312
365
  this._applyResponseMeta(meta);
@@ -381,18 +434,18 @@ var Resolver = class {
381
434
  const als = RequestContext.get();
382
435
  const httpReq = context.request;
383
436
  const params = this._zipRouteParams();
384
- const body = als?.body !== void 0 ? als.body : httpReq?.queryPost ?? null;
437
+ const body = readInvocationBody(httpReq) ?? null;
385
438
  const store = context.container?.get("idempotencyStore");
386
439
  const verdict = await evaluateIdempotency({
387
440
  store,
388
- identity: resolveIdentity(als?.user),
441
+ identity: resolveIdentity(),
389
442
  clientKey: resolveIdempotencyKey(als?.idempotencyKey, httpReq?.headers?.["idempotency-key"]),
390
443
  fingerprint: computeFingerprint([
391
444
  this.route?.name ?? this.actionName,
392
445
  params,
393
446
  body
394
447
  ]),
395
- isWs: Boolean(context.type?.startsWith("websocket")),
448
+ isWs: context.type === "websocket",
396
449
  required: meta.idempotent?.required ?? true
397
450
  });
398
451
  if (verdict.kind === "reject") throw new nodefonyError(verdict.message, verdict.status);
@@ -526,16 +579,14 @@ var Resolver = class {
526
579
  return idx === -1 ? void 0 : this.variables[idx];
527
580
  }
528
581
  _buildParamArgs(metas) {
529
- const httpCtx = this.context;
530
- const paramsMap = this._zipRouteParams();
531
- const ctx = this.context;
582
+ const context = this.context;
532
583
  return buildParamArgs(metas, {
533
- paramsMap,
534
- request: httpCtx.request,
535
- response: httpCtx.response,
536
- session: ctx.session,
584
+ paramsMap: this._zipRouteParams(),
585
+ request: context.request,
586
+ response: context.response,
587
+ session: context.session,
537
588
  queryOverride: this.queryOverride ?? void 0,
538
- getRequestCookies: (name) => ctx.getRequestCookies(name)
589
+ getRequestCookies: (name) => context.getRequestCookies(name)
539
590
  });
540
591
  }
541
592
  /**
@@ -617,17 +668,11 @@ var Resolver = class {
617
668
  return this.context.render(result);
618
669
  }
619
670
  switch (this.context.type) {
620
- case "http":
621
- case "http2":
622
- case "http3":
623
- case "https": this.context.waitAsync = true;
671
+ case "http": this.context.waitAsync = true;
624
672
  }
625
673
  return;
626
674
  default: switch (this.context.type) {
627
675
  case "http":
628
- case "http2":
629
- case "http3":
630
- case "https":
631
676
  if (this.context.sended) return;
632
677
  if (this.context.isRedirect) return this.context.send();
633
678
  if (NO_BODY_STATUS.has(this.getResponseStatus())) return this.context.send();
@@ -639,6 +684,31 @@ var Resolver = class {
639
684
  getResponseStatus() {
640
685
  return this.context.response?.statusCode ?? 0;
641
686
  }
687
+ /**
688
+ * `@RequireAuthentication` — les gardes d'autorisation d'abord (un rôle
689
+ * absent reste un 403 définitif : inutile de faire redemander une connexion à
690
+ * qui n'aura pas le droit), PUIS l'âge et les moyens de la connexion.
691
+ *
692
+ * Le refus est un 401 « step-up » (RFC 9470) : le `WWW-Authenticate` porte
693
+ * `error="insufficient_user_authentication"` et, quand la route fixe un âge,
694
+ * `max_age` — le client redemande une connexion au lieu de conclure à un
695
+ * accès fermé. Cold path : seules les routes décorées y passent.
696
+ *
697
+ * @throws nodefonyError 401 si la connexion est trop ancienne ou trop faible.
698
+ */
699
+ async _enforceStepUp(meta) {
700
+ const requirement = meta.authentication;
701
+ if (requirement === null) return;
702
+ await this._enforceActionSecurity({
703
+ ...meta,
704
+ authentication: null
705
+ });
706
+ const cause = authenticationShortfall(this.context.session ?? null, requirement, Date.now());
707
+ if (cause === null) return;
708
+ const response = this.context.response;
709
+ if (response !== null && "setHeader" in response) response.setHeader("WWW-Authenticate", stepUpChallenge(requirement));
710
+ throw new nodefonyError(identityHint(this.context.kernel) === null ? "Authentication required" : `Authentication required — ${cause}. Reconnectez-vous (\`@RequireAuthentication\` de la route).`, 401);
711
+ }
642
712
  };
643
713
  /** Statuts dont la réponse n'a, par définition, pas de corps (RFC 9110). */
644
714
  const NO_BODY_STATUS = /* @__PURE__ */ new Set([
@@ -646,5 +716,4 @@ const NO_BODY_STATUS = /* @__PURE__ */ new Set([
646
716
  205,
647
717
  304
648
718
  ]);
649
- //#endregion
650
719
  export { Resolver as default };