@mmmbuto/nexuscrew 0.8.19 → 0.8.20

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.
@@ -46,7 +46,7 @@ function publicPeeringRoutes(deps = {}) {
46
46
  if (!st || !nodesStore.NODE_ID_RE.test(st.nodeId)) return res.status(503).json({ error: 'identita nodo non disponibile' });
47
47
  return res.json({ ok: true, instanceId: st.nodeId, proof });
48
48
  });
49
- r.post('/join', (req, res) => {
49
+ r.post('/join', async (req, res) => {
50
50
  const key = `join:${String(req.socket && req.socket.remoteAddress || 'local')}`;
51
51
  const now = Date.now();
52
52
  const recent = (attempts.get(key) || []).filter((x) => now - x < 60_000);
@@ -65,21 +65,42 @@ function publicPeeringRoutes(deps = {}) {
65
65
  if (b.label !== undefined && !nodesStore.validLabel(b.label)) {
66
66
  return res.status(400).json({ error: 'label non valida' });
67
67
  }
68
- if (!peering.consumeInvite({ invitesPath, invite: b.invite })) return res.status(410).json({ error: 'invito scaduto o gia usato' });
68
+ // Validate the one-time capability before conflict checks without burning
69
+ // it: a duplicate/stale peer must not destroy an otherwise reusable invite.
70
+ if (!peering.hasInvite({ invitesPath, invite: b.invite, now })) return res.status(410).json({ error: 'invito scaduto o gia usato' });
71
+ let credential = null;
69
72
  try {
70
- const st = nodesStore.loadOrInitStore(nodesPath);
71
- if (st.nodeId === b.instanceId || st.nodes.some((n) => n.nodeId === b.instanceId)) return res.status(409).json({ error: 'peer duplicato' });
72
- let name = b.name;
73
- for (let i = 2; nodesStore.getNode(st, name); i += 1) name = `${b.name.slice(0, 28)}-${i}`;
74
- const reversePort = peering.allocateReversePort(st.nodes);
75
- const credential = peering.createPending({ pendingPath, data: {
73
+ const st = nodesStore.loadStoreStrict(nodesPath);
74
+ const pending = peering.readInvites(pendingPath, now);
75
+ if (st.nodeId === b.instanceId || st.nodes.some((n) => n.nodeId === b.instanceId)
76
+ || pending.some((row) => row.instanceId === b.instanceId)) return res.status(409).json({ error: 'peer duplicato' });
77
+ const reservedNames = new Set(pending.map((row) => row.name).filter(validPeerName));
78
+ if (nodesStore.getNode(st, b.name) || reservedNames.has(b.name)) {
79
+ return res.status(409).json({
80
+ error: `nome peer gia' in uso: ${b.name}`,
81
+ code: 'peer-name-conflict',
82
+ hint: 'rimuovi il record stale oppure scegli un nome univoco e riprova con lo stesso invito',
83
+ });
84
+ }
85
+ const name = b.name;
86
+ const reversePort = await peering.allocateAvailableReversePort(st.nodes, pending, {
87
+ ...(deps.createServerImpl ? { createServerImpl: deps.createServerImpl } : {}),
88
+ });
89
+ credential = peering.createPending({ pendingPath, data: {
76
90
  name, remotePort: b.port, reversePort, instanceId: b.instanceId, acceptToken: b.acceptToken,
77
91
  shared: false,
78
92
  label: nodesStore.sanitizeLabel(b.label, name),
79
93
  ...(peerRoles ? { roles: { ...peerRoles, node: false }, rolesKnown: true } : { rolesKnown: false }),
80
94
  } });
95
+ if (!peering.consumeInvite({ invitesPath, invite: b.invite, now })) {
96
+ peering.consumePending({ pendingPath, credential, now });
97
+ return res.status(410).json({ error: 'invito scaduto o gia usato' });
98
+ }
81
99
  res.json({ paired: true, instanceId: st.nodeId, reversePort, credential, roles: readRoles(configPath) });
82
- } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
100
+ } catch (e) {
101
+ if (credential) try { peering.consumePending({ pendingPath, credential, now }); } catch (_) {}
102
+ res.status(e.status || 500).json({ error: String(e.message || e), ...(e.code ? { code: e.code } : {}) });
103
+ }
83
104
  });
84
105
  r.post('/confirm', (req, res) => {
85
106
  const b = req.body || {};
@@ -91,8 +112,15 @@ function publicPeeringRoutes(deps = {}) {
91
112
  return res.status(410).json({ error: 'pairing pending scaduto o gia usato' });
92
113
  }
93
114
  try {
94
- let st = nodesStore.loadOrInitStore(nodesPath);
115
+ let st = nodesStore.loadStoreStrict(nodesPath);
95
116
  if (st.nodeId === pending.instanceId || st.nodes.some((n) => n.nodeId === pending.instanceId)) return res.status(409).json({ error: 'peer duplicato' });
117
+ if (st.nodes.some((n) => n.name === pending.name || n.localPort === pending.reversePort)) {
118
+ return res.status(409).json({
119
+ error: 'allocazione pairing non piu disponibile',
120
+ code: 'pairing-allocation-conflict',
121
+ hint: 'ripeti il pairing: verra negoziata una nuova porta reverse',
122
+ });
123
+ }
96
124
  st = nodesStore.addNode(st, {
97
125
  name: pending.name, remotePort: pending.remotePort, localPort: pending.reversePort,
98
126
  direction: 'inbound', transport: 'inbound', autostart: true,
@@ -104,7 +132,7 @@ function publicPeeringRoutes(deps = {}) {
104
132
  });
105
133
  nodesStore.atomicWriteStore(nodesPath, st);
106
134
  res.json({ confirmed: true });
107
- } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
135
+ } catch (e) { res.status(e.status || 500).json({ error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
108
136
  });
109
137
  r.post('/cancel', (req, res) => {
110
138
  const b = req.body || {};
@@ -47,7 +47,7 @@ const nodesStore = require('../nodes/store.js');
47
47
  const nodesCmds = require('../nodes/commands.js');
48
48
  const nodesTunnel = require('../nodes/tunnel.js');
49
49
  const peering = require('../nodes/peering.js');
50
- const { waitForHealthyPeer } = require('../proxy/federation.js');
50
+ const { waitForHealthyPeer, notifyHubShare } = require('../proxy/federation.js');
51
51
  const { rotateToken } = require('../auth/token.js');
52
52
  const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
53
53
  const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
@@ -215,7 +215,7 @@ function settingsRoutes(deps = {}) {
215
215
  };
216
216
  if (updateStatus) out.update = updateStatus;
217
217
  send(res, 200, out);
218
- } catch (e) { send(res, 500, { error: String(e.message || e) }); }
218
+ } catch (e) { send(res, e.status || 500, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
219
219
  });
220
220
 
221
221
  // --- POST /config — scrittura atomica, subset whitelisted ------------------
@@ -333,7 +333,7 @@ function settingsRoutes(deps = {}) {
333
333
  try {
334
334
  const b = req.body || {};
335
335
  const label = nodesStore.sanitizeLabel(b.label, defaultDeviceName());
336
- const st = nodesStore.loadOrInitStore(nodesPath);
336
+ const st = nodesStore.loadStoreStrict(nodesPath);
337
337
  const extra = {};
338
338
  if (typeof b.ssh === 'string' && b.ssh.trim()) {
339
339
  const ssh = nodesStore.parseSshTarget(b.ssh.trim());
@@ -359,7 +359,7 @@ function settingsRoutes(deps = {}) {
359
359
  send(res, 200, peering.createInvite({
360
360
  invitesPath, instanceId: st.nodeId, port: remotePort, linkPort: runtimePort(), label, ...extra,
361
361
  }));
362
- } catch (e) { send(res, 500, { error: String(e.message || e) }); }
362
+ } catch (e) { send(res, e.status || 500, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
363
363
  });
364
364
 
365
365
  // --- POST /nodes/pair — hydra join in stadi espliciti ---------------------
@@ -413,7 +413,7 @@ function settingsRoutes(deps = {}) {
413
413
  // impossibile (validata sopra), ma non facciamo mai fallire l'add per lei.
414
414
  if (b.label !== undefined) {
415
415
  try {
416
- let st = nodesStore.loadOrInitStore(nodesPath);
416
+ let st = nodesStore.loadStoreStrict(nodesPath);
417
417
  st = nodesStore.updateNode(st, out.name, { label: nodesStore.sanitizeLabel(b.label, out.name) });
418
418
  nodesStore.atomicWriteStore(nodesPath, st);
419
419
  } catch (_) { /* best-effort: il nodo e' gia' creato */ }
@@ -435,7 +435,11 @@ function settingsRoutes(deps = {}) {
435
435
  const status = /duplicato|self-reference/.test(msg) ? 409 : 400;
436
436
  return send(res, status, { error: msg });
437
437
  }
438
- // store invalido / keygen fallita / write fallita: errore lato server.
438
+ // Store mancante/invalido e' indisponibilita operativa, mai un invito a
439
+ // ricrearlo implicitamente. Gli altri errori restano 500.
440
+ if (out.reason === 'store invalido') {
441
+ return send(res, out.status || 503, { error: msg, ...(out.errorCode ? { code: out.errorCode } : {}) });
442
+ }
439
443
  return send(res, 500, { error: msg });
440
444
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
441
445
  });
@@ -522,25 +526,15 @@ function settingsRoutes(deps = {}) {
522
526
  if (body.shared && !nodesStore.isPort(node.reversePort)) {
523
527
  return send(res, 409, { error: 'canale share non negoziato: ripeti il pairing' });
524
528
  }
525
- if (node.shared === body.shared) return send(res, 200, { name, shared: body.shared, unchanged: true });
526
-
529
+ const wasShared = node.shared === true;
530
+ let persistedDesired = wasShared;
527
531
  const fetchImpl = seams.fetchImpl || fetch;
528
- const notifyHub = async (shared) => {
529
- const ctrl = new AbortController();
530
- const timer = setTimeout(() => ctrl.abort(), 5000);
531
- try {
532
- const response = await fetchImpl(`http://127.0.0.1:${node.localPort}/federation/share`, {
533
- method: 'POST', signal: ctrl.signal,
534
- headers: { authorization: `Bearer ${node.token}`, 'content-type': 'application/json' },
535
- body: JSON.stringify({ shared }),
536
- });
537
- if (!response.ok) throw new Error(`hub HTTP ${response.status}`);
538
- } finally { clearTimeout(timer); }
539
- };
532
+ const notifyHub = (shared) => notifyHubShare({ node, shared, fetchImpl, timeoutMs: 5000 });
540
533
  const applyLocal = async (shared) => {
541
- st = nodesStore.loadOrInitStore(nodesPath);
534
+ st = nodesStore.loadStoreStrict(nodesPath);
542
535
  st = nodesStore.updateNode(st, name, { shared });
543
536
  nodesStore.atomicWriteStore(nodesPath, st);
537
+ persistedDesired = shared;
544
538
  node = nodesStore.getNode(st, name);
545
539
  nodesTunnel.stopTunnel({ home, name });
546
540
  const started = nodesTunnel.startForward({
@@ -563,23 +557,31 @@ function settingsRoutes(deps = {}) {
563
557
  };
564
558
 
565
559
  try {
560
+ if (node.shared === body.shared) {
561
+ await notifyHub(body.shared);
562
+ return send(res, 200, { name, shared: body.shared, unchanged: true, reconciled: true });
563
+ }
566
564
  if (body.shared) {
567
565
  await applyLocal(true);
568
566
  await notifyHub(true);
569
567
  } else {
570
- // Revoke at the hub while -R is still alive, then remove -R locally.
571
- await notifyHub(false);
568
+ // Persist OFF first. If the process dies before the hub ACK, the next
569
+ // boot starts only -L and reconciles the stored false state.
572
570
  await applyLocal(false);
571
+ await notifyHub(false);
573
572
  }
574
573
  return send(res, 200, { name, shared: body.shared });
575
574
  } catch (e) {
576
575
  // Share-on is transactional: a failed hub acknowledgement returns to the
577
576
  // safe private -L-only state. Never include remote response bodies/tokens.
578
- if (body.shared) {
577
+ if (body.shared && !wasShared) {
579
578
  try { await applyLocal(false); } catch (_) { /* best-effort safe rollback */ }
580
579
  }
580
+ const offPersisted = body.shared === false && persistedDesired === false;
581
581
  return send(res, 502, {
582
- error: body.shared ? 'Share non attivato' : 'Share non disattivato',
582
+ error: body.shared ? 'Share non attivato'
583
+ : offPersisted ? 'Share disattivato localmente; hub non riconciliato' : 'Share non disattivato',
584
+ ...(offPersisted ? { shared: false, reconcilePending: true } : {}),
583
585
  detail: String(e && e.message || e).replace(/Bearer\s+\S+/gi, 'Bearer ***'),
584
586
  });
585
587
  }
@@ -593,11 +595,11 @@ function settingsRoutes(deps = {}) {
593
595
  if (!validName(name) || !['network', 'relay-only', 'selected'].includes(visibility)) {
594
596
  return send(res, 400, { error: 'visibility non valida' });
595
597
  }
596
- let st = nodesStore.loadOrInitStore(nodesPath);
598
+ let st = nodesStore.loadStoreStrict(nodesPath);
597
599
  st = nodesStore.updateNode(st, name, { visibility, selected: visibility === 'selected' ? selected : [] });
598
600
  nodesStore.atomicWriteStore(nodesPath, st);
599
601
  send(res, 200, { saved: true, name, visibility });
600
- } catch (e) { send(res, 400, { error: String(e.message || e) }); }
602
+ } catch (e) { send(res, e.status || 400, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
601
603
  });
602
604
  // Rinomina la label umana di un nodo SENZA toccare il name (route/URL stabili).
603
605
  r.patch('/nodes/:name/label', mutGate, (req, res) => {
@@ -606,12 +608,12 @@ function settingsRoutes(deps = {}) {
606
608
  const label = req.body && req.body.label;
607
609
  if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
608
610
  if (!nodesStore.validLabel(label)) return send(res, 400, { error: 'label non valida (max 64 char, niente a capo)' });
609
- let st = nodesStore.loadOrInitStore(nodesPath);
611
+ let st = nodesStore.loadStoreStrict(nodesPath);
610
612
  if (!nodesStore.getNode(st, name)) return send(res, 404, { error: `nodo sconosciuto "${name}"` });
611
613
  st = nodesStore.updateNode(st, name, { label: nodesStore.sanitizeLabel(label, name) });
612
614
  nodesStore.atomicWriteStore(nodesPath, st);
613
615
  send(res, 200, { saved: true, name, label: nodesStore.nodeLabel(nodesStore.getNode(st, name)) });
614
- } catch (e) { send(res, 400, { error: String(e.message || e) }); }
616
+ } catch (e) { send(res, e.status || 400, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
615
617
  });
616
618
 
617
619
  // Legacy compatibility endpoint: the old node-role/rendezvous flow opened a
package/lib/tmux/list.js CHANGED
@@ -52,6 +52,15 @@ function parseSessions(raw) {
52
52
  .filter((session) => session.windows > 0);
53
53
  }
54
54
 
55
+ // tmux uses different expected "no server" errors across platforms. Linux
56
+ // commonly prints "no server running" while macOS reports the missing socket
57
+ // path. Both mean an empty inventory, not a broken NexusCrew node.
58
+ function isNoTmuxServerError(raw) {
59
+ const message = String(raw || '');
60
+ return /no server running/i.test(message)
61
+ || /error connecting to .*(?:No such file or directory|Connection refused)/i.test(message);
62
+ }
63
+
55
64
  function setSessionVisibility(tmuxBin = 'tmux', name, technical = false) {
56
65
  return new Promise((resolve, reject) => {
57
66
  if (typeof name !== 'string' || !/^[\w.@%:+-]{1,128}$/.test(name) || name.startsWith('-')) {
@@ -73,7 +82,7 @@ function listSessions(tmuxBin = 'tmux') {
73
82
  return new Promise((resolve, reject) => {
74
83
  execFile(tmuxBin, ['list-sessions', '-F', FMT], (err, stdout, stderr) => {
75
84
  if (err) {
76
- if (/no server running/i.test(stderr || '')) return resolve([]);
85
+ if (isNoTmuxServerError(stderr || err.message)) return resolve([]);
77
86
  return reject(new Error(`tmux list-sessions failed: ${stderr || err.message}`));
78
87
  }
79
88
  resolve(parseSessions(stdout));
@@ -95,5 +104,5 @@ function attachedClients(tmuxBin = 'tmux', session) {
95
104
  }
96
105
 
97
106
  module.exports = {
98
- parsePaneTitle, parseSessions, listSessions, attachedClients, setSessionVisibility, FMT,
107
+ parsePaneTitle, parseSessions, isNoTmuxServerError, listSessions, attachedClients, setSessionVisibility, FMT,
99
108
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.19",
3
+ "version": "0.8.20",
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": {