@mmmbuto/nexuscrew 0.9.52 → 0.9.54

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.
@@ -118,7 +118,21 @@ function createEventFeedRoutes(deps) {
118
118
 
119
119
  const timeout = new Promise((resolve) => setTimeout(() => resolve('timeout'), SNAPSHOT_TIMEOUT_MS));
120
120
  const work = buildSnapshot(g);
121
- const built = await Promise.race([work, timeout]);
121
+ let built;
122
+ try {
123
+ built = await Promise.race([work, timeout]);
124
+ } catch (e) {
125
+ // buildSnapshot rifiuta con un errore tipizzato quando non ha potuto
126
+ // leggere gli asks: nessun elenco è un elenco autorevole, quindi non
127
+ // si spedisce Nessuno snapshot — 503 con corpo minimo. Un 200, con
128
+ // qualunque corpo o marcatore, verrebbe deciso dai riceventi che non
129
+ // conoscono il validatore (0.9.51/0.9.52 decidono solo su res.ok).
130
+ if (e && e.reason === 'asks-unreadable') {
131
+ return res.status(503).json({ error: 'asks-unreadable' });
132
+ }
133
+ if (!res.headersSent) return res.status(503).json({ error: 'snapshot failed', reason: 'snapshot-error' });
134
+ return;
135
+ }
122
136
  if (built === 'timeout') {
123
137
  return res.status(503).json({ error: 'snapshot timeout', reason: 'snapshot-timeout' });
124
138
  }
@@ -188,8 +202,27 @@ function createEventFeedRoutes(deps) {
188
202
  const viewEpoch = st ? st.viewEpoch : null;
189
203
 
190
204
  // Asks: open asks of VISIBLE cells only, capped.
205
+ // Fail closed: se la LETTURA dello store lancia, il produttore non ha un
206
+ // elenco — ha un'assenza di informazione. Spedire asks:[] (con o senza
207
+ // marcatore) farebbe di quell'assenza un elenco AUTOREVOLE: i riceventi
208
+ // senza validatore (0.9.51/0.9.52: `if (!res.ok) continue;` e nient'altro)
209
+ // lo accetterebbero e reconcileImportedAsks chiuderebbe come 'dismissed'
210
+ // alias ancora aperti. Non si spedisce Nessuno snapshot: si rilancia un
211
+ // errore tipizzato che l'handler traduce in 503 {error:"asks-unreadable"}.
191
212
  let asks = [];
192
213
  try {
214
+ // Health gate PRIMA di list(): un asks.json ESISTENTE ma MALFORMATO
215
+ // (virgola finale, asks non array) produce un list() [] SILENZIOSO — senza
216
+ // eccezione. Quel vuoto viaggerebbe come elenco AUTOREVOLE e i riceventi
217
+ // senza validatore (0.9.51/0.9.52) chiuderebbero alias ancora aperti.
218
+ // Se lo store non e' leggibile, nessuno snapshot: stesso percorso del
219
+ // ramo che lancia sotto (503 asks-unreadable).
220
+ const h = deps.asksStore.health && deps.asksStore.health();
221
+ if (h && !h.readable) {
222
+ const err = new Error(`asks-unreadable: ${h.reason || 'store illeggibile'}`);
223
+ err.reason = 'asks-unreadable';
224
+ throw err;
225
+ }
193
226
  const self = typeof deps.localNodeId === 'function' ? deps.localNodeId() : null;
194
227
  const open = deps.asksStore.list({ open: true }) || [];
195
228
  const visible = open
@@ -204,7 +237,14 @@ function createEventFeedRoutes(deps) {
204
237
  .filter((a) => peer.allows({ scope: 'cell', cellId: deps.cellForSession(a.session) }))
205
238
  .slice(0, SNAPSHOT_MAX_ASKS);
206
239
  asks = visible;
207
- } catch (_) { asks = []; }
240
+ } catch (e) {
241
+ // Una riga, nessun dato sensibile: il messaggio dello store e' gia'
242
+ // limitato a nome file e causa (v. persist.js readJsonSafe).
243
+ log(`event-feed: asks non leggibili, snapshot non autorevole per il peer ${peer.nodeId}: ${String((e && e.message) || e)}`);
244
+ const err = new Error('asks-unreadable');
245
+ err.reason = 'asks-unreadable';
246
+ throw err;
247
+ }
208
248
 
209
249
  // Notify history: local-only, ACL-filtered per envelope, capped.
210
250
  const notifications = deps.history.list()
@@ -251,7 +291,8 @@ function createEventFeedRoutes(deps) {
251
291
  if (asks.length >= SNAPSHOT_MAX_ASKS
252
292
  || notifications.length >= SNAPSHOT_MAX_NOTIFY
253
293
  || (fleetState.cells || []).length >= SNAPSHOT_MAX_CELLS) {
254
- built.resyncRequired = true; // at a cap: the client must treat this as a floor, not a whole
294
+ // at a cap: the client must treat this as a floor, not a whole.
295
+ built.resyncRequired = true;
255
296
  }
256
297
  return built;
257
298
  }
@@ -14,17 +14,21 @@ const crypto = require('node:crypto');
14
14
  // deve emergere, non essere nascosto da un chmod automatico).
15
15
  // Restano NON-errori: file assente ({} — stato iniziale legittimo) e JSON
16
16
  // malformato ({} — garbage non deve crashare il server, e non e' un leak).
17
- function readJsonSafe(p) {
18
- let fd;
19
- try {
20
- fd = fs.openSync(p, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
21
- } catch (e) {
22
- if (e.code === 'ENOENT') return {};
23
- if (e.code === 'ELOOP') {
24
- throw new Error(`refuse to read ${path.basename(p)}: symlink (atteso file regolare)`);
25
- }
26
- throw e;
27
- }
17
+
18
+ // Apre il file segreto con le guardie di sicurezza (O_NOFOLLOW + fstat sullo
19
+ // STESSO fd, owner, mode 0600). Le guardie LANCIANO come in readJsonSafe: un
20
+ // symlink, un tipo non regolare, un owner inatteso o permessi larghi devono
21
+ // emergere, non essere riparati in silenzio. Ritorna l'fd aperto; il chiamante
22
+ // chiude. Propaga ENOENT/ELOOP dal openSync (gestiti dai due read).
23
+ function openSecured(p) {
24
+ const fd = fs.openSync(p, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
25
+ // Le guardie dopo l'open (fstat, tipo, owner, mode) possono lanciare. L'fd e'
26
+ // gia' aperto: se uscissimo senza chiuderlo qui, i finally dei reader non lo
27
+ // vedrebbero (openSecured e' lanciata prima dell'assegnazione del loro `fd`),
28
+ // e ogni rifiuto lascerebbe un fd aperto. Con 20 rifiuti di fila (file 0644,
29
+ // p.es. un asks.json ripristinato da un backup senz'0600) sono 20 fd persi.
30
+ // Chiudiamo su OGNI errore delle guardie e rilanciamo: il contratto (apre o
31
+ // lancia, mai mezzo) resta, senza leak.
28
32
  try {
29
33
  const st = fs.fstatSync(fd);
30
34
  if (!st.isFile()) {
@@ -39,6 +43,25 @@ function readJsonSafe(p) {
39
43
  if ((st.mode & 0o077) !== 0) {
40
44
  throw new Error(`refuse to read ${path.basename(p)}: permessi troppo larghi (mode ${(st.mode & 0o777).toString(8)}, atteso 0600)`);
41
45
  }
46
+ } catch (e) {
47
+ try { fs.closeSync(fd); } catch (_) { /* best-effort */ }
48
+ throw e;
49
+ }
50
+ return fd;
51
+ }
52
+
53
+ function readJsonSafe(p) {
54
+ let fd;
55
+ try {
56
+ fd = openSecured(p);
57
+ } catch (e) {
58
+ if (e.code === 'ENOENT') return {};
59
+ if (e.code === 'ELOOP') {
60
+ throw new Error(`refuse to read ${path.basename(p)}: symlink (atteso file regolare)`);
61
+ }
62
+ throw e;
63
+ }
64
+ try {
42
65
  let obj;
43
66
  try { obj = JSON.parse(fs.readFileSync(fd, 'utf8')); }
44
67
  catch (_) { return {}; } // garbage JSON: non un problema di sicurezza
@@ -48,6 +71,40 @@ function readJsonSafe(p) {
48
71
  }
49
72
  }
50
73
 
74
+ // Variante STRETTA di readJsonSafe: distingue assente / valido / malformato.
75
+ // Stesse guardie di sicurezza (lanciano come readJsonSafe). Diverso trattamento
76
+ // di ENOENT e JSON malformato/non-oggetto: NON collassano in {} — restituiscono
77
+ // uno stato esplicito, cosi' il chiamante (asks.js) puo' rifiutare le mutazioni
78
+ // invece di sovrascrivere un file malformato con un defaults vuoto (che
79
+ // cancellerebbe le domande aperte).
80
+ // absent -> { state: 'absent' } (file non esiste)
81
+ // ok -> { state: 'ok', value } (JSON valido, oggetto)
82
+ // malformed -> { state: 'malformed', reason } (parse error o non-oggetto)
83
+ function readJsonStrict(p) {
84
+ let fd;
85
+ try {
86
+ fd = openSecured(p);
87
+ } catch (e) {
88
+ if (e.code === 'ENOENT') return { state: 'absent' };
89
+ if (e.code === 'ELOOP') {
90
+ throw new Error(`refuse to read ${path.basename(p)}: symlink (atteso file regolare)`);
91
+ }
92
+ throw e;
93
+ }
94
+ try {
95
+ let obj;
96
+ try { obj = JSON.parse(fs.readFileSync(fd, 'utf8')); }
97
+ catch (e) { return { state: 'malformed', reason: `JSON malformato: ${String((e && e.message) || e)}` }; }
98
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
99
+ const tipo = obj === null ? 'null' : Array.isArray(obj) ? 'array' : typeof obj;
100
+ return { state: 'malformed', reason: `non e' un oggetto JSON (tipo ${tipo})` };
101
+ }
102
+ return { state: 'ok', value: obj };
103
+ } finally {
104
+ try { fs.closeSync(fd); } catch (_) { /* best-effort */ }
105
+ }
106
+ }
107
+
51
108
  // Scrittura atomica 0600. Rifiuta un target symlink preesistente (ENOENT ok).
52
109
  function atomicWriteJson(p, obj) {
53
110
  try {
@@ -70,4 +127,4 @@ function atomicWriteJson(p, obj) {
70
127
  }
71
128
  }
72
129
 
73
- module.exports = { readJsonSafe, atomicWriteJson };
130
+ module.exports = { readJsonSafe, readJsonStrict, atomicWriteJson };
@@ -451,6 +451,20 @@ function notifyRoutes({
451
451
  const closed = asks.closeImported({
452
452
  ownerId: owner, ownerAskId: b.askId, outcome: b.closeOutcome,
453
453
  });
454
+ // closeImported rifiuta ({ok:false, reason:'store-unreadable'}) se lo
455
+ // store locale e' illeggibile: l'alias NON e' stato chiuso. Rispondere
456
+ // comunque 200 'delivered' nasconderebbe il fallimento al mittente: il
457
+ // dispatcher (lib/audio/dispatch.js:91) traduce un 5xx in
458
+ // {status:'unreachable'} — FUORI da DONE/FINAL (closure-retry.js:37-38)
459
+ // — cosi' la coda di recapito lato owner ritenta dopo la riparazione.
460
+ // 'refused' (403/404/409 -> dispatch.js:88) e' FINALE e non va usato:
461
+ // il tempo non ripara un ACL deny, ma ripara uno store illeggibile.
462
+ // No-op idempotente (closed.ok && !closed.changed, store leggibile,
463
+ // alias gia' chiuso o assente) resta 200 delivered come oggi.
464
+ if (closed.ok === false) {
465
+ const reason = closed.reason || 'store-unreadable';
466
+ return res.status(503).json({ status: 'unavailable', reason });
467
+ }
454
468
  if (closed.changed && closed.ask) {
455
469
  // Il frame porta l'id LOCALE: e' quello con cui questa UI identifica
456
470
  // la card, e senza di esso la card resterebbe a schermo.
@@ -573,7 +587,11 @@ function notifyRoutes({
573
587
  // chiusura mai recapitata (peer spento mentre l'owner chiudeva): senza,
574
588
  // l'alias resta aperto per sempre e la card mente. Best-effort: un owner
575
589
  // irraggiungibile non chiude nulla e non fa fallire la lettura.
576
- if (reconcileImported) { try { await reconcileImported(); } catch (_) {} }
590
+ // Best-effort resta il contratto (un owner irraggiungibile non fa
591
+ // fallire la lettura), ma NON in silenzio: la causa si logga.
592
+ if (reconcileImported) {
593
+ try { await reconcileImported(); } catch (e) { try { log(`riconciliazione ask importate fallita: ${String((e && e.message) || e)}`); } catch (_) {} }
594
+ }
577
595
  // Recapito recuperabile, lato OWNER: i peer che erano spenti quando la
578
596
  // chiusura e' partita vengono ritentati ADESSO. Chi legge lo stato sta
579
597
  // guardando le card, ed e' esattamente il momento in cui una card
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+ const express = require('express');
3
+ const store = require('./store.js');
4
+
5
+ function httpError(status, message, extra) { const e = new Error(message); e.status = status; e.extra = extra; return e; }
6
+
7
+ // Montato sotto /api (gia' dietro il bearer). Ogni richiesta porta anche X-NC-Device: l'id emesso dal server.
8
+ function prefsRoutes({ cfg = {}, prefsPath }) {
9
+ const r = express.Router();
10
+ r.use(express.json({ limit: '64kb' }));
11
+ const readonly = () => cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1';
12
+ const wrap = (fn, { write = false } = {}) => (req, res) => {
13
+ if (write && readonly()) return res.status(403).json({ error: 'READONLY: mutazione preferenze bloccata' });
14
+ try { return fn(req, res); } catch (e) { return res.status(e.status || 500).json({ error: String(e.message || e), ...(e.code ? { code: e.code } : {}), ...(e.extra || {}) }); }
15
+ };
16
+ const deviceOf = (req, st) => {
17
+ const id = String(req.get('x-nc-device') || '');
18
+ if (!store.DEVICE_RE.test(id)) throw httpError(400, 'dispositivo mancante o non valido', { code: 'bad-device' });
19
+ if (!st.devices[id]) throw httpError(404, 'dispositivo sconosciuto', { code: 'unknown-device' });
20
+ return id;
21
+ };
22
+
23
+ r.post('/devices', wrap((_req, res) => {
24
+ res.status(201).json({ deviceId: store.issueDevice(prefsPath) });
25
+ }, { write: true }));
26
+
27
+ r.get('/', wrap((req, res) => {
28
+ const st = store.loadStore(prefsPath); const id = deviceOf(req, st); const d = st.devices[id];
29
+ res.set('ETag', `"${d.revision}"`);
30
+ res.json({ deviceId: id, revision: d.revision, updatedAt: d.updatedAt, data: d.data });
31
+ }));
32
+
33
+ r.put('/', wrap((req, res) => {
34
+ const st = store.loadStore(prefsPath); const id = deviceOf(req, st); const d = st.devices[id];
35
+ const match = String(req.get('if-match') || '').match(/^"(\d+)"$/);
36
+ if (!match) throw httpError(428, 'If-Match richiesto', { code: 'if-match-required' });
37
+ if (Number(match[1]) !== d.revision) throw httpError(409, 'preferenze modificate altrove', { code: 'revision-conflict', current: { revision: d.revision, data: d.data } });
38
+ const data = store.parseData(req.body && req.body.data);
39
+ if (!data) throw httpError(422, 'preferenze non valide', { code: 'bad-schema' });
40
+ // Mai default vuoti sopra valori esistenti: una copia vuota non sostituisce una piena.
41
+ if (store.isEmpty(data) && !store.isEmpty(d.data)) throw httpError(422, 'copia vuota sopra preferenze esistenti', { code: 'refuse-empty-overwrite' });
42
+ const now = Date.now();
43
+ d.revision += 1; d.updatedAt = now; d.lastSeenAt = now; d.data = data;
44
+ store.atomicWrite(prefsPath, st);
45
+ res.set('ETag', `"${d.revision}"`);
46
+ res.json({ deviceId: id, revision: d.revision, updatedAt: d.updatedAt });
47
+ }, { write: true }));
48
+
49
+ return r;
50
+ }
51
+
52
+ module.exports = { prefsRoutes };
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+ // Preferenze di interfaccia (pin, ordine, viste, ordine nodi) per DISPOSITIVO, ospitate dal nodo.
3
+ // Servono a ritrovarle quando lo storage del browser viene svuotato. Non contengono mai token ne' diario:
4
+ // lo schema e' una whitelist. Un dispositivo e' un id opaco emesso DAL SERVER (non scelto dal client) e si
5
+ // presenta insieme al bearer: due telefoni con lo stesso token restano isolati.
6
+ const fs = require('node:fs');
7
+ const os = require('node:os');
8
+ const path = require('node:path');
9
+ const crypto = require('node:crypto');
10
+
11
+ const SCHEMA_VERSION = 1;
12
+ const MAX_DEVICES = 64;
13
+ const DEVICE_RE = /^[a-f0-9]{32}$/;
14
+ const MAX_KEY = 192; const MAX_LIST = 256; const MAX_POSITIONS = 128;
15
+
16
+ function defaultPrefsPath(home) { return path.join(home || os.homedir(), '.nexuscrew', 'prefs.json'); }
17
+
18
+ const text = (s, max) => {
19
+ if (typeof s !== 'string' || !s || s.length > max) return false;
20
+ for (let i = 0; i < s.length; i += 1) { const c = s.charCodeAt(i); if (c <= 0x1f || c === 0x7f) return false; }
21
+ return true;
22
+ };
23
+ const keyList = (v, max = MAX_LIST) => Array.isArray(v) && v.length <= max && v.every((k) => text(k, MAX_KEY));
24
+ const plain = (v) => v && typeof v === 'object' && !Array.isArray(v);
25
+
26
+ // Ritorna i dati normalizzati oppure null. Chiavi sconosciute => rifiuto (niente campi liberi).
27
+ function parseData(raw) {
28
+ if (!plain(raw)) return null;
29
+ const allowed = new Set(['pins', 'orders', 'views', 'nodeOrder']);
30
+ if (Object.keys(raw).some((k) => !allowed.has(k))) return null;
31
+ const out = { pins: [], orders: {}, views: {}, nodeOrder: [] };
32
+ if (raw.pins !== undefined) { if (!keyList(raw.pins)) return null; out.pins = [...new Set(raw.pins)]; }
33
+ if (raw.nodeOrder !== undefined) { if (!keyList(raw.nodeOrder, 128)) return null; out.nodeOrder = [...new Set(raw.nodeOrder)]; }
34
+ if (raw.orders !== undefined) {
35
+ if (!plain(raw.orders) || Object.keys(raw.orders).length > MAX_POSITIONS) return null;
36
+ for (const [pos, list] of Object.entries(raw.orders)) {
37
+ if (!text(pos, 96) || !keyList(list)) return null;
38
+ out.orders[pos] = [...new Set(list)];
39
+ }
40
+ }
41
+ if (raw.views !== undefined) {
42
+ if (!plain(raw.views) || Object.keys(raw.views).length > MAX_POSITIONS) return null;
43
+ for (const [pos, v] of Object.entries(raw.views)) {
44
+ if (!text(pos, 96) || !plain(v)) return null;
45
+ const view = {};
46
+ if (v.open !== undefined) { if (typeof v.open !== 'boolean') return null; view.open = v.open; }
47
+ if (v.filter !== undefined) { if (!text(v.filter, 16)) return null; view.filter = v.filter; }
48
+ out.views[pos] = view;
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+
54
+ const isEmpty = (d) => !d || (!d.pins.length && !d.nodeOrder.length && !Object.keys(d.orders).length && !Object.keys(d.views).length);
55
+
56
+ function emptyStore() { return { schemaVersion: SCHEMA_VERSION, devices: {} }; }
57
+
58
+ function parseStore(raw) {
59
+ try {
60
+ const obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
61
+ if (!plain(obj) || obj.schemaVersion !== SCHEMA_VERSION || !plain(obj.devices)) return null;
62
+ const devices = {};
63
+ for (const [id, d] of Object.entries(obj.devices)) {
64
+ if (!DEVICE_RE.test(id) || !plain(d) || !Number.isSafeInteger(d.revision) || d.revision < 0) return null;
65
+ const data = d.data === null || d.data === undefined ? null : parseData(d.data);
66
+ if (d.data && !data) return null;
67
+ devices[id] = { createdAt: Number(d.createdAt) || 0, lastSeenAt: Number(d.lastSeenAt) || 0,
68
+ revision: d.revision, updatedAt: Number(d.updatedAt) || 0, data };
69
+ }
70
+ return { schemaVersion: SCHEMA_VERSION, devices };
71
+ } catch (_) { return null; }
72
+ }
73
+
74
+ function loadStore(p) {
75
+ let raw;
76
+ try { raw = fs.readFileSync(p, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return emptyStore(); throw e; }
77
+ const parsed = parseStore(raw);
78
+ if (!parsed) { const e = new Error('prefs.json non valido'); e.status = 503; throw e; }
79
+ return parsed;
80
+ }
81
+
82
+ function atomicWrite(p, st) {
83
+ try { if (fs.lstatSync(p).isSymbolicLink()) throw new Error('refuse symlink prefs.json'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
84
+ const parsed = parseStore(st);
85
+ if (!parsed) throw new Error('prefs.json non valido');
86
+ fs.mkdirSync(path.dirname(p), { recursive: true });
87
+ const tmp = path.join(path.dirname(p), `.${path.basename(p)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
88
+ try {
89
+ fs.writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
90
+ fs.chmodSync(tmp, 0o600);
91
+ fs.renameSync(tmp, p);
92
+ } catch (e) { try { fs.unlinkSync(tmp); } catch (_) { /* best effort */ } throw e; }
93
+ return parsed;
94
+ }
95
+
96
+ // Emette un nuovo id dispositivo. Al limite fa posto scartando il piu' vecchio MAI USATO (nessun dato);
97
+ // se tutti hanno dati, rifiuta: mai cancellare preferenze di qualcuno per fare spazio.
98
+ function issueDevice(p, now = Date.now()) {
99
+ const st = loadStore(p);
100
+ const ids = Object.keys(st.devices);
101
+ if (ids.length >= MAX_DEVICES) {
102
+ const unused = ids.filter((id) => st.devices[id].revision === 0).sort((a, b) => st.devices[a].createdAt - st.devices[b].createdAt);
103
+ if (!unused.length) { const e = new Error('limite dispositivi raggiunto'); e.status = 409; e.code = 'device-limit'; throw e; }
104
+ delete st.devices[unused[0]];
105
+ }
106
+ const id = crypto.randomBytes(16).toString('hex');
107
+ st.devices[id] = { createdAt: now, lastSeenAt: now, revision: 0, updatedAt: 0, data: null };
108
+ atomicWrite(p, st);
109
+ return id;
110
+ }
111
+
112
+ module.exports = { SCHEMA_VERSION, MAX_DEVICES, DEVICE_RE, defaultPrefsPath, parseData, isEmpty, emptyStore, loadStore, atomicWrite, issueDevice };
@@ -469,7 +469,15 @@ function liveHostLedgerCapture({ ledger, ownerId, resource }) {
469
469
  };
470
470
  }
471
471
 
472
- function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null, setCookiePrefix = null, panelCookie = null, capture = null, getTimeoutMs = 10000 }) {
472
+ // An SSE stream is idle BY DESIGN between events. Past the response head the
473
+ // forward timeout must stop applying: the peer's heartbeat is 20 s apart and the
474
+ // GET forward timeout is 10 s, which used to destroy the upstream before the
475
+ // first beat and leave the client hanging on a dead stream until its own idle.
476
+ // What replaces it measures the STREAM's silence, not the socket's.
477
+ const SSE_IDLE_TIMEOUT_MS = 90000;
478
+ const SSE_CONTENT_TYPE_RE = /^text\/event-stream/i;
479
+
480
+ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null, setCookiePrefix = null, panelCookie = null, capture = null, getTimeoutMs = 10000, sseIdleMs = SSE_IDLE_TIMEOUT_MS }) {
473
481
  // A body a local middleware has already read is no longer in the stream: the
474
482
  // headers would announce bytes that nothing sends, and the peer would wait for
475
483
  // a body that never arrives. Say it at once, with a name, instead of letting
@@ -477,9 +485,39 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
477
485
  if (Number(req.headers['content-length'] || 0) > 0 && req.readableEnded) {
478
486
  return res.status(502).json({ error: 'request body already read by a local middleware', reason: 'federation-body-consumed' });
479
487
  }
488
+ let streamWatchdog = null;
489
+ const clearStreamWatchdog = () => {
490
+ if (streamWatchdog) { clearTimeout(streamWatchdog); streamWatchdog = null; }
491
+ };
480
492
  const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited, hopProof, panelCookie) }, (r) => {
481
493
  const headers = setCookiePrefix ? rewriteSetCookiePath(r.headers, setCookiePrefix) : r.headers;
482
- if (!capture) { res.writeHead(r.statusCode, sanitizeResponseHeaders(headers)); r.pipe(res); return; }
494
+ if (!capture) {
495
+ res.writeHead(r.statusCode, sanitizeResponseHeaders(headers));
496
+ if (SSE_CONTENT_TYPE_RE.test(String(r.headers['content-type'] || ''))) {
497
+ // The response is open: no inactivity timeout any more (the 504 below
498
+ // still covers a peer that never answers), and the watchdog is rearmed
499
+ // on every chunk, so only a stream that really went quiet is ended.
500
+ up.setTimeout(0);
501
+ const rearm = () => {
502
+ clearStreamWatchdog();
503
+ streamWatchdog = setTimeout(() => {
504
+ clearStreamWatchdog();
505
+ // End BOTH sides: leaving the client on a silent stream only makes
506
+ // it wait for its own idle and reconnect anyway.
507
+ try { up.destroy(); } catch (_) { /* already gone */ }
508
+ try { if (!res.writableEnded) res.end(); } catch (_) { /* client gone */ }
509
+ }, sseIdleMs);
510
+ if (typeof streamWatchdog.unref === 'function') streamWatchdog.unref();
511
+ };
512
+ rearm();
513
+ r.on('data', rearm);
514
+ // The client leaving first must take the upstream with it, or the peer
515
+ // keeps a writer on a socket nobody reads.
516
+ res.on('close', () => { clearStreamWatchdog(); try { up.destroy(); } catch (_) { /* gone */ } });
517
+ }
518
+ r.pipe(res);
519
+ return;
520
+ }
483
521
  // Buffered only where a capture asks for it (the two live-host mutations):
484
522
  // the owner's answer carries the revision that has to be recorded.
485
523
  const chunks = [];
@@ -505,11 +543,16 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
505
543
  }
506
544
  up.destroy();
507
545
  });
508
- up.on('error', () => { if (!res.headersSent) res.status(502).json({ error: 'peer non raggiungibile', reason: 'federation-peer-unreachable' }); else res.destroy(); });
546
+ up.on('close', clearStreamWatchdog);
547
+ up.on('error', () => {
548
+ clearStreamWatchdog();
549
+ if (!res.headersSent) res.status(502).json({ error: 'peer non raggiungibile', reason: 'federation-peer-unreachable' });
550
+ else res.destroy();
551
+ });
509
552
  req.pipe(up);
510
553
  }
511
554
 
512
- function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null, liveHostLedger = null, forwardGetTimeoutMs = 10000 }) {
555
+ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null, liveHostLedger = null, forwardGetTimeoutMs = 10000, forwardSseIdleMs = SSE_IDLE_TIMEOUT_MS }) {
513
556
  return (req, res) => {
514
557
  const parsed = parseRoute(req.url);
515
558
  // Prima dell'allowlist: un peer senza permesso non deve nemmeno sapere se la
@@ -558,6 +601,7 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
558
601
  hopProof: signHop(secret, { method: req.method, path, visited }),
559
602
  panelCookie,
560
603
  getTimeoutMs: forwardGetTimeoutMs,
604
+ sseIdleMs: forwardSseIdleMs,
561
605
  });
562
606
  }
563
607
  const next = st && store.getNode(st, parsed.route[0]);
@@ -578,7 +622,7 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
578
622
  const capture = liveHostLedger && (parsed.resource === '/live-host/designate' || parsed.resource === '/live-host/clear')
579
623
  ? liveHostLedgerCapture({ ledger: liveHostLedger, ownerId: next.nodeId, resource: parsed.resource })
580
624
  : null;
581
- proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited, setCookiePrefix, panelCookie, capture, getTimeoutMs: forwardGetTimeoutMs });
625
+ proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited, setCookiePrefix, panelCookie, capture, getTimeoutMs: forwardGetTimeoutMs, sseIdleMs: forwardSseIdleMs });
582
626
  };
583
627
  }
584
628
 
@@ -1576,6 +1620,22 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1576
1620
  }
1577
1621
  }
1578
1622
  const up = net.connect({ host: '127.0.0.1', port });
1623
+ // ECONNREFUSED sull'hop locale (tunnel che si riavvia) arriva PRIMA del
1624
+ // 'connect': nel ramo hubMode il listener 'error' era registrato solo dentro
1625
+ // up.once('connect', …) e l'evento restava senza gestore → Unhandled 'error'
1626
+ // event → crash del processo (caduto 5 volte su un nodo Termux). Lo
1627
+ // registriamo QUI, sincrono, prima che qualunque I/O possa fallire, valido
1628
+ // per entrambi i rami. Idempotente con i listener registrati dopo il connect:
1629
+ // destroy() e upstreamGone() sono gia' protetti da flag.
1630
+ let heldRecord = null;
1631
+ up.on('error', () => {
1632
+ if (hubMode) {
1633
+ if (heldRecord) hubAttaches.destroy(heldRecord);
1634
+ else acceptWsThenClose(socket, req, 4402, 'upstream gone');
1635
+ } else {
1636
+ upstreamGone();
1637
+ }
1638
+ });
1579
1639
  // Osservatore NON consumante della testa di risposta dell'upstream: serve
1580
1640
  // solo a sapere se il 101 e' transitato (client in stato WebSocket) o se
1581
1641
  // l'hop e' morto prima. La pipe resta l'unico trasporto dei byte: il
@@ -1635,6 +1695,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1635
1695
  const record = hubAttaches.hold({
1636
1696
  attachId, tokenHash: tokenFingerprint(attachToken), route: attachRoute, session: attachSession, up,
1637
1697
  });
1698
+ heldRecord = record;
1638
1699
  up.on('data', (chunk) => hubAttaches.feed(record, chunk));
1639
1700
  up.on('close', () => hubAttaches.destroy(record));
1640
1701
  up.on('error', () => hubAttaches.destroy(record));