@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
@@ -0,0 +1,1243 @@
1
+ import { CORE_PACKAGE, checkOutdated, countModuleDocs, listModuleDocs, listModuleSymbols, listTestFiles, listTestGroups, readCoreInfo, readCoverage, readDependencies, readModuleDoc, readSymbolDeclaration, resolveCorePath, runModuleTests, searchModuleDocs } from "./docsReader.js";
2
+ import { getResolvedPath, navigateSchemaNode, nodeFlags, notEditableReason, recipeFor, validateLeafValue } from "./configMutation.js";
3
+ import { createRequire } from "node:module";
4
+ import { join } from "node:path";
5
+ import { GitService, Syslog, applyResolvedPath, collectDevStatus, computeConfigProvenance, defaultAppConfig, extractJsonSchemaDefaults, extractMarkdownSection, getActiveLogDriver, listLogDrivers, outlineMarkdown, parseNfEnvOverrides } from "nodefony";
6
+ import { randomUUID } from "node:crypto";
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ //#region nodefony/src/KernelAdminApi.ts
9
+ /** Clé du pseudo-module core dans Studio (cf carte "Core" / `resolveCorePath`). */
10
+ const CORE_KEY = "core";
11
+ /** Racine projet — pour ne PAS exposer de chemin absolu (sécu). */
12
+ const REPO_ROOT = process.cwd();
13
+ /** Relativise tout chemin absolu présent dans une string de config. */
14
+ function stripAbs(s) {
15
+ if (!s.includes(REPO_ROOT)) return s;
16
+ return s.split(`${REPO_ROOT}/`).join("").split(REPO_ROOT).join(".");
17
+ }
18
+ /**
19
+ * Adapters de persistance OFFICIELS — juste les NOMS, pour la découvrabilité (savoir
20
+ * qu'ils existent même NON installés → état « à installer »). Leurs CAPABILITÉS
21
+ * (domaine + briques couvertes) ne sont PAS curatées ici : chaque adapter les DÉCLARE
22
+ * dans son `package.json` (`nodefony.storeKind` + `nodefony.stores`), lues à chaud par
23
+ * {@link readAdapterManifest} (Palier 3 — source de vérité = l'adapter, extensible aux
24
+ * modules tiers, jamais figée dans le core).
25
+ */
26
+ const OFFICIAL_STORE_ADAPTERS = [
27
+ {
28
+ engine: "drizzle",
29
+ package: "@nodefony/drizzle",
30
+ family: "sql"
31
+ },
32
+ {
33
+ engine: "mongoose",
34
+ package: "@nodefony/mongoose",
35
+ family: "mongo"
36
+ },
37
+ {
38
+ engine: "redis",
39
+ package: "@nodefony/redis",
40
+ family: "cache"
41
+ }
42
+ ];
43
+ /**
44
+ * Lit les capabilités DÉCLARÉES d'un adapter installé depuis son `package.json`
45
+ * (`nodefony.storeKind` = `durable|cache` ; `nodefony.stores` = briques couvertes).
46
+ * `null` si non installé ou sans déclaration. Le modèle est « couverture ADAPTÉE à la
47
+ * vocation », pas une parité 8/8 : un adapter déclare ce qu'il implémente, point.
48
+ */
49
+ function readAdapterManifest(pkg) {
50
+ const pkgJson = join(REPO_ROOT, "node_modules", ...pkg.split("/"), "package.json");
51
+ try {
52
+ if (!existsSync(pkgJson)) return null;
53
+ const nf = JSON.parse(readFileSync(pkgJson, "utf8")).nodefony;
54
+ if (!nf || !Array.isArray(nf.stores)) return null;
55
+ return {
56
+ storeKind: nf.storeKind === "cache" ? "cache" : "durable",
57
+ stores: nf.stores.filter((s) => typeof s === "string")
58
+ };
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+ const engineRequire = createRequire(import.meta.url);
64
+ /**
65
+ * Un package npm est-il INSTALLÉ (résolvable), qu'il soit chargé ou non ? Distingue
66
+ * « installé mais pas branché au manifeste » de « à installer ». Résolution standard
67
+ * d'abord ; repli sur la présence du dossier dans `node_modules` du projet (hoisting
68
+ * monorepo — `exports` peut ne pas publier tous les sous-chemins).
69
+ */
70
+ function isPackageInstalled(pkg) {
71
+ try {
72
+ engineRequire.resolve(pkg);
73
+ return true;
74
+ } catch {
75
+ return existsSync(join(REPO_ROOT, "node_modules", ...pkg.split("/")));
76
+ }
77
+ }
78
+ /**
79
+ * Clés dont la VALEUR est un secret (JWT, CSRF, OAuth client secret, clé de
80
+ * chiffrement). Redactées dans la config exposée au data plane — Zero Trust :
81
+ * un secret n'est JAMAIS renvoyé en clair, même à un admin.
82
+ *
83
+ * ## Pourquoi un motif de MOT ENTIER, et pas une sous-chaîne
84
+ *
85
+ * La forme précédente cherchait ces mots n'importe où dans la clé. Elle laissait
86
+ * passer **`encryptionKey`** — mesuré : `security.totp.encryptionKey` et
87
+ * `security.webhooks.encryptionKey` sortaient en clair —, et rédigeait à tort
88
+ * `privateKeyMode`, qui n'est qu'un MODE (`"file"`, `"env"`).
89
+ *
90
+ * L'élargir à `key` tout court n'était pas la réponse : sur une application
91
+ * réelle, cela emportait `apiKeys.prefix`, `passkeys.timeoutMs`,
92
+ * `tokenStore.gcIntervalS` et jusqu'à `key: "app"`, l'identifiant de module dont
93
+ * la console d'administration se sert pour indexer ses entrées. Une règle qui
94
+ * rédige du non-secret n'est pas « prudente » : elle rend l'écran inutilisable,
95
+ * donc on finit par la retirer.
96
+ *
97
+ * ⚠️ Cette liste doit rester alignée avec `pathLooksSecret`
98
+ * (`config/envOverride.ts`), qui rend le même jugement pour les journaux. Elles
99
+ * ont divergé — c'est cette divergence qui a laissé fuir `encryptionKey`.
100
+ */
101
+ const SECRET_KEY = /(?:^|[a-z0-9])(secret|password|passwd|passphrase|credential|clientsecret|keysetjson|privatekey|encryptionkey|signingkey|accesstoken|refreshtoken)s?$/i;
102
+ /**
103
+ * Sérialisation défensive de config : borne la profondeur, neutralise les
104
+ * fonctions, casse les cycles, et **relativise les chemins absolus** (sécu :
105
+ * ne jamais exposer l'arborescence serveur). Les `options` d'un module peuvent
106
+ * contenir des fonctions/refs circulaires (vers le kernel) → JSON.stringify
107
+ * direct planterait.
108
+ */
109
+ function safeConfig(value, depth = 0, seen = /* @__PURE__ */ new WeakSet()) {
110
+ if (typeof value === "function") return "[Function]";
111
+ if (typeof value === "bigint") return value.toString();
112
+ if (typeof value === "string") return stripAbs(value);
113
+ if (value === null || typeof value !== "object") return value;
114
+ if (depth > 5) return "[depth limit]";
115
+ if (seen.has(value)) return "[Circular]";
116
+ seen.add(value);
117
+ if (value instanceof Date) return value.toISOString();
118
+ if (value instanceof RegExp) return value.toString();
119
+ if (Array.isArray(value)) return value.slice(0, 100).map((v) => safeConfig(v, depth + 1, seen));
120
+ const out = {};
121
+ for (const k of Object.keys(value).slice(0, 200)) {
122
+ const raw = value[k];
123
+ if (SECRET_KEY.test(k) && raw != null && raw !== "" && typeof raw !== "boolean" && typeof raw !== "object") {
124
+ out[k] = "[redacted]";
125
+ continue;
126
+ }
127
+ try {
128
+ out[k] = safeConfig(raw, depth + 1, seen);
129
+ } catch {
130
+ out[k] = "[unreadable]";
131
+ }
132
+ }
133
+ return out;
134
+ }
135
+ /**
136
+ * Aplatit un objet de config en chemins-feuilles pointés MINUSCULES (les arrays
137
+ * sont des feuilles). Sert à savoir quels chemins un override `module-<X>` déclare.
138
+ */
139
+ function flattenPaths(value, prefix, out) {
140
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) for (const k of Object.keys(value)) flattenPaths(value[k], prefix ? `${prefix}.${k}` : k, out);
141
+ else if (prefix) out.add(prefix.toLowerCase());
142
+ }
143
+ /**
144
+ * Attribue chaque champ **app**-surchargé à sa SOURCE réelle (mute `overriddenBy`).
145
+ *
146
+ * Construit l'index inversé des overrides `module-<cible>` déclarés par CHAQUE
147
+ * module (y compris l'app), puis, pour chaque champ « app » d'une cible, retrouve
148
+ * le module qui pose ce chemin → c'est le vrai « qui surcharge ». Aucun candidat
149
+ * `module-<cible>` → la surcharge vient de la config app directe (`nodefony.config.ts`).
150
+ *
151
+ * @param entries - toutes les entrées config de l'agrégat (mutées en place).
152
+ */
153
+ function attributeOverrideSources(entries) {
154
+ const byTarget = /* @__PURE__ */ new Map();
155
+ for (const e of entries) for (const key of Object.keys(e.config)) {
156
+ const m = /^module-(.+)$/i.exec(key);
157
+ if (!m) continue;
158
+ const targetSeg = m[1].toLowerCase();
159
+ const paths = /* @__PURE__ */ new Set();
160
+ flattenPaths(e.config[key], "", paths);
161
+ const arr = byTarget.get(targetSeg);
162
+ if (arr) arr.push({
163
+ source: e.name,
164
+ paths
165
+ });
166
+ else byTarget.set(targetSeg, [{
167
+ source: e.name,
168
+ paths
169
+ }]);
170
+ }
171
+ for (const e of entries) {
172
+ if (!e.provenance) continue;
173
+ const candidates = byTarget.get(e.seg);
174
+ for (const [path, origin] of Object.entries(e.provenance)) {
175
+ if (origin !== "app") continue;
176
+ const pl = path.toLowerCase();
177
+ const hit = candidates?.find((c) => c.paths.has(pl) || [...c.paths].some((p) => pl.startsWith(`${p}.`) || p.startsWith(`${pl}.`)));
178
+ e.overriddenBy[path] = hit ? hit.source : "nodefony.config.ts";
179
+ }
180
+ }
181
+ }
182
+ /** Segment d'adressage des overrides (`NF__<SEG>__…`) d'un module : `app` ou le basename. */
183
+ function computeSeg(pkg, isApp) {
184
+ if (isApp) return "app";
185
+ return (pkg.includes("/") ? pkg.slice(pkg.lastIndexOf("/") + 1) : pkg).toLowerCase();
186
+ }
187
+ /**
188
+ * Calcule l'entrée CONFIG d'un module — partagée par `module/{name}` (détail) et
189
+ * l'agrégat `config` (page globale). Une seule logique de provenance (ADR-0006 D7),
190
+ * branche `isApp` (défauts = `defaultAppConfig`, env = segment réservé `app`) vs
191
+ * module (défauts = `extractJsonSchemaDefaults(schema)`, env = basename). La map de
192
+ * provenance ne porte que des ORIGINES (jamais de valeur → 0 fuite) ; les valeurs
193
+ * restent redactées par `safeConfig`.
194
+ *
195
+ * @param key - clé Studio du module.
196
+ * @param mod - module (forme minimale {@link ConfigModuleLike}).
197
+ * @param runtimePaths - chemins (pointés minuscule) édités À CHAUD via PATCH →
198
+ * provenance forcée `runtime` (≠ `app` : la valeur ne vient pas de la config app).
199
+ * @returns l'entrée config normalisée.
200
+ */
201
+ function computeConfigEntry(key, mod, runtimePaths) {
202
+ const pkg = mod.getModuleName?.() ?? key;
203
+ const opts = mod.options ?? {};
204
+ const schema = mod.configSchema();
205
+ const isApp = mod.isApp ?? false;
206
+ const seg = computeSeg(pkg, isApp);
207
+ const envKeys = {};
208
+ for (const o of parseNfEnvOverrides(process.env)) if (o.moduleSeg === seg) envKeys[o.path.join(".")] = o.envKey;
209
+ let provenance = null;
210
+ if (isApp) {
211
+ const envPaths = new Set(parseNfEnvOverrides(process.env).filter((o) => o.moduleSeg === "app").map((o) => o.path.join(".")));
212
+ provenance = computeConfigProvenance(opts, defaultAppConfig, envPaths);
213
+ } else if (schema) {
214
+ const envPaths = new Set(parseNfEnvOverrides(process.env).filter((o) => o.moduleSeg === seg).map((o) => o.path.join(".")));
215
+ provenance = computeConfigProvenance(opts, extractJsonSchemaDefaults(schema), envPaths);
216
+ }
217
+ if (provenance && runtimePaths && runtimePaths.size) {
218
+ for (const k of Object.keys(provenance)) if (runtimePaths.has(k.toLowerCase())) provenance[k] = "runtime";
219
+ }
220
+ return {
221
+ key,
222
+ name: pkg,
223
+ isApp,
224
+ seg,
225
+ config: safeConfig(opts),
226
+ configSchema: schema,
227
+ provenance,
228
+ envKeys,
229
+ overriddenBy: {}
230
+ };
231
+ }
232
+ /** Libellé d'identité de l'acteur d'une mutation (duck-type `IUser`, sans import). */
233
+ function actorLabel(user) {
234
+ if (!user || typeof user !== "object") return null;
235
+ const u = user;
236
+ if (typeof u.getUserIdentifier === "function") try {
237
+ return u.getUserIdentifier();
238
+ } catch {}
239
+ return u.username ?? u.email ?? (u.id != null ? String(u.id) : null);
240
+ }
241
+ /**
242
+ * Journalise une mutation de config (catégorie `config`) **si** le service d'audit
243
+ * est présent — résolution par nom dans le container du kernel, no-op sinon (même
244
+ * découplage que `recordAudit` côté security : framework n'importe pas security).
245
+ * Un secret n'arrive jamais ici (refusé en amont par {@link notEditableReason}).
246
+ */
247
+ function auditConfigChange(kernel, request, moduleKey, path, before, after) {
248
+ (kernel.container?.get("auditService"))?.record?.({
249
+ category: "config",
250
+ action: "config.update",
251
+ outcome: "success",
252
+ actor: actorLabel(request.user),
253
+ resource: `${moduleKey}.${path}`,
254
+ requestId: request.requestId ?? null,
255
+ metadata: {
256
+ before,
257
+ after
258
+ }
259
+ });
260
+ }
261
+ /**
262
+ * Journalise un changement de debug runtime (catégorie `log`). Même découplage
263
+ * que {@link auditConfigChange} (résolution par nom, no-op si pas d'audit).
264
+ */
265
+ function auditLogLevelChange(kernel, request, module, action, level, ttlMs) {
266
+ (kernel.container?.get("auditService"))?.record?.({
267
+ category: "log",
268
+ action: `log.debug.${action}`,
269
+ outcome: "success",
270
+ actor: actorLabel(request.user),
271
+ resource: module,
272
+ requestId: request.requestId ?? null,
273
+ metadata: {
274
+ level,
275
+ ttlMs
276
+ }
277
+ });
278
+ }
279
+ /** TTL par défaut d'un debug ciblé ouvert via l'endpoint (15 min). */
280
+ const DEFAULT_DEBUG_TTL_MS = 9e5;
281
+ /** Plafond dur du TTL (60 min) — borne anti-« debug oublié allumé » en prod. */
282
+ const MAX_DEBUG_TTL_MS = 36e5;
283
+ /**
284
+ * Producteur `IAdminApi` du **kernel** — exposé sous `/nodefony/kernel/api/*`.
285
+ *
286
+ * Le kernel ne peut pas s'enregistrer lui-même : il vit dans `@nodefony/core`
287
+ * et ne peut donc pas importer le broker (qui est dans `@nodefony/framework`).
288
+ * C'est framework qui construit cet `IAdminApi` à partir du kernel et
289
+ * l'enregistre auprès du broker (cf `Framework.onKernelReady`). Le kernel reste
290
+ * passif : on ne lit que ses getters publics + `process`.
291
+ *
292
+ * Endpoints (tous `ROLE_NODEFONY_ADMIN` par défaut) :
293
+ * - `GET /nodefony/kernel/api/health` → liveness léger (probe k8s-friendly)
294
+ * - `GET /nodefony/kernel/api/info` → identité runtime
295
+ * - `GET /nodefony/kernel/api/modules` → modules chargés + versions
296
+ *
297
+ * @param kernel - kernel courant (`Nodefony.getKernel()`).
298
+ * @returns le contrat admin du kernel, prêt à `broker.register()`.
299
+ */
300
+ /**
301
+ * Forme admise d'un nom de paquet npm — la garde entre un paramètre de route et
302
+ * une jointure de chemin. Sans elle, « ../../etc » désignerait un dossier hors
303
+ * de l'arbre des dépendances.
304
+ */
305
+ const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
306
+ /**
307
+ * Paquets à essayer pour une clé de module, **dans l'ordre**, et bornés au
308
+ * périmètre du framework.
309
+ *
310
+ * Pure — donc éprouvable sans disque ni dossier courant, et c'est nécessaire :
311
+ * les deux défauts qu'elle corrige tiennent l'un à l'ORDRE, l'autre au
312
+ * PÉRIMÈTRE, jamais au système de fichiers.
313
+ *
314
+ * 🔴 **Le scope d'abord.** `redis` désigne ici le module Nodefony — mais un
315
+ * client Redis tiers du même nom vit dans le même `node_modules`. L'essayer en
316
+ * premier le faisait gagner : le paquet trouvé n'avait pas de documentation,
317
+ * la réponse sortait VIDE, et le cas exact qu'on venait de corriger était le
318
+ * seul à rater. Un nom court est une clé Nodefony avant d'être un nom npm ;
319
+ * l'homonyme tiers ne vient qu'après.
320
+ *
321
+ * 🔴 **Le périmètre n'est pas la traversée.** {@link PACKAGE_NAME} empêche
322
+ * `../../etc` de désigner un dossier hors de l'arbre — elle ne dit rien de ce
323
+ * qu'on a le droit de servir. Sans cette seconde garde, la porte de
324
+ * documentation rendait les pages de n'importe quelle dépendance installée
325
+ * (`chrome-launcher` en a), c'est-à-dire qu'elle exposait l'arbre de
326
+ * dépendances d'une application à qui interroge la porte.
327
+ *
328
+ * ⚠️ `nodefony` en toutes lettres : le socle se nomme ainsi sur npm (héritage
329
+ * du dépôt JS) quand le reste de la pile porte le scope ; `CORE_PACKAGE` est
330
+ * son nom LOGIQUE, pas celui du dossier installé.
331
+ *
332
+ * @param name - clé courte (`redis`) ou nom de paquet (`@nodefony/redis`).
333
+ * @returns les noms de paquets à tenter, du plus probable au moins probable.
334
+ */
335
+ function nodefonyPackageCandidates(name) {
336
+ if (!PACKAGE_NAME.test(name)) return [];
337
+ const inScope = (pkg) => pkg === "nodefony" || pkg === "@nodefony/core" || pkg.startsWith("@nodefony/");
338
+ return (name.includes("/") ? [name] : [`@nodefony/${name}`, name]).filter(inScope);
339
+ }
340
+ function createKernelAdminApi(kernel) {
341
+ const descriptor = {
342
+ label: "Kernel",
343
+ icon: "server",
344
+ order: 0
345
+ };
346
+ const resolveTarget = (key) => {
347
+ if (key === CORE_KEY) return {
348
+ path: resolveCorePath(),
349
+ pkg: CORE_PACKAGE
350
+ };
351
+ const mod = kernel.getModules()[key];
352
+ if (!mod) return null;
353
+ return {
354
+ path: mod.path,
355
+ pkg: mod.getModuleName?.() ?? key
356
+ };
357
+ };
358
+ const resolvePackageDir = (name) => {
359
+ for (const candidate of nodefonyPackageCandidates(name)) {
360
+ const dir = join(repoRoot, "node_modules", candidate);
361
+ if (existsSync(join(dir, "package.json"))) return dir;
362
+ }
363
+ return null;
364
+ };
365
+ /**
366
+ * Chemin d'un porteur de documentation, module CHARGÉ ou paquet INSTALLÉ.
367
+ *
368
+ * ⚠️ Les deux cas existent et se confondaient en un seul refus. Un paquet
369
+ * peut être présent dans l'arbre des dépendances — sa documentation livrée
370
+ * avec lui — sans que le kernel l'ait chargé : `orm-core` est une
371
+ * bibliothèque pure, `redis` peut n'être pas activé dans cette application.
372
+ * Répondre « module introuvable » sur des pages parfaitement présentes
373
+ * faisait conclure qu'elles n'existaient pas, alors qu'elles sont
374
+ * précisément ce que git ignore et que les outils de recherche excluent.
375
+ *
376
+ * @param key - clé courte (`http`) ou nom de paquet (`@nodefony/redis`).
377
+ * @returns le dossier à lire, ou `null`.
378
+ */
379
+ const resolveDocDir = (key) => resolveTarget(key)?.path ?? resolvePackageDir(key);
380
+ /**
381
+ * Ce qu'on peut proposer à qui s'est trompé de nom — les clés qui répondent.
382
+ *
383
+ * Un refus qui ne nomme AUCUNE valeur valide laisse deviner, puis abandonner :
384
+ * l'appelant conclut que la ressource n'existe pas, quand il a seulement mal
385
+ * orthographié. Le secours accompagne donc le refus, il ne s'obtient pas par
386
+ * un second appel que personne ne pense à faire.
387
+ */
388
+ const docKeys = () => [CORE_KEY, ...Object.keys(kernel.getModules())];
389
+ const docTargets = () => {
390
+ const targets = [];
391
+ for (const key of [CORE_KEY, ...Object.keys(kernel.getModules())]) {
392
+ const target = resolveTarget(key);
393
+ if (target) targets.push({
394
+ key,
395
+ path: target.path
396
+ });
397
+ }
398
+ return targets;
399
+ };
400
+ const testJobs = /* @__PURE__ */ new Map();
401
+ const runtimeEdited = /* @__PURE__ */ new Map();
402
+ const devGuard = () => kernel.environment === "development" || Boolean(kernel.debug);
403
+ const repoRoot = process.cwd();
404
+ const relPath = (p) => p && p.startsWith(repoRoot) ? p.slice(repoRoot.length).replace(/^[/\\]+/, "") || "." : p ?? null;
405
+ const redactInfraUrl = (url) => url.replace(/\/\/[^/@]*@/, "//***@");
406
+ const buildConfigEntries = () => {
407
+ const modules = kernel.getModules();
408
+ const entries = [];
409
+ for (const k of Object.keys(modules)) {
410
+ const mod = modules[k];
411
+ const opts = mod.options ?? {};
412
+ if (Object.keys(opts).length === 0 && !mod.configSchema()) continue;
413
+ entries.push(computeConfigEntry(k, mod, runtimeEdited.get(k)));
414
+ }
415
+ attributeOverrideSources(entries);
416
+ return entries;
417
+ };
418
+ const resolveSourceFile = (src) => {
419
+ if (src === "nodefony.config.ts") return src;
420
+ for (const [k, m] of Object.entries(kernel.getModules())) {
421
+ const mm = m;
422
+ if ((mm.getModuleName?.() ?? k) !== src) continue;
423
+ if (mm.isApp) return "nodefony.config.ts";
424
+ if (!mm.path) return src;
425
+ const cfg = join(mm.path, "nodefony", "config", "config.ts");
426
+ return existsSync(cfg) ? relPath(cfg) ?? src : relPath(mm.path) ?? src;
427
+ }
428
+ return src;
429
+ };
430
+ const resolveStoreSource = (configPath, entriesBySeg) => {
431
+ if (!configPath) return null;
432
+ const dot = configPath.indexOf(".");
433
+ if (dot < 0) return null;
434
+ const seg = configPath.slice(0, dot).toLowerCase();
435
+ const field = configPath.slice(dot + 1).toLowerCase();
436
+ const entry = entriesBySeg.get(seg);
437
+ if (!entry?.provenance) return null;
438
+ const key = Object.keys(entry.provenance).find((k) => k.toLowerCase() === field);
439
+ const origin = key ? entry.provenance[key] : void 0;
440
+ if (!origin || !key) return null;
441
+ if (origin === "env") return {
442
+ origin,
443
+ detail: entry.envKeys[key] ?? "variable d'environnement"
444
+ };
445
+ if (origin === "app") return {
446
+ origin,
447
+ detail: resolveSourceFile(entry.overriddenBy[key] ?? "nodefony.config.ts")
448
+ };
449
+ if (origin === "runtime") return {
450
+ origin,
451
+ detail: "édition à chaud (Studio)"
452
+ };
453
+ return {
454
+ origin,
455
+ detail: "défaut du schéma"
456
+ };
457
+ };
458
+ const endpoints = [
459
+ {
460
+ path: "health",
461
+ summary: "Liveness probe — process up + boot status",
462
+ handler: () => ({
463
+ status: kernel.booted ? "ok" : "booting",
464
+ booted: kernel.booted,
465
+ uptime: process.uptime(),
466
+ pid: process.pid
467
+ })
468
+ },
469
+ {
470
+ path: "livez",
471
+ public: true,
472
+ summary: "Liveness/readiness probe (PUBLIC) — détails runtime gradués par authentification",
473
+ handler: (request) => {
474
+ const report = kernel.getBootReport();
475
+ const blocked = kernel.readinessBlocked;
476
+ const minimal = {
477
+ status: kernel.booted ? "ok" : "booting",
478
+ booted: kernel.booted,
479
+ ready: kernel.servable,
480
+ /** Nombre de composants qui retiennent la mise en service (0 = aucun). */
481
+ readinessBlocked: blocked,
482
+ degraded: report.modulesSkipped.length > 0 || !report.healthy,
483
+ uptime: process.uptime(),
484
+ environment: kernel.environment
485
+ };
486
+ const httpStatus = kernel.booted ? 200 : 503;
487
+ if (!request.roles.some((r) => r !== "ROLE_ANONYMOUS")) return {
488
+ status: httpStatus,
489
+ body: minimal
490
+ };
491
+ return {
492
+ status: httpStatus,
493
+ body: {
494
+ ...minimal,
495
+ version: kernel.version,
496
+ debug: kernel.debug,
497
+ pid: process.pid,
498
+ node: process.version,
499
+ platform: process.platform,
500
+ memory: process.memoryUsage(),
501
+ modules: Object.keys(kernel.getModules()).length,
502
+ modulesSkipped: report.modulesSkipped,
503
+ remediation: report.remediation,
504
+ readiness: kernel.readinessReport(),
505
+ cluster: { isCluster: process.env.NF_CLUSTER === "1" },
506
+ backplanes: { log: {
507
+ driver: getActiveLogDriver()?.name ?? null,
508
+ sink: Syslog.logSinkName
509
+ } },
510
+ git: GitService.read()
511
+ }
512
+ };
513
+ }
514
+ },
515
+ {
516
+ path: "info",
517
+ summary: "Runtime identity — version, environment, host",
518
+ handler: () => ({
519
+ version: kernel.version,
520
+ environment: kernel.environment,
521
+ debug: kernel.debug,
522
+ domain: kernel.domain,
523
+ pid: process.pid,
524
+ node: process.version,
525
+ platform: process.platform,
526
+ uptime: process.uptime(),
527
+ modules: Object.keys(kernel.getModules()).length,
528
+ cluster: { isCluster: process.env.NF_CLUSTER === "1" },
529
+ backplanes: { log: {
530
+ driver: getActiveLogDriver()?.name ?? null,
531
+ sink: Syslog.logSinkName,
532
+ available: listLogDrivers().map((d) => ({
533
+ name: d.name,
534
+ query: d.capabilities.query,
535
+ stream: d.capabilities.stream
536
+ }))
537
+ } },
538
+ git: GitService.read()
539
+ })
540
+ },
541
+ {
542
+ path: "processes",
543
+ summary: "Dev process topology (supervisor → server → Vite) + server ports — `nodefony status` over the data plane",
544
+ handler: async () => {
545
+ if (!devGuard()) return {
546
+ devMode: false,
547
+ supported: true,
548
+ running: false,
549
+ processes: [],
550
+ ports: [],
551
+ summary: {
552
+ supervisors: 0,
553
+ servers: 0,
554
+ vites: 0,
555
+ portsUp: 0,
556
+ portsTotal: 0
557
+ },
558
+ warnings: [],
559
+ pidfile: {
560
+ path: "",
561
+ pid: null,
562
+ alive: false
563
+ }
564
+ };
565
+ return {
566
+ devMode: true,
567
+ ...await collectDevStatus(REPO_ROOT, { includeSelf: true })
568
+ };
569
+ }
570
+ },
571
+ {
572
+ path: "modules",
573
+ summary: "Loaded modules with their versions (+ core pseudo-module)",
574
+ handler: async () => {
575
+ const modules = kernel.getModules();
576
+ const core = await readCoreInfo();
577
+ const list = [{
578
+ key: CORE_KEY,
579
+ name: core.name,
580
+ version: core.version,
581
+ isApp: false,
582
+ path: relPath(core.path)
583
+ }];
584
+ for (const name of Object.keys(modules)) {
585
+ const mod = modules[name];
586
+ list.push({
587
+ key: name,
588
+ name: mod.getModuleName?.() ?? name,
589
+ version: mod.getModuleVersion?.() ?? null,
590
+ isApp: mod.isApp ?? false,
591
+ path: relPath(mod.path)
592
+ });
593
+ }
594
+ return list;
595
+ }
596
+ },
597
+ {
598
+ path: "services",
599
+ summary: "Every service registered by every module, with its implementing class",
600
+ handler: () => {
601
+ const modules = kernel.getModules();
602
+ const services = [];
603
+ for (const name of Object.keys(modules)) {
604
+ const mod = modules[name];
605
+ for (const service of mod.getServiceNames?.() ?? []) services.push({
606
+ name: service,
607
+ module: name,
608
+ class: mod.get(service)?.constructor?.name ?? null
609
+ });
610
+ }
611
+ services.sort((a, b) => `${a.module}.${a.name}`.localeCompare(`${b.module}.${b.name}`));
612
+ return services;
613
+ }
614
+ },
615
+ {
616
+ path: "config",
617
+ summary: "Aggregated config of all modules (effective values redacted + JSON Schema + per-field provenance) for the global config page",
618
+ handler: async () => ({ modules: buildConfigEntries() })
619
+ },
620
+ {
621
+ path: "stores",
622
+ summary: "Runtime persistence stores per brick (resolved store, provenance, available backends) + declared infra",
623
+ handler: () => {
624
+ const infra = kernel.infra;
625
+ const entriesBySeg = new Map(buildConfigEntries().map((e) => [e.seg, e]));
626
+ const dbUrl = infra.database ? redactInfraUrl(infra.database.url) : null;
627
+ const cacheUrl = infra.cache ? redactInfraUrl(infra.cache.url) : null;
628
+ const storeEndpoint = (res) => {
629
+ if (res.location) return void 0;
630
+ if (res.resolved === "redis") return cacheUrl ?? void 0;
631
+ if (res.resolved === "drizzle" || res.resolved === "mongoose") return dbUrl ?? void 0;
632
+ };
633
+ const infraVar = (res) => {
634
+ if (res.resolved === "redis" && infra.cache) return "NF_REDIS_URL";
635
+ if ((res.resolved === "drizzle" || res.resolved === "mongoose") && infra.database) return "NF_DATABASE_URL";
636
+ return null;
637
+ };
638
+ const registered = new Set(kernel.storeResolutions.flatMap((r) => [...r.available]));
639
+ return {
640
+ engines: OFFICIAL_STORE_ADAPTERS.map((a) => {
641
+ const manifest = readAdapterManifest(a.package);
642
+ return {
643
+ engine: a.engine,
644
+ package: a.package,
645
+ family: a.family,
646
+ kind: manifest?.storeKind ?? "durable",
647
+ provides: manifest?.stores ?? [],
648
+ installed: manifest !== null || isPackageInstalled(a.package),
649
+ loaded: registered.has(a.engine)
650
+ };
651
+ }),
652
+ infra: {
653
+ database: infra.database ? {
654
+ scheme: infra.database.scheme,
655
+ family: infra.database.family,
656
+ dialect: infra.database.dialect,
657
+ url: dbUrl
658
+ } : null,
659
+ cache: infra.cache ? { url: cacheUrl } : null,
660
+ logs: infra.logs ? {
661
+ lokiUrl: infra.logs.lokiUrl ? redactInfraUrl(infra.logs.lokiUrl) : null,
662
+ opensearchUrl: infra.logs.opensearchUrl ? redactInfraUrl(infra.logs.opensearchUrl) : null
663
+ } : null
664
+ },
665
+ stores: kernel.storeResolutions.map((res) => {
666
+ const iv = res.provenance === "infra" ? infraVar(res) : null;
667
+ return {
668
+ ...res,
669
+ endpoint: storeEndpoint(res),
670
+ source: iv ? {
671
+ origin: "infra",
672
+ detail: iv
673
+ } : resolveStoreSource(res.configPath, entriesBySeg)
674
+ };
675
+ })
676
+ };
677
+ }
678
+ },
679
+ {
680
+ path: "config/{module}",
681
+ method: "PATCH",
682
+ summary: "Live-edit one runtimeMutable config field of a module (dev only) — validated + audited",
683
+ handler: (request) => {
684
+ if (!devGuard()) return {
685
+ status: 409,
686
+ body: {
687
+ error: "Config live-edit disabled outside development",
688
+ reason: "prod_immutable"
689
+ }
690
+ };
691
+ const key = request.params.module;
692
+ const mod = kernel.getModules()[key];
693
+ if (!mod) return {
694
+ status: 404,
695
+ body: {
696
+ error: "Module not found",
697
+ key,
698
+ available: docKeys()
699
+ }
700
+ };
701
+ const body = request.body ?? {};
702
+ if (typeof body.path !== "string" || body.path.length === 0) return {
703
+ status: 400,
704
+ body: { error: "Missing 'path'" }
705
+ };
706
+ const segments = body.path.split(".").filter((s) => s.length > 0);
707
+ if (segments.length === 0) return {
708
+ status: 400,
709
+ body: {
710
+ error: "Invalid 'path'",
711
+ path: body.path
712
+ }
713
+ };
714
+ const value = body.value;
715
+ const schema = mod.configSchema();
716
+ if (!schema) return {
717
+ status: 409,
718
+ body: {
719
+ error: "Module has no Zod schema — live-edit unavailable",
720
+ reason: "no_schema"
721
+ }
722
+ };
723
+ const node = navigateSchemaNode(schema, segments);
724
+ if (!node) return {
725
+ status: 404,
726
+ body: {
727
+ error: "Unknown config path",
728
+ path: body.path
729
+ }
730
+ };
731
+ const flags = nodeFlags(node);
732
+ const seg = computeSeg(mod.getModuleName?.() ?? key, mod.isApp ?? false);
733
+ const reason = notEditableReason(flags);
734
+ if (reason) return {
735
+ status: 409,
736
+ body: {
737
+ error: "Field is not live-editable",
738
+ reason,
739
+ recipe: recipeFor(seg, segments, flags.secret)
740
+ }
741
+ };
742
+ const verdict = validateLeafValue(node, value);
743
+ if (!verdict.ok) return {
744
+ status: 422,
745
+ body: {
746
+ error: "Invalid value",
747
+ message: verdict.message
748
+ }
749
+ };
750
+ const opts = mod.options ?? {};
751
+ const before = getResolvedPath(opts, segments);
752
+ if (!applyResolvedPath(opts, segments, value)) return {
753
+ status: 422,
754
+ body: {
755
+ error: "Path not present in module config",
756
+ path: body.path
757
+ }
758
+ };
759
+ const svcMod = mod;
760
+ for (const sname of svcMod.getServiceNames?.() ?? []) {
761
+ const svc = svcMod.get?.(sname);
762
+ if (svc?.options && svc.options !== opts) applyResolvedPath(svc.options, segments, value);
763
+ svc?.onConfigChanged?.(segments);
764
+ }
765
+ let edited = runtimeEdited.get(key);
766
+ if (!edited) {
767
+ edited = /* @__PURE__ */ new Set();
768
+ runtimeEdited.set(key, edited);
769
+ }
770
+ edited.add(body.path.toLowerCase());
771
+ auditConfigChange(kernel, request, key, body.path, before, value);
772
+ return {
773
+ status: 200,
774
+ body: {
775
+ ok: true,
776
+ key,
777
+ path: body.path,
778
+ value,
779
+ provenance: "runtime"
780
+ }
781
+ };
782
+ }
783
+ },
784
+ {
785
+ path: "log/level",
786
+ summary: "Current runtime debug state — global DEBUG flag + active per-module overrides",
787
+ handler: () => {
788
+ const syslog = kernel.syslog;
789
+ if (!syslog) return {
790
+ status: 503,
791
+ body: { error: "Syslog unavailable" }
792
+ };
793
+ return {
794
+ globalDebug: syslog.severityEnabled("DEBUG"),
795
+ overrides: syslog.getDebugOverrides(),
796
+ expiresAt: syslog.getDebugOverrideExpiry()
797
+ };
798
+ }
799
+ },
800
+ {
801
+ path: "log/level",
802
+ method: "PATCH",
803
+ summary: "Turn targeted per-module debug on/off at runtime (prod-safe, auto-expiring, audited)",
804
+ handler: (request) => {
805
+ const syslog = kernel.syslog;
806
+ if (!syslog) return {
807
+ status: 503,
808
+ body: { error: "Syslog unavailable" }
809
+ };
810
+ const body = request.body ?? {};
811
+ if (typeof body.module !== "string" || body.module.length === 0) return {
812
+ status: 400,
813
+ body: { error: "Missing 'module' (per-module debug only)" }
814
+ };
815
+ const module = body.module;
816
+ if (body.level === "off" || body.level === null || body.level === "") {
817
+ const cleared = syslog.clearDebugOverride(module);
818
+ auditLogLevelChange(kernel, request, module, "clear", null, null);
819
+ return {
820
+ ok: true,
821
+ module,
822
+ cleared,
823
+ overrides: syslog.getDebugOverrides()
824
+ };
825
+ }
826
+ if (typeof body.level !== "string" && typeof body.level !== "number") return {
827
+ status: 400,
828
+ body: { error: "Missing 'level'" }
829
+ };
830
+ const level = Syslog.severityFromInput(body.level);
831
+ if (level === null) return {
832
+ status: 422,
833
+ body: {
834
+ error: "Invalid 'level'",
835
+ level: body.level
836
+ }
837
+ };
838
+ const reqTtl = typeof body.ttlMs === "number" && body.ttlMs > 0 ? body.ttlMs : DEFAULT_DEBUG_TTL_MS;
839
+ const ttlMs = Math.min(reqTtl, MAX_DEBUG_TTL_MS);
840
+ syslog.setDebugOverride(module, level, ttlMs);
841
+ auditLogLevelChange(kernel, request, module, "set", level, ttlMs);
842
+ return {
843
+ ok: true,
844
+ module,
845
+ level,
846
+ ttlMs,
847
+ overrides: syslog.getDebugOverrides()
848
+ };
849
+ }
850
+ },
851
+ {
852
+ path: "module/{name}",
853
+ summary: "Detail of one module by key (http, framework, … or core)",
854
+ handler: async (request) => {
855
+ const key = request.params.name;
856
+ if (key === CORE_KEY) {
857
+ const core = await readCoreInfo();
858
+ return {
859
+ key: CORE_KEY,
860
+ name: core.name,
861
+ version: core.version,
862
+ isApp: false,
863
+ path: relPath(core.path),
864
+ dependencies: core.dependencies,
865
+ services: [],
866
+ config: {},
867
+ configSchema: null,
868
+ docsCount: await countModuleDocs(core.path),
869
+ symbolsCount: (await listModuleSymbols(CORE_PACKAGE)).length,
870
+ coverageLines: (await readCoverage(core.path)).total?.lines ?? null
871
+ };
872
+ }
873
+ const mod = kernel.getModules()[key];
874
+ if (!mod) return {
875
+ status: 404,
876
+ body: {
877
+ error: "Module not found",
878
+ key,
879
+ available: docKeys()
880
+ }
881
+ };
882
+ const services = mod.getServiceNames().map((sname) => ({
883
+ name: sname,
884
+ class: mod.get(sname)?.constructor?.name ?? null
885
+ }));
886
+ const cfg = computeConfigEntry(key, mod, runtimeEdited.get(key));
887
+ return {
888
+ key,
889
+ name: cfg.name,
890
+ version: mod.getModuleVersion?.() ?? null,
891
+ isApp: cfg.isApp,
892
+ path: relPath(mod.path),
893
+ dependencies: mod.getDependencies?.() ?? [],
894
+ services,
895
+ config: cfg.config,
896
+ configSchema: cfg.configSchema,
897
+ provenance: cfg.provenance,
898
+ seg: cfg.seg,
899
+ envKeys: cfg.envKeys,
900
+ docsCount: await countModuleDocs(mod.path),
901
+ symbolsCount: (await listModuleSymbols(cfg.name)).length,
902
+ coverageLines: (await readCoverage(mod.path)).total?.lines ?? null
903
+ };
904
+ }
905
+ },
906
+ {
907
+ path: "module/{name}/dependencies",
908
+ summary: "Module dependencies with installed versions",
909
+ handler: async (request) => {
910
+ const target = resolveTarget(request.params.name);
911
+ if (!target) return {
912
+ status: 404,
913
+ body: {
914
+ error: "Module not found",
915
+ key: request.params.name,
916
+ available: docKeys()
917
+ }
918
+ };
919
+ return {
920
+ key: request.params.name,
921
+ deps: await readDependencies(target.path)
922
+ };
923
+ }
924
+ },
925
+ {
926
+ path: "module/{name}/dependencies/outdated",
927
+ summary: "Check external dependencies for updates (npm registry)",
928
+ handler: async (request) => {
929
+ const target = resolveTarget(request.params.name);
930
+ if (!target) return {
931
+ status: 404,
932
+ body: {
933
+ error: "Module not found",
934
+ key: request.params.name,
935
+ available: docKeys()
936
+ }
937
+ };
938
+ const deps = await readDependencies(target.path);
939
+ return {
940
+ key: request.params.name,
941
+ outdated: await checkOutdated(deps)
942
+ };
943
+ }
944
+ },
945
+ {
946
+ path: "module/{name}/docs",
947
+ summary: "Documentation index of one module (markdown in <module>/docs)",
948
+ handler: async (request) => {
949
+ const key = request.params.name;
950
+ const dir = resolveDocDir(key);
951
+ if (!dir) return {
952
+ status: 404,
953
+ body: {
954
+ error: "Module not found",
955
+ key,
956
+ available: docKeys()
957
+ }
958
+ };
959
+ return {
960
+ key,
961
+ docs: await listModuleDocs(dir)
962
+ };
963
+ }
964
+ },
965
+ {
966
+ path: "module/{name}/docs/{slug}",
967
+ summary: "Raw markdown of one module doc by slug",
968
+ handler: async (request) => {
969
+ const key = request.params.name;
970
+ const dir = resolveDocDir(key);
971
+ if (!dir) return {
972
+ status: 404,
973
+ body: {
974
+ error: "Module not found",
975
+ key,
976
+ available: docKeys()
977
+ }
978
+ };
979
+ const doc = await readModuleDoc(dir, request.params.slug);
980
+ if (!doc) return {
981
+ status: 404,
982
+ body: {
983
+ error: "Doc not found",
984
+ key,
985
+ slug: request.params.slug,
986
+ available: (await listModuleDocs(dir)).map((d) => d.slug)
987
+ }
988
+ };
989
+ const wanted = typeof request.query.section === "string" ? request.query.section : "";
990
+ if (wanted !== "") {
991
+ const section = extractMarkdownSection(doc.markdown, wanted);
992
+ if (!section) return {
993
+ status: 404,
994
+ body: {
995
+ error: "Section not found",
996
+ key,
997
+ slug: doc.slug,
998
+ section: wanted,
999
+ outline: outlineMarkdown(doc.markdown)
1000
+ }
1001
+ };
1002
+ return {
1003
+ ...doc,
1004
+ section: section.title,
1005
+ markdown: section.markdown
1006
+ };
1007
+ }
1008
+ if (request.query.outline !== void 0) {
1009
+ const { markdown, ...rest } = doc;
1010
+ return {
1011
+ ...rest,
1012
+ key,
1013
+ chars: markdown.length,
1014
+ outline: outlineMarkdown(markdown)
1015
+ };
1016
+ }
1017
+ return doc;
1018
+ }
1019
+ },
1020
+ {
1021
+ path: "docs",
1022
+ summary: "Documentation index across every loaded module",
1023
+ handler: async () => {
1024
+ const modules = [];
1025
+ for (const target of docTargets()) {
1026
+ const docs = await listModuleDocs(target.path);
1027
+ if (docs.length > 0) modules.push({
1028
+ key: target.key,
1029
+ docs
1030
+ });
1031
+ }
1032
+ return {
1033
+ total: modules.reduce((sum, m) => sum + m.docs.length, 0),
1034
+ modules
1035
+ };
1036
+ }
1037
+ },
1038
+ {
1039
+ path: "docs/search",
1040
+ summary: "Full-text search across every loaded module's documentation",
1041
+ handler: async (request) => {
1042
+ const raw = request.query.q;
1043
+ const q = typeof raw === "string" ? raw : "";
1044
+ if (q.trim() === "") return {
1045
+ status: 400,
1046
+ body: {
1047
+ error: "Missing query parameter: q",
1048
+ hint: "ex. /nodefony/kernel/api/docs/search?q=session+redis"
1049
+ }
1050
+ };
1051
+ const limit = Number.parseInt(String(request.query.limit ?? ""), 10);
1052
+ return searchModuleDocs(docTargets(), q, { limit: Number.isFinite(limit) && limit > 0 ? limit : void 0 });
1053
+ }
1054
+ },
1055
+ {
1056
+ path: "module/{name}/symbol/{symbol}",
1057
+ summary: "Declaration (signature + TSDoc) of one exported symbol",
1058
+ handler: async (request) => {
1059
+ const key = request.params.name;
1060
+ const modulePath = resolveTarget(key)?.path ?? resolvePackageDir(key);
1061
+ if (!modulePath) return {
1062
+ status: 404,
1063
+ body: {
1064
+ error: "Module not found",
1065
+ key,
1066
+ available: docKeys()
1067
+ }
1068
+ };
1069
+ const symbol = request.params.symbol;
1070
+ const found = await readSymbolDeclaration(modulePath, symbol);
1071
+ if (!found) return {
1072
+ status: 404,
1073
+ body: {
1074
+ error: "Symbol declaration not found",
1075
+ key,
1076
+ symbol,
1077
+ hint: "le module publie-t-il ses types (dist/types) ?"
1078
+ }
1079
+ };
1080
+ return {
1081
+ key,
1082
+ symbol,
1083
+ ...found
1084
+ };
1085
+ }
1086
+ },
1087
+ {
1088
+ path: "module/{name}/symbols",
1089
+ summary: "Exported TS symbols + TSDoc descriptions (.ai/symbols.json)",
1090
+ handler: async (request) => {
1091
+ const key = request.params.name;
1092
+ const target = resolveTarget(key);
1093
+ if (!target) return {
1094
+ status: 404,
1095
+ body: {
1096
+ error: "Module not found",
1097
+ key,
1098
+ available: docKeys()
1099
+ }
1100
+ };
1101
+ return {
1102
+ key,
1103
+ package: target.pkg,
1104
+ symbols: await listModuleSymbols(target.pkg)
1105
+ };
1106
+ }
1107
+ },
1108
+ {
1109
+ path: "module/{name}/coverage",
1110
+ summary: "Latest test coverage report (vitest json-summary)",
1111
+ handler: async (request) => {
1112
+ const key = request.params.name;
1113
+ const target = resolveTarget(key);
1114
+ if (!target) return {
1115
+ status: 404,
1116
+ body: {
1117
+ error: "Module not found",
1118
+ key,
1119
+ available: docKeys()
1120
+ }
1121
+ };
1122
+ return {
1123
+ key,
1124
+ ...await readCoverage(target.path)
1125
+ };
1126
+ }
1127
+ },
1128
+ {
1129
+ path: "module/{name}/tests",
1130
+ summary: "List test files of one module",
1131
+ handler: async (request) => {
1132
+ const key = request.params.name;
1133
+ const target = resolveTarget(key);
1134
+ if (!target) return {
1135
+ status: 404,
1136
+ body: {
1137
+ error: "Module not found",
1138
+ key,
1139
+ available: docKeys()
1140
+ }
1141
+ };
1142
+ return {
1143
+ key,
1144
+ devMode: kernel.environment === "development" || Boolean(kernel.debug),
1145
+ files: await listTestFiles(target.path),
1146
+ groups: await listTestGroups(target.path)
1147
+ };
1148
+ }
1149
+ },
1150
+ {
1151
+ path: "module/{name}/test/run",
1152
+ method: "POST",
1153
+ summary: "Start a test run (dev only) — 1 file or whole suite → jobId",
1154
+ handler: (request) => {
1155
+ if (!devGuard()) return {
1156
+ status: 403,
1157
+ body: { error: "Test runner disabled outside development" }
1158
+ };
1159
+ const key = request.params.name;
1160
+ const target = resolveTarget(key);
1161
+ if (!target) return {
1162
+ status: 404,
1163
+ body: {
1164
+ error: "Module not found",
1165
+ key,
1166
+ available: docKeys()
1167
+ }
1168
+ };
1169
+ const body = request.body ?? {};
1170
+ let file;
1171
+ if (typeof body.file === "string" && body.file) {
1172
+ if (body.file.includes("..") || body.file.startsWith("-") || !body.file.endsWith(".test.ts")) return {
1173
+ status: 400,
1174
+ body: {
1175
+ error: "Invalid test file",
1176
+ file: body.file
1177
+ }
1178
+ };
1179
+ file = body.file;
1180
+ }
1181
+ const jobId = randomUUID();
1182
+ testJobs.set(jobId, {
1183
+ status: "running",
1184
+ startedAt: Date.now()
1185
+ });
1186
+ if (testJobs.size > 16) {
1187
+ const oldest = [...testJobs.entries()].sort((a, b) => a[1].startedAt - b[1].startedAt)[0];
1188
+ if (oldest) testJobs.delete(oldest[0]);
1189
+ }
1190
+ runModuleTests(target.path, file).then((result) => testJobs.set(jobId, {
1191
+ status: "done",
1192
+ startedAt: Date.now(),
1193
+ result
1194
+ }), (e) => testJobs.set(jobId, {
1195
+ status: "done",
1196
+ startedAt: Date.now(),
1197
+ result: {
1198
+ ok: false,
1199
+ code: null,
1200
+ passed: 0,
1201
+ failed: 0,
1202
+ durationMs: 0,
1203
+ output: String(e),
1204
+ mode: ""
1205
+ }
1206
+ }));
1207
+ return {
1208
+ key,
1209
+ jobId,
1210
+ running: true
1211
+ };
1212
+ }
1213
+ },
1214
+ {
1215
+ path: "module/{name}/test/run",
1216
+ method: "GET",
1217
+ summary: "Poll a test run by ?jobId",
1218
+ handler: (request) => {
1219
+ const jobId = String(request.query.jobId ?? "");
1220
+ const job = jobId ? testJobs.get(jobId) : void 0;
1221
+ if (!job) return {
1222
+ status: 404,
1223
+ body: {
1224
+ error: "Unknown jobId",
1225
+ jobId
1226
+ }
1227
+ };
1228
+ return {
1229
+ jobId,
1230
+ done: job.status === "done",
1231
+ ...job.result
1232
+ };
1233
+ }
1234
+ }
1235
+ ];
1236
+ return {
1237
+ adminNamespace: "kernel",
1238
+ adminDescriptor: () => descriptor,
1239
+ adminEndpoints: () => endpoints
1240
+ };
1241
+ }
1242
+ //#endregion
1243
+ export { createKernelAdminApi, nodefonyPackageCandidates, safeConfig };