@mmmbuto/nexuscrew 0.9.65 → 0.9.66

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,7 +11,7 @@
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-DkYRC1vV.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-DhZOap1-.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-Dwd37Z_R.css">
16
16
  </head>
17
17
  <body>
@@ -1 +1 @@
1
- {"version":"0.9.65"}
1
+ {"version":"0.9.66"}
@@ -85,6 +85,8 @@ Usage:
85
85
  nexuscrew nodes cells <name|nodeId> all|none|Cella1,Cella2
86
86
  [--selected NODE_ID,...]
87
87
  nexuscrew nodes share <name|nodeId> on|off [--json]
88
+ nexuscrew nodes publish-remote <name|nodeId> on --audience <instanceId,...> [--json]
89
+ nexuscrew nodes publish-remote <name|nodeId> off|status [--json]
88
90
  nexuscrew nodes panel <name|nodeId> on|off
89
91
  nexuscrew nodes live-host <name|nodeId> on|off
90
92
  nexuscrew nodes edit <name|nodeId> --access-role admin|user|nexushost
@@ -118,6 +120,7 @@ const CLI_VALUE_FLAGS = new Set([
118
120
  'name', 'local-name', 'local-label', 'identity-file', 'port', 'dir', 'events-receive',
119
121
  'access-role', 'access-revision',
120
122
  'events-access', 'node-events-access', 'ask-reply-access', 'files-read-access', 'peer-operator-access',
123
+ 'audience',
121
124
  ]);
122
125
 
123
126
  // valueFlags (Set|array opzionale): flag che consumano il token successivo nella
@@ -1367,6 +1370,34 @@ async function dispatchNodes(rest, flags, opts = {}) {
1367
1370
  return { code: 0 };
1368
1371
  } catch (error) { log(`nodes share: ${error.message}`); return { code: 1 }; }
1369
1372
  }
1373
+ // Host-only publication of a remote peer, one way, to an explicit audience of
1374
+ // our own admin nodes. NOT Share: it never calls the share path.
1375
+ if (sub === 'publish-remote') {
1376
+ const arg = rest[3];
1377
+ if (arg !== undefined && arg !== 'on' && arg !== 'off' && arg !== 'status') {
1378
+ log('nodes publish-remote: uso `nodes publish-remote <nodo> on --audience <id,id>|off|status`');
1379
+ return { code: 1 };
1380
+ }
1381
+ if (arg === 'on') {
1382
+ if (typeof flags.audience !== 'string' || !flags.audience.trim()) {
1383
+ log('nodes publish-remote: `on` richiede --audience <instanceId,instanceId,...> (gli id dei nodi admin che vedranno il peer)');
1384
+ return { code: 1 };
1385
+ }
1386
+ } else if (flags.audience !== undefined) {
1387
+ log('nodes publish-remote: --audience si usa solo con `on`');
1388
+ return { code: 1 };
1389
+ }
1390
+ const audience = arg === 'on' ? String(flags.audience).split(',').map((x) => x.trim()).filter(Boolean) : undefined;
1391
+ const expectedRevision = flags['access-revision'] === undefined ? undefined : Number(flags['access-revision']);
1392
+ if (expectedRevision !== undefined && !Number.isInteger(expectedRevision)) {
1393
+ log('nodes publish-remote: --access-revision vuole un intero (la revision letta da `nodes access`)');
1394
+ return { code: 1 };
1395
+ }
1396
+ return { code: nodesCmds.nodesPublishRemote({
1397
+ ...opts, log, ref, json: flags.json === true, audience, expectedRevision,
1398
+ enabled: arg === 'on' ? true : (arg === 'off' ? false : undefined),
1399
+ }).code };
1400
+ }
1370
1401
  if (sub === 'invite') {
1371
1402
  const body = {
1372
1403
  ...(flags.ssh !== undefined ? { ssh: flags.ssh } : {}),
@@ -225,15 +225,34 @@ function grantsOf(peer) {
225
225
  }
226
226
 
227
227
 
228
- // Read view of ONE peer for listing endpoints and the settings UI: the
229
- // derived label, the configured marker and the EFFECTIVE grant vector. The
230
- // vector is flat and always complete (denied for records that are not
231
- // configured), so a caller never has to guess defaults and no token or
232
- // secret is ever included: grants are decisions, not credentials.
228
+ // The vector the resource gate APPLIES to a record: the stored booleans and the
229
+ // cell scope, read exactly the way resource-acl.allowedByClass reads them — no
230
+ // `accessConfigured` marker, no coherence check. This is what a VIEW must show.
231
+ // `grantsOf` is different on purpose: it answers "was this vector set
232
+ // deliberately?" and feeds decisions that demand a configured record (the event
233
+ // stream, the ASK target check); it must keep denying for a legacy record.
234
+ // An absent scope is `all`: that is what the parser stores and what the gate
235
+ // sees (it only closes the cell-scoped classes on an explicit `none`).
236
+ function effectiveGrants(peer) {
237
+ const p = isPlainObject(peer) ? peer : {};
238
+ const grants = { cellVisibility: CELL_VISIBILITY.includes(p.cellVisibility) ? p.cellVisibility : 'all' };
239
+ if (grants.cellVisibility === 'selected') grants.cells = Array.isArray(p.cells) ? [...p.cells] : [];
240
+ for (const key of BOOLEAN_GRANTS) grants[key] = p[key] === true;
241
+ return grants;
242
+ }
243
+
244
+ // Read view of ONE peer for listing endpoints and the settings UI: the derived
245
+ // label and the configured marker (PROVENANCE: how the vector was set) and the
246
+ // EFFECTIVE grant vector (what the gate applies, see effectiveGrants). The
247
+ // vector is flat and always complete, so a caller never has to guess defaults,
248
+ // and no token or secret is ever included: grants are decisions, not
249
+ // credentials. A legacy record with a grant and no marker reads "unconfigured"
250
+ // AND shows the grant on, because both are true.
233
251
  function accessView(peer) {
234
252
  const view = grantsOf(peer);
235
- const access = { cellVisibility: view.grants.cellVisibility };
236
- for (const key of BOOLEAN_GRANTS) access[key] = view.grants[key] === true;
253
+ const effective = effectiveGrants(peer);
254
+ const access = { cellVisibility: effective.cellVisibility };
255
+ for (const key of BOOLEAN_GRANTS) access[key] = effective[key];
237
256
  return { accessLabel: view.label, accessConfigured: view.configured, access };
238
257
  }
239
258
 
@@ -252,6 +271,7 @@ module.exports = {
252
271
  accessView,
253
272
  applyPreset,
254
273
  deriveLabel,
274
+ effectiveGrants,
255
275
  grantsOf,
256
276
  isCompleteAdminVector,
257
277
  isConfigured,
@@ -336,9 +336,22 @@ function nodesInspect(opts) {
336
336
  // inferred from defaults.
337
337
  if (peer.kind === 'direct') {
338
338
  const view = accessPresets.grantsOf(peer);
339
+ // The label says how the vector was set; the vector is what the resource
340
+ // gate applies, so it is shown as stored even for a legacy record.
341
+ const effective = accessPresets.effectiveGrants(peer);
339
342
  log(`accesso: ${view.label}`);
340
- log(`grant: ${ACCESS_GRANT_KEYS.map((key) => `${key}=${view.grants[key] ? 'on' : 'off'}`).join(' ')}`);
341
- log(`scope: celle ${view.grants.cellVisibility}`);
343
+ log(`grant: ${ACCESS_VIEW_KEYS.map((key) => `${key}=${effective[key] ? 'on' : 'off'}`).join(' ')}`);
344
+ log(`scope: celle ${effective.cellVisibility}`);
345
+ if (!view.configured) {
346
+ log('nota: record non configurato: i grant mostrati sono quelli che il gate delle risorse applica; il feed eventi richiede un vettore configurato');
347
+ }
348
+ if (peer.publishRemote === true) {
349
+ const names = (peer.publishRemoteAudience || []).map((id) => {
350
+ const reader = out.nodes.find((n) => n.nodeId === id);
351
+ return reader ? `${reader.name} (${id})` : id;
352
+ });
353
+ log(`pubblicato: solo-host, audience: ${names.join(', ')}`);
354
+ }
342
355
  for (const line of feedLinesFor(opts.feedDiagnostics, peer.nodeId)) log(line);
343
356
  }
344
357
  log(`azioni: ${Object.keys(peer.actions || {}).filter((key) => peer.actions[key]).join(', ')}`);
@@ -351,6 +364,8 @@ function nodesInspect(opts) {
351
364
  // validated write. The label (admin/user/nexushost/custom) is derived from the
352
365
  // grants; nothing here stores or reads a role.
353
366
  const ACCESS_GRANT_KEYS = ['eventsAccess', 'nodeEventsAccess', 'askReplyAccess', 'filesReadAccess', 'peerOperatorAccess'];
367
+ // What the views print: the five grant flags of the edit commands plus the two the panel and live-host classes read.
368
+ const ACCESS_VIEW_KEYS = [...ACCESS_GRANT_KEYS, 'panelAccess', 'liveHostAccess'];
354
369
  const ACCESS_EDIT_KEYS = new Set(['accessRole', ...ACCESS_GRANT_KEYS]);
355
370
 
356
371
  const ACCESS_VECTOR_FIELDS = ['cellVisibility', 'cells', 'panelAccess', 'liveHostAccess'];
@@ -545,6 +560,50 @@ function nodesEdit(opts) {
545
560
  return { code: 0, name: node.name, node: edited, restarted: wasUp };
546
561
  }
547
562
 
563
+ // --- nodes publish-remote ---------------------------------------------------
564
+ // Host-only publication of a REMOTE peer: `nodes publish-remote <peer> on
565
+ // --audience <instanceId,...>` / `off`. Separate from Share on purpose: it
566
+ // never calls the share path, never restarts the tunnel and never touches the
567
+ // reverse pool. It edits the store (one validated, compare-and-set write, after
568
+ // a full backup on `on`); the running server reads the store per request.
569
+ function nodesPublishRemote(opts) {
570
+ const log = opts.log || console.log;
571
+ const { nodesPath } = resolveNodePaths(opts);
572
+ let st;
573
+ try { st = store.loadStoreStrict(nodesPath); }
574
+ catch (e) { log(`nodes publish-remote: ${e.message}`); return { code: 1, reason: 'store invalido', status: e.status || 500, errorCode: e.code || null }; }
575
+ const node = resolveStoredNode(st, opts.ref || opts.name);
576
+ if (!node) { log(`nodes publish-remote: nodo sconosciuto "${opts.ref || opts.name || ''}"`); return { code: 1, reason: 'unknown-node' }; }
577
+ if (opts.enabled === undefined) {
578
+ // Read-only status.
579
+ const state = node.publishRemote === true
580
+ ? { name: node.name, enabled: true, audience: [...node.publishRemoteAudience] }
581
+ : { name: node.name, enabled: false, audience: [] };
582
+ if (opts.json) log(JSON.stringify(state, null, 2));
583
+ else log(`nodes publish-remote [${node.name}]: ${state.enabled ? `on · audience ${state.audience.join(', ')}` : 'off'}`);
584
+ return { code: 0, ...state };
585
+ }
586
+ if (isReadonly(opts)) { log('nodes publish-remote: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
587
+ let res;
588
+ try {
589
+ res = store.setPublishRemoteCas({
590
+ filePath: nodesPath, name: node.name, enabled: opts.enabled === true,
591
+ audience: opts.audience, expectedRevision: opts.expectedRevision,
592
+ });
593
+ } catch (e) { log(`nodes publish-remote: ${e.message}`); return { code: 1, reason: 'refused', error: e.message }; }
594
+ if (!res.ok) {
595
+ log(`nodes publish-remote: revision delle concessioni superata (attuale ${res.revision}) — rileggi e riprova`);
596
+ return { code: 1, reason: 'revision-conflict', revision: res.revision };
597
+ }
598
+ if (opts.json) log(JSON.stringify(res, null, 2));
599
+ else if (res.enabled) {
600
+ log(`nodes publish-remote [${res.name}]: on · audience ${res.audience.join(', ')}`);
601
+ log(' grant verso il peer azzerati (celle none, eventi/file/ask/operatore/pannello/live host off, eventsReceive off); store a schema 4');
602
+ if (res.backup) log(` backup dello store: ${res.backup.path} (sha256 ${res.backup.sha256})`);
603
+ } else log(`nodes publish-remote [${res.name}]: off${res.unchanged ? ' (era gia\' spento)' : ' · i grant restano azzerati'}`);
604
+ return { code: 0, ...res };
605
+ }
606
+
548
607
  // --- nodes access: read-only inventory --------------------------------------
549
608
  // Shows what every peer may currently do on this node, with the revision of
550
609
  // the policy. Peers whose grants were never configured are listed as such:
@@ -566,7 +625,7 @@ function nodesAccess(opts = {}) {
566
625
  if (view.peers.length === 0) log('nessun peer diretto');
567
626
  for (const peer of view.peers) {
568
627
  log(`${peer.name}${peer.nodeId ? ` (${peer.nodeId})` : ''}: ${peer.label}`);
569
- log(` ${ACCESS_GRANT_KEYS.map((key) => `${key}=${peer.grants[key] ? 'on' : 'off'}`).join(' ')} · celle ${peer.grants.cellVisibility}`);
628
+ log(` ${ACCESS_VIEW_KEYS.map((key) => `${key}=${peer.grants[key] ? 'on' : 'off'}`).join(' ')} · celle ${peer.grants.cellVisibility}`);
570
629
  }
571
630
  if (legacy.length > 0) log(`da configurare esplicitamente (${legacy.length}): ${legacy.map((p) => p.name).join(', ')}`);
572
631
  return { code: 0, ...view, unconfigured: legacy.map((p) => p.name) };
@@ -880,7 +939,7 @@ function nodesSetToken(opts) {
880
939
  module.exports = {
881
940
  fetchFeedDiagnostics, feedLinesFor,
882
941
 
883
- nodesAccess, ACCESS_GRANT_KEYS,
942
+ nodesAccess, nodesPublishRemote, ACCESS_GRANT_KEYS,
884
943
  nodesAdd, nodesList, nodesInspect, nodesEdit, nodesRemove, nodesTest, nodesTestAll,
885
944
  nodesUp, nodesDown, nodesRestart, nodesSetToken,
886
945
  // helper esposti per test/riuso
@@ -20,8 +20,16 @@ const crypto = require('node:crypto');
20
20
  const reversePool = require('./reverse-pool.js');
21
21
  const identity = require('./identity.js');
22
22
  const accessPresets = require('./access-presets.js');
23
+ const { allowedByClass } = require('../proxy/resource-acl.js');
23
24
 
24
25
  const SCHEMA_VERSION = 3;
26
+ // Schema 4 = schema 3 + the host-only publication fields of a peer
27
+ // (`publishRemote`, `publishRemoteAudience`). It is written ONLY when that
28
+ // mode is first enabled, after a full backup of the store: until then every
29
+ // store stays at the schema it already has, and a 0.9.65 binary keeps reading
30
+ // it. A 0.9.65 binary cannot read a schema 4 store (closed schema): going back
31
+ // means restoring the backup taken at the opt-in, never a silent downgrade.
32
+ const HOST_ONLY_SCHEMA_VERSION = 4;
25
33
  const PREVIOUS_SCHEMA_VERSION = 2;
26
34
  const LEGACY_SCHEMA_VERSION = 1;
27
35
  const MAX_NODES = 64;
@@ -156,6 +164,11 @@ const NODE_KEYS = new Set([
156
164
  // coherent "custom" preset instead of "not configured". It is provenance,
157
165
  // not a permission — it grants nothing and handlers never read it as a role.
158
166
  'accessConfigured',
167
+ // Host-only publication of a REMOTE peer (schema 4). Not Share: Share
168
+ // publishes THIS node on the peer's hub through a reverse (-R) channel; this
169
+ // publishes the peer to an explicit audience of our own admin nodes, over the
170
+ // existing private -L, one way only (see hostOnlyViolation).
171
+ 'publishRemote', 'publishRemoteAudience',
159
172
  ]);
160
173
 
161
174
  // Nome di cella: stessa forma usata da fleet/definitions.js, cells/routes.js e
@@ -247,6 +260,28 @@ function reversePoolDefault(base, { verification = 'unverifiable', generation =
247
260
  };
248
261
  }
249
262
 
263
+ // Invariants of a host-only peer, on a node with its defaults applied.
264
+ // Returns the first violation as text, or null. This is the single source of
265
+ // truth: parseNode refuses a record that breaks it (so no write path can
266
+ // persist one) and the mutators use it to explain WHY they refuse.
267
+ // The vector is the one the authorization code reads: allowedByClass looks at
268
+ // these exact fields, never at a label.
269
+ function hostOnlyViolation(node) {
270
+ if (!node || node.publishRemote !== true) return null;
271
+ if (node.direction !== 'outbound') return 'vale solo per un peer in uscita (outbound)';
272
+ if (node.shared === true) return 'e\' incompatibile con Share acceso su questo peer';
273
+ const audience = node.publishRemoteAudience;
274
+ if (!Array.isArray(audience) || audience.length === 0) return 'l\'audience non puo\' essere vuota';
275
+ if (node.nodeId && audience.includes(node.nodeId)) return 'l\'audience non puo\' contenere il peer pubblicato';
276
+ if (node.eventsReceive === true) return 'eventsReceive deve restare spento';
277
+ if (node.cellVisibility !== 'none' || (Array.isArray(node.cells) && node.cells.length > 0)) {
278
+ return 'le celle di questo nodo devono restare invisibili al peer (cellVisibility none)';
279
+ }
280
+ const open = accessPresets.BOOLEAN_GRANTS.filter((key) => node[key] === true);
281
+ if (open.length > 0) return `i grant verso il peer devono restare tutti a zero (aperti: ${open.join(', ')})`;
282
+ return null;
283
+ }
284
+
250
285
  function parseNode(n, schemaVersion = SCHEMA_VERSION) {
251
286
  if (!n || typeof n !== 'object' || Array.isArray(n)) return null;
252
287
  for (const k of Object.keys(n)) { if (!NODE_KEYS.has(k)) return null; } // schema chiuso
@@ -434,6 +469,28 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
434
469
  out.keyConflict = { seen: c.seen, at: c.at, source: c.source };
435
470
  }
436
471
  if (label) out.label = label;
472
+ if (n.publishRemote !== undefined || n.publishRemoteAudience !== undefined) {
473
+ // Only a schema 4 store may carry the fields: a store written at 1-3 with
474
+ // them is not something this code ever produced.
475
+ if (schemaVersion < HOST_ONLY_SCHEMA_VERSION) return null;
476
+ if (n.publishRemote !== undefined && typeof n.publishRemote !== 'boolean') return null;
477
+ let audience;
478
+ if (n.publishRemoteAudience !== undefined) {
479
+ if (!Array.isArray(n.publishRemoteAudience) || n.publishRemoteAudience.length > MAX_NODES) return null;
480
+ audience = [...new Set(n.publishRemoteAudience)];
481
+ if (audience.some((id) => typeof id !== 'string' || !NODE_ID_RE.test(id))) return null;
482
+ audience.sort();
483
+ }
484
+ if (n.publishRemote === true) {
485
+ out.publishRemote = true;
486
+ out.publishRemoteAudience = audience || [];
487
+ if (hostOnlyViolation(out) !== null) return null;
488
+ } else {
489
+ // Off: no audience may linger (a stale list would read as a grant).
490
+ if (audience && audience.length > 0) return null;
491
+ if (n.publishRemote === false) out.publishRemote = false;
492
+ }
493
+ }
437
494
  return out;
438
495
  }
439
496
 
@@ -464,8 +521,8 @@ function parseStore(raw) {
464
521
  return null;
465
522
  }
466
523
 
467
- if (![SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION].includes(d.schemaVersion)) return null;
468
- const rootKeys = d.schemaVersion === SCHEMA_VERSION
524
+ if (![HOST_ONLY_SCHEMA_VERSION, SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION].includes(d.schemaVersion)) return null;
525
+ const rootKeys = d.schemaVersion >= SCHEMA_VERSION
469
526
  ? new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous', 'reversePoolAnchor', 'reversePoolLedgerInitialized', 'accessRevision', 'adminReceptionMigration'])
470
527
  : new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous', 'adminReceptionMigration']);
471
528
  if (Object.keys(d).some((key) => !rootKeys.has(key))) return null;
@@ -509,7 +566,7 @@ function parseStore(raw) {
509
566
  // advances it to 1. It is kept out of the parsed object when absent so a
510
567
  // plain load never rewrites the file with a field nobody set.
511
568
  if (d.accessRevision !== undefined) {
512
- if (d.schemaVersion !== SCHEMA_VERSION) return null;
569
+ if (d.schemaVersion < SCHEMA_VERSION) return null;
513
570
  if (!Number.isInteger(d.accessRevision) || d.accessRevision < 0) return null;
514
571
  out.accessRevision = d.accessRevision;
515
572
  }
@@ -519,7 +576,7 @@ function parseStore(raw) {
519
576
  if (!rdv) return null;
520
577
  out.rendezvous = rdv;
521
578
  }
522
- if (d.schemaVersion === SCHEMA_VERSION) {
579
+ if (d.schemaVersion >= SCHEMA_VERSION) {
523
580
  // This marker distinguishes a fresh v3 peer (which may have received a
524
581
  // pool from another hub) from a hub that has once owned allocations. If
525
582
  // the latter loses its anchor, it must not silently start a new ledger.
@@ -665,7 +722,14 @@ function getNode(store, name) {
665
722
  }
666
723
 
667
724
  function mutationSchemaVersion(store) {
668
- return store && store.schemaVersion === SCHEMA_VERSION ? SCHEMA_VERSION : PREVIOUS_SCHEMA_VERSION;
725
+ // A schema 3 or 4 store keeps ITS schema: writing 3 over a 4 store would
726
+ // silently drop the host-only fields (and parse would refuse the record).
727
+ return store && store.schemaVersion >= SCHEMA_VERSION ? store.schemaVersion : PREVIOUS_SCHEMA_VERSION;
728
+ }
729
+
730
+ // The schema a write that needs at least v3 must keep: never lower than the store's own.
731
+ function atLeastV3(store) {
732
+ return store && store.schemaVersion > SCHEMA_VERSION ? store.schemaVersion : SCHEMA_VERSION;
669
733
  }
670
734
 
671
735
  function addNode(store, entry) {
@@ -706,7 +770,12 @@ function setNodeToken(store, name, token) {
706
770
  function updateNode(store, name, patch) {
707
771
  const idx = store.nodes.findIndex((n) => n.name === name);
708
772
  if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
709
- const parsed = parseNode({ ...store.nodes[idx], ...patch }, mutationSchemaVersion(store));
773
+ const probe = { ...store.nodes[idx], ...patch };
774
+ // While the host-only mode is on, an edit that would break its invariants is
775
+ // refused WITH the reason (parseNode alone would only say "invalid").
776
+ const why = hostOnlyViolation(probe);
777
+ if (why) throw new Error(`publish-remote attivo su "${name}": ${why}`);
778
+ const parsed = parseNode(probe, mutationSchemaVersion(store));
710
779
  if (!parsed) throw new Error('aggiornamento nodo non valido');
711
780
  const nodes = store.nodes.slice();
712
781
  nodes[idx] = parsed;
@@ -728,6 +797,9 @@ function setPeerAccessGrants(store, name, grants) {
728
797
  if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
729
798
  const check = accessPresets.validateGrants(grants);
730
799
  if (!check.ok) throw new Error(`concessioni non valide: ${check.errors.join('; ')}`);
800
+ if (store.nodes[idx].publishRemote === true) {
801
+ throw new Error(`publish-remote attivo su "${name}": i grant verso questo nodo restano azzerati finche' la modalita' e' accesa (spegnila con \`nodes publish-remote ${name} off\`)`);
802
+ }
731
803
  const next = { ...store.nodes[idx] };
732
804
  next.cellVisibility = grants.cellVisibility;
733
805
  if (grants.cellVisibility === 'selected') next.cells = [...(grants.cells || [])];
@@ -736,7 +808,7 @@ function setPeerAccessGrants(store, name, grants) {
736
808
  next.accessConfigured = true;
737
809
  const nodes = store.nodes.slice();
738
810
  nodes[idx] = next;
739
- const out = { ...store, schemaVersion: SCHEMA_VERSION, nodes };
811
+ const out = { ...store, schemaVersion: atLeastV3(store), nodes };
740
812
  out.accessRevision = accessRevisionOf(store) + 1;
741
813
  return out;
742
814
  }
@@ -765,9 +837,12 @@ function accessInventory(store) {
765
837
  name: peer.name,
766
838
  nodeId: peer.nodeId || null,
767
839
  direction: peer.direction,
840
+ // Provenance: was the vector set on purpose? Not what is granted.
768
841
  configured: view.configured,
769
842
  label: view.label,
770
- grants: view.grants,
843
+ // What the resource gate applies, also for a legacy record (see
844
+ // accessPresets.effectiveGrants).
845
+ grants: accessPresets.effectiveGrants(peer),
771
846
  };
772
847
  });
773
848
  return { revision: accessRevisionOf(store), peers };
@@ -814,6 +889,100 @@ function setPeerAccessPresetCas({ filePath, name, presetName, expectedRevision,
814
889
  eventsReceive: written.nodes.find((n) => n.name === name).eventsReceive === true };
815
890
  }
816
891
 
892
+ // --- Host-only publication of a remote peer ---------------------------------
893
+ // `nodes publish-remote <peer> on --audience <ids>|off`. NOT Share: nothing
894
+ // here touches the tunnel, the reverse pool or the peer's SSH session.
895
+ //
896
+ // The audience is an explicit list of instanceIds of OUR direct peers that
897
+ // already hold the operator grant (the same predicate the authorization code
898
+ // uses: allowedByClass('operator', peer)). Unknown ids, ids of peers without
899
+ // that grant and the published peer itself are refused.
900
+ function enablePublishRemote(st, name, audience) {
901
+ const idx = st.nodes.findIndex((n) => n.name === name);
902
+ if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
903
+ const node = st.nodes[idx];
904
+ if (node.direction !== 'outbound') throw new Error(`"${name}" non e' un peer in uscita: publish-remote vale solo per quelli`);
905
+ if (!node.token) throw new Error(`"${name}" non e' accoppiato (manca il token): niente da pubblicare`);
906
+ if (node.shared === true) throw new Error(`Share e' acceso su "${name}": publish-remote e' un'altra cosa, spegni prima Share`);
907
+ if (node.publishRemote === true) throw new Error(`publish-remote e' gia' acceso su "${name}": spegnilo per cambiare l'audience`);
908
+ if (!Array.isArray(audience) || audience.length === 0) {
909
+ throw new Error('audience vuota: indica gli instanceId dei nodi admin che possono vedere il peer (--audience id,id,...)');
910
+ }
911
+ const ids = [...new Set(audience)];
912
+ if (ids.length > MAX_NODES) throw new Error('audience troppo lunga');
913
+ for (const id of ids) {
914
+ if (typeof id !== 'string' || !NODE_ID_RE.test(id)) throw new Error(`instanceId non valido nell'audience: "${String(id).slice(0, 40)}"`);
915
+ if (id === node.nodeId) throw new Error(`l'audience non puo' contenere il peer pubblicato (${name})`);
916
+ const reader = st.nodes.find((n) => n.nodeId === id);
917
+ if (!reader) throw new Error(`instanceId sconosciuto nell'audience: ${id} (non e' un peer diretto di questo nodo)`);
918
+ if (reader.publishRemote === true) throw new Error(`"${reader.name}" e' a sua volta un peer solo-host: non puo' stare nell'audience`);
919
+ if (!allowedByClass('operator', reader)) {
920
+ throw new Error(`"${reader.name}" (${id}) non e' un nodo admin: manca peerOperatorAccess`);
921
+ }
922
+ }
923
+ // ONE record, ONE save: the publication and the zeroed vector travel together,
924
+ // so there is no instant at which the peer is published with open grants.
925
+ const next = { ...node, cellVisibility: 'none', eventsReceive: false, accessConfigured: true,
926
+ publishRemote: true, publishRemoteAudience: ids.sort() };
927
+ delete next.cells;
928
+ for (const key of accessPresets.BOOLEAN_GRANTS) next[key] = false;
929
+ const nodes = st.nodes.slice();
930
+ nodes[idx] = next;
931
+ const out = parseStore({ ...st, schemaVersion: Math.max(HOST_ONLY_SCHEMA_VERSION, st.schemaVersion), nodes,
932
+ accessRevision: accessRevisionOf(st) + 1 });
933
+ if (!out) throw new Error('publish-remote: lo store risultante non e\' valido (schema strict)');
934
+ return out;
935
+ }
936
+
937
+ // Off: the publication ends at once; the grants stay at zero (they are NOT
938
+ // restored to whatever they were before: re-opening anything is an explicit act).
939
+ function disablePublishRemote(st, name) {
940
+ const idx = st.nodes.findIndex((n) => n.name === name);
941
+ if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
942
+ const node = st.nodes[idx];
943
+ if (node.publishRemote !== true) return st;
944
+ const next = { ...node, publishRemote: false };
945
+ delete next.publishRemoteAudience;
946
+ const nodes = st.nodes.slice();
947
+ nodes[idx] = next;
948
+ const out = parseStore({ ...st, nodes, accessRevision: accessRevisionOf(st) + 1 });
949
+ if (!out) throw new Error('publish-remote: lo store risultante non e\' valido (schema strict)');
950
+ return out;
951
+ }
952
+
953
+ // Full, protected copy of the store taken right before the opt-in write. It is
954
+ // the rollback path: a 0.9.65 binary cannot read a schema 4 store.
955
+ function backupStoreFile(filePath, now = new Date()) {
956
+ const st = fs.lstatSync(filePath);
957
+ if (st.isSymbolicLink() || !st.isFile()) throw new Error('backup rifiutato: nodes.json non e\' un file regolare');
958
+ const data = fs.readFileSync(filePath);
959
+ const sha256 = crypto.createHash('sha256').update(data).digest('hex');
960
+ const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
961
+ const target = `${filePath}.pre-publish-remote-${stamp}-${sha256.slice(0, 8)}.bak`;
962
+ fs.writeFileSync(target, data, { mode: 0o600, flag: 'wx' }); // wx: never overwrite a previous backup
963
+ fs.chmodSync(target, 0o600);
964
+ return { path: target, sha256 };
965
+ }
966
+
967
+ // Compare-and-set write of the mode, keyed on the same revision as the access
968
+ // grants (turning it on changes them). `on` takes the backup BEFORE writing.
969
+ function setPublishRemoteCas({ filePath, name, enabled, audience, expectedRevision }) {
970
+ const current = loadStoreStrict(filePath);
971
+ if (expectedRevision !== undefined && (!Number.isInteger(expectedRevision) || accessRevisionOf(current) !== expectedRevision)) {
972
+ return { ok: false, conflict: true, revision: accessRevisionOf(current) };
973
+ }
974
+ const next = enabled ? enablePublishRemote(current, name, audience) : disablePublishRemote(current, name);
975
+ if (next === current) return { ok: true, name, enabled: false, unchanged: true, revision: accessRevisionOf(current), schemaVersion: current.schemaVersion };
976
+ const backup = enabled ? backupStoreFile(filePath) : null;
977
+ const written = atomicWriteStore(filePath, next);
978
+ const node = written.nodes.find((n) => n.name === name);
979
+ return {
980
+ ok: true, name, enabled: node.publishRemote === true,
981
+ audience: node.publishRemote === true ? [...node.publishRemoteAudience] : [],
982
+ revision: accessRevisionOf(written), schemaVersion: written.schemaVersion, backup,
983
+ };
984
+ }
985
+
817
986
  // One startup migration; later operational reception edits remain authoritative.
818
987
  function migrateAdminReception({ filePath, readonly = false }) {
819
988
  if (readonly || process.env.NEXUSCREW_READONLY === '1') return { migrated: false };
@@ -828,7 +997,7 @@ function migrateAdminReception({ filePath, readonly = false }) {
828
997
  changed += 1;
829
998
  }
830
999
  }
831
- if (changed) next = { ...next, schemaVersion: SCHEMA_VERSION,
1000
+ if (changed) next = { ...next, schemaVersion: atLeastV3(current),
832
1001
  accessRevision: accessRevisionOf(current) + 1 };
833
1002
  atomicWriteStore(filePath, { ...next, adminReceptionMigration: 1 });
834
1003
  return { migrated: true, changed };
@@ -845,7 +1014,7 @@ function upgradeToReversePoolSchema(store, anchor = undefined) {
845
1014
  if (anchor !== undefined && !parsedAnchor) throw new Error('reverse pool anchor non valida');
846
1015
  const next = {
847
1016
  ...current,
848
- schemaVersion: SCHEMA_VERSION,
1017
+ schemaVersion: atLeastV3(current),
849
1018
  ...(parsedAnchor ? { reversePoolLedgerInitialized: true, reversePoolAnchor: parsedAnchor } : {}),
850
1019
  };
851
1020
  const parsed = parseStore(next);
@@ -855,7 +1024,7 @@ function upgradeToReversePoolSchema(store, anchor = undefined) {
855
1024
 
856
1025
  function setNodeReversePool(store, name, value) {
857
1026
  const current = store && getNode(store, name);
858
- if (!store || store.schemaVersion !== SCHEMA_VERSION || !current) {
1027
+ if (!store || store.schemaVersion < SCHEMA_VERSION || !current) {
859
1028
  throw new Error('reverse pool richiede schema v3');
860
1029
  }
861
1030
  if (current.direction === 'inbound' && !parseReversePoolAnchor(store.reversePoolAnchor)) {
@@ -883,10 +1052,24 @@ function redactNode(n) {
883
1052
  panelAccess: n.panelAccess === true,
884
1053
  liveHostAccess: n.liveHostAccess === true,
885
1054
  visibility: n.visibility || 'network',
1055
+ // The grants this node concedes to the peer. They are decisions, not
1056
+ // credentials, and consumers (the CLI `nodes inspect`, the inventory) derive
1057
+ // the label and the effective vector from THESE fields: leaving them out made
1058
+ // `inspect` read an "unconfigured" peer and print every grant as off.
1059
+ accessConfigured: n.accessConfigured === true,
1060
+ eventsAccess: n.eventsAccess === true,
1061
+ nodeEventsAccess: n.nodeEventsAccess === true,
1062
+ askReplyAccess: n.askReplyAccess === true,
1063
+ filesReadAccess: n.filesReadAccess === true,
1064
+ peerOperatorAccess: n.peerOperatorAccess === true,
886
1065
  hasToken: !!n.token, // presenza, non il valore
887
1066
  paired: !!(n.token && n.acceptToken),
888
1067
  };
889
1068
  if (n.identityFile || n.keyPath) out.hasIdentity = true;
1069
+ if (n.publishRemote === true) {
1070
+ out.publishRemote = true;
1071
+ out.publishRemoteAudience = [...(n.publishRemoteAudience || [])];
1072
+ }
890
1073
  if (n.visibility === 'selected') out.selected = [...(n.selected || [])];
891
1074
  // Lo scope celle non e' un segreto: e' una decisione dell'operatore e va
892
1075
  // mostrata, come `selected`. La Settings UI deve poterla leggere per dirla.
@@ -1076,6 +1259,8 @@ module.exports = {
1076
1259
  // concessioni federate (preset, CAS, inventario)
1077
1260
  accessRevisionOf, setPeerAccessGrants, setPeerAccessPreset, accessInventory, migrateAdminReception,
1078
1261
  setPeerAccessGrantsCas, setPeerAccessPresetCas,
1262
+ // host-only publication of a remote peer (schema 4, opt-in)
1263
+ hostOnlyViolation, enablePublishRemote, disablePublishRemote, setPublishRemoteCas, backupStoreFile,
1079
1264
  // redazione
1080
1265
  redactNode, redactStore, hasPairedPeers,
1081
1266
  // migrazione
@@ -1083,5 +1268,5 @@ module.exports = {
1083
1268
  // label / slug
1084
1269
  nodeLabel, validLabel, sanitizeLabel, toSlug, deriveNodeHandle, suggestNodeName, LABEL_MAX,
1085
1270
  // costanti
1086
- SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION, MAX_NODES, MAX_TOKEN_LEN, NODE_NAME_RE, NODE_ID_RE,
1271
+ SCHEMA_VERSION, HOST_ONLY_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION, MAX_NODES, MAX_TOKEN_LEN, NODE_NAME_RE, NODE_ID_RE,
1087
1272
  };
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+ // lib/notify/ask-cell.js — which cell an IMPORTED ask belongs to.
3
+ //
4
+ // The owner of an ask is the only node that knows its cell: it resolves the
5
+ // ask's tmux session through its own cell definitions (a cell may declare a
6
+ // custom tmux session, so the name is not always `cloud-<cell>`). Decoding the
7
+ // session name here is therefore only a FALLBACK for the canonical names; for
8
+ // any other name the cell has to come from the owner.
9
+ //
10
+ // Two authenticated sources carry what the owner resolved, and nothing else is
11
+ // trusted:
12
+ // - `ownerCellId`: set by the event-feed client from the owner's authenticated
13
+ // snapshot item or live envelope (never copied from a wire field of that name);
14
+ // - `originCell`: the cell the origin resolver attested for an ask pushed by the
15
+ // owner (written by the server when the alias is created).
16
+ // Never derived by stripping a prefix from a string, never from the local cell
17
+ // definitions (those describe THIS node, not the owner), never from a request body.
18
+ const { cellIdFromTmuxSession } = require('../fleet/definitions.js');
19
+
20
+ const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
21
+ function isCellId(value) { return typeof value === 'string' && CELL_ID_RE.test(value); }
22
+
23
+ // -> { cellId, source, conflict? }. `source` is 'owner-feed' | 'origin-attested' |
24
+ // 'canonical-session' | null. Two authenticated sources that disagree resolve to
25
+ // nothing (fail closed): there is no way to tell which one is stale.
26
+ function resolveAskCell(ask) {
27
+ if (!ask || typeof ask !== 'object') return { cellId: null, source: null };
28
+ const fromFeed = isCellId(ask.ownerCellId) ? ask.ownerCellId : null;
29
+ const attested = isCellId(ask.originCell) ? ask.originCell : null;
30
+ if (fromFeed && attested && fromFeed !== attested) return { cellId: null, source: null, conflict: true };
31
+ if (fromFeed) return { cellId: fromFeed, source: 'owner-feed' };
32
+ if (attested) return { cellId: attested, source: 'origin-attested' };
33
+ const decoded = cellIdFromTmuxSession(ask.session);
34
+ return decoded ? { cellId: decoded, source: 'canonical-session' } : { cellId: null, source: null };
35
+ }
36
+
37
+ // The same ask can be known from two places at once (the alias created when the
38
+ // owner pushed it, the card of the owner's feed). Call this with the candidates
39
+ // that are the SAME generation of the SAME ask. Authenticated sources outrank the
40
+ // session decode; two authenticated sources that disagree resolve to nothing; a
41
+ // source that knows the cell completes one that does not.
42
+ function resolveAskCellFrom(asks) {
43
+ const results = asks.filter(Boolean).map(resolveAskCell);
44
+ const authenticated = results.filter((r) => r.cellId && r.source !== 'canonical-session');
45
+ const ids = new Set(authenticated.map((r) => r.cellId));
46
+ if (results.some((r) => r.conflict) || ids.size > 1) return { cellId: null, source: null, conflict: true };
47
+ if (ids.size === 1) return authenticated[0];
48
+ const decoded = new Set(results.filter((r) => r.cellId).map((r) => r.cellId));
49
+ if (decoded.size === 1) return results.find((r) => r.cellId);
50
+ return decoded.size > 1 ? { cellId: null, source: null, conflict: true } : { cellId: null, source: null };
51
+ }
52
+
53
+ module.exports = { resolveAskCell, resolveAskCellFrom, isCellId, CELL_ID_RE };