@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.
Files changed (96) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +50 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +211 -0
  6. package/dist/nodefony/config/config.js +61 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +36 -0
  8. package/dist/nodefony/controller/AdminApiController.js +163 -0
  9. package/dist/nodefony/controller/ApiKeyController.js +151 -0
  10. package/dist/nodefony/controller/BenchController.js +132 -0
  11. package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
  12. package/dist/nodefony/controller/OAuth2Controller.js +133 -0
  13. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
  14. package/dist/nodefony/controller/SessionAuthController.js +141 -0
  15. package/dist/nodefony/controller/TokenAuthController.js +113 -0
  16. package/dist/nodefony/controller/TotpController.js +129 -0
  17. package/dist/nodefony/controller/WebAuthnController.js +242 -0
  18. package/dist/nodefony/controller/oauthAuthority.js +74 -0
  19. package/dist/nodefony/decorators/routerDecorators.js +967 -0
  20. package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
  21. package/dist/nodefony/interfaces/IController.js +1 -0
  22. package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
  23. package/dist/nodefony/interfaces/IResolver.js +1 -0
  24. package/dist/nodefony/interfaces/IRoute.js +1 -0
  25. package/dist/nodefony/interfaces/index.js +1 -0
  26. package/dist/nodefony/service/AdminBroker.js +106 -0
  27. package/dist/nodefony/service/Eta.js +68 -0
  28. package/dist/nodefony/service/IdempotencyStore.js +136 -0
  29. package/dist/nodefony/service/router.js +243 -0
  30. package/dist/nodefony/src/Controller.js +515 -0
  31. package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
  32. package/dist/nodefony/src/KernelAdminApi.js +1243 -0
  33. package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
  34. package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
  35. package/dist/nodefony/src/Resolver.js +416 -0
  36. package/dist/nodefony/src/ResourceController.js +148 -0
  37. package/dist/nodefony/src/Route.js +476 -0
  38. package/dist/nodefony/src/SyslogAdminApi.js +466 -0
  39. package/dist/nodefony/src/Template.js +15 -0
  40. package/dist/nodefony/src/configMutation.js +186 -0
  41. package/dist/nodefony/src/docsReader.js +929 -0
  42. package/dist/nodefony/src/idempotency.js +137 -0
  43. package/dist/nodefony/src/idempotencyGc.js +32 -0
  44. package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
  45. package/dist/nodefony/src/scopeCatalog.js +40 -0
  46. package/dist/nodefony/src/syslogFilters.js +51 -0
  47. package/dist/types/index.d.ts +96 -0
  48. package/dist/types/nodefony/config/config.d.ts +41 -0
  49. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  50. package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
  51. package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
  52. package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
  53. package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
  54. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
  55. package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
  56. package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
  57. package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
  58. package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
  59. package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
  60. package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
  61. package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
  62. package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
  63. package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
  64. package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
  65. package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
  66. package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
  67. package/dist/types/nodefony/interfaces/index.d.ts +5 -0
  68. package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
  69. package/dist/types/nodefony/service/Eta.d.ts +25 -0
  70. package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
  71. package/dist/types/nodefony/service/router.d.ts +53 -0
  72. package/dist/types/nodefony/src/Controller.d.ts +193 -0
  73. package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
  74. package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
  75. package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
  76. package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
  77. package/dist/types/nodefony/src/Resolver.d.ts +165 -0
  78. package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
  79. package/dist/types/nodefony/src/Route.d.ts +192 -0
  80. package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
  81. package/dist/types/nodefony/src/Template.d.ts +8 -0
  82. package/dist/types/nodefony/src/configMutation.d.ts +109 -0
  83. package/dist/types/nodefony/src/docsReader.d.ts +369 -0
  84. package/dist/types/nodefony/src/idempotency.d.ts +96 -0
  85. package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
  86. package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
  87. package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
  88. package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
  89. package/docs/admin.md +451 -0
  90. package/docs/controller.md +645 -0
  91. package/docs/decorateurs.md +845 -0
  92. package/docs/idempotence.md +741 -0
  93. package/docs/index.md +151 -0
  94. package/docs/routing.md +648 -0
  95. package/docs/templates.md +380 -0
  96. 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 };