@mmmbuto/nexuscrew 0.8.26 → 0.8.28

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.
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-DkIPrmX6.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-DqG-mDnV.css">
14
+ <script type="module" crossorigin src="/assets/index-DiKMLCvW.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-BAzlX2nP.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.8.26"}
1
+ {"version":"0.8.28"}
@@ -14,7 +14,7 @@ const pidf = require('./pidfile.js');
14
14
  const { runInit, ensureFleetDefaults } = require('./init.js');
15
15
  const { rotateToken } = require('../auth/token.js');
16
16
  const urlmod = require('./url.js');
17
- const { doctor } = require('./doctor.js');
17
+ const { doctor, checkServiceWorkingDirectory } = require('./doctor.js');
18
18
  const nodesStore = require('../nodes/store.js');
19
19
  const nodesTunnel = require('../nodes/tunnel.js');
20
20
  const nodesCmds = require('../nodes/commands.js');
@@ -346,6 +346,11 @@ function servicePinsLegacyPort(platform, home, installPath) {
346
346
  try { return /NEXUSCREW_PORT/.test(fs.readFileSync(target, 'utf8')); } catch (_) { return false; }
347
347
  }
348
348
 
349
+ function serviceDefinitionNeedsRefresh(platform, home, installPath) {
350
+ if (servicePinsLegacyPort(platform, home, installPath)) return true;
351
+ return !checkServiceWorkingDirectory(platform, home, installPath).ok;
352
+ }
353
+
349
354
  function portAvailable(port, host = '127.0.0.1') {
350
355
  return new Promise((resolve) => {
351
356
  const s = net.createServer();
@@ -465,8 +470,19 @@ async function smartUp(opts = {}) {
465
470
  // config.json forever. Regenerate once so config.json becomes authoritative.
466
471
  const home = opts.home || require('node:os').homedir();
467
472
  const persistent = bootState({ ...opts, platform }).enabled;
468
- if (persistent && servicePinsLegacyPort(platform, home, opts.installPath)) {
473
+ let serviceRefreshed = false;
474
+ if (persistent && serviceDefinitionNeedsRefresh(platform, home, opts.installPath)) {
469
475
  runInitImpl({ ...opts, log: quiet, platform, installBoot: true, printUrl: false });
476
+ serviceRefreshed = checkServiceWorkingDirectory(platform, home, opts.installPath).ok;
477
+ // Termux:Boot has no service manager, so replacing the script alone cannot
478
+ // change the cwd of the already-running pidfile owner. Restart exactly once
479
+ // after a verified migration; Linux/macOS are restarted by installService.
480
+ if (serviceRefreshed && platform === 'termux') {
481
+ const migrated = restartImpl({ ...opts, platform, log: quiet });
482
+ if (!migrated || migrated.restarted !== true) {
483
+ throw new Error(`service cwd migration restart failed: ${(migrated && migrated.reason) || 'unknown'}`);
484
+ }
485
+ }
470
486
  }
471
487
 
472
488
  if (!port) port = urlmod.loadPort(opts);
@@ -972,6 +988,7 @@ function dispatch(argv, opts = {}) {
972
988
  module.exports = {
973
989
  dispatch, dispatchNodes, serve, start, stop, status, parseFlags, HELP, NODES_HELP,
974
990
  smartUp, url, tokenRotate, logs, doctor, update, restart,
991
+ serviceDefinitionNeedsRefresh,
975
992
  portAvailable, findAvailablePort, probeNexusCrew, waitForNexusCrew, openPwa, startPortable, wizardComplete,
976
993
  servicePinsLegacyPort,
977
994
  isServiceRunning, readRoles,
package/lib/cli/doctor.js CHANGED
@@ -14,6 +14,9 @@ const { installPath } = require('./service.js');
14
14
  const { resolvePaths } = require('./url.js');
15
15
  const { commandExists } = require('./path.js');
16
16
  const { loadDefinitions } = require('../fleet/definitions.js');
17
+ const {
18
+ termuxRuntimePaths, trustedTermuxPreload, TERMUX_EXEC_BASENAME_RE,
19
+ } = require('../runtime/env.js');
17
20
 
18
21
  function nodeMajor() {
19
22
  return parseInt(String(process.versions.node).split('.')[0], 10);
@@ -64,6 +67,65 @@ function checkService(platform, home, execImpl, uidVal, installPathOverride) {
64
67
  };
65
68
  }
66
69
 
70
+ function decodeXmlText(value) {
71
+ return String(value)
72
+ .replace(/&quot;/g, '"')
73
+ .replace(/&gt;/g, '>')
74
+ .replace(/&lt;/g, '<')
75
+ .replace(/&amp;/g, '&');
76
+ }
77
+
78
+ function checkMacServiceWorkingDirectory(platform, home, installPathOverride) {
79
+ if (platform !== 'mac') {
80
+ return { name: 'launchd cwd stabile', ok: true, detail: `${platform}: non applicabile` };
81
+ }
82
+ return checkServiceWorkingDirectory(platform, home, installPathOverride);
83
+ }
84
+
85
+ // The service cwd is inherited by a shared tmux server and every future pane.
86
+ // It must therefore be HOME, never the replaceable runtime directory. This
87
+ // check reads only the installed definition and fails closed on missing,
88
+ // symlinked, malformed, or legacy service files.
89
+ function checkServiceWorkingDirectory(platform, home, installPathOverride) {
90
+ if (!['linux', 'mac', 'termux'].includes(platform)) {
91
+ return { name: 'service cwd stabile', ok: true, detail: `${platform}: non applicabile` };
92
+ }
93
+ const target = installPathOverride || installPath(platform, home);
94
+ let raw;
95
+ try {
96
+ const st = fs.lstatSync(target);
97
+ if (st.isSymbolicLink() || !st.isFile()) throw Object.assign(new Error('definizione service non regolare'), { code: 'UNSAFE' });
98
+ raw = fs.readFileSync(target, 'utf8');
99
+ }
100
+ catch (error) {
101
+ return {
102
+ name: 'service cwd stabile', ok: false,
103
+ detail: error.code === 'ENOENT' ? `service non installato (${target})` : error.message,
104
+ };
105
+ }
106
+ let actual = '';
107
+ let ok = false;
108
+ if (platform === 'mac') {
109
+ const match = raw.match(/<key>WorkingDirectory<\/key>\s*<string>([\s\S]*?)<\/string>/);
110
+ if (!match) return { name: 'service cwd stabile', ok: false, detail: 'WorkingDirectory assente dal plist' };
111
+ actual = decodeXmlText(match[1]);
112
+ ok = actual === String(home);
113
+ } else if (platform === 'linux') {
114
+ const match = raw.match(/^WorkingDirectory=(.+)$/m);
115
+ if (!match) return { name: 'service cwd stabile', ok: false, detail: 'WorkingDirectory assente dalla unit' };
116
+ actual = match[1].replace(/%%/g, '%');
117
+ ok = actual === String(home);
118
+ } else {
119
+ const stable = /^\s*cd -- "\$HOME"\s*$/m.test(raw);
120
+ actual = stable ? '$HOME' : (/^\s*cd\s+--\s+(.+)$/m.exec(raw)?.[1] || 'cd assente');
121
+ ok = stable;
122
+ }
123
+ return {
124
+ name: 'service cwd stabile', ok,
125
+ detail: ok ? (platform === 'termux' ? '$HOME' : String(home)) : `${actual} (atteso HOME stabile)`,
126
+ };
127
+ }
128
+
67
129
  function checkBoot(platform, home, execImpl) {
68
130
  if (platform === 'termux') {
69
131
  const p = path.join(home, '.termux', 'boot', 'nexuscrew.sh');
@@ -186,6 +248,102 @@ function checkTokenPerms(tokenPath) {
186
248
  }
187
249
  }
188
250
 
251
+ // Presence-only Termux preload check. On the Google Play build of Termux
252
+ // (targetSdk >= 29, SELinux `untrusted_app` domain) every command pane spawned
253
+ // by the shared tmux server dies at execve() unless libtermux-exec is
254
+ // preloaded. The validated preload is preserved by minimalRuntimeEnv; this
255
+ // check tells the user, BEFORE launching a cell, whether the trusted library
256
+ // exists under PREFIX/lib and whether the current process carries a trusted
257
+ // LD_PRELOAD. It is strictly read-only: no tmux socket is touched, no service
258
+ // or device state is mutated, no command is spawned.
259
+ function checkTermuxExec(runtimeEnv, opts = {}) {
260
+ const env = runtimeEnv || process.env;
261
+ const platform = opts.platform || detectPlatform();
262
+ const termux = termuxRuntimePaths(env, { platform, home: opts.home });
263
+ if (!termux) {
264
+ return { name: 'termux-exec preload', ok: true, detail: `${platform}: non applicabile` };
265
+ }
266
+ const trusted = trustedTermuxPreload(env, { platform, home: opts.home });
267
+ const libDir = path.join(termux.prefix, 'lib');
268
+ let present = '';
269
+ let candidates = [];
270
+ try {
271
+ candidates = fs.readdirSync(libDir).filter((name) => TERMUX_EXEC_BASENAME_RE.test(name)).sort();
272
+ } catch (_) { /* absent/unreadable */ }
273
+ for (const name of candidates) {
274
+ const candidate = path.join(libDir, name);
275
+ try { if (fs.statSync(candidate).isFile()) { present = candidate; break; } } catch (_) { /* next */ }
276
+ }
277
+ if (trusted) {
278
+ return { name: 'termux-exec preload', ok: true, detail: `preload trusted: ${path.basename(trusted)}` };
279
+ }
280
+ if (present) {
281
+ return {
282
+ name: 'termux-exec preload', ok: true, warn: true,
283
+ detail: `libreria presente (${path.basename(present)}) ma LD_PRELOAD non valido nell'env del doctor: avvia il servizio da una shell Termux di login o via termux-exec preload`,
284
+ };
285
+ }
286
+ return {
287
+ name: 'termux-exec preload', ok: false,
288
+ detail: 'libtermux-exec non trovata sotto PREFIX/lib: sulla build Google Play celle e shell non possono eseguire comandi',
289
+ };
290
+ }
291
+
292
+ // Read-only probe of the long-lived tmux server cwd. A server that retained an
293
+ // unlinked runtime directory keeps accepting clients but makes later children
294
+ // fail getcwd(3). Never reports the path itself; only stable state.
295
+ function checkTmuxServerCwd(platform, execImpl, opts = {}) {
296
+ if (!['linux', 'termux'].includes(platform)) {
297
+ return { name: 'tmux server cwd', ok: true, detail: `${platform}: non applicabile` };
298
+ }
299
+ let rawPid = '';
300
+ try {
301
+ rawPid = String(execImpl(opts.tmuxBin || 'tmux', ['display-message', '-p', '#{pid}'], { encoding: 'utf8' }) || '').trim();
302
+ } catch (_) {
303
+ return { name: 'tmux server cwd', ok: true, warn: true, detail: 'server tmux non attivo; probe rinviato al primo avvio' };
304
+ }
305
+ if (!/^\d+$/.test(rawPid)) {
306
+ return { name: 'tmux server cwd', ok: true, warn: true, detail: 'server tmux non rilevato' };
307
+ }
308
+ try {
309
+ const resolveImpl = opts.procCwdImpl || ((pid) => fs.realpathSync(`/proc/${pid}/cwd`));
310
+ const cwd = resolveImpl(Number(rawPid));
311
+ if (typeof cwd !== 'string' || !cwd) throw new Error('cwd vuota');
312
+ return { name: 'tmux server cwd', ok: true, detail: 'cwd risolvibile' };
313
+ } catch (_) {
314
+ return {
315
+ name: 'tmux server cwd', ok: false,
316
+ detail: 'cwd del server tmux non risolvibile (directory sostituita); termina le sessioni in modo esplicito e riavvia tmux',
317
+ };
318
+ }
319
+ }
320
+
321
+ // Inspect only the single tmux global environment key required by
322
+ // termux-exec. The value is validated and then discarded; it is never logged.
323
+ // This distinguishes a healthy server from RC-2 (stale/no preload) and RC-7
324
+ // (present but rejected by the trust boundary) without killing any session.
325
+ function checkTmuxServerTermuxPreload(runtimeEnv, execImpl, opts = {}) {
326
+ const env = runtimeEnv || process.env;
327
+ const platform = opts.platform || detectPlatform();
328
+ if (!termuxRuntimePaths(env, { platform, home: opts.home })) {
329
+ return { name: 'tmux server termux-exec', ok: true, detail: `${platform}: non applicabile` };
330
+ }
331
+ let raw = '';
332
+ try {
333
+ raw = String(execImpl(opts.tmuxBin || 'tmux', ['show-environment', '-g', 'LD_PRELOAD'], { encoding: 'utf8' }) || '').trim();
334
+ } catch (_) {
335
+ return { name: 'tmux server termux-exec', ok: true, warn: true, detail: 'server tmux non attivo; probe rinviato al primo avvio' };
336
+ }
337
+ const match = raw.match(/^LD_PRELOAD=(.+)$/);
338
+ const trusted = match && trustedTermuxPreload({ ...env, LD_PRELOAD: match[1] }, { platform, home: opts.home });
339
+ return trusted
340
+ ? { name: 'tmux server termux-exec', ok: true, detail: 'preload trusted presente nel server tmux' }
341
+ : {
342
+ name: 'tmux server termux-exec', ok: false,
343
+ detail: 'LD_PRELOAD assente o non trusted nel server tmux; server stale o formato non compatibile (nessun kill automatico)',
344
+ };
345
+ }
346
+
189
347
  // Check MCP identity (P0). NON-FAILING per costrutto: `ok` è sempre true, così
190
348
  // chi usa NexusCrew solo come PWA (nessuna integrazione MCP) non vede mai il
191
349
  // doctor andare in FAIL solo per env MCP assente. Il check osserva SOLO la
@@ -266,15 +424,19 @@ function doctor(opts = {}) {
266
424
  checkTmux(existsImpl, opts.tmuxBin),
267
425
  checkPty(ptyLoad),
268
426
  checkService(platform, home, execImpl, uidVal, opts.installPath),
427
+ checkServiceWorkingDirectory(platform, home, opts.installPath),
269
428
  checkBoot(platform, home, execImpl),
270
429
  checkUserLinger(platform, execImpl, uidVal),
271
430
  checkTmuxSurvival(platform, execImpl),
272
431
  checkTokenPerms(tokenPath),
273
432
  checkFleetDefinitions(home, opts.fleetDefsPath, fleetEnabled),
433
+ checkTermuxExec(opts.env, { platform, home }),
274
434
  checkSshClient(existsImpl),
275
435
  checkAutossh(existsImpl),
276
436
  checkSshPermitlisten(opts.sshVersion),
277
437
  checkMcpIdentity(opts.env),
438
+ checkTmuxServerCwd(platform, execImpl, { tmuxBin: opts.tmuxBin, procCwdImpl: opts.procCwdImpl }),
439
+ checkTmuxServerTermuxPreload(opts.env, execImpl, { platform, home, tmuxBin: opts.tmuxBin }),
278
440
  ];
279
441
 
280
442
  for (const c of checks) {
@@ -289,8 +451,11 @@ function doctor(opts = {}) {
289
451
  module.exports = {
290
452
  doctor, nodeMajor,
291
453
  checkNode, checkTmux, checkPty, checkService, checkBoot, checkTokenPerms,
454
+ checkMacServiceWorkingDirectory,
292
455
  checkFleetDefinitions,
456
+ checkServiceWorkingDirectory,
293
457
  checkTmuxSurvival, checkUserLinger,
294
458
  checkSshClient, checkAutossh, checkSshPermitlisten,
295
- checkMcpIdentity,
459
+ checkMcpIdentity, checkTermuxExec,
460
+ checkTmuxServerCwd, checkTmuxServerTermuxPreload,
296
461
  };
@@ -59,9 +59,12 @@ function generateService(platform, ctx) {
59
59
  function generateLinux(ctx) {
60
60
  assertSystemdSafe('repoRoot', ctx.repoRoot); // [M3] reject char non gestibili
61
61
  assertSystemdSafe('nodeBin', ctx.nodeBin);
62
- const workDir = ctx.runtimeDir || path.join(ctx.home, '.nexuscrew');
63
- assertSystemdSafe('runtimeDir', workDir);
64
- const runtime = escapeSystemdPath(workDir);
62
+ // HOME is stable across init/repair/update. The runtime directory may be
63
+ // atomically replaced; keeping it as the service cwd leaves the HTTP process
64
+ // and a shared tmux server holding an orphaned inode, so every later pane can
65
+ // fail getcwd(3). Runtime state still lives in runtimeDir; only cwd changes.
66
+ assertSystemdSafe('home', ctx.home);
67
+ const runtime = escapeSystemdPath(ctx.home);
65
68
  const node = escapeSystemdExec(ctx.nodeBin);
66
69
  const repoBin = escapeSystemdExec(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
67
70
  const nodeDir = escapeSystemdPath(path.dirname(ctx.nodeBin));
@@ -90,7 +93,9 @@ WantedBy=default.target
90
93
  function generateMac(ctx) {
91
94
  const nodeXml = escapeXml(ctx.nodeBin);
92
95
  const repoBinXml = escapeXml(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
93
- const runtimeXml = escapeXml(ctx.runtimeDir || path.join(ctx.home, '.nexuscrew'));
96
+ // HOME remains stable when init or repair atomically replaces the runtime
97
+ // directory; launchd must not retain an unlinked runtime-directory cwd.
98
+ const runtimeXml = escapeXml(ctx.home);
94
99
  const homeXml = escapeXml(ctx.home);
95
100
  const launchPath = [...new Set([
96
101
  path.dirname(ctx.nodeBin), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
@@ -149,7 +154,7 @@ export LANG=\${LANG:-en_US.UTF-8}
149
154
  export LC_CTYPE=\${LC_CTYPE:-en_US.UTF-8}
150
155
  termux-wake-lock 2>/dev/null || true
151
156
  mkdir -p "$HOME/.nexuscrew" "$TMPDIR" "$TMUX_TMPDIR"
152
- cd -- "$HOME/.nexuscrew"
157
+ cd -- "$HOME"
153
158
  exec ${nodeQ} ${repoBinQ} serve --pidfile >> "$HOME/.nexuscrew/nexuscrew.log" 2>&1
154
159
  `;
155
160
  }
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+ const express = require('express');
3
+
4
+ function diagnosticsRoutes({ diagnostics, readonly = () => false } = {}) {
5
+ if (!diagnostics) throw new Error('diagnostics store required');
6
+ const router = express.Router();
7
+ router.use(express.json({ limit: '2kb' }));
8
+ const mutGate = (_req, res, next) => {
9
+ if (readonly()) return res.status(403).json({ error: 'READONLY: mutazione diagnostica bloccata' });
10
+ next();
11
+ };
12
+
13
+ router.get('/status', (_req, res) => res.json(diagnostics.status()));
14
+ router.get('/logs', (req, res) => {
15
+ try {
16
+ const keys = Object.keys(req.query || {});
17
+ if (keys.some((key) => !['after', 'limit'].includes(key))) return res.status(400).json({ error: 'query non valida' });
18
+ const after = req.query.after === undefined ? 0 : Number(req.query.after);
19
+ const limit = req.query.limit === undefined ? 200 : Number(req.query.limit);
20
+ res.json(diagnostics.logs({ after, limit }));
21
+ } catch (error) { res.status(400).json({ error: String(error.message || error) }); }
22
+ });
23
+ router.patch('/verbose', mutGate, (req, res) => {
24
+ try {
25
+ const body = req.body;
26
+ if (!body || typeof body !== 'object' || Array.isArray(body)
27
+ || Object.keys(body).some((key) => !['enabled', 'durationSeconds'].includes(key))) {
28
+ return res.status(400).json({ error: 'body non valido' });
29
+ }
30
+ res.json(diagnostics.setVerbose(body.enabled, body.durationSeconds === undefined ? 900 : body.durationSeconds));
31
+ } catch (error) { res.status(400).json({ error: String(error.message || error) }); }
32
+ });
33
+ router.delete('/logs', mutGate, (_req, res) => res.json(diagnostics.clear()));
34
+
35
+ router.use((error, _req, res, _next) => {
36
+ if (error && error.type === 'entity.too.large') return res.status(413).json({ error: 'body troppo grande' });
37
+ if (error instanceof SyntaxError) return res.status(400).json({ error: 'JSON non valido' });
38
+ return res.status(400).json({ error: String(error.message || error) });
39
+ });
40
+ return router;
41
+ }
42
+
43
+ module.exports = { diagnosticsRoutes };
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ const DEFAULT_MAX_RECORDS = 500;
4
+ const DEFAULT_MAX_BYTES = 256 * 1024;
5
+ const DEFAULT_MAX_ENTRY_BYTES = 4096;
6
+ const ALLOWED_DURATIONS = new Set([300, 900, 1800, 3600]);
7
+ const LEVELS = new Set(['debug', 'info', 'warn', 'error']);
8
+ const META_KEYS = new Set([
9
+ 'port', 'platform', 'reason', 'durationSeconds', 'count', 'errno', 'client',
10
+ 'cell', 'engine', 'action', 'state', 'transport', 'phase', 'version', 'status', 'node', 'code',
11
+ ]);
12
+ const DENIED_KEY = /(authorization|cookie|token|secret|credential|password|prompt|terminal|argv|command|env|content|payload|file|path|endpoint|url)/i;
13
+
14
+ function redactText(value, max = 160) {
15
+ let text = String(value == null ? '' : value).normalize('NFC');
16
+ text = text.replace(/[\p{Cc}\p{Cf}]/gu, ' ').replace(/\s+/g, ' ').trim();
17
+ text = text
18
+ .replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]')
19
+ .replace(/\b(?:sk|xox[baprs]|gh[pousr]|npm)_[A-Za-z0-9_-]{8,}\b/g, '[redacted]')
20
+ .replace(/\b(?:token|password|secret|api[_-]?key)\s*[=:]\s*\S+/gi, '[redacted]')
21
+ .replace(/(?:\/home\/[^/\s]+|\/data\/(?:data|user\/\d+)\/[^/\s]+)(?:\/[^\s]*)?/g, '[path]')
22
+ .replace(/\b[A-Z][A-Z0-9_]{2,}\s*=\s*\S+/g, '[env]');
23
+ return text.slice(0, max);
24
+ }
25
+
26
+ function sanitizeMeta(meta) {
27
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return {};
28
+ const out = {};
29
+ for (const [key, value] of Object.entries(meta)) {
30
+ if (!META_KEYS.has(key) || DENIED_KEY.test(key)) continue;
31
+ if (typeof value === 'boolean') out[key] = value;
32
+ else if (typeof value === 'number' && Number.isFinite(value)) out[key] = value;
33
+ else if (typeof value === 'string') out[key] = redactText(value, 96);
34
+ }
35
+ return out;
36
+ }
37
+
38
+ function createDiagnostics(opts = {}) {
39
+ const now = typeof opts.now === 'function' ? opts.now : Date.now;
40
+ const maxRecords = opts.maxRecords || DEFAULT_MAX_RECORDS;
41
+ const maxBytes = opts.maxBytes || DEFAULT_MAX_BYTES;
42
+ const maxEntryBytes = opts.maxEntryBytes || DEFAULT_MAX_ENTRY_BYTES;
43
+ let entries = [];
44
+ let bytes = 0;
45
+ let seq = 0;
46
+ let enabledUntil = 0;
47
+ let expiryRecorded = false;
48
+
49
+ function push(level, component, code, message, meta = {}) {
50
+ const record = {
51
+ seq: ++seq,
52
+ ts: new Date(now()).toISOString(),
53
+ level,
54
+ component: redactText(component, 48) || 'runtime',
55
+ code: /^[A-Z][A-Z0-9_]{0,63}$/.test(String(code || '')) ? String(code) : 'DIAGNOSTIC_EVENT',
56
+ message: redactText(message, 240) || 'Diagnostic event',
57
+ meta: sanitizeMeta(meta),
58
+ };
59
+ let size = Buffer.byteLength(JSON.stringify(record));
60
+ if (size > maxEntryBytes) {
61
+ record.meta = {};
62
+ record.message = record.message.slice(0, 96);
63
+ size = Buffer.byteLength(JSON.stringify(record));
64
+ }
65
+ if (size > maxEntryBytes) return null;
66
+ entries.push({ record, size }); bytes += size;
67
+ while (entries.length > maxRecords || bytes > maxBytes) {
68
+ const removed = entries.shift(); bytes -= removed.size;
69
+ }
70
+ return record;
71
+ }
72
+
73
+ function refreshExpiry() {
74
+ if (enabledUntil > 0 && now() >= enabledUntil) {
75
+ enabledUntil = 0;
76
+ if (!expiryRecorded) {
77
+ expiryRecorded = true;
78
+ push('info', 'diagnostics', 'VERBOSE_EXPIRED', 'Verbose diagnostics expired', { reason: 'timeout' });
79
+ }
80
+ }
81
+ }
82
+
83
+ function record(level, component, code, message, meta = {}) {
84
+ if (!LEVELS.has(level)) return null;
85
+ refreshExpiry();
86
+ if ((level === 'debug' || level === 'info') && enabledUntil === 0) return null;
87
+ return push(level, component, code, message, meta);
88
+ }
89
+
90
+ function setVerbose(enabled, durationSeconds = 900) {
91
+ if (typeof enabled !== 'boolean') throw new Error('enabled deve essere boolean');
92
+ if (enabled) {
93
+ if (!ALLOWED_DURATIONS.has(durationSeconds)) throw new Error('durationSeconds deve essere 300, 900, 1800 o 3600');
94
+ enabledUntil = now() + durationSeconds * 1000;
95
+ expiryRecorded = false;
96
+ push('info', 'diagnostics', 'VERBOSE_ENABLED', 'Verbose diagnostics enabled', { durationSeconds });
97
+ } else {
98
+ enabledUntil = 0;
99
+ expiryRecorded = true;
100
+ push('info', 'diagnostics', 'VERBOSE_DISABLED', 'Verbose diagnostics disabled', { reason: 'manual' });
101
+ }
102
+ return status();
103
+ }
104
+
105
+ function clear() {
106
+ refreshExpiry();
107
+ const count = entries.length;
108
+ entries = []; bytes = 0;
109
+ push('info', 'diagnostics', 'LOGS_CLEARED', 'Diagnostic buffer cleared', { count });
110
+ return { cleared: count, ...status() };
111
+ }
112
+
113
+ function status() {
114
+ refreshExpiry();
115
+ return {
116
+ verbose: enabledUntil > 0,
117
+ expiresAt: enabledUntil > 0 ? new Date(enabledUntil).toISOString() : null,
118
+ nextSeq: seq + 1,
119
+ retained: entries.length,
120
+ bytes,
121
+ limits: { records: maxRecords, bytes: maxBytes, entryBytes: maxEntryBytes },
122
+ };
123
+ }
124
+
125
+ function logs({ after = 0, limit = 200 } = {}) {
126
+ refreshExpiry();
127
+ if (!Number.isSafeInteger(after) || after < 0) throw new Error('after non valido');
128
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) throw new Error('limit non valido (1..200)');
129
+ const records = entries.map((item) => item.record).filter((entry) => entry.seq > after).slice(0, limit);
130
+ return { records, cursor: records.length ? records[records.length - 1].seq : after, ...status() };
131
+ }
132
+
133
+ return { record, setVerbose, clear, status, logs };
134
+ }
135
+
136
+ module.exports = {
137
+ DEFAULT_MAX_RECORDS, DEFAULT_MAX_BYTES, DEFAULT_MAX_ENTRY_BYTES,
138
+ ALLOWED_DURATIONS, redactText, sanitizeMeta, createDiagnostics,
139
+ };