@mmmbuto/nexuscrew 0.8.52-rc.9 → 0.8.52

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.
@@ -20,8 +20,18 @@
20
20
  const express = require('express');
21
21
  const { isValidSession } = require('../files/store.js');
22
22
  const { normalizeNotificationLang } = require('./language.js');
23
+ const { HOP_HEADER } = require('../proxy/hop-proof.js');
23
24
 
24
- const NOTIFY_KEYS = new Set(['title', 'body', 'urgency', 'session', 'lang']);
25
+ const TARGET_RE = /^[a-f0-9]{32}$/i;
26
+
27
+ // `url` NON e' ammesso, ne' in locale ne' federato: sw.js fa
28
+ // clients.openWindow(url) sul click, quindi accettarlo da un peer sarebbe un
29
+ // open-redirect dentro la PWA autenticata. Il solo url legittimo lo genera in
30
+ // casa la route degli ask (deep-link /#ask=<id>), che non passa da qui.
31
+ const NOTIFY_KEYS = new Set(['title', 'body', 'urgency', 'session', 'lang', 'target']);
32
+ // Chiavi accettate SOLO su un ingresso federato provato: le mette il
33
+ // dispatcher del nodo di origine, non un chiamante locale.
34
+ const FEDERATED_KEYS = new Set(['originCell', 'originNode']);
25
35
  const ASK_KEYS = new Set(['question', 'options', 'session']);
26
36
  const RATE_MAX = 6;
27
37
  const RATE_WINDOW_MS = 60 * 1000;
@@ -91,7 +101,14 @@ function replyLabel(cfg) {
91
101
  return clean || 'human';
92
102
  }
93
103
 
94
- function notifyRoutes({ cfg, notifier, push, asks, paste, sessionExists }) {
104
+ function notifyRoutes({
105
+ cfg, notifier, push, asks, paste, sessionExists,
106
+ // Federazione delle notifiche. Assenti (test unitari, montaggi parziali) la
107
+ // route resta esattamente quella locale di prima: nessun percorso nuovo si
108
+ // apre per omissione.
109
+ localNodeId = () => null, originResolver = null, acl = null, dispatcher = null,
110
+ federatedRate = null,
111
+ }) {
95
112
  const r = express.Router();
96
113
  const json = express.json({ limit: '16kb' });
97
114
 
@@ -110,8 +127,14 @@ function notifyRoutes({ cfg, notifier, push, asks, paste, sessionExists }) {
110
127
  if (!b || typeof b !== 'object' || Array.isArray(b)) {
111
128
  return res.status(400).json({ error: 'body deve essere un oggetto JSON' });
112
129
  }
130
+ // Un ingresso e' federato solo se porta la prova di hop. Si stabilisce
131
+ // PRIMA di guardare il body: quali chiavi sono lecite dipende da come la
132
+ // richiesta e' arrivata, non da cosa dichiara.
133
+ const federated = !!(originResolver && req.headers && req.headers[HOP_HEADER]);
113
134
  for (const k of Object.keys(b)) {
114
- if (!NOTIFY_KEYS.has(k)) return res.status(400).json({ error: `chiave non ammessa: "${k}" (schema: title, body?, urgency?, session?, lang?)` });
135
+ if (NOTIFY_KEYS.has(k)) continue;
136
+ if (federated && FEDERATED_KEYS.has(k)) continue;
137
+ return res.status(400).json({ error: `chiave non ammessa: "${k}" (schema: title, body?, urgency?, session?, lang?, target?)` });
115
138
  }
116
139
  if (typeof b.title !== 'string' || !b.title.trim()) {
117
140
  return res.status(400).json({ error: 'title deve essere una stringa non vuota' });
@@ -130,6 +153,55 @@ function notifyRoutes({ cfg, notifier, push, asks, paste, sessionExists }) {
130
153
  if (b.session !== undefined && !isValidSession(b.session)) {
131
154
  return res.status(400).json({ error: 'session non valida' });
132
155
  }
156
+ if (b.target !== undefined && !TARGET_RE.test(String(b.target))) {
157
+ return res.status(400).json({ error: 'target deve essere un instanceId di nodo' });
158
+ }
159
+ const self = localNodeId();
160
+
161
+ // --- ingresso FEDERATO: la notifica arriva da un altro nodo -----------
162
+ if (federated) {
163
+ const resolved = await originResolver.resolve(req, { requireCell: true });
164
+ if (!resolved.ok) return res.status(403).json({ status: 'refused', reason: resolved.reason });
165
+ // Il target e' esatto e va confermato QUI: una route puo' consegnare a
166
+ // un nodo diverso da quello che il mittente credeva.
167
+ if (!self || b.target !== self) {
168
+ return res.status(404).json({ status: 'refused', reason: 'wrong-target' });
169
+ }
170
+ const verdict = acl ? acl.allows(resolved) : { allowed: false, reason: 'acl-unavailable' };
171
+ if (!verdict.allowed) return res.status(403).json({ status: 'refused', reason: verdict.reason });
172
+ // Budget SEPARATO da quello locale: senza, un peer rumoroso non solo
173
+ // spamma ma affama le notifiche delle celle di casa, che condividono
174
+ // lo stesso bucket da 6/60s.
175
+ if (federatedRate) {
176
+ const quota = federatedRate.check({ origin: resolved.origin, target: self, urgency: b.urgency });
177
+ if (!quota.allowed) {
178
+ return res.status(429).json({ status: 'refused', reason: `rate-${quota.bucket}` });
179
+ }
180
+ }
181
+ const delivered = await notifier.emit({
182
+ title: b.title.trim(), body: b.body, urgency: b.urgency, lang,
183
+ // Il mittente NON e' `b.session`: quel campo lo dichiara il chiamante.
184
+ // Qui vale solo cio' che la catena ha provato, piu' la cella che il
185
+ // nodo di origine attesta.
186
+ originNode: resolved.origin.node,
187
+ originCell: resolved.origin.cell,
188
+ });
189
+ return res.json({ status: 'delivered', delivered });
190
+ }
191
+
192
+ // --- target remoto: instrada, non consegnare qui -----------------------
193
+ if (b.target !== undefined && dispatcher && self && b.target !== self) {
194
+ const out = await dispatcher.dispatch({
195
+ resource: '/notify',
196
+ target: b.target,
197
+ // La cella di origine e' quella DICHIARATA dal chiamante locale: viene
198
+ // trasmessa come attestazione, e il target la trattera' come tale.
199
+ origin: { node: self, cell: b.session || 'unknown' },
200
+ payload: { title: b.title.trim(), ...(b.body ? { body: b.body } : {}), ...(b.urgency ? { urgency: b.urgency } : {}), ...(lang ? { lang } : {}) },
201
+ });
202
+ return res.json(out);
203
+ }
204
+
133
205
  const sender = b.session || 'unknown';
134
206
  if (!allowNotify(sender)) {
135
207
  return res.status(429).json({ error: 'rate limit notify superato (limite globale per token + per sessione)' });
@@ -60,6 +60,22 @@ function classifyShareFailure(health) {
60
60
  return { code: 'share-peer-unreachable', detail };
61
61
  }
62
62
 
63
+ // Porta del canale reverse realmente in gioco, vista DALL'HUB: lo slot attivo
64
+ // del pool quando c'e', altrimenti la `localPort` del peer — che e' il punto
65
+ // su cui l'hub sonda davvero (cfr. nodes test, ramo inbound). NON
66
+ // `reversePort`: quello e' il campo negoziato che vive sul CLIENT, e sull'hub
67
+ // e' assente. Il test del comportamento ha trovato questo scambio; il valore
68
+ // e' il dato che manca per capire un rifiuto, quindi sbagliarlo lo renderebbe
69
+ // peggio che inutile, mandando a cercare sulla porta sbagliata.
70
+ function activeReversePort(peer) {
71
+ const pool = peer && peer.reversePool;
72
+ if (pool && Array.isArray(pool.slots)) {
73
+ const slot = pool.slots[pool.activeSlot];
74
+ if (slot && Number.isInteger(slot.port)) return slot.port;
75
+ }
76
+ return peer && Number.isInteger(peer.localPort) ? peer.localPort : null;
77
+ }
78
+
63
79
  function peerFromToken(nodesPath, token) {
64
80
  const st = store.loadStore(nodesPath);
65
81
  if (!st || !token) return null;
@@ -151,11 +167,25 @@ function knownResource(resource) {
151
167
  // ADMITS a further node, so leaving it federated would let a peer break the
152
168
  // very boundary the owner chose. Minting an invite is done on the hub.
153
169
  || resource === '/ws'
170
+ // Un avviso all'operatore attraversa la federazione con la stessa spina
171
+ // dorsale dell'audio: origine provata dalla catena `visited`, ACL del
172
+ // target, budget dedicato. NON e' la coda di NC-NEXT-07 (§5 del modello di
173
+ // autorita'): quella differisce un'esecuzione, questo mostra del testo e
174
+ // non esegue nulla. `/audio/speak` esce da un altoparlante in una stanza
175
+ // fisica ed e' gia' federato: una notifica e' meno invasiva di cosi'.
176
+ || resource === '/notify'
154
177
  || resource === '/audio/capability'
155
178
  || resource === '/audio/speak'
156
179
  || resource === '/audio/speak/status'
157
180
  || resource === '/audio/stop'
158
- || /^\/fleet\/(status|schema|definitions|credentials\/status|credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource);
181
+ // I modelli dichiarati seguono gli engine, perche' sono la stessa cosa
182
+ // vista da un'altra angolazione: un engine gestito NON parte se il suo id
183
+ // di modello non e' nel catalogo, e un modello dichiarato e' cio' che
184
+ // estende quel catalogo. `define-engine` federato + `define-model` locale
185
+ // significava poter creare a distanza un engine che non si puo' rendere
186
+ // avviabile a distanza — un'asimmetria che rompeva la modifica remota a
187
+ // meta' strada, senza proteggere nulla.
188
+ || /^\/fleet\/(status|schema|definitions|credentials\/status|credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource);
159
189
  }
160
190
 
161
191
  function allowedResource(resource, method = 'GET') {
@@ -187,7 +217,16 @@ function allowedResource(resource, method = 'GET') {
187
217
  if (resource === '/diagnostics/verbose') return method === 'PATCH';
188
218
  if (resource === '/ws') return method === 'GET';
189
219
  if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) return method === 'GET';
190
- if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) return method === 'POST';
220
+ // `model-test` e' un POST che non muta niente: interroga il CATALOGO del
221
+ // fornitore (`GET .../models`) con la credenziale di quel nodo e ne ricava un
222
+ // enum. Non genera testo, quindi non consuma token. Resta comunque una
223
+ // mutazione ai fini di READONLY (sotto): un nodo dichiarato di sola lettura
224
+ // non emette richieste autenticate su comando di un peer.
225
+ if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) return method === 'POST';
226
+ // Notify: solo POST. `/events`, `/asks` e `/push/*` restano NON federati —
227
+ // SSE non puo' autenticarsi col Bearer e un ask ha un canale di ritorno che
228
+ // e' un paste nel tmux locale, quindi non attraverserebbe comunque.
229
+ if (resource === '/notify') return method === 'POST';
191
230
  // Audio: only capability read, speak and stop are exposed through Hydra.
192
231
  // audio.consent is a LOCAL mutation and MUST stay unreachable federated.
193
232
  if (resource === '/audio/capability') return method === 'GET';
@@ -806,7 +845,7 @@ async function probeReverseOwner(peer, fetchImpl = fetch) {
806
845
  });
807
846
  }
808
847
 
809
- function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null }) {
848
+ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null, diagnostics = null }) {
810
849
  const r = express.Router();
811
850
  r.use((req, res, next) => {
812
851
  const peer = peerFromToken(nodesPath, bearerFrom(req));
@@ -1042,6 +1081,21 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
1042
1081
  // guasto definitivo senza interpretare una stringa. Solo il canale
1043
1082
  // non ancora salito e' ritentabile.
1044
1083
  const failure = classifyShareFailure(health);
1084
+ // L'hub sa di aver rifiutato e sa perche', e finora non lo scriveva
1085
+ // da nessuna parte: l'errore viveva solo nel toast del dispositivo,
1086
+ // che e' l'unico posto dove chi amministra l'hub non puo' guardarlo.
1087
+ // Un rifiuto ripetuto diventava cosi' un mistero — e' costato ore su
1088
+ // un caso reale, risolto solo leggendo i log di sshd con i privilegi
1089
+ // di root. Meta bounded: nome nodo, codice tipizzato, porta tentata.
1090
+ // Nessun testo remoto, nessuna credenziale.
1091
+ if (diagnostics && typeof diagnostics.record === 'function') {
1092
+ diagnostics.record('warn', 'share', 'SHARE_CHANNEL_REFUSED',
1093
+ 'Share refused: the reverse channel did not come up', {
1094
+ node: req.peer.name,
1095
+ code: failure.code,
1096
+ port: activeReversePort(req.peer),
1097
+ });
1098
+ }
1045
1099
  return res.status(409).json({
1046
1100
  error: 'canale share non raggiungibile',
1047
1101
  code: failure.code,
@@ -1073,7 +1127,7 @@ function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecre
1073
1127
  return r;
1074
1128
  }
1075
1129
 
1076
- function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredential, ingress, readonly = () => false, activeSockets = null }) {
1130
+ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredential, ingress, readonly = () => false, activeSockets = null, hopSecret = null }) {
1077
1131
  if (readonly()) return reject(socket, 403);
1078
1132
  const parsed = parseRoute(req.url.replace(/^\/(?:api\/route|federation\/route)/, ''));
1079
1133
  if (!parsed || parsed.resource !== '/ws') return reject(socket, 404);
@@ -1092,7 +1146,18 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1092
1146
  }
1093
1147
  const up = net.connect({ host: '127.0.0.1', port });
1094
1148
  up.once('connect', () => {
1095
- const headers = cleanHeaders(req.headers, credential, parsed.route.length ? visited : null);
1149
+ // Ultimo hop: l'upgrade rientra nell'API locale col Bearer locale e da li'
1150
+ // in poi sarebbe indistinguibile da un attach diretto di chi possiede quel
1151
+ // token. La catena PIU' la prova di hop sono cio' che lo distingue —
1152
+ // esattamente come fa routeHandler per il percorso HTTP. Senza, il nodo che
1153
+ // possiede la sessione non sa quale peer stia aprendo il suo PTY, e ogni
1154
+ // permesso per-cella diventa decorativo: `/ws` attacca per NOME di
1155
+ // sessione, quindi basta indovinarlo.
1156
+ const secret = typeof hopSecret === 'function' ? hopSecret() : hopSecret;
1157
+ const hopProof = !parsed.route.length && secret
1158
+ ? signHop(secret, { method: req.method || 'GET', path, visited })
1159
+ : null;
1160
+ const headers = cleanHeaders(req.headers, credential, visited, hopProof);
1096
1161
  const lines = [`GET ${path} HTTP/1.1`, `Host: 127.0.0.1:${port}`];
1097
1162
  for (const [k, v] of Object.entries(headers)) lines.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
1098
1163
  lines.push('Connection: Upgrade', 'Upgrade: websocket', '', '');
@@ -1110,7 +1175,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1110
1175
  function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
1111
1176
 
1112
1177
  module.exports = {
1113
- MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure,
1178
+ MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure, activeReversePort,
1114
1179
  peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
1115
1180
  collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
1116
1181
  probeHealth, waitForHealthyPeer, canListenLoopback, preflightHubReverse, notifyHubShare, reconcilePeerShare, runShareRevokeBoot, probeReverseOwner,
package/lib/server.js CHANGED
@@ -39,12 +39,20 @@ const { isConsent: isAudioConsent } = require('./audio/consent.js');
39
39
  const { createOriginResolver } = require('./audio/origin.js');
40
40
  const { createAudioAcl } = require('./audio/acl.js');
41
41
  const { createDispatcher } = require('./audio/dispatch.js');
42
+ const { createCellScopeGuard } = require('./cells/scope-guard.js');
43
+ const { createCellScope } = require('./cells/scope.js');
44
+ const { cellIdFromTmuxSession } = require('./fleet/definitions.js');
45
+ // Budget a tre finestre, riusato dalle notifiche federate: e' gia' scritto per
46
+ // limitare il danno di un nodo che mente sulla propria cella (il tetto per
47
+ // target si calcola sul NODO, che la catena visited verifica).
48
+ const { createSpeakRateLimiter } = require('./audio/rate-limit.js');
42
49
  const { createSpeakQueue } = require('./audio/queue.js');
43
50
  const { createReceiptStore } = require('./audio/receipt.js');
44
51
  const audioAdapters = require('./audio/adapters.js');
45
52
  const audioGroups = require('./audio/groups.js');
46
53
  const { bridgeSecretPath, loadOrCreateBridgeSecret, createNonceCache } = require('./audio/bridge-auth.js');
47
- const { createHopSecret } = require('./proxy/hop-proof.js');
54
+ const { createHopSecret, verifyHop, HOP_HEADER } = require('./proxy/hop-proof.js');
55
+ const { parseVisitedChain } = require('./audio/origin.js');
48
56
  const { settingsRoutes, publicPeeringRoutes } = require('./settings/routes.js');
49
57
  const decksStore = require('./decks/store.js');
50
58
  const { decksRoutes } = require('./decks/routes.js');
@@ -105,6 +113,10 @@ function createServer(opts = {}) {
105
113
  // alla porta sbagliata (bug reale: 401/"unauthorized" dal servizio su cfg.port).
106
114
  let server = null;
107
115
  const previews = createPreviewSampler(cfg.tmuxBin);
116
+ // Seam di test per l'esistenza di una sessione: senza, un test sullo scope
117
+ // dell'attach non discrimina, perche' ogni sessione risulta assente e il
118
+ // rifiuto arriverebbe comunque — un verde che non prova nulla.
119
+ const sessionExistsImpl = cfg.sessionExistsSeam || ((name) => sessionExists(cfg.tmuxBin, name));
108
120
  // MCP bridge (notify/ask/push): lo stato vive accanto al token (dirname del
109
121
  // tokenPath = ~/.nexuscrew di default) cosi' le istanze isolate via opts/env
110
122
  // nei test NON scrivono mai nella home reale. Tutto lazy: vapid.json/asks.json
@@ -541,6 +553,51 @@ function createServer(opts = {}) {
541
553
  // ma il token chiude anche altri processi locali della stessa macchina.
542
554
  const api = express.Router();
543
555
  api.use(requireToken(tokenStore));
556
+ // Origine per lo scope celle: stessa prova di hop usata da audio e notify.
557
+ // requireCell:false perche' qui interessa QUALE NODO parla, non quale cella
558
+ // dichiari — la cella e' solo attestata e non deve decidere un permesso.
559
+ const cellScopeOrigin = createOriginResolver({
560
+ localNodeId: () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
561
+ hopSecret: () => hopSecret,
562
+ });
563
+ const cellScoper = createCellScope({
564
+ nodesPath,
565
+ cellForSession: (session) => cellIdFromTmuxSession(session),
566
+ });
567
+ // Variante SINCRONA per l'upgrade WebSocket: verifica la prova di hop e la
568
+ // catena senza passare dal resolver async. Un upgrade non federato — o con
569
+ // una prova che non regge — resta senza restrizioni, esattamente come una
570
+ // richiesta locale: la prova la costruisce il proxy, un peer non puo' ne'
571
+ // toglierla ne' falsificarla.
572
+ const wsCellScope = (req) => {
573
+ try {
574
+ const headers = (req && req.headers) || {};
575
+ const proof = headers[HOP_HEADER];
576
+ if (!proof) return cellScoper.resolve({ trust: 'local-bridge' });
577
+ const self = (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
578
+ const visited = parseVisitedChain(headers['x-nexuscrew-visited'], self);
579
+ if (!visited) return cellScoper.resolve({ trust: 'federated', visited: [] });
580
+ const ok = verifyHop(hopSecret, {
581
+ method: req.method || 'GET', path: '/ws', visited,
582
+ }, proof);
583
+ if (!ok) return cellScoper.resolve({ trust: 'federated', visited: [] });
584
+ return cellScoper.resolve({ trust: 'federated', visited });
585
+ } catch (_) {
586
+ // Fail-closed: se non si riesce a stabilire l'origine, nessuna sessione.
587
+ return cellScoper.resolve({ trust: 'federated', visited: [] });
588
+ }
589
+ };
590
+ // Scope celle (NC-E): un punto solo decide cosa un peer federato vede e puo'
591
+ // toccare. Sta qui, prima di ogni route, perche' i canali da cui una cella
592
+ // trapela sono molti e crescono: filtrarli uno per uno significa dimenticarne
593
+ // uno, e un permesso che vale su quasi tutti i canali non e' un permesso.
594
+ // Le richieste locali non lo attraversano: il proprietario della macchina non
595
+ // si limita da solo.
596
+ api.use(createCellScopeGuard({
597
+ nodesPath,
598
+ cellForSession: (session) => cellIdFromTmuxSession(session),
599
+ resolveOrigin: (req) => cellScopeOrigin.resolve(req, { requireCell: false }),
600
+ }));
544
601
  api.get('/sessions', async (_req, res) => {
545
602
  try {
546
603
  const sessions = await listSessions(cfg.tmuxBin);
@@ -615,6 +672,19 @@ function createServer(opts = {}) {
615
672
  }));
616
673
  // MCP bridge (design §2): /notify, /push/*, /asks — dietro lo stesso Bearer
617
674
  // del router /api; gate READONLY sui mutanti dentro notifyRoutes.
675
+ // Identita' e instradamento condivisi con l'audio: stessa fonte (node store),
676
+ // stesso segreto di hop, stessa inventory. Due modelli di provenienza
677
+ // divergenti sarebbero un bug latente.
678
+ const federatedNodeId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
679
+ const federatedPeers = async () => {
680
+ const st = nodesStore.loadStore(nodesPath);
681
+ if (!st) return [];
682
+ const topology = await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath, fetchImpl: healthFetch });
683
+ return nodesInventory.buildInventory({
684
+ direct: nodesStore.redactStore(st).nodes,
685
+ topology: topology && Array.isArray(topology.nodes) ? topology.nodes : [],
686
+ });
687
+ };
618
688
  api.use(notifyRoutes({
619
689
  cfg,
620
690
  notifier,
@@ -622,6 +692,22 @@ function createServer(opts = {}) {
622
692
  asks: asksStore,
623
693
  paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
624
694
  sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
695
+ localNodeId: federatedNodeId,
696
+ // requireCell resta true: una notifica federata porta sempre una cella
697
+ // attestata, che serve ad attribuirla e a calcolarne il budget.
698
+ originResolver: createOriginResolver({
699
+ localNodeId: federatedNodeId,
700
+ hopSecret: () => hopSecret,
701
+ }),
702
+ acl: createAudioAcl({ nodesPath }),
703
+ dispatcher: createDispatcher({
704
+ localNodeId: federatedNodeId,
705
+ peers: federatedPeers,
706
+ localPort: () => (server && server.address() ? server.address().port : cfg.port),
707
+ localToken: () => tokenHolder.value,
708
+ statuses: new Set(['delivered', 'refused', 'unreachable', 'unknown']),
709
+ }),
710
+ federatedRate: createSpeakRateLimiter(),
625
711
  }));
626
712
  api.use('/fleet', fleetRoutes(fleetP, { ...cfg, diagnostics }));
627
713
  // Audio Share. L'identita' del nodo NON e' un campo di cfg: si legge dal node
@@ -787,6 +873,7 @@ function createServer(opts = {}) {
787
873
  nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
788
874
  fetchImpl: healthFetch, hopSecret: () => hopSecret,
789
875
  roles: () => require('./cli/commands.js').readRoles(cfg.configPath || configJsonPath()),
876
+ diagnostics,
790
877
  }));
791
878
 
792
879
  // Reverse-proxy single-origin /node/<name>/… (design §4b(2)). Auth locale PRIMA
@@ -868,13 +955,13 @@ function createServer(opts = {}) {
868
955
  let u; try { u = new URL(req.url, 'http://127.0.0.1'); } catch (_) { return socket.destroy(); }
869
956
  const given = bearerFrom(req) || u.searchParams.get('token') || '';
870
957
  if (!verify(tokenHolder.value, given)) return socket.destroy();
871
- federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets });
958
+ federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret });
872
959
  return;
873
960
  }
874
961
  if (pathname.startsWith('/federation/route/')) {
875
962
  const ingress = federation.peerFromToken(nodesPath, bearerFrom(req));
876
963
  if (!ingress) return socket.destroy();
877
- federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets });
964
+ federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret });
878
965
  return;
879
966
  }
880
967
  if (pathname === '/node' || pathname.startsWith('/node/')) {
@@ -900,10 +987,19 @@ function createServer(opts = {}) {
900
987
  // non possono settare header sui WS -> il flusso locale resta identico
901
988
  // (token nel frame attach, mai in URL).
902
989
  const preauth = req ? verify(tokenHolder.value, bearerFrom(req)) : false;
990
+ // Scope celle sull'ATTACH. Calcolato in modo sincrono di proposito: rendere
991
+ // async questo handler rischierebbe di perdere i frame che arrivano prima
992
+ // di bindWs. `/ws` attacca per NOME DI SESSIONE e senza questo gate ogni
993
+ // filtro sugli elenchi sarebbe decorativo — basterebbe indovinare
994
+ // `cloud-Dev`.
995
+ const wsScope = wsCellScope(req);
903
996
  bindWs(ws, {
904
997
  openAttach,
905
998
  verifyToken: (t) => preauth || verify(tokenHolder.value, t),
906
- isValidSession: (name) => sessionExists(cfg.tmuxBin, name),
999
+ // Una sessione fuori scope viene trattata come inesistente: il client
1000
+ // riceve 4404, lo stesso codice di una sessione che non c'e'. Dire "esiste
1001
+ // ma non puoi" rivelerebbe proprio cio' che lo scope nasconde.
1002
+ isValidSession: (name) => sessionExistsImpl(name) && wsScope.allowsSession(name),
907
1003
  runAction: (sess, action) => runAction(cfg.tmuxBin, sess, action),
908
1004
  countClients: (sess) => attachedClients(cfg.tmuxBin, sess),
909
1005
  defaults: { readonlyDefault: cfg.readonlyDefault, tmuxBin: cfg.tmuxBin },
@@ -32,6 +32,7 @@ const os = require('node:os');
32
32
  const crypto = require('node:crypto');
33
33
 
34
34
  const nodesStore = require('../nodes/store.js');
35
+ const identity = require('../nodes/identity.js');
35
36
  const nodesCmds = require('../nodes/commands.js');
36
37
  const nodesTunnel = require('../nodes/tunnel.js');
37
38
  const peering = require('../nodes/peering.js');
@@ -223,6 +224,15 @@ function createPairHandler(deps) {
223
224
  // Pairing establishes a private client-to-hub connection. Sharing
224
225
  // this device back through the hub is a separate explicit action.
225
226
  shared: false,
227
+ // La nostra pubblica, passo 1 del modello di autorita'. Se non si
228
+ // riesce a leggerla si accoppia senza: il passo 1 non ha effetti, e
229
+ // un guasto sulla chiave non deve togliere la capacita' di
230
+ // accoppiare un dispositivo.
231
+ ...(() => {
232
+ try {
233
+ return { publicKey: identity.ensureNodeKey({ keyPath: identity.keyPathNextTo(nodesPath) }).publicKey };
234
+ } catch (_) { return {}; }
235
+ })(),
226
236
  roles: readRoles(configPath),
227
237
  }),
228
238
  });
@@ -295,6 +305,12 @@ function createPairHandler(deps) {
295
305
  st = nodesStore.updateNode(st, b.name, {
296
306
  token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
297
307
  shared: false,
308
+ // L'altra meta' dello scambio. Un hub piu' vecchio non manda la sua
309
+ // pubblica e il pairing riesce lo stesso — nessun campo, nessun
310
+ // cambiamento di comportamento.
311
+ ...(identity.isPublicKey(joined.publicKey)
312
+ ? { publicKey: joined.publicKey, keySource: 'pairing', keyBoundAt: new Date().toISOString() }
313
+ : {}),
298
314
  ...(joinedPool ? { reversePool: joinedPool } : {}),
299
315
  ...(joinedRoles ? { roles: joinedRoles, rolesKnown: true } : {}),
300
316
  });
@@ -13,6 +13,7 @@ const express = require('express');
13
13
 
14
14
  const nodesStore = require('../nodes/store.js');
15
15
  const peering = require('../nodes/peering.js');
16
+ const identity = require('../nodes/identity.js');
16
17
  const reversePool = require('../nodes/reverse-pool.js');
17
18
  const { readRoles } = require('../cli/commands.js');
18
19
  const { configJsonPath } = require('../config.js');
@@ -27,6 +28,15 @@ function publicPeeringRoutes(deps = {}) {
27
28
  const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
28
29
  const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
29
30
  const reversePoolLedgerPath = deps.reversePoolLedgerPath || cfg.reversePoolLedgerPath || reversePool.defaultLedgerPath(home);
31
+ // La pubblica di questa installazione, per il passo 1 del modello di
32
+ // autorita'. Si legge PIGRAMENTE e non all'avvio del router: un errore sul
33
+ // file di chiave non deve impedire di montare le route di pairing — il passo
34
+ // 1 non ha effetti, e «non riesco a leggere la mia chiave» non e' una buona
35
+ // ragione per non poter piu' accoppiare un dispositivo.
36
+ const localPublicKey = () => {
37
+ try { return identity.ensureNodeKey({ keyPath: identity.keyPathNextTo(nodesPath) }).publicKey; }
38
+ catch (_) { return null; }
39
+ };
30
40
  const r = express.Router();
31
41
  const attempts = new Map();
32
42
  r.use(express.json({ limit: '8kb' }));
@@ -150,15 +160,23 @@ function publicPeeringRoutes(deps = {}) {
150
160
  name, remotePort: b.port, reversePort, reversePool: assignedPool, instanceId: b.instanceId, acceptToken: b.acceptToken,
151
161
  shared: false,
152
162
  label: nodesStore.sanitizeLabel(b.label, name),
163
+ // La pubblica del peer, se la manda. Un peer piu' vecchio non la manda
164
+ // e si accoppia come sempre: il passo 1 osserva, non pretende.
165
+ ...(identity.isPublicKey(b.publicKey) ? { publicKey: b.publicKey } : {}),
153
166
  ...(peerRoles ? { roles: { ...peerRoles, node: false }, rolesKnown: true } : { rolesKnown: false }),
154
167
  } });
155
168
  if (!peering.consumeInvite({ invitesPath, invite: b.invite, now })) {
156
169
  peering.consumePending({ pendingPath, credential, now });
157
170
  return res.status(410).json({ error: 'invito scaduto o gia usato' });
158
171
  }
172
+ const mia = localPublicKey();
159
173
  res.json({ paired: true, instanceId: poolStore.nodeId, reversePort,
160
174
  reversePool: { base: assignedPool.base, slots: assignedPool.slots.map((slot) => slot.port) },
161
- credential, roles: readRoles(configPath) });
175
+ credential, roles: readRoles(configPath),
176
+ // Lo scambio e' simmetrico e avviene QUI, dentro l'atto che consuma
177
+ // l'invito monouso: e' il solo momento in cui l'operatore ha deciso,
178
+ // su entrambe le macchine, che questi due nodi si conoscono.
179
+ ...(mia ? { publicKey: mia } : {}) });
162
180
  } catch (e) {
163
181
  if (credential) try { peering.consumePending({ pendingPath, credential, now }); } catch (_) {}
164
182
  res.status(e.status || 500).json({ error: String(e.message || e), ...(e.code ? { code: e.code } : {}) });
@@ -188,6 +206,11 @@ function publicPeeringRoutes(deps = {}) {
188
206
  direction: 'inbound', transport: 'inbound', autostart: true,
189
207
  visibility: 'network', shared: pending.shared === true, nodeId: pending.instanceId,
190
208
  token: pending.acceptToken, acceptToken: b.credential,
209
+ // Legata al pairing, non asserita: e' `keySource` a dire la differenza,
210
+ // e al passo 3 un grant potra' pretendere questa e non l'altra.
211
+ ...(identity.isPublicKey(pending.publicKey)
212
+ ? { publicKey: pending.publicKey, keySource: 'pairing', keyBoundAt: new Date().toISOString() }
213
+ : {}),
191
214
  ...(pending.roles ? { roles: pending.roles } : {}),
192
215
  rolesKnown: pending.rolesKnown === true,
193
216
  ...(pending.label ? { label: pending.label } : {}),
@@ -72,7 +72,7 @@ const { publicPeeringRoutes } = require('./public-peering-routes.js');
72
72
  const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate', 'alternateScreen']);
73
73
  const ROLE_KEYS = new Set(['client', 'node']);
74
74
  const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
75
- const EDIT_KEYS = new Set(['label', 'ssh', 'sshPort', 'autostart', 'visibility', 'selected']);
75
+ const EDIT_KEYS = new Set(['label', 'ssh', 'sshPort', 'autostart', 'visibility', 'selected', 'cellVisibility', 'cells']);
76
76
  const LOCAL_AUDIO_TEST_TEXT = 'NexusCrew audio test.';
77
77
 
78
78
  // Default sensato per il "nome dispositivo" proposto nei form (pairing/invite).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.52-rc.9",
3
+ "version": "0.8.52",
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": {