@mmmbuto/nexuscrew 0.8.39 → 0.8.41
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 +56 -1
- package/README.md +2 -1
- package/frontend/dist/assets/index-CrZBnpKn.css +32 -0
- package/frontend/dist/assets/index-CwZySkm2.js +93 -0
- package/frontend/dist/assets/index-hq-2ORFQ.js +93 -0
- package/frontend/dist/index.html +2 -2
- 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/cli/doctor.js +37 -0
- package/lib/config.js +7 -0
- package/lib/fleet/builtin.js +2 -1
- package/lib/fleet/launch.js +16 -0
- package/lib/fleet/managed.js +53 -2
- package/lib/fleet/provider.js +6 -0
- package/lib/fleet/runtime.js +14 -1
- 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 +97 -3
- package/lib/settings/routes.js +150 -4
- package/lib/tmux/lifecycle.js +24 -3
- package/package.json +1 -1
- package/skills/nexuscrew-agent/SKILL.md +30 -1
package/frontend/dist/index.html
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
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-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-hq-2ORFQ.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CrZBnpKn.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="root"></div>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.8.
|
|
1
|
+
{"version":"0.8.41"}
|
package/lib/audio/acl.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/audio/acl.js — ACL lato TARGET per gli enunciati in arrivo.
|
|
3
|
+
//
|
|
4
|
+
// Il target non delega a nessuno la decisione di suonare. Anche quando la
|
|
5
|
+
// richiesta e' passata da una route federata gia' autorizzata al transito, il
|
|
6
|
+
// nodo che possiede l'altoparlante riapplica la propria ACL: `shared` e'
|
|
7
|
+
// pubblicazione, `visibility` e' routing, e nessuno dei due e' un permesso a
|
|
8
|
+
// emettere suono in casa d'altri.
|
|
9
|
+
//
|
|
10
|
+
// Sorgente della decisione: solo il node store locale, mai il body della
|
|
11
|
+
// richiesta. L'identita' del nodo di origine arriva dalla catena `visited`
|
|
12
|
+
// controllata dal server (lib/audio/origin.js), non da un campo dichiarato.
|
|
13
|
+
const store = require('../nodes/store.js');
|
|
14
|
+
|
|
15
|
+
// Stessa semantica di `peerAllows` in lib/proxy/federation.js: `network` apre a
|
|
16
|
+
// tutti i peer, `relay-only` non autorizza il nodo come interlocutore, la lista
|
|
17
|
+
// `selected` e' un'allowlist esplicita. Riusata qui invece di inventarne una
|
|
18
|
+
// seconda: due modelli di visibilita' divergenti sarebbero un bug latente.
|
|
19
|
+
function peerAllows(peer, otherId) {
|
|
20
|
+
if (!peer) return false;
|
|
21
|
+
if (peer.visibility === 'network') return true;
|
|
22
|
+
if (peer.visibility === 'relay-only') return false;
|
|
23
|
+
return Array.isArray(peer.selected) && peer.selected.includes(otherId);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function findByNodeId(st, nodeId) {
|
|
27
|
+
if (!st || !Array.isArray(st.nodes) || !nodeId) return null;
|
|
28
|
+
return st.nodes.find((n) => n && n.nodeId === nodeId) || null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// createAudioAcl(): `allows({origin, trust, visited})` -> {allowed, reason}
|
|
32
|
+
// trust 'local-bridge' : origine sul nodo stesso, gia' provata dal bridge.
|
|
33
|
+
// trust 'federated' : va verificato sia chi consegna sia chi ha originato.
|
|
34
|
+
function createAudioAcl({ nodesPath, loadStoreImpl = store.loadStore } = {}) {
|
|
35
|
+
function allows({ trust, origin, visited } = {}) {
|
|
36
|
+
if (trust === 'local-bridge') return { allowed: true };
|
|
37
|
+
if (trust !== 'federated') return { allowed: false, reason: 'unknown-trust' };
|
|
38
|
+
const chain = Array.isArray(visited) ? visited : [];
|
|
39
|
+
if (chain.length < 2) return { allowed: false, reason: 'no-delivering-peer' };
|
|
40
|
+
const deliveringId = chain[chain.length - 2];
|
|
41
|
+
let st;
|
|
42
|
+
try { st = loadStoreImpl(nodesPath); } catch (_) { st = null; }
|
|
43
|
+
if (!st) return { allowed: false, reason: 'store-unavailable' };
|
|
44
|
+
|
|
45
|
+
// 1) Il peer che consegna deve essere un peer noto di questo nodo. Un hop
|
|
46
|
+
// sconosciuto non diventa autorevole per il fatto di aver bussato.
|
|
47
|
+
const delivering = findByNodeId(st, deliveringId);
|
|
48
|
+
if (!delivering) return { allowed: false, reason: 'unknown-peer' };
|
|
49
|
+
if (!peerAllows(delivering, origin && origin.node)) {
|
|
50
|
+
return { allowed: false, reason: 'peer-visibility' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 2) Se il nodo di ORIGINE e' a sua volta un peer diretto, la sua stessa
|
|
54
|
+
// visibilita' deve permettere questo nodo: un peer marcato `relay-only`
|
|
55
|
+
// puo' far transitare traffico, non puo' farmi parlare.
|
|
56
|
+
const originPeer = findByNodeId(st, origin && origin.node);
|
|
57
|
+
if (originPeer && !peerAllows(originPeer, st.nodeId)) {
|
|
58
|
+
return { allowed: false, reason: 'origin-visibility' };
|
|
59
|
+
}
|
|
60
|
+
return { allowed: true };
|
|
61
|
+
}
|
|
62
|
+
return { allows };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { createAudioAcl, peerAllows };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/audio/adapters.js — adapter TTS NATIVI a livello di nodo.
|
|
3
|
+
//
|
|
4
|
+
// Un adapter fa due cose e non una di piu': dichiarare se il nodo puo' parlare
|
|
5
|
+
// (probe di capability, senza emettere suono) e avviare la sintesi di un
|
|
6
|
+
// enunciato. Non decide ACL, non decide consenso, non conosce la federazione.
|
|
7
|
+
//
|
|
8
|
+
// Onesta' su cosa prova un exit code. `exit 0` significa che il binario e'
|
|
9
|
+
// terminato senza errore, NON che una persona ha sentito qualcosa: il sink puo'
|
|
10
|
+
// essere nullo, il volume a zero, le cuffie staccate, il servizio Termux:API
|
|
11
|
+
// assente. Per questo l'adapter conferma soltanto l'AVVIO della sintesi e non
|
|
12
|
+
// promette mai udibilita'. La verifica fisica resta un test manuale autorizzato.
|
|
13
|
+
//
|
|
14
|
+
// Il testo passa da STDIN, non da argv. Un argomento di comando e' leggibile da
|
|
15
|
+
// qualunque processo locale con `ps`: mettere l'enunciato nell'argv sarebbe una
|
|
16
|
+
// fuga di contenuto verso utenti non autorizzati sulla stessa macchina. Dove il
|
|
17
|
+
// binario non supporta stdin (spd-say) il limite viene dichiarato nel descrittore
|
|
18
|
+
// dell'adapter invece di essere ignorato.
|
|
19
|
+
//
|
|
20
|
+
// Tutta l'esecuzione passa da un seam (`spawnImpl`): i test coprono argv, stdin,
|
|
21
|
+
// watchdog e stop senza mai riprodurre audio.
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
const fsDefault = require('node:fs');
|
|
24
|
+
const { spawn: spawnDefault } = require('node:child_process');
|
|
25
|
+
|
|
26
|
+
// Vita massima di un enunciato: oltre questa soglia il processo viene terminato.
|
|
27
|
+
// Serve a impedire che un binario bloccato tenga occupato il canale audio del
|
|
28
|
+
// nodo per sempre (un enunciato e' <= 320 caratteri: 30s sono larghi).
|
|
29
|
+
const UTTERANCE_TIMEOUT_MS = 30 * 1000;
|
|
30
|
+
// Finestra di grazia dopo lo spawn: se il processo muore entro questo tempo con
|
|
31
|
+
// errore, la sintesi non e' partita davvero.
|
|
32
|
+
const START_GRACE_MS = 250;
|
|
33
|
+
|
|
34
|
+
function isExecutable(fsImpl, file) {
|
|
35
|
+
try {
|
|
36
|
+
const st = fsImpl.statSync(file);
|
|
37
|
+
if (!st.isFile()) return false;
|
|
38
|
+
fsImpl.accessSync(file, fsDefault.constants.X_OK);
|
|
39
|
+
return true;
|
|
40
|
+
} catch (_) { return false; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Ricerca bounded nel PATH: nessuna shell, nessun glob, nessun fallback su cwd.
|
|
44
|
+
function lookupBin(name, { env = process.env, fsImpl = fsDefault } = {}) {
|
|
45
|
+
if (!name || name.includes('/')) return null;
|
|
46
|
+
const raw = String(env.PATH || '');
|
|
47
|
+
if (!raw) return null;
|
|
48
|
+
for (const dir of raw.split(path.delimiter).filter(Boolean).slice(0, 64)) {
|
|
49
|
+
const candidate = path.join(dir, name);
|
|
50
|
+
if (isExecutable(fsImpl, candidate)) return candidate;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Descrittori di piattaforma. `stdin:true` = il testo non tocca argv.
|
|
56
|
+
// L'ordine dentro ogni piattaforma e' una preferenza dichiarata, non casuale:
|
|
57
|
+
// prima chi accetta stdin.
|
|
58
|
+
const DESCRIPTORS = Object.freeze([
|
|
59
|
+
{
|
|
60
|
+
id: 'termux-tts-speak',
|
|
61
|
+
platforms: ['android'],
|
|
62
|
+
bin: 'termux-tts-speak',
|
|
63
|
+
stdin: true,
|
|
64
|
+
args: ({ lang }) => (lang ? ['-l', lang] : []),
|
|
65
|
+
limits: 'richiede Termux:API installato e il permesso audio; Doze puo\' sospendere il processo',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'say',
|
|
69
|
+
platforms: ['darwin'],
|
|
70
|
+
bin: 'say',
|
|
71
|
+
stdin: true,
|
|
72
|
+
// `-f -` legge l'enunciato da stdin: il testo non entra in argv.
|
|
73
|
+
args: ({ voice }) => [...(voice ? ['-v', voice] : []), '-f', '-'],
|
|
74
|
+
limits: 'richiede una sessione GUI con CoreAudio disponibile; udibilita\' non provata dall\'exit code',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'espeak-ng',
|
|
78
|
+
platforms: ['linux'],
|
|
79
|
+
bin: 'espeak-ng',
|
|
80
|
+
stdin: true,
|
|
81
|
+
args: ({ lang }) => (lang ? ['-v', lang] : []),
|
|
82
|
+
limits: 'richiede un sink audio reale; su un host senza uscita (sink null) l\'exit code resta 0 senza suono',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: 'spd-say',
|
|
86
|
+
platforms: ['linux'],
|
|
87
|
+
bin: 'spd-say',
|
|
88
|
+
stdin: false,
|
|
89
|
+
// spd-say non legge stdin: il testo finisce in argv ed e' visibile in `ps`.
|
|
90
|
+
// Preferito solo se espeak-ng manca, e il limite e' dichiarato.
|
|
91
|
+
args: ({ lang, text }) => [...(lang ? ['-l', lang] : []), '--', text],
|
|
92
|
+
limits: 'il testo passa in argv (visibile a `ps` sulla stessa macchina); richiede speech-dispatcher attivo e un sink reale',
|
|
93
|
+
},
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
// detectAdapter(): probe puramente locale e senza suono. Ritorna il primo
|
|
97
|
+
// descrittore disponibile per la piattaforma, con il path risolto.
|
|
98
|
+
function detectAdapter({ platform = process.platform, env = process.env, fsImpl = fsDefault, descriptors = DESCRIPTORS } = {}) {
|
|
99
|
+
// Termux si presenta come 'android' o come 'linux' con PREFIX Termux: il
|
|
100
|
+
// secondo caso e' quello reale su Android, quindi va riconosciuto.
|
|
101
|
+
const termux = typeof env.PREFIX === 'string' && env.PREFIX.includes('com.termux');
|
|
102
|
+
const key = termux ? 'android' : platform;
|
|
103
|
+
for (const d of descriptors) {
|
|
104
|
+
if (!d.platforms.includes(key)) continue;
|
|
105
|
+
const bin = lookupBin(d.bin, { env, fsImpl });
|
|
106
|
+
if (bin) return { ...d, bin, platform: key, installed: true };
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// createAdapter(): wrappa un descrittore in un adapter eseguibile.
|
|
112
|
+
// speak({text, lang, voice}) -> { started:boolean, reason?, kill() }
|
|
113
|
+
// `started` e' un ack di AVVIO, non di ascolto. Il watchdog termina l'enunciato
|
|
114
|
+
// oltre `timeoutMs`; `kill()` e' lo stop locale sovrano e non richiede rete.
|
|
115
|
+
function createAdapter(descriptor, {
|
|
116
|
+
spawnImpl = spawnDefault,
|
|
117
|
+
timeoutMs = UTTERANCE_TIMEOUT_MS,
|
|
118
|
+
graceMs = START_GRACE_MS,
|
|
119
|
+
now = Date.now,
|
|
120
|
+
setTimeoutImpl = setTimeout,
|
|
121
|
+
clearTimeoutImpl = clearTimeout,
|
|
122
|
+
} = {}) {
|
|
123
|
+
if (!descriptor) return null;
|
|
124
|
+
return {
|
|
125
|
+
id: descriptor.id,
|
|
126
|
+
platform: descriptor.platform || null,
|
|
127
|
+
installed: descriptor.installed === true,
|
|
128
|
+
limits: descriptor.limits,
|
|
129
|
+
stdinText: descriptor.stdin === true,
|
|
130
|
+
// Metadati bounded: nessuna enumerazione di voci di sistema nell'MVP, cosi'
|
|
131
|
+
// la capability non diventa un canale di fingerprinting della macchina.
|
|
132
|
+
languages: [],
|
|
133
|
+
voices: [],
|
|
134
|
+
speak({ text, lang = '', voice = '' } = {}) {
|
|
135
|
+
const args = descriptor.args({ lang, voice, text });
|
|
136
|
+
let child;
|
|
137
|
+
try {
|
|
138
|
+
child = spawnImpl(descriptor.bin, args, {
|
|
139
|
+
stdio: [descriptor.stdin ? 'pipe' : 'ignore', 'ignore', 'ignore'],
|
|
140
|
+
});
|
|
141
|
+
} catch (e) {
|
|
142
|
+
return { started: false, reason: 'adapter-spawn-failed' };
|
|
143
|
+
}
|
|
144
|
+
// `spawn()` restituisce un ChildProcess anche quando l'eseguibile non
|
|
145
|
+
// esiste, ma in quel caso `pid` e' indefinito e arriva un `error`
|
|
146
|
+
// asincrono. Non trattare quell'oggetto come una sintesi partita: e' il
|
|
147
|
+
// modo piu' comune per trasformare ENOENT in un falso `spoken`.
|
|
148
|
+
if (!child || !Number.isInteger(child.pid) || child.pid <= 0) {
|
|
149
|
+
return { started: false, reason: 'adapter-spawn-failed' };
|
|
150
|
+
}
|
|
151
|
+
let failed = null;
|
|
152
|
+
const startedAt = now();
|
|
153
|
+
let startTimer = null;
|
|
154
|
+
let settleStart = null;
|
|
155
|
+
const start = new Promise((resolve) => {
|
|
156
|
+
let settled = false;
|
|
157
|
+
settleStart = (result) => {
|
|
158
|
+
if (settled) return;
|
|
159
|
+
settled = true;
|
|
160
|
+
if (startTimer) { clearTimeoutImpl(startTimer); startTimer = null; }
|
|
161
|
+
resolve(result);
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
const failStart = (reason) => {
|
|
165
|
+
failed = reason;
|
|
166
|
+
settleStart({ started: false, reason });
|
|
167
|
+
};
|
|
168
|
+
child.once('error', () => failStart('adapter-spawn-failed'));
|
|
169
|
+
// Il solo PID prova che il kernel ha accettato lo spawn; l'evento `spawn`
|
|
170
|
+
// prova che il processo e' realmente partito. Aspettiamo una breve grazia
|
|
171
|
+
// per intercettare un errore d'avvio immediato, senza pretendere di
|
|
172
|
+
// dedurre l'udibilita' dall'exit code.
|
|
173
|
+
child.once('spawn', () => {
|
|
174
|
+
startTimer = setTimeoutImpl(() => settleStart({ started: true }), Math.max(0, graceMs));
|
|
175
|
+
if (startTimer && typeof startTimer.unref === 'function') startTimer.unref();
|
|
176
|
+
});
|
|
177
|
+
if (descriptor.stdin && child.stdin) {
|
|
178
|
+
// Un EPIPE qui significa che il binario e' morto prima di leggere: va
|
|
179
|
+
// trattato come mancato avvio, non ignorato.
|
|
180
|
+
child.stdin.on('error', () => failStart('adapter-stdin-failed'));
|
|
181
|
+
try { child.stdin.end(`${text}\n`); } catch (_) { failStart('adapter-stdin-failed'); }
|
|
182
|
+
}
|
|
183
|
+
const watchdog = setTimeoutImpl(() => {
|
|
184
|
+
try { child.kill('SIGTERM'); } catch (_) {}
|
|
185
|
+
}, timeoutMs);
|
|
186
|
+
if (typeof watchdog.unref === 'function') watchdog.unref();
|
|
187
|
+
const done = new Promise((resolve) => {
|
|
188
|
+
child.on('close', (code, signal) => {
|
|
189
|
+
clearTimeoutImpl(watchdog);
|
|
190
|
+
// Un processo che termina prima della grazia puo' comunque aver
|
|
191
|
+
// iniziato davvero se esce pulito; un errore/non-zero invece non e'
|
|
192
|
+
// un ack di sintesi. Risolviamo `start` prima di `done`, cosi' la
|
|
193
|
+
// coda registra prima l'eventuale spoken e solo poi libera la slot.
|
|
194
|
+
if (!failed) {
|
|
195
|
+
if (code === 0) settleStart({ started: true });
|
|
196
|
+
else failStart('adapter-start-failed');
|
|
197
|
+
}
|
|
198
|
+
resolve({ code, signal, runtimeMs: Math.max(0, now() - startedAt) });
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
return {
|
|
202
|
+
// La coda mantiene il receipt `accepted` finche' `start` non attesta
|
|
203
|
+
// l'avvio. Il booleano qui dice solo che esiste un processo verificabile
|
|
204
|
+
// da attendere, non che la voce sia gia' partita.
|
|
205
|
+
started: true,
|
|
206
|
+
start,
|
|
207
|
+
done,
|
|
208
|
+
kill: () => { clearTimeoutImpl(watchdog); try { child.kill('SIGTERM'); } catch (_) {} },
|
|
209
|
+
};
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// describeAdapter(): metadati redatti per la capability. Mai il path del binario
|
|
215
|
+
// (rivelerebbe il layout del filesystem a un peer), solo l'id logico.
|
|
216
|
+
function describeAdapter(adapter) {
|
|
217
|
+
if (!adapter) return { adapter: null, installed: false, liveness: 'unavailable', voices: [], languages: [] };
|
|
218
|
+
return {
|
|
219
|
+
adapter: adapter.id,
|
|
220
|
+
installed: adapter.installed === true,
|
|
221
|
+
// 'ready' dice che un adapter esiste e il binario e' eseguibile. NON dice
|
|
222
|
+
// che il nodo e' udibile: la distinzione e' esplicita nel campo `limits`.
|
|
223
|
+
liveness: adapter.installed === true ? 'ready' : 'unknown',
|
|
224
|
+
voices: [],
|
|
225
|
+
languages: [],
|
|
226
|
+
...(adapter.limits ? { limits: adapter.limits } : {}),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = {
|
|
231
|
+
DESCRIPTORS, detectAdapter, createAdapter, describeAdapter, lookupBin,
|
|
232
|
+
UTTERANCE_TIMEOUT_MS, START_GRACE_MS,
|
|
233
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/audio/bridge-auth.js — confine di identita' del bridge MCP, separato dal
|
|
3
|
+
// token della UI.
|
|
4
|
+
//
|
|
5
|
+
// Il problema che risolve. Il bridge (`nexuscrew mcp`) parla con l'API locale
|
|
6
|
+
// usando lo STESSO Bearer della UI: quel token prova "qualcuno in loopback ha il
|
|
7
|
+
// token", non "questa e' la cella X". Finche' l'origine viene dichiarata nel
|
|
8
|
+
// body (`{session}`) chiunque possieda il token — inclusa la pagina web aperta
|
|
9
|
+
// nel browser — puo' attribuire un enunciato a una cella qualsiasi. Per Audio
|
|
10
|
+
// Share l'origine e' un dato di sicurezza (rate limit, ACL, attribuzione del
|
|
11
|
+
// receipt), quindi serve una prova che il Bearer non puo' dare.
|
|
12
|
+
//
|
|
13
|
+
// Il confine. Un segreto dedicato, distinto dal token UI, in un file 0600 creato
|
|
14
|
+
// in modo esclusivo e anti-symlink (stessa disciplina di lib/auth/token.js), e
|
|
15
|
+
// una firma HMAC-SHA256 timing-safe su un payload canonico che lega metodo,
|
|
16
|
+
// path, sessione dichiarata, timestamp, nonce e hash del body grezzo. Il body e'
|
|
17
|
+
// coperto per BYTE, non per forma: si firma il buffer effettivamente trasmesso,
|
|
18
|
+
// non un JSON ri-serializzato con un ordine di chiavi diverso.
|
|
19
|
+
//
|
|
20
|
+
// Anti-replay. Finestra temporale bounded piu' cache dei nonce bounded: un nonce
|
|
21
|
+
// vale una volta sola dentro la finestra, la cache non cresce senza limite e non
|
|
22
|
+
// ha bisogno di persistenza. Il segreto non compare mai in risposte, log o
|
|
23
|
+
// errori: la verifica ritorna un codice di motivo, e la route risponde comunque
|
|
24
|
+
// un 401 generico, per non trasformare la diagnostica in un oracolo.
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
const os = require('node:os');
|
|
28
|
+
const crypto = require('node:crypto');
|
|
29
|
+
|
|
30
|
+
const PREFIX = 'NEXUSCREW-AUDIO-BRIDGE-V1';
|
|
31
|
+
const SESSION_HEADER = 'x-nexuscrew-audio-session';
|
|
32
|
+
const TS_HEADER = 'x-nexuscrew-audio-ts';
|
|
33
|
+
const NONCE_HEADER = 'x-nexuscrew-audio-nonce';
|
|
34
|
+
const PROOF_HEADER = 'x-nexuscrew-audio-proof';
|
|
35
|
+
|
|
36
|
+
// Finestra di validita': +-60s copre lo skew fra processi sulla stessa macchina
|
|
37
|
+
// senza tenere un nonce vivo abbastanza a lungo da far crescere la cache.
|
|
38
|
+
const SKEW_MS = 60 * 1000;
|
|
39
|
+
const NONCE_CAP = 4096;
|
|
40
|
+
const NONCE_RE = /^[a-f0-9]{32}$/i;
|
|
41
|
+
const PROOF_RE = /^[a-f0-9]{64}$/i;
|
|
42
|
+
|
|
43
|
+
function bridgeSecretPath(cfg = {}, home = (cfg && cfg.home) || os.homedir()) {
|
|
44
|
+
return (cfg && cfg.audioBridgeSecretPath) || path.join(home, '.nexuscrew', 'audio-bridge.key');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Lettura no-follow: un symlink al posto del file e' un rifiuto, non un
|
|
48
|
+
// redirect silenzioso verso un segreto scelto da altri.
|
|
49
|
+
function readBridgeSecretSafe(secretPath) {
|
|
50
|
+
const st = fs.lstatSync(secretPath);
|
|
51
|
+
if (st.isSymbolicLink()) throw new Error(`refusing symlink bridge secret path: ${secretPath}`);
|
|
52
|
+
if (!st.isFile()) return null;
|
|
53
|
+
const s = fs.readFileSync(secretPath, 'utf8').trim();
|
|
54
|
+
return s || null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Create esclusivo (flag 'wx') + 0600. Preserva un segreto esistente valido, e
|
|
58
|
+
// sulla race EEXIST rilegge quello dell'altro processo invece di sovrascriverlo:
|
|
59
|
+
// due bridge avviati insieme devono convergere sullo stesso segreto.
|
|
60
|
+
function loadOrCreateBridgeSecret(secretPath) {
|
|
61
|
+
try {
|
|
62
|
+
const existing = readBridgeSecretSafe(secretPath);
|
|
63
|
+
if (existing) return existing;
|
|
64
|
+
fs.unlinkSync(secretPath); // file vuoto: ricrea esclusivo
|
|
65
|
+
} catch (e) {
|
|
66
|
+
if (e.code !== 'ENOENT') throw e;
|
|
67
|
+
}
|
|
68
|
+
fs.mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 });
|
|
69
|
+
const secret = crypto.randomBytes(32).toString('base64url');
|
|
70
|
+
try {
|
|
71
|
+
fs.writeFileSync(secretPath, `${secret}\n`, { flag: 'wx', mode: 0o600 });
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (e.code === 'EEXIST') {
|
|
74
|
+
const other = readBridgeSecretSafe(secretPath);
|
|
75
|
+
if (other) return other;
|
|
76
|
+
}
|
|
77
|
+
throw e;
|
|
78
|
+
}
|
|
79
|
+
fs.chmodSync(secretPath, 0o600);
|
|
80
|
+
return secret;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function bodyDigest(rawBody) {
|
|
84
|
+
const buf = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody === undefined || rawBody === null ? '' : String(rawBody));
|
|
85
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Payload canonico: campi separati da newline in ordine fisso. Nessun campo puo'
|
|
89
|
+
// contenere un newline (session/nonce/ts sono validati a monte), quindi la
|
|
90
|
+
// concatenazione non e' ambigua.
|
|
91
|
+
function canonicalRequest({ method, path: reqPath, session, timestamp, nonce, rawBody }) {
|
|
92
|
+
return [
|
|
93
|
+
PREFIX,
|
|
94
|
+
String(method || '').toUpperCase(),
|
|
95
|
+
String(reqPath || ''),
|
|
96
|
+
String(session || ''),
|
|
97
|
+
String(timestamp || ''),
|
|
98
|
+
String(nonce || ''),
|
|
99
|
+
bodyDigest(rawBody),
|
|
100
|
+
].join('\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function signRequest(secret, parts) {
|
|
104
|
+
return crypto.createHmac('sha256', String(secret)).update(canonicalRequest(parts)).digest('hex');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Header pronti per il client del bridge: un solo posto genera nonce/timestamp e
|
|
108
|
+
// firma, cosi' bridge e server non possono divergere sul formato.
|
|
109
|
+
function signedHeaders(secret, { method, path: reqPath, session, rawBody, now = Date.now, nonce = null }) {
|
|
110
|
+
const timestamp = String(now());
|
|
111
|
+
const n = nonce || crypto.randomBytes(16).toString('hex');
|
|
112
|
+
return {
|
|
113
|
+
[SESSION_HEADER]: String(session),
|
|
114
|
+
[TS_HEADER]: timestamp,
|
|
115
|
+
[NONCE_HEADER]: n,
|
|
116
|
+
[PROOF_HEADER]: signRequest(secret, { method, path: reqPath, session, timestamp, nonce: n, rawBody }),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Cache nonce bounded. TTL = 2x la finestra di skew: oltre quella soglia il
|
|
121
|
+
// timestamp e' gia' rifiutato, quindi ricordare il nonce non serve piu'. Il cap
|
|
122
|
+
// e' un tetto duro: si eliminano prima gli scaduti, poi i piu' vecchi.
|
|
123
|
+
function createNonceCache({ now = Date.now, cap = NONCE_CAP, ttlMs = SKEW_MS * 2 } = {}) {
|
|
124
|
+
const seen = new Map(); // nonce -> firstSeenMs
|
|
125
|
+
function prune(t) {
|
|
126
|
+
for (const [n, at] of seen) if (t - at > ttlMs) seen.delete(n);
|
|
127
|
+
while (seen.size > cap) {
|
|
128
|
+
const oldest = seen.keys().next();
|
|
129
|
+
if (oldest.done) break;
|
|
130
|
+
seen.delete(oldest.value);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
// true = nonce accettato (primo uso); false = replay.
|
|
135
|
+
use(nonce) {
|
|
136
|
+
const t = now();
|
|
137
|
+
prune(t);
|
|
138
|
+
if (seen.has(nonce)) return false;
|
|
139
|
+
seen.set(nonce, t);
|
|
140
|
+
prune(t);
|
|
141
|
+
return true;
|
|
142
|
+
},
|
|
143
|
+
size: () => seen.size,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// verifyRequest(): ordine deliberato. Prima la forma degli header (rifiuto
|
|
148
|
+
// gratuito, nessun HMAC calcolato), poi la finestra temporale, poi l'HMAC, e
|
|
149
|
+
// SOLO alla fine il consumo del nonce: un nonce non deve essere bruciato da una
|
|
150
|
+
// firma sbagliata, altrimenti un terzo potrebbe invalidare richieste legittime
|
|
151
|
+
// indovinando i nonce.
|
|
152
|
+
function verifyRequest({ secret, method, path: reqPath, headers = {}, rawBody, nonceCache, now = Date.now, skewMs = SKEW_MS }) {
|
|
153
|
+
if (!secret) return { ok: false, reason: 'no-secret' };
|
|
154
|
+
const session = headers[SESSION_HEADER];
|
|
155
|
+
const ts = headers[TS_HEADER];
|
|
156
|
+
const nonce = headers[NONCE_HEADER];
|
|
157
|
+
const proof = headers[PROOF_HEADER];
|
|
158
|
+
if (typeof session !== 'string' || !session || session.includes('\n')) return { ok: false, reason: 'malformed' };
|
|
159
|
+
if (typeof ts !== 'string' || !/^\d{1,15}$/.test(ts)) return { ok: false, reason: 'malformed' };
|
|
160
|
+
if (typeof nonce !== 'string' || !NONCE_RE.test(nonce)) return { ok: false, reason: 'malformed' };
|
|
161
|
+
if (typeof proof !== 'string' || !PROOF_RE.test(proof)) return { ok: false, reason: 'malformed' };
|
|
162
|
+
const delta = now() - Number(ts);
|
|
163
|
+
if (!Number.isFinite(delta) || Math.abs(delta) > skewMs) return { ok: false, reason: 'expired' };
|
|
164
|
+
const expected = signRequest(secret, { method, path: reqPath, session, timestamp: ts, nonce, rawBody });
|
|
165
|
+
const a = Buffer.from(expected, 'hex');
|
|
166
|
+
const b = Buffer.from(proof.toLowerCase(), 'hex');
|
|
167
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return { ok: false, reason: 'bad-proof' };
|
|
168
|
+
if (nonceCache && !nonceCache.use(nonce)) return { ok: false, reason: 'replay' };
|
|
169
|
+
return { ok: true, session };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
bridgeSecretPath, readBridgeSecretSafe, loadOrCreateBridgeSecret,
|
|
174
|
+
canonicalRequest, signRequest, signedHeaders, verifyRequest, createNonceCache, bodyDigest,
|
|
175
|
+
SESSION_HEADER, TS_HEADER, NONCE_HEADER, PROOF_HEADER, SKEW_MS, NONCE_CAP,
|
|
176
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/audio/capability.js — descrizione della capability audio di UN nodo.
|
|
3
|
+
//
|
|
4
|
+
// Cosa dice e cosa non dice. Dice se esiste un adapter, se il binario e'
|
|
5
|
+
// installato, se il consenso locale e' attivo e quali limiti valgono su quella
|
|
6
|
+
// piattaforma. NON dice che il nodo e' udibile: nessun probe software puo'
|
|
7
|
+
// stabilirlo. `liveness: 'ready'` significa "un adapter e' pronto ad accettare
|
|
8
|
+
// un enunciato", non "qualcuno lo sentira'".
|
|
9
|
+
//
|
|
10
|
+
// Metadati bounded per costruzione: niente path di binari (rivelerebbero il
|
|
11
|
+
// layout del filesystem), niente enumerazione delle voci di sistema (sarebbe
|
|
12
|
+
// fingerprinting della macchina verso un peer), niente informazioni di
|
|
13
|
+
// configurazione. Un peer autorizzato deve poter decidere se ha senso provare a
|
|
14
|
+
// parlare, non ricostruire com'e' fatto il computer di qualcun altro.
|
|
15
|
+
const { describeAdapter } = require('./adapters.js');
|
|
16
|
+
|
|
17
|
+
const MAX_VOICES = 32;
|
|
18
|
+
const MAX_LANGS = 32;
|
|
19
|
+
|
|
20
|
+
// describeCapability(): vista pubblica. `consent:false` e' il default e resta
|
|
21
|
+
// separato dall'esistenza dell'adapter: un nodo puo' essere perfettamente in
|
|
22
|
+
// grado di parlare e avere comunque negato il permesso di farlo.
|
|
23
|
+
function describeCapability({ adapter = null, consent = false, nodeId = null } = {}) {
|
|
24
|
+
const described = describeAdapter(adapter);
|
|
25
|
+
return {
|
|
26
|
+
...(nodeId ? { nodeId } : {}),
|
|
27
|
+
adapter: described.adapter,
|
|
28
|
+
installed: described.installed,
|
|
29
|
+
// Senza consenso la liveness effettiva e' `unavailable`: dichiarare 'ready'
|
|
30
|
+
// un endpoint che rifiutera' comunque significherebbe invitare a un
|
|
31
|
+
// tentativo che non puo' riuscire.
|
|
32
|
+
liveness: consent === true ? described.liveness : 'unavailable',
|
|
33
|
+
consent: consent === true,
|
|
34
|
+
voices: described.voices.slice(0, MAX_VOICES),
|
|
35
|
+
languages: described.languages.slice(0, MAX_LANGS),
|
|
36
|
+
...(described.limits ? { limits: described.limits } : {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { describeCapability, MAX_VOICES, MAX_LANGS };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/audio/consent.js — WP2R: self-owned audio consent store. Consent is a
|
|
3
|
+
// property of the LOCAL target node (the device that would sound), NOT of a
|
|
4
|
+
// peer record. Lives in a dedicated local file (~/.nexuscrew/audio.json),
|
|
5
|
+
// schema closed audio:{consent:boolean}, default OFF, atomic read/write.
|
|
6
|
+
// Never derived from a peer's nodes.json record. Local-only mutation; the
|
|
7
|
+
// federation whitelist never exposes it, so a federated consent mutation is
|
|
8
|
+
// unreachable.
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
|
|
13
|
+
const SCHEMA_VERSION = 1;
|
|
14
|
+
|
|
15
|
+
// Il consenso vive accanto al token, nella stessa directory di stato del nodo:
|
|
16
|
+
// e' l'ancora gia' usata dal bridge di notifica (`notifyDir`) e resta corretta
|
|
17
|
+
// anche quando i test isolano l'istanza fuori dalla home reale. Ancorarlo a
|
|
18
|
+
// os.homedir() farebbe divergere server e Settings in quelle installazioni.
|
|
19
|
+
function consentPath(cfg = {}, home = (cfg.home || os.homedir())) {
|
|
20
|
+
if (cfg.audioConsentPath) return cfg.audioConsentPath;
|
|
21
|
+
if (cfg.tokenPath) return path.join(path.dirname(cfg.tokenPath), 'audio.json');
|
|
22
|
+
return path.join(home, '.nexuscrew', 'audio.json');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function defaultConsent() {
|
|
26
|
+
return { schemaVersion: SCHEMA_VERSION, audio: { consent: false } };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readConsent(cfg = {}, home = (cfg.home || os.homedir())) {
|
|
30
|
+
const p = consentPath(cfg, home);
|
|
31
|
+
try {
|
|
32
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
33
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return defaultConsent();
|
|
34
|
+
if (raw.schemaVersion !== SCHEMA_VERSION) return defaultConsent();
|
|
35
|
+
if (!raw.audio || typeof raw.audio !== 'object' || Array.isArray(raw.audio)) return defaultConsent();
|
|
36
|
+
if (Object.keys(raw.audio).some((k) => k !== 'consent')) return defaultConsent(); // schema chiuso
|
|
37
|
+
if (typeof raw.audio.consent !== 'boolean') return defaultConsent();
|
|
38
|
+
return { schemaVersion: SCHEMA_VERSION, audio: { consent: raw.audio.consent === true } };
|
|
39
|
+
} catch (_) {
|
|
40
|
+
return defaultConsent();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isConsent(cfg, home) {
|
|
45
|
+
return readConsent(cfg, home).audio.consent === true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function atomicWrite(file, obj) {
|
|
49
|
+
const dir = path.dirname(file);
|
|
50
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
51
|
+
const tmp = path.join(dir, `.audio.${process.pid}.${Math.floor(Date.now() * Math.random() + Date.now())}.tmp`);
|
|
52
|
+
fs.writeFileSync(tmp, `${JSON.stringify(obj)}\n`, { mode: 0o600 });
|
|
53
|
+
fs.chmodSync(tmp, 0o600);
|
|
54
|
+
fs.renameSync(tmp, file);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function setConsent(cfg, consent, home = (cfg && cfg.home) || os.homedir()) {
|
|
58
|
+
if (typeof consent !== 'boolean') throw new Error('consent deve essere boolean');
|
|
59
|
+
const obj = { schemaVersion: SCHEMA_VERSION, audio: { consent } };
|
|
60
|
+
atomicWrite(consentPath(cfg, home), obj);
|
|
61
|
+
return { audio: { consent } };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { consentPath, readConsent, isConsent, setConsent, defaultConsent, SCHEMA_VERSION };
|