@mmmbuto/nexuscrew 0.9.65 → 0.9.67

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).
@@ -500,7 +501,7 @@ function createServer(opts = {}) {
500
501
  if (!st) return null;
501
502
  const node = nodesStore.getNode(st, name);
502
503
  if (!node) return null;
503
- return { localPort: node.localPort, token: node.token || null };
504
+ return { localPort: node.localPort, token: node.token || null, hostOnly: node.publishRemote === true };
504
505
  }
505
506
 
506
507
  const runtimePort = () => (server && server.address() ? server.address().port : cfg.port);
@@ -993,6 +994,15 @@ function createServer(opts = {}) {
993
994
  const resource = req.url.slice(i + 2);
994
995
  if (!/^\/panel\/[A-Za-z0-9._-]{1,32}(?:\/.*)?$/.test(resource)) return next();
995
996
  if (req.method === 'POST' && /^\/panel\/[A-Za-z0-9._-]{1,32}\/ticket\/?$/.test(resource)) return next();
997
+ // The unauthenticated door exists only for a browser frame, which carries no
998
+ // header and arrives on this machine's own listener. It never opens for: a
999
+ // request with ANY Authorization (the local token goes through requireToken like
1000
+ // every other /api call; a peer's token is not the owner's); a request that came
1001
+ // in on a peer-bound reverse listener; a route that names a host-only peer at
1002
+ // any hop. Those fall through to requireToken, whatever the first hop is.
1003
+ const onPeerListener = reverseSlotListeners && reverseSlotListeners.isSlotPort(req.socket && req.socket.localPort);
1004
+ if (bearerFrom(req) || onPeerListener
1005
+ || federation.touchesHostOnly(nodesPath, { route: federation.routeNamesOf(req.url) })) return next();
996
1006
  panelFederatoRouter(req, res, next);
997
1007
  });
998
1008
  api.use(requireToken(tokenStore));
@@ -1160,12 +1170,28 @@ function createServer(opts = {}) {
1160
1170
  }
1161
1171
  return out;
1162
1172
  };
1163
- api.get('/config', (_req, res) => res.json({
1173
+ // A host-only peer is named only to the readers it is published to; a federated
1174
+ // reader is identified by the verified hop proof, never by the bare header.
1175
+ const hostOnlyNodesFor = (req) => {
1176
+ const st = nodesStore.loadStore(nodesPath);
1177
+ const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
1178
+ if (!st || vis.unreadable) return [];
1179
+ return st.nodes.filter((n) => n.publishRemote === true && !vis.hiddenNames.has(n.name)).map((n) => n.name);
1180
+ };
1181
+ const nodePanelPortsFor = (req) => {
1182
+ const out = nodePanelPorts();
1183
+ const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
1184
+ for (const name of vis.hiddenNames) delete out[name];
1185
+ if (vis.unreadable) return {};
1186
+ return out;
1187
+ };
1188
+ api.get('/config', (req, res) => res.json({
1164
1189
  readonlyDefault: cfg.readonlyDefault, version: VERSION, uiVersion: uiBuildVersion(distDir),
1165
1190
  bind: cfg.bind, port: cfg.port, panelPort: cfg.panelPort,
1166
1191
  protectSharedTmuxServer: cfg.protectSharedTmuxServer !== false,
1167
1192
  instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
1168
- nodePanelPorts: nodePanelPorts(),
1193
+ nodePanelPorts: nodePanelPortsFor(req),
1194
+ hostOnlyNodes: hostOnlyNodesFor(req),
1169
1195
  presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
1170
1196
  // i parametri del terminale sono configurabili dal server e leggibili
1171
1197
  // dal client (che ne ha comunque i default iniettabili).
@@ -1519,15 +1545,32 @@ function createServer(opts = {}) {
1519
1545
  // Guards and alias mutation belong only to the selected generation.
1520
1546
  const alias = storedAlias && ask && asksStore.sameImportedGeneration(storedAlias, ask) ? storedAlias : null;
1521
1547
  const grants = peer && accessPresets.grantsOf(peer);
1522
- const cellId = ask ? cellIdFromTmuxSession(ask.session) : previous && previous.cellId;
1523
- const visible = !!((ask || previous) && cellId && grants && grants.configured && grants.grants.eventsAccess === true
1548
+ // The cell comes from what the OWNER resolved (feed or attested origin) and,
1549
+ // only for canonical names, from the session; see lib/notify/ask-cell.js. A
1550
+ // body field is never consulted: the dismissal request carries no cell.
1551
+ // The selected ask keeps its OWN cell. The other source (alias <-> feed card)
1552
+ // lends its cell only when it is bound to the same generation by an explicit,
1553
+ // equal owner timestamp and the same content: a generation that is unknown
1554
+ // on either side is not "the same" for a cell (sameImportedGeneration stays
1555
+ // permissive about it, because guards and alias mutation want that). If both
1556
+ // sources are authenticated and disagree there is no cell at all.
1557
+ const boundToSelected = item => item === ask || (tsOf(item) !== null && tsOf(item) === tsOf(ask)
1558
+ && asksStore.askFingerprint(item) === asksStore.askFingerprint(ask));
1559
+ const sameAsk = ask && [storedAlias, feedAsk].filter(item => item && boundToSelected(item));
1560
+ const cellId = ask ? askCell.resolveAskCellFrom(sameAsk).cellId : previous && previous.cellId;
1561
+ const grantsAllowFeed = !!(grants && grants.configured && grants.grants.eventsAccess === true);
1562
+ const visible = !!((ask || previous) && cellId && grantsAllowFeed
1524
1563
  && eventFeedAcl.visibilityAllows(grants.grants, cellId));
1564
+ // The ask is known and this node may read the owner's feed, but no
1565
+ // authenticated source tells which cell it belongs to (a card persisted by an
1566
+ // older release, an owner that does not publish the cell): said, not guessed.
1567
+ const cellUnresolved = !!(ask && !cellId && grantsAllowFeed);
1525
1568
  const blocked = !!((ask && ask.answered) || (alias && (asksStore.isAnswering(alias.id) || askReceipts.isBlocked(alias.id)))
1526
1569
  || askRelay.state().some(attempt => attempt.ownerId === ownerId && attempt.askId === askId && ['sent', 'uncertain'].includes(attempt.state)));
1527
1570
  const slot = peer && peer.reversePool && peer.reversePool.slots && peer.reversePool.slots[peer.reversePool.activeSlot];
1528
1571
  const unavailable = !!(peer && peer.direction === 'inbound' && (!peer.shared || !slot || !Number.isInteger(slot.port)))
1529
1572
  || askRelay.channelState(ownerId).unavailable;
1530
- return { peer, alias, previous, ask, grants, cellId, visible, blocked: blocked || ambiguous, unavailable, ambiguous };
1573
+ return { peer, alias, previous, ask, grants, cellId, visible, cellUnresolved, blocked: blocked || ambiguous, unavailable, ambiguous };
1531
1574
  }
1532
1575
  api.post('/asks-relay', express.json({ limit: '8kb' }), async (req, res) => {
1533
1576
  try {
@@ -1541,8 +1584,13 @@ function createServer(opts = {}) {
1541
1584
  }
1542
1585
  try { if (!asksStore.health().readable) return res.status(503).json({ error: 'dismissal store unavailable' }); }
1543
1586
  catch (_) { return res.status(503).json({ error: 'dismissal store unavailable' }); }
1544
- const { peer, alias, previous, ask, grants, cellId, visible, unavailable, ambiguous } = localDismissalEligibility(b.ownerId, b.askId);
1545
- if (!visible) return res.status(404).json({ error: 'unknown or invisible ask' });
1587
+ const { peer, alias, previous, ask, grants, cellId, visible, cellUnresolved, unavailable, ambiguous } = localDismissalEligibility(b.ownerId, b.askId);
1588
+ if (!visible) {
1589
+ if (cellUnresolved) {
1590
+ 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' });
1591
+ }
1592
+ return res.status(404).json({ error: 'unknown or invisible ask' });
1593
+ }
1546
1594
  if (ambiguous) return res.status(409).json({ error: 'ask generation is ambiguous; refresh the owner view', reason: 'generation-ambiguous' });
1547
1595
  if (ask && ask.answered) return res.status(409).json({ error: 'ask already answered', reason: 'answered' });
1548
1596
  if ((alias && (asksStore.isAnswering(alias.id) || askReceipts.isBlocked(alias.id)))
@@ -1890,6 +1938,9 @@ function createServer(opts = {}) {
1890
1938
  },
1891
1939
  localPort: () => (server && server.address() ? server.address().port : cfg.port),
1892
1940
  localToken: () => tokenHolder.value,
1941
+ // A federated request that terminates here is never re-sent to a host-only
1942
+ // peer (checked on the target and route actually resolved, store read now).
1943
+ federatedGuard: ({ target, route }) => federation.touchesHostOnly(nodesPath, { target, route }),
1893
1944
  }),
1894
1945
  getGroup: (name) => audioGroups.getGroup(audioCfg, name, audioHome),
1895
1946
  }));
@@ -1948,7 +1999,15 @@ function createServer(opts = {}) {
1948
1999
  peers: federatedPeers,
1949
2000
  localPort: () => (server && server.address() ? server.address().port : cfg.port),
1950
2001
  localToken: () => tokenHolder.value,
2002
+ // Never designate, query or bridge to a host-only peer, whatever the route.
2003
+ ownerGuard: ({ target, route }) => federation.touchesHostOnly(nodesPath, { target, route }),
1951
2004
  }),
2005
+ // A designation that already points at a host-only peer is not shown to a
2006
+ // federated reader outside its audience (it would name the peer).
2007
+ ownerHiddenFor: (req, ownerId) => {
2008
+ const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
2009
+ return vis.unreadable || vis.hiddenIds.has(ownerId);
2010
+ },
1952
2011
  originResolver: createOriginResolver({ localNodeId: federatedNodeId, hopSecret: () => hopSecret }),
1953
2012
  federatedRate: createSpeakRateLimiter(),
1954
2013
  }));
@@ -1958,6 +2017,30 @@ function createServer(opts = {}) {
1958
2017
  api.use('/vl-nodes', vlNodeApiRoutes({
1959
2018
  storePath: vlNodesPath, broker: vlNodeBroker, ownerId: vlOwnerId, readonly: proxyReadonly,
1960
2019
  }));
2020
+ // A deck keeps references to nodes (tiles, floating windows): those that point at a host-only peer are not
2021
+ // served to a federated reader outside its audience, in any answer (the 409 carries the current deck too).
2022
+ api.use('/decks', (req, res, next) => {
2023
+ const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
2024
+ if (!vis.hiddenNames.size && !vis.hiddenIds.size && !vis.unreadable) return next();
2025
+ // A reader served a filtered deck must not be able to save it back and erase what it was not shown:
2026
+ // changing a deck that carries references it cannot see is refused (and says nothing about them).
2027
+ if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS') {
2028
+ const first = String(req.url || '').split('?')[0].split('/').filter(Boolean)[0];
2029
+ let target = null; try { target = first ? decodeURIComponent(first) : null; } catch (_) { target = ''; }
2030
+ if (vis.unreadable) return res.status(503).json({ error: 'non disponibile' });
2031
+ let hiddenContent = false;
2032
+ try {
2033
+ // creating a deck (no name in the path) overwrites nothing: only the deck being changed is looked at
2034
+ const st = target === null ? null : decksStore.loadStore(decksPath);
2035
+ const touched = (st ? st.decks : []).filter((d) => d.name === target);
2036
+ hiddenContent = touched.some((d) => JSON.stringify(federation.redactHostOnlyReferences(d, vis)) !== JSON.stringify(d));
2037
+ } catch (_) { return res.status(503).json({ error: 'non disponibile' }); }
2038
+ if (hiddenContent) return res.status(403).json({ error: 'deck non modificabile da questo nodo', reason: 'deck-has-hidden-content' });
2039
+ }
2040
+ const json = res.json.bind(res);
2041
+ res.json = (body) => json(vis.unreadable ? { error: 'non disponibile' } : federation.redactHostOnlyReferences(body, vis));
2042
+ return next();
2043
+ });
1961
2044
  api.use('/decks', decksRoutes({ cfg, decksPath }));
1962
2045
  api.use('/prefs', prefsRoutes({ cfg, prefsPath }));
1963
2046
  api.use('/fs', fsRoutes({ home: os.homedir() })); // folder-picker del dialog new session
@@ -2044,9 +2127,37 @@ function createServer(opts = {}) {
2044
2127
  audio: { adapter: audioAdapter, queue: audioQueue },
2045
2128
  reverseSlots: { ensure: startRotatableReverse, close: stopRotatableReverse, verify: verifyRotatablePool },
2046
2129
  }));
2130
+ // Records that name a host-only peer are served only to the readers it is
2131
+ // published to. The WHOLE record is inspected (name or id anywhere in it).
2132
+ api.use('/diagnostics', (req, res, next) => {
2133
+ const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
2134
+ if (!vis.hiddenNames.size && !vis.hiddenIds.size && !vis.unreadable) return next();
2135
+ const esc = (v) => String(v).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2136
+ const names = [...vis.hiddenNames].map((n) => new RegExp(`(^|[^A-Za-z0-9_-])${esc(n)}([^A-Za-z0-9_-]|$)`));
2137
+ const ids = [...vis.hiddenIds];
2138
+ const mentions = (rec) => { const t = JSON.stringify(rec); return ids.some((id) => t.includes(id)) || names.some((re) => re.test(t)); };
2139
+ const json = res.json.bind(res);
2140
+ res.json = (body) => {
2141
+ if (vis.unreadable) return json({ error: 'non disponibile' });
2142
+ if (body && Array.isArray(body.records)) return json({ ...body, records: body.records.filter((r) => !mentions(r)) });
2143
+ return json(body);
2144
+ };
2145
+ return next();
2146
+ });
2047
2147
  api.use('/diagnostics', diagnosticsRoutes({ diagnostics, readonly: proxyReadonly }));
2048
- api.get('/topology', async (_req, res) => {
2049
- try { res.json(await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath })); }
2148
+ api.get('/topology', async (req, res) => {
2149
+ try {
2150
+ // What was hidden when the request began stays hidden: a publication that ends during the wait does not
2151
+ // hand its leaf to a reader that was not allowed to see it when it asked.
2152
+ const visBefore = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
2153
+ const topo = await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath });
2154
+ // Decided AFTER the fan-out wait and on the final result, cache and stale
2155
+ // entries included: the policy is read again now, not before the await.
2156
+ const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
2157
+ if (vis.unreadable || visBefore.unreadable) return res.json({ instanceId: topo && topo.instanceId, nodes: [] });
2158
+ const merged = { hiddenNames: new Set([...visBefore.hiddenNames, ...vis.hiddenNames]), hiddenIds: new Set([...visBefore.hiddenIds, ...vis.hiddenIds]) };
2159
+ res.json({ ...topo, nodes: federation.hideHostOnlyEntries(topo && topo.nodes, merged) });
2160
+ }
2050
2161
  catch (e) { res.status(500).json({ error: String(e.message || e) }); }
2051
2162
  });
2052
2163
  api.use('/route', federation.localRouter({
@@ -2185,7 +2296,10 @@ function createServer(opts = {}) {
2185
2296
  // emesso): transita e decide il proprietario, gate panelAccess compreso
2186
2297
  // — stessa forma del bypass HTTP sulle panel-resource federate.
2187
2298
  const panelFederato = /^\/api\/route\/[^?#]*\/_\/panel\/[A-Za-z0-9._-]{1,32}(?:\/.*)?$/.test(pathname)
2188
- && /(?:^|;\s*)npanel=/.test(String(req.headers.cookie || ''));
2299
+ && /(?:^|;\s*)npanel=/.test(String(req.headers.cookie || ''))
2300
+ && !bearerFrom(req) && !u.searchParams.get('token')
2301
+ && !(reverseSlotListeners && reverseSlotListeners.isSlotPort(req.socket && req.socket.localPort))
2302
+ && !federation.touchesHostOnly(nodesPath, { route: federation.routeNamesOf(pathname.slice('/api/route'.length)) });
2189
2303
  if (!panelFederato && !verify(tokenHolder.value, given)) return socket.destroy();
2190
2304
  federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret, hubAttaches });
2191
2305
  return;
@@ -2273,7 +2387,7 @@ function createServer(opts = {}) {
2273
2387
 
2274
2388
  return {
2275
2389
  app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP, updater, diagnostics,
2276
- panelApp, panelServer, panelServerV6,
2390
+ panelApp, panelServer, panelServerV6, reverseSlotListeners,
2277
2391
  // Test/observability seam: publish a normalized state event through the
2278
2392
  // same per-peer projection the local producers use.
2279
2393
  notify: { publishState: notifier.publishState },
@@ -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.65",
3
+ "version": "0.9.67",
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": {