@mmmbuto/nexuscrew 0.9.65 → 0.9.67

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-D__76yWR.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.67"}
@@ -30,6 +30,10 @@ function createDispatcher(opts = {}) {
30
30
  // stesso percorso e rispondono `delivered`. Il default resta quello audio,
31
31
  // cosi' nessun chiamante esistente cambia comportamento.
32
32
  const statuses = opts.statuses instanceof Set ? opts.statuses : PER_ENDPOINT_STATUS;
33
+ // Policy for requests that arrived FEDERATED and terminate here: re-read on the
34
+ // resolution used for the I/O, just before the request leaves. Local callers are
35
+ // never subject to it.
36
+ const federatedGuard = typeof opts.federatedGuard === 'function' ? opts.federatedGuard : null;
33
37
 
34
38
  function isLocal(target) {
35
39
  const self = localNodeId();
@@ -143,10 +147,19 @@ function createDispatcher(opts = {}) {
143
147
  resolveRoute,
144
148
  probeCapability,
145
149
  // dispatch(): unico ingresso per un target remoto.
146
- async dispatch({ resource, target, origin, payload }) {
150
+ async dispatch({ resource, target, origin, payload, federated = false }) {
151
+ // What the policy says when the request begins counts too: a publication that ends while the topology is
152
+ // awaited does not turn the request into an allowed one.
153
+ let deniedBefore = false;
154
+ if (federated && federatedGuard) { try { deniedBefore = federatedGuard({ target, route: [] }) !== false; } catch (_) { deniedBefore = true; } }
147
155
  const resolved = await resolveRoute(target);
148
156
  if (resolved.error === 'invalid-target') return { status: 'refused', reason: 'invalid-target' };
149
157
  if (resolved.error) return { status: 'unreachable', reason: resolved.error };
158
+ if (federated && federatedGuard) {
159
+ let denied = true;
160
+ try { denied = federatedGuard({ target, route: resolved.route, peer: resolved.peer }) !== false; } catch (_) { denied = true; }
161
+ if (denied || deniedBefore) return { status: 'refused', reason: 'host-only-target' };
162
+ }
150
163
  return forward(resource, resolved.route, {
151
164
  ...payload,
152
165
  target,
@@ -251,7 +251,7 @@ function audioRoutes(deps = {}) {
251
251
  origin, target, status: 'accepted', utteranceId, attested: trust === 'federated',
252
252
  });
253
253
  const out = await dispatcher.dispatch({
254
- resource: '/audio/speak', target, origin,
254
+ resource: '/audio/speak', target, origin, federated: trust === 'federated',
255
255
  payload: {
256
256
  text: parsed.text, lang: parsed.lang, voice: parsed.voice, urgency: parsed.urgency,
257
257
  utteranceId: local.utteranceId,
@@ -285,7 +285,7 @@ function audioRoutes(deps = {}) {
285
285
  const { target } = parsed;
286
286
  if (dispatcher && isValidInstance(target) && !dispatcher.isLocal(target)) {
287
287
  const out = await dispatcher.dispatch({
288
- resource: '/audio/stop', target, origin,
288
+ resource: '/audio/stop', target, origin, federated: resolved.trust === 'federated',
289
289
  payload: { ...(parsed.utteranceId ? { utteranceId: parsed.utteranceId } : {}) },
290
290
  });
291
291
  return send(res, out.status === 'accepted' ? 200 : out.status === 'unreachable' ? 503 : 422, out);
@@ -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 } : {}),
@@ -30,6 +30,9 @@ function createLiveHostFederation({
30
30
  localToken = () => '',
31
31
  fetchImpl = fetch,
32
32
  timeoutMs = FED_TIMEOUT_MS,
33
+ // Refuses an owner that is (or routes through) a host-only peer. Read on the
34
+ // resolution used for the I/O, just before the request leaves.
35
+ ownerGuard = null,
33
36
  } = {}) {
34
37
  async function resolveRoute(target) {
35
38
  if (!INSTANCE_ID_RE.test(String(target || ''))) return { error: 'invalid-target' };
@@ -48,11 +51,18 @@ function createLiveHostFederation({
48
51
  }
49
52
 
50
53
  async function forward(target, resource, method, body) {
54
+ let deniedBefore = false;
55
+ if (typeof ownerGuard === 'function') { try { deniedBefore = ownerGuard({ target, route: [] }) !== false; } catch (_) { deniedBefore = true; } }
51
56
  const resolved = await resolveRoute(target);
52
57
  if (resolved.error) return { ok: false, reason: resolved.error === 'invalid-target' ? 'invalid-target' : 'live-host-owner-unreachable' };
53
58
  const port = localPort();
54
59
  const token = localToken();
55
60
  if (!port || !token) return { ok: false, reason: 'live-host-owner-unreachable' };
61
+ if (typeof ownerGuard === 'function') {
62
+ let denied = true;
63
+ try { denied = ownerGuard({ target, route: resolved.route }) !== false; } catch (_) { denied = true; }
64
+ if (denied || deniedBefore) return { ok: false, reason: 'live-host-owner-unreachable' };
65
+ }
56
66
  const url = `http://127.0.0.1:${port}/api/route/${resolved.route.join('/')}/_${resource}`;
57
67
  let res;
58
68
  try {
@@ -95,7 +95,10 @@ function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Dat
95
95
  // verifica (prova di hop + catena `visited` costruita dal server), e il budget
96
96
  // del ponte si applica per NODO mittente ATTESTATO — stessa regola e stessi
97
97
  // limiti dell'audio (lib/audio/rate-limit.js).
98
- federation = null, originResolver = null, federatedRate = null }) {
98
+ federation = null, originResolver = null, federatedRate = null,
99
+ // (req, ownerId) => true when this reader must not learn that the designation
100
+ // points at a host-only peer.
101
+ ownerHiddenFor = null }) {
99
102
  const r = express.Router();
100
103
 
101
104
  // Una designazione con `ownerId` e' REMOTA: il puntamento vive qui,
@@ -124,11 +127,18 @@ function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Dat
124
127
  // hostCell e revision vengono dallo store (preservato); eligible e' la verita'
125
128
  // COMPOSTA roster+lease (si veda hostLeaseState sopra); host.lease espone lo
126
129
  // stato del lease della cella designata, distinto, perche' chi legge distingue.
127
- r.get('/', async (_req, res) => {
130
+ r.get('/', async (req, res) => {
128
131
  try {
129
132
  const snap = store.snapshot();
133
+ if (snap.hostCell != null && snap.ownerId && ownerHiddenFor && ownerHiddenFor(req, snap.ownerId)) {
134
+ return res.json({ hostCell: null, revision: snap.revision, eligible: false, threadStatus: 'absent', host: { lease: null }, at: now() });
135
+ }
130
136
  if (snap.hostCell != null && snap.ownerId) {
131
137
  const remote = await remoteOwnerState(snap);
138
+ // the reader is judged again after the wait: a publication that changed meanwhile must not name the owner
139
+ if (ownerHiddenFor && ownerHiddenFor(req, snap.ownerId)) {
140
+ return res.json({ hostCell: null, revision: snap.revision, eligible: false, threadStatus: 'absent', host: { lease: null }, at: now() });
141
+ }
132
142
  return res.json({
133
143
  hostCell: snap.hostCell, revision: snap.revision, ownerId: snap.ownerId, remote: true,
134
144
  eligible: remote.state ? remote.state.eligible : false,
@@ -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
@@ -83,7 +83,10 @@ function createReverseSlotListeners({ app, createServerImpl = http.createServer,
83
83
  return true;
84
84
  }
85
85
 
86
- return { open, closePort, closeAll, respond, size: () => listeners.size };
86
+ // A request that arrived on one of these listeners comes from a peer's channel,
87
+ // never from this machine's own browser: callers use it to tell the two apart.
88
+ const isSlotPort = (localPort) => listeners.has(localPort);
89
+ return { open, closePort, closeAll, respond, isSlotPort, size: () => listeners.size };
87
90
  }
88
91
 
89
92
  module.exports = { createReverseSlotListeners };