@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
|
@@ -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 };
|