@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/CHANGELOG.md +49 -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/commands.js +35 -25
- 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 +133 -1
- package/package.json +1 -1
- package/frontend/dist/assets/index-0uO9YgxK.js +0 -93
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({
|
|
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
|
|
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' }));
|
|
@@ -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,
|
|
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.
|