@nodefony/framework 10.0.0-alpha.1
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/LICENSE +544 -0
- package/README.md +50 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +211 -0
- package/dist/nodefony/config/config.js +61 -0
- package/dist/nodefony/config/defineModuleConfig.js +36 -0
- package/dist/nodefony/controller/AdminApiController.js +163 -0
- package/dist/nodefony/controller/ApiKeyController.js +151 -0
- package/dist/nodefony/controller/BenchController.js +132 -0
- package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
- package/dist/nodefony/controller/OAuth2Controller.js +133 -0
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
- package/dist/nodefony/controller/SessionAuthController.js +141 -0
- package/dist/nodefony/controller/TokenAuthController.js +113 -0
- package/dist/nodefony/controller/TotpController.js +129 -0
- package/dist/nodefony/controller/WebAuthnController.js +242 -0
- package/dist/nodefony/controller/oauthAuthority.js +74 -0
- package/dist/nodefony/decorators/routerDecorators.js +967 -0
- package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
- package/dist/nodefony/interfaces/IController.js +1 -0
- package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
- package/dist/nodefony/interfaces/IResolver.js +1 -0
- package/dist/nodefony/interfaces/IRoute.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/AdminBroker.js +106 -0
- package/dist/nodefony/service/Eta.js +68 -0
- package/dist/nodefony/service/IdempotencyStore.js +136 -0
- package/dist/nodefony/service/router.js +243 -0
- package/dist/nodefony/src/Controller.js +515 -0
- package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
- package/dist/nodefony/src/KernelAdminApi.js +1243 -0
- package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
- package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
- package/dist/nodefony/src/Resolver.js +416 -0
- package/dist/nodefony/src/ResourceController.js +148 -0
- package/dist/nodefony/src/Route.js +476 -0
- package/dist/nodefony/src/SyslogAdminApi.js +466 -0
- package/dist/nodefony/src/Template.js +15 -0
- package/dist/nodefony/src/configMutation.js +186 -0
- package/dist/nodefony/src/docsReader.js +929 -0
- package/dist/nodefony/src/idempotency.js +137 -0
- package/dist/nodefony/src/idempotencyGc.js +32 -0
- package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
- package/dist/nodefony/src/scopeCatalog.js +40 -0
- package/dist/nodefony/src/syslogFilters.js +51 -0
- package/dist/types/index.d.ts +96 -0
- package/dist/types/nodefony/config/config.d.ts +41 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
- package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
- package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
- package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
- package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
- package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
- package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
- package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
- package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
- package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
- package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
- package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
- package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
- package/dist/types/nodefony/interfaces/index.d.ts +5 -0
- package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
- package/dist/types/nodefony/service/Eta.d.ts +25 -0
- package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
- package/dist/types/nodefony/service/router.d.ts +53 -0
- package/dist/types/nodefony/src/Controller.d.ts +193 -0
- package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
- package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
- package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
- package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
- package/dist/types/nodefony/src/Resolver.d.ts +165 -0
- package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
- package/dist/types/nodefony/src/Route.d.ts +192 -0
- package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
- package/dist/types/nodefony/src/Template.d.ts +8 -0
- package/dist/types/nodefony/src/configMutation.d.ts +109 -0
- package/dist/types/nodefony/src/docsReader.d.ts +369 -0
- package/dist/types/nodefony/src/idempotency.d.ts +96 -0
- package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
- package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
- package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
- package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
- package/docs/admin.md +451 -0
- package/docs/controller.md +645 -0
- package/docs/decorateurs.md +845 -0
- package/docs/idempotence.md +741 -0
- package/docs/index.md +151 -0
- package/docs/routing.md +648 -0
- package/docs/templates.md +380 -0
- package/package.json +83 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import config_default, { frameworkConfigSchema } from "./nodefony/config/config.js";
|
|
2
|
+
import { defineFrameworkConfig, frameworkConfigJsonSchema } from "./nodefony/config/defineModuleConfig.js";
|
|
3
|
+
import { getIdempotencyStoreFactory, listIdempotencyBackends, listIdempotencyStores, registerIdempotencyStore } from "./nodefony/src/idempotencyStoreRegistry.js";
|
|
4
|
+
import { RedisIdempotencyStore } from "./nodefony/src/RedisIdempotencyStore.js";
|
|
5
|
+
import { scheduleIdempotencyGc } from "./nodefony/src/idempotencyGc.js";
|
|
6
|
+
import Route from "./nodefony/src/Route.js";
|
|
7
|
+
import Controller from "./nodefony/src/Controller.js";
|
|
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
|
+
import Resolver from "./nodefony/src/Resolver.js";
|
|
10
|
+
import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js";
|
|
11
|
+
import __decorate from "./_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js";
|
|
12
|
+
import router_default from "./nodefony/service/router.js";
|
|
13
|
+
import ResourceController from "./nodefony/src/ResourceController.js";
|
|
14
|
+
import AdminApiController from "./nodefony/controller/AdminApiController.js";
|
|
15
|
+
import AdminBroker_default from "./nodefony/service/AdminBroker.js";
|
|
16
|
+
import IdempotencyStore_default from "./nodefony/service/IdempotencyStore.js";
|
|
17
|
+
import SessionAuthController, { mountSessionAuthRoutes } from "./nodefony/controller/SessionAuthController.js";
|
|
18
|
+
import TokenAuthController, { mountTokenAuthRoutes } from "./nodefony/controller/TokenAuthController.js";
|
|
19
|
+
import IssuerMetadataController, { mountIssuerMetadataRoutes } from "./nodefony/controller/IssuerMetadataController.js";
|
|
20
|
+
import ProtectedResourceMetadataController, { collectProtectedResources, mountProtectedResourceRoutes, protectedResourceRoutePaths } from "./nodefony/controller/ProtectedResourceMetadataController.js";
|
|
21
|
+
import WebAuthnController, { mountWebAuthnRoutes } from "./nodefony/controller/WebAuthnController.js";
|
|
22
|
+
import OAuth2Controller, { mountOAuth2Routes } from "./nodefony/controller/OAuth2Controller.js";
|
|
23
|
+
import BenchController, { mountBenchRoutes } from "./nodefony/controller/BenchController.js";
|
|
24
|
+
import ApiKeyController, { mountApiKeyRoutes } from "./nodefony/controller/ApiKeyController.js";
|
|
25
|
+
import TotpController, { mountTotpRoutes } from "./nodefony/controller/TotpController.js";
|
|
26
|
+
import { createKernelAdminApi } from "./nodefony/src/KernelAdminApi.js";
|
|
27
|
+
import { buildPlaygroundSnapshot } from "./nodefony/src/PlaygroundAdminApi.js";
|
|
28
|
+
import { createFrameworkAdminApi } from "./nodefony/src/FrameworkAdminApi.js";
|
|
29
|
+
import { createSyslogAdminApi } from "./nodefony/src/SyslogAdminApi.js";
|
|
30
|
+
import Eta from "./nodefony/service/Eta.js";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import { AUTO_STORE, EMPTY_INFRA, Kernel, Module, readStoreLocation, resolveAutoStore, services } from "nodefony";
|
|
33
|
+
import { mergeResolvers, mergeTypeDefs } from "@graphql-tools/merge";
|
|
34
|
+
import { makeExecutableSchema, mergeSchemas } from "@graphql-tools/schema";
|
|
35
|
+
//#region index.ts
|
|
36
|
+
registerIdempotencyStore("redis", (ctx) => {
|
|
37
|
+
const redis = ctx.module.kernel?.container?.get("redis");
|
|
38
|
+
if (!redis) throw new Error("the @nodefony/redis module is not loaded (add use(\"@nodefony/redis\") in nodefony.config.ts)");
|
|
39
|
+
return new RedisIdempotencyStore(() => redis.getClient("main") ?? null, void 0, void 0, () => typeof redis.keyPrefix === "function" ? redis.keyPrefix("nf:idem") : "nf:idem");
|
|
40
|
+
});
|
|
41
|
+
let Framework = class Framework extends Module {
|
|
42
|
+
/** Balayage périodique du store d'idempotence SQL (drizzle) — `null` sinon. */
|
|
43
|
+
#idempotencyGc = null;
|
|
44
|
+
/**
|
|
45
|
+
* Store d'idempotence distribué (drizzle/redis) posé au container, retenu pour
|
|
46
|
+
* rafraîchir sa `location` au `onKernelReady` (à `onKernelBoot` l'ORM n'est pas
|
|
47
|
+
* encore connecté → `readStoreLocation` vide). `null` = repli mémoire (per-pod).
|
|
48
|
+
*/
|
|
49
|
+
#idempotencyStore = null;
|
|
50
|
+
constructor(kernel) {
|
|
51
|
+
super("framework", kernel, import.meta.url, config_default);
|
|
52
|
+
}
|
|
53
|
+
/** JSON Schema de la config framework → data plane admin (config riche Studio). */
|
|
54
|
+
configSchema() {
|
|
55
|
+
return frameworkConfigJsonSchema();
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Phase `onRegister` : valide la config du module via le builder
|
|
59
|
+
* ({@link defineFrameworkConfig}) AVANT que les `@services` (Router,
|
|
60
|
+
* AdminBroker — qui lisent `module.options.router`/`.adminBroker`) ne soient
|
|
61
|
+
* instanciés à `onBoot`. Une config invalide plante proprement ici avec un
|
|
62
|
+
* message clair, plutôt qu'un `undefined.x` silencieux en runtime
|
|
63
|
+
* (cf `feedback_config_validation_zod`). La config validée est ré-assignée à
|
|
64
|
+
* `this.options` (matérialise les défauts, préserve `router`/`adminBroker`).
|
|
65
|
+
*/
|
|
66
|
+
async onKernelRegister() {
|
|
67
|
+
this.options = defineFrameworkConfig(this.options ?? {});
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Phase `onBoot` (après les `@services` à `onPreBoot` → le défaut mémoire
|
|
72
|
+
* `idempotencyStore` est déjà enregistré, ET après l'`onPreBoot` des autres
|
|
73
|
+
* modules → le service `redis` est résoluble) : si la config sélectionne un
|
|
74
|
+
* store d'idempotence **distribué** (`idempotency.store` ≠ `memory`), le résout
|
|
75
|
+
* via le registre et **override** le service `idempotencyStore` → toutes les
|
|
76
|
+
* mutations (`@Idempotent` + data plane admin) dédupliquent cross-pod.
|
|
77
|
+
*
|
|
78
|
+
* **Politique en cas d'échec de résolution** (nom inconnu, ou store distribué
|
|
79
|
+
* qui ne peut pas s'initialiser — ex. `idempotency.store="redis"` sans module
|
|
80
|
+
* `@nodefony/redis`) :
|
|
81
|
+
* - **prod** → **fatal** (rethrow → le module framework est `critical` → boot
|
|
82
|
+
* avorté) : en cluster multi-pod, dégrader en silence vers le cache per-pod
|
|
83
|
+
* serait du double-effet non dédupliqué (cf « pas de dégradation silencieuse »).
|
|
84
|
+
* - **dev/test** (mono-pod) → **WARNING fort + fallback sur le cache mémoire**
|
|
85
|
+
* déjà en place : la dédup per-pod suffit hors cluster, et on ne casse PAS le
|
|
86
|
+
* framework (= le routeur) pour une option d'infra absente en local. Jamais
|
|
87
|
+
* silencieux (le WARNING annonce la dégradation).
|
|
88
|
+
*/
|
|
89
|
+
async onKernelBoot() {
|
|
90
|
+
const configured = this.options?.idempotency?.store ?? AUTO_STORE;
|
|
91
|
+
let name = configured;
|
|
92
|
+
let reason = `store explicitement configuré ("${configured}")`;
|
|
93
|
+
if (name === AUTO_STORE) {
|
|
94
|
+
const auto = resolveAutoStore("ephemeral", this.kernel?.infra ?? EMPTY_INFRA, listIdempotencyBackends());
|
|
95
|
+
name = auto.store;
|
|
96
|
+
reason = auto.reason;
|
|
97
|
+
this.log(`idempotency.store "auto" → "${name}" (${auto.reason})`, "INFO");
|
|
98
|
+
}
|
|
99
|
+
if (name === "memory") {
|
|
100
|
+
if (this.kernel?.environment === "production") this.log("idempotency.store \"memory\" en PRODUCTION — déduplication per-pod uniquement : un rejeu routé vers un autre pod n'est pas dédupliqué (double-effet possible). Déclarer une infra partagée (NF_REDIS_URL ou NF_DATABASE_URL).", "WARNING");
|
|
101
|
+
this.kernel?.registerStoreResolution({
|
|
102
|
+
brick: "idempotency",
|
|
103
|
+
nature: "ephemeral",
|
|
104
|
+
configured,
|
|
105
|
+
resolved: "memory",
|
|
106
|
+
available: listIdempotencyBackends(),
|
|
107
|
+
reason,
|
|
108
|
+
configPath: "framework.idempotency.store"
|
|
109
|
+
});
|
|
110
|
+
return this;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const factory = getIdempotencyStoreFactory(name);
|
|
114
|
+
if (!factory) throw new Error(`not registered (known distributed stores: [${listIdempotencyStores().join(", ") || "none"}])`);
|
|
115
|
+
const store = factory({
|
|
116
|
+
module: this,
|
|
117
|
+
config: this.options
|
|
118
|
+
});
|
|
119
|
+
this.set("idempotencyStore", store);
|
|
120
|
+
this.#idempotencyStore = store;
|
|
121
|
+
this.log(`Idempotency store → "${name}" (distributed)`, "INFO");
|
|
122
|
+
this.kernel?.registerStoreResolution({
|
|
123
|
+
brick: "idempotency",
|
|
124
|
+
nature: "ephemeral",
|
|
125
|
+
configured,
|
|
126
|
+
resolved: name,
|
|
127
|
+
available: listIdempotencyBackends(),
|
|
128
|
+
reason,
|
|
129
|
+
configPath: "framework.idempotency.store",
|
|
130
|
+
location: readStoreLocation(store)
|
|
131
|
+
});
|
|
132
|
+
const idem = this.options.idempotency;
|
|
133
|
+
this.#idempotencyGc = scheduleIdempotencyGc(store, {
|
|
134
|
+
intervalS: idem.gcIntervalS,
|
|
135
|
+
jitter: idem.gcJitter,
|
|
136
|
+
onError: (e) => this.log(e, "WARNING"),
|
|
137
|
+
log: (m) => this.log(m, "INFO")
|
|
138
|
+
});
|
|
139
|
+
if (this.#idempotencyGc) this.kernel?.once("onTerminate", () => this.#idempotencyGc?.stop());
|
|
140
|
+
} catch (e) {
|
|
141
|
+
const msg = `[@nodefony/framework] idempotency.store="${name}" failed to initialize: ${e.message}.`;
|
|
142
|
+
if (this.kernel?.environment === "production") throw new Error(`${msg} A distributed store is mandatory in production.`, { cause: e });
|
|
143
|
+
this.log(`${msg} Falling back to the per-pod "memory" store (dev/test only; a distributed store is required for a multi-pod cluster).`, "WARNING");
|
|
144
|
+
}
|
|
145
|
+
return this;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Phase `onReady` (après le boot de tous les modules) : enregistre le
|
|
149
|
+
* producteur admin du kernel puis monte le data plane `/nodefony/<ns>/api/*`.
|
|
150
|
+
*
|
|
151
|
+
* Les autres modules s'enregistrent dans leur `onKernelBoot` / `onKernelReady`
|
|
152
|
+
* (le module realtime auto-enregistre son producteur `realtime` ici) → tous
|
|
153
|
+
* présents au moment du `mountAll()` qui clôt cette phase.
|
|
154
|
+
*/
|
|
155
|
+
async onKernelReady() {
|
|
156
|
+
const idemLocation = readStoreLocation(this.#idempotencyStore);
|
|
157
|
+
if (idemLocation && this.kernel) {
|
|
158
|
+
const current = this.kernel.storeResolutions.find((r) => r.brick === "idempotency");
|
|
159
|
+
if (current && !current.location) this.kernel.registerStoreResolution({
|
|
160
|
+
...current,
|
|
161
|
+
location: idemLocation
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const broker = this.kernel?.container?.get("adminBroker");
|
|
165
|
+
if (broker && this.kernel) {
|
|
166
|
+
if (!broker.has("kernel")) broker.register(createKernelAdminApi(this.kernel));
|
|
167
|
+
if (!broker.has("framework")) broker.register(createFrameworkAdminApi(broker, { playground: this.kernel.environment === "development" || Boolean(this.kernel.debug) }));
|
|
168
|
+
if (this.kernel.syslog && !broker.has("syslog")) {
|
|
169
|
+
const logDir = path.resolve(process.cwd(), this.kernel.options?.log?.dir ?? "logs");
|
|
170
|
+
broker.register(createSyslogAdminApi(this.kernel.syslog, {
|
|
171
|
+
logDir,
|
|
172
|
+
enableFiles: this.kernel.environment !== "production",
|
|
173
|
+
environment: this.kernel.environment
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
broker.mountAll();
|
|
177
|
+
}
|
|
178
|
+
if (this.kernel?.container?.get("authFlow")) mountSessionAuthRoutes(this);
|
|
179
|
+
if (this.kernel?.container?.get("tokenService")) {
|
|
180
|
+
mountTokenAuthRoutes(this);
|
|
181
|
+
const issuer = this.kernel.container.get("tokenService")?.publishedIssuer();
|
|
182
|
+
if (issuer) mountIssuerMetadataRoutes(this, issuer);
|
|
183
|
+
}
|
|
184
|
+
const container = this.kernel?.container;
|
|
185
|
+
if (container) {
|
|
186
|
+
const mountedCount = mountProtectedResourceRoutes(this, collectProtectedResources(container));
|
|
187
|
+
if (mountedCount > 0) this.log(`OAuth resource server — ${mountedCount} protected resource document(s) published (RFC 9728)`, "DEBUG");
|
|
188
|
+
}
|
|
189
|
+
if (this.kernel?.container?.get("webauthn")) mountWebAuthnRoutes(this);
|
|
190
|
+
if (this.kernel?.container?.get("oauth2")) mountOAuth2Routes(this);
|
|
191
|
+
if (process.env.NF_BENCH_ROUTE === "1") mountBenchRoutes(this);
|
|
192
|
+
if (this.kernel?.container?.get("apiKeys")) mountApiKeyRoutes(this);
|
|
193
|
+
if (this.kernel?.container?.get("totp")) mountTotpRoutes(this);
|
|
194
|
+
return this;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
Framework = __decorate([services([
|
|
198
|
+
router_default,
|
|
199
|
+
Eta,
|
|
200
|
+
AdminBroker_default,
|
|
201
|
+
IdempotencyStore_default
|
|
202
|
+
]), __decorateMetadata("design:paramtypes", [typeof Kernel === "undefined" ? Object : Kernel])], Framework);
|
|
203
|
+
const graphql = {
|
|
204
|
+
mergeSchemas,
|
|
205
|
+
makeExecutableSchema,
|
|
206
|
+
mergeResolvers,
|
|
207
|
+
mergeTypeDefs
|
|
208
|
+
};
|
|
209
|
+
var framework_default = Framework;
|
|
210
|
+
//#endregion
|
|
211
|
+
export { AdminApiController, AdminBroker_default as AdminBroker, All, Anonymous, ApiKeyController, BenchController, Body, BypassFirewall, Controller, Cookie, Csp, CsrfExempt, CsrfProtect, CurrentUser, Delete, Domain, Eta, Get, Head, Header, Headers, HttpCode, Idempotent, IsGranted, IssuerMetadataController, IdempotencyStore_default as MemoryIdempotencyStore, OAuth2Controller, Options, Param, Patch, Post, ProtectedResourceMetadataController, Put, Query, Redirect, Req, RequireScope, Res, Resolver, ResourceController, Route, router_default as Router, Scope, Session, SessionAuthController, TokenAuthController, TotpController, UploadedFile, UploadedFiles, UseSession, WebAuthnController, buildPlaygroundSnapshot, collectProtectedResources, controller, controllers, createFrameworkAdminApi, createKernelAdminApi, createSyslogAdminApi, framework_default as default, defineFrameworkConfig, frameworkConfigJsonSchema, frameworkConfigSchema, getIdempotencyStoreFactory, graphql, listIdempotencyStores, mountApiKeyRoutes, mountBenchRoutes, mountIssuerMetadataRoutes, mountOAuth2Routes, mountProtectedResourceRoutes, mountSessionAuthRoutes, mountTokenAuthRoutes, mountTotpRoutes, mountWebAuthnRoutes, protectedResourceRoutePaths, registerIdempotencyStore, route, routeExpectsBodyStream };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
//#region nodefony/config/config.ts
|
|
3
|
+
/**
|
|
4
|
+
* @nodefony/framework — CONFIGURATION DU MODULE (schéma Zod = source unique).
|
|
5
|
+
*
|
|
6
|
+
* ⭐ TL;DR : CE SCHÉMA EST LA CONFIG. Chaque `.default(...)` = la valeur d'usine ;
|
|
7
|
+
* changer un défaut du module = ÉDITER ICI (et nulle part ailleurs). L'app, elle,
|
|
8
|
+
* surcharge via `use("@nodefony/...", { … })` dans SON `nodefony.config.ts`.
|
|
9
|
+
*
|
|
10
|
+
* RÈGLE D'OR (ADR-0006) : ce fichier porte le **schéma Zod commenté** (type +
|
|
11
|
+
* validation + défaut + doc) ET matérialise les défauts. Aucune valeur n'est
|
|
12
|
+
* re-tapée ailleurs. Le builder (`defineModuleConfig.ts` →
|
|
13
|
+
* `defineFrameworkConfig`) importe le schéma D'ICI (nœud bas : ce fichier
|
|
14
|
+
* n'importe que `zod` → pas de cycle). La config est validée au boot du Module
|
|
15
|
+
* class (hook `onKernelRegister`, cf `index.ts`) → plante propre avec un
|
|
16
|
+
* message clair si la config est invalide, plutôt qu'un `undefined.x`
|
|
17
|
+
* silencieux en runtime.
|
|
18
|
+
*
|
|
19
|
+
* ## Surface volontairement minimale
|
|
20
|
+
*
|
|
21
|
+
* Le framework n'expose presque aucune option : son rôle (Router/Resolver/
|
|
22
|
+
* Controller/décorateurs) est piloté par les décorateurs et le code, pas par la
|
|
23
|
+
* config. Les seules clés réellement consommées dans le source :
|
|
24
|
+
* - `router` / `adminBroker` — bags d'options de **Service de base** transmis
|
|
25
|
+
* tels quels aux Services `Router` / `AdminBroker` (4ᵉ arg du `super(...)`).
|
|
26
|
+
* Aucune forme métier figée → `looseObject` + `optional` (ne RIEN stripper,
|
|
27
|
+
* absent par défaut = `undefined`, comme avant validation).
|
|
28
|
+
*
|
|
29
|
+
* ## Pureté
|
|
30
|
+
*
|
|
31
|
+
* Aucun `Nodefony.getKernel()` ni `process.env` → sortie déterministe,
|
|
32
|
+
* sérialisable en JSON Schema (`frameworkConfigJsonSchema` dans
|
|
33
|
+
* `defineModuleConfig.ts`) pour Studio.
|
|
34
|
+
*/
|
|
35
|
+
const serviceOptionsSchema = z.looseObject({});
|
|
36
|
+
const idempotencySchema = z.strictObject({
|
|
37
|
+
store: z.string().default("auto").describe("Backing du cache d'idempotence des mutations (`@Idempotent` + data plane admin). `auto` (défaut) = suit l'infra déclarée (NF_REDIS_URL → redis, sinon NF_DATABASE_URL → drizzle, sinon memory per-pod). `memory` = cache per-pod (la socket reste affine à son pod). Un nom DISTRIBUÉ (`redis`, `drizzle`) est enregistré via `registerIdempotencyStore(name, …)` (auto-register par les adapters) ET résolu au boot → override du défaut mémoire. Un nom EXPLICITE dont l'initialisation échoue AVORTE le boot en PRODUCTION uniquement (fail-loud : pas de dédup silencieuse en cluster) ; en dev/test, WARNING + repli mémoire pour ne pas bloquer une machine sans infra. Reco prod multi-pod : `redis` (SET NX + TTL natif)."),
|
|
38
|
+
gcIntervalS: z.number().int().min(0).default(600).describe("Intervalle de purge des clés d'idempotence expirées (s), HORS hot-path. N'a d'effet QUE pour un store SANS expiration native (`drizzle` → `DELETE WHERE expiresAt<=now`) ; `redis` (TTL `PX`) et `memory` (purge passive) l'ignorent. 0 = timer désarmé (cron/k8s)."),
|
|
39
|
+
gcJitter: z.boolean().default(true).describe("Étale le départ du gc d'idempotence par process — anti thundering-herd sur le store SQL partagé en cluster.")
|
|
40
|
+
});
|
|
41
|
+
const frameworkConfigSchema = z.strictObject({
|
|
42
|
+
router: serviceOptionsSchema.optional().describe("Options transmises au Service `Router` (bag d'options de Service de base : logger, timers…). Loose : non strippées. Absent (défaut) = aucune."),
|
|
43
|
+
adminBroker: serviceOptionsSchema.optional().describe("Options transmises au Service `AdminBroker` (data plane admin `/nodefony/<ns>/api/*`). Loose : non strippées. Absent (défaut) = aucune."),
|
|
44
|
+
idempotency: idempotencySchema.default(() => idempotencySchema.parse({})).describe("Idempotence des mutations (anti double-effet). Cf draft-ietf-httpapi-idempotency-key-header.")
|
|
45
|
+
}).describe("Configuration de @nodefony/framework.");
|
|
46
|
+
var config_default = {
|
|
47
|
+
...frameworkConfigSchema.parse({}),
|
|
48
|
+
"module-security": { areas: {
|
|
49
|
+
"nodefony-liveness": {
|
|
50
|
+
pattern: "^/nodefony/kernel/api/livez$",
|
|
51
|
+
authenticators: ["session", "anonymous"],
|
|
52
|
+
realtime: false
|
|
53
|
+
},
|
|
54
|
+
"nodefony-admin": {
|
|
55
|
+
pattern: "^/nodefony/[^/]+/api(/|$)",
|
|
56
|
+
authenticators: ["session"]
|
|
57
|
+
}
|
|
58
|
+
} }
|
|
59
|
+
};
|
|
60
|
+
//#endregion
|
|
61
|
+
export { config_default as default, frameworkConfigSchema };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { frameworkConfigSchema } from "./config.js";
|
|
2
|
+
import { parseModuleConfig } from "nodefony";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
//#region nodefony/config/defineModuleConfig.ts
|
|
5
|
+
/**
|
|
6
|
+
* Builder type-safe de la configuration de `@nodefony/framework` (PUR — ne
|
|
7
|
+
* retape JAMAIS un défaut : source unique = `./config.ts`).
|
|
8
|
+
*
|
|
9
|
+
* ⭐ TL;DR : MACHINERIE DE BOOT — on n'édite (presque) jamais ce fichier. Même
|
|
10
|
+
* pattern que `nodefony.config.ts` ↔ `defineConfig()` du core : `config.ts` PORTE
|
|
11
|
+
* la config (schéma + défauts), `define<X>Config()` la VALIDE au boot (parse +
|
|
12
|
+
* env + freeze) et publie le JSON Schema Studio.
|
|
13
|
+
*
|
|
14
|
+
* Valide la config brute contre le schéma Zod et matérialise les défauts.
|
|
15
|
+
* Une config invalide échoue avec un message lisible par champ (fail-loud au
|
|
16
|
+
* boot, plutôt qu'un `undefined.x` silencieux en runtime).
|
|
17
|
+
*
|
|
18
|
+
* @param config - configuration brute (sections omises = défauts sûrs).
|
|
19
|
+
* @returns config validée (défauts matérialisés, `router`/`adminBroker`
|
|
20
|
+
* préservés tels quels — bags loose non strippés).
|
|
21
|
+
* @throws BootConfigurationError si la config est invalide ou porte une clé
|
|
22
|
+
* inconnue — le boot s'interrompt, en dev comme en prod.
|
|
23
|
+
*/
|
|
24
|
+
function defineFrameworkConfig(config = {}) {
|
|
25
|
+
return parseModuleConfig(frameworkConfigSchema, config, "@nodefony/framework");
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* JSON Schema introspectable de la config framework — destiné au formulaire
|
|
29
|
+
* d'édition Studio et à la documentation générée (les flags de champ posés via
|
|
30
|
+
* `.meta()` sont recopiés par `z.toJSONSchema`).
|
|
31
|
+
*/
|
|
32
|
+
function frameworkConfigJsonSchema() {
|
|
33
|
+
return z.toJSONSchema(frameworkConfigSchema);
|
|
34
|
+
}
|
|
35
|
+
//#endregion
|
|
36
|
+
export { defineFrameworkConfig, frameworkConfigJsonSchema };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import Controller from "../src/Controller.js";
|
|
2
|
+
import { computeFingerprint, evaluateIdempotency, resolveIdempotencyKey, resolveIdentity } from "../src/idempotency.js";
|
|
3
|
+
import { RequestContext, RpcError, executeAdminEndpoint } from "nodefony";
|
|
4
|
+
//#region nodefony/controller/AdminApiController.ts
|
|
5
|
+
/**
|
|
6
|
+
* Controller pont unique du data plane admin (Studio).
|
|
7
|
+
*
|
|
8
|
+
* Toutes les routes `/nodefony/<namespace>/api/*` montées par le broker
|
|
9
|
+
* pointent vers `AdminApiController.dispatch`. À l'exécution, le controller :
|
|
10
|
+
* 1. retrouve l'`IAdminRoute` via le nom de route (lookup O(1) du broker) ;
|
|
11
|
+
* 2. projette le `Context` HTTP en {@link IAdminRequest} (découplage core) ;
|
|
12
|
+
* 3. applique le RBAC (différé tant que P6/auth n'est pas câblé) ;
|
|
13
|
+
* 4. appelle le handler du producteur et sérialise le retour en JSON.
|
|
14
|
+
*
|
|
15
|
+
* Un seul controller réutilisé pour N endpoints : pas de génération dynamique
|
|
16
|
+
* de classes, et chaque route reste une vraie `Route` (404/405 du Router OK).
|
|
17
|
+
*/
|
|
18
|
+
var AdminApiController = class AdminApiController extends Controller {
|
|
19
|
+
/**
|
|
20
|
+
* Identité de l'instance qui répond — `NF_INSTANCE_ID` (k8s pod, worker)
|
|
21
|
+
* ou `pid` en fallback. Même convention que les providers realtime Studio.
|
|
22
|
+
* Calculée une fois (statique) : invariante sur la vie du process.
|
|
23
|
+
*/
|
|
24
|
+
static instanceId = process.env.NF_INSTANCE_ID ?? String(process.pid);
|
|
25
|
+
constructor(context) {
|
|
26
|
+
super("AdminApiController", context);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Action générique appelée par le Resolver pour toute route admin — DUPLEX
|
|
30
|
+
* (« API souveraine ») : même exécution sur les deux transports, seul
|
|
31
|
+
* l'emballage diffère.
|
|
32
|
+
*
|
|
33
|
+
* - **HTTP** : rendu historique inchangé (`renderJson` + status + header
|
|
34
|
+
* `x-nodefony-instance`).
|
|
35
|
+
* - **Pont WS-RPC `api.request`** : valeur **nue** (le pont l'enveloppe
|
|
36
|
+
* `{id, result}` — snapshot ≡ GET par construction) ; statut ≥ 400 →
|
|
37
|
+
* {@link RpcError} (`data.status` + `data.body`), symétrie d'un `fetch`
|
|
38
|
+
* qui expose son statut. L'identité d'instance n'est pas répétée par
|
|
39
|
+
* réponse : une socket est tenue par UN process (info de connexion).
|
|
40
|
+
*
|
|
41
|
+
* @param args - variables de route positionnelles (`{id}`…), zippées avec
|
|
42
|
+
* `route.variables` pour reconstruire `request.params`.
|
|
43
|
+
*/
|
|
44
|
+
async dispatch(...args) {
|
|
45
|
+
const { status, headers, body } = await this.runAdmin(args);
|
|
46
|
+
if (this.context?.type?.startsWith("websocket")) {
|
|
47
|
+
if (status >= 400) {
|
|
48
|
+
const message = body?.error ?? "admin error";
|
|
49
|
+
throw new RpcError(message, -32e3, {
|
|
50
|
+
status,
|
|
51
|
+
body
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return body;
|
|
55
|
+
}
|
|
56
|
+
return this.renderJson(body, status, {
|
|
57
|
+
...headers,
|
|
58
|
+
"x-nodefony-instance": AdminApiController.instanceId
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Résout la route admin, puis délègue l'exécution à la porte unique du cœur.
|
|
63
|
+
*
|
|
64
|
+
* Le LOOKUP est ce qui reste propre à ce transport : ici par **nom de route**
|
|
65
|
+
* (le Router l'impose), là où la CLI et le serveur MCP résolvent par couple
|
|
66
|
+
* namespace/chemin. Tout ce qui suit — autorisation, idempotence, handler,
|
|
67
|
+
* normalisation, traduction des erreurs — est commun, donc partagé
|
|
68
|
+
* ({@link executeAdminEndpoint}) : deux implémentations divergeraient, et
|
|
69
|
+
* c'est la porte la moins relue qui deviendrait la plus permissive.
|
|
70
|
+
*/
|
|
71
|
+
async runAdmin(args) {
|
|
72
|
+
const broker = this.get("adminBroker");
|
|
73
|
+
const name = this.route?.name;
|
|
74
|
+
const adminRoute = broker && name ? broker.resolve(name) : void 0;
|
|
75
|
+
if (!adminRoute) return {
|
|
76
|
+
status: 500,
|
|
77
|
+
body: {
|
|
78
|
+
error: "Admin endpoint not registered",
|
|
79
|
+
route: name ?? null
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
return executeAdminEndpoint({
|
|
83
|
+
endpoint: adminRoute.endpoint,
|
|
84
|
+
request: this.buildRequest(args),
|
|
85
|
+
requiredRole: adminRoute.role,
|
|
86
|
+
gate: (request) => this.idempotencyGate(adminRoute, request),
|
|
87
|
+
onServerError: (error) => this.log(error, "ERROR")
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Porte d'idempotence d'une **mutation** admin. La sémantique normative
|
|
92
|
+
* (`draft-ietf-httpapi-idempotency-key-header` : 400 clé requise WS / 409
|
|
93
|
+
* concurrent / 422 mismatch / rejeu mémorisé, clé scopée identité anti-IDOR,
|
|
94
|
+
* fingerprint du payload) vit dans le **helper partagé** `idempotency.ts` — le
|
|
95
|
+
* MÊME que le seam `Resolver` des controllers userland `@Idempotent`. Ici on ne
|
|
96
|
+
* fait que TRADUIRE le verdict neutre en forme admin (`shortCircuit` immédiat,
|
|
97
|
+
* ou callbacks `onSuccess`/`onFailure` autour de l'exécution).
|
|
98
|
+
*
|
|
99
|
+
* `required: false` → l'admin n'exige la clé qu'en WS (porté par `isWs` dans le
|
|
100
|
+
* helper) ; en HTTP, une mutation sans clé s'exécute directement (historique).
|
|
101
|
+
*/
|
|
102
|
+
async idempotencyGate(adminRoute, request) {
|
|
103
|
+
if (adminRoute.method === "GET") return {};
|
|
104
|
+
const store = this.get("idempotencyStore");
|
|
105
|
+
const verdict = await evaluateIdempotency({
|
|
106
|
+
store,
|
|
107
|
+
identity: resolveIdentity(request.user),
|
|
108
|
+
clientKey: request.idempotencyKey,
|
|
109
|
+
fingerprint: computeFingerprint([
|
|
110
|
+
adminRoute.name,
|
|
111
|
+
request.params,
|
|
112
|
+
request.body ?? null
|
|
113
|
+
]),
|
|
114
|
+
isWs: Boolean((this.context?.type)?.startsWith("websocket")),
|
|
115
|
+
required: false
|
|
116
|
+
});
|
|
117
|
+
switch (verdict.kind) {
|
|
118
|
+
case "reject": return { shortCircuit: {
|
|
119
|
+
status: verdict.status,
|
|
120
|
+
body: verdict.detail ? {
|
|
121
|
+
error: verdict.message,
|
|
122
|
+
detail: verdict.detail
|
|
123
|
+
} : { error: verdict.message }
|
|
124
|
+
} };
|
|
125
|
+
case "replay": return { shortCircuit: verdict.response };
|
|
126
|
+
case "guarded": return {
|
|
127
|
+
onSuccess: (resp) => store?.complete(verdict.key, resp),
|
|
128
|
+
onFailure: () => store?.abort(verdict.key)
|
|
129
|
+
};
|
|
130
|
+
default: return {};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Projette le Context courant en requête admin normalisée. */
|
|
134
|
+
buildRequest(args) {
|
|
135
|
+
const names = this.route?.variables ?? [];
|
|
136
|
+
const params = {};
|
|
137
|
+
for (let i = 0; i < names.length; i++) {
|
|
138
|
+
const key = names[i];
|
|
139
|
+
if (typeof key === "string" && args[i] !== void 0) params[key] = String(args[i]);
|
|
140
|
+
}
|
|
141
|
+
const als = RequestContext.get();
|
|
142
|
+
const user = als?.user ?? null;
|
|
143
|
+
return {
|
|
144
|
+
params,
|
|
145
|
+
query: this.query ?? {},
|
|
146
|
+
body: als?.body !== void 0 ? als.body : this.queryPost ?? null,
|
|
147
|
+
user,
|
|
148
|
+
roles: this.extractRoles(user),
|
|
149
|
+
requestId: als?.requestId,
|
|
150
|
+
idempotencyKey: resolveIdempotencyKey(als?.idempotencyKey, this.context?.request?.headers?.["idempotency-key"])
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** Extrait les rôles de l'utilisateur ALS sans coupler le core à `IUser`. */
|
|
154
|
+
extractRoles(user) {
|
|
155
|
+
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");
|
|
158
|
+
}
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
//#endregion
|
|
163
|
+
export { AdminApiController as default };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import Controller from "../src/Controller.js";
|
|
2
|
+
import router_default from "../service/router.js";
|
|
3
|
+
import { collectDeclaredApiScopes } from "../src/scopeCatalog.js";
|
|
4
|
+
//#region nodefony/controller/ApiKeyController.ts
|
|
5
|
+
let mounted = false;
|
|
6
|
+
/**
|
|
7
|
+
* Endpoints HTTP de **gestion des clés API personnelles (PAT, P6.12)** — console
|
|
8
|
+
* « mes clés » façon GitHub, adaptateurs MINCES au-dessus du service `apiKeys`
|
|
9
|
+
* (`@nodefony/security`) :
|
|
10
|
+
*
|
|
11
|
+
* - `POST /nodefony/security/api/keys` — body `{name, scopes?, expiresInDays?}`
|
|
12
|
+
* → `201 {id, prefix, name, scopes, token, …}` (le `token` clair n'apparaît qu'ICI)
|
|
13
|
+
* - `GET /nodefony/security/api/keys` — liste les clés du porteur (sans secret)
|
|
14
|
+
* - `DELETE /nodefony/security/api/keys/{id}` — révoque → `200 {ok:true}` / `404`
|
|
15
|
+
* si la clé n'existe pas **ou** appartient à autrui (indiscernable, jamais 403)
|
|
16
|
+
*
|
|
17
|
+
* **PAS de `bypassFirewall`** (≠ login/token/oauth) : ces routes vivent DANS la
|
|
18
|
+
* zone data plane `^/nodefony/[^/]+/api(/|$)` → **session BFF requise**. Le porteur
|
|
19
|
+
* est TOUJOURS l'utilisateur courant (`authFlow.me`), jamais un paramètre — on ne
|
|
20
|
+
* crée/révoque jamais une clé pour autrui. Montés seulement si le service `apiKeys`
|
|
21
|
+
* existe (security chargé + clés activées) → 404, zéro surface, sinon.
|
|
22
|
+
*
|
|
23
|
+
* Erreurs mappées par DUCK-TYPING sur `code` (400/409/503) — framework ne peut pas
|
|
24
|
+
* importer les classes d'erreur de security.
|
|
25
|
+
*/
|
|
26
|
+
var ApiKeyController = class extends Controller {
|
|
27
|
+
constructor(context) {
|
|
28
|
+
super("ApiKeyController", context);
|
|
29
|
+
}
|
|
30
|
+
/** Émission : crée une clé pour le porteur courant → token clair (1×). */
|
|
31
|
+
async create() {
|
|
32
|
+
const svc = this.#manager();
|
|
33
|
+
if (!svc || !svc.isEnabled()) return this.renderJson({ error: "API keys unavailable" }, 503);
|
|
34
|
+
const subject = await this.#currentSubject();
|
|
35
|
+
if (subject === null) return this.renderJson({ error: "Unauthorized" }, 401);
|
|
36
|
+
const body = this.queryPost ?? {};
|
|
37
|
+
try {
|
|
38
|
+
const created = await svc.createForSubject(subject, "user", {
|
|
39
|
+
name: body.name,
|
|
40
|
+
scopes: body.scopes,
|
|
41
|
+
expiresInDays: body.expiresInDays
|
|
42
|
+
});
|
|
43
|
+
return this.renderJson(created, 201);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return this.#renderApiKeyError(e);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Capacités/contraintes d'émission (plafond, scopes, préfixe, durée par défaut)
|
|
50
|
+
* — alimente le formulaire de création. Lecture pure, aucune valeur sensible ;
|
|
51
|
+
* accessible à tout porteur authentifié (zone data plane, session BFF).
|
|
52
|
+
*/
|
|
53
|
+
async capabilities() {
|
|
54
|
+
const svc = this.#manager();
|
|
55
|
+
if (!svc || !svc.isEnabled()) return this.renderJson({ error: "API keys unavailable" }, 503);
|
|
56
|
+
const caps = svc.describeCapabilities();
|
|
57
|
+
return this.renderJson({
|
|
58
|
+
...caps,
|
|
59
|
+
declaredScopes: collectDeclaredApiScopes()
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** Liste les clés du porteur courant (vue publique, sans secret). */
|
|
63
|
+
async list() {
|
|
64
|
+
const svc = this.#manager();
|
|
65
|
+
if (!svc || !svc.isEnabled()) return this.renderJson({ error: "API keys unavailable" }, 503);
|
|
66
|
+
const subject = await this.#currentSubject();
|
|
67
|
+
if (subject === null) return this.renderJson({ error: "Unauthorized" }, 401);
|
|
68
|
+
return this.renderJson({ keys: await svc.listForSubject(subject) });
|
|
69
|
+
}
|
|
70
|
+
/** Révocation : seulement une clé DU porteur courant (sinon 404, anti-énumération). */
|
|
71
|
+
async revoke(id) {
|
|
72
|
+
const svc = this.#manager();
|
|
73
|
+
if (!svc || !svc.isEnabled()) return this.renderJson({ error: "API keys unavailable" }, 503);
|
|
74
|
+
const subject = await this.#currentSubject();
|
|
75
|
+
if (subject === null) return this.renderJson({ error: "Unauthorized" }, 401);
|
|
76
|
+
if (typeof id !== "string" || id.length === 0) return this.renderJson({ error: "Not found" }, 404);
|
|
77
|
+
if (!await svc.revokeForSubject(subject, id)) return this.renderJson({ error: "Not found" }, 404);
|
|
78
|
+
return this.renderJson({ ok: true });
|
|
79
|
+
}
|
|
80
|
+
#manager() {
|
|
81
|
+
return this.get("apiKeys") ?? null;
|
|
82
|
+
}
|
|
83
|
+
#flow() {
|
|
84
|
+
return this.get("authFlow") ?? null;
|
|
85
|
+
}
|
|
86
|
+
/** Identifiant du porteur courant (session BFF revalidée), ou `null` si non authentifié. */
|
|
87
|
+
async #currentSubject() {
|
|
88
|
+
const flow = this.#flow();
|
|
89
|
+
if (!flow) return null;
|
|
90
|
+
const me = await flow.me(this.context);
|
|
91
|
+
return me && typeof me.username === "string" ? me.username : null;
|
|
92
|
+
}
|
|
93
|
+
#renderApiKeyError(e) {
|
|
94
|
+
const code = e.code;
|
|
95
|
+
if (code === 400) {
|
|
96
|
+
const message = e.message;
|
|
97
|
+
return this.renderJson({ error: typeof message === "string" ? message : "Bad request" }, 400);
|
|
98
|
+
}
|
|
99
|
+
if (code === 409) return this.renderJson({ error: "API key limit reached" }, 409);
|
|
100
|
+
if (code === 503) return this.renderJson({ error: "API keys unavailable" }, 503);
|
|
101
|
+
throw e;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Monte les routes de gestion des clés API — appelé par le module framework à
|
|
106
|
+
* `onKernelReady`, seulement si le service `apiKeys` est présent.
|
|
107
|
+
*
|
|
108
|
+
* Routes nommées `security.apikeys.*` (espace data plane `/nodefony/security/api/*`).
|
|
109
|
+
* **Aucun `bypassFirewall`** : l'aire data plane (session BFF) les garde — c'est
|
|
110
|
+
* voulu (gérer ses clés exige d'être authentifié).
|
|
111
|
+
*/
|
|
112
|
+
function mountApiKeyRoutes(frameworkModule) {
|
|
113
|
+
if (mounted) return;
|
|
114
|
+
const base = "/nodefony/security/api/keys";
|
|
115
|
+
const routes = [
|
|
116
|
+
[
|
|
117
|
+
"security.apikeys.create",
|
|
118
|
+
base,
|
|
119
|
+
"POST",
|
|
120
|
+
"create"
|
|
121
|
+
],
|
|
122
|
+
[
|
|
123
|
+
"security.apikeys.list",
|
|
124
|
+
base,
|
|
125
|
+
"GET",
|
|
126
|
+
"list"
|
|
127
|
+
],
|
|
128
|
+
[
|
|
129
|
+
"security.apikeys.capabilities",
|
|
130
|
+
`${base}/capabilities`,
|
|
131
|
+
"GET",
|
|
132
|
+
"capabilities"
|
|
133
|
+
],
|
|
134
|
+
[
|
|
135
|
+
"security.apikeys.revoke",
|
|
136
|
+
`${base}/{id}`,
|
|
137
|
+
"DELETE",
|
|
138
|
+
"revoke"
|
|
139
|
+
]
|
|
140
|
+
];
|
|
141
|
+
for (const [name, path, method, classMethod] of routes) router_default.createRoute(name, {
|
|
142
|
+
path,
|
|
143
|
+
constructor: ApiKeyController,
|
|
144
|
+
classMethod,
|
|
145
|
+
requirements: { methods: [method] }
|
|
146
|
+
});
|
|
147
|
+
if (!Object.prototype.hasOwnProperty.call(ApiKeyController.prototype, "module")) router_default.setController(ApiKeyController, frameworkModule);
|
|
148
|
+
mounted = true;
|
|
149
|
+
}
|
|
150
|
+
//#endregion
|
|
151
|
+
export { ApiKeyController as default, mountApiKeyRoutes };
|