@nodefony/framework 10.0.0-beta.3 → 10.0.0-beta.5

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 (66) hide show
  1. package/README.md +19 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/index.js +9 -18
  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 +39 -8
  7. package/dist/nodefony/controller/ApiKeyController.js +16 -8
  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 +4 -11
  11. package/dist/nodefony/controller/OAuth2Controller.js +3 -4
  12. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -2
  13. package/dist/nodefony/controller/SessionAuthController.js +13 -4
  14. package/dist/nodefony/controller/TokenAuthController.js +3 -4
  15. package/dist/nodefony/controller/TotpController.js +2 -3
  16. package/dist/nodefony/controller/WebAuthnController.js +2 -3
  17. package/dist/nodefony/controller/oauthAuthority.js +1 -2
  18. package/dist/nodefony/decorators/routerDecorators.js +155 -53
  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 +49 -15
  23. package/dist/nodefony/src/Controller.js +32 -39
  24. package/dist/nodefony/src/FrameworkAdminApi.js +31 -10
  25. package/dist/nodefony/src/KernelAdminApi.js +7 -9
  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 +68 -33
  29. package/dist/nodefony/src/ResourceController.js +13 -6
  30. package/dist/nodefony/src/Route.js +2 -3
  31. package/dist/nodefony/src/SecurityApiController.js +1 -2
  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 +1 -2
  36. package/dist/nodefony/src/idempotency.js +1 -2
  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 +2 -3
  40. package/dist/nodefony/src/requireAuthentication.js +1 -2
  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 +7 -2
  45. package/dist/types/index.d.ts +5 -10
  46. package/dist/types/nodefony/controller/AdminApiController.d.ts +13 -1
  47. package/dist/types/nodefony/controller/SessionAuthController.d.ts +7 -0
  48. package/dist/types/nodefony/decorators/routerDecorators.d.ts +133 -32
  49. package/dist/types/nodefony/interfaces/IController.d.ts +2 -3
  50. package/dist/types/nodefony/service/router.d.ts +27 -1
  51. package/dist/types/nodefony/src/Controller.d.ts +23 -17
  52. package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +12 -0
  53. package/dist/types/nodefony/src/Resolver.d.ts +33 -1
  54. package/dist/types/nodefony/src/ResourceController.d.ts +12 -4
  55. package/dist/types/nodefony/src/Route.d.ts +3 -0
  56. package/dist/types/nodefony/src/loginPage.d.ts +1 -1
  57. package/dist/types/nodefony/src/routeSchema.d.ts +112 -0
  58. package/dist/types/nodefony/src/syslogFilters.d.ts +6 -0
  59. package/docs/admin.md +131 -82
  60. package/docs/controller.md +109 -110
  61. package/docs/decorateurs.md +214 -161
  62. package/docs/idempotence.md +154 -136
  63. package/docs/routing.md +105 -90
  64. package/docs/templates.md +55 -55
  65. package/package.json +8 -11
  66. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
@@ -1,7 +1,7 @@
1
1
  import router_default from "../service/router.js";
2
2
  import { createPlaygroundEndpoints } from "./PlaygroundAdminApi.js";
3
- import { parseFilters, parsePageQuery } from "nodefony";
4
- //#region nodefony/src/FrameworkAdminApi.ts
3
+ import { PageQueryError, parseFilters, parsePageQuery } from "nodefony";
4
+ // nodefony/src/FrameworkAdminApi.ts
5
5
  /**
6
6
  * Capacités de page d'un endpoint, ÉVALUÉES sous filet.
7
7
  *
@@ -29,6 +29,34 @@ function readPageCapabilities(caps) {
29
29
  }
30
30
  }
31
31
  /**
32
+ * Lit le paramètre `filters` de `routes/page` : un tableau JSON de
33
+ * `{ key, op, value }`. Absent → aucun filtre.
34
+ *
35
+ * @throws PageQueryError (400) si le JSON est cassé ou d'une autre forme — un
36
+ * filtre ignoré rendrait TOUTES les routes, lues comme son résultat.
37
+ */
38
+ function parseRouteFilters(raw) {
39
+ if (!raw) return [];
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(raw);
43
+ } catch {
44
+ throw new PageQueryError(`Invalid "filters": not JSON.`);
45
+ }
46
+ if (!Array.isArray(parsed)) throw new PageQueryError(`Invalid "filters": expected a JSON array.`);
47
+ const entries = parsed;
48
+ const filters = [];
49
+ for (const item of entries) {
50
+ if (typeof item !== "object" || item === null || !("key" in item) || !("op" in item) || typeof item.key !== "string" || typeof item.op !== "string") throw new PageQueryError(`Invalid "filters": each entry needs a string "key" and "op".`);
51
+ filters.push({
52
+ key: item.key,
53
+ op: item.op,
54
+ value: "value" in item ? item.value : void 0
55
+ });
56
+ }
57
+ return filters;
58
+ }
59
+ /**
32
60
  * Producteur `IAdminApi` du module **framework** — exposé sous
33
61
  * `/nodefony/framework/api/*`.
34
62
  *
@@ -205,13 +233,7 @@ function createFrameworkAdminApi(broker, opts) {
205
233
  });
206
234
  parseFilters(request.query, {}, { accepts: ["filters"] });
207
235
  const search = query.q?.toLowerCase() ?? "";
208
- let filters = [];
209
- try {
210
- const raw = one(request.query.filters);
211
- if (raw) filters = JSON.parse(raw);
212
- } catch {
213
- filters = [];
214
- }
236
+ const filters = parseRouteFilters(one(request.query.filters));
215
237
  let rows = router_default.routes.map(serializeRoute);
216
238
  if (search) rows = rows.filter((r) => matchesSearch(r, search));
217
239
  for (const f of filters) rows = rows.filter((r) => matchOp(cell(r, f.key), f.op, f.value));
@@ -297,5 +319,4 @@ function createFrameworkAdminApi(broker, opts) {
297
319
  adminEndpoints: () => endpoints
298
320
  };
299
321
  }
300
- //#endregion
301
322
  export { createFrameworkAdminApi };
@@ -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, collectEnvShortcuts, computeConfigProvenance, defaultAppConfig, extractJsonSchemaDefaults, extractMarkdownSection, flattenConfigSchema, getActiveLogDriver, isFrameworkRepository, listLogDrivers, outlineMarkdown, parseNfEnvOverrides, parseStoreManifest, readResolvedPath, resumeSupervisor, runAppContributions, suspendSupervisor, 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]";
@@ -441,7 +441,6 @@ function createKernelAdminApi(kernel) {
441
441
  const devGuard = () => kernel.environment === "development" || Boolean(kernel.debug);
442
442
  const repoRoot = process.cwd();
443
443
  const relPath = (p) => p?.startsWith(repoRoot) ? p.slice(repoRoot.length).replace(/^[/\\]+/, "") || "." : p ?? null;
444
- const redactInfraUrl = (url) => url.replace(/\/\/[^/@]*@/, "//***@");
445
444
  const buildConfigEntries = () => {
446
445
  const modules = kernel.getModules();
447
446
  const entries = [];
@@ -736,8 +735,8 @@ function createKernelAdminApi(kernel) {
736
735
  handler: () => {
737
736
  const infra = kernel.infra;
738
737
  const entriesBySeg = new Map(buildConfigEntries().map((e) => [e.seg, e]));
739
- const dbUrl = infra.database ? redactInfraUrl(infra.database.url) : null;
740
- 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;
741
740
  const storeEndpoint = (res) => {
742
741
  if (res.location) return void 0;
743
742
  if (res.resolved === "redis") return cacheUrl ?? void 0;
@@ -776,8 +775,8 @@ function createKernelAdminApi(kernel) {
776
775
  } : null,
777
776
  cache: infra.cache ? { url: cacheUrl } : null,
778
777
  logs: infra.logs ? {
779
- lokiUrl: infra.logs.lokiUrl ? redactInfraUrl(infra.logs.lokiUrl) : null,
780
- 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
781
780
  } : null
782
781
  },
783
782
  stores: kernel.storeResolutions.map((res) => {
@@ -1367,5 +1366,4 @@ function createKernelAdminApi(kernel) {
1367
1366
  adminEndpoints: () => endpoints
1368
1367
  };
1369
1368
  }
1370
- //#endregion
1371
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,10 +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";
4
5
  import { authenticationShortfall, stepUpChallenge } from "./requireAuthentication.js";
5
6
  import { RequestContext, finallyMaybe, identityHint, isArray, isPlainObject, isPromise, nodefonyError, thenMaybe, typeOf } from "nodefony";
6
7
  import { Http2Response, HttpResponse, WebsocketResponse } from "@nodefony/http";
7
- //#region nodefony/src/Resolver.ts
8
+ // nodefony/src/Resolver.ts
8
9
  /**
9
10
  * Résout une route vers son couple controller/action et exécute l'action —
10
11
  * UN Resolver est alloué par requête HTTP (et par connexion WS, réutilisé
@@ -47,7 +48,7 @@ var Resolver = class {
47
48
  /**
48
49
  * Query d'une invocation **par message** (pont WS-RPC `api.request`) — pendant
49
50
  * de `cleanPathOverride` pour le `?…` du path invoqué. Le contexte WS étant
50
- * PARTAGÉ par la connexion (sa `queryGet` = celle du handshake), la query
51
+ * PARTAGÉ par la connexion (sa `query` = celle du handshake), la query
51
52
  * per-invocation vit ici (le Resolver est per-invocation → zéro bleed entre
52
53
  * requêtes concurrentes d'une même socket). `null` (hot path HTTP) = ignoré.
53
54
  * Consommé par `@Query` (`_buildParamArgs`) et copié sur le controller
@@ -75,6 +76,11 @@ var Resolver = class {
75
76
  * administrateurs.
76
77
  */
77
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;
78
84
  constructor(context) {
79
85
  this.context = context;
80
86
  this.injector = context.container?.get("injector") ?? null;
@@ -84,7 +90,7 @@ var Resolver = class {
84
90
  if (match) {
85
91
  this.variables = match;
86
92
  this.route = route;
87
- this.controller = route.controller;
93
+ this.controller = route.controller ?? null;
88
94
  this.actionName = route.classMethod;
89
95
  this.resolve = true;
90
96
  this.bypassFirewall = this.route.bypassFirewall;
@@ -231,7 +237,7 @@ var Resolver = class {
231
237
  * @returns le controller reçu, pour enchaîner.
232
238
  */
233
239
  _guardSingleton(controller, ctx) {
234
- 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);
235
241
  return controller;
236
242
  }
237
243
  /**
@@ -256,9 +262,46 @@ var Resolver = class {
256
262
  */
257
263
  executeAction(data, reload = false, metaArg) {
258
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);
259
300
  const guarded = this._enforceActionSecurity(meta);
260
- if (guarded !== void 0) return guarded.then(() => this._acquireAndInvoke(meta, data, reload));
261
- return this._acquireAndInvoke(meta, data, reload);
301
+ if (guarded === void 0) return void 0;
302
+ return guarded.then(() => {
303
+ this.preAuthorized = true;
304
+ });
262
305
  }
263
306
  /**
264
307
  * Les gardes d'autorisation d'une action : `@IsGranted`/`@RequireScope`, puis
@@ -296,10 +339,10 @@ var Resolver = class {
296
339
  * c'est la bonne classe, sinon une création —, puis l'action elle-même.
297
340
  * N'attend que la création, et seulement quand elle attend.
298
341
  */
299
- _acquireAndInvoke(meta, data, reload) {
342
+ _acquireAndInvoke(meta, data, reload, input) {
300
343
  const pinned = this.context.container?.get("controller");
301
- if (!pinned || reload || this.controller && !(pinned instanceof this.controller)) return thenMaybe(this.newController(), (controller) => this._invokeAction(controller, meta, data));
302
- 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);
303
346
  }
304
347
  /**
305
348
  * Appelle l'action sur l'instance : état de requête (portée `request`
@@ -308,17 +351,15 @@ var Resolver = class {
308
351
  *
309
352
  * @throws de façon SYNCHRONE ce que lève l'action, ou l'absence d'action.
310
353
  */
311
- _invokeAction(controller, meta, data) {
354
+ _invokeAction(controller, meta, data, input) {
312
355
  if (this.controller?.scope !== "singleton") {
313
356
  controller.setRoute(this.route);
314
- if (this.queryOverride !== null) {
315
- controller.queryGet = this.queryOverride;
316
- controller.query = this.queryOverride;
317
- }
357
+ if (this.queryOverride !== null) controller.query = this.queryOverride;
318
358
  }
319
359
  const methodKey = this.actionName;
320
360
  let args;
321
- if (meta.paramsMeta) args = this._buildParamArgs(meta.paramsMeta);
361
+ if (input !== null) args = [input];
362
+ else if (meta.paramsMeta) args = this._buildParamArgs(meta.paramsMeta);
322
363
  else if (data) args = [...this.variables, ...data];
323
364
  else args = [...this.variables];
324
365
  this._applyResponseMeta(meta);
@@ -393,7 +434,7 @@ var Resolver = class {
393
434
  const als = RequestContext.get();
394
435
  const httpReq = context.request;
395
436
  const params = this._zipRouteParams();
396
- const body = als?.body !== void 0 ? als.body : httpReq?.queryPost ?? null;
437
+ const body = readInvocationBody(httpReq) ?? null;
397
438
  const store = context.container?.get("idempotencyStore");
398
439
  const verdict = await evaluateIdempotency({
399
440
  store,
@@ -404,7 +445,7 @@ var Resolver = class {
404
445
  params,
405
446
  body
406
447
  ]),
407
- isWs: Boolean(context.type?.startsWith("websocket")),
448
+ isWs: context.type === "websocket",
408
449
  required: meta.idempotent?.required ?? true
409
450
  });
410
451
  if (verdict.kind === "reject") throw new nodefonyError(verdict.message, verdict.status);
@@ -538,16 +579,14 @@ var Resolver = class {
538
579
  return idx === -1 ? void 0 : this.variables[idx];
539
580
  }
540
581
  _buildParamArgs(metas) {
541
- const httpCtx = this.context;
542
- const paramsMap = this._zipRouteParams();
543
- const ctx = this.context;
582
+ const context = this.context;
544
583
  return buildParamArgs(metas, {
545
- paramsMap,
546
- request: httpCtx.request,
547
- response: httpCtx.response,
548
- session: ctx.session,
584
+ paramsMap: this._zipRouteParams(),
585
+ request: context.request,
586
+ response: context.response,
587
+ session: context.session,
549
588
  queryOverride: this.queryOverride ?? void 0,
550
- getRequestCookies: (name) => ctx.getRequestCookies(name)
589
+ getRequestCookies: (name) => context.getRequestCookies(name)
551
590
  });
552
591
  }
553
592
  /**
@@ -630,20 +669,17 @@ var Resolver = class {
630
669
  }
631
670
  switch (this.context.type) {
632
671
  case "http":
633
- case "http2":
634
- case "http3":
635
- case "https": this.context.waitAsync = true;
672
+ this.context.waitAsync = true;
673
+ this.unrendered = `une instance de ${(typeof result === "object" && result !== null ? result.constructor.name : void 0) ?? "classe"}`;
636
674
  }
637
675
  return;
638
676
  default: switch (this.context.type) {
639
677
  case "http":
640
- case "http2":
641
- case "http3":
642
- case "https":
643
678
  if (this.context.sended) return;
644
679
  if (this.context.isRedirect) return this.context.send();
645
680
  if (NO_BODY_STATUS.has(this.getResponseStatus())) return this.context.send();
646
681
  this.context.waitAsync = true;
682
+ this.unrendered = result === null ? "null" : typeof result;
647
683
  }
648
684
  }
649
685
  }
@@ -683,5 +719,4 @@ const NO_BODY_STATUS = /* @__PURE__ */ new Set([
683
719
  205,
684
720
  304
685
721
  ]);
686
- //#endregion
687
722
  export { Resolver as default };
@@ -1,9 +1,9 @@
1
1
  import Controller from "./Controller.js";
2
2
  import { assertPageQuery } from "nodefony";
3
3
  import { HttpError } from "@nodefony/http";
4
- //#region nodefony/src/ResourceController.ts
4
+ // nodefony/src/ResourceController.ts
5
5
  /**
6
- * Controller de ressource **souverain** (V4.2 — POC API souveraine, Phase 2) :
6
+ * Controller de ressource **duplex** (V4.2 — une action, deux transports) :
7
7
  * la logique métier est écrite UNE fois dans le service de ressource, les
8
8
  * actions de la sous-classe ne sont que des portes (REST, WS-RPC `invoke`,
9
9
  * GraphQL à venir) qui retournent la **valeur brute** — `returnController`
@@ -22,7 +22,7 @@ import { HttpError } from "@nodefony/http";
22
22
  * sur `this` doit rétrograder : `static scope = "request"`.
23
23
  *
24
24
  * Sécurité : aucun critère de requête n'est passé AUTOMATIQUEMENT au service
25
- * (pas de `find(this.queryGet)` implicite) — exposer un filtrage est une
25
+ * (pas de `find(this.query)` implicite) — exposer un filtrage est une
26
26
  * décision EXPLICITE de la sous-classe (deny-by-default ; le scope de
27
27
  * sécurité des données se branche au niveau service/criteria, P6).
28
28
  *
@@ -38,8 +38,12 @@ import { HttpError } from "@nodefony/http";
38
38
  * return this.listResource();
39
39
  * }
40
40
  * \@route("books-get", { path: "/{id}", requirements: { methods: ["GET", "WEBSOCKET"] } })
41
- * detail(\@Param("id") id: string) {
42
- * return this.getResource(id);
41
+ * async detail(\@Param("id") id: string) {
42
+ * // `null` rendu tel quel = « l'action répond elle-même » : la requête
43
+ * // pendrait jusqu'au délai d'expiration. La porte décide du 404.
44
+ * const found = await this.getResource(id);
45
+ * if (!found) throw new HttpError(`Book ${id} not found`, 404);
46
+ * return found;
43
47
  * }
44
48
  * }
45
49
  * ```
@@ -117,6 +121,10 @@ var ResourceController = class extends Controller {
117
121
  /**
118
122
  * Lit une entité par id — `null` si absente (la porte décide du 404).
119
123
  *
124
+ * ⚠️ Ne pas rendre ce `null` tel quel depuis une action : pour le Resolver,
125
+ * `null` veut dire « l'action répond elle-même », et la requête attend
126
+ * alors son délai d'expiration. Lever un `HttpError` 404.
127
+ *
120
128
  * `options.relations` charge les associations dans la foulée. La porte doit
121
129
  * n'y laisser passer que des relations qu'elle a DÉCLARÉES : un `include`
122
130
  * libre laisse le client nommer n'importe quelle association, donc lire des
@@ -144,5 +152,4 @@ var ResourceController = class extends Controller {
144
152
  return Promise.resolve(resource.delete(criteria));
145
153
  }
146
154
  };
147
- //#endregion
148
155
  export { ResourceController as default };
@@ -1,7 +1,7 @@
1
1
  import { escapeRegExp, stripTrailingSlashes, typeOf } from "nodefony";
2
2
  import { HttpError, compileDomainPatterns, isDomainAllowed } from "@nodefony/http";
3
3
  import { createHash } from "node:crypto";
4
- //#region nodefony/src/Route.ts
4
+ // nodefony/src/Route.ts
5
5
  /**
6
6
  * Motif d'un segment variable : `{id}`, `/{slug}?`, `.{format}`, `{id}(\d+)`.
7
7
  *
@@ -266,7 +266,7 @@ var Route = class Route {
266
266
  const k = this.variables[i] || "wildcard";
267
267
  param &&= decode(param);
268
268
  const req = this.getRequirement(k);
269
- let result = null;
269
+ let result;
270
270
  if (req) {
271
271
  let compiled = this.varRegexp?.[k];
272
272
  if (!compiled) {
@@ -549,5 +549,4 @@ var Route = class Route {
549
549
  return true;
550
550
  }
551
551
  };
552
- //#endregion
553
552
  export { Route as default };
@@ -1,5 +1,5 @@
1
1
  import Controller from "./Controller.js";
2
- //#region nodefony/src/SecurityApiController.ts
2
+ // nodefony/src/SecurityApiController.ts
3
3
  const NO_STORE = { "cache-control": "no-store" };
4
4
  /**
5
5
  * Base des contrôleurs d'authentification (`/nodefony/security/api/*`) : toute
@@ -20,5 +20,4 @@ var SecurityApiController = class extends Controller {
20
20
  });
21
21
  }
22
22
  };
23
- //#endregion
24
23
  export { SecurityApiController as default };
@@ -2,7 +2,7 @@ import { SYSLOG_FILTERS, SYSLOG_SORTABLE } from "./syslogFilters.js";
2
2
  import path from "node:path";
3
3
  import { Syslog, getActiveLogDriver, getLogDriver, listLogDrivers, parseFilters, parsePageQuery, redactSecrets, setActiveLogDriver } from "nodefony";
4
4
  import fsp from "node:fs/promises";
5
- //#region nodefony/src/SyslogAdminApi.ts
5
+ // nodefony/src/SyslogAdminApi.ts
6
6
  /** Plafond d'octets lus en queue de fichier (tail / fenêtre incrémentale). */
7
7
  const MAX_TAIL_BYTES = 262144;
8
8
  /**
@@ -462,5 +462,4 @@ function createSyslogAdminApi(syslog, options = {}) {
462
462
  adminEndpoints: () => endpoints
463
463
  };
464
464
  }
465
- //#endregion
466
465
  export { createSyslogAdminApi };
@@ -1,5 +1,5 @@
1
1
  import { Service } from "nodefony";
2
- //#region nodefony/src/Template.ts
2
+ // nodefony/src/Template.ts
3
3
  var Template = class extends Service {
4
4
  engine;
5
5
  module;
@@ -11,5 +11,4 @@ var Template = class extends Service {
11
11
  this.cache = module.kernel?.environment === "prod" || module.kernel?.environment === "production";
12
12
  }
13
13
  };
14
- //#endregion
15
14
  export { Template as default };
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/configMutation.ts
1
+ // nodefony/src/configMutation.ts
2
2
  /** Narrowing défensif : `unknown` → nœud JSON Schema (ou `null` si non-objet). */
3
3
  function asNode(value) {
4
4
  if (value !== null && typeof value === "object" && !Array.isArray(value)) return value;
@@ -182,5 +182,4 @@ function getResolvedPath(obj, segments) {
182
182
  }
183
183
  return cur;
184
184
  }
185
- //#endregion
186
185
  export { getResolvedPath, navigateSchemaNode, nodeFlags, notEditableReason, recipeFor, validateLeafValue };
@@ -5,7 +5,7 @@ import { promisify } from "node:util";
5
5
  import { readFile, readdir, stat } from "node:fs/promises";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { execFile, spawn } from "node:child_process";
8
- //#region nodefony/src/docsReader.ts
8
+ // nodefony/src/docsReader.ts
9
9
  const execFileAsync = promisify(execFile);
10
10
  /**
11
11
  * Racine de l'APPLICATION servie — jamais le dossier courant du process.
@@ -952,5 +952,4 @@ async function readCoreInfo() {
952
952
  dependencies
953
953
  };
954
954
  }
955
- //#endregion
956
955
  export { CORE_PACKAGE, checkOutdated, countModuleDocs, listModuleDocs, listModuleSymbols, listTestFiles, listTestGroups, parseFrontmatter, readCoreInfo, readCoverage, readDependencies, readModuleDoc, readSymbolDeclaration, resolveCorePath, runModuleTests, searchModuleDocs, testRunCommand };
@@ -1,6 +1,6 @@
1
1
  import { RequestContext } from "nodefony";
2
2
  import { createHash } from "node:crypto";
3
- //#region nodefony/src/idempotency.ts
3
+ // nodefony/src/idempotency.ts
4
4
  /**
5
5
  * Logique **pure et partagée** de la porte d'idempotence des mutations, conforme à
6
6
  * `draft-ietf-httpapi-idempotency-key-header-06`. Source unique de la sémantique
@@ -126,5 +126,4 @@ async function evaluateIdempotency(opts) {
126
126
  };
127
127
  }
128
128
  }
129
- //#endregion
130
129
  export { IDEMPOTENCY_KEY_MAX, computeFingerprint, evaluateIdempotency, isMutationMethod, resolveIdempotencyKey, resolveIdentity };