@mmmbuto/nexuscrew 0.8.6 → 0.8.9

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-CEFh0hI4.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-kX5IUxt4.css">
14
+ <script type="module" crossorigin src="/assets/index-DTmT7yhV.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-9Drd8g0k.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.8.6"}
1
+ {"version":"0.8.9"}
package/lib/config.js CHANGED
@@ -30,6 +30,9 @@ function baseDefaults() {
30
30
  fleetEnabled: true,
31
31
  fleetBin: path.join(os.homedir(), '.local', 'bin', 'fleet'),
32
32
  providerSecretsPath: path.join(os.homedir(), '.nexuscrew', 'providers.env'),
33
+ // Installazioni npm globali controllano periodicamente il dist-tag latest.
34
+ // Il manager aggiorna solo verso una semver superiore: mai downgrade.
35
+ autoUpdate: true,
33
36
  sessionPresets: {},
34
37
  };
35
38
  }
@@ -63,6 +66,9 @@ function envOverrides() {
63
66
  if (process.env.NEXUSCREW_FLEET) e.fleetEnabled = process.env.NEXUSCREW_FLEET !== '0';
64
67
  if (process.env.NEXUSCREW_FLEET_BIN) e.fleetBin = process.env.NEXUSCREW_FLEET_BIN;
65
68
  if (process.env.NEXUSCREW_PROVIDER_SECRETS) e.providerSecretsPath = process.env.NEXUSCREW_PROVIDER_SECRETS;
69
+ if (process.env.NEXUSCREW_AUTO_UPDATE !== undefined) {
70
+ e.autoUpdate = !['', '0', 'false', 'no', 'off'].includes(String(process.env.NEXUSCREW_AUTO_UPDATE).toLowerCase());
71
+ }
66
72
  return e;
67
73
  }
68
74
 
@@ -26,7 +26,7 @@ const path = require('node:path');
26
26
  const { execFile } = require('node:child_process');
27
27
  const {
28
28
  parseDefinitions, validateCommandTrust, resolveCwd,
29
- loadDefinitions, atomicWrite, CAPS, validTmuxName,
29
+ loadDefinitions, atomicWrite, CAPS, MAX_CELLS, validTmuxName,
30
30
  } = require('./definitions.js');
31
31
  const {
32
32
  publicCatalog, describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels,
@@ -56,7 +56,7 @@ function minimalEnv() {
56
56
  return env;
57
57
  }
58
58
 
59
- function httpError(status, msg) { const e = new Error(msg); e.status = status; return e; }
59
+ function httpError(status, msg, data = null) { const e = new Error(msg); e.status = status; if (data) e.data = data; return e; }
60
60
 
61
61
  // Marcatore di redazione (design §9h): stderr/stdout dei comandi tmux falliti
62
62
  // NON devono mai ecoare i segreti delle definizioni.
@@ -541,6 +541,59 @@ async function createBuiltinFleet(cfg = {}) {
541
541
  const sessions = await refreshSessions();
542
542
  return { ok: true, active: sessions.has(findCell(defs, id).tmuxSession) };
543
543
  }
544
+
545
+ // Ripristino selettivo atomico da backup PWA. Il body contiene SOLO campi
546
+ // cella allowlisted (mai engine.env/provider secrets). Tutte le celle vengono
547
+ // validate insieme da atomicWrite: un errore lascia il file precedente intatto.
548
+ async function restoreCells(cells) {
549
+ if (readonly()) throw httpError(403, 'READONLY: restore-cells bloccato');
550
+ if (!Array.isArray(cells) || cells.length < 1 || cells.length > MAX_CELLS) {
551
+ throw httpError(400, `cells deve contenere 1..${MAX_CELLS} definizioni`);
552
+ }
553
+ const allowed = new Set(['id', 'cwd', 'engine', 'boot', 'model', 'models', 'permissionPolicies', 'prompt']);
554
+ const seen = new Set();
555
+ for (const cell of cells) {
556
+ if (!cell || typeof cell !== 'object' || Array.isArray(cell)) throw httpError(400, 'definizione cell non valida');
557
+ for (const key of Object.keys(cell)) if (!allowed.has(key)) throw httpError(400, `campo backup non ammesso: ${key}`);
558
+ if (typeof cell.id !== 'string' || seen.has(cell.id)) throw httpError(400, `id cell duplicato o mancante: ${cell.id || '?'}`);
559
+ seen.add(cell.id);
560
+ }
561
+ const defs = reloadDefs();
562
+ const availableEngines = new Set(defs.engines.map((engine) => engine.id));
563
+ const referencedEngines = new Set();
564
+ for (const cell of cells) {
565
+ referencedEngines.add(cell.engine);
566
+ for (const id of Object.keys(cell.models || {})) referencedEngines.add(id);
567
+ for (const id of Object.keys(cell.permissionPolicies || {})) referencedEngines.add(id);
568
+ }
569
+ const missingEngines = [...referencedEngines].filter((id) => !availableEngines.has(id)).sort();
570
+ if (missingEngines.length) {
571
+ throw httpError(400, `engine mancanti sul target: ${missingEngines.join(', ')}`, {
572
+ code: 'missing-engines', missingEngines,
573
+ hint: 'definisci prima gli engine mancanti oppure mappa le celle su engine disponibili',
574
+ });
575
+ }
576
+ const replaced = cells.filter((cell) => !!findCell(defs, cell.id)).map((cell) => cell.id);
577
+ const created = cells.filter((cell) => !findCell(defs, cell.id)).map((cell) => cell.id);
578
+ await mutate(defs, (draft) => {
579
+ for (const cell of cells) {
580
+ const index = draft.cells.findIndex((current) => current.id === cell.id);
581
+ const next = { ...cell };
582
+ if (index >= 0) {
583
+ // tmuxSession e' identita' runtime immutabile: il backup non la porta.
584
+ next.tmuxSession = draft.cells[index].tmuxSession;
585
+ draft.cells[index] = next;
586
+ } else draft.cells.push(next);
587
+ }
588
+ });
589
+ const sessions = await refreshSessions();
590
+ const saved = reloadDefs();
591
+ return {
592
+ ok: true, count: cells.length, created, replaced,
593
+ needsRestart: cells.map((cell) => findCell(saved, cell.id))
594
+ .filter((cell) => cell && sessions.has(cell.tmuxSession)).map((cell) => cell.id),
595
+ };
596
+ }
544
597
  async function removeCell(id, opts = {}) {
545
598
  if (readonly()) throw httpError(403, 'READONLY: remove-cell bloccato');
546
599
  let defs = reloadDefs();
@@ -704,7 +757,7 @@ async function createBuiltinFleet(cfg = {}) {
704
757
  }
705
758
 
706
759
  function capabilities() {
707
- return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'schema', 'definitions'];
760
+ return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions'];
708
761
  }
709
762
 
710
763
  return {
@@ -712,7 +765,7 @@ async function createBuiltinFleet(cfg = {}) {
712
765
  provider: 'builtin',
713
766
  status, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
714
767
  defineEngine, editEngine, removeEngine,
715
- defineCell, editCell, removeCell, importCell,
768
+ defineCell, editCell, removeCell, importCell, restoreCells,
716
769
  schema, definitions, capabilities,
717
770
  };
718
771
  }
@@ -25,7 +25,9 @@ function requireCap(fleet, cap) {
25
25
  // async): ogni handler attende la resolve; unavailable → 404 sui comandi.
26
26
  function fleetRoutes(fleetP, cfg = {}) {
27
27
  const r = express.Router();
28
- r.use(express.json({ limit: '4kb' }));
28
+ const smallJson = express.json({ limit: '4kb' });
29
+ const restoreJson = express.json({ limit: '256kb' });
30
+ r.use((req, res, next) => (req.path === '/restore-cells' ? restoreJson : smallJson)(req, res, next));
29
31
 
30
32
  const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
31
33
 
@@ -38,7 +40,7 @@ function fleetRoutes(fleetP, cfg = {}) {
38
40
  if (!fleet.available) return res.status(404).json({ error: 'fleet non disponibile' });
39
41
  res.json(await fn(fleet, req.body || {}));
40
42
  } catch (e) {
41
- res.status(e.status || 500).json({ error: String(e.message || e) });
43
+ res.status(e.status || 500).json({ error: String(e.message || e), ...(e.data || {}) });
42
44
  }
43
45
  };
44
46
 
@@ -110,10 +112,19 @@ function fleetRoutes(fleetP, cfg = {}) {
110
112
  r.post('/define-cell', guard((f, b) => { requireCap(f, 'define'); return f.defineCell(b.def); }, { mutate: true }));
111
113
  r.post('/edit-cell', guard((f, b) => { requireCap(f, 'edit'); return f.editCell(b.id, b.patch); }, { mutate: true }));
112
114
  r.post('/remove-cell', guard((f, b) => { requireCap(f, 'remove'); return f.removeCell(b.id, { stop: b.stop === true }); }, { mutate: true }));
115
+ r.post('/restore-cells', guard((f, b) => { requireCap(f, 'restore'); return f.restoreCells(b.cells); }, { mutate: true }));
113
116
  // Riconciliazione sessione tmux esistente (cella Fleet legacy orfana) in cella
114
117
  // gestita fleet.json. Capability 'define' (solo builtin): external legacy -> 501.
115
118
  r.post('/import-cell', guard(async (f, b) => { requireCap(f, 'import'); return f.importCell(b || {}); }, { mutate: true }));
116
119
 
120
+ r.use((err, _req, res, _next) => {
121
+ if (err && (err.type === 'entity.too.large' || err.status === 413)) {
122
+ return res.status(413).json({ error: 'body troppo grande', code: 'body-too-large' });
123
+ }
124
+ if (err instanceof SyntaxError) return res.status(400).json({ error: 'JSON non valido', code: 'invalid-json' });
125
+ return res.status(err.status || 400).json({ error: String(err.message || err) });
126
+ });
127
+
117
128
  return r;
118
129
  }
119
130
 
@@ -30,6 +30,7 @@ function clearHealthCache() { cache.clear(); }
30
30
  // onesto). outbound down/401 -> 'down'/'degraded'.
31
31
  function tunnelFromHealth(h) {
32
32
  if (!h) return { status: 'unknown', managed: false };
33
+ if (h.status === 'passive') return { status: 'passive', managed: false };
33
34
  if (h.transport === 'up' && h.auth === 'ok') return { status: 'up', managed: h.managed !== false };
34
35
  if (h.transport === 'up' && h.auth === 'failed') return { status: 'degraded', managed: h.managed !== false };
35
36
  if (h.transport === 'up') return { status: 'degraded', managed: h.managed !== false };
@@ -39,7 +40,7 @@ function tunnelFromHealth(h) {
39
40
 
40
41
  async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = false }) {
41
42
  if (!node || typeof node !== 'object') return null;
42
- const cacheKey = `${home || ''}\0${node.direction || 'outbound'}\0${node.name}\0${node.localPort}\0${node.nodeId || ''}`;
43
+ const cacheKey = `${home || ''}\0${node.direction || 'outbound'}\0${node.name}\0${node.localPort}\0${node.nodeId || ''}\0${node.rolesKnown === true}\0${node.roles?.node === true}`;
43
44
  const cached = !force && cache.get(cacheKey);
44
45
  if (cached && (now - cached.at) < TTL_MS) return cached.health;
45
46
 
@@ -54,7 +55,17 @@ async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = fal
54
55
  const probed = await probeHealth({
55
56
  port: node.localPort, token: node.token, expectedInstanceId: node.nodeId || null, fetchImpl, now,
56
57
  });
57
- health = { ...probed, managed: false };
58
+ // The receiving side does not own an inbound client's lifecycle. A
59
+ // client-only (or legacy unknown-role) peer being offline is expected,
60
+ // not a broken server. Live auth/payload failures remain real failures.
61
+ if (probed.transport === 'down' && (node.rolesKnown !== true || node.roles?.node !== true)) {
62
+ health = {
63
+ ...probed, status: 'passive', expected: true, managed: false,
64
+ detail: node.rolesKnown === true ? 'client peer offline (expected)' : 'inbound peer offline',
65
+ };
66
+ } else {
67
+ health = { ...probed, managed: false };
68
+ }
58
69
  }
59
70
  } else {
60
71
  const ts = nodesTunnel.readTunnelState(home, node.name);
@@ -89,7 +89,7 @@ function parsePairingUrl(value) {
89
89
  }
90
90
 
91
91
  function createInvite({
92
- invitesPath, instanceId, port, label = 'NexusCrew', now = Date.now(), randomBytes = crypto.randomBytes,
92
+ invitesPath, instanceId, port, linkPort = port, label = 'NexusCrew', now = Date.now(), randomBytes = crypto.randomBytes,
93
93
  ssh, sshPort, name,
94
94
  } = {}) {
95
95
  const invite = randomBytes(32).toString('base64url');
@@ -116,7 +116,8 @@ function createInvite({
116
116
  if (sshVal && store.isPort(sshPort)) payload.sshPort = sshPort;
117
117
  }
118
118
  const pair = encodePairing(payload);
119
- return { pairingUrl: `http://127.0.0.1:${port}/#pair=${pair}`, expiresAt, version: payload.v };
119
+ if (!store.isPort(linkPort)) throw new Error('porta locale del link non valida');
120
+ return { pairingUrl: `http://127.0.0.1:${linkPort}/#pair=${pair}`, expiresAt, version: payload.v };
120
121
  }
121
122
 
122
123
  function consumeInvite({ invitesPath, invite, now = Date.now() }) {
@@ -161,8 +162,34 @@ function consumePending({ pendingPath, credential, now = Date.now() }) {
161
162
  return found;
162
163
  }
163
164
 
165
+ // Probe di trasporto del tunnel -L provvisorio PRIMA di consumare l'invite
166
+ // one-time: qualunque risposta HTTP dal peer attraverso la forward (anche un
167
+ // 401 su /federation/health senza credenziali) dimostra che ssh+forward sono
168
+ // vivi; un errore di rete no. Sostituisce lo sleep fisso 900ms: bounded, con
169
+ // sleep iniettabile (deterministico nei test) e timeout per tentativo.
170
+ async function probeTransportReady({ port, fetchImpl = fetch, attempts = 6, timeoutMs = 1500, sleep } = {}) {
171
+ const wait = typeof sleep === 'function' ? sleep : (ms) => new Promise((r) => setTimeout(r, ms));
172
+ let lastError = '';
173
+ for (let i = 0; i < attempts; i += 1) {
174
+ let timer;
175
+ try {
176
+ const ctrl = new AbortController();
177
+ timer = setTimeout(() => ctrl.abort(), timeoutMs);
178
+ await fetchImpl(`http://127.0.0.1:${port}/federation/health`, { signal: ctrl.signal });
179
+ return { ready: true, attempts: i + 1 };
180
+ } catch (e) {
181
+ lastError = String((e && e.message) || e);
182
+ } finally {
183
+ if (timer) clearTimeout(timer);
184
+ }
185
+ if (i < attempts - 1) await wait(250 * (i + 1));
186
+ }
187
+ return { ready: false, attempts, lastError };
188
+ }
189
+
164
190
  module.exports = {
165
191
  INVITE_TTL_MS, REVERSE_PORT_BASE, defaultInvitesPath, defaultPendingPath, safeEqual,
166
192
  readInvites, writeInvites, encodePairing, decodePairing, parsePairingUrl,
167
193
  createInvite, consumeInvite, allocateReversePort, createPending, consumePending,
194
+ probeTransportReady,
168
195
  };
@@ -103,7 +103,7 @@ const LABEL_MAX = 64;
103
103
  // tollerato oltre a quelli noti (schema chiuso: garbage -> null, non guess).
104
104
  const NODE_KEYS = new Set([
105
105
  'name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'identityFile',
106
- 'roles', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
106
+ 'roles', 'rolesKnown', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
107
107
  'direction', 'reversePort', 'label',
108
108
  ]);
109
109
  function parseNode(n, schemaVersion = SCHEMA_VERSION) {
@@ -123,6 +123,10 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
123
123
  if (schemaVersion === LEGACY_SCHEMA_VERSION && !identityFile) return null;
124
124
  const roles = parseRoles(n.roles);
125
125
  if (!roles) return null;
126
+ if (n.rolesKnown !== undefined && typeof n.rolesKnown !== 'boolean') return null;
127
+ // Before 0.8.9 `roles` was filled with a local default and did not describe
128
+ // the remote peer. Only the explicit marker makes role-based health safe.
129
+ const rolesKnown = n.rolesKnown === true;
126
130
  // label (display) opzionale ma, se presente, strict: stringa 1..LABEL_MAX, no
127
131
  // control char (newline/tab inclusi: una label su piu' righe non ha senso in
128
132
  // UI), no solo-spazi. Garbage -> null (schema chiuso), mai guess/truncate.
@@ -140,6 +144,7 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
140
144
  remotePort: n.remotePort,
141
145
  localPort: n.localPort,
142
146
  roles,
147
+ rolesKnown,
143
148
  direction,
144
149
  transport: n.transport || (direction === 'inbound' ? 'inbound' : (schemaVersion === LEGACY_SCHEMA_VERSION ? 'ssh' : 'auto')),
145
150
  autostart: n.autostart === undefined ? schemaVersion !== LEGACY_SCHEMA_VERSION : n.autostart,
@@ -379,6 +384,8 @@ function redactNode(n) {
379
384
  remotePort: n.remotePort,
380
385
  localPort: n.localPort,
381
386
  direction: n.direction || 'outbound',
387
+ roles: { ...n.roles },
388
+ rolesKnown: n.rolesKnown === true,
382
389
  transport: n.transport || 'ssh',
383
390
  autostart: !!n.autostart,
384
391
  visibility: n.visibility || 'network',
@@ -486,7 +493,7 @@ function suggestNodeName(input, existing = []) {
486
493
 
487
494
  module.exports = {
488
495
  // parse/validate
489
- parseStore, parseNode, parseRendezvous, parseSsh, parseSshTarget, isPort, isAbsPath, validToken,
496
+ parseStore, parseNode, parseRendezvous, parseRoles, parseSsh, parseSshTarget, isPort, isAbsPath, validToken,
490
497
  // I/O
491
498
  defaultNodesPath, loadStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
492
499
  // mutazioni
@@ -55,7 +55,7 @@ function knownResource(resource) {
55
55
  || resource === '/files/download'
56
56
  || resource === '/files/upload'
57
57
  || resource === '/ws'
58
- || /^\/fleet\/(status|schema|definitions|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell)$/.test(resource);
58
+ || /^\/fleet\/(status|schema|definitions|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells)$/.test(resource);
59
59
  }
60
60
 
61
61
  function allowedResource(resource, method = 'GET') {
@@ -68,7 +68,7 @@ function allowedResource(resource, method = 'GET') {
68
68
  if (resource === '/files/upload') return method === 'POST';
69
69
  if (resource === '/ws') return method === 'GET';
70
70
  if (/^\/fleet\/(status|schema|definitions)$/.test(resource)) return method === 'GET';
71
- if (/^\/fleet\/(up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell)$/.test(resource)) return method === 'POST';
71
+ if (/^\/fleet\/(up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells)$/.test(resource)) return method === 'POST';
72
72
  return false;
73
73
  }
74
74
 
@@ -155,6 +155,14 @@ async function probeHealth({ port, token, expectedInstanceId = null, fetchImpl =
155
155
  } else if (expectedInstanceId && body.instanceId !== expectedInstanceId) {
156
156
  out.reachability = 'failed'; out.status = 'degraded'; out.detail = 'peer instanceId inatteso — tunnel/porta punta al nodo sbagliato';
157
157
  } else {
158
+ if (body.roles !== undefined) {
159
+ const roles = store.parseRoles(body.roles);
160
+ if (!roles) {
161
+ out.reachability = 'failed'; out.status = 'degraded'; out.detail = 'health roles non validi';
162
+ return out;
163
+ }
164
+ out.roles = roles; out.rolesKnown = true;
165
+ }
158
166
  out.reachability = 'ok'; out.status = 'healthy'; out.detail = 'ok';
159
167
  }
160
168
  } else if (r.status === 401) {
@@ -256,7 +264,7 @@ async function collectLocalTopology({ nodesPath, cachePath, fetchImpl = fetch, n
256
264
  return { instanceId: live.instanceId, nodes };
257
265
  }
258
266
 
259
- function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly, version = null }) {
267
+ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly, version = null, roles = null }) {
260
268
  const r = express.Router();
261
269
  r.use((req, res, next) => {
262
270
  const peer = peerFromToken(nodesPath, bearerFrom(req));
@@ -269,7 +277,9 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
269
277
  // reachability (payload). Nessun segreto in risposta.
270
278
  r.get('/health', (_req, res) => {
271
279
  const st = store.loadStore(nodesPath);
272
- res.json({ ok: true, instanceId: (st && st.nodeId) || null, version, readonly: !!readonly() });
280
+ const advertisedRoles = typeof roles === 'function' ? roles() : null;
281
+ res.json({ ok: true, instanceId: (st && st.nodeId) || null, version, readonly: !!readonly(),
282
+ ...(advertisedRoles ? { roles: advertisedRoles } : {}) });
273
283
  });
274
284
  r.get('/topology', async (req, res) => {
275
285
  const ttl = Math.max(0, Math.min(MAX_HOPS, Number(req.query.ttl) || MAX_HOPS));
package/lib/server.js CHANGED
@@ -36,6 +36,7 @@ const { createPushService } = require('./notify/push.js');
36
36
  const { createAsksStore } = require('./notify/asks.js');
37
37
  const { createNotifier } = require('./notify/notifier.js');
38
38
  const { notifyRoutes } = require('./notify/routes.js');
39
+ const { createNpmUpdater } = require('./update/manager.js');
39
40
 
40
41
  function sessionExists(tmuxBin, name) {
41
42
  if (typeof name !== 'string' || !/^[\w.@%:+-]{1,128}$/.test(name)) return false;
@@ -97,6 +98,13 @@ function createServer(opts = {}) {
97
98
  });
98
99
  const asksStore = createAsksStore({ dir: notifyDir });
99
100
  const notifier = createNotifier({ hub: eventsHub, push: pushSvc });
101
+ const updater = opts.updateManager || createNpmUpdater({
102
+ currentVersion: VERSION,
103
+ home: cfg.home || os.homedir(),
104
+ enabled: cfg.autoUpdate !== false,
105
+ readonly: bridgeReadonly(),
106
+ ...(cfg.updateSeams || {}),
107
+ });
100
108
  const attachedWs = new Map(); // ws -> session (per il push dei frame files)
101
109
  // selectProvider sceglie UNA volta (startup) il provider external|builtin|disabled
102
110
  // (design §4b/§9b/§9g) e ritorna {mode,reason,fleet}; routes consumano il .fleet,
@@ -251,7 +259,7 @@ function createServer(opts = {}) {
251
259
  // Settings API B2 (design §4b(6)): read-only GET + mutanti lista chiusa per il
252
260
  // wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
253
261
  // READONLY route-level e la redazione token vivono dentro settingsRoutes.
254
- api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions }));
262
+ api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions, updater }));
255
263
  api.get('/topology', async (_req, res) => {
256
264
  try { res.json(await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath })); }
257
265
  catch (e) { res.status(500).json({ error: String(e.message || e) }); }
@@ -283,6 +291,7 @@ function createServer(opts = {}) {
283
291
  app.use('/api', api);
284
292
  app.use('/federation', federation.peerRouter({
285
293
  nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
294
+ roles: () => require('./cli/commands.js').readRoles(cfg.configPath || configJsonPath()),
286
295
  }));
287
296
 
288
297
  // Reverse-proxy single-origin /node/<name>/… (design §4b(2)). Auth locale PRIMA
@@ -314,7 +323,8 @@ function createServer(opts = {}) {
314
323
  // Close the watcher when the HTTP server closes. Registered HERE (inside
315
324
  // createServer) — not in start() — so every createServer consumer is covered,
316
325
  // not only the start() path. watcher.close() is idempotent.
317
- server.on('close', () => { watcher.close(); previews.close(); eventsHub.closeAll(); });
326
+ server.on('listening', () => updater.start());
327
+ server.on('close', () => { watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); });
318
328
  // noServer: gestiamo l'upgrade a mano per instradare /ws (locale) e /node/*
319
329
  // (proxy). Il WS locale resta identico; il proxy WS applica gli STESSI check
320
330
  // dell'HTTP (auth locale -> name strict -> inject token) prima del piping.
@@ -394,7 +404,7 @@ function createServer(opts = {}) {
394
404
  }
395
405
  });
396
406
 
397
- return { app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP };
407
+ return { app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP, updater };
398
408
  }
399
409
 
400
410
  function start(opts = {}) {