@mmmbuto/nexuscrew 0.9.56 → 0.9.58

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.
@@ -1,11 +1,13 @@
1
1
  'use strict';
2
2
 
3
+ const { createAskSlotVerifier, askSlotVerifier, binding: askSlotBinding } = require('../nodes/ask-slot-verifier.js');
3
4
  const http = require('node:http');
4
5
  const net = require('node:net');
5
6
  const crypto = require('node:crypto');
6
7
  const express = require('express');
7
8
  const { WebSocketServer } = require('ws');
8
9
  const store = require('../nodes/store.js');
10
+ const accessPresets = require('../nodes/access-presets.js');
9
11
  const topologyCache = require('../nodes/topology-cache.js');
10
12
  const { allowedByClass, classifyResource } = require('./resource-acl.js');
11
13
  const reverseRotation = require('../nodes/reverse-rotation.js');
@@ -223,7 +225,7 @@ function knownResource(resource) {
223
225
  || resource === '/event-feed'
224
226
  || resource === '/event-feed/snapshot'
225
227
  // Federated ask write-back: answer, dismiss, attempt status.
226
- || /^\/event-feed\/asks\/[a-f0-9]{8}(?:\/answer|\/requests\/[0-9a-f-]{16,64})?$/.test(resource)
228
+ || /^\/event-feed\/asks\/[a-f0-9]{8}(?:\/answer|\/capability|\/requests\/[0-9a-f-]{16,64})?$/.test(resource)
227
229
  || /^\/event-feed\/[A-Za-z0-9._-]{1,32}$/.test(resource)
228
230
  || /^\/event-feed\/node\/[a-f0-9]{32}$/.test(resource)
229
231
  || isPanelResource(resource)
@@ -330,6 +332,7 @@ function allowedResource(resource, method = 'GET') {
330
332
  if (/^\/event-feed\/[A-Za-z0-9._-]{1,32}$/.test(resource)) return method === 'GET';
331
333
  if (/^\/event-feed\/node\/[a-f0-9]{32}$/.test(resource)) return method === 'GET';
332
334
  if (/^\/event-feed\/asks\/[a-f0-9]{8}\/answer$/.test(resource)) return method === 'POST';
335
+ if (/^\/event-feed\/asks\/[a-f0-9]{8}\/capability$/.test(resource)) return method === 'GET';
333
336
  if (/^\/event-feed\/asks\/[a-f0-9]{8}$/.test(resource)) return method === 'DELETE';
334
337
  if (/^\/event-feed\/asks\/[a-f0-9]{8}\/requests\/[0-9a-f-]{16,64}$/.test(resource)) return method === 'GET';
335
338
  if (resource === '/asks') return method === 'POST';
@@ -477,19 +480,23 @@ function liveHostLedgerCapture({ ledger, ownerId, resource }) {
477
480
  const SSE_IDLE_TIMEOUT_MS = 90000;
478
481
  const SSE_CONTENT_TYPE_RE = /^text\/event-stream/i;
479
482
 
480
- function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null, setCookiePrefix = null, panelCookie = null, capture = null, getTimeoutMs = 10000, sseIdleMs = SSE_IDLE_TIMEOUT_MS }) {
483
+ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null, setCookiePrefix = null, panelCookie = null, capture = null, getTimeoutMs = 10000, sseIdleMs = SSE_IDLE_TIMEOUT_MS, onForwardFailure = null, abortWithClient = false }) {
481
484
  // A body a local middleware has already read is no longer in the stream: the
482
485
  // headers would announce bytes that nothing sends, and the peer would wait for
483
486
  // a body that never arrives. Say it at once, with a name, instead of letting
484
487
  // the forward sit until the upstream timeout expires.
485
488
  if (Number(req.headers['content-length'] || 0) > 0 && req.readableEnded) {
489
+ if (onForwardFailure) onForwardFailure();
486
490
  return res.status(502).json({ error: 'request body already read by a local middleware', reason: 'federation-body-consumed' });
487
491
  }
492
+ const failed = () => { if (onForwardFailure) onForwardFailure(); };
488
493
  let streamWatchdog = null;
489
494
  const clearStreamWatchdog = () => {
490
495
  if (streamWatchdog) { clearTimeout(streamWatchdog); streamWatchdog = null; }
491
496
  };
492
497
  const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited, hopProof, panelCookie) }, (r) => {
498
+ if (r.statusCode < 200 || r.statusCode >= 300) failed();
499
+ r.once('aborted', failed); r.once('error', failed);
493
500
  const headers = setCookiePrefix ? rewriteSetCookiePath(r.headers, setCookiePrefix) : r.headers;
494
501
  if (!capture) {
495
502
  res.writeHead(r.statusCode, sanitizeResponseHeaders(headers));
@@ -534,8 +541,13 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
534
541
  // quando il tunnel del peer è «su a metà» (socket aperto, nessuna risposta):
535
542
  // 10 s e 504 — prima era un unico 30 s che chiudeva con 502. Gli altri
536
543
  // metodi mantengono il tetto largo di prima.
544
+ if (abortWithClient) {
545
+ req.once('aborted', () => { failed(); up.destroy(); });
546
+ res.once('close', () => { if (!res.writableFinished) { failed(); up.destroy(); } });
547
+ }
537
548
  const FORWARD_TIMEOUT_MS = req.method === 'GET' ? getTimeoutMs : 30000;
538
549
  up.setTimeout(FORWARD_TIMEOUT_MS, () => {
550
+ failed();
539
551
  if (!res.headersSent) {
540
552
  res.status(504).json({ error: 'peer non risponde entro il timeout', reason: 'federation-peer-timeout' });
541
553
  up.destroy();
@@ -545,6 +557,7 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
545
557
  });
546
558
  up.on('close', clearStreamWatchdog);
547
559
  up.on('error', () => {
560
+ failed();
548
561
  clearStreamWatchdog();
549
562
  if (!res.headersSent) res.status(502).json({ error: 'peer non raggiungibile', reason: 'federation-peer-unreachable' });
550
563
  else res.destroy();
@@ -552,9 +565,20 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
552
565
  req.pipe(up);
553
566
  }
554
567
 
555
- function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null, liveHostLedger = null, forwardGetTimeoutMs = 10000, forwardSseIdleMs = SSE_IDLE_TIMEOUT_MS }) {
556
- return (req, res) => {
568
+ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null, liveHostLedger = null, forwardGetTimeoutMs = 10000, forwardSseIdleMs = SSE_IDLE_TIMEOUT_MS, askProofNow = null, slotVerifier = null }) {
569
+ const verifier = slotVerifier || (askProofNow ? createAskSlotVerifier({ now: askProofNow }) : askSlotVerifier);
570
+ return async (req, res) => {
557
571
  const parsed = parseRoute(req.url);
572
+ const rejectAskRequest = (status, body) => {
573
+ if (parsed && classifyResource(parsed.resource, req.method) === 'ask-action') {
574
+ const current = store.loadStore(nodesPath);
575
+ verifier.sync(current, nodesPath);
576
+ const peer = current && store.getNode(current, parsed.route[0]);
577
+ const selected = askSlotBinding(current, peer);
578
+ verifier.invalidate(selected && selected.key);
579
+ }
580
+ return res.status(status).json(body);
581
+ };
558
582
  // Prima dell'allowlist: un peer senza permesso non deve nemmeno sapere se la
559
583
  // risorsa esiste per una cella o per un'altra.
560
584
  if (parsed && isPanelResource(parsed.resource) && !panelAllowedFor(ingress)) {
@@ -571,7 +595,7 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
571
595
  if (parsed && allowedResource(parsed.resource, req.method)) {
572
596
  const gate = classGateCheck(parsed.resource, req.method, ingress);
573
597
  if (!gate.ok) {
574
- return res.status(gate.status).json({
598
+ return rejectAskRequest(gate.status, {
575
599
  error: gate.status === 404 ? 'not found' : 'risorsa non concessa a questo nodo',
576
600
  reason: gate.reason,
577
601
  });
@@ -579,9 +603,11 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
579
603
  }
580
604
  if (!parsed || !allowedResource(parsed.resource, req.method)
581
605
  || !allowedQuery(parsed.resource, req.method, req.url)) return res.status(404).json({ error: 'not found' });
582
- if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
606
+ if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) return rejectAskRequest(403, { error: 'READONLY: federated mutation blocked' });
583
607
  const st = store.loadStore(nodesPath);
584
608
  if (!st) return res.status(503).json({ error: 'node store unavailable' });
609
+ const isAskAction = classifyResource(parsed.resource, req.method) === 'ask-action';
610
+ if (isAskAction) verifier.sync(st, nodesPath);
585
611
  const visited = controlledVisited(req, ingress, st.nodeId);
586
612
  if (!visited) return res.status(409).json({ error: 'federation cycle rejected' });
587
613
  // Vale per l'ultimo hop come per il transito: se si fermasse al primo, una
@@ -602,11 +628,18 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
602
628
  panelCookie,
603
629
  getTimeoutMs: forwardGetTimeoutMs,
604
630
  sseIdleMs: forwardSseIdleMs,
631
+ abortWithClient: isAskAction,
605
632
  });
606
633
  }
607
634
  const next = st && store.getNode(st, parsed.route[0]);
608
635
  const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
609
- if (!next || !next.token || privateInbound || (ingress && !canTransit(ingress, next))) return res.status(403).json({ error: 'route non consentita' });
636
+ if (!next || !next.token || privateInbound || (ingress && !canTransit(ingress, next))) return rejectAskRequest(403, { error: 'route non consentita' });
637
+ if (parsed.resource === '/asks' && req.method === 'POST') {
638
+ const view = accessPresets.grantsOf(next);
639
+ if (!view.configured || view.label !== 'admin') {
640
+ return res.status(403).json({ error: 'ASK target is not an admin peer', reason: 'ask-target-not-admin' });
641
+ }
642
+ }
610
643
  const rest = parsed.route.slice(1);
611
644
  const path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}${parsed.resource}${queryOf(req.url)}`;
612
645
  // Verso il browser di questo nodo (ingress nullo) il cookie di visione del
@@ -622,7 +655,68 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
622
655
  const capture = liveHostLedger && (parsed.resource === '/live-host/designate' || parsed.resource === '/live-host/clear')
623
656
  ? liveHostLedgerCapture({ ledger: liveHostLedger, ownerId: next.nodeId, resource: parsed.resource })
624
657
  : null;
625
- proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited, setCookiePrefix, panelCookie, capture, getTimeoutMs: forwardGetTimeoutMs, sseIdleMs: forwardSseIdleMs });
658
+ let forwardPort = next.localPort;
659
+ let proofKey = null;
660
+ if (next.direction === 'inbound' && classifyResource(parsed.resource, req.method) === 'ask-action') {
661
+ const rejectForward = (status, body) => { verifier.invalidate(proofKey); return res.status(status).json(body); };
662
+ const unverified = () => rejectForward(404, { error: 'not found', reason: 'reverse-slot-unverified' });
663
+ const channel = (peer) => {
664
+ const pool = peer && peer.reversePool;
665
+ const slot = pool && Array.isArray(pool.slots) && pool.slots[pool.activeSlot];
666
+ if (!slot || slot.state !== 'active' || !Number.isInteger(slot.port) || slot.port < 1 || slot.port > 65535
667
+ || !Number.isSafeInteger(pool.activeGeneration) || pool.activeGeneration < 1
668
+ || slot.generation !== pool.activeGeneration || !peer.token || !peer.nodeId) return null;
669
+ return { token: peer.token, nodeId: peer.nodeId, localPort: peer.localPort,
670
+ activeSlot: pool.activeSlot, port: slot.port, generation: pool.activeGeneration };
671
+ };
672
+ const selected = channel(next);
673
+ if (!selected) return unverified();
674
+ let disconnected = req.aborted || res.destroyed;
675
+ const controller = new AbortController();
676
+ const onDisconnect = () => { disconnected = true; controller.abort(); };
677
+ req.once('aborted', onDisconnect);
678
+ res.once('close', onDisconnect);
679
+ let proof;
680
+ try {
681
+ proof = await verifier.verify({ store: st, peer: next, loadStore: () => store.loadStore(nodesPath),
682
+ scope: nodesPath, signal: controller.signal });
683
+ } catch (_) { proof = null; }
684
+ finally {
685
+ req.removeListener('aborted', onDisconnect);
686
+ res.removeListener('close', onDisconnect);
687
+ }
688
+ proofKey = proof && proof.key;
689
+ if (disconnected || req.aborted || res.destroyed) { verifier.invalidate(proofKey); return; }
690
+ if (!proof || !proof.owned) return unverified();
691
+
692
+ const current = store.loadStore(nodesPath);
693
+ const currentNext = current && store.getNode(current, parsed.route[0]);
694
+ if (!current || current.nodeId !== st.nodeId || !currentNext || currentNext.direction !== 'inbound'
695
+ || JSON.stringify(channel(currentNext)) !== JSON.stringify(selected)) return unverified();
696
+ // The proof awaited remote I/O: none of the authorization held above is
697
+ // retained across that wait. Recheck the ingress credential and grants.
698
+ const currentIngress = ingress ? peerFromToken(nodesPath, bearerFrom(req)) : null;
699
+ if (ingress && (!currentIngress || currentIngress.name !== ingress.name || currentIngress.nodeId !== ingress.nodeId)) {
700
+ return rejectForward(401, { error: 'unauthorized peer' });
701
+ }
702
+ const gate = classGateCheck(parsed.resource, req.method, currentIngress);
703
+ if (!gate.ok) return rejectForward(gate.status, { error: 'risorsa non concessa a questo nodo', reason: gate.reason });
704
+ if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) {
705
+ return rejectForward(403, { error: 'READONLY: federated mutation blocked' });
706
+ }
707
+ if (!currentNext.token || currentNext.shared !== true || (currentIngress && !canTransit(currentIngress, currentNext))) {
708
+ return rejectForward(403, { error: 'route non consentita' });
709
+ }
710
+ if (!controlledVisited(req, currentIngress, current.nodeId)) return rejectForward(409, { error: 'federation cycle rejected' });
711
+ forwardPort = selected.port;
712
+ }
713
+ const invalidateProof = () => verifier.invalidate(proofKey);
714
+ const actionCapture = isAskAction ? (status, text) => {
715
+ if (status < 200 || status >= 300) invalidateProof();
716
+ try { JSON.parse(text); } catch (_) { invalidateProof(); }
717
+ } : capture;
718
+ proxyHttp(req, res, { port: forwardPort, path, credential: next.token, visited, setCookiePrefix, panelCookie, capture: actionCapture,
719
+ onForwardFailure: isAskAction ? invalidateProof : null, abortWithClient: isAskAction, getTimeoutMs: forwardGetTimeoutMs, sseIdleMs: forwardSseIdleMs });
626
720
  };
627
721
  }
628
722
 
@@ -1251,7 +1345,8 @@ async function probeReverseOwner(peer, fetchImpl = fetch) {
1251
1345
  });
1252
1346
  }
1253
1347
 
1254
- function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null, diagnostics = null }) {
1348
+ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null, diagnostics = null, askProofNow = null, slotVerifier = null }) {
1349
+ slotVerifier = slotVerifier || (askProofNow ? createAskSlotVerifier({ now: askProofNow }) : askSlotVerifier);
1255
1350
  const r = express.Router();
1256
1351
  r.use((req, res, next) => {
1257
1352
  const peer = peerFromToken(nodesPath, bearerFrom(req));
@@ -1532,13 +1627,18 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
1532
1627
  return res.status(e.status || 500).json({ error: String(e && e.message || e), ...(e.code ? { code: e.code } : {}) });
1533
1628
  }
1534
1629
  });
1535
- r.use('/route', (req, res) => routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly, hopSecret })(req, res));
1630
+ r.use('/route', (req, res, next) => {
1631
+ routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly, hopSecret, slotVerifier })(req, res).catch(next);
1632
+ });
1536
1633
  return r;
1537
1634
  }
1538
1635
 
1539
- function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecret = null, liveHostLedger = null }) {
1636
+ function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecret = null, liveHostLedger = null, askProofNow = null, slotVerifier = null }) {
1637
+ slotVerifier = slotVerifier || (askProofNow ? createAskSlotVerifier({ now: askProofNow }) : askSlotVerifier);
1540
1638
  const r = express.Router();
1541
- r.use((req, res) => routeHandler({ nodesPath, localPort, localCredential, readonly, hopSecret, liveHostLedger })(req, res));
1639
+ r.use((req, res, next) => {
1640
+ routeHandler({ nodesPath, localPort, localCredential, readonly, hopSecret, liveHostLedger, slotVerifier })(req, res).catch(next);
1641
+ });
1542
1642
  return r;
1543
1643
  }
1544
1644
 
@@ -55,6 +55,7 @@ function classifyResource(resource, method = 'GET') {
55
55
  // attempt id a UUID. The routes live UNDER /event-feed because they are the
56
56
  // feed's own write-back path; the class is the same ask-action.
57
57
  if (/^\/event-feed\/asks\/[a-f0-9]{8}\/answer$/.test(resource)) return m === 'POST' ? 'ask-action' : null;
58
+ if (/^\/event-feed\/asks\/[a-f0-9]{8}\/capability$/.test(resource)) return m === 'GET' ? 'ask-action' : null;
58
59
  if (/^\/event-feed\/asks\/[a-f0-9]{8}$/.test(resource)) return m === 'DELETE' ? 'ask-action' : null;
59
60
  if (/^\/event-feed\/asks\/[a-f0-9]{8}\/requests\/[0-9a-f-]{16,64}$/.test(resource)) return m === 'GET' ? 'ask-action' : null;
60
61
 
package/lib/server.js CHANGED
@@ -26,7 +26,7 @@ const { transcribe } = require('./voice/transcribe.js');
26
26
  const { selectProvider } = require('./fleet/provider.js');
27
27
  const { fleetRoutes } = require('./fleet/routes.js');
28
28
  const { leaseRoutes } = require('./fleet/lease-routes.js');
29
- const { cellsRoutes } = require('./cells/routes.js');
29
+ const { cellsRoutes, publicCells } = require('./cells/routes.js');
30
30
  const { liveHostRoutes } = require('./live-host/routes.js');
31
31
  const { createLiveHostStore, liveHostPath } = require('./live-host/store.js');
32
32
  const { createLiveBridge } = require('./live-host/bridge.js');
@@ -85,6 +85,7 @@ const { createAskReceipts } = require('./notify/ask-receipts.js');
85
85
  const { createAskAnswerService } = require('./notify/ask-answer-service.js');
86
86
  const { createEventFeedAsksRoutes } = require('./notify/event-feed-asks-routes.js');
87
87
  const { createAskRelay } = require('./notify/ask-relay.js');
88
+ const { createAskSlotVerifier } = require('./nodes/ask-slot-verifier.js');
88
89
  const { readFederationEventsEnabled } = require('./config.js');
89
90
 
90
91
  function sanitizeReplyLabel(raw) {
@@ -127,6 +128,7 @@ function wsBridgeDefaults(cfg = {}) {
127
128
  }
128
129
 
129
130
  function createServer(opts = {}) {
131
+ const askReverseVerifier = createAskSlotVerifier();
130
132
  const cfg = loadConfig(opts);
131
133
  // UNA istanza per server: trattiene le attach federate quando il client cade,
132
134
  // cosi' chi torna con la stessa identita' riprende dal buffer. I parametri
@@ -234,7 +236,27 @@ function createServer(opts = {}) {
234
236
  let closureRetry = null;
235
237
  const askAnswerService = createAskAnswerService({
236
238
  asks: asksStore,
237
- paste: cfg.pasteSeam || ((session, text) => pasteToSession(cfg.tmuxBin, session, text)),
239
+ submit: opts.askSubmit || (async (session, text) => {
240
+ let cell;
241
+ try {
242
+ const fleet = await fleetP;
243
+ const statusFn = fleet && (typeof fleet.cellStatus === 'function' ? fleet.cellStatus : fleet.status);
244
+ const status = fleet && fleet.available === true && typeof statusFn === 'function' ? await statusFn.call(fleet) : null;
245
+ if (!status || status.available !== true || !Array.isArray(status.cells)) {
246
+ return { outcome: 'failed-pre-paste', submitted: false, reason: 'fleet-unavailable' };
247
+ }
248
+ cell = publicCells(status, apiNodeId()).find(entry => entry.tmuxSession === session);
249
+ } catch (_) { return { outcome: 'failed-pre-paste', submitted: false, reason: 'fleet-unavailable' }; }
250
+ if (!cell) {
251
+ if (!sessionExistsImpl(session)) return { outcome: 'failed-pre-paste', submitted: false, reason: 'session-unavailable' };
252
+ // Manual tmux sessions keep the existing paste-only answer contract.
253
+ const pasted = await pasteToSession(cfg.tmuxBin, session, text);
254
+ return { outcome: pasted ? 'pasted-not-submitted' : 'delivery-unknown', submitted: false };
255
+ }
256
+ if (!cell.active || !cell.engine) return { outcome: 'failed-pre-paste', submitted: false, reason: 'cell-unavailable' };
257
+ // Same submission as cell messages; operator draft in the composer may be submitted.
258
+ return submitToSession(cfg.tmuxBin, session, text, { engine: cell.engine });
259
+ }),
238
260
  receipts: askReceipts,
239
261
  // La chiusura esce da QUI: e' il punto in cui OGNI transizione autorevole
240
262
  // passa — risposta e scarto, rotta locale e rotta federata. Emetterla dalle
@@ -920,7 +942,7 @@ function createServer(opts = {}) {
920
942
  // abbia mai preso un ticket). Resta della PWA autenticata l'EMISSIONE: il POST
921
943
  // .../panel/<cella>/ticket transita di sotto, col Bearer, come ogni altra /api.
922
944
  const panelFederatoRouter = federation.localRouter({
923
- nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
945
+ nodesPath, slotVerifier: askReverseVerifier, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
924
946
  hopSecret: () => hopSecret,
925
947
  });
926
948
  api.use('/route', (req, res, next) => {
@@ -1139,7 +1161,10 @@ function createServer(opts = {}) {
1139
1161
  });
1140
1162
  const askPeerTargets = async () => {
1141
1163
  const list = await federatedPeers();
1142
- return (Array.isArray(list) ? list : []).map((p) => p && (p.nodeId || p.instanceId)).filter(Boolean);
1164
+ const current = nodesStore.loadStoreStrict(nodesPath);
1165
+ return (Array.isArray(list) ? list : []).map(peer => ({ ...peer,
1166
+ firstHop: current && current.nodes.find(node => node.name === peer.route?.[0]),
1167
+ }));
1143
1168
  };
1144
1169
  // Riconciliazione del RICEVENTE: per ogni alias ancora aperto si chiede
1145
1170
  // all'owner quali domande sono ancora aperte. Se l'alias non c'e' piu' la'
@@ -1252,15 +1277,6 @@ function createServer(opts = {}) {
1252
1277
  federatedRate: createSpeakRateLimiter(),
1253
1278
  answerService: askAnswerService,
1254
1279
  receipts: askReceipts,
1255
- // Elenco dei peer autorizzati per il fan-out di default della domanda
1256
- // (nessun `target` → tutti i nodi dell'owner). Il
1257
- // dispatcher resta a target esatto: l'enumerazione la fa la route.
1258
- peerTargets: async () => {
1259
- const list = await federatedPeers();
1260
- return (Array.isArray(list) ? list : [])
1261
- .map((p) => p && (p.nodeId || p.instanceId))
1262
- .filter(Boolean);
1263
- },
1264
1280
  log: opts.log || console.log,
1265
1281
  }));
1266
1282
  // Event feed: the closed SSE/JSON surface, mounted inside the
@@ -1305,7 +1321,11 @@ function createServer(opts = {}) {
1305
1321
  // resolves the owner from the authorized store and forwards with the peer's
1306
1322
  // own pair token. Uncertain outcomes are parked, never retried silently.
1307
1323
  const askRelay = createAskRelay({
1324
+ nodesPath, slotVerifier: askReverseVerifier,
1308
1325
  loadStore: () => nodesStore.loadStore(nodesPath),
1326
+ peers: federatedPeers,
1327
+ localPort: () => (server && server.address() ? server.address().port : cfg.port),
1328
+ localToken: () => tokenHolder.value,
1309
1329
  log: opts.log || console.log,
1310
1330
  });
1311
1331
  // Il body va parsato QUI: il router `api` non monta un parser globale, ogni
@@ -1326,23 +1346,30 @@ function createServer(opts = {}) {
1326
1346
  if (out.ok && typeof eventFeedClient.dismissConfirmed === 'function') {
1327
1347
  eventFeedClient.dismissConfirmed(b.ownerId, b.askId);
1328
1348
  }
1329
- return res.status(out.ok ? 200 : out.code || 500).json(out.ok ? { dismissed: true } : { error: out.error || 'unknown' });
1349
+ return res.status(out.ok ? 200 : out.code || 500).json(out.ok ? { dismissed: true } : { error: out.error || 'unknown', reason: out.reason });
1330
1350
  }
1331
1351
  if (b.action === 'verify') {
1332
1352
  const out = await askRelay.verifyStatus({ ownerId: b.ownerId, askId: b.askId, requestId: b.requestId });
1333
- return res.status(out.ok ? 200 : out.code || 500).json(out.ok ? { state: out.state } : { error: out.error || 'unknown', reason: out.reason });
1353
+ return res.status(out.ok ? 200 : out.code || 500).json(out.ok ? { state: out.state, ownerId: out.ownerId, askId: out.askId, actor: out.actor, receipt: out.receipt } : { error: out.error || 'unknown', reason: out.reason });
1334
1354
  }
1335
- const out = await askRelay.relayAnswer({ ownerId: b.ownerId, askId: b.askId, text: b.text });
1355
+ const out = await askRelay.relayAnswer({ ownerId: b.ownerId, askId: b.askId, text: b.text, rid: b.requestId });
1336
1356
  return res.status(out.ok ? 200 : out.code || 500).json({
1337
- ...(out.ok ? { status: out.status } : {}),
1357
+ ...(out.ok ? { status: out.status, ownerId: out.ownerId, askId: out.askId, actor: out.actor, receipt: out.receipt } : {}),
1338
1358
  requestId: out.requestId || b.requestId,
1339
- ...(out.uncertain ? { uncertain: true, reason: out.reason } : {}),
1359
+ ...(out.uncertain ? { uncertain: true, reason: out.reason, originalRequestId: out.originalRequestId } : {}),
1340
1360
  ...(!out.ok && !out.uncertain ? { error: out.error, reason: out.reason } : {}),
1341
1361
  });
1342
1362
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1343
1363
  });
1344
1364
  // Riconciliazione dopo refresh: gli esiti incerti noti al relay (solo
1345
1365
  // contatori/ID, mai testo) per ricostruire lo stato «verifica» senza reinvii.
1366
+ api.get('/asks-relay/capability', async (req, res) => {
1367
+ const ownerId = typeof req.query.ownerId === 'string' ? req.query.ownerId : '';
1368
+ const askId = typeof req.query.askId === 'string' ? req.query.askId : '';
1369
+ if (!/^[a-f0-9]{32}$/.test(ownerId) || !/^[a-f0-9]{8}$/.test(askId)) return res.status(400).json({ error: 'ownerId e askId richiesti' });
1370
+ try { res.json(await askRelay.replyCapability({ ownerId, askId })); }
1371
+ catch (_) { res.json({ ownerId, askId, canReply: false, status: 'unreachable' }); }
1372
+ });
1346
1373
  api.get('/asks-relay/state', (_req, res) => {
1347
1374
  try { res.json({ attempts: askRelay.state() }); }
1348
1375
  catch (e) { res.status(500).json({ error: String(e.message || e) }); }
@@ -1358,6 +1385,12 @@ function createServer(opts = {}) {
1358
1385
  if (!bridgeReadonly()) pushRelay.considerImported(event);
1359
1386
  },
1360
1387
  };
1388
+ try { nodesStore.migrateAdminReception({ filePath: nodesPath, readonly: bridgeReadonly() }); }
1389
+ catch (error) {
1390
+ if (error?.code !== 'NODES_STORE_MISSING') {
1391
+ (opts.log || console.log)(`Admin reception migration skipped: ${error?.code || 'NODES_STORE_UNAVAILABLE'}`);
1392
+ }
1393
+ }
1361
1394
  const eventFeedClient = createEventFeedClient({
1362
1395
  loadStore: () => nodesStore.loadStore(nodesPath),
1363
1396
  token: () => tokenHolder.value,
@@ -1640,7 +1673,7 @@ function createServer(opts = {}) {
1640
1673
  catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1641
1674
  });
1642
1675
  api.use('/route', federation.localRouter({
1643
- nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
1676
+ nodesPath, slotVerifier: askReverseVerifier, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
1644
1677
  hopSecret: () => hopSecret,
1645
1678
  // A designation forwarded from here to a peer is recorded by the answer
1646
1679
  // the peer gives, not by a local guess: see liveHostLedgerCapture.
@@ -1672,7 +1705,7 @@ function createServer(opts = {}) {
1672
1705
 
1673
1706
  app.use('/api', api);
1674
1707
  app.use('/federation', federation.peerRouter({
1675
- nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
1708
+ nodesPath, slotVerifier: askReverseVerifier, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
1676
1709
  fetchImpl: healthFetch, hopSecret: () => hopSecret,
1677
1710
  roles: () => require('./cli/commands.js').readRoles(cfg.configPath || configJsonPath()),
1678
1711
  diagnostics,
@@ -118,8 +118,10 @@ function execTmux(execFileImpl, tmuxBin, args, timeout = 5000) {
118
118
  // risolto prima del paste e verificato di nuovo prima dell'Enter, evitando
119
119
  // prefix match e il riuso del solo nome sessione.
120
120
  async function submitToSession(tmuxBin, session, text, opts = {}) {
121
+ let pasteAttempted = false;
122
+ const failed = reason => ({ submitted: false, outcome: pasteAttempted ? 'delivery-unknown' : 'failed-pre-paste', reason });
121
123
  if (!isValidSession(session) || !submitTextOk(text)) {
122
- return { submitted: false, reason: 'input non valido' };
124
+ return failed('input non valido');
123
125
  }
124
126
  const execFileImpl = opts.execFileImpl || execFile;
125
127
  const fsImpl = opts.fsImpl || fs;
@@ -128,7 +130,7 @@ async function submitToSession(tmuxBin, session, text, opts = {}) {
128
130
  ? opts.delay : (ms) => new Promise((resolve) => setTimeout(resolve, ms));
129
131
  const codexComposer = /^(?:codex|codex-vl)(?:\.|$)/.test(String(opts.engine || ''));
130
132
  const nonce = (opts.nonce || crypto.randomBytes(8).toString('hex')).replace(/[^a-f0-9]/g, '').slice(0, 32);
131
- if (!nonce) return { submitted: false, reason: 'invio non disponibile' };
133
+ if (!nonce) return failed('invio non disponibile');
132
134
  const buffer = `ncmsg-${nonce}`;
133
135
  const tmp = path.join(tmpRoot, `.nexuscrew-message-${nonce}.txt`);
134
136
  let loaded = false;
@@ -139,14 +141,15 @@ async function submitToSession(tmuxBin, session, text, opts = {}) {
139
141
  ['display-message', '-p', '-t', `=${session}:`, '#{pane_id}']);
140
142
  const paneId = pane.stdout.trim();
141
143
  if (!pane.ok || !/^%[0-9]+$/.test(paneId)) {
142
- return { submitted: false, reason: 'sessione non raggiungibile' };
144
+ return failed('sessione non raggiungibile');
143
145
  }
144
146
  const load = await execTmux(execFileImpl, tmuxBin, ['load-buffer', '-b', buffer, tmp]);
145
- if (!load.ok) return { submitted: false, reason: 'buffer non disponibile' };
147
+ if (!load.ok) return failed('buffer non disponibile');
146
148
  loaded = true;
149
+ pasteAttempted = true;
147
150
  const paste = await execTmux(execFileImpl, tmuxBin,
148
151
  ['paste-buffer', '-p', '-t', paneId, '-b', buffer]);
149
- if (!paste.ok) return { submitted: false, reason: 'consegna non riuscita' };
152
+ if (!paste.ok) return failed('consegna non riuscita');
150
153
  const paneAlive = async () => {
151
154
  const verify = await execTmux(execFileImpl, tmuxBin,
152
155
  ['display-message', '-p', '-t', paneId, '#{session_name}\t#{pane_dead}\t#{pane_id}']);
@@ -190,20 +193,20 @@ async function submitToSession(tmuxBin, session, text, opts = {}) {
190
193
  // messaggio lungo conviene verificare che la cella si sia mossa.
191
194
  const attesaBase = codexComposer ? 400 : 150;
192
195
  await delay(attesaBase + Math.floor(text.length / 500) * 150);
193
- if (!(await paneAlive())) return { submitted: false, reason: 'sessione terminata durante la consegna' };
196
+ if (!(await paneAlive())) return failed('sessione terminata durante la consegna');
194
197
  if (codexComposer) {
195
198
  const flush = await execTmux(execFileImpl, tmuxBin, ['send-keys', '-t', paneId, 'C-e']);
196
- if (!flush.ok) return { submitted: false, reason: 'testo consegnato ma flush composer non riuscito' };
199
+ if (!flush.ok) return failed('testo consegnato ma flush composer non riuscito');
197
200
  await delay(300);
198
- if (!(await paneAlive())) return { submitted: false, reason: 'sessione terminata durante la consegna' };
201
+ if (!(await paneAlive())) return failed('sessione terminata durante la consegna');
199
202
  }
200
203
  const enter = await execTmux(execFileImpl, tmuxBin, ['send-keys', '-t', paneId, 'Enter']);
201
- if (!enter.ok) return { submitted: false, reason: 'testo consegnato ma invio non riuscito' };
202
- return { submitted: true, reason: codexComposer
204
+ if (!enter.ok) return failed('testo consegnato ma invio non riuscito');
205
+ return { submitted: true, outcome: 'submitted', reason: codexComposer
203
206
  ? 'bracketed paste + burst flush + Enter separati'
204
207
  : 'bracketed paste + Enter separato' };
205
208
  } catch (_) {
206
- return { submitted: false, reason: 'consegna non riuscita' };
209
+ return failed('consegna non riuscita');
207
210
  } finally {
208
211
  if (loaded) await execTmux(execFileImpl, tmuxBin, ['delete-buffer', '-b', buffer]);
209
212
  try { fsImpl.unlinkSync(tmp); } catch (_) { /* cleanup best-effort */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.56",
3
+ "version": "0.9.58",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {