@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.
- package/dist/nodefony/config/config.js +2 -1
- package/dist/nodefony/controller/AdminApiController.js +24 -4
- package/dist/nodefony/controller/ApiKeyController.js +2 -1
- package/dist/nodefony/controller/TotpController.js +2 -1
- package/dist/nodefony/decorators/routerDecorators.js +4 -1
- package/dist/nodefony/service/AdminBroker.js +2 -1
- package/dist/nodefony/service/router.js +1 -0
- package/dist/nodefony/src/Resolver.js +61 -0
- package/dist/nodefony/src/Route.js +9 -2
- package/dist/types/nodefony/config/config.d.ts +1 -0
- package/dist/types/nodefony/controller/AdminApiController.d.ts +17 -1
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +14 -0
- package/dist/types/nodefony/src/Resolver.d.ts +35 -10
- package/dist/types/nodefony/src/Route.d.ts +28 -0
- package/docs/idempotence.md +1 -1
- package/package.json +6 -6
|
@@ -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
|
-
/**
|
|
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
|
|
157
|
-
if (Array.isArray(
|
|
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({
|
|
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
|
|
@@ -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) {
|
|
@@ -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
|
-
/**
|
|
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/idempotence.md
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
54
|
+
"@nodefony/http": "^10.0.0-alpha.8",
|
|
55
55
|
"@types/chai": "5.2.3",
|
|
56
|
-
"@types/node": "26.6.
|
|
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.
|
|
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.
|
|
69
|
-
"nodefony": "^10.0.0-alpha.
|
|
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": [
|