@nodefony/framework 10.0.0-alpha.1

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