@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.
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +5 -4
- 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 +4 -3
- package/dist/nodefony/service/IdempotencyStore.js +2 -2
- package/dist/nodefony/service/router.js +3 -2
- package/dist/nodefony/src/KernelAdminApi.js +12 -7
- 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/admin.md +25 -25
- package/docs/controller.md +44 -44
- package/docs/decorateurs.md +79 -79
- package/docs/idempotence.md +70 -65
- package/docs/routing.md +40 -40
- package/docs/templates.md +7 -7
- package/package.json +10 -10
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
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.
|
|
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.
|
|
11
|
-
import __decorate from "./_virtual/_@oxc-project_runtime@0.
|
|
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, {
|
|
@@ -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
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.
|
|
2
|
-
import __decorate from "../../_virtual/_@oxc-project_runtime@0.
|
|
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.
|
|
2
|
-
import __decorate from "../../_virtual/_@oxc-project_runtime@0.
|
|
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.
|
|
5
|
-
import __decorate from "../../_virtual/_@oxc-project_runtime@0.
|
|
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
|
|
54
|
-
if (!
|
|
53
|
+
const manifest = parseStoreManifest(JSON.parse(readFileSync(pkgJson, "utf8")));
|
|
54
|
+
if (!manifest?.provides) return null;
|
|
55
55
|
return {
|
|
56
|
-
storeKind:
|
|
57
|
-
stores:
|
|
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
|
|
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:
|
|
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) {
|
|
@@ -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/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:
|
|
268
|
-
| 2 | Le framework monte tout | `AdminBroker.mountAll()` (`AdminBroker.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
314
|
-
- **Endpoint public** : `public: true` (`IAdminApi.ts:
|
|
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:
|
|
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:
|
|
368
|
-
| `framework` | `createFrameworkAdminApi` (`FrameworkAdminApi.ts:
|
|
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:
|
|
377
|
-
| `user` | `@nodefony/user` | `adminNamespace` (`UserAdminApi.ts:
|
|
378
|
-
| `orm` | `@nodefony/orm-core` | `adminNamespace` (`OrmAdminApi.ts:
|
|
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:
|
|
393
|
-
| `unregister(ns)` | Retire un producteur (et ses routes si montées) | `AdminBroker.ts:
|
|
394
|
-
| `has(ns)` / `getApi(ns)` | Interrogation du registre | `AdminBroker.ts:
|
|
395
|
-
| `list()` | Producteurs enregistrés (immuable) | `AdminBroker.ts:
|
|
396
|
-
| `resolvePath(ns, path)` | Chemin absolu d'un endpoint sans le monter | `AdminBroker.ts:
|
|
397
|
-
| `mountAll()` | Monte toutes les routes (idempotent) | `AdminBroker.ts:
|
|
398
|
-
| `resolve(routeName)` | Lookup O(1) d'une route montée (utilisé par le pont) | `AdminBroker.ts:
|
|
399
|
-
| `routes()` | Introspection des routes montées (source du catalogue) | `AdminBroker.ts:
|
|
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:
|
|
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`) |
|