@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/lib/mcp/server.js CHANGED
@@ -20,10 +20,12 @@
20
20
  const readline = require('node:readline');
21
21
  const crypto = require('node:crypto');
22
22
  const os = require('node:os');
23
+ const path = require('node:path');
23
24
  const { execFile } = require('node:child_process');
24
25
  const { loadConfig } = require('../config.js');
25
26
  const { readTokenSafe } = require('../auth/token.js');
26
27
  const { isValidSession } = require('../files/store.js');
28
+ const { loadOrCreateBridgeSecret, signedHeaders } = require('../audio/bridge-auth.js');
27
29
  const VERSION = require('../../package.json').version;
28
30
  const MCP_COMPANIONS = require('../../mcp-companions.json');
29
31
  const { TOOLS, IDENTITY_CODE, IDENTITY_REMEDIATION } = require('./tools.js');
@@ -162,17 +164,39 @@ function createMcpServer(opts = {}) {
162
164
  throw new Error('token NexusCrew non leggibile: il server e\' inizializzato? (nexuscrew init)');
163
165
  }
164
166
 
165
- async function api(method, apiPath, body) {
167
+ // Segreto del bridge: file 0600 accanto al token, distinto dal token stesso.
168
+ // Serve dove il Bearer non basta — Audio Share — perche' il Bearer prova solo
169
+ // "qualcuno in loopback ce l'ha", non "questa e' la cella X".
170
+ const bridgeKeyPath = () => cfg.audioBridgeSecretPath || path.join(path.dirname(cfg.tokenPath), 'audio-bridge.key');
171
+
172
+ // `opts.signedSession` attiva la firma HMAC del bridge: copre metodo, path,
173
+ // sessione, timestamp, nonce e i BYTE del body effettivamente inviati. Per
174
+ // questo il payload viene serializzato UNA volta sola e riusato: firmare un
175
+ // JSON e spedirne un altro, anche solo con un ordine di chiavi diverso,
176
+ // produrrebbe una firma valida per un corpo che il server non vede mai.
177
+ async function api(method, apiPath, body, opts = {}) {
166
178
  const token = readToken();
179
+ const payload = body !== undefined ? JSON.stringify(body) : undefined;
180
+ let signed = {};
181
+ if (opts.signedSession) {
182
+ try {
183
+ signed = signedHeaders(loadOrCreateBridgeSecret(bridgeKeyPath()), {
184
+ method, path: apiPath, session: opts.signedSession, rawBody: payload === undefined ? '' : payload,
185
+ });
186
+ } catch (_) {
187
+ throw new Error('segreto bridge audio non leggibile: il server e\' inizializzato? (nexuscrew init)');
188
+ }
189
+ }
167
190
  let r;
168
191
  try {
169
192
  r = await fetchImpl(`${baseUrl}${apiPath}`, {
170
193
  method,
171
194
  headers: {
172
195
  authorization: `Bearer ${token}`,
173
- ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
196
+ ...signed,
197
+ ...(payload !== undefined ? { 'content-type': 'application/json' } : {}),
174
198
  },
175
- ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
199
+ ...(payload !== undefined ? { body: payload } : {}),
176
200
  signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
177
201
  });
178
202
  } catch (e) {
package/lib/mcp/tools.js CHANGED
@@ -496,6 +496,148 @@ const TOOLS = [
496
496
  return out;
497
497
  },
498
498
  },
499
+ {
500
+ name: 'nc_speak',
501
+ description: 'Speak un utterance audio verso un nodo target (exact instanceId). Ritorna un receipt caller-scoped con status per-endpoint (refused|unreachable|accepted|spoken|unknown) e attribution; MAI text/lang/voice o aggregate success. L\'origin genera un utteranceId immutabile se omesso.',
502
+ inputSchema: {
503
+ type: 'object',
504
+ properties: {
505
+ target: { type: 'string', description: 'exact target node instanceId (32 hex)' },
506
+ text: { type: 'string', description: 'utterance text (bounded)' },
507
+ lang: { type: 'string', description: 'BCP-47 lang hint (optional)' },
508
+ voice: { type: 'string', description: 'voice id hint (optional)' },
509
+ urgency: { type: 'string', enum: ['normal', 'high'], description: 'high urgency non bypassa i rate limit' },
510
+ utteranceId: { type: 'string', description: 'immutable id (generato se omesso)' },
511
+ },
512
+ required: ['target', 'text'],
513
+ },
514
+ async handler(args, ctx) {
515
+ const target = argString(args, 'target', { required: true, max: 32 });
516
+ const text = argString(args, 'text', { required: true, max: 320 });
517
+ const lang = argString(args, 'lang', { max: 35 });
518
+ const voice = argString(args, 'voice', { max: 64 });
519
+ if (args.urgency !== undefined && args.urgency !== 'normal' && args.urgency !== 'high') {
520
+ throw new Error('urgency deve essere "normal" o "high"');
521
+ }
522
+ const urgency = args.urgency === 'high' ? 'high' : 'normal';
523
+ const utteranceId = argString(args, 'utteranceId', { max: 128 });
524
+ const identity = await ctx.identity();
525
+ const session = requireSession(identity.session, 'nc_speak', identity.code);
526
+ // La sessione NON viaggia nel body: sarebbe una dichiarazione, e una
527
+ // dichiarazione e' falsificabile da chiunque abbia il token della UI.
528
+ // Viaggia dentro la firma HMAC del bridge, che il server verifica prima di
529
+ // attribuire l'enunciato a una cella.
530
+ const payload = { target, text, urgency, ...(lang ? { lang } : {}), ...(voice ? { voice } : {}), ...(utteranceId ? { utteranceId } : {}) };
531
+ const receipt = await ctx.api('POST', '/api/audio/speak', payload, { signedSession: session });
532
+ return { receipt };
533
+ },
534
+ },
535
+ {
536
+ name: 'nc_speak_status',
537
+ description: 'Interroga lo status di uno speak per utteranceId (read-only). Ritorna il receipt caller-scoped redacted (status/reason/attribution); MAI text/lang/voice/secret.',
538
+ inputSchema: {
539
+ type: 'object',
540
+ properties: { utteranceId: { type: 'string', description: 'utterance id da interrogare' } },
541
+ required: ['utteranceId'],
542
+ },
543
+ annotations: { readOnlyHint: true },
544
+ async handler(args, ctx) {
545
+ const utteranceId = argString(args, 'utteranceId', { required: true, max: 128 });
546
+ const identity = await ctx.identity();
547
+ const session = requireSession(identity.session, 'nc_speak_status', identity.code);
548
+ const receipt = await ctx.api('GET', `/api/audio/speak/status/${encodeURIComponent(utteranceId)}`, undefined, { signedSession: session });
549
+ return { receipt };
550
+ },
551
+ },
552
+ {
553
+ name: 'nc_speak_stop',
554
+ description: 'Ferma un enunciato su un nodo target. Con utteranceId ferma quello specifico (solo se e\' del chiamante), senza utteranceId ferma tutto sul target. Lo stop locale del nodo resta sovrano e non dipende da questa chiamata.',
555
+ inputSchema: {
556
+ type: 'object',
557
+ properties: {
558
+ target: { type: 'string', description: 'exact target node instanceId (32 hex)' },
559
+ utteranceId: { type: 'string', description: 'enunciato da fermare; omesso = tutti gli enunciati del target' },
560
+ },
561
+ required: ['target'],
562
+ },
563
+ async handler(args, ctx) {
564
+ const target = argString(args, 'target', { required: true, max: 32 });
565
+ const utteranceId = argString(args, 'utteranceId', { max: 128 });
566
+ const identity = await ctx.identity();
567
+ const session = requireSession(identity.session, 'nc_speak_stop', identity.code);
568
+ const receipt = await ctx.api('POST', '/api/audio/stop', {
569
+ target, ...(utteranceId ? { utteranceId } : {}),
570
+ }, { signedSession: session });
571
+ return { receipt };
572
+ },
573
+ },
574
+ {
575
+ name: 'nc_speak_group',
576
+ description: 'Speak verso un gruppo audio locale nominato. Il gruppo espande solo instanceId esatti: primary-failover prova un endpoint alla volta, fanout e esplicito. Ritorna subito un receipt per endpoint; usa nc_speak_group_status per gli aggiornamenti. Un gruppo non concede consenso e non aggira liveness/ACL del target.',
577
+ inputSchema: {
578
+ type: 'object',
579
+ properties: {
580
+ group: { type: 'string', description: 'nome del gruppo audio locale (a-z, 0-9, trattini)' },
581
+ text: { type: 'string', description: 'utterance text (bounded)' },
582
+ lang: { type: 'string', description: 'BCP-47 lang hint (optional)' },
583
+ voice: { type: 'string', description: 'voice id hint (optional)' },
584
+ urgency: { type: 'string', enum: ['normal', 'high'], description: 'high urgency non bypassa i rate limit' },
585
+ utteranceId: { type: 'string', description: 'immutable id (generato se omesso)' },
586
+ },
587
+ required: ['group', 'text'],
588
+ },
589
+ async handler(args, ctx) {
590
+ const group = argString(args, 'group', { required: true, max: 32 });
591
+ const text = argString(args, 'text', { required: true, max: 320 });
592
+ const lang = argString(args, 'lang', { max: 35 });
593
+ const voice = argString(args, 'voice', { max: 64 });
594
+ if (args.urgency !== undefined && args.urgency !== 'normal' && args.urgency !== 'high') {
595
+ throw new Error('urgency deve essere "normal" o "high"');
596
+ }
597
+ const urgency = args.urgency === 'high' ? 'high' : 'normal';
598
+ const utteranceId = argString(args, 'utteranceId', { max: 128 });
599
+ const identity = await ctx.identity();
600
+ const session = requireSession(identity.session, 'nc_speak_group', identity.code);
601
+ const receipt = await ctx.api('POST', '/api/audio/groups/speak', {
602
+ group, text, urgency,
603
+ ...(lang ? { lang } : {}), ...(voice ? { voice } : {}), ...(utteranceId ? { utteranceId } : {}),
604
+ }, { signedSession: session });
605
+ return { receipt };
606
+ },
607
+ },
608
+ {
609
+ name: 'nc_speak_group_status',
610
+ description: 'Interroga lo status per-endpoint di un gruppo audio (read-only). Il receipt e caller-scoped e non contiene testo, lingua, voce o successi aggregati.',
611
+ inputSchema: {
612
+ type: 'object',
613
+ properties: { utteranceId: { type: 'string', description: 'utterance id del gruppo' } },
614
+ required: ['utteranceId'],
615
+ },
616
+ annotations: { readOnlyHint: true },
617
+ async handler(args, ctx) {
618
+ const utteranceId = argString(args, 'utteranceId', { required: true, max: 128 });
619
+ const identity = await ctx.identity();
620
+ const session = requireSession(identity.session, 'nc_speak_group_status', identity.code);
621
+ const receipt = await ctx.api('GET', `/api/audio/groups/status/${encodeURIComponent(utteranceId)}`, undefined, { signedSession: session });
622
+ return { receipt };
623
+ },
624
+ },
625
+ {
626
+ name: 'nc_speak_group_stop',
627
+ description: 'Ferma un gruppo audio per utteranceId: blocca i candidati non ancora tentati e invia Stop a ogni endpoint gia ammesso. Lo Stop locale del nodo resta sovrano anche offline.',
628
+ inputSchema: {
629
+ type: 'object',
630
+ properties: { utteranceId: { type: 'string', description: 'utterance id del gruppo da fermare' } },
631
+ required: ['utteranceId'],
632
+ },
633
+ async handler(args, ctx) {
634
+ const utteranceId = argString(args, 'utteranceId', { required: true, max: 128 });
635
+ const identity = await ctx.identity();
636
+ const session = requireSession(identity.session, 'nc_speak_group_stop', identity.code);
637
+ const receipt = await ctx.api('POST', '/api/audio/groups/stop', { utteranceId }, { signedSession: session });
638
+ return { receipt };
639
+ },
640
+ },
499
641
  ];
500
642
 
501
643
  module.exports = {
@@ -21,6 +21,17 @@ const ownershipGraceMs = Number.isFinite(ownershipGraceRaw) && ownershipGraceRaw
21
21
  const reverseFailureMaxRaw = Number(process.env.NEXUSCREW_TUNNEL_REVERSE_FAILURE_MAX || 8);
22
22
  const reverseFailureMax = Number.isInteger(reverseFailureMaxRaw) && reverseFailureMaxRaw >= 1
23
23
  ? Math.min(reverseFailureMaxRaw, 32) : 8;
24
+ // Once the initial reverse-forward budget is exhausted the supervisor does NOT
25
+ // die: it stays "degraded" and retries at the fixed production cadence of 60s,
26
+ // so a transient reverse listener on the hub (e.g. a mobile reconnect) heals on
27
+ // its own without an OFF/ON. A short cadence is a test-only seam, never a
28
+ // production runtime override: otherwise a bad environment value could recreate
29
+ // the retry storm that this breaker is meant to prevent.
30
+ const steadyRetryTestMode = process.env.NEXUSCREW_TUNNEL_TEST_MODE === '1';
31
+ const steadyRetryMsRaw = Number(steadyRetryTestMode ? (process.env.NEXUSCREW_TUNNEL_STEADY_RETRY_MS || 60000) : 60000);
32
+ const steadyRetryMinMs = steadyRetryTestMode ? 1 : 60000;
33
+ const steadyRetryMs = Number.isFinite(steadyRetryMsRaw) && steadyRetryMsRaw >= steadyRetryMinMs
34
+ ? Math.min(steadyRetryMsRaw, 120000) : 60000;
24
35
  if (!sshBin || !statePath || !pidPath || !runId) process.exit(2);
25
36
 
26
37
  let child = null;
@@ -92,6 +103,7 @@ function probeForward(expectedChild) {
92
103
  if (stopping || child !== expectedChild || !child || child.exitCode != null) return;
93
104
  if (ready) {
94
105
  attempt = 0;
106
+ reverseFailures = 0;
95
107
  logEvent(`forward ready stableMs=${stableMs}`);
96
108
  if (!writeState('transport-ready', { sshPid: child.pid, stableMs, probe: 'tcp-forward' })) stop();
97
109
  return;
@@ -137,14 +149,28 @@ function scheduleRetry(detail) {
137
149
  retryTimer = setTimeout(run, delayMs);
138
150
  }
139
151
 
140
- function terminalFailure(diagnosis) {
152
+ // Bounded, attributable degraded state. The supervisor stays alive, advertises
153
+ // the reverse failure class plus the negotiated reversePort and the honest
154
+ // remote-listener attribution (unknown without hub privilege), then retries on
155
+ // a fixed cadence. It never exits on a reverse-forward failure: the tunnel
156
+ // self-heals when the channel frees up.
157
+ function enterDegraded(diagnosis) {
158
+ if (stopping) return finish();
141
159
  const safe = diagnosis || { code: 'reverse-forward-failed', detail: 'canale inverso non disponibile' };
142
- logEvent(`ssh terminal failure code=${safe.code} attempts=${reverseFailures}`);
143
- writeState('failed', {
160
+ if (reverseFailures > reverseFailureMax) reverseFailures = reverseFailureMax;
161
+ // `ownsGeneration()` only proves that THIS local supervisor owns its pidfile;
162
+ // it cannot identify the process holding a listener on the hub. Never turn
163
+ // local pidfile ownership into a false remote-listener attribution.
164
+ const ownership = 'unknown';
165
+ logEvent(`ssh degraded code=${safe.code} reversePort=${reversePort || 'none'} ownership=${ownership} failures=${reverseFailures} steadyRetryMs=${steadyRetryMs}`);
166
+ if (!writeState('degraded', {
144
167
  code: safe.code, detail: safe.detail,
145
- ...(safe.hint ? { hint: safe.hint } : {}), terminal: true,
146
- });
147
- process.exit(0);
168
+ ...(safe.hint ? { hint: safe.hint } : {}),
169
+ reversePort, ownership, steadyRetryMs, terminal: false,
170
+ })) return stop();
171
+ // Keep the backoff counter honest for observers; the degraded retry is fixed.
172
+ if (attempt < reverseFailureMax) attempt = reverseFailureMax;
173
+ retryTimer = setTimeout(run, steadyRetryMs);
148
174
  }
149
175
 
150
176
  function run() {
@@ -176,7 +202,7 @@ function run() {
176
202
  const reverseFailure = diagnosis && ['reverse-forward-bind', 'reverse-forward-failed'].includes(diagnosis.code);
177
203
  if (reverseFailure) {
178
204
  reverseFailures += 1;
179
- if (reverseFailures >= reverseFailureMax) return terminalFailure(diagnosis);
205
+ if (reverseFailures >= reverseFailureMax) return enterDegraded(diagnosis);
180
206
  } else {
181
207
  reverseFailures = 0;
182
208
  }
@@ -290,6 +290,19 @@ function readTunnelState(home, name) {
290
290
  const owned = state.supervisorPid === meta.pid && (!meta.runId || state.runId === meta.runId);
291
291
  if (!owned) return { status: 'down', pid: meta.pid, since: meta.startTs || null, reason: 'starting' };
292
292
  if (state.status === 'failed' && state.terminal === true) return terminalState();
293
+ if (state.status === 'degraded') {
294
+ return {
295
+ status: 'down', phase: 'degraded', reason: 'degraded', pid: meta.pid, since: meta.startTs || null,
296
+ ...(typeof state.code === 'string' ? { code: state.code } : {}),
297
+ ...(typeof state.detail === 'string' ? { detail: state.detail } : {}),
298
+ ...(typeof state.hint === 'string' ? { hint: state.hint } : {}),
299
+ ...(Number.isInteger(state.reversePort) ? { reversePort: state.reversePort } : {}),
300
+ ...(typeof state.ownership === 'string' ? { ownership: state.ownership } : {}),
301
+ ...(Number.isInteger(state.attempt) ? { attempt: state.attempt } : {}),
302
+ ...(Number.isFinite(state.steadyRetryMs) ? { retryInMs: state.steadyRetryMs } : {}),
303
+ ...(transport ? { transport } : {}),
304
+ };
305
+ }
293
306
  if (state.status === 'transport-ready' || state.status === 'up') {
294
307
  return { status: 'up', phase: 'transport-ready', pid: meta.pid, since: meta.startTs || null,
295
308
  ...(Number.isInteger(state.attempt) ? { attempt: state.attempt } : {}), ...(transport ? { transport } : {}) };
@@ -330,6 +343,14 @@ function diagnoseTunnel(home, node, state = null) {
330
343
  detail: `connessione SSH in retry${Number.isInteger(current.attempt) ? ` (tentativo ${current.attempt + 1})` : ''}`,
331
344
  hint: 'usa Test connessione per vedere la causa; verifica target SSH, chiave e porta' };
332
345
  }
346
+ if (current.reason === 'degraded' || current.phase === 'degraded') {
347
+ return {
348
+ stage: 'ssh', code: current.code || 'reverse-forward-failed', status: 'down', phase: 'degraded',
349
+ detail: current.detail || 'canale inverso non disponibile, retry fisso in corso',
350
+ hint: current.hint || 'il tunnel resta attivo e riprovera automaticamente; verifica il listener reverse sul hub',
351
+ ...(Number.isInteger(current.reversePort) ? { reversePort: current.reversePort } : {}),
352
+ };
353
+ }
333
354
  if (current.reason === 'failed' || current.phase === 'failed') {
334
355
  return {
335
356
  stage: 'ssh', code: current.code || 'ssh-terminal-failure', status: 'down', phase: 'failed',
@@ -10,6 +10,7 @@ const { safeEqual } = require('../nodes/peering.js');
10
10
  const {
11
11
  sanitizeRequestHeaders, sanitizeResponseHeaders, stripLocalTokenQuery,
12
12
  } = require('./node-proxy.js');
13
+ const { signHop, HOP_HEADER } = require('./hop-proof.js');
13
14
 
14
15
  const MAX_HOPS = 4;
15
16
  const ROUTE_DELIMITER = '_';
@@ -71,6 +72,10 @@ function knownResource(resource) {
71
72
  // Hydra: the rest of /settings stays unreachable.
72
73
  || resource === '/settings/peering/invite'
73
74
  || resource === '/ws'
75
+ || resource === '/audio/capability'
76
+ || resource === '/audio/speak'
77
+ || resource === '/audio/speak/status'
78
+ || resource === '/audio/stop'
74
79
  || /^\/fleet\/(status|schema|definitions|credentials\/status|credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource);
75
80
  }
76
81
 
@@ -97,9 +102,25 @@ function allowedResource(resource, method = 'GET') {
97
102
  if (resource === '/ws') return method === 'GET';
98
103
  if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) return method === 'GET';
99
104
  if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) return method === 'POST';
105
+ // Audio: only capability read, speak and stop are exposed through Hydra.
106
+ // audio.consent is a LOCAL mutation and MUST stay unreachable federated.
107
+ if (resource === '/audio/capability') return method === 'GET';
108
+ if (resource === '/audio/speak') return method === 'POST';
109
+ if (resource === '/audio/speak/status') return method === 'POST';
110
+ if (resource === '/audio/stop') return method === 'POST';
100
111
  return false;
101
112
  }
102
113
 
114
+ // READONLY blocca ogni mutazione che crea o modifica stato, con due eccezioni
115
+ // audio deliberate: interrogare lo status federato usa POST soltanto per
116
+ // trasportare l'attestazione della cella, e Stop e' un'azione di sicurezza che
117
+ // deve poter zittire un endpoint anche quando quel nodo e' READONLY. Speak
118
+ // resta naturalmente bloccato.
119
+ function readonlyBlocksFederated(resource, method) {
120
+ if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) return false;
121
+ return resource !== '/audio/speak/status' && resource !== '/audio/stop';
122
+ }
123
+
103
124
  function allowedQuery(resource, method, rawUrl) {
104
125
  if (!resource.startsWith('/diagnostics/')) return true;
105
126
  const index = String(rawUrl || '').indexOf('?');
@@ -117,17 +138,20 @@ function allowedQuery(resource, method, rawUrl) {
117
138
  return true;
118
139
  }
119
140
 
120
- function cleanHeaders(headers, credential, visited = null) {
141
+ function cleanHeaders(headers, credential, visited = null, hopProof = null) {
121
142
  const out = sanitizeRequestHeaders(headers, credential);
122
143
  for (const key of Object.keys(out)) {
123
144
  if (['x-nexuscrew-route', 'x-nexuscrew-visited', 'x-nexuscrew-hop'].includes(key.toLowerCase())) delete out[key];
124
145
  }
125
146
  if (Array.isArray(visited) && visited.length) out['x-nexuscrew-visited'] = visited.join(',');
147
+ // La prova di hop viene aggiunta DOPO la cancellazione: il canale e' riservato
148
+ // al server, un valore arrivato dal client non sopravvive mai fin qui.
149
+ if (hopProof) out[HOP_HEADER] = hopProof;
126
150
  return out;
127
151
  }
128
152
 
129
- function proxyHttp(req, res, { port, path, credential, visited = null }) {
130
- const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited) }, (r) => {
153
+ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null }) {
154
+ const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited, hopProof) }, (r) => {
131
155
  res.writeHead(r.statusCode, sanitizeResponseHeaders(r.headers)); r.pipe(res);
132
156
  });
133
157
  up.setTimeout(30000, () => up.destroy());
@@ -135,22 +159,28 @@ function proxyHttp(req, res, { port, path, credential, visited = null }) {
135
159
  req.pipe(up);
136
160
  }
137
161
 
138
- function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false }) {
162
+ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null }) {
139
163
  return (req, res) => {
140
164
  const parsed = parseRoute(req.url);
141
165
  if (!parsed || !allowedResource(parsed.resource, req.method)
142
166
  || !allowedQuery(parsed.resource, req.method, req.url)) return res.status(404).json({ error: 'not found' });
143
- if (readonly() && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
167
+ if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
144
168
  const st = store.loadStore(nodesPath);
145
169
  if (!st) return res.status(503).json({ error: 'node store unavailable' });
146
170
  const visited = controlledVisited(req, ingress, st.nodeId);
147
171
  if (!visited) return res.status(409).json({ error: 'federation cycle rejected' });
148
172
  if (parsed.route.length === 0) {
173
+ // Ultimo hop: la richiesta rientra nell'API locale con il Bearer locale e
174
+ // da li' in poi sarebbe indistinguibile da un POST diretto. La prova di
175
+ // hop e' cio' che la distingue, ed e' legata a metodo, path e catena.
176
+ const path = `/api${parsed.resource}${queryOf(req.url)}`;
177
+ const secret = typeof hopSecret === 'function' ? hopSecret() : hopSecret;
149
178
  return proxyHttp(req, res, {
150
179
  port: typeof localPort === 'function' ? localPort() : localPort,
151
- path: `/api${parsed.resource}${queryOf(req.url)}`,
180
+ path,
152
181
  credential: localCredential(),
153
182
  visited,
183
+ hopProof: signHop(secret, { method: req.method, path, visited }),
154
184
  });
155
185
  }
156
186
  const next = st && store.getNode(st, parsed.route[0]);
@@ -514,7 +544,7 @@ async function collectLocalTopology({
514
544
  return { instanceId: live.instanceId, nodes };
515
545
  }
516
546
 
517
- function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null }) {
547
+ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null }) {
518
548
  const r = express.Router();
519
549
  r.use((req, res, next) => {
520
550
  const peer = peerFromToken(nodesPath, bearerFrom(req));
@@ -576,13 +606,13 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
576
606
  return res.status(e.status || 500).json({ error: String(e && e.message || e), ...(e.code ? { code: e.code } : {}) });
577
607
  }
578
608
  });
579
- r.use('/route', (req, res) => routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly })(req, res));
609
+ r.use('/route', (req, res) => routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly, hopSecret })(req, res));
580
610
  return r;
581
611
  }
582
612
 
583
- function localRouter({ nodesPath, localPort, localCredential, readonly }) {
613
+ function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecret = null }) {
584
614
  const r = express.Router();
585
- r.use((req, res) => routeHandler({ nodesPath, localPort, localCredential, readonly })(req, res));
615
+ r.use((req, res) => routeHandler({ nodesPath, localPort, localCredential, readonly, hopSecret })(req, res));
586
616
  return r;
587
617
  }
588
618
 
@@ -624,7 +654,7 @@ function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConn
624
654
 
625
655
  module.exports = {
626
656
  MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS,
627
- peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery,
657
+ peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
628
658
  collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
629
659
  probeHealth, waitForHealthyPeer, notifyHubShare, reconcilePeerShare, runShareRevokeBoot,
630
660
  };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+ // lib/proxy/hop-proof.js — prova che una richiesta arrivata su /api e' l'ULTIMO
3
+ // hop di una route federata generata da QUESTO processo, non un POST diretto di
4
+ // un client locale che ha semplicemente scritto gli header a mano.
5
+ //
6
+ // Perche' serve. `controlledVisited()` lega gia' `x-nexuscrew-visited` al peer
7
+ // autenticato dal suo acceptToken, ma l'ultimo hop entra in `/api/<resource>`
8
+ // con il Bearer LOCALE: a quel punto l'handler non distingue piu' un inbound
9
+ // federato da una richiesta diretta di chiunque possieda il token della UI.
10
+ // `cleanHeaders()` cancella gia' `x-nexuscrew-{route,visited,hop}` da ogni
11
+ // header in ingresso, quindi il canale e' riservato: basta renderlo non
12
+ // riproducibile. La firma usa un segreto casuale per-processo, mai su disco,
13
+ // mai esposto da un'API: un client locale non puo' calcolarla.
14
+ //
15
+ // Il payload firmato include metodo e path oltre alla catena visited, cosi' una
16
+ // prova non e' trasportabile su un'altra risorsa o su un altro verbo.
17
+ const crypto = require('node:crypto');
18
+
19
+ const HOP_HEADER = 'x-nexuscrew-hop';
20
+ const PREFIX = 'NEXUSCREW-HOP-V1';
21
+
22
+ function createHopSecret() {
23
+ return crypto.randomBytes(32);
24
+ }
25
+
26
+ function canonicalHop({ method, path: reqPath, visited }) {
27
+ const chain = Array.isArray(visited) ? visited.join(',') : String(visited || '');
28
+ return `${PREFIX}\n${String(method || '').toUpperCase()}\n${String(reqPath || '')}\n${chain}`;
29
+ }
30
+
31
+ // signHop(): hex HMAC-SHA256. Ritorna null senza segreto, cosi' un server che
32
+ // non ha inizializzato la prova non emette un header vuoto interpretabile.
33
+ function signHop(secret, parts) {
34
+ if (!secret) return null;
35
+ return crypto.createHmac('sha256', secret).update(canonicalHop(parts)).digest('hex');
36
+ }
37
+
38
+ // verifyHop(): confronto timing-safe. Fail-closed su qualunque anomalia
39
+ // (segreto assente, header assente, lunghezza diversa, hex non valido).
40
+ function verifyHop(secret, parts, proof) {
41
+ if (!secret || typeof proof !== 'string' || !/^[a-f0-9]{64}$/i.test(proof)) return false;
42
+ const expected = signHop(secret, parts);
43
+ if (!expected) return false;
44
+ const a = Buffer.from(expected, 'hex');
45
+ const b = Buffer.from(proof.toLowerCase(), 'hex');
46
+ if (a.length !== b.length) return false;
47
+ return crypto.timingSafeEqual(a, b);
48
+ }
49
+
50
+ module.exports = { createHopSecret, signHop, verifyHop, canonicalHop, HOP_HEADER };
package/lib/server.js CHANGED
@@ -32,6 +32,17 @@ const nodesInventory = require('./nodes/inventory.js');
32
32
  const topologyCache = require('./nodes/topology-cache.js');
33
33
  const { createNodeProxy, handleNodeUpgrade } = require('./proxy/node-proxy.js');
34
34
  const federation = require('./proxy/federation.js');
35
+ const { audioRoutes } = require('./audio/routes.js');
36
+ const { isConsent: isAudioConsent } = require('./audio/consent.js');
37
+ const { createOriginResolver } = require('./audio/origin.js');
38
+ const { createAudioAcl } = require('./audio/acl.js');
39
+ const { createDispatcher } = require('./audio/dispatch.js');
40
+ const { createSpeakQueue } = require('./audio/queue.js');
41
+ const { createReceiptStore } = require('./audio/receipt.js');
42
+ const audioAdapters = require('./audio/adapters.js');
43
+ const audioGroups = require('./audio/groups.js');
44
+ const { bridgeSecretPath, loadOrCreateBridgeSecret, createNonceCache } = require('./audio/bridge-auth.js');
45
+ const { createHopSecret } = require('./proxy/hop-proof.js');
35
46
  const { settingsRoutes, publicPeeringRoutes } = require('./settings/routes.js');
36
47
  const decksStore = require('./decks/store.js');
37
48
  const { decksRoutes } = require('./decks/routes.js');
@@ -133,6 +144,22 @@ function createServer(opts = {}) {
133
144
  const topologyCachePath = cfg.topologyCachePath || topologyCache.defaultPath(cfg.home || os.homedir());
134
145
  const decksPath = cfg.decksPath || decksStore.defaultDecksPath(cfg.home || os.homedir());
135
146
  const proxyReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
147
+ const audioHome = cfg.home || os.homedir();
148
+ // Stato audio ancorato alla directory del token (come notifyDir): server,
149
+ // Settings e bridge MCP devono guardare gli stessi file anche quando un test
150
+ // isola l'istanza fuori dalla home reale.
151
+ const audioCfg = {
152
+ home: audioHome,
153
+ tokenPath: cfg.tokenPath,
154
+ audioConsentPath: cfg.audioConsentPath,
155
+ audioGroupsPath: cfg.audioGroupsPath,
156
+ audioBridgeSecretPath: cfg.audioBridgeSecretPath || path.join(notifyDir, 'audio-bridge.key'),
157
+ };
158
+ // Segreto per-processo della prova di hop federata: vive solo in memoria, non
159
+ // e' su disco e nessuna API lo espone. Serve a distinguere l'ultimo hop di una
160
+ // route federata da un POST diretto di chi possiede il token della UI.
161
+ const hopSecret = createHopSecret();
162
+ const audioNonceCache = createNonceCache();
136
163
  function resolveNode(name) {
137
164
  const st = nodesStore.loadStore(nodesPath);
138
165
  if (!st) return null;
@@ -299,6 +326,64 @@ function createServer(opts = {}) {
299
326
  sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
300
327
  }));
301
328
  api.use('/fleet', fleetRoutes(fleetP, { ...cfg, diagnostics }));
329
+ // Audio Share. L'identita' del nodo NON e' un campo di cfg: si legge dal node
330
+ // store, la stessa fonte usata da /api/cells e /api/peers. Lo stato Fleet e'
331
+ // asincrono e va atteso: leggerlo come se fosse sincrono lascerebbe la
332
+ // risoluzione dell'origine sempre vuota e la feature morta senza che un test
333
+ // sul router se ne accorga.
334
+ const audioNodeId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
335
+ // Seam di test per l'adapter: permette di esercitare il percorso completo
336
+ // (route -> gate -> coda -> receipt) sul server REALE senza emettere suono.
337
+ // In produzione resta il probe locale.
338
+ const audioAdapter = cfg.audioAdapterSeam
339
+ || audioAdapters.createAdapter(audioAdapters.detectAdapter({ env: cfg.env || process.env }));
340
+ const audioReceipts = createReceiptStore();
341
+ const audioQueue = createSpeakQueue({
342
+ adapter: audioAdapter,
343
+ // La coda e' l'unica a sapere com'e' finito davvero un enunciato: e' lei ad
344
+ // aggiornare il receipt da `accepted` a spoken/refused/unknown.
345
+ onStatus: (utteranceId, status, reason) => { try { audioReceipts.update(utteranceId, status, reason); } catch (_) {} },
346
+ });
347
+ api.use('/audio', audioRoutes({
348
+ readonly: proxyReadonly,
349
+ localNodeId: audioNodeId,
350
+ receiptStore: audioReceipts,
351
+ adapter: audioAdapter,
352
+ queue: audioQueue,
353
+ acl: createAudioAcl({ nodesPath }),
354
+ consent: () => { try { return isAudioConsent(audioCfg, audioHome); } catch (_) { return false; } },
355
+ originResolver: createOriginResolver({
356
+ localNodeId: audioNodeId,
357
+ // Celle Fleet ATTIVE in questo momento, attese davvero.
358
+ activeCells: async () => {
359
+ const fleet = await fleetP;
360
+ if (!fleet || fleet.available !== true || typeof fleet.status !== 'function') return [];
361
+ const st = await fleet.status();
362
+ return Array.isArray(st && st.cells) ? st.cells.map((c) => ({
363
+ cell: c && c.cell, tmuxSession: c && c.tmuxSession,
364
+ active: c && c.active === true && c.tmux !== false,
365
+ })) : [];
366
+ },
367
+ bridgeSecret: () => { try { return loadOrCreateBridgeSecret(bridgeSecretPath(audioCfg, audioHome)); } catch (_) { return null; } },
368
+ hopSecret: () => hopSecret,
369
+ nonceCache: audioNonceCache,
370
+ }),
371
+ dispatcher: createDispatcher({
372
+ localNodeId: audioNodeId,
373
+ peers: async () => {
374
+ const st = nodesStore.loadStore(nodesPath);
375
+ if (!st) return [];
376
+ const topology = await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath, fetchImpl: healthFetch });
377
+ return nodesInventory.buildInventory({
378
+ direct: nodesStore.redactStore(st).nodes,
379
+ topology: topology && Array.isArray(topology.nodes) ? topology.nodes : [],
380
+ });
381
+ },
382
+ localPort: () => (server && server.address() ? server.address().port : cfg.port),
383
+ localToken: () => tokenHolder.value,
384
+ }),
385
+ getGroup: (name) => audioGroups.getGroup(audioCfg, name, audioHome),
386
+ }));
302
387
  api.use('/cells', cellsRoutes({
303
388
  fleetP,
304
389
  instanceId: () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
@@ -359,7 +444,12 @@ function createServer(opts = {}) {
359
444
  // Settings API B2 (design §4b(6)): read-only GET + mutanti lista chiusa per il
360
445
  // wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
361
446
  // READONLY route-level e la redazione token vivono dentro settingsRoutes.
362
- api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions, updater, runtimePort }));
447
+ api.use('/settings', settingsRoutes({
448
+ cfg, nodesPath, tokenStore, closeSessions, updater, runtimePort,
449
+ // Stesso adapter/coda dell'API Audio: Settings puo' fare solo la prova
450
+ // locale a frase fissa e lo stop sovrano, mai una seconda sintesi parallela.
451
+ audio: { adapter: audioAdapter, queue: audioQueue },
452
+ }));
363
453
  api.use('/diagnostics', diagnosticsRoutes({ diagnostics, readonly: proxyReadonly }));
364
454
  api.get('/topology', async (_req, res) => {
365
455
  try { res.json(await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath })); }
@@ -367,6 +457,7 @@ function createServer(opts = {}) {
367
457
  });
368
458
  api.use('/route', federation.localRouter({
369
459
  nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
460
+ hopSecret: () => hopSecret,
370
461
  }));
371
462
  api.get('/voice/status', (_req, res) => res.json({ serverSttConfigured: !!cfg.voiceUrl }));
372
463
  api.post('/voice/transcribe',
@@ -392,7 +483,7 @@ function createServer(opts = {}) {
392
483
  app.use('/api', api);
393
484
  app.use('/federation', federation.peerRouter({
394
485
  nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
395
- fetchImpl: healthFetch,
486
+ fetchImpl: healthFetch, hopSecret: () => hopSecret,
396
487
  roles: () => require('./cli/commands.js').readRoles(cfg.configPath || configJsonPath()),
397
488
  }));
398
489