@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/README.md +2 -1
  3. package/frontend/dist/assets/index-CrZBnpKn.css +32 -0
  4. package/frontend/dist/assets/index-CwZySkm2.js +93 -0
  5. package/frontend/dist/assets/index-hq-2ORFQ.js +93 -0
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/dist/version.json +1 -1
  8. package/lib/audio/acl.js +65 -0
  9. package/lib/audio/adapters.js +233 -0
  10. package/lib/audio/bridge-auth.js +176 -0
  11. package/lib/audio/capability.js +40 -0
  12. package/lib/audio/consent.js +64 -0
  13. package/lib/audio/dispatch.js +154 -0
  14. package/lib/audio/group-receipt.js +119 -0
  15. package/lib/audio/group-speak.js +146 -0
  16. package/lib/audio/groups.js +121 -0
  17. package/lib/audio/origin.js +128 -0
  18. package/lib/audio/queue.js +191 -0
  19. package/lib/audio/rate-limit.js +71 -0
  20. package/lib/audio/receipt.js +146 -0
  21. package/lib/audio/routes.js +374 -0
  22. package/lib/audio/speak.js +125 -0
  23. package/lib/cli/doctor.js +37 -0
  24. package/lib/config.js +7 -0
  25. package/lib/fleet/builtin.js +2 -1
  26. package/lib/fleet/launch.js +16 -0
  27. package/lib/fleet/managed.js +53 -2
  28. package/lib/fleet/provider.js +6 -0
  29. package/lib/fleet/runtime.js +14 -1
  30. package/lib/mcp/server.js +27 -3
  31. package/lib/mcp/tools.js +142 -0
  32. package/lib/nodes/tunnel-supervisor.js +33 -7
  33. package/lib/nodes/tunnel.js +21 -0
  34. package/lib/proxy/federation.js +41 -11
  35. package/lib/proxy/hop-proof.js +50 -0
  36. package/lib/server.js +97 -3
  37. package/lib/settings/routes.js +150 -4
  38. package/lib/tmux/lifecycle.js +24 -3
  39. package/package.json +1 -1
  40. package/skills/nexuscrew-agent/SKILL.md +30 -1
@@ -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-D0MBXlAl.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-5PMZjYKR.css">
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.39"}
1
+ {"version":"0.8.41"}
@@ -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 };