@mmmbuto/nexuscrew 0.8.52-rc.2 → 0.8.52-rc.21

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.
@@ -0,0 +1,127 @@
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
+ const delivering = scopeOf(findByNodeId(st, deliveringId));
118
+ // Quando origine e consegnante coincidono (hop singolo) l'intersezione con
119
+ // se stesso e' identita': nessun caso speciale da mantenere allineato.
120
+ const origin = originId === deliveringId ? delivering : scopeOf(findByNodeId(st, originId));
121
+ return buildApi(intersect(delivering, origin), st.nodeId || null);
122
+ }
123
+
124
+ return { resolve };
125
+ }
126
+
127
+ 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;
package/lib/mcp/tools.js CHANGED
@@ -237,6 +237,10 @@ const TOOLS = [
237
237
  type: 'string',
238
238
  description: 'lingua opzionale del testo: it, en, es o locale BCP-47 equivalente (es. it-IT)',
239
239
  },
240
+ target: {
241
+ type: 'string',
242
+ description: 'instanceId ESATTO del nodo su cui sta l\'operatore, per avvisarlo quando non e\' su questo nodo. Ottienilo da nc_cells/nc_status; niente wildcard. Omesso = questo nodo.',
243
+ },
240
244
  },
241
245
  required: ['title'],
242
246
  },
@@ -245,6 +249,10 @@ const TOOLS = [
245
249
  const body = argString(args, 'body', { max: 2000 });
246
250
  const urgency = argString(args, 'urgency', { max: 16 });
247
251
  const rawLang = argString(args, 'lang', { max: 35 });
252
+ const target = argString(args, 'target', { max: 64 });
253
+ if (target !== undefined && !/^[a-f0-9]{32}$/i.test(target)) {
254
+ throw new Error('target deve essere un instanceId di nodo (32 hex)');
255
+ }
248
256
  if (urgency !== undefined && urgency !== 'normal' && urgency !== 'high') {
249
257
  throw new Error('urgency deve essere "normal" o "high"');
250
258
  }
@@ -258,9 +266,18 @@ const TOOLS = [
258
266
  ...(body ? { body } : {}),
259
267
  ...(urgency ? { urgency } : {}),
260
268
  ...(lang ? { lang } : {}),
269
+ ...(target ? { target } : {}),
261
270
  };
262
271
  if (session) payload.session = session;
263
272
  const j = await ctx.api('POST', '/api/notify', payload);
273
+ // Con un target remoto l'esito e' quello del nodo che ha consegnato:
274
+ // `delivered` da solo direbbe "0 tentativi qui", che e' vero e inutile.
275
+ // Restituirlo cosi' evita di far leggere un successo dove c'e' un rifiuto.
276
+ // Nessun conteggio: il dispatcher non lo propaga, e un ramo che non puo'
277
+ // mai essere vero e' codice morto travestito da informazione
278
+ // (rilievo R1 di DevAuditor su rc.14). Se un giorno servira' il dettaglio,
279
+ // va fatto propagare da forward(), non dedotto qui.
280
+ if (target) return { status: j.status, ...(j.reason ? { reason: j.reason } : {}) };
264
281
  return { delivered: j.delivered };
265
282
  },
266
283
  },
@@ -252,6 +252,15 @@ function nodesInspect(opts) {
252
252
  log(`tipo: ${peer.kind} · ${peer.relation}`);
253
253
  log(`route: ${peer.route.join(' -> ')}`);
254
254
  if (peer.tunnel) log(`status: ${peer.tunnel.status}`);
255
+ // Uno scope celle ristretto e' una limitazione che l'operatore ha imposto
256
+ // e che poi non vede piu' da nessuna parte: si dimentica, e il giorno che
257
+ // un peer "non trova" una cella la causa e' invisibile. Il caso `all` non
258
+ // si stampa: e' il default, e una riga per dire "nessun limite" e' rumore.
259
+ if (peer.cellVisibility === 'none') log('celle: nessuna (scope: none)');
260
+ else if (peer.cellVisibility === 'selected') {
261
+ const cells = Array.isArray(peer.cells) ? peer.cells : [];
262
+ log(`celle: ${cells.length ? cells.join(', ') : 'nessuna (elenco vuoto)'} (scope: selected)`);
263
+ }
255
264
  log(`azioni: ${Object.keys(peer.actions || {}).filter((key) => peer.actions[key]).join(', ')}`);
256
265
  }
257
266
  return { code: 0, peer: found.peer };
@@ -267,9 +276,12 @@ function nodesEdit(opts) {
267
276
  const node = resolveStoredNode(st, opts.ref || opts.name);
268
277
  if (!node) { log(`nodes edit: nodo sconosciuto "${opts.ref || opts.name || ''}"`); return { code: 1, reason: 'unknown-node' }; }
269
278
  const supplied = opts.patch && typeof opts.patch === 'object' ? opts.patch : {};
279
+ // Lo scope celle vale per QUALUNQUE peer accoppiato, in entrambe le
280
+ // direzioni: riguarda chi accede alle celle di questo nodo, non chi ha
281
+ // aperto il tunnel.
270
282
  const allowed = node.direction === 'inbound'
271
- ? new Set(['label', 'visibility', 'selected'])
272
- : new Set(['label', 'ssh', 'sshPort', 'autostart']);
283
+ ? new Set(['label', 'visibility', 'selected', 'cellVisibility', 'cells'])
284
+ : new Set(['label', 'ssh', 'sshPort', 'autostart', 'cellVisibility', 'cells']);
273
285
  const keys = Object.keys(supplied);
274
286
  const invalid = keys.find((key) => !allowed.has(key));
275
287
  if (invalid) {
@@ -298,6 +310,13 @@ function nodesEdit(opts) {
298
310
  log('nodes edit: visibility non valida'); return { code: 1, reason: 'invalid-visibility' };
299
311
  }
300
312
  if (patch.visibility !== 'selected') delete patch.selected;
313
+ // Stessa simmetria per lo scope celle. `delete` non basterebbe: updateNode
314
+ // fonde patch e nodo esistente, quindi le celle concesse prima
315
+ // sopravviverebbero alla modalita' che le revoca — e tornerebbero buone al
316
+ // primo ritorno a `selected`, concedendo in silenzio cio' che l'operatore
317
+ // credeva di aver tolto. `undefined` invece le fa sparire, perche' parseNode
318
+ // ricostruisce il nodo da zero.
319
+ if (Object.hasOwn(patch, 'cellVisibility') && patch.cellVisibility !== 'selected') patch.cells = undefined;
301
320
 
302
321
  let next;
303
322
  try { next = store.updateNode(st, node.name, patch); }
@@ -475,6 +494,70 @@ async function nodesTest(opts) {
475
494
  };
476
495
  }
477
496
 
497
+ // `nodes test` senza riferimento: prova TUTTI i peer diretti e chiude con le
498
+ // incoerenze.
499
+ //
500
+ // Nasce da un caso reale: tre peer su quattro risultavano "Share enabled" e uno
501
+ // solo aveva davvero il canale inverso attivo. La diagnosi c'era gia' —
502
+ // `nodes test <nodo>` distingue OK, KO e passivo con precisione — ma andava
503
+ // lanciata un nodo alla volta, sapendo gia' di doverlo fare. Uno stato che
504
+ // mostra il DESIDERIO ("Share enabled") accanto a nessuna verifica non e' una
505
+ // bugia del codice: e' una domanda che nessuno pensa di fare.
506
+ //
507
+ // Il valore non e' l'elenco, che si otteneva anche prima: e' l'ultima riga.
508
+ async function nodesTestAll(opts) {
509
+ const log = opts.log || console.log;
510
+ const { nodesPath } = resolveNodePaths(opts);
511
+ const st = store.loadStore(nodesPath);
512
+ const nodes = st && Array.isArray(st.nodes) ? st.nodes : [];
513
+ if (!nodes.length) {
514
+ log('nodes test: nessun nodo configurato');
515
+ return { code: 0, results: [] };
516
+ }
517
+ // In parallelo: un peer irraggiungibile consuma il proprio timeout, e in
518
+ // sequenza quattro nodi spenti farebbero sembrare rotto il comando.
519
+ const results = await Promise.all(nodes.map(async (node) => {
520
+ const lines = [];
521
+ let outcome;
522
+ try {
523
+ outcome = await nodesTest({ ...opts, ref: node.name, log: (line) => lines.push(String(line)) });
524
+ } catch (e) {
525
+ outcome = { code: 1, result: 'errore', detail: String(e && e.message || e) };
526
+ }
527
+ return {
528
+ name: node.name,
529
+ shared: node.shared === true,
530
+ result: outcome.result || 'sconosciuto',
531
+ line: lines.length ? lines[lines.length - 1] : `nodes test [${node.name}]: esito non riportato`,
532
+ };
533
+ }));
534
+ for (const entry of results) log(entry.line);
535
+
536
+ // L'incoerenza che conta: dichiarato condiviso, canale non verificabile.
537
+ // Non e' per forza un difetto — un dispositivo spento finisce qui — ma e'
538
+ // esattamente cio' che lo stato mostrato non dice.
539
+ const declaredNotProven = results.filter((entry) => entry.shared && entry.result !== 'ok');
540
+ if (declaredNotProven.length) {
541
+ log('');
542
+ log(`nodes test: ${declaredNotProven.length} nodo/i risultano condivisi ma il canale inverso non risponde: ${declaredNotProven.map((entry) => entry.name).join(', ')}`);
543
+ // La riga esiste per essere letta da chi ha il problema. Dice DOVE
544
+ // guardare (qui, sull'hub), COSA deve esserci (le porte del pool di quel
545
+ // dispositivo) e la parola da cercare (permitlisten).
546
+ //
547
+ // La concessione sta sull'HUB, non sul dispositivo: il dispositivo CHIEDE
548
+ // il bind reverse, ma e' lo sshd di questa macchina a concederlo o negarlo
549
+ // in base alla riga di `authorized_keys` che porta la sua chiave. Mandare
550
+ // l'operatore a cercare sul dispositivo sarebbe peggio di una riga vaga —
551
+ // lo si manderebbe dalla parte sbagliata, che e' esattamente il modo in cui
552
+ // questo difetto e' rimasto aperto per giorni.
553
+ log('nodes test: "Share attivo" e\' lo stato desiderato, non una verifica. Un dispositivo spento finisce qui; se e\' acceso, il bind inverso viene rifiutato prima di salire: su QUESTO hub, la chiave di quel dispositivo in ~/.ssh/authorized_keys deve concedere le porte del suo pool (opzione SSH permitlisten).');
554
+ }
555
+ // Esito 0 anche con incoerenze: questo comando RIFERISCE, non giudica. Un
556
+ // codice diverso da zero farebbe fallire ogni script che lo usa per stampare
557
+ // lo stato, e un dispositivo spento non e' un errore dell'installazione.
558
+ return { code: 0, results, declaredNotProven: declaredNotProven.map((entry) => entry.name) };
559
+ }
560
+
478
561
  // probe HTTP di default (fetch built-in, Node >=18). Ritorna {ok, status}.
479
562
  async function defaultHttpProbe(url, headers) {
480
563
  const res = await fetch(url, { headers, redirect: 'manual' });
@@ -576,7 +659,7 @@ function nodesSetToken(opts) {
576
659
  }
577
660
 
578
661
  module.exports = {
579
- nodesAdd, nodesList, nodesInspect, nodesEdit, nodesRemove, nodesTest,
662
+ nodesAdd, nodesList, nodesInspect, nodesEdit, nodesRemove, nodesTest, nodesTestAll,
580
663
  nodesUp, nodesDown, nodesRestart, nodesSetToken,
581
664
  // helper esposti per test/riuso
582
665
  assignLocalPort, bindLocalPort, reserveLocalPort, defaultKeyPath, ensureKey, readSecretToken,
@@ -108,8 +108,17 @@ const NODE_KEYS = new Set([
108
108
  'name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'identityFile',
109
109
  'roles', 'rolesKnown', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
110
110
  'direction', 'reversePort', 'shared', 'label', 'reversePool',
111
+ // Quali CELLE di questo nodo il peer puo' vedere. Distinto da `visibility`,
112
+ // che governa il TRANSITO (attraverso chi passa il traffico) e non l'accesso.
113
+ 'cellVisibility', 'cells',
111
114
  ]);
112
115
 
116
+ // Nome di cella: stessa forma usata da fleet/definitions.js, cells/routes.js e
117
+ // mcp/cells.js. E' `cell` la chiave del permesso — unica nel nodo e immutabile —
118
+ // non `tmuxSession`, che e' derivata e ammette override non canonici.
119
+ const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
120
+ const MAX_CELLS = 128;
121
+
113
122
  const REVERSE_POOL_SLOT_STATES = new Set(['active', 'ready', 'reserved', 'draining', 'quarantined', 'retired']);
114
123
  const REVERSE_POOL_VERIFICATIONS = new Set(['verified', 'unverifiable', 'missing', 'invalidated']);
115
124
  const REVERSE_POOL_PHASES = new Set(['active', 'prepared', 'switched', 'abandoned']);
@@ -265,6 +274,28 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
265
274
  if (selected.some((id) => typeof id !== 'string' || !NODE_ID_RE.test(id))) return null;
266
275
  out.selected = selected;
267
276
  }
277
+ // Scope celle. Il default e' `all` quando il campo e' ASSENTE: un default
278
+ // fail-closed renderebbe muta l'intera flotta al primo aggiornamento, senza
279
+ // che nessuno abbia deciso nulla. Restringere deve restare un atto esplicito.
280
+ // `selected` con lista vuota significa invece "nessuna cella", ed e' proprio
281
+ // per distinguerlo da "campo assente" che la modalita' e' un campo a parte.
282
+ if (n.cellVisibility !== undefined) {
283
+ if (!['all', 'none', 'selected'].includes(n.cellVisibility)) return null;
284
+ out.cellVisibility = n.cellVisibility;
285
+ } else {
286
+ out.cellVisibility = 'all';
287
+ }
288
+ if (n.cells !== undefined) {
289
+ // Elencare celle senza dichiarare la modalita' e' ambiguo: l'elenco
290
+ // sembrerebbe un permesso mentre non lo e'. Meglio rifiutare che indovinare.
291
+ if (out.cellVisibility !== 'selected') return null;
292
+ if (!Array.isArray(n.cells) || n.cells.length > MAX_CELLS) return null;
293
+ const cells = [...new Set(n.cells)];
294
+ if (cells.some((id) => typeof id !== 'string' || !CELL_ID_RE.test(id))) return null;
295
+ out.cells = cells;
296
+ } else if (out.cellVisibility === 'selected') {
297
+ out.cells = [];
298
+ }
268
299
  // Campo opzionale per compatibilita' con gli store 0.8.0: se assente, ssh
269
300
  // continua a usare la porta risolta da ~/.ssh/config o il default OpenSSH.
270
301
  if (n.sshPort !== undefined) out.sshPort = n.sshPort;
@@ -577,6 +608,10 @@ function redactNode(n) {
577
608
  };
578
609
  if (n.identityFile || n.keyPath) out.hasIdentity = true;
579
610
  if (n.visibility === 'selected') out.selected = [...(n.selected || [])];
611
+ // Lo scope celle non e' un segreto: e' una decisione dell'operatore e va
612
+ // mostrata, come `selected`. La Settings UI deve poterla leggere per dirla.
613
+ out.cellVisibility = n.cellVisibility || 'all';
614
+ if (out.cellVisibility === 'selected') out.cells = [...(n.cells || [])];
580
615
  if (n.sshPort !== undefined) out.sshPort = n.sshPort;
581
616
  if (n.nodeId) out.nodeId = n.nodeId;
582
617
  if (n.reversePool) {
@@ -66,7 +66,7 @@ function createAsksStore(opts = {}) {
66
66
  }
67
67
 
68
68
  function openCount() {
69
- return load().filter((a) => !a.answered).length;
69
+ return load().filter((a) => !a.answered && !a.dismissed).length;
70
70
  }
71
71
 
72
72
  function create({ question, options, session }) {
@@ -99,7 +99,7 @@ function createAsksStore(opts = {}) {
99
99
 
100
100
  function list({ open = false } = {}) {
101
101
  const all = load();
102
- return (open ? all.filter((a) => !a.answered) : all.slice())
102
+ return (open ? all.filter((a) => !a.answered && !a.dismissed) : all.slice())
103
103
  .map((a) => ({ ...a, ...(a.options ? { options: a.options.slice() } : {}) }));
104
104
  }
105
105
 
@@ -114,6 +114,10 @@ function createAsksStore(opts = {}) {
114
114
  function claim(id) {
115
115
  const ask = get(id);
116
116
  if (!ask) return { ok: false, reason: 'unknown' };
117
+ // Un ask dismissato non e' piu' risponibile: senza questa guardia la
118
+ // sequenza dismiss -> claim -> commit lascia lo stato ibrido
119
+ // `dismissed && answered` (F-02).
120
+ if (ask.dismissed) return { ok: false, reason: 'dismissed' };
117
121
  if (ask.answered) return { ok: false, reason: 'answered' };
118
122
  if (answering.has(id)) return { ok: false, reason: 'answering' };
119
123
  answering.add(id);
@@ -137,6 +141,22 @@ function createAsksStore(opts = {}) {
137
141
  return true;
138
142
  }
139
143
 
144
+ // Dismiss (scarta la domanda): NON cancella la riga — la marca `dismissed`,
145
+ // perche' lo storico serve (come `answered`). Non compete con una answer in
146
+ // corso: 409 se c'e' un claim attivo (answering). Idempotente: scartare due
147
+ // volte non e' un errore. Un ask dismissato non e' piu' open (non appare in
148
+ // list({open:true})/openCount) ma resta nello storico (list({open:false})).
149
+ function dismiss(id) {
150
+ const ask = get(id);
151
+ if (!ask) return { ok: false, reason: 'unknown' };
152
+ if (answering.has(id)) return { ok: false, reason: 'answering' };
153
+ if (ask.dismissed) return { ok: true, ask: { ...ask }, idempotent: true };
154
+ ask.dismissed = true;
155
+ ask.dismissedTs = now();
156
+ save();
157
+ return { ok: true, ask: { ...ask } };
158
+ }
159
+
140
160
  // Retrocompat (usata nei test di store): claim+commit in un colpo.
141
161
  function markAnswered(id, text) {
142
162
  const c = claim(id);
@@ -144,7 +164,7 @@ function createAsksStore(opts = {}) {
144
164
  return commit(id, text);
145
165
  }
146
166
 
147
- return { create, get, list, openCount, claim, release, commit, markAnswered, validate, filePath, MAX_OPEN };
167
+ return { create, get, list, openCount, claim, release, commit, markAnswered, dismiss, validate, filePath, MAX_OPEN };
148
168
  }
149
169
 
150
170
  module.exports = { createAsksStore };
@@ -13,6 +13,13 @@ function createNotifier({ hub, push }) {
13
13
  urgency: frame.urgency === 'high' ? 'high' : 'normal',
14
14
  ...(frame.session ? { session: String(frame.session) } : {}),
15
15
  ...(frame.lang ? { lang: String(frame.lang) } : {}),
16
+ // Provenienza federata. `originNode` e' VERIFICATO (catena visited
17
+ // costruita dal server); `originCell` e' soltanto ATTESTATO dal nodo di
18
+ // origine, che l'ha verificata da se'. La differenza va fino alla UI: chi
19
+ // guarda deve poter distinguere cio' che e' provato da cio' che e'
20
+ // dichiarato, altrimenti il mittente diventa un campo di phishing.
21
+ ...(frame.originNode ? { originNode: String(frame.originNode) } : {}),
22
+ ...(frame.originCell ? { originCell: String(frame.originCell) } : {}),
16
23
  ts: Date.now(),
17
24
  });
18
25
  let pushed = 0;
@@ -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)' });
@@ -214,6 +286,25 @@ function notifyRoutes({ cfg, notifier, push, asks, paste, sessionExists }) {
214
286
  catch (e) { res.status(500).json({ error: String(e.message || e) }); }
215
287
  });
216
288
 
289
+ // Dismiss (scarta domanda): NON cancella la riga, la marca `dismissed` (lo
290
+ // storico serve). Stesso mutGate degli altri mutanti (F3: scrittura durevole).
291
+ // Idempotente; 404 se id inesistente; 409 se answering (claim attivo: non si
292
+ // scarta una risposta in corso). Emette il frame per le UI aperte come fa
293
+ // POST /asks con emitRaw: la card sparisce senza aspettare il poll.
294
+ r.delete('/asks/:id', mutGate, (req, res) => {
295
+ try {
296
+ const id = String(req.params.id || '');
297
+ const out = asks.dismiss(id);
298
+ if (!out.ok) {
299
+ if (out.reason === 'unknown') return res.status(404).json({ error: 'ask inesistente' });
300
+ if (out.reason === 'answering') return res.status(409).json({ error: 'risposta in corso: non si scarta un ask in answering' });
301
+ return res.status(500).json({ error: 'dismiss non riuscito' });
302
+ }
303
+ notifier.emitRaw({ type: 'ask-dismissed', id });
304
+ res.json({ dismissed: true, id });
305
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
306
+ });
307
+
217
308
  // Answer: READONLY floor (il paste e' una scrittura PTY). F2 (audit): il
218
309
  // ciclo e' claim atomico (open -> answering, sincrono, PRIMA dell'await del
219
310
  // paste) -> paste -> commit su successo / release su fallimento. Una sola
@@ -230,6 +321,7 @@ function notifyRoutes({ cfg, notifier, push, asks, paste, sessionExists }) {
230
321
  const claim = asks.claim(id);
231
322
  if (!claim.ok) {
232
323
  if (claim.reason === 'unknown') return res.status(404).json({ error: 'ask inesistente' });
324
+ if (claim.reason === 'dismissed') return res.status(409).json({ error: 'ask gia\' scartato (dismissed)' });
233
325
  if (claim.reason === 'answering') return res.status(409).json({ error: 'risposta gia\' in corso da un\'altra richiesta' });
234
326
  return res.status(409).json({ error: 'ask gia\' risposto' });
235
327
  }