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

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.
@@ -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
@@ -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,
@@ -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();
@@ -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
@@ -368,7 +368,7 @@ MongoDB mérite une mention : `mongoose` n'implémente **pas encore** de store d
368
368
  `NF_DATABASE_URL=mongodb://…` fait donc tomber la résolution en étape **4** → repli `memory` avec la
369
369
  raison annoncée — et un repli `memory` en cluster ne déduplique plus rien entre pods : le rejeu que
370
370
  cette brique promet d'empêcher passe sur un autre pod. En attendant le store Mongo (objectif « full
371
- NoSQL », `MIGRATION_STATUS.md` P7.11), la dédup cross-pod passe par `redis`.
371
+ NoSQL »), la dédup cross-pod passe par `redis`.
372
372
 
373
373
  ### Le contrat de dégradation : fail-loud, jamais silencieux
374
374
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/framework",
3
- "version": "10.0.0-alpha.7",
3
+ "version": "10.0.0-alpha.8",
4
4
  "description": "Le modèle de programmation Nodefony : routeur, contrôleurs, décorateurs et vues — HTTP et WebSocket dans le même contexte",
5
5
  "author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
6
6
  "main": "./dist/index.js",
@@ -51,12 +51,12 @@
51
51
  "tslib": "2.8.1"
52
52
  },
53
53
  "devDependencies": {
54
- "@nodefony/http": "^10.0.0-alpha.7",
54
+ "@nodefony/http": "^10.0.0-alpha.8",
55
55
  "@types/chai": "5.2.3",
56
- "@types/node": "26.6.1",
56
+ "@types/node": "26.6.2",
57
57
  "@vitest/coverage-v8": "5.0.1",
58
58
  "chai": "6.2.2",
59
- "nodefony": "^10.0.0-alpha.7",
59
+ "nodefony": "^10.0.0-alpha.8",
60
60
  "rimraf": "6.1.3",
61
61
  "tsx": "4.23.13",
62
62
  "vitest": "5.0.1"
@@ -65,8 +65,8 @@
65
65
  "readmeFilename": "README.md",
66
66
  "contributors": [],
67
67
  "peerDependencies": {
68
- "@nodefony/http": "^10.0.0-alpha.7",
69
- "nodefony": "^10.0.0-alpha.7",
68
+ "@nodefony/http": "^10.0.0-alpha.8",
69
+ "nodefony": "^10.0.0-alpha.8",
70
70
  "zod": "^4.6.5"
71
71
  },
72
72
  "files": [