@mmmbuto/nexuscrew 0.9.57 → 0.9.59

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,8 @@ 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 { createImportedDismissalDrainer } = require('./notify/imported-dismissals.js');
89
+ const { createAskSlotVerifier } = require('./nodes/ask-slot-verifier.js');
88
90
  const { readFederationEventsEnabled } = require('./config.js');
89
91
 
90
92
  function sanitizeReplyLabel(raw) {
@@ -127,6 +129,7 @@ function wsBridgeDefaults(cfg = {}) {
127
129
  }
128
130
 
129
131
  function createServer(opts = {}) {
132
+ const askReverseVerifier = createAskSlotVerifier();
130
133
  const cfg = loadConfig(opts);
131
134
  // UNA istanza per server: trattiene le attach federate quando il client cade,
132
135
  // cosi' chi torna con la stessa identita' riprende dal buffer. I parametri
@@ -234,7 +237,27 @@ function createServer(opts = {}) {
234
237
  let closureRetry = null;
235
238
  const askAnswerService = createAskAnswerService({
236
239
  asks: asksStore,
237
- paste: cfg.pasteSeam || ((session, text) => pasteToSession(cfg.tmuxBin, session, text)),
240
+ submit: opts.askSubmit || (async (session, text) => {
241
+ let cell;
242
+ try {
243
+ const fleet = await fleetP;
244
+ const statusFn = fleet && (typeof fleet.cellStatus === 'function' ? fleet.cellStatus : fleet.status);
245
+ const status = fleet && fleet.available === true && typeof statusFn === 'function' ? await statusFn.call(fleet) : null;
246
+ if (!status || status.available !== true || !Array.isArray(status.cells)) {
247
+ return { outcome: 'failed-pre-paste', submitted: false, reason: 'fleet-unavailable' };
248
+ }
249
+ cell = publicCells(status, apiNodeId()).find(entry => entry.tmuxSession === session);
250
+ } catch (_) { return { outcome: 'failed-pre-paste', submitted: false, reason: 'fleet-unavailable' }; }
251
+ if (!cell) {
252
+ if (!sessionExistsImpl(session)) return { outcome: 'failed-pre-paste', submitted: false, reason: 'session-unavailable' };
253
+ // Manual tmux sessions keep the existing paste-only answer contract.
254
+ const pasted = await pasteToSession(cfg.tmuxBin, session, text);
255
+ return { outcome: pasted ? 'pasted-not-submitted' : 'delivery-unknown', submitted: false };
256
+ }
257
+ if (!cell.active || !cell.engine) return { outcome: 'failed-pre-paste', submitted: false, reason: 'cell-unavailable' };
258
+ // Same submission as cell messages; operator draft in the composer may be submitted.
259
+ return submitToSession(cfg.tmuxBin, session, text, { engine: cell.engine });
260
+ }),
238
261
  receipts: askReceipts,
239
262
  // La chiusura esce da QUI: e' il punto in cui OGNI transizione autorevole
240
263
  // passa — risposta e scarto, rotta locale e rotta federata. Emetterla dalle
@@ -350,7 +373,7 @@ function createServer(opts = {}) {
350
373
  if (cellId) {
351
374
  recordAndPublish({
352
375
  producer: 'ask', cellId,
353
- payload: { askId: ask.id, revision: ask.revision || 0, question: ask.question, options: ask.options, session: ask.session, ts: Date.now() },
376
+ payload: { askId: ask.id, revision: ask.revision || 0, question: ask.question, options: ask.options, session: ask.session, askTs: ask.ts, ts: Date.now() },
354
377
  });
355
378
  }
356
379
  } else if (frame && (frame.type === 'ask-answered' || frame.type === 'ask-dismissed')) {
@@ -920,7 +943,7 @@ function createServer(opts = {}) {
920
943
  // abbia mai preso un ticket). Resta della PWA autenticata l'EMISSIONE: il POST
921
944
  // .../panel/<cella>/ticket transita di sotto, col Bearer, come ogni altra /api.
922
945
  const panelFederatoRouter = federation.localRouter({
923
- nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
946
+ nodesPath, slotVerifier: askReverseVerifier, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
924
947
  hopSecret: () => hopSecret,
925
948
  });
926
949
  api.use('/route', (req, res, next) => {
@@ -1139,7 +1162,10 @@ function createServer(opts = {}) {
1139
1162
  });
1140
1163
  const askPeerTargets = async () => {
1141
1164
  const list = await federatedPeers();
1142
- return (Array.isArray(list) ? list : []).map((p) => p && (p.nodeId || p.instanceId)).filter(Boolean);
1165
+ const current = nodesStore.loadStoreStrict(nodesPath);
1166
+ return (Array.isArray(list) ? list : []).map(peer => ({ ...peer,
1167
+ firstHop: current && current.nodes.find(node => node.name === peer.route?.[0]),
1168
+ }));
1143
1169
  };
1144
1170
  // Riconciliazione del RICEVENTE: per ogni alias ancora aperto si chiede
1145
1171
  // all'owner quali domande sono ancora aperte. Se l'alias non c'e' piu' la'
@@ -1199,7 +1225,7 @@ function createServer(opts = {}) {
1199
1225
  try {
1200
1226
  const closed = asksStore.closeImported({ ownerId, ownerAskId: alias.ownerAskId, outcome: 'dismissed' });
1201
1227
  if (closed.changed && closed.ask) {
1202
- try { notifier.deliverOnlyRaw({ type: 'ask-dismissed', id: closed.ask.id, ownerId }); } catch (_) {}
1228
+ try { notifier.deliverOnlyRaw({ type: 'ask-dismissed', id: closed.ask.id, ownerId, ownerAskId: closed.ask.ownerAskId }); } catch (_) {}
1203
1229
  }
1204
1230
  } catch (_) { /* un alias non chiudibile non ferma gli altri */ }
1205
1231
  }
@@ -1252,15 +1278,6 @@ function createServer(opts = {}) {
1252
1278
  federatedRate: createSpeakRateLimiter(),
1253
1279
  answerService: askAnswerService,
1254
1280
  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
1281
  log: opts.log || console.log,
1265
1282
  }));
1266
1283
  // Event feed: the closed SSE/JSON surface, mounted inside the
@@ -1305,7 +1322,12 @@ function createServer(opts = {}) {
1305
1322
  // resolves the owner from the authorized store and forwards with the peer's
1306
1323
  // own pair token. Uncertain outcomes are parked, never retried silently.
1307
1324
  const askRelay = createAskRelay({
1325
+ availabilityBinding: dismissalBinding,
1326
+ nodesPath, slotVerifier: askReverseVerifier,
1308
1327
  loadStore: () => nodesStore.loadStore(nodesPath),
1328
+ peers: federatedPeers,
1329
+ localPort: () => (server && server.address() ? server.address().port : cfg.port),
1330
+ localToken: () => tokenHolder.value,
1309
1331
  log: opts.log || console.log,
1310
1332
  });
1311
1333
  // Il body va parsato QUI: il router `api` non monta un parser globale, ogni
@@ -1314,9 +1336,94 @@ function createServer(opts = {}) {
1314
1336
  // 400 «ownerId, askId e text richiesti» a OGNI richiesta: la risposta
1315
1337
  // federata non ha mai potuto attraversare, e nessun test copriva questa
1316
1338
  // rotta. Difetto preesistente, trovato scrivendo il test del ritorno federato.
1339
+ function dismissalBinding(ownerId) {
1340
+ const st = nodesStore.loadStore(nodesPath);
1341
+ if (!st) return null;
1342
+ const direct = st.nodes.filter(node => node.nodeId === ownerId);
1343
+ const cached = topologyCache.loadCache(topologyCachePath);
1344
+ const route = cached && cached.nodes.find(node => node.instanceId === ownerId)?.route;
1345
+ const relevant = direct.length ? direct : st.nodes.filter(node => route && route.includes(node.name));
1346
+ return require('node:crypto').createHash('sha256').update(JSON.stringify(relevant.map(node => ({ nodeId: node.nodeId, direction: node.direction,
1347
+ shared: node.shared, pairingRevision: node.pairingRevision || 0, localPort: node.localPort, reversePool: node.reversePool,
1348
+ configured: accessPresets.grantsOf(node).configured, grants: accessPresets.grantsOf(node).grants }))) + JSON.stringify(route || [])).digest('hex');
1349
+ }
1350
+ function dismissalPeer(ownerId) {
1351
+ const st = nodesStore.loadStore(nodesPath);
1352
+ if (!st) return null;
1353
+ const direct = st.nodes.filter(node => node.nodeId === ownerId);
1354
+ if (direct.length) return direct.length === 1 ? direct[0] : null;
1355
+ // A cached route identifies the first hop, never authorizes it or proves
1356
+ // an outage. The current local grants and channel are checked separately.
1357
+ const cached = topologyCache.loadCache(topologyCachePath);
1358
+ const routes = cached && cached.nodes.filter(node => node.instanceId === ownerId);
1359
+ if (!routes || routes.length !== 1) return null;
1360
+ const first = st.nodes.filter(node => node.name === routes[0].route[0]);
1361
+ return first.length === 1 ? first[0] : null;
1362
+ }
1363
+ function localDismissalEligibility(ownerId, askId) {
1364
+ const peer = dismissalPeer(ownerId);
1365
+ const storedAlias = asksStore.findImported(ownerId, askId);
1366
+ const previous = asksStore.getImportedDismissal(ownerId, askId);
1367
+ const view = eventFeedClient.state().views.find(item => item.ownerId === ownerId);
1368
+ const feedAsk = view && view.asks.find(item => (item.ownerAskId || item.id) === askId);
1369
+ const tsOf = item => { const ts = item && (Object.hasOwn(item, 'ownerAskTs') ? item.ownerAskTs : item.originNode ? null : item.ts); return Number.isSafeInteger(ts) && ts > 0 ? ts : null; };
1370
+ let ask = storedAlias || feedAsk, ambiguous = false;
1371
+ if (storedAlias && feedAsk) {
1372
+ const aliasTs = tsOf(storedAlias), feedTs = tsOf(feedAsk);
1373
+ const sameContent = asksStore.askFingerprint(storedAlias) === asksStore.askFingerprint(feedAsk);
1374
+ if (aliasTs !== null && feedTs !== null && aliasTs !== feedTs) {
1375
+ ask = feedTs > aliasTs ? feedAsk : storedAlias;
1376
+ // An older visible feed cannot be acknowledged through a newer closed
1377
+ // alias. Its current generation needs an authoritative refresh.
1378
+ if (ask === storedAlias && (storedAlias.dismissed || storedAlias.answered)) ambiguous = true;
1379
+ }
1380
+ else if (!sameContent) ambiguous = true;
1381
+ else ask = feedTs !== null && aliasTs === null ? feedAsk : storedAlias;
1382
+ }
1383
+ // Guards and alias mutation belong only to the selected generation.
1384
+ const alias = storedAlias && ask && asksStore.sameImportedGeneration(storedAlias, ask) ? storedAlias : null;
1385
+ const grants = peer && accessPresets.grantsOf(peer);
1386
+ const cellId = ask ? cellIdFromTmuxSession(ask.session) : previous && previous.cellId;
1387
+ const visible = !!((ask || previous) && cellId && grants && grants.configured && grants.grants.eventsAccess === true
1388
+ && eventFeedAcl.visibilityAllows(grants.grants, cellId));
1389
+ const blocked = !!((ask && ask.answered) || (alias && (asksStore.isAnswering(alias.id) || askReceipts.isBlocked(alias.id)))
1390
+ || askRelay.state().some(attempt => attempt.ownerId === ownerId && attempt.askId === askId && ['sent', 'uncertain'].includes(attempt.state)));
1391
+ const slot = peer && peer.reversePool && peer.reversePool.slots && peer.reversePool.slots[peer.reversePool.activeSlot];
1392
+ const unavailable = !!(peer && peer.direction === 'inbound' && (!peer.shared || !slot || !Number.isInteger(slot.port)))
1393
+ || askRelay.channelState(ownerId).unavailable;
1394
+ return { peer, alias, previous, ask, grants, cellId, visible, blocked: blocked || ambiguous, unavailable, ambiguous };
1395
+ }
1317
1396
  api.post('/asks-relay', express.json({ limit: '8kb' }), async (req, res) => {
1318
1397
  try {
1319
1398
  const b = req.body || {};
1399
+ if (b.action === 'dismiss-local') {
1400
+ if (proxyReadonly()) return res.status(403).json({ error: 'READONLY: mutation blocked' });
1401
+ if (req.headers[HOP_HEADER]) return res.status(403).json({ error: 'local operator required' });
1402
+ if (!b || Array.isArray(b) || Object.keys(b).some(key => !['action', 'ownerId', 'askId'].includes(key))
1403
+ || !/^[a-f0-9]{32}$/.test(b.ownerId) || !/^[a-f0-9]{8}$/.test(b.askId) || b.ownerId === federatedNodeId()) {
1404
+ return res.status(400).json({ error: 'invalid local dismissal body' });
1405
+ }
1406
+ try { if (!asksStore.health().readable) return res.status(503).json({ error: 'dismissal store unavailable' }); }
1407
+ catch (_) { return res.status(503).json({ error: 'dismissal store unavailable' }); }
1408
+ const { peer, alias, previous, ask, grants, cellId, visible, unavailable, ambiguous } = localDismissalEligibility(b.ownerId, b.askId);
1409
+ if (!visible) return res.status(404).json({ error: 'unknown or invisible ask' });
1410
+ if (ambiguous) return res.status(409).json({ error: 'ask generation is ambiguous; refresh the owner view', reason: 'generation-ambiguous' });
1411
+ if (ask && ask.answered) return res.status(409).json({ error: 'ask already answered', reason: 'answered' });
1412
+ if ((alias && (asksStore.isAnswering(alias.id) || askReceipts.isBlocked(alias.id)))
1413
+ || askRelay.state().some(attempt => attempt.ownerId === b.ownerId && attempt.askId === b.askId && ['sent', 'uncertain'].includes(attempt.state))) {
1414
+ return res.status(409).json({ error: 'answer requires reconciliation', reason: 'delivery-unknown-block' });
1415
+ }
1416
+ if (!ask && previous) return res.json({ dismissed: true, scope: 'local', ownerSync: previous.syncState === 'blocked' ? 'blocked' : 'pending' });
1417
+ if (!unavailable) return res.status(409).json({ error: 'owner channel is not known unavailable', reason: 'owner-availability-unknown' });
1418
+ const out = asksStore.dismissImported({ ownerId: b.ownerId, ownerAskId: b.askId, ask, cellId, retryBinding: dismissalBinding(b.ownerId),
1419
+ syncState: grants.grants.askReplyAccess === true ? 'pending' : 'blocked' });
1420
+ if (!out.ok) return res.status(out.reason === 'dismissal-cap' ? 429 : 503).json({ error: 'local dismissal refused', reason: out.reason });
1421
+ notifier.deliverOnlyRaw({ type: 'ask-dismissed', id: alias ? alias.id : b.askId, ownerId: b.ownerId, ownerAskId: b.askId, scope: 'local',
1422
+ // UI-only context also distinguishes changed content before the first snapshot.
1423
+ askGeneration: { question: ask.question, options: ask.options || [], session: ask.session },
1424
+ ownerAskTs: out.record.ownerAskTs, ownerSync: out.record.syncState === 'blocked' ? 'blocked' : 'pending' });
1425
+ return res.json({ dismissed: true, scope: 'local', ownerSync: out.record.syncState === 'blocked' ? 'blocked' : 'pending' });
1426
+ }
1320
1427
  if (b.action === 'dismiss') {
1321
1428
  const out = await askRelay.relayDismiss({ ownerId: b.ownerId, askId: b.askId });
1322
1429
  // Dismiss CONFERMATO dall'owner (2xx): la view del feed lo dimentica
@@ -1326,23 +1433,39 @@ function createServer(opts = {}) {
1326
1433
  if (out.ok && typeof eventFeedClient.dismissConfirmed === 'function') {
1327
1434
  eventFeedClient.dismissConfirmed(b.ownerId, b.askId);
1328
1435
  }
1329
- return res.status(out.ok ? 200 : out.code || 500).json(out.ok ? { dismissed: true } : { error: out.error || 'unknown' });
1436
+ return res.status(out.ok ? 200 : out.code || 500).json(out.ok ? { dismissed: true } : { error: out.error || 'unknown', reason: out.reason });
1330
1437
  }
1331
1438
  if (b.action === 'verify') {
1332
1439
  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 });
1440
+ 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
1441
  }
1335
- const out = await askRelay.relayAnswer({ ownerId: b.ownerId, askId: b.askId, text: b.text });
1442
+ const out = await askRelay.relayAnswer({ ownerId: b.ownerId, askId: b.askId, text: b.text, rid: b.requestId });
1336
1443
  return res.status(out.ok ? 200 : out.code || 500).json({
1337
- ...(out.ok ? { status: out.status } : {}),
1444
+ ...(out.ok ? { status: out.status, ownerId: out.ownerId, askId: out.askId, actor: out.actor, receipt: out.receipt } : {}),
1338
1445
  requestId: out.requestId || b.requestId,
1339
- ...(out.uncertain ? { uncertain: true, reason: out.reason } : {}),
1446
+ ...(out.uncertain ? { uncertain: true, reason: out.reason, originalRequestId: out.originalRequestId } : {}),
1340
1447
  ...(!out.ok && !out.uncertain ? { error: out.error, reason: out.reason } : {}),
1341
1448
  });
1342
1449
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1343
1450
  });
1344
1451
  // Riconciliazione dopo refresh: gli esiti incerti noti al relay (solo
1345
1452
  // contatori/ID, mai testo) per ricostruire lo stato «verifica» senza reinvii.
1453
+ api.get('/asks-relay/capability', async (req, res) => {
1454
+ const ownerId = typeof req.query.ownerId === 'string' ? req.query.ownerId : '';
1455
+ const askId = typeof req.query.askId === 'string' ? req.query.askId : '';
1456
+ if (!/^[a-f0-9]{32}$/.test(ownerId) || !/^[a-f0-9]{8}$/.test(askId)) return res.status(400).json({ error: 'ownerId e askId richiesti' });
1457
+ try {
1458
+ const remote = await askRelay.replyCapability({ ownerId, askId });
1459
+ if (req.query.dismissals !== '1') return res.json(remote);
1460
+ const local = localDismissalEligibility(ownerId, askId);
1461
+ const mutable = !proxyReadonly() && !req.headers[HOP_HEADER];
1462
+ let readable = false;
1463
+ try { readable = asksStore.health().readable; } catch (_) {}
1464
+ res.json({ ...remote, canDismissRemote: mutable && remote.canReply === true,
1465
+ canDismissLocal: mutable && readable && local.visible && !local.blocked && (local.unavailable || !!local.previous) });
1466
+ }
1467
+ catch (_) { res.json({ ownerId, askId, canReply: false, status: 'unreachable' }); }
1468
+ });
1346
1469
  api.get('/asks-relay/state', (_req, res) => {
1347
1470
  try { res.json({ attempts: askRelay.state() }); }
1348
1471
  catch (e) { res.status(500).json({ error: String(e.message || e) }); }
@@ -1358,7 +1481,15 @@ function createServer(opts = {}) {
1358
1481
  if (!bridgeReadonly()) pushRelay.considerImported(event);
1359
1482
  },
1360
1483
  };
1484
+ try { nodesStore.migrateAdminReception({ filePath: nodesPath, readonly: bridgeReadonly() }); }
1485
+ catch (error) {
1486
+ if (error?.code !== 'NODES_STORE_MISSING') {
1487
+ (opts.log || console.log)(`Admin reception migration skipped: ${error?.code || 'NODES_STORE_UNAVAILABLE'}`);
1488
+ }
1489
+ }
1361
1490
  const eventFeedClient = createEventFeedClient({
1491
+ isAskDismissed: (ownerId, ask) => asksStore.isImportedDismissed(ownerId, ask),
1492
+ onAskClosed: (ownerId, ownerAskId, outcome) => asksStore.closeImported({ ownerId, ownerAskId, outcome }),
1362
1493
  loadStore: () => nodesStore.loadStore(nodesPath),
1363
1494
  token: () => tokenHolder.value,
1364
1495
  eventsHub: importHub,
@@ -1371,6 +1502,11 @@ function createServer(opts = {}) {
1371
1502
  pollMs: cfg.eventFeedClientPollMs,
1372
1503
  });
1373
1504
  eventFeedClient.start();
1505
+ const importedDismissalDrainer = createImportedDismissalDrainer({
1506
+ store: asksStore, relay: askRelay, readonly: proxyReadonly, log: opts.log || console.log,
1507
+ bindingForOwner: dismissalBinding,
1508
+ });
1509
+
1374
1510
  // Local aggregated read for the UI: connected views only, with the cursor
1375
1511
  // watermark so the first fetch cannot race the live stream. Local
1376
1512
  // authenticated route, NOT in the federation allowlist.
@@ -1584,6 +1720,7 @@ function createServer(opts = {}) {
1584
1720
  });
1585
1721
  // Store-root revision of the peer access policy: the CAS value the UI
1586
1722
  // must send back with every preset write (stale revision -> 409).
1723
+ importedDismissalDrainer.nudge();
1587
1724
  res.json({ nodeId: view.nodeId, nodes, accessRevision: nodesStore.accessRevisionOf(st) });
1588
1725
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1589
1726
  });
@@ -1640,7 +1777,7 @@ function createServer(opts = {}) {
1640
1777
  catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1641
1778
  });
1642
1779
  api.use('/route', federation.localRouter({
1643
- nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
1780
+ nodesPath, slotVerifier: askReverseVerifier, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
1644
1781
  hopSecret: () => hopSecret,
1645
1782
  // A designation forwarded from here to a peer is recorded by the answer
1646
1783
  // the peer gives, not by a local guess: see liveHostLedgerCapture.
@@ -1672,7 +1809,7 @@ function createServer(opts = {}) {
1672
1809
 
1673
1810
  app.use('/api', api);
1674
1811
  app.use('/federation', federation.peerRouter({
1675
- nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
1812
+ nodesPath, slotVerifier: askReverseVerifier, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
1676
1813
  fetchImpl: healthFetch, hopSecret: () => hopSecret,
1677
1814
  roles: () => require('./cli/commands.js').readRoles(cfg.configPath || configJsonPath()),
1678
1815
  diagnostics,
@@ -1715,13 +1852,14 @@ function createServer(opts = {}) {
1715
1852
  diagnostics.record('info', 'server', 'SERVER_STARTED', 'NexusCrew server started', {
1716
1853
  port: runtimePort(), platform: cfg.platform || process.platform,
1717
1854
  });
1718
- updater.start(); startManagedTunnels();
1855
+ updater.start(); startManagedTunnels(); importedDismissalDrainer.start();
1719
1856
  });
1720
1857
  server.on('close', () => {
1721
1858
  diagnostics.record('info', 'server', 'SERVER_STOPPED', 'NexusCrew server stopped', { reason: 'close' });
1722
1859
  watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); ptyGrace.close();
1723
1860
  // Nessun timer di ritentativo sopravvive alla chiusura del server.
1724
1861
  if (closureRetry) { try { closureRetry.stop(); } catch (_) {} }
1862
+ importedDismissalDrainer.stop(); eventFeedClient.stop();
1725
1863
  for (const timer of reverseWatchers.values()) clearInterval(timer);
1726
1864
  reverseWatchers.clear(); rotatableReverse.clear(); void reverseSlotListeners?.closeAll();
1727
1865
  // Il pannello non sopravvive al control plane: senza requireToken sopra,