@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,466 @@
|
|
|
1
|
+
import { SYSLOG_FILTERS, SYSLOG_SORTABLE } from "./syslogFilters.js";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Syslog, getActiveLogDriver, getLogDriver, listLogDrivers, parseFilters, parsePageQuery, redactSecrets, setActiveLogDriver } from "nodefony";
|
|
4
|
+
import fsp from "node:fs/promises";
|
|
5
|
+
//#region nodefony/src/SyslogAdminApi.ts
|
|
6
|
+
/** Plafond d'octets lus en queue de fichier (tail / fenêtre incrémentale). */
|
|
7
|
+
const MAX_TAIL_BYTES = 262144;
|
|
8
|
+
/**
|
|
9
|
+
* Nom de fichier de log autorisé : basename simple terminant par `.log` (sink
|
|
10
|
+
* texte LB.W) OU `.jsonl` (queryable LB.2/5). Anti path-traversal (pas de `/`,
|
|
11
|
+
* pas de `..`). Couvre les deux familles écrites dans le répertoire des logs.
|
|
12
|
+
*/
|
|
13
|
+
const LOG_NAME = /^[A-Za-z0-9._-]+\.(log|jsonl)$/;
|
|
14
|
+
/**
|
|
15
|
+
* Producteur `IAdminApi` du **syslog** (core) — exposé sous
|
|
16
|
+
* `/nodefony/syslog/api/*`. 4ᵉ et dernier producteur de P10.3.
|
|
17
|
+
*
|
|
18
|
+
* Le syslog vit dans `@nodefony/core` et ne peut pas importer framework →
|
|
19
|
+
* framework le wrappe (comme le kernel) via `createSyslogAdminApi(syslog)`.
|
|
20
|
+
* Lecture seule du ring buffer (`ISyslog.ringStack`, FIFO O(1)).
|
|
21
|
+
*
|
|
22
|
+
* Endpoints :
|
|
23
|
+
* - `GET /nodefony/syslog/api/logs` → Pdu récents (`?severity=ERROR&limit=N`)
|
|
24
|
+
* - `GET /nodefony/syslog/api/info` → compteurs (valid/invalid/missed/buffer)
|
|
25
|
+
*
|
|
26
|
+
* @param syslog - instance Syslog du kernel (`kernel.syslog`).
|
|
27
|
+
* @param options - viewer de fichiers (DEV) : `logDir` + `enableFiles`.
|
|
28
|
+
*/
|
|
29
|
+
function createSyslogAdminApi(syslog, options = {}) {
|
|
30
|
+
const logDir = options.enableFiles && options.logDir ? path.resolve(options.logDir) : void 0;
|
|
31
|
+
const logDirPublic = (() => {
|
|
32
|
+
if (!logDir) return null;
|
|
33
|
+
const rel = path.relative(process.cwd(), logDir);
|
|
34
|
+
return rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : path.basename(logDir);
|
|
35
|
+
})();
|
|
36
|
+
/** Lit un entier de query (`?limit=50`), borné, avec défaut. */
|
|
37
|
+
const intParam = (req, key, def, max) => {
|
|
38
|
+
const raw = req.query[key];
|
|
39
|
+
const v = Array.isArray(raw) ? raw[0] : raw;
|
|
40
|
+
const n = v !== void 0 ? Number.parseInt(v, 10) : NaN;
|
|
41
|
+
if (Number.isNaN(n) || n <= 0) return def;
|
|
42
|
+
return Math.min(n, max);
|
|
43
|
+
};
|
|
44
|
+
/** Première valeur d'un param de query (`?raw=1`). */
|
|
45
|
+
const oneParam = (req, key) => {
|
|
46
|
+
const raw = req.query[key];
|
|
47
|
+
return Array.isArray(raw) ? raw[0] : raw;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Construit un {@link ILogQueryCriteria} depuis la query string — la traduction
|
|
51
|
+
* HTTP→backplane partagée par `logs` (array, rétrocompat) et `logs/search`
|
|
52
|
+
* (paginé).
|
|
53
|
+
*
|
|
54
|
+
* Elle passe par les DEUX lecteurs du contrat de page : `parsePageQuery` pour
|
|
55
|
+
* la fenêtre, l'ordre et la recherche, `parseFilters` pour le vocabulaire de
|
|
56
|
+
* {@link SYSLOG_FILTERS}. Ce qu'ils remplacent n'était pas seulement une
|
|
57
|
+
* lecture à la main, c'était un **accepter puis jeter** systématique : chaque
|
|
58
|
+
* valeur invalide (`?severity=CRITICAL`, `?protocol=grpc`, `?flow=nimporte`)
|
|
59
|
+
* et chaque faute de frappe (`?severty=ERROR`) laissait le critère vide et
|
|
60
|
+
* rendait le journal ENTIER sous un `200`. Sur un journal d'exploitation,
|
|
61
|
+
* c'est la réponse qui se lit « rien à signaler ».
|
|
62
|
+
*
|
|
63
|
+
* @param req - la requête admin (query string déjà parsée).
|
|
64
|
+
* @param accepts - paramètres que l'appelant lit LUI-MÊME (`driver`, `scope`) ;
|
|
65
|
+
* sans cette déclaration, le refus de l'inconnu les rejetterait à tort.
|
|
66
|
+
* @throws `PageQueryError` (400) sur un paramètre, une valeur ou un tri
|
|
67
|
+
* qu'aucune partie de cet endpoint ne sait honorer.
|
|
68
|
+
*/
|
|
69
|
+
const buildCriteria = (req, accepts = []) => {
|
|
70
|
+
const page = parsePageQuery(req.query, {
|
|
71
|
+
defaultLimit: 200,
|
|
72
|
+
maxLimit: 1e3,
|
|
73
|
+
sortable: SYSLOG_SORTABLE,
|
|
74
|
+
searchable: true
|
|
75
|
+
});
|
|
76
|
+
const filters = parseFilters(req.query, SYSLOG_FILTERS, { accepts });
|
|
77
|
+
const criteria = {
|
|
78
|
+
limit: page.limit,
|
|
79
|
+
...filters
|
|
80
|
+
};
|
|
81
|
+
if (page.offset !== void 0 && page.offset > 0) criteria.offset = page.offset;
|
|
82
|
+
if (page.q !== void 0) criteria.text = page.q;
|
|
83
|
+
const dir = page.order?.[0]?.[1];
|
|
84
|
+
if (dir !== void 0) criteria.order = dir === "ASC" ? "asc" : "desc";
|
|
85
|
+
return criteria;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Driver de RELECTURE pour la TRACE par requestId (`?scope=trace`) — privilégie
|
|
89
|
+
* la rétention LONGUE. Le ring `memory` ne garde que ~`maxStack` entrées et
|
|
90
|
+
* churne vite sous le trafic data-plane de Studio (chaque page = ~12 logs) → la
|
|
91
|
+
* trace d'une requête vue il y a quelques minutes est souvent déjà évincée. Choix
|
|
92
|
+
* AGNOSTIQUE de l'env : (1) si le driver actif est persistant (loki/opensearch/
|
|
93
|
+
* file en prod 12-factor) on le garde ; (2) sinon, en dev, le JSONL du worker
|
|
94
|
+
* COURANT (`file` = 1 fichier `nodefony-<pid>.jsonl`, PAS `cluster-file` qui
|
|
95
|
+
* scannerait tous les pids du dossier) ; (3) à défaut, le driver actif (memory) —
|
|
96
|
+
* rétention courte mais jamais en erreur. Le front ne connaît donc pas l'infra.
|
|
97
|
+
*/
|
|
98
|
+
const resolveTraceDriver = () => {
|
|
99
|
+
const active = getActiveLogDriver();
|
|
100
|
+
if (active && active.name !== "memory" && active.query) return active;
|
|
101
|
+
const file = getLogDriver("file");
|
|
102
|
+
if (file?.query) return file;
|
|
103
|
+
return active;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Résout un nom de fichier de log en chemin absolu **sûr** dans `logDir`.
|
|
107
|
+
* Rejette tout nom hors `^[A-Za-z0-9._-]+\.log$` et tout chemin qui
|
|
108
|
+
* s'échapperait du répertoire (anti path-traversal). `null` = invalide.
|
|
109
|
+
*/
|
|
110
|
+
const resolveLogFile = (name) => {
|
|
111
|
+
if (!logDir || !LOG_NAME.test(name) || name.includes("..")) return null;
|
|
112
|
+
const resolved = path.resolve(logDir, name);
|
|
113
|
+
if (path.dirname(resolved) !== logDir) return null;
|
|
114
|
+
return resolved;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Lit la queue d'un fichier (octets `[start, size)`), ne renvoie que des
|
|
118
|
+
* **lignes complètes** (jusqu'au dernier `\n`) pour éviter les lignes coupées
|
|
119
|
+
* entre deux polls. `to` pointe sur la frontière de ligne → prochain `from`.
|
|
120
|
+
*/
|
|
121
|
+
const readTail = async (file, start, size) => {
|
|
122
|
+
if (start >= size) return {
|
|
123
|
+
text: "",
|
|
124
|
+
to: size
|
|
125
|
+
};
|
|
126
|
+
const len = size - start;
|
|
127
|
+
const buf = Buffer.alloc(len);
|
|
128
|
+
await using fh = await fsp.open(file, "r");
|
|
129
|
+
await fh.read(buf, 0, len, start);
|
|
130
|
+
const chunk = buf.toString("utf8");
|
|
131
|
+
const lastNl = chunk.lastIndexOf("\n");
|
|
132
|
+
if (lastNl === -1) return {
|
|
133
|
+
text: "",
|
|
134
|
+
to: start
|
|
135
|
+
};
|
|
136
|
+
return {
|
|
137
|
+
text: chunk.slice(0, lastNl),
|
|
138
|
+
to: start + Buffer.byteLength(chunk.slice(0, lastNl + 1), "utf8")
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
const descriptor = {
|
|
142
|
+
label: "Logs",
|
|
143
|
+
icon: "file-text",
|
|
144
|
+
order: 3
|
|
145
|
+
};
|
|
146
|
+
const endpoints = [
|
|
147
|
+
{
|
|
148
|
+
path: "logs",
|
|
149
|
+
summary: "Logs récents via le driver backplane actif — ?severity=&module=&requestId=&q=&limit=N (renvoie un ARRAY, rétrocompat).",
|
|
150
|
+
page: {
|
|
151
|
+
sortable: () => SYSLOG_SORTABLE,
|
|
152
|
+
filters: SYSLOG_FILTERS,
|
|
153
|
+
search: () => true
|
|
154
|
+
},
|
|
155
|
+
handler: async (request) => {
|
|
156
|
+
const driver = getActiveLogDriver();
|
|
157
|
+
if (!driver?.query) return [];
|
|
158
|
+
return (await driver.query(buildCriteria(request))).rows;
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
path: "logs/search",
|
|
163
|
+
summary: "Query paginée du backplane → { rows, total, truncated } (DataGrid Studio). Mêmes critères que logs + offset.",
|
|
164
|
+
page: {
|
|
165
|
+
sortable: () => SYSLOG_SORTABLE,
|
|
166
|
+
filters: SYSLOG_FILTERS,
|
|
167
|
+
search: () => true
|
|
168
|
+
},
|
|
169
|
+
handler: async (request) => {
|
|
170
|
+
const wanted = oneParam(request, "driver");
|
|
171
|
+
const driver = wanted ? getLogDriver(wanted) : oneParam(request, "scope") === "trace" ? resolveTraceDriver() : getActiveLogDriver();
|
|
172
|
+
if (!driver?.query) return {
|
|
173
|
+
status: 409,
|
|
174
|
+
body: {
|
|
175
|
+
queryable: false,
|
|
176
|
+
driver: driver?.name ?? wanted ?? null
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
return await driver.query(buildCriteria(request, ["driver", "scope"]));
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
path: "backplane",
|
|
184
|
+
summary: "Méta du Log Backplane — driver de relecture actif, drivers dispo, sink write (LB.W), compteurs syslog.",
|
|
185
|
+
handler: () => {
|
|
186
|
+
const driver = getActiveLogDriver();
|
|
187
|
+
return {
|
|
188
|
+
activeDriver: driver ? {
|
|
189
|
+
name: driver.name,
|
|
190
|
+
capabilities: driver.capabilities
|
|
191
|
+
} : null,
|
|
192
|
+
drivers: listLogDrivers(),
|
|
193
|
+
write: {
|
|
194
|
+
sink: Syslog.logSinkName,
|
|
195
|
+
sinkEnabled: Syslog.sinkEnabled,
|
|
196
|
+
transports: syslog.listTransports(),
|
|
197
|
+
ringEnabled: syslog.ringEnabled,
|
|
198
|
+
streamEnabled: syslog.streamEnabled,
|
|
199
|
+
logDir: logDirPublic
|
|
200
|
+
},
|
|
201
|
+
cluster: {
|
|
202
|
+
isCluster: process.env.NF_CLUSTER === "1",
|
|
203
|
+
pid: process.pid
|
|
204
|
+
},
|
|
205
|
+
counters: {
|
|
206
|
+
valid: syslog.valid,
|
|
207
|
+
invalid: syslog.invalid,
|
|
208
|
+
missed: syslog.missed,
|
|
209
|
+
errorTotal: syslog.errorTotal,
|
|
210
|
+
criticTotal: syslog.criticTotal,
|
|
211
|
+
buffered: syslog.ringStack.length,
|
|
212
|
+
bufferCapacity: syslog.bufferCapacity
|
|
213
|
+
},
|
|
214
|
+
environment: options.environment ?? null
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
path: "backplane/ping",
|
|
220
|
+
summary: "Sonde de santé de la destination — ?driver=<name> (défaut = actif). Renvoie { ok, latencyMs, detail?, info? }. Chemin FROID (réseau pour loki/opensearch).",
|
|
221
|
+
handler: async (request) => {
|
|
222
|
+
const name = oneParam(request, "driver");
|
|
223
|
+
const driver = name ? getLogDriver(name) : getActiveLogDriver();
|
|
224
|
+
if (!driver) return {
|
|
225
|
+
status: 404,
|
|
226
|
+
body: { error: name ? `unknown log driver "${name}"` : "no active log driver" }
|
|
227
|
+
};
|
|
228
|
+
if (!driver.probe) return {
|
|
229
|
+
ok: true,
|
|
230
|
+
latencyMs: 0,
|
|
231
|
+
info: {
|
|
232
|
+
driver: driver.name,
|
|
233
|
+
local: "destination locale (pas de réseau)"
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
return await driver.probe();
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
path: "backplane/driver",
|
|
241
|
+
method: "POST",
|
|
242
|
+
summary: "Switch du driver de relecture (DEV-only) — body { name }. Action de contrôle runtime.",
|
|
243
|
+
handler: (request) => {
|
|
244
|
+
if (options.environment !== "development") return {
|
|
245
|
+
status: 403,
|
|
246
|
+
body: { error: "log driver switch is development-only" }
|
|
247
|
+
};
|
|
248
|
+
const body = request.body ?? {};
|
|
249
|
+
const name = typeof body.name === "string" ? body.name : "";
|
|
250
|
+
if (!name) return {
|
|
251
|
+
status: 400,
|
|
252
|
+
body: { error: "missing driver name" }
|
|
253
|
+
};
|
|
254
|
+
try {
|
|
255
|
+
const d = setActiveLogDriver(name);
|
|
256
|
+
return {
|
|
257
|
+
active: d.name,
|
|
258
|
+
capabilities: d.capabilities
|
|
259
|
+
};
|
|
260
|
+
} catch {
|
|
261
|
+
return {
|
|
262
|
+
status: 404,
|
|
263
|
+
body: { error: `unknown log driver "${name}"` }
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
path: "backplane/transport",
|
|
270
|
+
method: "POST",
|
|
271
|
+
summary: "Active/désactive un transport d'écriture à chaud (DEV-only) — body { name, enabled }. Axe WRITE (fan-out).",
|
|
272
|
+
handler: (request) => {
|
|
273
|
+
if (options.environment !== "development") return {
|
|
274
|
+
status: 403,
|
|
275
|
+
body: { error: "transport toggle is development-only" }
|
|
276
|
+
};
|
|
277
|
+
const body = request.body ?? {};
|
|
278
|
+
const name = typeof body.name === "string" ? body.name : "";
|
|
279
|
+
if (!name) return {
|
|
280
|
+
status: 400,
|
|
281
|
+
body: { error: "missing transport name" }
|
|
282
|
+
};
|
|
283
|
+
if (typeof body.enabled !== "boolean") return {
|
|
284
|
+
status: 400,
|
|
285
|
+
body: { error: "missing enabled (boolean)" }
|
|
286
|
+
};
|
|
287
|
+
const changed = syslog.setTransportEnabled(name, body.enabled);
|
|
288
|
+
if (changed) syslog.log(`transport d'écriture « ${name} » ${body.enabled ? "activé" : "désactivé"}`, "NOTICE", "LOG-BACKPLANE");
|
|
289
|
+
return {
|
|
290
|
+
name,
|
|
291
|
+
enabled: body.enabled,
|
|
292
|
+
changed
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
path: "backplane/sink",
|
|
298
|
+
method: "POST",
|
|
299
|
+
summary: "Mute/démute le sink texte (console/fichier) à chaud (DEV-only) — body { enabled }.",
|
|
300
|
+
handler: (request) => {
|
|
301
|
+
if (options.environment !== "development") return {
|
|
302
|
+
status: 403,
|
|
303
|
+
body: { error: "sink toggle is development-only" }
|
|
304
|
+
};
|
|
305
|
+
const body = request.body ?? {};
|
|
306
|
+
if (typeof body.enabled !== "boolean") return {
|
|
307
|
+
status: 400,
|
|
308
|
+
body: { error: "missing enabled (boolean)" }
|
|
309
|
+
};
|
|
310
|
+
if (body.enabled === false) syslog.log(`sink texte « ${Syslog.logSinkName} » coupé (console/fichier)`, "NOTICE", "LOG-BACKPLANE");
|
|
311
|
+
const changed = Syslog.setSinkEnabled(body.enabled);
|
|
312
|
+
if (changed && body.enabled) syslog.log("sink texte rétabli", "NOTICE", "LOG-BACKPLANE");
|
|
313
|
+
return {
|
|
314
|
+
enabled: body.enabled,
|
|
315
|
+
sink: Syslog.logSinkName,
|
|
316
|
+
changed
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
path: "backplane/ring",
|
|
322
|
+
method: "POST",
|
|
323
|
+
summary: "Active/désactive le stockage mémoire (ring) à chaud (DEV-only) — body { enabled }.",
|
|
324
|
+
handler: (request) => {
|
|
325
|
+
if (options.environment !== "development") return {
|
|
326
|
+
status: 403,
|
|
327
|
+
body: { error: "ring toggle is development-only" }
|
|
328
|
+
};
|
|
329
|
+
const body = request.body ?? {};
|
|
330
|
+
if (typeof body.enabled !== "boolean") return {
|
|
331
|
+
status: 400,
|
|
332
|
+
body: { error: "missing enabled (boolean)" }
|
|
333
|
+
};
|
|
334
|
+
const changed = syslog.setRingEnabled(body.enabled);
|
|
335
|
+
if (changed) syslog.log(`stockage mémoire (ring) ${body.enabled ? "activé" : "désactivé"}`, "NOTICE", "LOG-BACKPLANE");
|
|
336
|
+
return {
|
|
337
|
+
enabled: body.enabled,
|
|
338
|
+
changed
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
path: "backplane/stream",
|
|
344
|
+
method: "POST",
|
|
345
|
+
summary: "Active/désactive la diffusion temps réel (nodefony:syslog / Live) à chaud (DEV-only) — body { enabled }.",
|
|
346
|
+
handler: (request) => {
|
|
347
|
+
if (options.environment !== "development") return {
|
|
348
|
+
status: 403,
|
|
349
|
+
body: { error: "stream toggle is development-only" }
|
|
350
|
+
};
|
|
351
|
+
const body = request.body ?? {};
|
|
352
|
+
if (typeof body.enabled !== "boolean") return {
|
|
353
|
+
status: 400,
|
|
354
|
+
body: { error: "missing enabled (boolean)" }
|
|
355
|
+
};
|
|
356
|
+
const changed = syslog.setStreamEnabled(body.enabled);
|
|
357
|
+
if (changed) syslog.log(`diffusion temps réel ${body.enabled ? "activée" : "coupée"}`, "NOTICE", "LOG-BACKPLANE");
|
|
358
|
+
return {
|
|
359
|
+
enabled: body.enabled,
|
|
360
|
+
changed
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
path: "info",
|
|
366
|
+
summary: "Syslog counters — valid, invalid, missed, buffered",
|
|
367
|
+
handler: () => ({
|
|
368
|
+
valid: syslog.valid,
|
|
369
|
+
invalid: syslog.invalid,
|
|
370
|
+
missed: syslog.missed,
|
|
371
|
+
buffered: syslog.ringStack.length
|
|
372
|
+
})
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
path: "files",
|
|
376
|
+
summary: "Fichiers de log du tmpDir (DEV) — name, size, mtime. Désactivé en prod.",
|
|
377
|
+
handler: async () => {
|
|
378
|
+
if (!logDir) return {
|
|
379
|
+
enabled: false,
|
|
380
|
+
reason: "Production : logs → stdout/stderr → collecteur (pas de fichiers).",
|
|
381
|
+
files: []
|
|
382
|
+
};
|
|
383
|
+
let names;
|
|
384
|
+
try {
|
|
385
|
+
names = await fsp.readdir(logDir);
|
|
386
|
+
} catch {
|
|
387
|
+
return {
|
|
388
|
+
enabled: true,
|
|
389
|
+
files: []
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
const files = [];
|
|
393
|
+
for (const name of names) {
|
|
394
|
+
if (!LOG_NAME.test(name)) continue;
|
|
395
|
+
try {
|
|
396
|
+
const st = await fsp.stat(path.join(logDir, name));
|
|
397
|
+
if (st.isFile()) files.push({
|
|
398
|
+
name,
|
|
399
|
+
size: st.size,
|
|
400
|
+
mtime: st.mtimeMs
|
|
401
|
+
});
|
|
402
|
+
} catch {}
|
|
403
|
+
}
|
|
404
|
+
files.sort((a, b) => b.mtime - a.mtime);
|
|
405
|
+
return {
|
|
406
|
+
enabled: true,
|
|
407
|
+
files
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
path: "files/{name}",
|
|
413
|
+
summary: "Tail d'un fichier de log (DEV) — ?from=<offset>&lines=N&raw=1. Sans from = N dernières lignes ; avec from = octets ajoutés (follow).",
|
|
414
|
+
handler: async (request) => {
|
|
415
|
+
const name = request.params.name ?? "";
|
|
416
|
+
const file = resolveLogFile(name);
|
|
417
|
+
if (!file) return {
|
|
418
|
+
status: 400,
|
|
419
|
+
body: { error: "invalid log file name" }
|
|
420
|
+
};
|
|
421
|
+
let size;
|
|
422
|
+
try {
|
|
423
|
+
const st = await fsp.stat(file);
|
|
424
|
+
if (!st.isFile()) return {
|
|
425
|
+
status: 400,
|
|
426
|
+
body: { error: "not a file" }
|
|
427
|
+
};
|
|
428
|
+
size = st.size;
|
|
429
|
+
} catch {
|
|
430
|
+
return {
|
|
431
|
+
status: 404,
|
|
432
|
+
body: { error: "log file not found" }
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
const fromStr = oneParam(request, "from");
|
|
436
|
+
const fromNum = fromStr !== void 0 ? Number.parseInt(fromStr, 10) : NaN;
|
|
437
|
+
const incremental = !Number.isNaN(fromNum) && fromNum >= 0 && fromNum <= size;
|
|
438
|
+
const reset = !Number.isNaN(fromNum) && fromNum > size;
|
|
439
|
+
const lines = intParam(request, "lines", 500, 5e3);
|
|
440
|
+
const start = incremental ? fromNum : Math.max(0, size - MAX_TAIL_BYTES);
|
|
441
|
+
const { text, to } = await readTail(file, start, size);
|
|
442
|
+
let out = text === "" ? [] : text.split("\n");
|
|
443
|
+
if (!incremental && start > 0 && out.length > 0) out.shift();
|
|
444
|
+
if (!incremental) out = out.slice(-lines);
|
|
445
|
+
const raw = oneParam(request, "raw") === "1";
|
|
446
|
+
if (!raw) out = out.map(redactSecrets);
|
|
447
|
+
return {
|
|
448
|
+
name,
|
|
449
|
+
size,
|
|
450
|
+
from: start,
|
|
451
|
+
to,
|
|
452
|
+
reset,
|
|
453
|
+
redacted: !raw,
|
|
454
|
+
lines: out
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
];
|
|
459
|
+
return {
|
|
460
|
+
adminNamespace: "syslog",
|
|
461
|
+
adminDescriptor: () => descriptor,
|
|
462
|
+
adminEndpoints: () => endpoints
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
//#endregion
|
|
466
|
+
export { createSyslogAdminApi };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Service } from "nodefony";
|
|
2
|
+
//#region nodefony/src/Template.ts
|
|
3
|
+
var Template = class extends Service {
|
|
4
|
+
engine;
|
|
5
|
+
module;
|
|
6
|
+
cache = true;
|
|
7
|
+
constructor(name, engine, module, options = {}) {
|
|
8
|
+
super(name, module.container, module.notificationsCenter, options);
|
|
9
|
+
this.engine = engine;
|
|
10
|
+
this.module = module;
|
|
11
|
+
this.cache = module.kernel?.environment === "prod" || module.kernel?.environment === "production";
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { Template as default };
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
//#region nodefony/src/configMutation.ts
|
|
2
|
+
/** Narrowing défensif : `unknown` → nœud JSON Schema (ou `null` si non-objet). */
|
|
3
|
+
function asNode(value) {
|
|
4
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) return value;
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Récupère la map `properties` d'un nœud — directement, ou dans la 1ʳᵉ branche
|
|
9
|
+
* objet d'un `anyOf`/`oneOf` (cas d'un champ optionnel/nullable typé objet).
|
|
10
|
+
*/
|
|
11
|
+
function getProperties(node) {
|
|
12
|
+
if (node.properties && typeof node.properties === "object") return node.properties;
|
|
13
|
+
const variants = node.anyOf ?? node.oneOf;
|
|
14
|
+
if (Array.isArray(variants)) for (const v of variants) {
|
|
15
|
+
const vn = asNode(v);
|
|
16
|
+
if (vn?.properties) return vn.properties;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
/** Résout un segment vers la clé réelle d'une map : exact d'abord, sinon insensible casse. */
|
|
21
|
+
function resolveKeyCI(obj, seg) {
|
|
22
|
+
if (Object.prototype.hasOwnProperty.call(obj, seg)) return seg;
|
|
23
|
+
const lower = seg.toLowerCase();
|
|
24
|
+
for (const k of Object.keys(obj)) if (k.toLowerCase() === lower) return k;
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Descend dans un JSON Schema le long d'un chemin pointé (`upload.maxFileSize`)
|
|
29
|
+
* et renvoie le nœud feuille, ou `null` si un segment ne résout pas.
|
|
30
|
+
*
|
|
31
|
+
* @param schema - JSON Schema racine du module (`mod.configSchema()`).
|
|
32
|
+
* @param segments - segments du chemin (casse réelle ou insensible).
|
|
33
|
+
* @returns le nœud feuille, ou `null` si le chemin est inconnu.
|
|
34
|
+
*/
|
|
35
|
+
function navigateSchemaNode(schema, segments) {
|
|
36
|
+
let node = asNode(schema);
|
|
37
|
+
for (const seg of segments) {
|
|
38
|
+
if (!node) return null;
|
|
39
|
+
const props = getProperties(node);
|
|
40
|
+
if (!props) return null;
|
|
41
|
+
const key = resolveKeyCI(props, seg);
|
|
42
|
+
if (key === null) return null;
|
|
43
|
+
node = asNode(props[key]);
|
|
44
|
+
}
|
|
45
|
+
return node;
|
|
46
|
+
}
|
|
47
|
+
/** Extrait les flags Nodefony d'un nœud (défaut `false` partout). */
|
|
48
|
+
function nodeFlags(node) {
|
|
49
|
+
return {
|
|
50
|
+
runtimeMutable: node.runtimeMutable === true,
|
|
51
|
+
reserved: node.reserved === true,
|
|
52
|
+
kernelDerived: node.kernelDerived === true,
|
|
53
|
+
secret: node.secret === true
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Type JS effectif d'une valeur, dans le vocabulaire JSON Schema. */
|
|
57
|
+
function jsonTypeOf(value) {
|
|
58
|
+
if (value === null) return "null";
|
|
59
|
+
if (Array.isArray(value)) return "array";
|
|
60
|
+
return typeof value;
|
|
61
|
+
}
|
|
62
|
+
/** Une valeur satisfait-elle UN type JSON Schema (`integer` = number entier) ? */
|
|
63
|
+
function matchesType(t, value) {
|
|
64
|
+
if (t === "integer") return typeof value === "number" && Number.isInteger(value);
|
|
65
|
+
return t === jsonTypeOf(value);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Valide une valeur scalaire contre un nœud JSON Schema feuille (type(s), `enum`,
|
|
69
|
+
* `anyOf`/`oneOf`, bornes numériques, longueur/pattern de chaîne). Fail-closed :
|
|
70
|
+
* un nœud objet/array non scalaire est refusé (édition feuille uniquement).
|
|
71
|
+
*
|
|
72
|
+
* @param node - nœud JSON Schema cible.
|
|
73
|
+
* @param value - valeur candidate (déjà typée — provient d'un body JSON).
|
|
74
|
+
* @returns succès, ou échec avec message explicatif.
|
|
75
|
+
*/
|
|
76
|
+
function validateLeafValue(node, value) {
|
|
77
|
+
if (Array.isArray(node.enum)) return node.enum.includes(value) ? { ok: true } : {
|
|
78
|
+
ok: false,
|
|
79
|
+
message: `valeur attendue parmi : ${node.enum.map((e) => JSON.stringify(e)).join(", ")}`
|
|
80
|
+
};
|
|
81
|
+
const variants = node.anyOf ?? node.oneOf;
|
|
82
|
+
if (Array.isArray(variants) && variants.length > 0) {
|
|
83
|
+
for (const v of variants) {
|
|
84
|
+
const vn = asNode(v);
|
|
85
|
+
if (vn && validateLeafValue(vn, value).ok) return { ok: true };
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
message: "ne correspond à aucune variante autorisée"
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const types = Array.isArray(node.type) ? node.type : node.type ? [node.type] : [];
|
|
93
|
+
if (types.length > 0 && !types.some((t) => matchesType(t, value))) return {
|
|
94
|
+
ok: false,
|
|
95
|
+
message: `type attendu : ${types.join(" | ")}`
|
|
96
|
+
};
|
|
97
|
+
if (types.length === 0 && (value === null || typeof value !== "object")) {} else if (types.length === 0) return {
|
|
98
|
+
ok: false,
|
|
99
|
+
message: "édition d'objet/tableau non supportée"
|
|
100
|
+
};
|
|
101
|
+
if (typeof value === "number") {
|
|
102
|
+
if (node.minimum !== void 0 && value < node.minimum) return {
|
|
103
|
+
ok: false,
|
|
104
|
+
message: `doit être ≥ ${node.minimum}`
|
|
105
|
+
};
|
|
106
|
+
if (node.maximum !== void 0 && value > node.maximum) return {
|
|
107
|
+
ok: false,
|
|
108
|
+
message: `doit être ≤ ${node.maximum}`
|
|
109
|
+
};
|
|
110
|
+
if (node.exclusiveMinimum !== void 0 && value <= node.exclusiveMinimum) return {
|
|
111
|
+
ok: false,
|
|
112
|
+
message: `doit être > ${node.exclusiveMinimum}`
|
|
113
|
+
};
|
|
114
|
+
if (node.exclusiveMaximum !== void 0 && value >= node.exclusiveMaximum) return {
|
|
115
|
+
ok: false,
|
|
116
|
+
message: `doit être < ${node.exclusiveMaximum}`
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (typeof value === "string") {
|
|
120
|
+
if (node.minLength !== void 0 && value.length < node.minLength) return {
|
|
121
|
+
ok: false,
|
|
122
|
+
message: `longueur minimale ${node.minLength}`
|
|
123
|
+
};
|
|
124
|
+
if (node.maxLength !== void 0 && value.length > node.maxLength) return {
|
|
125
|
+
ok: false,
|
|
126
|
+
message: `longueur maximale ${node.maxLength}`
|
|
127
|
+
};
|
|
128
|
+
if (node.pattern) try {
|
|
129
|
+
if (!new RegExp(node.pattern).test(value)) return {
|
|
130
|
+
ok: false,
|
|
131
|
+
message: "format invalide"
|
|
132
|
+
};
|
|
133
|
+
} catch {}
|
|
134
|
+
}
|
|
135
|
+
return { ok: true };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Décide si un champ est éditable à chaud. Ordre de refus : secret > réservé >
|
|
139
|
+
* dérivé kernel > non-`runtimeMutable` (boot). `null` = éditable.
|
|
140
|
+
*
|
|
141
|
+
* @param flags - flags du nœud ({@link nodeFlags}).
|
|
142
|
+
* @returns la raison du refus, ou `null` si éditable live.
|
|
143
|
+
*/
|
|
144
|
+
function notEditableReason(flags) {
|
|
145
|
+
if (flags.secret) return "secret";
|
|
146
|
+
if (flags.reserved) return "reserved";
|
|
147
|
+
if (flags.kernelDerived) return "kernel_derived";
|
|
148
|
+
if (!flags.runtimeMutable) return "boot_only";
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Construit la **recette** d'override à appliquer dans le déploiement pour un champ
|
|
153
|
+
* non mutable à chaud (12-factor). Un secret passe par la variante `*_FILE` (jamais
|
|
154
|
+
* la valeur en clair dans l'environnement).
|
|
155
|
+
*
|
|
156
|
+
* @param seg - segment d'adressage du module (`http`, `security`, `app`…).
|
|
157
|
+
* @param segments - chemin pointé du champ.
|
|
158
|
+
* @param isSecret - le champ porte-t-il le flag secret ?
|
|
159
|
+
* @returns la ligne d'override prête à copier.
|
|
160
|
+
*/
|
|
161
|
+
function recipeFor(seg, segments, isSecret) {
|
|
162
|
+
const envKey = `NF__${seg.toUpperCase()}__${segments.map((s) => s.toUpperCase()).join("__")}`;
|
|
163
|
+
if (isSecret) return `${envKey}__FILE=/run/secrets/${segments.join("_").toLowerCase()}`;
|
|
164
|
+
return `${envKey}=<valeur>`;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Lit la valeur courante d'une config à un chemin pointé (insensible à la casse) —
|
|
168
|
+
* sert à journaliser l'ancienne valeur (`before`) avant mutation.
|
|
169
|
+
*
|
|
170
|
+
* @param obj - objet de config (`mod.options`).
|
|
171
|
+
* @param segments - chemin pointé.
|
|
172
|
+
* @returns la valeur, ou `undefined` si le chemin ne résout pas.
|
|
173
|
+
*/
|
|
174
|
+
function getResolvedPath(obj, segments) {
|
|
175
|
+
let cur = obj;
|
|
176
|
+
for (const seg of segments) {
|
|
177
|
+
if (cur === null || typeof cur !== "object" || Array.isArray(cur)) return;
|
|
178
|
+
const rec = cur;
|
|
179
|
+
const key = resolveKeyCI(rec, seg);
|
|
180
|
+
if (key === null) return void 0;
|
|
181
|
+
cur = rec[key];
|
|
182
|
+
}
|
|
183
|
+
return cur;
|
|
184
|
+
}
|
|
185
|
+
//#endregion
|
|
186
|
+
export { getResolvedPath, navigateSchemaNode, nodeFlags, notEditableReason, recipeFor, validateLeafValue };
|