@mmmbuto/nexuscrew 0.9.64 → 0.9.66

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.
package/lib/server.js CHANGED
@@ -58,6 +58,7 @@ const { createCellScopeGuard } = require('./cells/scope-guard.js');
58
58
  const { createCellScope } = require('./cells/scope.js');
59
59
  const { cellIdFromTmuxSession } = require('./fleet/definitions.js');
60
60
  const { createOwnerAskScope } = require('./notify/owner-ask-scope.js');
61
+ const askCell = require('./notify/ask-cell.js');
61
62
  // Budget a tre finestre, riusato dalle notifiche federate: e' gia' scritto per
62
63
  // limitare il danno di un nodo che mente sulla propria cella (il tetto per
63
64
  // target si calcola sul NODO, che la catena visited verifica).
@@ -91,7 +92,11 @@ const { createEventFeedClient } = require('./notify/event-feed-client.js');
91
92
  const { createAskReceipts } = require('./notify/ask-receipts.js');
92
93
  const { createAskAnswerService } = require('./notify/ask-answer-service.js');
93
94
  const { createEventFeedAsksRoutes } = require('./notify/event-feed-asks-routes.js');
95
+ const { createEventFeedNoticesRoutes } = require('./notify/event-feed-notices-routes.js');
96
+ const { createNoticeDismissals } = require('./notify/notice-dismissals.js');
94
97
  const { createAskRelay } = require('./notify/ask-relay.js');
98
+ const { createNoticeRelay } = require('./notify/notice-relay.js');
99
+ const { createNoticeDismissalQueue, createNoticeDismissalDrainer } = require('./notify/notice-dismissal-queue.js');
95
100
  const { createImportedDismissalDrainer } = require('./notify/imported-dismissals.js');
96
101
  const { createAskSlotVerifier } = require('./nodes/ask-slot-verifier.js');
97
102
  const { readFederationEventsEnabled } = require('./config.js');
@@ -221,6 +226,12 @@ function createServer(opts = {}) {
221
226
  const feedHistory = createEventFeedHistory({
222
227
  filePath: path.join(notifyDir, 'event-feed-history.json'),
223
228
  });
229
+ // Notices the OWNER cleared: ids and timestamps only, expiring with the
230
+ // history they filter. This is what makes a cleanup true on every device —
231
+ // the owner's snapshot stops carrying the entry, so a reload cannot revive it.
232
+ const noticeDismissals = createNoticeDismissals({
233
+ filePath: path.join(notifyDir, 'notice-dismissals.json'),
234
+ });
224
235
  // Kill switch: strict boolean, read FRESH on every use so flipping it in
225
236
  // config.json acts on live streams without a restart.
226
237
  const eventsEnabled = () => readFederationEventsEnabled({ configJsonPath: cfg.configPath, federationEventsEnabled: cfg.federationEventsEnabled });
@@ -1340,6 +1351,33 @@ function createServer(opts = {}) {
1340
1351
  cellForSession: ownerAskScope.cellForSession,
1341
1352
  readonly: proxyReadonly,
1342
1353
  }));
1354
+ // Owner-side notice dismissals: the same action gate and the same chain as
1355
+ // the ask write-back, with its own rate instance. Mounted beside the asks,
1356
+ // before the feed router, because it is a write-back path of the feed.
1357
+ api.use('/event-feed/notices', createEventFeedNoticesRoutes({
1358
+ nodesPath,
1359
+ localNodeId: federatedNodeId,
1360
+ originResolver: createOriginResolver({
1361
+ localNodeId: federatedNodeId,
1362
+ hopSecret: () => hopSecret,
1363
+ }),
1364
+ eventsEnabled,
1365
+ history: feedHistory,
1366
+ dismissals: noticeDismissals,
1367
+ // The closure inherits scope and cell of the original notice, so it is
1368
+ // delivered to exactly the peers that could see it. It enters the history
1369
+ // too: a peer that missed the live frame recovers it from the snapshot,
1370
+ // where toRemoteNotice drops it by type.
1371
+ closeNotice: ({ scope, cellId, eventId }) => {
1372
+ recordAndPublish({
1373
+ producer: scope === 'node' ? 'notify-closed-node' : 'notify-closed',
1374
+ cellId: scope === 'node' ? null : cellId,
1375
+ payload: { eventId, ts: Date.now() },
1376
+ });
1377
+ },
1378
+ readonly: proxyReadonly,
1379
+ log: opts.log || console.log,
1380
+ }));
1343
1381
  api.use('/event-feed', createEventFeedRoutes({
1344
1382
  nodesPath,
1345
1383
  localNodeId: federatedNodeId,
@@ -1349,6 +1387,7 @@ function createServer(opts = {}) {
1349
1387
  }),
1350
1388
  eventFeed,
1351
1389
  history: feedHistory,
1390
+ noticeDismissals,
1352
1391
  asksStore,
1353
1392
  fleetP,
1354
1393
  cellForSession: ownerAskScope.cellForSession,
@@ -1377,6 +1416,60 @@ function createServer(opts = {}) {
1377
1416
  // Il body va parsato QUI: il router `api` non monta un parser globale, ogni
1378
1417
  // rotta porta il suo (`express.json`) — vedi /sessions, /notify, /asks.
1379
1418
  // Senza, `req.body` e' undefined, il relay legge un body vuoto e risponde
1419
+ // Notice dismissals, client half: the relay that carries a dismissal to the
1420
+ // owner, the durable queue of the intents that could not be delivered yet, and
1421
+ // the drainer that retries them. Same channel as the ask answers (one
1422
+ // implementation of the chain), same binding as the imported dismissals: a
1423
+ // refusal stays blocked until the pairing to that owner changes.
1424
+ const noticeRelay = createNoticeRelay({
1425
+ nodesPath, slotVerifier: askReverseVerifier,
1426
+ loadStore: () => nodesStore.loadStore(nodesPath),
1427
+ peers: federatedPeers,
1428
+ localPort: () => (server && server.address() ? server.address().port : cfg.port),
1429
+ localToken: () => tokenHolder.value,
1430
+ log: opts.log || console.log,
1431
+ });
1432
+ const noticeDismissalQueue = createNoticeDismissalQueue({
1433
+ filePath: path.join(notifyDir, 'notice-dismissal-queue.json'),
1434
+ });
1435
+ // Durability signal for the operator's answer: the queue honours the intent
1436
+ // in memory when the disk refuses the write (that policy stands), but a
1437
+ // record that will not survive a restart is NOT the normal durable success,
1438
+ // so queued answers declare it instead of looking healthy. Empty = healthy.
1439
+ const queueDurability = () => {
1440
+ try {
1441
+ const st = noticeDismissalQueue.status();
1442
+ return st && st.degraded ? { durable: false, degraded: st.degraded } : {};
1443
+ } catch (_) { return {}; }
1444
+ };
1445
+ // One durable record per cleared notice. A refused write keeps the in-memory
1446
+ // truth (the intent was honoured here anyway) and only loses durability; a
1447
+ // refused enqueue means the intent was NOT recorded, so nothing is left behind.
1448
+ function enqueueNoticeDismissals(ownerId, eventIds, syncState) {
1449
+ const added = [];
1450
+ for (const eventId of eventIds) {
1451
+ const queued = noticeDismissalQueue.enqueue({ ownerId, eventId });
1452
+ if (!queued.ok) {
1453
+ for (const done of added) noticeDismissalQueue.confirm(ownerId, done);
1454
+ return false;
1455
+ }
1456
+ added.push(eventId);
1457
+ }
1458
+ if (syncState === 'blocked') {
1459
+ for (const eventId of eventIds) noticeDismissalQueue.update(ownerId, eventId, { syncState: 'blocked', lastReason: 'unsupported' });
1460
+ }
1461
+ return true;
1462
+ }
1463
+ const noticeDismissalDrainer = createNoticeDismissalDrainer({
1464
+ queue: noticeDismissalQueue, relay: noticeRelay,
1465
+ readonly: proxyReadonly, bindingForOwner: dismissalBinding,
1466
+ // Same seam as the feed client's poll: a test (or an installation with its
1467
+ // own rhythm) can shorten the retry interval without touching the policy.
1468
+ ...(Number.isInteger(cfg.noticeDismissalDrainMs) && cfg.noticeDismissalDrainMs > 0
1469
+ ? { intervalMs: cfg.noticeDismissalDrainMs } : {}),
1470
+ log: opts.log || console.log,
1471
+ });
1472
+
1380
1473
  // 400 «ownerId, askId e text richiesti» a OGNI richiesta: la risposta
1381
1474
  // federata non ha mai potuto attraversare, e nessun test copriva questa
1382
1475
  // rotta. Difetto preesistente, trovato scrivendo il test del ritorno federato.
@@ -1427,15 +1520,32 @@ function createServer(opts = {}) {
1427
1520
  // Guards and alias mutation belong only to the selected generation.
1428
1521
  const alias = storedAlias && ask && asksStore.sameImportedGeneration(storedAlias, ask) ? storedAlias : null;
1429
1522
  const grants = peer && accessPresets.grantsOf(peer);
1430
- const cellId = ask ? cellIdFromTmuxSession(ask.session) : previous && previous.cellId;
1431
- const visible = !!((ask || previous) && cellId && grants && grants.configured && grants.grants.eventsAccess === true
1523
+ // The cell comes from what the OWNER resolved (feed or attested origin) and,
1524
+ // only for canonical names, from the session; see lib/notify/ask-cell.js. A
1525
+ // body field is never consulted: the dismissal request carries no cell.
1526
+ // The selected ask keeps its OWN cell. The other source (alias <-> feed card)
1527
+ // lends its cell only when it is bound to the same generation by an explicit,
1528
+ // equal owner timestamp and the same content: a generation that is unknown
1529
+ // on either side is not "the same" for a cell (sameImportedGeneration stays
1530
+ // permissive about it, because guards and alias mutation want that). If both
1531
+ // sources are authenticated and disagree there is no cell at all.
1532
+ const boundToSelected = item => item === ask || (tsOf(item) !== null && tsOf(item) === tsOf(ask)
1533
+ && asksStore.askFingerprint(item) === asksStore.askFingerprint(ask));
1534
+ const sameAsk = ask && [storedAlias, feedAsk].filter(item => item && boundToSelected(item));
1535
+ const cellId = ask ? askCell.resolveAskCellFrom(sameAsk).cellId : previous && previous.cellId;
1536
+ const grantsAllowFeed = !!(grants && grants.configured && grants.grants.eventsAccess === true);
1537
+ const visible = !!((ask || previous) && cellId && grantsAllowFeed
1432
1538
  && eventFeedAcl.visibilityAllows(grants.grants, cellId));
1539
+ // The ask is known and this node may read the owner's feed, but no
1540
+ // authenticated source tells which cell it belongs to (a card persisted by an
1541
+ // older release, an owner that does not publish the cell): said, not guessed.
1542
+ const cellUnresolved = !!(ask && !cellId && grantsAllowFeed);
1433
1543
  const blocked = !!((ask && ask.answered) || (alias && (asksStore.isAnswering(alias.id) || askReceipts.isBlocked(alias.id)))
1434
1544
  || askRelay.state().some(attempt => attempt.ownerId === ownerId && attempt.askId === askId && ['sent', 'uncertain'].includes(attempt.state)));
1435
1545
  const slot = peer && peer.reversePool && peer.reversePool.slots && peer.reversePool.slots[peer.reversePool.activeSlot];
1436
1546
  const unavailable = !!(peer && peer.direction === 'inbound' && (!peer.shared || !slot || !Number.isInteger(slot.port)))
1437
1547
  || askRelay.channelState(ownerId).unavailable;
1438
- return { peer, alias, previous, ask, grants, cellId, visible, blocked: blocked || ambiguous, unavailable, ambiguous };
1548
+ return { peer, alias, previous, ask, grants, cellId, visible, cellUnresolved, blocked: blocked || ambiguous, unavailable, ambiguous };
1439
1549
  }
1440
1550
  api.post('/asks-relay', express.json({ limit: '8kb' }), async (req, res) => {
1441
1551
  try {
@@ -1449,8 +1559,13 @@ function createServer(opts = {}) {
1449
1559
  }
1450
1560
  try { if (!asksStore.health().readable) return res.status(503).json({ error: 'dismissal store unavailable' }); }
1451
1561
  catch (_) { return res.status(503).json({ error: 'dismissal store unavailable' }); }
1452
- const { peer, alias, previous, ask, grants, cellId, visible, unavailable, ambiguous } = localDismissalEligibility(b.ownerId, b.askId);
1453
- if (!visible) return res.status(404).json({ error: 'unknown or invisible ask' });
1562
+ const { peer, alias, previous, ask, grants, cellId, visible, cellUnresolved, unavailable, ambiguous } = localDismissalEligibility(b.ownerId, b.askId);
1563
+ if (!visible) {
1564
+ if (cellUnresolved) {
1565
+ return res.status(409).json({ error: 'the cell of this ask is not known from an authenticated owner source; refresh the owner view', reason: 'cell-unresolved' });
1566
+ }
1567
+ return res.status(404).json({ error: 'unknown or invisible ask' });
1568
+ }
1454
1569
  if (ambiguous) return res.status(409).json({ error: 'ask generation is ambiguous; refresh the owner view', reason: 'generation-ambiguous' });
1455
1570
  if (ask && ask.answered) return res.status(409).json({ error: 'ask already answered', reason: 'answered' });
1456
1571
  if ((alias && (asksStore.isAnswering(alias.id) || askReceipts.isBlocked(alias.id)))
@@ -1520,6 +1635,106 @@ function createServer(opts = {}) {
1520
1635
  try { res.json({ attempts: askRelay.state() }); }
1521
1636
  catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1522
1637
  });
1638
+ // Operator surface for the notice dismissals. LOCAL and authenticated, never in
1639
+ // the federation allowlist: the remote surface is the owner's (the routes under
1640
+ // /event-feed/notices). The intent is honoured HERE first — the card leaves this
1641
+ // node's view — and the owner's copy is either delivered or queued, with the
1642
+ // answer saying which.
1643
+ api.post('/notices-relay', express.json({ limit: '8kb' }), async (req, res) => {
1644
+ try {
1645
+ const b = req.body || {};
1646
+ if (proxyReadonly()) return res.status(403).json({ error: 'READONLY: mutation blocked' });
1647
+ if (req.headers[HOP_HEADER]) return res.status(403).json({ error: 'local operator required' });
1648
+ if (!b || Array.isArray(b) || typeof b.action !== 'string') return res.status(400).json({ error: 'invalid notices body' });
1649
+ if (b.action === 'dismiss') {
1650
+ if (Object.keys(b).some((key) => !['action', 'ownerId', 'eventId'].includes(key))
1651
+ || !/^[a-f0-9]{32}$/.test(String(b.ownerId || ''))
1652
+ || !/^[0-9a-f-]{36}$/.test(String(b.eventId || ''))
1653
+ || b.ownerId === federatedNodeId()) {
1654
+ return res.status(400).json({ error: 'invalid local dismissal body' });
1655
+ }
1656
+ const out = await noticeRelay.relayNoticeDismiss({ ownerId: b.ownerId, eventId: b.eventId });
1657
+ if (out.ok) {
1658
+ if (typeof eventFeedClient.dismissNoticeConfirmed === 'function') eventFeedClient.dismissNoticeConfirmed(b.ownerId, b.eventId);
1659
+ return res.json({ dismissed: true, ...(out.idempotent ? { idempotent: true } : {}), ...(out.gone ? { gone: true } : {}) });
1660
+ }
1661
+ // The owner is not reachable NOW (or has no such surface at all): the
1662
+ // local intent stands, and the queue carries the rest. A reachable owner
1663
+ // that REFUSES is never queued — its answer is the decision.
1664
+ if (out.transient === true || out.reason === 'unsupported') {
1665
+ const queued = noticeDismissalQueue.enqueue({ ownerId: b.ownerId, eventId: b.eventId });
1666
+ if (!queued.ok) return res.status(503).json({ error: 'dismissal queue unavailable', reason: queued.reason });
1667
+ if (out.reason === 'unsupported') noticeDismissalQueue.update(b.ownerId, b.eventId, { syncState: 'blocked', lastReason: 'unsupported' });
1668
+ if (typeof eventFeedClient.dismissNoticeConfirmed === 'function') eventFeedClient.dismissNoticeConfirmed(b.ownerId, b.eventId);
1669
+ res.json({ dismissed: true, scope: 'local', ownerSync: out.reason === 'unsupported' ? 'blocked' : 'pending', ...queueDurability() });
1670
+ noticeDismissalDrainer.nudge();
1671
+ return;
1672
+ }
1673
+ return res.status(out.code || 500).json({ error: out.error || 'unknown', reason: out.reason });
1674
+ }
1675
+ if (b.action === 'dismiss-all') {
1676
+ if (Object.keys(b).some((key) => !['action', 'owners'].includes(key))
1677
+ || !Array.isArray(b.owners) || b.owners.length === 0 || b.owners.length > 100
1678
+ || b.owners.some((owner) => !/^[a-f0-9]{32}$/.test(String(owner || '')) || owner === federatedNodeId())) {
1679
+ return res.status(400).json({ error: 'invalid dismiss-all body' });
1680
+ }
1681
+ const results = {};
1682
+ for (const ownerId of [...new Set(b.owners)]) {
1683
+ // What this node is SHOWING for that owner: the whole visible set is
1684
+ // what the operator asked to clear, and it is the set whose local
1685
+ // intent has to outlive a reload or a restart of this node.
1686
+ const view = (eventFeedClient.state().views || []).find((v) => v.ownerId === ownerId);
1687
+ const affected = ((view && view.notifications) || [])
1688
+ .map((n) => n && n.eventId).filter((eventId) => typeof eventId === 'string' && eventId !== '');
1689
+ const out = await noticeRelay.relayNoticeDismissAll({ ownerId });
1690
+ if (out.ok) {
1691
+ // The owner took it: its own store stops serving these notices, so
1692
+ // the view forgets them here as well, exactly like the single route.
1693
+ for (const eventId of affected) eventFeedClient.dismissNoticeConfirmed(ownerId, eventId);
1694
+ results[ownerId] = { ok: true, dismissed: out.dismissed || 0 };
1695
+ continue;
1696
+ }
1697
+ // The owner is not reachable NOW (`pending`) or has no notices surface
1698
+ // at all (`blocked`): the local intent stands, durably — one queue
1699
+ // record per cleared notice is what keeps them out of the view on the
1700
+ // next snapshot — and the owner's copy is carried later, or never.
1701
+ const localSync = out.reason === 'unsupported' ? 'blocked' : (out.transient === true ? 'pending' : null);
1702
+ if (localSync === null) { results[ownerId] = { failed: out.reason || 'refused' }; continue; }
1703
+ if (!enqueueNoticeDismissals(ownerId, affected, localSync)) {
1704
+ results[ownerId] = { failed: 'queue-unavailable' };
1705
+ continue;
1706
+ }
1707
+ for (const eventId of affected) eventFeedClient.dismissNoticeConfirmed(ownerId, eventId);
1708
+ // Queued answers depend on the durable write: a queue that cannot
1709
+ // persist says so per owner, instead of looking like the healthy
1710
+ // pending/blocked that survives a restart.
1711
+ results[ownerId] = localSync === 'blocked'
1712
+ ? { blocked: 'unsupported', ...queueDurability() }
1713
+ : { pending: affected.length, ...queueDurability() };
1714
+ if (localSync === 'pending') noticeDismissalDrainer.nudge();
1715
+ }
1716
+ return res.json({ results });
1717
+ }
1718
+ return res.status(400).json({ error: 'azione non riconosciuta' });
1719
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1720
+ });
1721
+ api.get('/notices-relay/capability', (req, res) => {
1722
+ const ownerId = typeof req.query.ownerId === 'string' ? req.query.ownerId : '';
1723
+ if (!/^[a-f0-9]{32}$/.test(ownerId)) return res.status(400).json({ error: 'ownerId richiesto' });
1724
+ let canDismiss = false;
1725
+ try {
1726
+ const st = nodesStore.loadStore(nodesPath);
1727
+ const peer = st && (st.nodes || []).find((node) => node && node.nodeId === ownerId);
1728
+ const slot = peer && peer.reversePool && Array.isArray(peer.reversePool.slots)
1729
+ ? peer.reversePool.slots[peer.reversePool.activeSlot] : null;
1730
+ canDismiss = !proxyReadonly() && !req.headers[HOP_HEADER] && !!peer && (
1731
+ (peer.direction === 'outbound' && !!peer.token && !!peer.localPort)
1732
+ || (peer.direction === 'inbound' && peer.shared === true && peer.askReplyAccess === true
1733
+ && peer.eventsAccess === true && peer.cellVisibility !== 'none'
1734
+ && !!slot && Number.isInteger(slot.port)));
1735
+ } catch (_) { canDismiss = false; }
1736
+ res.json({ ownerId, canDismiss });
1737
+ });
1523
1738
  // The client re-emits imported events into the local hub (deliverOnly: the
1524
1739
  // notifier is bypassed on purpose). The alert relay hangs off that same call,
1525
1740
  // so ONLY an imported notify can ring this device, and never through the
@@ -1548,6 +1763,13 @@ function createServer(opts = {}) {
1548
1763
  if (!bridgeReadonly()) for (const ask of asks) askAlertRegistry.adopt(ownerId, ask, ask.ownerAskTs === undefined ? ask.ts : ask.ownerAskTs);
1549
1764
  },
1550
1765
  isAskDismissed: (ownerId, ask) => asksStore.isImportedDismissed(ownerId, ask),
1766
+ // A notice this node cleared locally but could not hand to the owner (queued
1767
+ // as pending, or blocked for good) must not return to the view: the record in
1768
+ // the queue is the node's intent, and it is what survives a restart. The
1769
+ // read is the same one the operator's answer is built from.
1770
+ isNoticeDismissed: (ownerId, eventId) => {
1771
+ try { return noticeDismissalQueue.get(ownerId, eventId) !== null; } catch (_) { return false; }
1772
+ },
1551
1773
  onAskClosed: (ownerId, ownerAskId, outcome, ownerAskTs) => asksStore.closeImported({ ownerId, ownerAskId, outcome, ownerAskTs }),
1552
1774
  loadStore: () => nodesStore.loadStore(nodesPath),
1553
1775
  token: () => tokenHolder.value,
@@ -1794,6 +2016,7 @@ function createServer(opts = {}) {
1794
2016
  // Store-root revision of the peer access policy: the CAS value the UI
1795
2017
  // must send back with every preset write (stale revision -> 409).
1796
2018
  importedDismissalDrainer.nudge();
2019
+ noticeDismissalDrainer.nudge();
1797
2020
  res.json({ nodeId: view.nodeId, nodes, accessRevision: nodesStore.accessRevisionOf(st) });
1798
2021
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1799
2022
  });
@@ -1925,14 +2148,14 @@ function createServer(opts = {}) {
1925
2148
  diagnostics.record('info', 'server', 'SERVER_STARTED', 'NexusCrew server started', {
1926
2149
  port: runtimePort(), platform: cfg.platform || process.platform,
1927
2150
  });
1928
- updater.start(); startManagedTunnels(); importedDismissalDrainer.start();
2151
+ updater.start(); startManagedTunnels(); importedDismissalDrainer.start(); noticeDismissalDrainer.start();
1929
2152
  });
1930
2153
  server.on('close', () => {
1931
2154
  diagnostics.record('info', 'server', 'SERVER_STOPPED', 'NexusCrew server stopped', { reason: 'close' });
1932
2155
  watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); ptyGrace.close();
1933
2156
  // Nessun timer di ritentativo sopravvive alla chiusura del server.
1934
2157
  if (closureRetry) { try { closureRetry.stop(); } catch (_) {} }
1935
- importedDismissalDrainer.stop(); eventFeedClient.stop();
2158
+ importedDismissalDrainer.stop(); noticeDismissalDrainer.stop(); eventFeedClient.stop();
1936
2159
  for (const timer of reverseWatchers.values()) clearInterval(timer);
1937
2160
  reverseWatchers.clear(); rotatableReverse.clear(); void reverseSlotListeners?.closeAll();
1938
2161
  // Il pannello non sopravvive al control plane: senza requireToken sopra,
@@ -710,6 +710,12 @@ function settingsRoutes(deps = {}) {
710
710
  if (!node) return send(res, 404, { error: `nodo sconosciuto "${name}"` });
711
711
  if (node.direction !== 'outbound') return send(res, 409, { error: 'Share si attiva sul dispositivo che possiede la connessione SSH' });
712
712
  if (!node.token || !node.nodeId) return send(res, 409, { error: 'nodo non associato: ripeti il pairing' });
713
+ // A host-only peer (publish-remote) is published ONE WAY to our own admin
714
+ // nodes; Share would publish THIS node on it. Refuse before any SSH restart
715
+ // or reverse negotiation is attempted (turning Share off stays possible).
716
+ if (node.publishRemote === true && body.shared) {
717
+ return send(res, 409, { error: 'publish-remote attivo su questo peer: Share resta spento (spegni prima publish-remote)', reason: 'publish-remote-active' });
718
+ }
713
719
  if (body.shared && !nodesStore.isPort(node.reversePort)) {
714
720
  return send(res, 409, { error: 'canale share non negoziato: ripeti il pairing' });
715
721
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.64",
3
+ "version": "0.9.66",
4
4
  "description": "Faithful browser tmux client \u2014 attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {