@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.
- package/CHANGELOG.md +243 -0
- package/frontend/dist/assets/{index-_kotgT8Z.css → index-CYi_lhCg.css} +1 -1
- package/frontend/dist/assets/index-Cxwpa2Y_.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +11 -0
- package/frontend/dist/version.json +1 -1
- package/lib/audio/dispatch.js +6 -1
- package/lib/cells/scope-guard.js +239 -0
- package/lib/cells/scope.js +144 -0
- package/lib/cli/commands.js +23 -1
- package/lib/fleet/builtin.js +129 -4
- package/lib/fleet/catalogs/alibaba-token-plan-qwen3.8.json +3 -3
- package/lib/fleet/definitions.js +128 -7
- package/lib/fleet/managed.js +229 -20
- package/lib/fleet/model-probe.js +129 -0
- package/lib/fleet/routes.js +27 -1
- package/lib/fleet/runtime.js +3 -3
- package/lib/mcp/tools.js +67 -4
- package/lib/nodes/commands.js +86 -3
- package/lib/nodes/identity.js +232 -0
- package/lib/nodes/store.js +89 -0
- package/lib/notify/notifier.js +7 -0
- package/lib/notify/routes.js +75 -3
- package/lib/proxy/federation.js +71 -6
- package/lib/server.js +100 -4
- package/lib/settings/pairing-coordinator.js +16 -0
- package/lib/settings/public-peering-routes.js +24 -1
- package/lib/settings/routes.js +1 -1
- package/package.json +1 -1
- package/frontend/dist/assets/index-DHmXeaJX.js +0 -93
package/frontend/dist/index.html
CHANGED
|
@@ -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-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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>
|
package/frontend/dist/sw.js
CHANGED
|
@@ -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
|
|
1
|
+
{"version":"0.8.52"}
|
package/lib/audio/dispatch.js
CHANGED
|
@@ -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' || !
|
|
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 };
|
package/lib/cli/commands.js
CHANGED
|
@@ -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
|
-
|
|
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;
|