@nodefony/framework 10.0.0-alpha.3 → 10.0.0-alpha.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.
package/README.md CHANGED
@@ -47,4 +47,4 @@ npm run test:integration # intégration (vitest, serveur dev requis : 5151/5152)
47
47
 
48
48
  ## Licence
49
49
 
50
- CeCILL-B — Christophe CAMENSULI.
50
+ Apache 2.0 — Christophe CAMENSULI.
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.148.0/helpers/esm/decorate.js
1
+ //#region \0@oxc-project+runtime@0.149.0/helpers/esm/decorate.js
2
2
  function __decorate(decorators, target, key, desc) {
3
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.148.0/helpers/esm/decorateMetadata.js
1
+ //#region \0@oxc-project+runtime@0.149.0/helpers/esm/decorateMetadata.js
2
2
  function __decorateMetadata(k, v) {
3
3
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
4
4
  }
package/dist/index.js CHANGED
@@ -7,8 +7,8 @@ import Route from "./nodefony/src/Route.js";
7
7
  import Controller from "./nodefony/src/Controller.js";
8
8
  import { All, Anonymous, Body, BypassFirewall, Cookie, Csp, CsrfExempt, CsrfProtect, CurrentUser, Delete, Domain, Get, Head, Header, Headers, HttpCode, Idempotent, IsGranted, Options, Param, Patch, Post, Put, Query, Redirect, Req, RequireScope, Res, Scope, Session, UploadedFile, UploadedFiles, UseSession, controller, controllers, route, routeExpectsBodyStream } from "./nodefony/decorators/routerDecorators.js";
9
9
  import Resolver from "./nodefony/src/Resolver.js";
10
- import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js";
11
- import __decorate from "./_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js";
10
+ import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorateMetadata.js";
11
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorate.js";
12
12
  import router_default from "./nodefony/service/router.js";
13
13
  import ResourceController from "./nodefony/src/ResourceController.js";
14
14
  import AdminApiController from "./nodefony/controller/AdminApiController.js";
@@ -29,7 +29,7 @@ import { createFrameworkAdminApi } from "./nodefony/src/FrameworkAdminApi.js";
29
29
  import { createSyslogAdminApi } from "./nodefony/src/SyslogAdminApi.js";
30
30
  import Eta from "./nodefony/service/Eta.js";
31
31
  import path from "node:path";
32
- import { AUTO_STORE, EMPTY_INFRA, Kernel, Module, readStoreLocation, resolveAutoStore, services } from "nodefony";
32
+ import { AUTO_STORE, EMPTY_INFRA, Kernel, Module, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices, services } from "nodefony";
33
33
  import { mergeResolvers, mergeTypeDefs } from "@graphql-tools/merge";
34
34
  import { makeExecutableSchema, mergeSchemas } from "@graphql-tools/schema";
35
35
  //#region index.ts
@@ -91,13 +91,13 @@ let Framework = class Framework extends Module {
91
91
  let name = configured;
92
92
  let reason = `store explicitement configuré ("${configured}")`;
93
93
  if (name === AUTO_STORE) {
94
- const auto = resolveAutoStore("ephemeral", this.kernel?.infra ?? EMPTY_INFRA, listIdempotencyBackends());
94
+ const auto = resolveAutoStore("ephemeral", this.kernel?.infra ?? EMPTY_INFRA, listIdempotencyBackends(), "memory", runNeedsExternalServices(this.kernel));
95
95
  name = auto.store;
96
96
  reason = auto.reason;
97
97
  this.log(`idempotency.store "auto" → "${name}" (${auto.reason})`, "INFO");
98
98
  }
99
99
  if (name === "memory") {
100
- if (this.kernel?.environment === "production") this.log("idempotency.store \"memory\" en PRODUCTION — déduplication per-pod uniquement : un rejeu routé vers un autre pod n'est pas dédupliqué (double-effet possible). Déclarer une infra partagée (NF_REDIS_URL ou NF_DATABASE_URL).", "WARNING");
100
+ if (this.kernel?.environment === "production") this.log("idempotency.store \"memory\" en PRODUCTION — déduplication per-pod uniquement : un rejeu routé vers un autre pod n'est pas dédupliqué (double-effet possible). " + durableStoreRemedy(runNeedsExternalServices(this.kernel)), "WARNING");
101
101
  this.kernel?.registerStoreResolution({
102
102
  brick: "idempotency",
103
103
  nature: "ephemeral",
@@ -30,13 +30,22 @@ var OAuth2Controller = class extends Controller {
30
30
  super("OAuth2Controller", context);
31
31
  }
32
32
  /**
33
- * Liste PUBLIQUE des fournisseurs activés (configurés ET connus du registre).
34
- * Consommé par l'UI de login pour n'afficher QUE les boutons opérationnels —
35
- * jamais de bouton mort. Aucun secret n'est exposé (uniquement les noms).
33
+ * Liste PUBLIQUE des fournisseurs à proposer à l'écran de connexion.
34
+ *
35
+ * Rend `{ name, label }` : le libellé vient du serveur, seul à connaître la
36
+ * configuration — un écran ne doit jamais avoir à deviner comment nommer un
37
+ * fournisseur, ni s'autoriser à en masquer un qu'il ne reconnaît pas. Un
38
+ * fournisseur déclaré `hidden` n'y figure pas, mais reste autorisable :
39
+ * masquer n'est pas désactiver. Aucun secret n'est exposé.
36
40
  */
37
41
  providers() {
38
42
  const svc = this.#service();
39
- return this.renderJson({ providers: svc ? svc.listProviders() : [] });
43
+ if (!svc) return this.renderJson({ providers: [] });
44
+ const providers = svc.listDisplayProviders ? svc.listDisplayProviders() : svc.listProviders().map((name) => ({
45
+ name,
46
+ label: name
47
+ }));
48
+ return this.renderJson({ providers });
40
49
  }
41
50
  /** Démarre le flux : URL d'autorisation + état anti-replay en session, 302. */
42
51
  async authorize(provider) {
@@ -1,5 +1,5 @@
1
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js";
2
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js";
1
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorateMetadata.js";
2
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorate.js";
3
3
  import router_default from "./router.js";
4
4
  import AdminApiController from "../controller/AdminApiController.js";
5
5
  import { ADMIN_DEFAULT_ROLE, Module, Service, injectable, resolveAdminRole } from "nodefony";
@@ -1,5 +1,5 @@
1
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js";
2
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js";
1
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorateMetadata.js";
2
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorate.js";
3
3
  import { Module, Service, assertPageQuery, injectable } from "nodefony";
4
4
  //#region nodefony/service/IdempotencyStore.ts
5
5
  const serviceName = "idempotencyStore";
@@ -1,8 +1,8 @@
1
1
  import Route from "../src/Route.js";
2
2
  import { routeExpectsBodyStream } from "../decorators/routerDecorators.js";
3
3
  import Resolver from "../src/Resolver.js";
4
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js";
5
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js";
4
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorateMetadata.js";
5
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorate.js";
6
6
  import { Module, Service, injectable } from "nodefony";
7
7
  import { HttpError, isDomainAllowed } from "@nodefony/http";
8
8
  //#region nodefony/service/router.ts
@@ -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, applyResolvedPath, collectDevStatus, computeConfigProvenance, defaultAppConfig, extractJsonSchemaDefaults, extractMarkdownSection, getActiveLogDriver, listLogDrivers, outlineMarkdown, parseNfEnvOverrides } from "nodefony";
5
+ import { GitService, Syslog, aggregateProvenance, applyResolvedPath, collectDevStatus, computeConfigProvenance, defaultAppConfig, extractJsonSchemaDefaults, extractMarkdownSection, flattenConfigSchema, getActiveLogDriver, listLogDrivers, outlineMarkdown, parseNfEnvOverrides, readResolvedPath } from "nodefony";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { existsSync, readFileSync } from "node:fs";
8
8
  //#region nodefony/src/KernelAdminApi.ts
@@ -617,6 +617,63 @@ function createKernelAdminApi(kernel) {
617
617
  summary: "Aggregated config of all modules (effective values redacted + JSON Schema + per-field provenance) for the global config page",
618
618
  handler: async () => ({ modules: buildConfigEntries() })
619
619
  },
620
+ {
621
+ path: "config/schema",
622
+ summary: "Assignable config keys of every module (dotted path, type, default, effective value, provenance, description) — the CATALOG, where `config` gives the STATE",
623
+ handler: (request) => {
624
+ const wanted = request.query.module;
625
+ const filter = typeof wanted === "string" ? wanted.toLowerCase() : null;
626
+ const rows = [];
627
+ const entries = buildConfigEntries();
628
+ const matches = (name, key) => name.toLowerCase() === filter || key.toLowerCase() === filter;
629
+ if (filter !== null) {
630
+ const entry = entries.find((e) => matches(e.name, e.key));
631
+ if (!entry || entry.configSchema == null) {
632
+ const modules = kernel.getModules();
633
+ const nameOf = (k) => modules[k].getModuleName?.() ?? k;
634
+ const loadedKey = Object.keys(modules).find((k) => matches(nameOf(k), k));
635
+ if (loadedKey !== void 0) {
636
+ const name = nameOf(loadedKey);
637
+ return {
638
+ status: 404,
639
+ body: {
640
+ error: "Schema not published",
641
+ module: name,
642
+ loaded: true,
643
+ hint: `Le module ${name} est chargé mais ne publie pas son schéma de configuration : ajouter dans son index.ts \`override configSchema(): unknown { return z.toJSONSchema(schema); }\` (schéma Zod de nodefony/config/config.ts). Sans lui, ses clés sont indécouvrables ici.`
644
+ }
645
+ };
646
+ }
647
+ return {
648
+ status: 404,
649
+ body: {
650
+ error: "Unknown module",
651
+ module: wanted,
652
+ available: entries.map((e) => e.name)
653
+ }
654
+ };
655
+ }
656
+ }
657
+ for (const entry of entries) {
658
+ if (filter !== null && entry.name.toLowerCase() !== filter && entry.key.toLowerCase() !== filter) continue;
659
+ for (const leaf of flattenConfigSchema(entry.configSchema)) {
660
+ const path = leaf.key.split(".");
661
+ const note = SECRET_KEY.test(path[path.length - 1]) && !leaf.note.includes("secret") ? [leaf.note, "secret"].filter(Boolean).join(", ") : leaf.note;
662
+ rows.push({
663
+ key: leaf.key,
664
+ module: entry.name,
665
+ type: leaf.type,
666
+ default: leaf.default,
667
+ effective: readResolvedPath(entry.config, path),
668
+ source: aggregateProvenance(entry.provenance, leaf.key),
669
+ note,
670
+ description: leaf.description
671
+ });
672
+ }
673
+ }
674
+ return rows;
675
+ }
676
+ },
620
677
  {
621
678
  path: "stores",
622
679
  summary: "Runtime persistence stores per brick (resolved store, provenance, available backends) + declared infra",
@@ -1,6 +1,6 @@
1
1
  import { buildParamArgs, computeActionMeta, resolveActionMeta, resolveSessionIntent } from "../decorators/routerDecorators.js";
2
2
  import { computeFingerprint, evaluateIdempotency, isMutationMethod, resolveIdempotencyKey, resolveIdentity } from "./idempotency.js";
3
- import { RequestContext, isArray, isPlainObject, isPromise, nodefonyError, typeOf } from "nodefony";
3
+ import { RequestContext, identityHint, isArray, isPlainObject, isPromise, nodefonyError, typeOf } from "nodefony";
4
4
  import { Http2Response, HttpResponse, WebsocketResponse } from "@nodefony/http";
5
5
  //#region nodefony/src/Resolver.ts
6
6
  /**
@@ -292,7 +292,7 @@ var Resolver = class {
292
292
  async _enforceSecurity(req) {
293
293
  const authz = this.context.container?.get("authorization");
294
294
  const token = RequestContext.get()?.token;
295
- if (!authz || token === void 0) throw new nodefonyError("Access denied", 403);
295
+ if (!authz || token === void 0) throw new nodefonyError(this._accessDenied(!authz ? `aucun moteur d'autorisation n'est posé sur cette requête` : "aucune identité n'est résolue sur cette requête — la route est-elle couverte par une zone du firewall ?"), 403);
296
296
  const clauses = req.clauses;
297
297
  for (let i = 0; i < clauses.length; i++) {
298
298
  const clause = clauses[i];
@@ -303,10 +303,27 @@ var Resolver = class {
303
303
  ok = true;
304
304
  break;
305
305
  }
306
- if (!ok) throw new nodefonyError("Access denied", 403);
306
+ if (!ok) throw new nodefonyError(this._accessDenied("l'identité de cette requête ne porte pas les droits exigés par la route (`@IsGranted`)"), 403);
307
307
  }
308
308
  }
309
309
  /**
310
+ * Le message d'un refus d'autorisation — augmenté du GESTE en développement.
311
+ *
312
+ * Refuser correctement ne suffit pas : celui qui vient d'écrire une route
313
+ * gardée n'a, sans cela, aucun moyen de l'essayer — mesuré, c'est le premier
314
+ * poste de coût d'un essai réel. La cause et le geste ne franchissent jamais
315
+ * la production ({@link identityHint} rend `null` hors développement) : le
316
+ * message y reste le `Access denied` nu qu'il a toujours été, puisque ces
317
+ * phrases y renseigneraient l'attaquant autant que le développeur.
318
+ *
319
+ * @param cause - ce qui a fait échouer l'autorisation, en clair.
320
+ * @returns le message à porter par l'erreur 403.
321
+ */
322
+ _accessDenied(cause) {
323
+ const hint = identityHint(this.context.kernel);
324
+ return hint === null ? "Access denied" : `Access denied — ${cause}. ${hint}`;
325
+ }
326
+ /**
310
327
  * Résout un paramètre de route NOMMÉ (`@IsGranted(..., { subject: "id" })`) vers
311
328
  * sa valeur, depuis `route.variables` (noms) + `this.variables` (valeurs déjà
312
329
  * parsées). 0 alloc (indexOf + accès tableau).
@@ -1,5 +1,5 @@
1
1
  import { Kernel, Module } from "nodefony";
2
- import type { FrameworkConfigInput, FrameworkConfig } from "./nodefony/config/config.js";
2
+ import type { IFrameworkConfigInput, IFrameworkConfig } from "./nodefony/config/config.js";
3
3
  import { getIdempotencyStoreFactory, registerIdempotencyStore, listIdempotencyStores } from "./nodefony/src/idempotencyStoreRegistry.js";
4
4
  import Router from "./nodefony/service/router.js";
5
5
  import Route from "./nodefony/src/Route.js";
@@ -28,10 +28,10 @@ import { mergeSchemas, makeExecutableSchema } from "@graphql-tools/schema";
28
28
  import { controllers, route, controller, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, routeExpectsBodyStream } from "./nodefony/decorators/routerDecorators.js";
29
29
  declare module "nodefony" {
30
30
  interface NodefonyModuleConfig {
31
- "@nodefony/framework": FrameworkConfigInput;
31
+ "@nodefony/framework": IFrameworkConfigInput;
32
32
  }
33
33
  }
34
- declare class Framework extends Module<FrameworkConfig> {
34
+ declare class Framework extends Module<IFrameworkConfig> {
35
35
  #private;
36
36
  constructor(kernel: Kernel);
37
37
  /** JSON Schema de la config framework → data plane admin (config riche Studio). */
@@ -91,6 +91,6 @@ export type { ControllerScope } from "./nodefony/src/Controller.js";
91
91
  export type { FrameworkAdminApiOptions } from "./nodefony/src/FrameworkAdminApi.js";
92
92
  export type { PlaygroundAction, PlaygroundController, PlaygroundGuards, PlaygroundParam, } from "./nodefony/src/PlaygroundAdminApi.js";
93
93
  export type { IResourceService, IResourceReadOptions, IResourcePageQuery, } from "./nodefony/src/ResourceController.js";
94
- export type { FrameworkConfig, FrameworkConfigInput, } from "./nodefony/config/config.js";
94
+ export type { IFrameworkConfig, IFrameworkConfigInput, } from "./nodefony/config/config.js";
95
95
  export { defineFrameworkConfig, frameworkConfigJsonSchema, } from "./nodefony/config/defineModuleConfig.js";
96
96
  export type { IController, IRoute, IResolver, IAdminBroker, IAdminRoute, IIdempotencyStore, IdempotencyOutcome, IdempotentResponse, } from "./nodefony/interfaces/index.js";
@@ -9,9 +9,9 @@ export declare const frameworkConfigSchema: z.ZodObject<{
9
9
  }, z.core.$strict>>;
10
10
  }, z.core.$strict>;
11
11
  /** Type de sortie (config normalisée + défauts appliqués). */
12
- export type FrameworkConfig = z.infer<typeof frameworkConfigSchema>;
12
+ export type IFrameworkConfig = z.infer<typeof frameworkConfigSchema>;
13
13
  /** Type d'entrée (toutes sections omissibles — défauts du schéma). */
14
- export type FrameworkConfigInput = z.input<typeof frameworkConfigSchema>;
14
+ export type IFrameworkConfigInput = z.input<typeof frameworkConfigSchema>;
15
15
  declare const _default: {
16
16
  router?: {
17
17
  [x: string]: unknown;
@@ -1,4 +1,4 @@
1
- import type { FrameworkConfig, FrameworkConfigInput } from "./config.js";
1
+ import type { IFrameworkConfig, IFrameworkConfigInput } from "./config.js";
2
2
  /**
3
3
  * Builder type-safe de la configuration de `@nodefony/framework` (PUR — ne
4
4
  * retape JAMAIS un défaut : source unique = `./config.ts`).
@@ -18,7 +18,7 @@ import type { FrameworkConfig, FrameworkConfigInput } from "./config.js";
18
18
  * @throws BootConfigurationError si la config est invalide ou porte une clé
19
19
  * inconnue — le boot s'interrompt, en dev comme en prod.
20
20
  */
21
- export declare function defineFrameworkConfig(config?: FrameworkConfigInput): FrameworkConfig;
21
+ export declare function defineFrameworkConfig(config?: IFrameworkConfigInput): IFrameworkConfig;
22
22
  /**
23
23
  * JSON Schema introspectable de la config framework — destiné au formulaire
24
24
  * d'édition Studio et à la documentation générée (les flags de champ posés via
@@ -8,7 +8,18 @@ import Controller from "../src/Controller.js";
8
8
  */
9
9
  export interface IOAuth2Service {
10
10
  isEnabled(): boolean;
11
+ /** Fournisseurs OPÉRATIONNELS — la garde de `/authorize`, jamais l'affichage. */
11
12
  listProviders(): string[];
13
+ /**
14
+ * Fournisseurs à AFFICHER, libellés compris. Optionnel à dessein : le
15
+ * couplage à `@nodefony/security` se fait par NOM, sans liaison de build —
16
+ * une version du module qui ne l'expose pas encore doit dégrader, pas
17
+ * casser l'écran de connexion (repli sur {@link listProviders}).
18
+ */
19
+ listDisplayProviders?(): {
20
+ name: string;
21
+ label: string;
22
+ }[];
12
23
  getRedirects(provider?: string): {
13
24
  success: string;
14
25
  failure: string;
@@ -63,9 +74,13 @@ declare class OAuth2Controller extends Controller {
63
74
  #private;
64
75
  constructor(context: ContextType);
65
76
  /**
66
- * Liste PUBLIQUE des fournisseurs activés (configurés ET connus du registre).
67
- * Consommé par l'UI de login pour n'afficher QUE les boutons opérationnels —
68
- * jamais de bouton mort. Aucun secret n'est exposé (uniquement les noms).
77
+ * Liste PUBLIQUE des fournisseurs à proposer à l'écran de connexion.
78
+ *
79
+ * Rend `{ name, label }` : le libellé vient du serveur, seul à connaître la
80
+ * configuration — un écran ne doit jamais avoir à deviner comment nommer un
81
+ * fournisseur, ni s'autoriser à en masquer un qu'il ne reconnaît pas. Un
82
+ * fournisseur déclaré `hidden` n'y figure pas, mais reste autorisable :
83
+ * masquer n'est pas désactiver. Aucun secret n'est exposé.
69
84
  */
70
85
  providers(): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
71
86
  /** Démarre le flux : URL d'autorisation + état anti-replay en session, 302. */
@@ -38,6 +38,38 @@ export interface IConfigEntry {
38
38
  */
39
39
  overriddenBy: Record<string, string>;
40
40
  }
41
+ /**
42
+ * Une clé assignable du catalogue de configuration — ce qu'on a le DROIT
43
+ * d'écrire, par opposition à {@link IConfigEntry} qui dit ce QUI EST écrit.
44
+ *
45
+ * Les noms de champs deviennent les en-têtes de colonnes de
46
+ * `nodefony inspect schema`, et les clés du JSON qu'un agent filtre.
47
+ */
48
+ export interface ISchemaCatalogRow {
49
+ /**
50
+ * Chemin pointé de la clé (`certificates.selfSigned.hash`).
51
+ *
52
+ * EN PREMIER, et ce n'est pas cosmétique : le rendu tient la première
53
+ * colonne pour l'identité de la ligne et ne l'élague jamais, quand il retire
54
+ * les suivantes si elles valent partout pareil. `module` d'abord aurait donc
55
+ * fait répéter le nom du module sur les cent fiches d'un lot filtré.
56
+ */
57
+ key: string;
58
+ /** Nom de paquet du module qui porte la clé, tel qu'on l'écrit dans `use()`. */
59
+ module: string;
60
+ /** Type déclaré, ou les valeurs de l'énumération quand il y en a une. */
61
+ type: string;
62
+ /** Défaut du schéma — `undefined` si le schéma n'en pose aucun. */
63
+ default?: unknown;
64
+ /** Valeur résolue au boot, secrets déjà REDACTÉS par `computeConfigEntry`. */
65
+ effective: unknown;
66
+ /** D'où vient la valeur effective : `default`, `app`, `env` ou `runtime`. */
67
+ source: string;
68
+ /** Ce que les métadonnées disent de la clé (`réservé`, `secret`…), vide sinon. */
69
+ note: string;
70
+ /** La phrase du schéma (`.describe()`) — c'est elle qu'on venait chercher. */
71
+ description: string;
72
+ }
41
73
  /**
42
74
  * Paquets à essayer pour une clé de module, **dans l'ordre**, et bornés au
43
75
  * périmètre du framework.
@@ -142,6 +142,20 @@ declare class Resolver implements IResolver {
142
142
  * @throws nodefonyError 403 si l'accès est refusé.
143
143
  */
144
144
  private _enforceSecurity;
145
+ /**
146
+ * Le message d'un refus d'autorisation — augmenté du GESTE en développement.
147
+ *
148
+ * Refuser correctement ne suffit pas : celui qui vient d'écrire une route
149
+ * gardée n'a, sans cela, aucun moyen de l'essayer — mesuré, c'est le premier
150
+ * poste de coût d'un essai réel. La cause et le geste ne franchissent jamais
151
+ * la production ({@link identityHint} rend `null` hors développement) : le
152
+ * message y reste le `Access denied` nu qu'il a toujours été, puisque ces
153
+ * phrases y renseigneraient l'attaquant autant que le développeur.
154
+ *
155
+ * @param cause - ce qui a fait échouer l'autorisation, en clair.
156
+ * @returns le message à porter par l'erreur 403.
157
+ */
158
+ private _accessDenied;
145
159
  /**
146
160
  * Résout un paramètre de route NOMMÉ (`@IsGranted(..., { subject: "id" })`) vers
147
161
  * sa valeur, depuis `route.variables` (noms) + `this.variables` (valeurs déjà
@@ -1,5 +1,5 @@
1
1
  import type { Module, IIdempotencyStore } from "nodefony";
2
- import type { FrameworkConfig } from "../config/config.js";
2
+ import type { IFrameworkConfig } from "../config/config.js";
3
3
  /**
4
4
  * Registre de **fabriques de stores d'idempotence DISTRIBUÉS** — résout le nom
5
5
  * configuré (`framework.idempotency.store`) vers une instance d'
@@ -29,7 +29,7 @@ export interface IIdempotencyStoreFactoryContext {
29
29
  /** Module framework (porte `kernel.container` pour résoudre `redis`, etc.). */
30
30
  readonly module: Module;
31
31
  /** Config framework validée + gelée. */
32
- readonly config: FrameworkConfig;
32
+ readonly config: IFrameworkConfig;
33
33
  }
34
34
  /** Fabrique d'un store d'idempotence pour un nom donné. */
35
35
  export type IdempotencyStoreFactory = (ctx: IIdempotencyStoreFactoryContext) => IIdempotencyStore;
@@ -310,7 +310,7 @@ action se tromperait d'objet.
310
310
  > frame 2 — pratique pour un état de conversation, piège si tu comptais sur une instance neuve. En
311
311
  > HTTP, l'inverse : chaque requête repart d'une instance vierge.
312
312
 
313
- Côté WebSocket, l'ordre est encore plus marqué : `HttpKernel.onConnect()` (`http-kernel.ts:1659`)
313
+ Côté WebSocket, l'ordre est encore plus marqué : `HttpKernel.onConnect()` (`http-kernel.ts:1702`)
314
314
  appelle `handleFrontController()` (donc `initialize()`) **avant** `startSession()`
315
315
  (`http-kernel.ts:1131`), avant l'acceptation de la socket, et avant le firewall
316
316
  (`http-kernel.ts:1457`).
@@ -326,11 +326,11 @@ sont des accesseurs qui dérivent du contexte **vivant**, selon le motif `champ
326
326
  | `this.route` | La route matchée | `Controller.ts:158` |
327
327
  | `this.request` | La requête (HTTP, HTTP/2 ou WS) | `Controller.ts:162` |
328
328
  | `this.response` | La réponse du transport | `Controller.ts:169` |
329
- | `this.method` | La méthode HTTP (ou `WEBSOCKET`) | `Controller.ts:178` |
330
- | `this.queryGet` | Les paramètres de la query string | `Controller.ts:187` |
331
- | `this.queryPost` | Le corps parsé | `Controller.ts:214` |
332
- | `this.body` | Le corps parsé — alias de `queryPost` | `Controller.ts:229` |
333
- | `this.queryFile` | Les fichiers uploadés | `Controller.ts:205` |
329
+ | `this.method` | La méthode HTTP (ou `WEBSOCKET`) | `Controller.ts:212` |
330
+ | `this.queryGet` | Les paramètres de la query string | `Controller.ts:221` |
331
+ | `this.queryPost` | Le corps parsé | `Controller.ts:248` |
332
+ | `this.body` | Le corps parsé — alias de `queryPost` | `Controller.ts:248` |
333
+ | `this.queryFile` | Les fichiers uploadés | `Controller.ts:239` |
334
334
  | `this.session` | La session **ou `null`** si elle n'est pas activée | `Controller.ts:229` |
335
335
 
336
336
  Pourquoi des accesseurs plutôt que des champs recopiés au constructeur : **la fraîcheur et le coût**.
@@ -349,16 +349,16 @@ n'est créée** — donc aucun coût de stockage.
349
349
  Deux corollaires :
350
350
 
351
351
  - Dans `initialize()`, `this.session` vaut `null` (l'activation vient plus tard — étape 6 du cycle).
352
- - `this.getSession()` (`Controller.ts:394`) ne « démarre » rien : il retourne la session existante,
352
+ - `this.getSession()` (`Controller.ts:496`) ne « démarre » rien : il retourne la session existante,
353
353
  ou `undefined`.
354
354
 
355
- Les messages flash s'appuient dessus : `setFlashBag()`/`addFlash()` (`Controller.ts:420`) et
356
- `getFlashBag()` (`Controller.ts:412`) journalisent une **erreur** et retournent `null` si aucune
355
+ Les messages flash s'appuient dessus : `setFlashBag()`/`addFlash()` (`Controller.ts:530`) et
356
+ `getFlashBag()` (`Controller.ts:514`) journalisent une **erreur** et retournent `null` si aucune
357
357
  session n'est active — pas de crash, mais rien n'est mémorisé.
358
358
 
359
359
  ### Contrôleur `singleton` — quand `this` n'est plus à toi
360
360
 
361
- Par défaut, `Controller.scope` vaut `"request"` (`Controller.ts:119`). Un contrôleur **sans état**
361
+ Par défaut, `Controller.scope` vaut `"request"` (`Controller.ts:196`). Un contrôleur **sans état**
362
362
  peut passer en instance unique partagée :
363
363
 
364
364
  ```typescript
@@ -399,14 +399,14 @@ de ce que tu as retourné :
399
399
  | Un `number` / un `boolean` | Auto-JSON scalaire (RFC 8259 §2 : `42`, `true` sont des documents valides) | `Resolver.ts:734` |
400
400
  | Un `Buffer` | Envoyé brut | `Resolver.ts:723` |
401
401
  | Une `Response` (via un `render*`) | Retournée telle quelle — l'envoi a déjà eu lieu | `Resolver.ts:716` |
402
- | `void`/`null` **et** statut 204/205/304 | Réponse **vide envoyée** (RFC 9110 : ces statuts n'ont pas de corps) | `NO_BODY_STATUS` (`Resolver.ts:798`) |
402
+ | `void`/`null` **et** statut 204/205/304 | Réponse **vide envoyée** (RFC 9110 : ces statuts n'ont pas de corps) | `NO_BODY_STATUS` (`Resolver.ts:855`) |
403
403
  | `void`/`null` avec tout autre statut | `waitAsync` : « l'action enverra plus tard » | `Resolver.ts:801` |
404
404
  | Une instance de classe (entité ORM, DTO) | **Non sérialisée** → `waitAsync` (le teardown avertit du blocage) | `Resolver.ts:770-777` |
405
405
 
406
406
  > [!WARNING]
407
407
  > **Le piège n° 1 : `return null` sur un statut à corps.** Le framework l'interprète comme « je
408
408
  > répondrai moi-même » et attend — jusqu'au timeout. La distinction se fait sur le **statut** :
409
- > `NO_BODY_STATUS` (`Resolver.ts:817`) contient 204, 205 et 304. Donc un `@Delete` qui fait
409
+ > `NO_BODY_STATUS` (`Resolver.ts:855`) contient 204, 205 et 304. Donc un `@Delete` qui fait
410
410
  > `@HttpCode(204)` puis `return null` répond bien 204 vide ; le même `return null` sans `@HttpCode`
411
411
  > laisse la requête pendue.
412
412
 
@@ -420,19 +420,19 @@ Quand tu veux piloter l'envoi plutôt que retourner une valeur :
420
420
  | Helper | Pour… | Ancre |
421
421
  | -------------------------------------------- | -------------------------------------------------------- | ------------------- |
422
422
  | `renderJson(obj, status?, headers?)` | JSON explicite avec statut/en-têtes | `Controller.ts:379` |
423
- | `render(data, encoding?, status?, headers?)` | Envoyer un corps quelconque via le contexte | `Controller.ts:273` |
424
- | `renderView(path, params, status?)` | Rendre un template **Eta** (avec les helpers frontend) | `Controller.ts:308` |
425
- | `renderResponse(data, encoding?, …)` | Poser statut + en-têtes, puis envoyer | `Controller.ts:290` |
423
+ | `render(data, encoding?, status?, headers?)` | Envoyer un corps quelconque via le contexte | `Controller.ts:377` |
424
+ | `renderView(path, params, status?)` | Rendre un template **Eta** (avec les helpers frontend) | `Controller.ts:410` |
425
+ | `renderResponse(data, encoding?, …)` | Poser statut + en-têtes, puis envoyer | `Controller.ts:392` |
426
426
  | `redirect(url, status?, headers?)` | Rediriger | `Controller.ts:382` |
427
427
  | `forward("module:controller:action")` | Déléguer à une autre action **sans** aller-retour réseau | `Controller.ts:432` |
428
- | `setContextJson()` / `setContextHtml()` | Choisir le type de contenu avant d'envoyer | `Controller.ts:282` |
428
+ | `setContextJson()` / `setContextHtml()` | Choisir le type de contenu avant d'envoyer | `Controller.ts:319` |
429
429
 
430
430
  `renderView()` mesure sa propre phase `render` et injecte automatiquement les aides frontend
431
431
  (`frontendTags`, `frontendDocument`, `asset`) dans les variables du template
432
- (`withFrontendLocals()`, `Controller.ts:345`) — tes propres valeurs restent prioritaires.
432
+ (`withFrontendLocals()`, `Controller.ts:448`) — tes propres valeurs restent prioritaires.
433
433
 
434
434
  `forward()` re-résout un contrôleur sur le **même** contexte et rappelle son action
435
- (`Controller.ts:445`) : c'est une délégation interne, la requête cliente reste unique.
435
+ (`Controller.ts:534`) : c'est une délégation interne, la requête cliente reste unique.
436
436
 
437
437
  > [!TIP]
438
438
  > **Redirection : le code par défaut est 302** (Found), pas 301. Un statut absent ou hors de la liste
@@ -448,7 +448,7 @@ Deux besoins distincts, deux helpers.
448
448
 
449
449
  `renderFileDownload(file, options?, headers?)` (`Controller.ts:473`) pose
450
450
  `Content-Disposition: attachment`, `Content-Length`, le type MIME du fichier, puis délègue au moteur
451
- de flux. Le fichier est résolu **sans bloquer l'event loop** (`getFileAsync()`, `Controller.ts:497`) ;
451
+ de flux. Le fichier est résolu **sans bloquer l'event loop** (`getFileAsync()`, `Controller.ts:586`) ;
452
452
  la variante synchrone `getFile()` existe encore mais est marquée obsolète — elle appelle `lstatSync`
453
453
  et gèle le process le temps du stat.
454
454
 
@@ -465,12 +465,12 @@ plage** (RFC 9110 §14), ce qui permet à un lecteur vidéo de sauter dans le fl
465
465
  | Plage hors fichier | **416** + `Content-Range: bytes */<taille>` (RFC 9110 §15.5.17) |
466
466
  | Syntaxe invalide, multi-plage, unité inconnue | En-tête **ignoré** → 200 complet (jamais un 500) |
467
467
 
468
- La logique est isolée dans une fonction pure exportée, `parseByteRange()` (`Controller.ts:73`) —
468
+ La logique est isolée dans une fonction pure exportée, `parseByteRange()` (`Controller.ts:107`) —
469
469
  donc testable sans serveur.
470
470
 
471
471
  ### Ce que `streamFile()` garantit
472
472
 
473
- `streamFile()` (`Controller.ts:580`) est le moteur commun. Sa subtilité n'est pas le pipe, c'est le
473
+ `streamFile()` (`Controller.ts:669`) est le moteur commun. Sa subtilité n'est pas le pipe, c'est le
474
474
  **nettoyage** : le flux est ouvert avec `autoClose: false`, et un client qui raccroche en plein
475
475
  téléchargement laisserait sinon un descripteur de fichier ouvert et une promesse pendue à jamais. Un
476
476
  écouteur `close` sur la réponse détruit le flux, ce qui déclenche la fermeture du descripteur et
@@ -578,9 +578,9 @@ code du framework applique — et attend de toi — les règles suivantes :
578
578
 
579
579
  | Domaine | Norme | Comment le code s'y conforme |
580
580
  | -------------------------------- | ------------------------ | -------------------------------------------------------------- |
581
- | Statuts sans corps (204/205/304) | RFC 9110 §15.3.5/§15.4.5 | `NO_BODY_STATUS` (`Resolver.ts:817`) |
582
- | Requêtes par plage | RFC 9110 §14.1.2, §14.2 | `parseByteRange()` (`Controller.ts:73`) |
583
- | Plage insatisfiable → 416 | RFC 9110 §15.5.17 | `renderResponse()` avec 416 (`Controller.ts:304`) |
581
+ | Statuts sans corps (204/205/304) | RFC 9110 §15.3.5/§15.4.5 | `NO_BODY_STATUS` (`Resolver.ts:855`) |
582
+ | Requêtes par plage | RFC 9110 §14.1.2, §14.2 | `parseByteRange()` (`Controller.ts:107`) |
583
+ | Plage insatisfiable → 416 | RFC 9110 §15.5.17 | `renderResponse()` avec 416 (`Controller.ts:392`) |
584
584
  | Redirections | RFC 9110 §15.4 | Liste blanche + repli 302 (`Response.ts:534`) |
585
585
  | Média JSON sans `charset` | RFC 8259 §11 | Auto-JSON (`Resolver.ts:760`), vérifié par le banc `auto-json` |
586
586
  | Scalaire JSON de premier niveau | RFC 8259 §2 | `number`/`boolean` rendus (`Resolver.ts:734`) |
@@ -610,7 +610,7 @@ code du framework applique — et attend de toi — les règles suivantes :
610
610
  | WS : l'état d'une frame « bave » sur la suivante | L'instance est partagée par toute la connexion (`Resolver.ts:262`) | Réinitialiser l'état en tête d'action, ou le porter par message |
611
611
  | WS : l'action n'est jamais appelée | Route sans transport `WEBSOCKET` déclaré | `requirements: { methods: ["WEBSOCKET"] }` |
612
612
  | Contrôleur `singleton` : données d'un autre utilisateur | Champ mutable per-requête sur une instance partagée | Retirer `@Scope("singleton")`, ou passer par les arguments décorés |
613
- | Event loop figé sur une route de fichier | `getFile()` synchrone (`lstatSync`, `Controller.ts:457`) | Utiliser `getFileAsync()` (`Controller.ts:472`) |
613
+ | Event loop figé sur une route de fichier | `getFile()` synchrone (`lstatSync`, `Controller.ts:546`) | Utiliser `getFileAsync()` (`Controller.ts:586`) |
614
614
 
615
615
  ## 🧪 Tests & couverture
616
616
 
@@ -298,7 +298,7 @@ d'écriture (`routerDecorators.ts:281`) — sinon un attrape-tout masquerait les
298
298
  **`@Scope("singleton")` est un contrat, pas une optimisation.** L'instance étant partagée, l'action
299
299
  ne doit lire ni écrire **aucun** état de requête sur `this` : tout passe par les arguments décorés et
300
300
  les accesseurs, qui retrouvent la requête courante via l'ALS. Le défaut reste `"request"` — une
301
- instance par requête (`ControllerScope`, `Controller.ts:110`).
301
+ instance par requête (`ControllerScope`, `Controller.ts:144`).
302
302
 
303
303
  > [!NOTE]
304
304
  > Le core `nodefony` exporte lui aussi un `Scope` (les portées du conteneur d'injection). Celui des
@@ -455,12 +455,12 @@ ne s'auto-promeut pas.
455
455
  | `@Header("X-Foo", "bar")` | méthode | Ajoute un en-tête ; **s'empile** (plusieurs `@Header` cumulent, `routerDecorators.ts:580`) | `@Header("Cache-Control","no-store")` |
456
456
  | `@Redirect("/url", 302)` | méthode | Redirige **si** l'action ne renvoie rien (`Redirect()`, `routerDecorators.ts:589`) | `@Redirect("/login", 302)` |
457
457
 
458
- Les deux premiers sont appliqués par `Resolver._applyResponseMeta()` (`Resolver.ts:650`) **avant**
458
+ Les deux premiers sont appliqués par `Resolver._applyResponseMeta()` (`Resolver.ts:687`) **avant**
459
459
  l'appel de l'action : ton code peut donc les écraser ensuite (`this.renderJson(data, 202)` gagne).
460
460
 
461
461
  `@Redirect` a une subtilité utile : si l'action **retourne un objet** portant `url` (et
462
462
  éventuellement `statusCode`), cet objet **prend le dessus** sur les valeurs du décorateur
463
- (`Resolver._handleRedirect()`, `Resolver.ts:666`) — la cible peut donc être calculée à l'exécution :
463
+ (`Resolver._handleRedirect()`, `Resolver.ts:703`) — la cible peut donc être calculée à l'exécution :
464
464
 
465
465
  ```typescript
466
466
  @Get("/go")
@@ -620,7 +620,7 @@ Trois faits à retenir :
620
620
  - **Les décorateurs de paramètre fonctionnent pareil.** Pour une invocation par socket, le corps de
621
621
  la mutation voyage dans l'ALS et **prime** sur le corps HTTP (vide dans ce cas) — c'est traité dans
622
622
  `resolveParamArg()` (`routerDecorators.ts:1283`), et `@Query` lit la query du chemin **invoqué**,
623
- pas celle du handshake (`Resolver._buildParamArgs()`, `Resolver.ts:637`).
623
+ pas celle du handshake (`Resolver._buildParamArgs()`, `Resolver.ts:656`).
624
624
  - **Les gardes s'appliquent identiquement.** `@IsGranted` protège une action joignable par socket
625
625
  exactement comme une action HTTP : la décision est prise avant l'instanciation, quel que soit le
626
626
  transport.
@@ -708,7 +708,7 @@ Le `Resolver` consomme ce snapshot dans un ordre qui a du sens sécurité :
708
708
  **garde d'abord, instanciation ensuite**. `security !== null` déclenche
709
709
  `_enforceSecurity()` (`Resolver.ts:576`) **avant** `newController()` — un `403` n'instancie pas le
710
710
  contrôleur et n'exécute pas son `initialize()`. Puis viennent les arguments
711
- (`_buildParamArgs()`, `Resolver.ts:619`), les métadonnées de réponse
711
+ (`_buildParamArgs()`, `Resolver.ts:656`), les métadonnées de réponse
712
712
  (`_applyResponseMeta()`, `Resolver.ts:650`), l'action, et enfin la redirection éventuelle.
713
713
 
714
714
  Un usage cold path mérite d'être connu : `extractActionScopes()` (`routerDecorators.ts:1476`) parcourt
@@ -345,7 +345,7 @@ Source unique = schéma Zod `idempotencySchema`
345
345
  ### Comment `store: "auto"` se résout VRAIMENT
346
346
 
347
347
  `Framework.onKernelBoot()` (`src/packages/@nodefony/framework/index.ts:189`) délègue à
348
- `resolveAutoStore("ephemeral", …)` (`src/nodefony/src/config/infra.ts:241`), borné aux backends
348
+ `resolveAutoStore("ephemeral", …)` (`src/nodefony/src/config/infra.ts:289`), borné aux backends
349
349
  **réellement enregistrés** (`listIdempotencyBackends()`, `idempotencyStoreRegistry.ts:81`). L'ordre
350
350
  réel est le suivant :
351
351
 
package/docs/routing.md CHANGED
@@ -114,7 +114,7 @@ mêmes décorateurs.
114
114
 
115
115
  **Le routeur passe avant les fichiers statiques.** Une requête qui correspond à une route ne paie
116
116
  jamais le `stat` du serveur de fichiers : le repli statique n'est tenté que si la résolution a échoué
117
- (`serverStatic.handle()`, `http-kernel.ts:1200`).
117
+ (`serverStatic.handle()`, `http-kernel.ts:725`).
118
118
 
119
119
  > [!NOTE]
120
120
  > **Le routage n'a aucune option de configuration.** Le schéma Zod du module n'expose qu'un sac
@@ -569,7 +569,7 @@ alloué par requête.
569
569
  | Cible identifiée par l'URI, hôte compris | RFC 9110 §7.2 | hôte vérifié avant la méthode (`Route.match()`, `Route.ts:298`) |
570
570
  | 403 sur ressource d'un autre vhost | RFC 9110 §15.5.4 | `Route.matchHostname()` (`Route.ts:605`) |
571
571
  | 404 quand rien ne correspond | RFC 9110 §15.5.5 | après repli statique (`http-kernel.ts:688`) |
572
- | 421 sur `Host` non servi | RFC 9110 §15.5.20 | `checkValidDomain()` (`http-kernel.ts:1697`) |
572
+ | 421 sur `Host` non servi | RFC 9110 §15.5.20 | `checkValidDomain()` (`http-kernel.ts:1740`) |
573
573
  | Erreur de sous-protocole WS = 1002 | RFC 6455 §7.4 | `Route.matchRequirements()` (`Route.ts:649`) |
574
574
  | Décodage pourcent des segments | RFC 3986 §2.1 | `decode()` (`Route.ts:79`) |
575
575