@mmmbuto/nexuscrew 0.9.58 → 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 +7 -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/nodes/store.js +19 -1
- package/lib/notify/ask-relay.js +64 -14
- package/lib/notify/asks.js +117 -9
- package/lib/notify/event-feed-asks-routes.js +18 -5
- package/lib/notify/event-feed-client.js +10 -4
- package/lib/notify/event-feed-producers.js +1 -0
- package/lib/notify/imported-dismissals.js +106 -0
- package/lib/notify/routes.js +7 -3
- package/lib/server.js +109 -4
- package/package.json +1 -1
- package/frontend/dist/assets/index-D8cf4AEo.js +0 -169
package/lib/notify/routes.js
CHANGED
|
@@ -44,7 +44,7 @@ const ASK_KEYS = new Set(['question', 'options', 'session', 'target']);
|
|
|
44
44
|
// del nodo di origine, non un chiamante locale (stessa regola di FEDERATED_KEYS).
|
|
45
45
|
// `ownerNode`/`originNode` qualificano l'identita', `askId` e' l'id con cui
|
|
46
46
|
// l'owner conosce la domanda (serve alla risposta per tornare sul bersaglio).
|
|
47
|
-
const FEDERATED_ASK_KEYS = new Set(['askId', 'ownerNode', 'originNode', 'originCell', 'closeOutcome']);
|
|
47
|
+
const FEDERATED_ASK_KEYS = new Set(['askId', 'ownerNode', 'originNode', 'originCell', 'closeOutcome', 'ownerAskTs']);
|
|
48
48
|
const RATE_MAX = 6;
|
|
49
49
|
const RATE_WINDOW_MS = 60 * 1000;
|
|
50
50
|
const RATE_MAX_BUCKETS = 64;
|
|
@@ -481,12 +481,13 @@ function notifyRoutes({
|
|
|
481
481
|
return res.status(503).json({ status: 'unavailable', reason });
|
|
482
482
|
}
|
|
483
483
|
if (closed.changed && closed.ask) {
|
|
484
|
-
//
|
|
485
|
-
//
|
|
484
|
+
// Preserve the local alias id for older clients and the canonical
|
|
485
|
+
// owner ask id for clients that merge aliases with feed cards.
|
|
486
486
|
notifier.deliverOnlyRaw({
|
|
487
487
|
type: b.closeOutcome === 'dismissed' ? 'ask-dismissed' : 'ask-answered',
|
|
488
488
|
id: closed.ask.id,
|
|
489
489
|
ownerId: owner,
|
|
490
|
+
ownerAskId: closed.ask.ownerAskId,
|
|
490
491
|
});
|
|
491
492
|
}
|
|
492
493
|
return res.json({ status: 'delivered', closed: closed.changed });
|
|
@@ -506,12 +507,14 @@ function notifyRoutes({
|
|
|
506
507
|
// L'id con cui l'OWNER conosce la domanda: e' quello che la risposta
|
|
507
508
|
// deve citare. Il nostro `id` locale resta nostro.
|
|
508
509
|
ownerAskId: b.askId,
|
|
510
|
+
ownerAskTs: b.ownerAskTs,
|
|
509
511
|
originNode: owner,
|
|
510
512
|
originCell: resolved.origin.cell,
|
|
511
513
|
});
|
|
512
514
|
if (!out.ok) {
|
|
513
515
|
return res.status(out.reason === 'cap' ? 429 : 400).json({ status: 'refused', reason: out.reason, error: out.error });
|
|
514
516
|
}
|
|
517
|
+
if (out.suppressed) return res.json({ status: 'delivered', suppressed: true, ownerId: owner });
|
|
515
518
|
const ask = out.ask;
|
|
516
519
|
// deliverOnly: consegna locale alla UI, NIENTE pubblicazione sul feed e
|
|
517
520
|
// niente ri-esportazione. E' l'invariante che uccide il loop A->B->A per
|
|
@@ -565,6 +568,7 @@ function notifyRoutes({
|
|
|
565
568
|
...(ask.options ? { options: ask.options } : {}),
|
|
566
569
|
session: ask.session,
|
|
567
570
|
askId: ask.id,
|
|
571
|
+
ownerAskTs: ask.ts,
|
|
568
572
|
ownerNode: self,
|
|
569
573
|
},
|
|
570
574
|
});
|
package/lib/server.js
CHANGED
|
@@ -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 { createImportedDismissalDrainer } = require('./notify/imported-dismissals.js');
|
|
88
89
|
const { createAskSlotVerifier } = require('./nodes/ask-slot-verifier.js');
|
|
89
90
|
const { readFederationEventsEnabled } = require('./config.js');
|
|
90
91
|
|
|
@@ -372,7 +373,7 @@ function createServer(opts = {}) {
|
|
|
372
373
|
if (cellId) {
|
|
373
374
|
recordAndPublish({
|
|
374
375
|
producer: 'ask', cellId,
|
|
375
|
-
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() },
|
|
376
377
|
});
|
|
377
378
|
}
|
|
378
379
|
} else if (frame && (frame.type === 'ask-answered' || frame.type === 'ask-dismissed')) {
|
|
@@ -1224,7 +1225,7 @@ function createServer(opts = {}) {
|
|
|
1224
1225
|
try {
|
|
1225
1226
|
const closed = asksStore.closeImported({ ownerId, ownerAskId: alias.ownerAskId, outcome: 'dismissed' });
|
|
1226
1227
|
if (closed.changed && closed.ask) {
|
|
1227
|
-
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 (_) {}
|
|
1228
1229
|
}
|
|
1229
1230
|
} catch (_) { /* un alias non chiudibile non ferma gli altri */ }
|
|
1230
1231
|
}
|
|
@@ -1321,6 +1322,7 @@ function createServer(opts = {}) {
|
|
|
1321
1322
|
// resolves the owner from the authorized store and forwards with the peer's
|
|
1322
1323
|
// own pair token. Uncertain outcomes are parked, never retried silently.
|
|
1323
1324
|
const askRelay = createAskRelay({
|
|
1325
|
+
availabilityBinding: dismissalBinding,
|
|
1324
1326
|
nodesPath, slotVerifier: askReverseVerifier,
|
|
1325
1327
|
loadStore: () => nodesStore.loadStore(nodesPath),
|
|
1326
1328
|
peers: federatedPeers,
|
|
@@ -1334,9 +1336,94 @@ function createServer(opts = {}) {
|
|
|
1334
1336
|
// 400 «ownerId, askId e text richiesti» a OGNI richiesta: la risposta
|
|
1335
1337
|
// federata non ha mai potuto attraversare, e nessun test copriva questa
|
|
1336
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
|
+
}
|
|
1337
1396
|
api.post('/asks-relay', express.json({ limit: '8kb' }), async (req, res) => {
|
|
1338
1397
|
try {
|
|
1339
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
|
+
}
|
|
1340
1427
|
if (b.action === 'dismiss') {
|
|
1341
1428
|
const out = await askRelay.relayDismiss({ ownerId: b.ownerId, askId: b.askId });
|
|
1342
1429
|
// Dismiss CONFERMATO dall'owner (2xx): la view del feed lo dimentica
|
|
@@ -1367,7 +1454,16 @@ function createServer(opts = {}) {
|
|
|
1367
1454
|
const ownerId = typeof req.query.ownerId === 'string' ? req.query.ownerId : '';
|
|
1368
1455
|
const askId = typeof req.query.askId === 'string' ? req.query.askId : '';
|
|
1369
1456
|
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 {
|
|
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
|
+
}
|
|
1371
1467
|
catch (_) { res.json({ ownerId, askId, canReply: false, status: 'unreachable' }); }
|
|
1372
1468
|
});
|
|
1373
1469
|
api.get('/asks-relay/state', (_req, res) => {
|
|
@@ -1392,6 +1488,8 @@ function createServer(opts = {}) {
|
|
|
1392
1488
|
}
|
|
1393
1489
|
}
|
|
1394
1490
|
const eventFeedClient = createEventFeedClient({
|
|
1491
|
+
isAskDismissed: (ownerId, ask) => asksStore.isImportedDismissed(ownerId, ask),
|
|
1492
|
+
onAskClosed: (ownerId, ownerAskId, outcome) => asksStore.closeImported({ ownerId, ownerAskId, outcome }),
|
|
1395
1493
|
loadStore: () => nodesStore.loadStore(nodesPath),
|
|
1396
1494
|
token: () => tokenHolder.value,
|
|
1397
1495
|
eventsHub: importHub,
|
|
@@ -1404,6 +1502,11 @@ function createServer(opts = {}) {
|
|
|
1404
1502
|
pollMs: cfg.eventFeedClientPollMs,
|
|
1405
1503
|
});
|
|
1406
1504
|
eventFeedClient.start();
|
|
1505
|
+
const importedDismissalDrainer = createImportedDismissalDrainer({
|
|
1506
|
+
store: asksStore, relay: askRelay, readonly: proxyReadonly, log: opts.log || console.log,
|
|
1507
|
+
bindingForOwner: dismissalBinding,
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1407
1510
|
// Local aggregated read for the UI: connected views only, with the cursor
|
|
1408
1511
|
// watermark so the first fetch cannot race the live stream. Local
|
|
1409
1512
|
// authenticated route, NOT in the federation allowlist.
|
|
@@ -1617,6 +1720,7 @@ function createServer(opts = {}) {
|
|
|
1617
1720
|
});
|
|
1618
1721
|
// Store-root revision of the peer access policy: the CAS value the UI
|
|
1619
1722
|
// must send back with every preset write (stale revision -> 409).
|
|
1723
|
+
importedDismissalDrainer.nudge();
|
|
1620
1724
|
res.json({ nodeId: view.nodeId, nodes, accessRevision: nodesStore.accessRevisionOf(st) });
|
|
1621
1725
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1622
1726
|
});
|
|
@@ -1748,13 +1852,14 @@ function createServer(opts = {}) {
|
|
|
1748
1852
|
diagnostics.record('info', 'server', 'SERVER_STARTED', 'NexusCrew server started', {
|
|
1749
1853
|
port: runtimePort(), platform: cfg.platform || process.platform,
|
|
1750
1854
|
});
|
|
1751
|
-
updater.start(); startManagedTunnels();
|
|
1855
|
+
updater.start(); startManagedTunnels(); importedDismissalDrainer.start();
|
|
1752
1856
|
});
|
|
1753
1857
|
server.on('close', () => {
|
|
1754
1858
|
diagnostics.record('info', 'server', 'SERVER_STOPPED', 'NexusCrew server stopped', { reason: 'close' });
|
|
1755
1859
|
watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); ptyGrace.close();
|
|
1756
1860
|
// Nessun timer di ritentativo sopravvive alla chiusura del server.
|
|
1757
1861
|
if (closureRetry) { try { closureRetry.stop(); } catch (_) {} }
|
|
1862
|
+
importedDismissalDrainer.stop(); eventFeedClient.stop();
|
|
1758
1863
|
for (const timer of reverseWatchers.values()) clearInterval(timer);
|
|
1759
1864
|
reverseWatchers.clear(); rotatableReverse.clear(); void reverseSlotListeners?.closeAll();
|
|
1760
1865
|
// Il pannello non sopravvive al control plane: senza requireToken sopra,
|