@mmmbuto/nexuscrew 0.8.39 → 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/CHANGELOG.md +33 -1
- package/README.md +11 -1
- package/frontend/dist/assets/index-CwZySkm2.js +93 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/audio/acl.js +65 -0
- package/lib/audio/adapters.js +233 -0
- package/lib/audio/bridge-auth.js +176 -0
- package/lib/audio/capability.js +40 -0
- package/lib/audio/consent.js +64 -0
- package/lib/audio/dispatch.js +154 -0
- package/lib/audio/group-receipt.js +119 -0
- package/lib/audio/group-speak.js +146 -0
- package/lib/audio/groups.js +121 -0
- package/lib/audio/origin.js +128 -0
- package/lib/audio/queue.js +191 -0
- package/lib/audio/rate-limit.js +71 -0
- package/lib/audio/receipt.js +146 -0
- package/lib/audio/routes.js +374 -0
- package/lib/audio/speak.js +125 -0
- package/lib/fleet/managed.js +53 -2
- package/lib/fleet/provider.js +6 -0
- package/lib/mcp/server.js +27 -3
- package/lib/mcp/tools.js +142 -0
- package/lib/nodes/tunnel-supervisor.js +33 -7
- package/lib/nodes/tunnel.js +21 -0
- package/lib/proxy/federation.js +41 -11
- package/lib/proxy/hop-proof.js +50 -0
- package/lib/server.js +93 -2
- package/lib/settings/routes.js +131 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-D0MBXlAl.js +0 -93
package/lib/settings/routes.js
CHANGED
|
@@ -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' }));
|
|
@@ -650,6 +659,23 @@ function settingsRoutes(deps = {}) {
|
|
|
650
659
|
// the store/UI says private. Same-state OFF revokes first, then enters
|
|
651
660
|
// the spec-aware start path without rewriting nodes.json.
|
|
652
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
|
+
}
|
|
653
679
|
await ensureLocal();
|
|
654
680
|
await notifyHub(true);
|
|
655
681
|
return send(res, 200, { name, shared: true, unchanged: true, reconciled: true });
|
|
@@ -709,6 +735,111 @@ function settingsRoutes(deps = {}) {
|
|
|
709
735
|
if (!nodesStore.validLabel(label)) return send(res, 400, { error: 'label non valida (max 64 char, niente a capo)' });
|
|
710
736
|
return applyNodeEdit(res, name, { label });
|
|
711
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
|
+
});
|
|
712
843
|
|
|
713
844
|
// Viewer-local aliases for routed nodes. These routes are local-only and do
|
|
714
845
|
// not mutate the peer identity, route, label, or federated topology.
|