@mmmbuto/nexuscrew 0.8.38 → 0.8.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/server.js CHANGED
@@ -32,6 +32,17 @@ const nodesInventory = require('./nodes/inventory.js');
32
32
  const topologyCache = require('./nodes/topology-cache.js');
33
33
  const { createNodeProxy, handleNodeUpgrade } = require('./proxy/node-proxy.js');
34
34
  const federation = require('./proxy/federation.js');
35
+ const { audioRoutes } = require('./audio/routes.js');
36
+ const { isConsent: isAudioConsent } = require('./audio/consent.js');
37
+ const { createOriginResolver } = require('./audio/origin.js');
38
+ const { createAudioAcl } = require('./audio/acl.js');
39
+ const { createDispatcher } = require('./audio/dispatch.js');
40
+ const { createSpeakQueue } = require('./audio/queue.js');
41
+ const { createReceiptStore } = require('./audio/receipt.js');
42
+ const audioAdapters = require('./audio/adapters.js');
43
+ const audioGroups = require('./audio/groups.js');
44
+ const { bridgeSecretPath, loadOrCreateBridgeSecret, createNonceCache } = require('./audio/bridge-auth.js');
45
+ const { createHopSecret } = require('./proxy/hop-proof.js');
35
46
  const { settingsRoutes, publicPeeringRoutes } = require('./settings/routes.js');
36
47
  const decksStore = require('./decks/store.js');
37
48
  const { decksRoutes } = require('./decks/routes.js');
@@ -133,6 +144,22 @@ function createServer(opts = {}) {
133
144
  const topologyCachePath = cfg.topologyCachePath || topologyCache.defaultPath(cfg.home || os.homedir());
134
145
  const decksPath = cfg.decksPath || decksStore.defaultDecksPath(cfg.home || os.homedir());
135
146
  const proxyReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
147
+ const audioHome = cfg.home || os.homedir();
148
+ // Stato audio ancorato alla directory del token (come notifyDir): server,
149
+ // Settings e bridge MCP devono guardare gli stessi file anche quando un test
150
+ // isola l'istanza fuori dalla home reale.
151
+ const audioCfg = {
152
+ home: audioHome,
153
+ tokenPath: cfg.tokenPath,
154
+ audioConsentPath: cfg.audioConsentPath,
155
+ audioGroupsPath: cfg.audioGroupsPath,
156
+ audioBridgeSecretPath: cfg.audioBridgeSecretPath || path.join(notifyDir, 'audio-bridge.key'),
157
+ };
158
+ // Segreto per-processo della prova di hop federata: vive solo in memoria, non
159
+ // e' su disco e nessuna API lo espone. Serve a distinguere l'ultimo hop di una
160
+ // route federata da un POST diretto di chi possiede il token della UI.
161
+ const hopSecret = createHopSecret();
162
+ const audioNonceCache = createNonceCache();
136
163
  function resolveNode(name) {
137
164
  const st = nodesStore.loadStore(nodesPath);
138
165
  if (!st) return null;
@@ -299,6 +326,64 @@ function createServer(opts = {}) {
299
326
  sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
300
327
  }));
301
328
  api.use('/fleet', fleetRoutes(fleetP, { ...cfg, diagnostics }));
329
+ // Audio Share. L'identita' del nodo NON e' un campo di cfg: si legge dal node
330
+ // store, la stessa fonte usata da /api/cells e /api/peers. Lo stato Fleet e'
331
+ // asincrono e va atteso: leggerlo come se fosse sincrono lascerebbe la
332
+ // risoluzione dell'origine sempre vuota e la feature morta senza che un test
333
+ // sul router se ne accorga.
334
+ const audioNodeId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
335
+ // Seam di test per l'adapter: permette di esercitare il percorso completo
336
+ // (route -> gate -> coda -> receipt) sul server REALE senza emettere suono.
337
+ // In produzione resta il probe locale.
338
+ const audioAdapter = cfg.audioAdapterSeam
339
+ || audioAdapters.createAdapter(audioAdapters.detectAdapter({ env: cfg.env || process.env }));
340
+ const audioReceipts = createReceiptStore();
341
+ const audioQueue = createSpeakQueue({
342
+ adapter: audioAdapter,
343
+ // La coda e' l'unica a sapere com'e' finito davvero un enunciato: e' lei ad
344
+ // aggiornare il receipt da `accepted` a spoken/refused/unknown.
345
+ onStatus: (utteranceId, status, reason) => { try { audioReceipts.update(utteranceId, status, reason); } catch (_) {} },
346
+ });
347
+ api.use('/audio', audioRoutes({
348
+ readonly: proxyReadonly,
349
+ localNodeId: audioNodeId,
350
+ receiptStore: audioReceipts,
351
+ adapter: audioAdapter,
352
+ queue: audioQueue,
353
+ acl: createAudioAcl({ nodesPath }),
354
+ consent: () => { try { return isAudioConsent(audioCfg, audioHome); } catch (_) { return false; } },
355
+ originResolver: createOriginResolver({
356
+ localNodeId: audioNodeId,
357
+ // Celle Fleet ATTIVE in questo momento, attese davvero.
358
+ activeCells: async () => {
359
+ const fleet = await fleetP;
360
+ if (!fleet || fleet.available !== true || typeof fleet.status !== 'function') return [];
361
+ const st = await fleet.status();
362
+ return Array.isArray(st && st.cells) ? st.cells.map((c) => ({
363
+ cell: c && c.cell, tmuxSession: c && c.tmuxSession,
364
+ active: c && c.active === true && c.tmux !== false,
365
+ })) : [];
366
+ },
367
+ bridgeSecret: () => { try { return loadOrCreateBridgeSecret(bridgeSecretPath(audioCfg, audioHome)); } catch (_) { return null; } },
368
+ hopSecret: () => hopSecret,
369
+ nonceCache: audioNonceCache,
370
+ }),
371
+ dispatcher: createDispatcher({
372
+ localNodeId: audioNodeId,
373
+ peers: async () => {
374
+ const st = nodesStore.loadStore(nodesPath);
375
+ if (!st) return [];
376
+ const topology = await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath, fetchImpl: healthFetch });
377
+ return nodesInventory.buildInventory({
378
+ direct: nodesStore.redactStore(st).nodes,
379
+ topology: topology && Array.isArray(topology.nodes) ? topology.nodes : [],
380
+ });
381
+ },
382
+ localPort: () => (server && server.address() ? server.address().port : cfg.port),
383
+ localToken: () => tokenHolder.value,
384
+ }),
385
+ getGroup: (name) => audioGroups.getGroup(audioCfg, name, audioHome),
386
+ }));
302
387
  api.use('/cells', cellsRoutes({
303
388
  fleetP,
304
389
  instanceId: () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
@@ -359,7 +444,12 @@ function createServer(opts = {}) {
359
444
  // Settings API B2 (design §4b(6)): read-only GET + mutanti lista chiusa per il
360
445
  // wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
361
446
  // READONLY route-level e la redazione token vivono dentro settingsRoutes.
362
- api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions, updater, runtimePort }));
447
+ api.use('/settings', settingsRoutes({
448
+ cfg, nodesPath, tokenStore, closeSessions, updater, runtimePort,
449
+ // Stesso adapter/coda dell'API Audio: Settings puo' fare solo la prova
450
+ // locale a frase fissa e lo stop sovrano, mai una seconda sintesi parallela.
451
+ audio: { adapter: audioAdapter, queue: audioQueue },
452
+ }));
363
453
  api.use('/diagnostics', diagnosticsRoutes({ diagnostics, readonly: proxyReadonly }));
364
454
  api.get('/topology', async (_req, res) => {
365
455
  try { res.json(await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath })); }
@@ -367,6 +457,7 @@ function createServer(opts = {}) {
367
457
  });
368
458
  api.use('/route', federation.localRouter({
369
459
  nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
460
+ hopSecret: () => hopSecret,
370
461
  }));
371
462
  api.get('/voice/status', (_req, res) => res.json({ serverSttConfigured: !!cfg.voiceUrl }));
372
463
  api.post('/voice/transcribe',
@@ -392,7 +483,7 @@ function createServer(opts = {}) {
392
483
  app.use('/api', api);
393
484
  app.use('/federation', federation.peerRouter({
394
485
  nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
395
- fetchImpl: healthFetch,
486
+ fetchImpl: healthFetch, hopSecret: () => hopSecret,
396
487
  roles: () => require('./cli/commands.js').readRoles(cfg.configPath || configJsonPath()),
397
488
  }));
398
489
 
@@ -45,6 +45,10 @@ const express = require('express');
45
45
 
46
46
  const nodesStore = require('../nodes/store.js');
47
47
  const nodeAliases = require('../nodes/aliases.js');
48
+ const { setConsent: setAudioConsent, isConsent: isAudioConsent } = require('../audio/consent.js');
49
+ const { describeCapability } = require('../audio/capability.js');
50
+ const audioAdapters = require('../audio/adapters.js');
51
+ const audioGroups = require('../audio/groups.js');
48
52
  const nodesCmds = require('../nodes/commands.js');
49
53
  const nodesTunnel = require('../nodes/tunnel.js');
50
54
  const peering = require('../nodes/peering.js');
@@ -67,6 +71,7 @@ const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate']);
67
71
  const ROLE_KEYS = new Set(['client', 'node']);
68
72
  const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
69
73
  const EDIT_KEYS = new Set(['label', 'ssh', 'sshPort', 'autostart', 'visibility', 'selected']);
74
+ const LOCAL_AUDIO_TEST_TEXT = 'NexusCrew audio test.';
70
75
 
71
76
  // Default sensato per il "nome dispositivo" proposto nei form (pairing/invite).
72
77
  // NON usa ciecamente hostname: se l'hostname e' vuoto o 'localhost' (tipico di
@@ -165,6 +170,10 @@ function settingsRoutes(deps = {}) {
165
170
  const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
166
171
  const platform = seams.platform || detectPlatform();
167
172
  const runtimePort = typeof deps.runtimePort === 'function' ? deps.runtimePort : () => cfg.port;
173
+ // Il server passa qui l'adapter e la coda REALI. Il fallback serve solo ai
174
+ // test unitari di Settings costruiti senza il server: non deve creare una
175
+ // seconda coda che dichiara un test riuscito ma non parla sul nodo vero.
176
+ const audioService = deps.audio && typeof deps.audio === 'object' ? deps.audio : null;
168
177
 
169
178
  const r = express.Router();
170
179
  r.use(express.json({ limit: '8kb' }));
@@ -184,7 +193,8 @@ function settingsRoutes(deps = {}) {
184
193
  home, configDir, configPath, nodesPath, log: cap.log,
185
194
  keygen: seams.keygen, execFileImpl: seams.execFileImpl,
186
195
  spawnImpl: seams.spawnImpl, sshBin: seams.sshBin, logFd: seams.logFd,
187
- httpProbe: seams.httpProbe, sshVersion: seams.sshVersion,
196
+ httpProbe: seams.httpProbe, federationProbe: seams.federationProbe,
197
+ fetchImpl: seams.fetchImpl, sshVersion: seams.sshVersion,
188
198
  spawnSyncImpl: seams.spawnSyncImpl,
189
199
  localAppPort: runtimePort(),
190
200
  });
@@ -649,6 +659,23 @@ function settingsRoutes(deps = {}) {
649
659
  // the store/UI says private. Same-state OFF revokes first, then enters
650
660
  // the spec-aware start path without rewriting nodes.json.
651
661
  if (body.shared) {
662
+ // A same-state Share ON may target a supervisor stuck in "degraded"
663
+ // (reverse channel retrying on a fixed cadence after the initial
664
+ // budget). Accelerate ONLY a degraded supervisor that readTunnelState
665
+ // confirms we own (owned live pidfile + sidecar): force a local
666
+ // restart so the -R is retried immediately instead of waiting for the
667
+ // steady retry. A healthy tunnel stays idempotent, and we never revoke
668
+ // on the hub nor change the persisted shared intent in this branch.
669
+ const liveState = nodesTunnel.readTunnelState(home, name);
670
+ if (liveState && liveState.phase === 'degraded') {
671
+ // Reuse the normal restart transaction: it fails closed on a local
672
+ // spawn error and waits for the authenticated -L health probe before
673
+ // publishing Share again. A process restart alone is never proof
674
+ // that the reverse channel is ready.
675
+ await ensureLocal({ restart: true });
676
+ await notifyHub(true);
677
+ return send(res, 200, { name, shared: true, unchanged: true, accelerated: true });
678
+ }
652
679
  await ensureLocal();
653
680
  await notifyHub(true);
654
681
  return send(res, 200, { name, shared: true, unchanged: true, reconciled: true });
@@ -708,6 +735,111 @@ function settingsRoutes(deps = {}) {
708
735
  if (!nodesStore.validLabel(label)) return send(res, 400, { error: 'label non valida (max 64 char, niente a capo)' });
709
736
  return applyNodeEdit(res, name, { label });
710
737
  });
738
+ // Consenso audio: proprieta' del nodo LOCALE, cioe' della macchina che
739
+ // suonerebbe, non di un record peer. Vive in un file dedicato e si muta solo
740
+ // da qui: `/audio/consent` non compare nella whitelist federation, quindi
741
+ // nessun peer puo' concedersi da remoto il permesso di far parlare un
742
+ // computer altrui. E' il terzo asse, separato da `shared` (pubblicazione) e da
743
+ // `visibility` (routing): nessuno dei due autorizza un suono fisico.
744
+ const audioCfg = {
745
+ home,
746
+ tokenPath: cfg && cfg.tokenPath,
747
+ audioConsentPath: cfg && cfg.audioConsentPath,
748
+ audioGroupsPath: cfg && cfg.audioGroupsPath,
749
+ };
750
+ const localAudioAdapter = () => {
751
+ if (audioService) return audioService.adapter || null;
752
+ return audioAdapters.createAdapter(audioAdapters.detectAdapter({}));
753
+ };
754
+ const localAudioQueue = () => (audioService ? audioService.queue || null : null);
755
+ const localAudioCapability = () => {
756
+ const consent = isAudioConsent(audioCfg, home) === true;
757
+ return describeCapability({ adapter: localAudioAdapter(), consent });
758
+ };
759
+ const emptyBody = (body) => body === undefined
760
+ || (body && typeof body === 'object' && !Array.isArray(body) && Object.keys(body).length === 0);
761
+ r.get('/audio', (_req, res) => {
762
+ try {
763
+ // Capability redatta: id logico dell'adapter e limiti dichiarati, mai il
764
+ // path del binario e mai l'elenco delle voci di sistema.
765
+ return send(res, 200, localAudioCapability());
766
+ } catch (_) {
767
+ return send(res, 500, { error: 'stato audio non leggibile' });
768
+ }
769
+ });
770
+ r.patch('/audio/consent', mutGate, (req, res) => {
771
+ const consent = req.body && req.body.consent;
772
+ if (typeof consent !== 'boolean') {
773
+ return send(res, 400, { error: 'body non valido: atteso {consent:boolean}' });
774
+ }
775
+ try {
776
+ setAudioConsent(audioCfg, consent, home);
777
+ return send(res, 200, localAudioCapability());
778
+ } catch (_) {
779
+ return send(res, 500, { error: 'scrittura consenso audio non riuscita' });
780
+ }
781
+ });
782
+ // Prova esplicita dal pannello Settings. Non e' `nc_speak`: non accetta testo,
783
+ // target, cella o origine dal browser e non e' inoltrabile via federazione.
784
+ // Usa solo una frase fissa, il consenso del nodo e la sua coda reale; un 200
785
+ // descrive l'esito onesto dell'azione, NON prova che qualcuno abbia sentito.
786
+ r.post('/audio/test', mutGate, (req, res) => {
787
+ if (!emptyBody(req.body)) return send(res, 400, { error: 'body non valido: nessun campo ammesso' });
788
+ try {
789
+ const capability = localAudioCapability();
790
+ if (capability.consent !== true) return send(res, 200, { status: 'refused', reason: 'consent' });
791
+ const queue = localAudioQueue();
792
+ if (capability.installed !== true || !queue || typeof queue.enqueue !== 'function') {
793
+ return send(res, 200, { status: 'refused', reason: 'no-adapter' });
794
+ }
795
+ const result = queue.enqueue({
796
+ utteranceId: `settings-${crypto.randomUUID()}`,
797
+ text: LOCAL_AUDIO_TEST_TEXT,
798
+ urgency: 'normal',
799
+ });
800
+ return send(res, 200, {
801
+ status: result && result.status === 'accepted' ? 'accepted' : 'refused',
802
+ ...(result && result.reason ? { reason: String(result.reason).slice(0, 64) } : {}),
803
+ });
804
+ } catch (_) {
805
+ return send(res, 500, { error: 'prova audio locale non riuscita' });
806
+ }
807
+ });
808
+ // Stop locale sovrano: resta disponibile anche in READONLY. Non richiede rete,
809
+ // origin MCP o receipt, perche' zittire il dispositivo non deve dipendere da
810
+ // chi l'aveva fatto parlare. Ferma tutti i test/enunciati ancora in coda.
811
+ r.post('/audio/stop', (req, res) => {
812
+ if (!emptyBody(req.body)) return send(res, 400, { error: 'body non valido: nessun campo ammesso' });
813
+ try {
814
+ const queue = localAudioQueue();
815
+ const stopped = !!(queue && typeof queue.stopAll === 'function' && queue.stopAll());
816
+ return send(res, 200, { status: 'accepted', stopped });
817
+ } catch (_) {
818
+ return send(res, 500, { error: 'stop audio locale non riuscito' });
819
+ }
820
+ });
821
+ // Gruppi dell'ORIGINE: configurazione locale, separata dal consenso di ogni
822
+ // endpoint. Non e' nella whitelist federation e una lista non puo' far
823
+ // parlare un nodo senza il suo consenso/liveness al momento della consegna.
824
+ r.get('/audio/groups', (_req, res) => {
825
+ try { return send(res, 200, { groups: audioGroups.listGroups(audioCfg, home) }); }
826
+ catch (_) { return send(res, 500, { error: 'gruppi audio non leggibili' }); }
827
+ });
828
+ r.put('/audio/groups/:name', mutGate, (req, res) => {
829
+ const body = req.body;
830
+ if (!body || typeof body !== 'object' || Array.isArray(body)
831
+ || Object.keys(body).some((key) => !['targets', 'mode'].includes(key))) {
832
+ return send(res, 400, { error: 'body non valido: attesi targets e mode' });
833
+ }
834
+ try { return send(res, 200, { group: audioGroups.saveGroup(audioCfg, String(req.params.name || ''), body, home) }); }
835
+ catch (_) { return send(res, 400, { error: 'gruppo audio non valido' }); }
836
+ });
837
+ r.delete('/audio/groups/:name', mutGate, (req, res) => {
838
+ try {
839
+ const removed = audioGroups.removeGroup(audioCfg, String(req.params.name || ''), home);
840
+ return removed ? send(res, 200, { removed: true }) : send(res, 404, { error: 'gruppo audio non trovato' });
841
+ } catch (_) { return send(res, 400, { error: 'nome gruppo audio non valido' }); }
842
+ });
711
843
 
712
844
  // Viewer-local aliases for routed nodes. These routes are local-only and do
713
845
  // not mutate the peer identity, route, label, or federated topology.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.38",
3
+ "version": "0.8.40",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {