@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.
- package/CHANGELOG.md +20 -0
- package/README.md +32 -8
- package/frontend/dist/assets/index-CZFJnks_.js +169 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/fleet/managed.js +57 -0
- package/lib/nodes/access-presets.js +1 -0
- package/lib/nodes/ask-slot-verifier.js +72 -0
- package/lib/nodes/commands.js +7 -3
- package/lib/nodes/reverse-slot-proof.js +19 -7
- package/lib/nodes/store.js +54 -6
- package/lib/notify/ask-answer-service.js +23 -11
- package/lib/notify/ask-receipts.js +1 -0
- package/lib/notify/ask-relay.js +261 -74
- package/lib/notify/asks.js +117 -9
- package/lib/notify/event-feed-asks-routes.js +79 -19
- package/lib/notify/event-feed-client.js +130 -50
- package/lib/notify/event-feed-producers.js +1 -0
- package/lib/notify/imported-dismissals.js +106 -0
- package/lib/notify/routes.js +39 -20
- package/lib/proxy/federation.js +112 -12
- package/lib/proxy/resource-acl.js +1 -0
- package/lib/server.js +161 -23
- package/lib/tmux/actions.js +14 -11
- package/package.json +1 -1
- package/frontend/dist/assets/index-CAgxp3qK.js +0 -169
package/lib/proxy/federation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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,
|