@mmmbuto/nexuscrew 0.8.21 → 0.8.22

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.
@@ -16,6 +16,8 @@ const path = require('node:path');
16
16
  const { execFileSync } = require('node:child_process');
17
17
  const store = require('./store.js');
18
18
  const tunnel = require('./tunnel.js');
19
+ const topologyCache = require('./topology-cache.js');
20
+ const inventory = require('./inventory.js');
19
21
  const federation = require('../proxy/federation.js');
20
22
  const { resolvePaths, DEFAULT_PORT } = require('../cli/url.js');
21
23
 
@@ -31,6 +33,32 @@ function resolveNodePaths(opts) {
31
33
  return { home, configDir, configPath, nodesPath };
32
34
  }
33
35
 
36
+ function resolveStoredNode(st, ref) {
37
+ const value = String(ref || '').trim();
38
+ if (!value) return null;
39
+ return st.nodes.find((node) => node.name === value || node.nodeId === value) || null;
40
+ }
41
+
42
+ function loadPeerInventory(opts = {}) {
43
+ const { home, nodesPath } = resolveNodePaths(opts);
44
+ const st = store.loadStore(nodesPath);
45
+ if (!st) return { nodeId: null, nodes: [], peers: [] };
46
+ const direct = store.redactStore(st).nodes;
47
+ const extras = new Map(direct.map((node) => [node.name, {
48
+ tunnel: node.direction === 'inbound'
49
+ ? { status: node.shared === true ? 'shared-peer' : 'private-peer', managed: false }
50
+ : tunnel.readTunnelState(home, node.name),
51
+ }]));
52
+ const cachePath = opts.topologyCachePath || topologyCache.defaultPath(home);
53
+ const cached = topologyCache.loadCache(cachePath);
54
+ const peers = inventory.buildInventory({
55
+ direct,
56
+ topology: cached && Array.isArray(cached.nodes) ? cached.nodes : [],
57
+ extras,
58
+ });
59
+ return { nodeId: st.nodeId, nodes: peers.filter((peer) => peer.kind === 'direct'), peers };
60
+ }
61
+
34
62
  // Prima localPort libera >= base, evitando le porte gia' assegnate (porta STABILE:
35
63
  // una volta scelta resta in nodes.json, non si ricicla).
36
64
  function assignLocalPort(st) {
@@ -187,16 +215,122 @@ function nodesList(opts) {
187
215
  return { code: 0, nodes: [] };
188
216
  }
189
217
 
190
- const view = store.redactStore(st); // MAI il token
191
- const nodes = view.nodes.map((n) => ({ ...n, tunnel: tunnel.readTunnelState(home, n.name) }));
192
- const out = { nodeId: view.nodeId, nodes };
218
+ const out = loadPeerInventory(opts); // MAI il token
219
+ const { nodes, peers } = out;
220
+ const visiblePeers = opts.direct === true ? nodes : peers;
193
221
 
194
- if (opts.json) { log(JSON.stringify(out, null, 2)); return { code: 0, nodes }; }
195
- if (nodes.length === 0) { log('nodes: (nessun nodo)'); return { code: 0, nodes }; }
222
+ if (opts.json) {
223
+ log(JSON.stringify({ ...out, peers: visiblePeers }, null, 2));
224
+ return { code: 0, nodes, peers: visiblePeers };
225
+ }
226
+ if (visiblePeers.length === 0) { log('nodes: (nessun nodo)'); return { code: 0, nodes, peers: visiblePeers }; }
196
227
  for (const n of nodes) {
197
- log(`${n.name}\t${n.ssh}${n.sshPort ? `:${n.sshPort}` : ''}\tlocal:${n.localPort} -> nexus:${n.remotePort}\ttunnel:${n.tunnel.status}\ttoken:${n.hasToken ? 'set' : 'unset'}`);
228
+ const endpoint = n.direction === 'inbound' ? 'client collegato al hub'
229
+ : `${n.ssh}${n.sshPort ? `:${n.sshPort}` : ''} · local:${n.localPort} -> nexus:${n.remotePort}`;
230
+ log(`${n.name}\t${n.nodeId || '-'}\t${n.relation}\t${endpoint}\tstatus:${n.tunnel.status}`);
231
+ }
232
+ for (const n of visiblePeers.filter((peer) => peer.kind === 'transitive')) {
233
+ log(`${n.name}\t${n.nodeId}\trouted\tvia:${n.route.join(' -> ')}\tazioni:inspect`);
234
+ }
235
+ return { code: 0, nodes, peers: visiblePeers };
236
+ }
237
+
238
+ function nodesInspect(opts) {
239
+ const log = opts.log || console.log;
240
+ const out = loadPeerInventory(opts);
241
+ const found = inventory.resolvePeer(out.peers, opts.ref || opts.name);
242
+ if (!found.peer) {
243
+ log(`nodes inspect: ${found.error}`);
244
+ return { code: 1, reason: 'unknown-node' };
198
245
  }
199
- return { code: 0, nodes };
246
+ if (opts.json) log(JSON.stringify(found.peer, null, 2));
247
+ else {
248
+ const peer = found.peer;
249
+ log(`${peer.label || peer.name} (${peer.name})`);
250
+ log(`nodeId: ${peer.nodeId || '-'}`);
251
+ log(`tipo: ${peer.kind} · ${peer.relation}`);
252
+ log(`route: ${peer.route.join(' -> ')}`);
253
+ if (peer.tunnel) log(`status: ${peer.tunnel.status}`);
254
+ log(`azioni: ${Object.keys(peer.actions || {}).filter((key) => peer.actions[key]).join(', ')}`);
255
+ }
256
+ return { code: 0, peer: found.peer };
257
+ }
258
+
259
+ function nodesEdit(opts) {
260
+ const log = opts.log || console.log;
261
+ if (isReadonly(opts)) { log('nodes edit: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
262
+ const { home, nodesPath } = resolveNodePaths(opts);
263
+ let st;
264
+ try { st = store.loadStoreStrict(nodesPath); }
265
+ catch (e) { log(`nodes edit: ${e.message}`); return { code: 1, reason: 'store invalido', status: e.status || 500, errorCode: e.code || null }; }
266
+ const node = resolveStoredNode(st, opts.ref || opts.name);
267
+ if (!node) { log(`nodes edit: nodo sconosciuto "${opts.ref || opts.name || ''}"`); return { code: 1, reason: 'unknown-node' }; }
268
+ const supplied = opts.patch && typeof opts.patch === 'object' ? opts.patch : {};
269
+ const allowed = node.direction === 'inbound'
270
+ ? new Set(['label', 'visibility', 'selected'])
271
+ : new Set(['label', 'ssh', 'sshPort', 'autostart']);
272
+ const keys = Object.keys(supplied);
273
+ const invalid = keys.find((key) => !allowed.has(key));
274
+ if (invalid) {
275
+ log(`nodes edit: campo "${invalid}" non modificabile per un peer ${node.direction}`);
276
+ return { code: 1, reason: 'field-not-editable' };
277
+ }
278
+ if (keys.length === 0) { log('nodes edit: nessuna modifica richiesta'); return { code: 1, reason: 'empty-patch' }; }
279
+ const patch = { ...supplied };
280
+ if (Object.hasOwn(patch, 'label')) {
281
+ if (!store.validLabel(patch.label)) { log('nodes edit: label non valida (max 64 char, niente a capo)'); return { code: 1, reason: 'invalid-label' }; }
282
+ patch.label = patch.label.trim();
283
+ }
284
+ if (Object.hasOwn(patch, 'ssh')) {
285
+ const parsed = store.parseSshTarget(patch.ssh);
286
+ if (!parsed) { log('nodes edit: ssh non valido (atteso user@host o Host alias)'); return { code: 1, reason: 'invalid-ssh' }; }
287
+ patch.ssh = parsed.value;
288
+ }
289
+ if (Object.hasOwn(patch, 'sshPort')) {
290
+ patch.sshPort = Number(patch.sshPort);
291
+ if (!store.isPort(patch.sshPort)) { log('nodes edit: sshPort non valida (1..65535)'); return { code: 1, reason: 'invalid-ssh-port' }; }
292
+ }
293
+ if (Object.hasOwn(patch, 'autostart') && typeof patch.autostart !== 'boolean') {
294
+ log('nodes edit: autostart deve essere booleano'); return { code: 1, reason: 'invalid-autostart' };
295
+ }
296
+ if (Object.hasOwn(patch, 'visibility') && !['network', 'relay-only', 'selected'].includes(patch.visibility)) {
297
+ log('nodes edit: visibility non valida'); return { code: 1, reason: 'invalid-visibility' };
298
+ }
299
+ if (patch.visibility !== 'selected') delete patch.selected;
300
+
301
+ let next;
302
+ try { next = store.updateNode(st, node.name, patch); }
303
+ catch (e) { log(`nodes edit: ${e.message}`); return { code: 1, reason: 'invalid-patch' }; }
304
+
305
+ const transportChanged = node.direction === 'outbound'
306
+ && ['ssh', 'sshPort'].some((key) => Object.hasOwn(patch, key));
307
+ const wasUp = transportChanged && tunnel.readTunnelState(home, node.name).status === 'up';
308
+ try {
309
+ store.atomicWriteStore(nodesPath, next);
310
+ if (wasUp) {
311
+ const stopImpl = opts.stopTunnel || tunnel.stopTunnel;
312
+ const startImpl = opts.startForward || tunnel.startForward;
313
+ stopImpl({ home, name: node.name });
314
+ const started = startImpl({
315
+ home, node: store.getNode(next, node.name), localAppPort: opts.localAppPort,
316
+ spawnImpl: opts.spawnImpl, spawnSyncImpl: opts.spawnSyncImpl, sshBin: opts.sshBin, logFd: opts.logFd,
317
+ });
318
+ if (!started.started && started.reason !== 'already running') throw new Error(started.reason || 'riavvio tunnel fallito');
319
+ }
320
+ } catch (e) {
321
+ try {
322
+ store.atomicWriteStore(nodesPath, st);
323
+ if (wasUp) (opts.startForward || tunnel.startForward)({
324
+ home, node, localAppPort: opts.localAppPort,
325
+ spawnImpl: opts.spawnImpl, spawnSyncImpl: opts.spawnSyncImpl, sshBin: opts.sshBin, logFd: opts.logFd,
326
+ });
327
+ } catch (_) { /* rollback best-effort */ }
328
+ log(`nodes edit: modifica annullata — ${e.message}`);
329
+ return { code: 1, reason: 'write-or-restart-failed' };
330
+ }
331
+ const edited = store.redactNode(store.getNode(next, node.name));
332
+ log(`nodes edit: nodo "${node.name}" aggiornato`);
333
+ return { code: 0, name: node.name, node: edited, restarted: wasUp };
200
334
  }
201
335
 
202
336
  // --- nodes remove ----------------------------------------------------------
@@ -204,10 +338,13 @@ function nodesRemove(opts) {
204
338
  const log = opts.log || console.log;
205
339
  if (isReadonly(opts)) { log('nodes remove: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
206
340
  const { home, nodesPath } = resolveNodePaths(opts);
207
- const name = opts.name;
208
- if (!name) { log('nodes remove: name mancante'); return { code: 1 }; }
341
+ const ref = opts.ref || opts.name;
342
+ if (!ref) { log('nodes remove: riferimento nodo mancante'); return { code: 1 }; }
209
343
  const st = store.loadStore(nodesPath);
210
344
  if (!st) { log('nodes remove: nessun nodes.json valido'); return { code: 1 }; }
345
+ const node = resolveStoredNode(st, ref);
346
+ if (!node) { log(`nodes remove: nodo sconosciuto "${ref}"`); return { code: 1, reason: 'unknown-node' }; }
347
+ const name = node.name;
211
348
  let next;
212
349
  try { next = store.removeNode(st, name); }
213
350
  catch (e) { log(`nodes remove: ${e.message}`); return { code: 1 }; }
@@ -236,11 +373,12 @@ function nodesRemove(opts) {
236
373
  async function nodesTest(opts) {
237
374
  const log = opts.log || console.log;
238
375
  const { home, nodesPath } = resolveNodePaths(opts);
239
- const name = opts.name;
240
- if (!name) { log('nodes test: name mancante'); return { code: 1 }; }
376
+ const ref = opts.ref || opts.name;
377
+ if (!ref) { log('nodes test: riferimento nodo mancante'); return { code: 1 }; }
241
378
  const st = store.loadStore(nodesPath);
242
- const node = st ? store.getNode(st, name) : null;
243
- if (!node) { log(`nodes test: nodo sconosciuto "${name}"`); return { code: 1, result: 'unknown-node' }; }
379
+ const node = st ? resolveStoredNode(st, ref) : null;
380
+ if (!node) { log(`nodes test: nodo sconosciuto "${ref}"`); return { code: 1, result: 'unknown-node' }; }
381
+ const name = node.name;
244
382
 
245
383
  // An inbound peer is reached through the reverse listener owned by sshd on
246
384
  // this hub; there is intentionally no local tunnel pidfile to inspect.
@@ -319,11 +457,11 @@ async function defaultHttpProbe(url, headers) {
319
457
  // --- nodes up/down/restart (lifecycle tunnel, NON tocca la config) ----------
320
458
  function loadNodeOrFail(opts, log) {
321
459
  const { home, nodesPath } = resolveNodePaths(opts);
322
- const name = opts.name;
323
- if (!name) { log('nodes: name mancante'); return null; }
460
+ const ref = opts.ref || opts.name;
461
+ if (!ref) { log('nodes: riferimento nodo mancante'); return null; }
324
462
  const st = store.loadStore(nodesPath);
325
- const node = st ? store.getNode(st, name) : null;
326
- if (!node) { log(`nodes: nodo sconosciuto "${name}"`); return null; }
463
+ const node = st ? resolveStoredNode(st, ref) : null;
464
+ if (!node) { log(`nodes: nodo sconosciuto "${ref}"`); return null; }
327
465
  return { home, nodesPath, store: st, node };
328
466
  }
329
467
 
@@ -336,6 +474,7 @@ function persistAutostart(ctx, enabled) {
336
474
 
337
475
  function nodesUp(opts) {
338
476
  const log = opts.log || console.log;
477
+ if (isReadonly(opts)) { log('nodes up: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
339
478
  const ctx = loadNodeOrFail(opts, log);
340
479
  if (!ctx) return { code: 1 };
341
480
  if (ctx.node.direction === 'inbound') return { code: 0, started: false, inbound: true };
@@ -359,6 +498,7 @@ function nodesUp(opts) {
359
498
 
360
499
  function nodesDown(opts) {
361
500
  const log = opts.log || console.log;
501
+ if (isReadonly(opts)) { log('nodes down: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
362
502
  const ctx = loadNodeOrFail(opts, log);
363
503
  if (!ctx) return { code: 1 };
364
504
  if (ctx.node.direction === 'inbound') return { code: 0, stopped: false, inbound: true };
@@ -373,6 +513,7 @@ function nodesDown(opts) {
373
513
 
374
514
  function nodesRestart(opts) {
375
515
  const log = opts.log || console.log;
516
+ if (isReadonly(opts)) { log('nodes restart: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
376
517
  const ctx = loadNodeOrFail(opts, log);
377
518
  if (!ctx) return { code: 1 };
378
519
  if (ctx.node.direction === 'inbound') return { code: 0, started: false, inbound: true };
@@ -408,9 +549,9 @@ function nodesSetToken(opts) {
408
549
  }
409
550
 
410
551
  module.exports = {
411
- nodesAdd, nodesList, nodesRemove, nodesTest,
552
+ nodesAdd, nodesList, nodesInspect, nodesEdit, nodesRemove, nodesTest,
412
553
  nodesUp, nodesDown, nodesRestart, nodesSetToken,
413
554
  // helper esposti per test/riuso
414
555
  assignLocalPort, bindLocalPort, reserveLocalPort, defaultKeyPath, ensureKey, readSecretToken,
415
- resolveNodePaths, defaultHttpProbe,
556
+ resolveNodePaths, resolveStoredNode, loadPeerInventory, defaultHttpProbe,
416
557
  };
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ // Canonical peer inventory shared by HTTP, CLI and the PWA. A direct peer is
4
+ // backed by nodes.json and can therefore expose management actions. A routed
5
+ // peer comes from topology-cache.json: it is visible and inspectable, but it
6
+ // must never pretend to support mutations on this installation.
7
+
8
+ function actionsFor(node, kind = 'direct') {
9
+ if (kind !== 'direct') return { inspect: true };
10
+ const outbound = node.direction !== 'inbound';
11
+ return {
12
+ inspect: true,
13
+ edit: true,
14
+ remove: true,
15
+ test: true,
16
+ ...(outbound ? {
17
+ connect: true,
18
+ disconnect: true,
19
+ restart: true,
20
+ share: true,
21
+ } : {
22
+ visibility: true,
23
+ }),
24
+ };
25
+ }
26
+
27
+ function directPeer(node, extra = {}) {
28
+ const direction = node.direction || 'outbound';
29
+ return {
30
+ ...node,
31
+ ...extra,
32
+ kind: 'direct',
33
+ relation: direction === 'inbound' ? 'client' : 'hub',
34
+ manageable: true,
35
+ route: [node.name],
36
+ hops: 1,
37
+ actions: actionsFor(node, 'direct'),
38
+ };
39
+ }
40
+
41
+ function routedPeer(entry) {
42
+ const route = Array.isArray(entry.route) ? [...entry.route] : [];
43
+ return {
44
+ name: entry.name,
45
+ nodeId: entry.instanceId,
46
+ instanceId: entry.instanceId,
47
+ kind: 'transitive',
48
+ relation: 'routed',
49
+ manageable: false,
50
+ route,
51
+ hops: route.length,
52
+ lastSeen: entry.lastSeen,
53
+ stale: entry.stale === true,
54
+ actions: actionsFor(entry, 'transitive'),
55
+ };
56
+ }
57
+
58
+ function buildInventory({ direct = [], topology = [], extras = new Map() } = {}) {
59
+ const directIds = new Set(direct.map((node) => node && node.nodeId).filter(Boolean));
60
+ const peers = direct.map((node) => directPeer(node, extras.get(node.name) || {}));
61
+ for (const entry of topology) {
62
+ if (!entry || directIds.has(entry.instanceId)) continue;
63
+ peers.push(routedPeer(entry));
64
+ }
65
+ return peers;
66
+ }
67
+
68
+ function resolvePeer(peers, ref) {
69
+ const value = String(ref || '').trim();
70
+ if (!value) return { error: 'riferimento nodo mancante' };
71
+ const byId = peers.filter((peer) => peer.nodeId === value || peer.instanceId === value);
72
+ if (byId.length === 1) return { peer: byId[0] };
73
+ const byName = peers.filter((peer) => peer.name === value);
74
+ if (byName.length === 1) return { peer: byName[0] };
75
+ if (byId.length > 1 || byName.length > 1) {
76
+ return { error: `riferimento ambiguo "${value}": usa il nodeId` };
77
+ }
78
+ return { error: `nodo sconosciuto "${value}"` };
79
+ }
80
+
81
+ module.exports = { actionsFor, directPeer, routedPeer, buildInventory, resolvePeer };
package/lib/server.js CHANGED
@@ -27,6 +27,8 @@ const { fsRoutes } = require('./fs/routes.js');
27
27
  const nodesStore = require('./nodes/store.js');
28
28
  const nodesTunnel = require('./nodes/tunnel.js');
29
29
  const nodesHealth = require('./nodes/health.js');
30
+ const nodesInventory = require('./nodes/inventory.js');
31
+ const topologyCache = require('./nodes/topology-cache.js');
30
32
  const { createNodeProxy, handleNodeUpgrade } = require('./proxy/node-proxy.js');
31
33
  const federation = require('./proxy/federation.js');
32
34
  const { settingsRoutes, publicPeeringRoutes } = require('./settings/routes.js');
@@ -107,8 +109,8 @@ function createServer(opts = {}) {
107
109
  ...(cfg.updateSeams || {}),
108
110
  });
109
111
  const attachedWs = new Map(); // ws -> session (per il push dei frame files)
110
- // selectProvider sceglie UNA volta (startup) il provider external|builtin|disabled
111
- // (design §4b/§9b/§9g) e ritorna {mode,reason,fleet}; routes consumano il .fleet,
112
+ // selectProvider sceglie UNA volta (startup) builtin|disabled e ritorna
113
+ // {mode,reason,fleet}; routes consumano il .fleet,
112
114
  // quindi fleetP resta una Promise<Fleet> (createServer non diventa async).
113
115
  const fleetP = selectProvider(cfg).then((p) => p.fleet);
114
116
 
@@ -119,7 +121,7 @@ function createServer(opts = {}) {
119
121
  const nodesPath = cfg.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
120
122
  // fetch usata dai probe di salute federati (iniettabile nei test); default globale.
121
123
  const healthFetch = cfg.fetchImpl || fetch;
122
- const topologyCachePath = cfg.topologyCachePath || require('./nodes/topology-cache.js').defaultPath(cfg.home || os.homedir());
124
+ const topologyCachePath = cfg.topologyCachePath || topologyCache.defaultPath(cfg.home || os.homedir());
123
125
  const decksPath = cfg.decksPath || decksStore.defaultDecksPath(cfg.home || os.homedir());
124
126
  const proxyReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
125
127
  function resolveNode(name) {
@@ -292,6 +294,32 @@ function createServer(opts = {}) {
292
294
  res.json({ nodeId: view.nodeId, nodes });
293
295
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
294
296
  });
297
+ // Canonical management inventory: direct hubs/clients plus routed peers. The
298
+ // latter are deliberately inspect-only because they have no local nodes.json
299
+ // record to mutate. UI and CLI consume the same action matrix.
300
+ api.get('/peers', async (_req, res) => {
301
+ try {
302
+ const st = nodesStore.loadStore(nodesPath);
303
+ if (!st) return res.json({ nodeId: null, peers: [] });
304
+ const direct = nodesStore.redactStore(st).nodes;
305
+ const healths = await nodesHealth.nodesHealth({
306
+ nodes: st.nodes, home: cfg.home || os.homedir(), fetchImpl: healthFetch, now: Date.now(),
307
+ });
308
+ const extras = new Map(direct.map((node, index) => {
309
+ const health = healths[index] || null;
310
+ return [node.name, { health, tunnel: nodesHealth.tunnelFromHealth(health) }];
311
+ }));
312
+ const topology = await federation.collectLocalTopology({
313
+ nodesPath, cachePath: topologyCachePath, fetchImpl: healthFetch,
314
+ });
315
+ const peers = nodesInventory.buildInventory({
316
+ direct,
317
+ topology: topology && Array.isArray(topology.nodes) ? topology.nodes : [],
318
+ extras,
319
+ });
320
+ res.json({ nodeId: st.nodeId, peers });
321
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
322
+ });
295
323
  // Settings API B2 (design §4b(6)): read-only GET + mutanti lista chiusa per il
296
324
  // wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
297
325
  // READONLY route-level e la redazione token vivono dentro settingsRoutes.
@@ -65,6 +65,7 @@ const { publicPeeringRoutes } = require('./public-peering-routes.js');
65
65
  const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate']);
66
66
  const ROLE_KEYS = new Set(['client', 'node']);
67
67
  const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
68
+ const EDIT_KEYS = new Set(['label', 'ssh', 'sshPort', 'autostart', 'visibility', 'selected']);
68
69
 
69
70
  // Default sensato per il "nome dispositivo" proposto nei form (pairing/invite).
70
71
  // NON usa ciecamente hostname: se l'hostname e' vuoto o 'localhost' (tipico di
@@ -186,6 +187,21 @@ function settingsRoutes(deps = {}) {
186
187
  });
187
188
 
188
189
  const validName = (name) => typeof name === 'string' && nodesStore.NODE_NAME_RE.test(name);
190
+ const applyNodeEdit = (res, name, patch) => {
191
+ const cap = capture();
192
+ const out = nodesCmds.nodesEdit({ ...cliOpts(cap), ref: name, patch });
193
+ if (out.code === 0) return send(res, 200, {
194
+ saved: true, name: out.name, node: out.node, restarted: out.restarted === true,
195
+ });
196
+ const message = lastLine(cap, 'nodes edit fallito');
197
+ if (out.reason === 'readonly') return send(res, 403, { error: message });
198
+ if (out.reason === 'unknown-node') return send(res, 404, { error: message });
199
+ if (out.reason === 'store invalido') {
200
+ return send(res, out.status || 503, { error: message, ...(out.errorCode ? { code: out.errorCode } : {}) });
201
+ }
202
+ const status = out.reason === 'write-or-restart-failed' ? 502 : 400;
203
+ return send(res, status, { error: message });
204
+ };
189
205
 
190
206
  // --- GET / — vista read-only per wizard/settings UI -----------------------
191
207
  r.get('/', (_req, res) => {
@@ -459,6 +475,21 @@ function settingsRoutes(deps = {}) {
459
475
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
460
476
  });
461
477
 
478
+ // General direct-peer edit endpoint used by both the PWA and headless CLI.
479
+ // Stable identity fields (name/nodeId/ports/capabilities) are not writable;
480
+ // nodesEdit applies the direction-aware allowlist and tunnel rollback.
481
+ r.patch('/nodes/:name', mutGate, (req, res) => {
482
+ const name = String(req.params.name || '');
483
+ const body = req.body;
484
+ if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
485
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
486
+ return send(res, 400, { error: 'body deve essere un oggetto JSON' });
487
+ }
488
+ const invalid = Object.keys(body).find((key) => !EDIT_KEYS.has(key));
489
+ if (invalid) return send(res, 400, { error: `campo non modificabile: "${invalid}"` });
490
+ return applyNodeEdit(res, name, body);
491
+ });
492
+
462
493
  // --- POST /nodes/:name/test — non-mutante (POST per coerenza azione) -------
463
494
  // NON gated READONLY: e' una probe diagnostica, coerente col `nodes test` CLI.
464
495
  r.post('/nodes/:name/test', async (req, res) => {
@@ -604,32 +635,21 @@ function settingsRoutes(deps = {}) {
604
635
  });
605
636
 
606
637
  r.patch('/nodes/:name/visibility', mutGate, (req, res) => {
607
- try {
608
- const name = String(req.params.name || '');
609
- const visibility = req.body && req.body.visibility;
610
- const selected = (req.body && req.body.selected) || [];
611
- if (!validName(name) || !['network', 'relay-only', 'selected'].includes(visibility)) {
612
- return send(res, 400, { error: 'visibility non valida' });
613
- }
614
- let st = nodesStore.loadStoreStrict(nodesPath);
615
- st = nodesStore.updateNode(st, name, { visibility, selected: visibility === 'selected' ? selected : [] });
616
- nodesStore.atomicWriteStore(nodesPath, st);
617
- send(res, 200, { saved: true, name, visibility });
618
- } catch (e) { send(res, e.status || 400, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
638
+ const name = String(req.params.name || '');
639
+ const visibility = req.body && req.body.visibility;
640
+ const selected = (req.body && req.body.selected) || [];
641
+ if (!validName(name) || !['network', 'relay-only', 'selected'].includes(visibility)) {
642
+ return send(res, 400, { error: 'visibility non valida' });
643
+ }
644
+ return applyNodeEdit(res, name, { visibility, selected: visibility === 'selected' ? selected : [] });
619
645
  });
620
646
  // Rinomina la label umana di un nodo SENZA toccare il name (route/URL stabili).
621
647
  r.patch('/nodes/:name/label', mutGate, (req, res) => {
622
- try {
623
- const name = String(req.params.name || '');
624
- const label = req.body && req.body.label;
625
- if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
626
- if (!nodesStore.validLabel(label)) return send(res, 400, { error: 'label non valida (max 64 char, niente a capo)' });
627
- let st = nodesStore.loadStoreStrict(nodesPath);
628
- if (!nodesStore.getNode(st, name)) return send(res, 404, { error: `nodo sconosciuto "${name}"` });
629
- st = nodesStore.updateNode(st, name, { label: nodesStore.sanitizeLabel(label, name) });
630
- nodesStore.atomicWriteStore(nodesPath, st);
631
- send(res, 200, { saved: true, name, label: nodesStore.nodeLabel(nodesStore.getNode(st, name)) });
632
- } catch (e) { send(res, e.status || 400, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
648
+ const name = String(req.params.name || '');
649
+ const label = req.body && req.body.label;
650
+ if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
651
+ if (!nodesStore.validLabel(label)) return send(res, 400, { error: 'label non valida (max 64 char, niente a capo)' });
652
+ return applyNodeEdit(res, name, { label });
633
653
  });
634
654
 
635
655
  // Legacy compatibility endpoint: the old node-role/rendezvous flow opened a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.21",
3
+ "version": "0.8.22",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {