@mmmbuto/nexuscrew 0.8.52-rc.8 → 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.
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-DHmXeaJX.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-_kotgT8Z.css">
14
+ <script type="module" crossorigin src="/assets/index-Cxwpa2Y_.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-CYi_lhCg.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -2,6 +2,17 @@ self.addEventListener('install', () => self.skipWaiting());
2
2
  self.addEventListener('activate', () => self.clients.claim());
3
3
  self.addEventListener('fetch', (e) => e.respondWith(fetch(e.request)));
4
4
 
5
+ // applyUpdate() (lib/sw-update.js) manda questo messaggio a un worker in
6
+ // waiting e aspetta il `controllerchange`. Senza un listener il messaggio
7
+ // cadeva nel vuoto: scattava il reload di fallback, il worker restava in
8
+ // waiting, e al ricaricamento `reg.waiting` faceva ricomparire il banner —
9
+ // "nuova versione disponibile" per sempre, e il bottone non poteva spegnerlo.
10
+ // Un worker installato da una versione precedente di questo file resta in
11
+ // waiting fino a che non lo si attiva: e' quello il caso che si incastrava.
12
+ self.addEventListener('message', (e) => {
13
+ if (e && e.data && e.data.type === 'SKIP_WAITING') self.skipWaiting();
14
+ });
15
+
5
16
  // Web Push del MCP bridge: payload JSON {title, body?, lang?, url?} dal server.
6
17
  // tag fisso 'nexuscrew': le notifiche si sostituiscono invece di accumularsi.
7
18
  self.addEventListener('push', (e) => {
@@ -1 +1 @@
1
- {"version":"0.8.52-rc.8"}
1
+ {"version":"0.8.52"}
@@ -25,6 +25,11 @@ function createDispatcher(opts = {}) {
25
25
  const localToken = opts.localToken || (() => '');
26
26
  const fetchImpl = opts.fetchImpl || fetch;
27
27
  const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DISPATCH_TIMEOUT_MS;
28
+ // Gli esiti che un endpoint puo' legittimamente restituire. Parametrizzato
29
+ // perche' l'instradamento non e' audio-specifico: le notifiche riusano lo
30
+ // stesso percorso e rispondono `delivered`. Il default resta quello audio,
31
+ // cosi' nessun chiamante esistente cambia comportamento.
32
+ const statuses = opts.statuses instanceof Set ? opts.statuses : PER_ENDPOINT_STATUS;
28
33
 
29
34
  function isLocal(target) {
30
35
  const self = localNodeId();
@@ -84,7 +89,7 @@ function createDispatcher(opts = {}) {
84
89
  return { status: 'refused', reason: 'route-denied', httpStatus: res.status };
85
90
  }
86
91
  if (res.status >= 500) return { status: 'unreachable', reason: 'peer-error', httpStatus: res.status };
87
- if (!payload || typeof payload !== 'object' || !PER_ENDPOINT_STATUS.has(payload.status)) {
92
+ if (!payload || typeof payload !== 'object' || !statuses.has(payload.status)) {
88
93
  // Il peer ha risposto qualcosa che non e' un esito per endpoint: non si
89
94
  // indovina. `unknown` e' esattamente questo caso.
90
95
  return { status: 'unknown', reason: 'unreadable-endpoint-result', httpStatus: res.status };
@@ -0,0 +1,239 @@
1
+ 'use strict';
2
+ // lib/cells/scope-guard.js — il punto in cui lo scope celle diventa effettivo.
3
+ //
4
+ // Sta in testa al router /api perche' i canali da cui una cella puo' trapelare
5
+ // sono tredici, e un permesso che vale su dodici di essi non e' un permesso:
6
+ // e' un'etichetta. Gli elenchi (cosa esce) e le azioni (cosa si puo' toccare)
7
+ // passano dallo stesso predicato di lib/cells/scope.js.
8
+ //
9
+ // Due scelte deliberate:
10
+ //
11
+ // * il bersaglio di ogni route e' DICHIARATO in una tabella, non indovinato.
12
+ // Una regola magica sbaglia in silenzio quando qualcuno aggiunge una route;
13
+ // un elenco esplicito, quando qualcuno lo dimentica, fa scattare la rete di
14
+ // sicurezza qui sotto.
15
+ // * fail-closed sulle route sconosciute che nominano una cella: se un corpo
16
+ // porta `cell`, `to.cell` o `session` e la route non e' dichiarata, si nega.
17
+ // E' l'unico modo perche' una route aggiunta domani non nasca scoperta.
18
+ const { createCellScope } = require('./scope.js');
19
+
20
+
21
+ // Chiavi da cui, in una RISPOSTA, esce un elenco di celle o di sessioni.
22
+ // Coprono /cells, /fleet/status, /fleet/definitions, /sessions.
23
+ const CELL_LIST_KEYS = ['cells'];
24
+ const SESSION_LIST_KEYS = ['sessions', 'unmanaged'];
25
+
26
+ // I deck sono il quarto elenco, e non lo sembrava: non contengono celle ne'
27
+ // sessioni sotto quei nomi, ma TILE, e ogni tile porta il nome della sessione
28
+ // tmux. Filtrare tre chiavi su quattro lasciava passare a un peer ristretto
29
+ // l'esistenza e il nome di ogni cella dell'hub — non il contenuto, ma la mappa.
30
+ // Trovato dall'audit indipendente di NC-E, non da questo codice.
31
+ function filterDecks(decks, scope) {
32
+ if (!Array.isArray(decks)) return decks;
33
+ const out = [];
34
+ for (const deck of decks) {
35
+ const layout = deck && deck.layout;
36
+ const columns = Array.isArray(layout && layout.columns) ? layout.columns : null;
37
+ // Forma inattesa: non si inventa un filtro su una struttura che non si
38
+ // riconosce. Meglio lasciarla passare qui e vederla rompere un test, che
39
+ // svuotarla in silenzio.
40
+ if (!columns) { out.push(deck); continue; }
41
+ const kept = [];
42
+ for (const column of columns) {
43
+ const tiles = Array.isArray(column && column.tiles)
44
+ ? column.tiles.filter((tile) => scope.allowsTile(tile))
45
+ : [];
46
+ if (tiles.length) kept.push({ ...column, tiles });
47
+ }
48
+ // Un deck rimasto senza tile era fatto solo di celle non concesse: sparisce
49
+ // invece di comparire vuoto, che sarebbe la stessa rivelazione in negativo.
50
+ if (kept.length) out.push({ ...deck, layout: { ...layout, columns: kept } });
51
+ }
52
+ return out;
53
+ }
54
+
55
+ // Route che AGISCONO su una cella o su una sessione, con il punto esatto in cui
56
+ // leggere il bersaglio. `null` = la route e' nota e non ha un bersaglio cella.
57
+ function declaredTarget(req) {
58
+ const p = req.path || '';
59
+ const b = (req.body && typeof req.body === 'object') ? req.body : {};
60
+ const q = req.query || {};
61
+
62
+ // Azioni sul ciclo di vita di una cella: il bersaglio e' `body.cell`.
63
+ if (/^\/fleet\/(up|down|restart|engine|boot)$/.test(p)) return { cell: b.cell };
64
+ // Definire/modificare/rimuovere celle e' una capacita' a se': chi ha uno
65
+ // scope ristretto non deve poter creare la cella che gli manca.
66
+ if (/^\/fleet\/(define-cell|edit-cell|remove-cell|restore-cells|import-cell)$/.test(p)) return { deny: 'scope-cannot-define-cells' };
67
+ // Messaggio a una cella: il bersaglio e' dichiarato due volte, e devono
68
+ // valere entrambe (il codice a valle le confronta gia' fra loro).
69
+ if (p === '/cells/send') return { cell: b.to && b.to.cell, session: b.to && b.to.tmuxSession };
70
+ // Sessioni: creazione, eliminazione, visibilita', file.
71
+ if (p === '/sessions' && req.method === 'POST') return { session: b.name };
72
+ if (/^\/sessions\/[^/]+/.test(p)) return { session: decodeURIComponent(p.split('/')[2] || '') };
73
+ if (p === '/files' || p.startsWith('/files/')) return { session: q.session || b.session };
74
+ // Route note che NON agiscono su una cella: la notifica arriva all'operatore
75
+ // e la voce passa dal suo ACL per-nodo (lib/audio/acl.js). Vanno dichiarate
76
+ // esplicitamente, altrimenti la rete di sicurezza qui sotto le scambia per
77
+ // route dimenticate — cosa che e' successa davvero: entrambe portano
78
+ // `originCell`, che e' la PROVENIENZA attestata dal server, non un bersaglio.
79
+ if (p === '/notify' || p.startsWith('/notify/')) return null;
80
+ if (p === '/audio' || p.startsWith('/audio/')) return null;
81
+ return undefined; // route non dichiarata
82
+ }
83
+
84
+ // Rete di sicurezza: un corpo che nomina una cella o una sessione su una route
85
+ // non dichiarata. Meglio un 403 su qualcosa di legittimo — che si nota subito e
86
+ // si aggiunge alla tabella — di un canale aperto che nessuno vede.
87
+ //
88
+ // La prima versione elencava sei chiavi esatte (`cell`, `session`,
89
+ // `tmuxSession`, `to.cell`, `to.tmuxSession`, `?session`) e l'audit ha mostrato
90
+ // il limite: una route dimenticata che chiama il suo bersaglio `cellId` passava
91
+ // liscia. Si riconosce quindi la FORMA del nome — qualunque chiave che parli di
92
+ // celle o di sessioni — invece di un elenco che invecchia a ogni sinonimo.
93
+ //
94
+ // Limite residuo, dichiarato invece che nascosto: un bersaglio chiamato `name`
95
+ // o `target` non e' riconoscibile senza sapere quali celle esistono, e negare
96
+ // ogni `name` renderebbe la rete di sicurezza un muro. Le route reali che
97
+ // agiscono su una cella sono tutte nella tabella qui sopra; questa e' la
98
+ // seconda linea, non la prima.
99
+ const TARGET_KEY_RE = /cell|session/i;
100
+ // Una chiave che dichiara da DOVE viene la richiesta non e' un bersaglio, ed e'
101
+ // il caso che ha fatto scattare la rete di sicurezza su /notify e /audio/speak:
102
+ // il loro corpo federato porta `originCell`, scritto dal server come
103
+ // provenienza attestata. Negarlo bloccava due funzioni sane per ogni peer
104
+ // ristretto. La provenienza si controlla altrove — dalla catena `visited`, che
105
+ // il peer non puo' scrivere — e qui non va nemmeno guardata.
106
+ const ORIGIN_KEY_RE = /^(origin|from|sender|source)/i;
107
+
108
+ function scanTargets(source) {
109
+ if (!source || typeof source !== 'object' || Array.isArray(source)) return null;
110
+ for (const [key, value] of Object.entries(source)) {
111
+ if (typeof value !== 'string' || !value) continue;
112
+ if (ORIGIN_KEY_RE.test(key)) continue;
113
+ if (TARGET_KEY_RE.test(key)) return value;
114
+ }
115
+ return null;
116
+ }
117
+
118
+ function undeclaredTarget(req) {
119
+ const b = (req.body && typeof req.body === 'object') ? req.body : {};
120
+ const q = req.query || {};
121
+ // `to` e' il solo annidamento noto (il destinatario di /cells/send); piu' a
122
+ // fondo non si scende: un corpo arbitrariamente profondo diventerebbe una
123
+ // scansione senza fine su ogni richiesta federata.
124
+ const named = scanTargets(b) || scanTargets(b.to) || scanTargets(q);
125
+ return named ? { target: named } : null;
126
+ }
127
+
128
+ function filterPayload(payload, scope) {
129
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload;
130
+ const out = { ...payload };
131
+ for (const key of CELL_LIST_KEYS) {
132
+ if (Array.isArray(out[key])) out[key] = scope.filterCells(out[key]);
133
+ }
134
+ for (const key of SESSION_LIST_KEYS) {
135
+ if (Array.isArray(out[key])) out[key] = scope.filterSessions(out[key]);
136
+ }
137
+ if (Array.isArray(out.decks)) out.decks = filterDecks(out.decks, scope);
138
+ if (Array.isArray(out.records)) out.records = filterRecords(out.records, scope);
139
+ return out;
140
+ }
141
+
142
+ // I record diagnostici (/diagnostics/logs, federata in lettura) portano
143
+ // `meta.cell`: il quinto canale, e di nuovo non somigliava a un elenco di
144
+ // celle. Un record che NON nomina una cella passa: lo scope celle governa le
145
+ // celle, e i diagnostici di sistema non appartengono a nessuna. Il resto del
146
+ // record e' gia' bounded per contratto — i diagnostici rifiutano contenuto
147
+ // grezzo di terminale — quindi qui basta il bersaglio dichiarato.
148
+ function filterRecords(records, scope) {
149
+ return records.filter((entry) => {
150
+ const cell = entry && entry.meta && entry.meta.cell;
151
+ if (typeof cell !== 'string' || !cell) return true;
152
+ return scope.allowsCell(cell);
153
+ });
154
+ }
155
+
156
+ // Parser JSON usato SOLO sulle richieste federate. Non si puo' montare un
157
+ // parser globale: le route locali catturano `rawBody` con `verify` per la firma
158
+ // del bridge, e `verify` non viene chiamato se il corpo e' gia' stato
159
+ // consumato; inoltre i limiti di dimensione per-route (16kb per notify, 8kb per
160
+ // i comandi VL) verrebbero saltati, perche' express.json esce subito quando
161
+ // `req.body` esiste gia'. Le richieste locali devono arrivare INTATTE ai loro
162
+ // parser.
163
+ function federatedBodyParser(limit) {
164
+ const parse = require('express').json({ limit });
165
+ return (req, res, next) => {
166
+ if (req.body !== undefined) return next();
167
+ const method = (req.method || 'GET').toUpperCase();
168
+ if (method === 'GET' || method === 'HEAD' || method === 'DELETE') return next();
169
+ return parse(req, res, () => next());
170
+ };
171
+ }
172
+
173
+ function createCellScopeGuard({
174
+ nodesPath, loadStoreImpl, cellForSession,
175
+ // resolveOrigin(req) -> {ok, trust, visited}. In produzione e' l'origin
176
+ // resolver basato sulla prova di hop; nei test e' un seam, cosi' questo
177
+ // modulo prova il GUARD e non di nuovo la firma (che ha i suoi test).
178
+ resolveOrigin,
179
+ bodyLimit = '1mb',
180
+ } = {}) {
181
+ const scoper = createCellScope({ nodesPath, loadStoreImpl, cellForSession });
182
+ const parseFederatedBody = federatedBodyParser(bodyLimit);
183
+
184
+ return async function cellScopeGuard(req, res, next) {
185
+ let origin;
186
+ try {
187
+ origin = await resolveOrigin(req);
188
+ } catch (_) {
189
+ origin = null;
190
+ }
191
+ // Un hop che non verifica NON e' una richiesta federata, e questo guard non
192
+ // e' il posto dove deciderne l'esito: le route che sanno rifiutarlo lo
193
+ // fanno gia' con il loro codice (401 su audio, non 403), e sostituirlo qui
194
+ // cambierebbe una risposta di sicurezza esistente senza migliorarla.
195
+ // Non e' nemmeno una scappatoia: `cleanHeaders` cancella gli header di hop
196
+ // in ingresso e il proxy li riscrive lui, quindi un peer non puo' ne'
197
+ // toglierli ne' falsificarli. Chi puo' inventarne uno possiede gia' il
198
+ // Bearer locale — e' il proprietario della macchina, che non si limita da
199
+ // solo.
200
+ if (!origin || origin.ok !== true) return next();
201
+ if (origin.trust !== 'federated') return next();
202
+
203
+ const scope = scoper.resolve({ trust: 'federated', visited: origin.visited });
204
+ req.cellScope = scope;
205
+ if (scope.mode === 'all') return next();
206
+
207
+ // Il bersaglio di molte azioni sta nel corpo: qui, e solo per le federate,
208
+ // va parsato prima di poterlo guardare.
209
+ return parseFederatedBody(req, res, () => applyScope(req, res, next, scope));
210
+ };
211
+
212
+ function applyScope(req, res, next, scope) {
213
+ // --- azioni ---------------------------------------------------------
214
+ const declared = declaredTarget(req);
215
+ if (declared === undefined) {
216
+ const sneaky = undeclaredTarget(req);
217
+ if (sneaky) {
218
+ return res.status(403).json({ error: 'route non dichiarata per lo scope celle', reason: 'undeclared-cell-route' });
219
+ }
220
+ } else if (declared) {
221
+ if (declared.deny) return res.status(403).json({ error: 'fuori dallo scope celle', reason: declared.deny });
222
+ if (declared.cell !== undefined && !scope.allowsCell(declared.cell)) {
223
+ return res.status(403).json({ error: 'fuori dallo scope celle', reason: 'cell-not-granted' });
224
+ }
225
+ if (declared.session !== undefined && !scope.allowsSession(declared.session)) {
226
+ return res.status(403).json({ error: 'fuori dallo scope celle', reason: 'session-not-granted' });
227
+ }
228
+ }
229
+
230
+ // --- letture --------------------------------------------------------
231
+ // Si avvolge res.json invece di riscrivere ogni handler: gli elenchi sono
232
+ // quattro e crescono, e un handler nuovo nascerebbe scoperto.
233
+ const json = res.json.bind(res);
234
+ res.json = (payload) => json(filterPayload(payload, scope));
235
+ return next();
236
+ }
237
+ }
238
+
239
+ module.exports = { createCellScopeGuard, declaredTarget, filterPayload, filterDecks, filterRecords };
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+ // lib/cells/scope.js — quali celle di QUESTO nodo un peer federato puo' vedere.
3
+ //
4
+ // Gemello di lib/audio/acl.js, e per le stesse ragioni:
5
+ // * la sorgente della decisione e' il node store locale, mai il corpo della
6
+ // richiesta. Un peer non dichiara il proprio scope: lo subisce.
7
+ // * l'identita' arriva dalla catena `visited` che costruisce il server
8
+ // (controlledVisited), non da un campo dichiarato.
9
+ //
10
+ // Due invarianti che una versione semplificata romperebbe in silenzio:
11
+ //
12
+ // 1. In multi-hop lo scope e' l'INTERSEZIONE fra chi consegna e chi origina.
13
+ // `canTransit` autorizza il transito, non l'accesso: un peer B puo'
14
+ // instradare una richiesta di C. Guardare solo B lascerebbe C ereditare i
15
+ // permessi di B. lib/audio/acl.js risolve lo stesso caso controllando
16
+ // entrambi, e qui va replicato invece che semplificato.
17
+ // 2. Una sessione tmux e' concessa solo se lo e' la CELLA a cui appartiene, e
18
+ // una sessione che non appartiene a nessuna cella non e' "libera": e'
19
+ // fuori scope. Altrimenti basterebbe una tmux creata a mano per aggirare
20
+ // il permesso — e `/ws` attacca proprio per nome di sessione.
21
+ //
22
+ // Il permesso e' indicizzato per `nodeId`, non per `name`: il name e' uno slug
23
+ // locale rinominabile, il nodeId e' l'identita' stabile provata dal pairing.
24
+ const nodesStore = require('../nodes/store.js');
25
+
26
+ // Scope di un singolo nodo, normalizzato. `all` non elenca nulla di proposito:
27
+ // una lista che significa "tutte" invecchierebbe a ogni cella nuova.
28
+ function scopeOf(node) {
29
+ if (!node) return { mode: 'none', cells: new Set() };
30
+ const mode = node.cellVisibility || 'all';
31
+ if (mode === 'all') return { mode: 'all', cells: null };
32
+ if (mode === 'none') return { mode: 'none', cells: new Set() };
33
+ return { mode: 'selected', cells: new Set(Array.isArray(node.cells) ? node.cells : []) };
34
+ }
35
+
36
+ // Intersezione di due scope. `all` e' l'elemento neutro; `none` assorbe.
37
+ function intersect(a, b) {
38
+ if (a.mode === 'none' || b.mode === 'none') return { mode: 'none', cells: new Set() };
39
+ if (a.mode === 'all') return b;
40
+ if (b.mode === 'all') return a;
41
+ return { mode: 'selected', cells: new Set([...a.cells].filter((c) => b.cells.has(c))) };
42
+ }
43
+
44
+ function findByNodeId(st, nodeId) {
45
+ if (!st || !Array.isArray(st.nodes) || !nodeId) return null;
46
+ return st.nodes.find((n) => n && n.nodeId === nodeId) || null;
47
+ }
48
+
49
+ // createCellScope(): deps esplicite, nessun accesso globale.
50
+ // nodesPath percorso del node store
51
+ // loadStoreImpl iniettabile nei test
52
+ // cellForSession mappa tmuxSession -> id cella (null se non e' di una cella)
53
+ function createCellScope({ nodesPath, loadStoreImpl = nodesStore.loadStore, cellForSession = () => null } = {}) {
54
+ // Scope "tutto", usato dal percorso locale: il proprietario della macchina
55
+ // non si limita da solo, e questo modulo non e' il posto dove decidere
56
+ // altrimenti.
57
+ function openScope() {
58
+ return buildApi({ mode: 'all', cells: null });
59
+ }
60
+
61
+ function buildApi(resolved, localNodeId = null) {
62
+ const allowsCell = (cell) => {
63
+ if (resolved.mode === 'all') return true;
64
+ if (resolved.mode === 'none') return false;
65
+ return typeof cell === 'string' && resolved.cells.has(cell);
66
+ };
67
+ const allowsSession = (session) => {
68
+ if (resolved.mode === 'all') return true;
69
+ const cell = cellForSession(session);
70
+ // Nessuna cella dietro la sessione => fuori scope. Vedi invariante 2.
71
+ if (!cell) return false;
72
+ return allowsCell(cell);
73
+ };
74
+ // Un tile di deck che punta a un ALTRO nodo non e' una cella di questo
75
+ // hub: lo scope celle governa le celle locali, e la topologia ha gia' le
76
+ // sue regole (canTransit, allowlist federata). Quando pero' non sappiamo
77
+ // chi siamo, un `ownerId` non confrontabile si tratta come LOCALE: e' il
78
+ // verso fail-closed, perche' l'errore costa un tile in meno invece di un
79
+ // nome di cella in piu'.
80
+ const tileIsRemote = (tile) => {
81
+ if (!tile || typeof tile !== 'object') return false;
82
+ if (typeof tile.node === 'string' && tile.node) return true;
83
+ if (typeof tile.ownerId === 'string' && tile.ownerId) {
84
+ return localNodeId ? tile.ownerId !== localNodeId : false;
85
+ }
86
+ return false;
87
+ };
88
+ return {
89
+ mode: resolved.mode,
90
+ cells: resolved.cells ? [...resolved.cells] : null,
91
+ allowsCell,
92
+ allowsSession,
93
+ allowsTile: (tile) => tileIsRemote(tile) || allowsSession(tile && tile.session),
94
+ // Filtri: gli elenchi si restringono con lo STESSO predicato che decide
95
+ // le azioni. Due implementazioni divergenti sarebbero un bug latente.
96
+ filterCells: (list) => (Array.isArray(list) ? list.filter((c) => allowsCell(c && (c.cell ?? c.id))) : []),
97
+ filterSessions: (list) => (Array.isArray(list) ? list.filter((s) => allowsSession(s && (s.name ?? s.tmuxSession))) : []),
98
+ };
99
+ }
100
+
101
+ // resolve({trust, visited}) -> api dello scope.
102
+ // trust 'local-bridge' (o assente e non federato) : nessuna restrizione
103
+ // trust 'federated' : intersezione consegnante ∩ origine
104
+ function resolve({ trust, visited } = {}) {
105
+ if (trust !== 'federated') return openScope();
106
+ const chain = Array.isArray(visited) ? visited : [];
107
+ // Serve almeno origine + questo nodo. Una catena piu' corta non identifica
108
+ // nessun peer: fail-closed.
109
+ if (chain.length < 2) return buildApi({ mode: 'none', cells: new Set() });
110
+
111
+ let st;
112
+ try { st = loadStoreImpl(nodesPath); } catch (_) { st = null; }
113
+ if (!st) return buildApi({ mode: 'none', cells: new Set() });
114
+
115
+ const deliveringId = chain[chain.length - 2];
116
+ const originId = chain[0];
117
+ // Il CONSEGNANTE deve essere un mio peer: mi ha consegnato la richiesta
118
+ // autenticandosi. Se non lo trovo nello store, fail-closed.
119
+ const delivering = scopeOf(findByNodeId(st, deliveringId));
120
+ // L'ORIGINE no. Un peer transitivo arriva attraverso un hub che io ho
121
+ // autorizzato a instradare, e non ho alcun motivo di conoscerlo: e' il caso
122
+ // normale di una rete a tre nodi (A chiede a C passando per B).
123
+ //
124
+ // Trattare un'origine sconosciuta come `none` sembrava prudente ed era
125
+ // invece una porta chiusa in faccia al traffico legittimo: nella rete reale
126
+ // il terzo nodo smetteva di vedere QUALSIASI cella, senza errore, appena
127
+ // questo codice e' arrivato sui peer. Lo scope celle governa i MIEI peer
128
+ // diretti; chi non e' mio non ha una restrizione mia da subire.
129
+ //
130
+ // Non e' un ampliamento: `all` e' l'elemento neutro dell'intersezione, e la
131
+ // restrizione del consegnante — che e' mio e che ho configurato io —
132
+ // continua a valere per intero. L'invariante resta: un'origine che E' nel
133
+ // mio store porta con se' la propria restrizione anche in multi-hop.
134
+ const originNode = originId === deliveringId ? null : findByNodeId(st, originId);
135
+ const origin = originId === deliveringId
136
+ ? delivering
137
+ : (originNode ? scopeOf(originNode) : { mode: 'all', cells: null });
138
+ return buildApi(intersect(delivering, origin), st.nodeId || null);
139
+ }
140
+
141
+ return { resolve };
142
+ }
143
+
144
+ module.exports = { createCellScope, scopeOf, intersect };
@@ -61,6 +61,7 @@ Usage:
61
61
  [--persist]
62
62
  nexuscrew nodes rename <name|nodeId> --label TEXT
63
63
  nexuscrew nodes visibility <name|nodeId> network|relay-only|selected
64
+ nexuscrew nodes cells <name|nodeId> all|none|Cella1,Cella2
64
65
  [--selected NODE_ID,...]
65
66
  nexuscrew nodes share <name|nodeId> on|off [--json]
66
67
  nexuscrew nodes invite --ssh TARGET [--ssh-port PORT] [--name SLUG]
@@ -985,11 +986,32 @@ async function dispatchNodes(rest, flags, opts = {}) {
985
986
  : String(flags.selected).split(',').map((value) => value.trim()).filter(Boolean);
986
987
  return { code: nodesCmds.nodesEdit({ ...opts, log, ref, patch: { visibility, selected } }).code };
987
988
  }
989
+ // Scope celle (NC-E): quali celle di QUESTO nodo il peer puo' vedere.
990
+ // Diverso da `visibility`, che governa il transito e non l'accesso.
991
+ if (sub === 'cells') {
992
+ const arg = rest[3];
993
+ if (!arg) {
994
+ log('nodes cells: uso `nodes cells <nodo> all|none|Cella1,Cella2`');
995
+ return { code: 1 };
996
+ }
997
+ if (arg === 'all' || arg === 'none') {
998
+ return { code: nodesCmds.nodesEdit({ ...opts, log, ref, patch: { cellVisibility: arg, cells: undefined } }).code };
999
+ }
1000
+ const cells = String(arg).split(',').map((v) => v.trim()).filter(Boolean);
1001
+ if (!cells.length) { log('nodes cells: elenco vuoto — usa `none` se intendi nessuna cella'); return { code: 1 }; }
1002
+ return { code: nodesCmds.nodesEdit({ ...opts, log, ref, patch: { cellVisibility: 'selected', cells } }).code };
1003
+ }
988
1004
  if (sub === 'remove') {
989
1005
  if (flags.yes !== true) { log('nodes remove: conferma richiesta con --yes'); return { code: 1 }; }
990
1006
  return { code: nodesCmds.nodesRemove({ ...opts, log, ref }).code };
991
1007
  }
992
- if (sub === 'test') return { code: (await nodesCmds.nodesTest({ ...opts, log, ref })).code };
1008
+ // Senza riferimento non e' un errore: e' la domanda "quali nodi sono davvero
1009
+ // condivisi?", che prima si poteva fare solo un nodo alla volta.
1010
+ if (sub === 'test') {
1011
+ return ref
1012
+ ? { code: (await nodesCmds.nodesTest({ ...opts, log, ref })).code }
1013
+ : { code: (await nodesCmds.nodesTestAll({ ...opts, log })).code };
1014
+ }
993
1015
  if (['up', 'down', 'connect', 'disconnect', 'restart', 'reconnect'].includes(sub)) {
994
1016
  const fn = sub === 'up' || sub === 'connect' ? nodesCmds.nodesUp
995
1017
  : sub === 'restart' || sub === 'reconnect' ? nodesCmds.nodesRestart : nodesCmds.nodesDown;