@nodefony/framework 10.0.0-alpha.7 → 10.0.0-alpha.9

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.
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorate.js
1
+ //#region \0@oxc-project+runtime@0.151.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.150.0/helpers/esm/decorateMetadata.js
1
+ //#region \0@oxc-project+runtime@0.151.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.150.0/helpers/esm/decorateMetadata.js";
11
- import __decorate from "./_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js";
10
+ import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
11
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.151.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, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices, services } from "nodefony";
32
+ import { AUTO_STORE, EMPTY_INFRA, Kernel, Module, durableStoreRemedy, readStoreConnector, 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
@@ -127,7 +127,8 @@ let Framework = class Framework extends Module {
127
127
  available: listIdempotencyBackends(),
128
128
  reason,
129
129
  configPath: "framework.idempotency.store",
130
- location: readStoreLocation(store)
130
+ location: readStoreLocation(store),
131
+ connector: readStoreConnector(store)
131
132
  });
132
133
  const idem = this.options.idempotency;
133
134
  this.#idempotencyGc = scheduleIdempotencyGc(store, {
@@ -53,7 +53,8 @@ var config_default = {
53
53
  },
54
54
  "nodefony-admin": {
55
55
  pattern: "^/nodefony/[^/]+/api(/|$)",
56
- authenticators: ["session"]
56
+ authenticators: ["session"],
57
+ roles: ["ROLE_NODEFONY_ADMIN"]
57
58
  }
58
59
  } }
59
60
  };
@@ -150,13 +150,33 @@ var AdminApiController = class AdminApiController extends Controller {
150
150
  idempotencyKey: resolveIdempotencyKey(als?.idempotencyKey, this.context?.request?.headers?.["idempotency-key"])
151
151
  };
152
152
  }
153
- /** Extrait les rôles de l'utilisateur ALS sans coupler le core à `IUser`. */
153
+ /**
154
+ * Extrait les rôles de l'utilisateur ALS sans coupler le core à `IUser`, en
155
+ * y **ajoutant ceux que la hiérarchie leur fait couvrir**.
156
+ *
157
+ * Sans cet aplatissement, un point d'entrée qui exige `ROLE_DEV` refuserait
158
+ * un administrateur de plateforme qui le couvre pourtant par hiérarchie : la
159
+ * décision du plan d'administration est une comparaison de noms
160
+ * (`isAdminGranted`), là où le jury d'autorisation et le verrou de frame
161
+ * consultent tous deux la hiérarchie. Trois portes, un même rôle exigé, deux
162
+ * verdicts possibles — et c'est la porte qu'on relit le moins qui serait la
163
+ * plus stricte.
164
+ *
165
+ * Le parcours est résolu **par nom** (`roleHierarchy`), comme le moteur
166
+ * d'autorisation : `@nodefony/framework` n'importe jamais `@nodefony/security`.
167
+ * Absent (module de sécurité non chargé), on rend les rôles bruts — on ne
168
+ * perd donc jamais ce que l'appelant porte déjà.
169
+ */
154
170
  extractRoles(user) {
171
+ let roles = [];
155
172
  if (user && typeof user === "object" && "roles" in user) {
156
- const roles = user.roles;
157
- if (Array.isArray(roles)) return roles.filter((r) => typeof r === "string");
173
+ const brut = user.roles;
174
+ if (Array.isArray(brut)) roles = brut.filter((r) => typeof r === "string");
158
175
  }
159
- return [];
176
+ if (roles.length === 0) return roles;
177
+ const walker = this.context?.container?.get("roleHierarchy");
178
+ if (!walker?.reachableRoles) return roles;
179
+ return [...walker.reachableRoles(roles)];
160
180
  }
161
181
  };
162
182
  //#endregion
@@ -142,7 +142,8 @@ function mountApiKeyRoutes(frameworkModule) {
142
142
  path,
143
143
  constructor: ApiKeyController,
144
144
  classMethod,
145
- requirements: { methods: [method] }
145
+ requirements: { methods: [method] },
146
+ areaRoleExempt: true
146
147
  });
147
148
  if (!Object.prototype.hasOwnProperty.call(ApiKeyController.prototype, "module")) router_default.setController(ApiKeyController, frameworkModule);
148
149
  mounted = true;
@@ -120,7 +120,8 @@ function mountTotpRoutes(frameworkModule) {
120
120
  path,
121
121
  constructor: TotpController,
122
122
  classMethod,
123
- requirements: { methods: [method] }
123
+ requirements: { methods: [method] },
124
+ areaRoleExempt: true
124
125
  });
125
126
  if (!Object.prototype.hasOwnProperty.call(TotpController.prototype, "module")) router_default.setController(TotpController, frameworkModule);
126
127
  mounted = true;
@@ -904,7 +904,10 @@ function computeSecurityRequirement(ctor, method) {
904
904
  ...methodScopes
905
905
  ];
906
906
  if (all.length === 0) return null;
907
- return Object.freeze({ clauses: Object.freeze(all) });
907
+ return Object.freeze({
908
+ clauses: Object.freeze(all),
909
+ hasRoleClause: classClauses.length + methodClauses.length > 0
910
+ });
908
911
  }
909
912
  /**
910
913
  * P6 — Fusionne les directives `@Csp` (classe + méthode) en un jeu figé, ou
@@ -1,5 +1,5 @@
1
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateMetadata.js";
2
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js";
1
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
2
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.151.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";
@@ -81,7 +81,8 @@ let AdminBroker = class AdminBroker extends Service {
81
81
  path,
82
82
  constructor: AdminApiController,
83
83
  classMethod: "dispatch",
84
- requirements: { methods }
84
+ requirements: { methods },
85
+ areaRoleExempt: true
85
86
  });
86
87
  this.byRouteName.set(name, {
87
88
  name,
@@ -1,5 +1,5 @@
1
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateMetadata.js";
2
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js";
1
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
2
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.151.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.150.0/helpers/esm/decorateMetadata.js";
5
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js";
4
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
5
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.151.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
@@ -125,6 +125,7 @@ let Router = class Router extends Service {
125
125
  resolve(context, cleanPathOverride, methodOverride) {
126
126
  const resolver = new Resolver(context);
127
127
  resolver.methodOverride = methodOverride ?? null;
128
+ resolver.messageInvocation = cleanPathOverride !== void 0;
128
129
  const cleanPath = cleanPathOverride ?? Route.cleanPathname(context);
129
130
  let index = routeIndex;
130
131
  if (index === null || index.length !== routes.length || index.first !== routes[0] || index.last !== routes[routes.length - 1]) index = buildRouteIndex();
@@ -2,7 +2,7 @@ import { CORE_PACKAGE, checkOutdated, countModuleDocs, listModuleDocs, listModul
2
2
  import { getResolvedPath, navigateSchemaNode, nodeFlags, notEditableReason, recipeFor, validateLeafValue } from "./configMutation.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { join } from "node:path";
5
- import { GitService, Syslog, aggregateProvenance, applyResolvedPath, collectDevStatus, computeConfigProvenance, defaultAppConfig, extractJsonSchemaDefaults, extractMarkdownSection, flattenConfigSchema, getActiveLogDriver, listLogDrivers, outlineMarkdown, parseNfEnvOverrides, readResolvedPath } from "nodefony";
5
+ import { GitService, Syslog, aggregateProvenance, applyResolvedPath, collectDevStatus, computeConfigProvenance, defaultAppConfig, extractJsonSchemaDefaults, extractMarkdownSection, flattenConfigSchema, getActiveLogDriver, listLogDrivers, outlineMarkdown, parseNfEnvOverrides, parseStoreManifest, 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
@@ -50,11 +50,11 @@ function readAdapterManifest(pkg) {
50
50
  const pkgJson = join(REPO_ROOT, "node_modules", ...pkg.split("/"), "package.json");
51
51
  try {
52
52
  if (!existsSync(pkgJson)) return null;
53
- const nf = JSON.parse(readFileSync(pkgJson, "utf8")).nodefony;
54
- if (!nf || !Array.isArray(nf.stores)) return null;
53
+ const manifest = parseStoreManifest(JSON.parse(readFileSync(pkgJson, "utf8")));
54
+ if (!manifest?.provides) return null;
55
55
  return {
56
- storeKind: nf.storeKind === "cache" ? "cache" : "durable",
57
- stores: nf.stores.filter((s) => typeof s === "string")
56
+ storeKind: manifest.storeKind,
57
+ stores: manifest.stores
58
58
  };
59
59
  } catch {
60
60
  return null;
@@ -692,7 +692,12 @@ function createKernelAdminApi(kernel) {
692
692
  if ((res.resolved === "drizzle" || res.resolved === "mongoose") && infra.database) return "NF_DATABASE_URL";
693
693
  return null;
694
694
  };
695
- const registered = new Set(kernel.storeResolutions.flatMap((r) => [...r.available]));
695
+ const loadedPackages = /* @__PURE__ */ new Set();
696
+ for (const mod of Object.values(kernel.modules)) {
697
+ const npmName = mod.package?.name;
698
+ if (typeof npmName === "string") loadedPackages.add(npmName);
699
+ loadedPackages.add(mod.name);
700
+ }
696
701
  return {
697
702
  engines: OFFICIAL_STORE_ADAPTERS.map((a) => {
698
703
  const manifest = readAdapterManifest(a.package);
@@ -703,7 +708,7 @@ function createKernelAdminApi(kernel) {
703
708
  kind: manifest?.storeKind ?? "durable",
704
709
  provides: manifest?.stores ?? [],
705
710
  installed: manifest !== null || isPackageInstalled(a.package),
706
- loaded: registered.has(a.engine)
711
+ loaded: loadedPackages.has(a.package) || loadedPackages.has(a.engine)
707
712
  };
708
713
  }),
709
714
  infra: {
@@ -13,6 +13,14 @@ import { Http2Response, HttpResponse, WebsocketResponse } from "@nodefony/http";
13
13
  * `context.container` (le cache `"controller"` y survit au Resolver : un
14
14
  * forward ou un 2ᵉ Resolver WS sur la MÊME connexion retrouve l'instance).
15
15
  */
16
+ /**
17
+ * Exigences d'autorisation dérivées des **zones** du firewall, mémoïsées par
18
+ * zone (`Resolver._areaSecurity`). Les zones sont figées au boot : une entrée
19
+ * par zone, fabriquée une fois pour la vie du processus. `WeakMap` plutôt que
20
+ * `Map` pour ne pas retenir une zone d'un kernel arrêté — plusieurs kernels
21
+ * cohabitent dans une même suite de tests.
22
+ */
23
+ const areaRequirements = /* @__PURE__ */ new WeakMap();
16
24
  var Resolver = class {
17
25
  injector;
18
26
  controller = null;
@@ -44,6 +52,18 @@ var Resolver = class {
44
52
  * sur `context.method`.
45
53
  */
46
54
  methodOverride = null;
55
+ /**
56
+ * Cette résolution est-elle une invocation **par message** (pont WS-RPC
57
+ * `api.request`) plutôt que l'établissement de la connexion ?
58
+ *
59
+ * Posé par `Router.resolve` dès qu'un `cleanPathOverride` est fourni : c'est
60
+ * exactement ce qui distingue « la socket s'ouvre » de « la socket demande
61
+ * telle ressource ». Le premier n'accède à rien, le second accède — et seul
62
+ * le second doit subir le rôle par défaut de la zone ({@link _areaSecurity}),
63
+ * sans quoi l'ouverture même du tuyau deviendrait réservée aux
64
+ * administrateurs.
65
+ */
66
+ messageInvocation = false;
47
67
  constructor(context) {
48
68
  this.context = context;
49
69
  this.injector = context.container?.get("injector") ?? null;
@@ -152,6 +172,10 @@ var Resolver = class {
152
172
  async executeAction(data, reload = false, metaArg) {
153
173
  const meta = metaArg ?? (this.route ? resolveActionMeta(this.route) : computeActionMeta(this.controller, this.actionName));
154
174
  if (meta.security !== null) await this._enforceSecurity(meta.security);
175
+ if (meta.security === null || !meta.security.hasRoleClause) {
176
+ const area = this._areaSecurity();
177
+ if (area !== null) await this._enforceSecurity(area);
178
+ }
155
179
  let controller = this.context.container?.get("controller");
156
180
  if (!controller || reload || this.controller && !(controller instanceof this.controller)) controller = await this.newController();
157
181
  if (this.controller?.scope !== "singleton") {
@@ -282,6 +306,43 @@ var Resolver = class {
282
306
  }
283
307
  }
284
308
  /**
309
+ * Exigence d'autorisation **héritée de la zone** du firewall, pour une route
310
+ * qui n'en déclare aucune — ou `null` quand il n'y a rien à appliquer.
311
+ *
312
+ * Pourquoi ce filet existe : une zone qui n'exige qu'une **identité** laisse
313
+ * toute route qu'elle couvre accessible à n'importe quel compte authentifié.
314
+ * Sur une surface d'administration, le défaut doit être l'inverse — l'oubli
315
+ * se solde par un refus, jamais par une fuite. Le rôle se déclare donc UNE
316
+ * fois sur la zone, au lieu d'être recopié sur chaque route.
317
+ *
318
+ * Trois dispenses, et elles sont toutes des déclarations explicites :
319
+ * `bypassFirewall` (la route EST le mécanisme d'authentification),
320
+ * {@link Route.areaRoleExempt} (le pont du plan d'administration, qui résout un
321
+ * rôle par point d'entrée), et une garde d'action — traitée par l'appelant,
322
+ * qui n'entre ici que si `meta.security` est nul.
323
+ *
324
+ * L'exigence est **mémoïsée par zone** : les zones sont figées au boot et se
325
+ * comptent sur une main, donc la fabrication n'a lieu qu'une fois par zone
326
+ * sur la vie du processus — le hot path ne fait qu'un `get` de `WeakMap`.
327
+ *
328
+ * @returns l'exigence à évaluer, ou `null` s'il n'y en a aucune.
329
+ */
330
+ _areaSecurity() {
331
+ if (this.bypassFirewall) return null;
332
+ if (this.route?.areaRoleExempt) return null;
333
+ if (this.context.method === "WEBSOCKET" && !this.messageInvocation) return null;
334
+ const area = this.context.security;
335
+ if (!area || !area.security) return null;
336
+ const roles = area.roles;
337
+ if (roles === null || roles.length === 0) return null;
338
+ let requirement = areaRequirements.get(area);
339
+ if (requirement === void 0) {
340
+ requirement = Object.freeze({ clauses: Object.freeze([Object.freeze({ anyOf: roles })]) });
341
+ areaRequirements.set(area, requirement);
342
+ }
343
+ return requirement;
344
+ }
345
+ /**
285
346
  * Évalue l'exigence d'autorisation (`@IsGranted`) d'une action via le service
286
347
  * `authorization` (par nom, 0 import security). Clauses en **AND**, attributs
287
348
  * d'une clause en **OR**. Refus (ou moteur/identité absents) → 403 (Zero Trust,
@@ -140,6 +140,11 @@ var Route = class Route {
140
140
  varRegexp;
141
141
  bypassFirewall = false;
142
142
  /**
143
+ * La route décide seule de son autorisation → la zone du firewall n'applique
144
+ * pas son rôle par défaut. Cf {@link RouteOptions.areaRoleExempt}.
145
+ */
146
+ areaRoleExempt = false;
147
+ /**
143
148
  * P2.9 — Cache mémoïsé : l'action attend-elle le **flux brut** du body
144
149
  * (`@Body({ stream:true })`) ? `undefined` = pas encore calculé (résolu au 1er
145
150
  * `routeExpectsBodyStream(route)` via lecture `Reflect` des `ParamMeta`, O(1)
@@ -174,6 +179,7 @@ var Route = class Route {
174
179
  this.setDefaults(obj.defaults);
175
180
  this.requirements = obj.requirements || {};
176
181
  this.bypassFirewall = obj.bypassFirewall ?? false;
182
+ this.areaRoleExempt = obj.areaRoleExempt ?? false;
177
183
  this.compile();
178
184
  }
179
185
  this.generateId();
@@ -335,7 +341,7 @@ var Route = class Route {
335
341
  const ctrl = this.controller?.name || "?";
336
342
  const action = this.classMethod || this.name;
337
343
  const mod = this.module?.name ? `@${this.module.name}/` : "";
338
- const auth = this.bypassFirewall ? " (no auth)" : "";
344
+ const auth = this.bypassFirewall ? " (no auth)" : this.areaRoleExempt ? " (hors rôle de zone)" : "";
339
345
  return `${method} ${this.path} → ${mod}${ctrl}.${action}${auth}`;
340
346
  }
341
347
  toObject() {
@@ -348,7 +354,8 @@ var Route = class Route {
348
354
  filePath: this.filePath,
349
355
  schemes: this.schemes,
350
356
  variables: this.variables,
351
- bypassFirewall: this.bypassFirewall
357
+ bypassFirewall: this.bypassFirewall,
358
+ areaRoleExempt: this.areaRoleExempt
352
359
  };
353
360
  }
354
361
  setDefaults(arg) {
@@ -34,6 +34,7 @@ declare const _default: {
34
34
  "nodefony-admin": {
35
35
  pattern: string;
36
36
  authenticators: string[];
37
+ roles: string[];
37
38
  };
38
39
  };
39
40
  };
@@ -64,7 +64,23 @@ declare class AdminApiController extends Controller {
64
64
  private idempotencyGate;
65
65
  /** Projette le Context courant en requête admin normalisée. */
66
66
  private buildRequest;
67
- /** Extrait les rôles de l'utilisateur ALS sans coupler le core à `IUser`. */
67
+ /**
68
+ * Extrait les rôles de l'utilisateur ALS sans coupler le core à `IUser`, en
69
+ * y **ajoutant ceux que la hiérarchie leur fait couvrir**.
70
+ *
71
+ * Sans cet aplatissement, un point d'entrée qui exige `ROLE_DEV` refuserait
72
+ * un administrateur de plateforme qui le couvre pourtant par hiérarchie : la
73
+ * décision du plan d'administration est une comparaison de noms
74
+ * (`isAdminGranted`), là où le jury d'autorisation et le verrou de frame
75
+ * consultent tous deux la hiérarchie. Trois portes, un même rôle exigé, deux
76
+ * verdicts possibles — et c'est la porte qu'on relit le moins qui serait la
77
+ * plus stricte.
78
+ *
79
+ * Le parcours est résolu **par nom** (`roleHierarchy`), comme le moteur
80
+ * d'autorisation : `@nodefony/framework` n'importe jamais `@nodefony/security`.
81
+ * Absent (module de sécurité non chargé), on rend les rôles bruts — on ne
82
+ * perd donc jamais ce que l'appelant porte déjà.
83
+ */
68
84
  private extractRoles;
69
85
  }
70
86
  export default AdminApiController;
@@ -121,6 +121,20 @@ export interface SecurityClause {
121
121
  export interface SecurityRequirement {
122
122
  /** Clauses en AND — toutes doivent être accordées (chacune est un OR interne). */
123
123
  readonly clauses: readonly SecurityClause[];
124
+ /**
125
+ * L'exigence porte-t-elle au moins une clause de **rôle** (`@IsGranted`), par
126
+ * opposition à des clauses de **scope** seules (`@RequireScope`) ?
127
+ *
128
+ * Les deux axes sont orthogonaux : un rôle dit QUI tu es, un scope dit ce
129
+ * qu'une clé déléguée peut faire EN TON NOM. Une action gardée par un scope
130
+ * seul n'a donc rien décidé de l'identité — et sur une session humaine le
131
+ * voter de scope accorde sans condition, puisqu'il ne contraint que les clés
132
+ * machine. C'est ce que lit le rôle par défaut d'une zone pour savoir s'il
133
+ * doit s'appliquer EN PLUS (`Resolver`) : sans cette distinction, un
134
+ * `@RequireScope` seul dispenserait du rôle de zone et ouvrirait la route à
135
+ * tout compte connecté.
136
+ */
137
+ readonly hasRoleClause: boolean;
124
138
  }
125
139
  /**
126
140
  * Directives CSP additionnelles déclarées par une action (`@Csp`) :
@@ -16,16 +16,6 @@ import { type RouteActionMeta, type RedirectMeta } from "../decorators/routerDec
16
16
  export interface ControllerWithInitialize {
17
17
  initialize(): Promise<this>;
18
18
  }
19
- /**
20
- * Résout une route vers son couple controller/action et exécute l'action —
21
- * UN Resolver est alloué par requête HTTP (et par connexion WS, réutilisé
22
- * par message). **POJO volontaire** (V3.1) : n'étend PAS `Service` — le
23
- * plumbing Service (Map de listeners trackés, spread d'options, lookups
24
- * kernel/syslog) coûtait par requête sans aucun consommateur (jamais écouté,
25
- * jamais loggé, jamais dans le container). Le DI per-request passe par
26
- * `context.container` (le cache `"controller"` y survit au Resolver : un
27
- * forward ou un 2ᵉ Resolver WS sur la MÊME connexion retrouve l'instance).
28
- */
29
19
  declare class Resolver implements IResolver {
30
20
  injector?: Injector | null;
31
21
  controller: ControllerConstructor | null;
@@ -57,6 +47,18 @@ declare class Resolver implements IResolver {
57
47
  * sur `context.method`.
58
48
  */
59
49
  methodOverride: string | null;
50
+ /**
51
+ * Cette résolution est-elle une invocation **par message** (pont WS-RPC
52
+ * `api.request`) plutôt que l'établissement de la connexion ?
53
+ *
54
+ * Posé par `Router.resolve` dès qu'un `cleanPathOverride` est fourni : c'est
55
+ * exactement ce qui distingue « la socket s'ouvre » de « la socket demande
56
+ * telle ressource ». Le premier n'accède à rien, le second accède — et seul
57
+ * le second doit subir le rôle par défaut de la zone ({@link _areaSecurity}),
58
+ * sans quoi l'ouverture même du tuyau deviendrait réservée aux
59
+ * administrateurs.
60
+ */
61
+ messageInvocation: boolean;
60
62
  constructor(context: ContextType);
61
63
  match(route: Route, context: ContextType, cleanPath?: string): ((string | null)[] & Record<string, unknown>) | null | undefined;
62
64
  /**
@@ -133,6 +135,29 @@ declare class Resolver implements IResolver {
133
135
  * le pont (`executeActionGuarded`) enveloppe la valeur nue.
134
136
  */
135
137
  private _callWithIdempotency;
138
+ /**
139
+ * Exigence d'autorisation **héritée de la zone** du firewall, pour une route
140
+ * qui n'en déclare aucune — ou `null` quand il n'y a rien à appliquer.
141
+ *
142
+ * Pourquoi ce filet existe : une zone qui n'exige qu'une **identité** laisse
143
+ * toute route qu'elle couvre accessible à n'importe quel compte authentifié.
144
+ * Sur une surface d'administration, le défaut doit être l'inverse — l'oubli
145
+ * se solde par un refus, jamais par une fuite. Le rôle se déclare donc UNE
146
+ * fois sur la zone, au lieu d'être recopié sur chaque route.
147
+ *
148
+ * Trois dispenses, et elles sont toutes des déclarations explicites :
149
+ * `bypassFirewall` (la route EST le mécanisme d'authentification),
150
+ * {@link Route.areaRoleExempt} (le pont du plan d'administration, qui résout un
151
+ * rôle par point d'entrée), et une garde d'action — traitée par l'appelant,
152
+ * qui n'entre ici que si `meta.security` est nul.
153
+ *
154
+ * L'exigence est **mémoïsée par zone** : les zones sont figées au boot et se
155
+ * comptent sur une main, donc la fabrication n'a lieu qu'une fois par zone
156
+ * sur la vie du processus — le hot path ne fait qu'un `get` de `WeakMap`.
157
+ *
158
+ * @returns l'exigence à évaluer, ou `null` s'il n'y en a aucune.
159
+ */
160
+ private _areaSecurity;
136
161
  /**
137
162
  * Évalue l'exigence d'autorisation (`@IsGranted`) d'une action via le service
138
163
  * `authorization` (par nom, 0 import security). Clauses en **AND**, attributs
@@ -37,6 +37,29 @@ export interface RouteOptions {
37
37
  * le mécanisme qu'elles servent. Défaut `false` (Zero Trust).
38
38
  */
39
39
  bypassFirewall?: boolean;
40
+ /**
41
+ * **INTERNE — n'est pas destiné aux applications.** Soustrait la route au
42
+ * rôle exigé par défaut dans sa zone du firewall (`ISecuredArea.roles`).
43
+ *
44
+ * Le nom dit ce que le drapeau FAIT, et non ce qu'il promettrait : il
45
+ * n'installe **aucune** garde, il en retire une. Posé sur une route qui ne
46
+ * décide de rien, il la rend accessible à tout compte connecté, en silence.
47
+ * C'est pourquoi un contrôle refuse toute route qui le porte hors des
48
+ * producteurs déclarés du framework — voir
49
+ * `tests/unit/areaRoleExemptInventory.test.ts`.
50
+ *
51
+ * Les deux seuls usages légitimes, tous deux internes : le pont du plan
52
+ * d'administration, qui résout un rôle **par point d'entrée**
53
+ * (`resolveAdminRole`) sous une route unique ; et les routes de gestion des
54
+ * clés personnelles, dont l'action est scopée au porteur courant. Sans eux,
55
+ * une zone fermée au rôle d'administrateur ferait disparaître le
56
+ * self-service (`me`, `sessions/mine`, `keys`) pour son propre destinataire.
57
+ *
58
+ * Une application qui veut protéger sa route déclare une garde
59
+ * (`@IsGranted`) : c'est une décision qui se relit, là où ceci est une
60
+ * exception qui se justifie. Défaut `false`.
61
+ */
62
+ areaRoleExempt?: boolean;
40
63
  }
41
64
  export interface RouteRequirements {
42
65
  domain?: string | string[];
@@ -85,6 +108,11 @@ declare class Route implements IRoute {
85
108
  methodsAllow?: string;
86
109
  varRegexp?: Record<string, RegExp>;
87
110
  bypassFirewall: boolean;
111
+ /**
112
+ * La route décide seule de son autorisation → la zone du firewall n'applique
113
+ * pas son rôle par défaut. Cf {@link RouteOptions.areaRoleExempt}.
114
+ */
115
+ areaRoleExempt: boolean;
88
116
  /**
89
117
  * P2.9 — Cache mémoïsé : l'action attend-elle le **flux brut** du body
90
118
  * (`@Body({ stream:true })`) ? `undefined` = pas encore calculé (résolu au 1er
package/docs/admin.md CHANGED
@@ -264,8 +264,8 @@ sequenceDiagram
264
264
 
265
265
  | # | Étape | Où |
266
266
  | --- | ----------------------------------------- | ---------------------------------------------------------- |
267
- | 1 | Le producteur s'enregistre | `AdminBroker.register()` (`AdminBroker.ts:45`) |
268
- | 2 | Le framework monte tout | `AdminBroker.mountAll()` (`AdminBroker.ts:104`) |
267
+ | 1 | Le producteur s'enregistre | `AdminBroker.register()` (`AdminBroker.ts:53`) |
268
+ | 2 | Le framework monte tout | `AdminBroker.mountAll()` (`AdminBroker.ts:112`) |
269
269
  | 3 | Une route par endpoint (nom déterministe) | `Router.createRoute()` (`AdminBroker.ts:124`) |
270
270
  | 4 | Le controller pont estampillé une fois | `Router.setController()` idempotent (`AdminBroker.ts:146`) |
271
271
  | 5 | Dispatch : lookup de la route | `AdminBroker.resolve()` (`AdminApiController.ts:94`) |
@@ -275,15 +275,15 @@ sequenceDiagram
275
275
  Points de conception saillants :
276
276
 
277
277
  - **Le montage FIGE les routes.** Après `mountAll()`, tout `register()` lève
278
- (`AdminBroker.ts:46`) : on ne monte pas une route à chaud (Zero surprise en prod). Le broker garde
278
+ (`AdminBroker.ts:53`) : on ne monte pas une route à chaud (Zero surprise en prod). Le broker garde
279
279
  la trace via son drapeau `mounted`.
280
- - **Le nom de route est déterministe** : `admin.<ns>.<method>.<path>` (`AdminBroker.ts:114`) — c'est
280
+ - **Le nom de route est déterministe** : `admin.<ns>.<method>.<path>` (`AdminBroker.ts:123`) — c'est
281
281
  la clé du lookup O(1) que le pont refait à chaque requête.
282
282
  - **`Router.setController` n'est appelé qu'une fois** par process (`AdminBroker.ts:146`) : il pose
283
283
  une propriété non réinscriptible sur le prototype ; une garde `hasOwnProperty` rend l'appel
284
284
  idempotent (multi-broker en test, re-boot).
285
285
  - **Le catalogue se construit à la volée** depuis `AdminBroker.list()` + `AdminBroker.routes()`
286
- (`AdminBroker.ts:100`) — jamais un état retenu.
286
+ (`AdminBroker.ts:108`) — jamais un état retenu.
287
287
 
288
288
  > [!IMPORTANT]
289
289
  > Convention de route **figée** : le data plane est toujours en **≥ 3 segments**
@@ -310,8 +310,8 @@ La décision est **fail-closed** : un authentifié **sans** le rôle requis —
310
310
  laissez-passer.
311
311
 
312
312
  - **Rôle par défaut** : sans `role` explicite, un endpoint exige `ROLE_NODEFONY_ADMIN`
313
- (`AdminBroker.ts:112` ; défaut du champ `IAdminEndpoint.role`, `IAdminApi.ts:152`).
314
- - **Endpoint public** : `public: true` (`IAdminApi.ts:152`) → le RBAC du broker est court-circuité
313
+ (`AdminBroker.ts:112` ; défaut du champ `IAdminEndpoint.role`, `IAdminApi.ts:166`).
314
+ - **Endpoint public** : `public: true` (`IAdminApi.ts:175`) → le RBAC du broker est court-circuité
315
315
  (`role === ""`, `adminRbac.ts:26`). À réserver aux sondes cloud-native (liveness/readiness) et à
316
316
  placer hors d'une zone fermée — sinon le firewall verrouille en amont. Exemple réel :
317
317
  `GET /nodefony/kernel/api/livez` (`KernelAdminApi.ts:615`), sorti de `nodefony-admin` par la zone
@@ -334,7 +334,7 @@ montée avec `[method, "WEBSOCKET"]`. Elle devient donc invocable par le pont WS
334
334
  expose son statut.
335
335
 
336
336
  Les **mutations** sont pontables par socket. La sécurité d'écriture repose alors sur l'**idempotence**
337
- (`idempotencyGate()`, `AdminApiController.ts:158`) : la clé `Idempotency-Key` est **obligatoire en
337
+ (`idempotencyGate()`, `AdminApiController.ts:131`) : la clé `Idempotency-Key` est **obligatoire en
338
338
  WS** (une socket reconnecte et rejoue), **optionnelle en HTTP** (`required: false`,
339
339
  `AdminApiController.ts:184`). Un `GET` n'est jamais idempotenté (`AdminApiController.ts:149`) ; la
340
340
  porte est évaluée **après** le RBAC (un 403 ne consomme aucune entrée). Le helper est le **même** que
@@ -364,18 +364,18 @@ sont enregistrés au `onKernelReady` du framework :
364
364
 
365
365
  | Namespace | Producteur | Rôle |
366
366
  | ----------- | ----------------------------------------------------- | ------------------------------------------------- |
367
- | `kernel` | `createKernelAdminApi` (`KernelAdminApi.ts:468`) | modules, process, uptime, `livez` |
368
- | `framework` | `createFrameworkAdminApi` (`FrameworkAdminApi.ts:40`) | dump du Router + **catalogue** + Playground (dev) |
367
+ | `kernel` | `createKernelAdminApi` (`KernelAdminApi.ts:582`) | modules, process, uptime, `livez` |
368
+ | `framework` | `createFrameworkAdminApi` (`FrameworkAdminApi.ts:86`) | dump du Router + **catalogue** + Playground (dev) |
369
369
  | `syslog` | `createSyslogAdminApi` (`SyslogAdminApi.ts:95`) | viewer de logs (dev) |
370
370
 
371
371
  Les modules externes s'enregistrent depuis leur propre `onKernelBoot` :
372
372
 
373
- | Namespace | Module | Enregistrement |
374
- | ---------- | -------------------- | --------------------------------------------------- |
375
- | `http` | `@nodefony/http` | `createHttpAdminApi` (`http/index.ts:121`) |
376
- | `security` | `@nodefony/security` | `registerSecurityAdminApi` (`security/index.ts:94`) |
377
- | `user` | `@nodefony/user` | `adminNamespace` (`UserAdminApi.ts:879`) |
378
- | `orm` | `@nodefony/orm-core` | `adminNamespace` (`OrmAdminApi.ts:537`) |
373
+ | Namespace | Module | Enregistrement |
374
+ | ---------- | -------------------- | ---------------------------------------------------- |
375
+ | `http` | `@nodefony/http` | `createHttpAdminApi` (`http/index.ts:121`) |
376
+ | `security` | `@nodefony/security` | `registerSecurityAdminApi` (`security/index.ts:104`) |
377
+ | `user` | `@nodefony/user` | `adminNamespace` (`UserAdminApi.ts:945`) |
378
+ | `orm` | `@nodefony/orm-core` | `adminNamespace` (`OrmAdminApi.ts:709`) |
379
379
 
380
380
  Pour le détail de chacun, se reporter à la doc de son module — le broker reste agnostique de leur
381
381
  contenu.
@@ -389,14 +389,14 @@ recopiées ici (elles s'y périmeraient).
389
389
 
390
390
  | Membre (`IAdminBroker`) | Rôle | Ancre |
391
391
  | ------------------------ | -------------------------------------------------------- | -------------------- |
392
- | `register(api)` | Enregistre un producteur (throw si namespace pris/monté) | `AdminBroker.ts:45` |
393
- | `unregister(ns)` | Retire un producteur (et ses routes si montées) | `AdminBroker.ts:61` |
394
- | `has(ns)` / `getApi(ns)` | Interrogation du registre | `AdminBroker.ts:79` |
395
- | `list()` | Producteurs enregistrés (immuable) | `AdminBroker.ts:87` |
396
- | `resolvePath(ns, path)` | Chemin absolu d'un endpoint sans le monter | `AdminBroker.ts:91` |
397
- | `mountAll()` | Monte toutes les routes (idempotent) | `AdminBroker.ts:104` |
398
- | `resolve(routeName)` | Lookup O(1) d'une route montée (utilisé par le pont) | `AdminBroker.ts:96` |
399
- | `routes()` | Introspection des routes montées (source du catalogue) | `AdminBroker.ts:100` |
392
+ | `register(api)` | Enregistre un producteur (throw si namespace pris/monté) | `AdminBroker.ts:53` |
393
+ | `unregister(ns)` | Retire un producteur (et ses routes si montées) | `AdminBroker.ts:69` |
394
+ | `has(ns)` / `getApi(ns)` | Interrogation du registre | `AdminBroker.ts:91` |
395
+ | `list()` | Producteurs enregistrés (immuable) | `AdminBroker.ts:95` |
396
+ | `resolvePath(ns, path)` | Chemin absolu d'un endpoint sans le monter | `AdminBroker.ts:99` |
397
+ | `mountAll()` | Monte toutes les routes (idempotent) | `AdminBroker.ts:112` |
398
+ | `resolve(routeName)` | Lookup O(1) d'une route montée (utilisé par le pont) | `AdminBroker.ts:104` |
399
+ | `routes()` | Introspection des routes montées (source du catalogue) | `AdminBroker.ts:108` |
400
400
 
401
401
  ## 📡 Observabilité — Studio
402
402
 
@@ -416,7 +416,7 @@ recopiées ici (elles s'y périmeraient).
416
416
 
417
417
  | Symptôme | Cause (dans le code) | Correction |
418
418
  | ------------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
419
- | `register()` throw « routes figées » | Appel **après** `mountAll()` (`AdminBroker.ts:46`) | Enregistrer au `onKernelBoot`, pas plus tard |
419
+ | `register()` throw « routes figées » | Appel **après** `mountAll()` (`AdminBroker.ts:112`) | Enregistrer au `onKernelBoot`, pas plus tard |
420
420
  | `register()` throw « namespace déjà enregistré » | Deux producteurs sur le même `adminNamespace` (`AdminBroker.ts:51`) | Namespace unique ; garder `register()` idempotent (`has(ns)` avant) |
421
421
  | 401 sur toute route `/nodefony/<ns>/api/*` | Zone `nodefony-admin` : pas de session BFF (`config.ts:141`) | S'authentifier (login BFF) ; pour une sonde publique → `public: true` + zone anonyme |
422
422
  | 403 alors qu'on est connecté | Rôle manquant, `isAdminGranted` fail-closed (`adminRbac.ts:24`) | Doter le compte du rôle requis (défaut `ROLE_NODEFONY_ADMIN`) |