@mmmbuto/nexuscrew 0.9.64 → 0.9.65
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 +8 -0
- package/docs/NOTIFICATIONS.md +34 -0
- package/frontend/dist/assets/index-DkYRC1vV.js +169 -0
- package/frontend/dist/assets/{index-BC6vO7Zd.css → index-Dwd37Z_R.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/notify/ask-relay.js +36 -166
- package/lib/notify/event-feed-asks-routes.js +18 -3
- package/lib/notify/event-feed-client.js +85 -10
- package/lib/notify/event-feed-notices-routes.js +135 -0
- package/lib/notify/event-feed-producers.js +21 -0
- package/lib/notify/event-feed-routes.js +30 -11
- package/lib/notify/notice-dismissal-queue.js +336 -0
- package/lib/notify/notice-dismissals.js +178 -0
- package/lib/notify/notice-relay.js +128 -0
- package/lib/notify/owner-channel.js +172 -0
- package/lib/proxy/federation.js +9 -0
- package/lib/proxy/resource-acl.js +6 -0
- package/lib/server.js +202 -2
- package/package.json +1 -1
- package/frontend/dist/assets/index-oUd5O8W8.js +0 -169
package/lib/server.js
CHANGED
|
@@ -91,7 +91,11 @@ const { createEventFeedClient } = require('./notify/event-feed-client.js');
|
|
|
91
91
|
const { createAskReceipts } = require('./notify/ask-receipts.js');
|
|
92
92
|
const { createAskAnswerService } = require('./notify/ask-answer-service.js');
|
|
93
93
|
const { createEventFeedAsksRoutes } = require('./notify/event-feed-asks-routes.js');
|
|
94
|
+
const { createEventFeedNoticesRoutes } = require('./notify/event-feed-notices-routes.js');
|
|
95
|
+
const { createNoticeDismissals } = require('./notify/notice-dismissals.js');
|
|
94
96
|
const { createAskRelay } = require('./notify/ask-relay.js');
|
|
97
|
+
const { createNoticeRelay } = require('./notify/notice-relay.js');
|
|
98
|
+
const { createNoticeDismissalQueue, createNoticeDismissalDrainer } = require('./notify/notice-dismissal-queue.js');
|
|
95
99
|
const { createImportedDismissalDrainer } = require('./notify/imported-dismissals.js');
|
|
96
100
|
const { createAskSlotVerifier } = require('./nodes/ask-slot-verifier.js');
|
|
97
101
|
const { readFederationEventsEnabled } = require('./config.js');
|
|
@@ -221,6 +225,12 @@ function createServer(opts = {}) {
|
|
|
221
225
|
const feedHistory = createEventFeedHistory({
|
|
222
226
|
filePath: path.join(notifyDir, 'event-feed-history.json'),
|
|
223
227
|
});
|
|
228
|
+
// Notices the OWNER cleared: ids and timestamps only, expiring with the
|
|
229
|
+
// history they filter. This is what makes a cleanup true on every device —
|
|
230
|
+
// the owner's snapshot stops carrying the entry, so a reload cannot revive it.
|
|
231
|
+
const noticeDismissals = createNoticeDismissals({
|
|
232
|
+
filePath: path.join(notifyDir, 'notice-dismissals.json'),
|
|
233
|
+
});
|
|
224
234
|
// Kill switch: strict boolean, read FRESH on every use so flipping it in
|
|
225
235
|
// config.json acts on live streams without a restart.
|
|
226
236
|
const eventsEnabled = () => readFederationEventsEnabled({ configJsonPath: cfg.configPath, federationEventsEnabled: cfg.federationEventsEnabled });
|
|
@@ -1340,6 +1350,33 @@ function createServer(opts = {}) {
|
|
|
1340
1350
|
cellForSession: ownerAskScope.cellForSession,
|
|
1341
1351
|
readonly: proxyReadonly,
|
|
1342
1352
|
}));
|
|
1353
|
+
// Owner-side notice dismissals: the same action gate and the same chain as
|
|
1354
|
+
// the ask write-back, with its own rate instance. Mounted beside the asks,
|
|
1355
|
+
// before the feed router, because it is a write-back path of the feed.
|
|
1356
|
+
api.use('/event-feed/notices', createEventFeedNoticesRoutes({
|
|
1357
|
+
nodesPath,
|
|
1358
|
+
localNodeId: federatedNodeId,
|
|
1359
|
+
originResolver: createOriginResolver({
|
|
1360
|
+
localNodeId: federatedNodeId,
|
|
1361
|
+
hopSecret: () => hopSecret,
|
|
1362
|
+
}),
|
|
1363
|
+
eventsEnabled,
|
|
1364
|
+
history: feedHistory,
|
|
1365
|
+
dismissals: noticeDismissals,
|
|
1366
|
+
// The closure inherits scope and cell of the original notice, so it is
|
|
1367
|
+
// delivered to exactly the peers that could see it. It enters the history
|
|
1368
|
+
// too: a peer that missed the live frame recovers it from the snapshot,
|
|
1369
|
+
// where toRemoteNotice drops it by type.
|
|
1370
|
+
closeNotice: ({ scope, cellId, eventId }) => {
|
|
1371
|
+
recordAndPublish({
|
|
1372
|
+
producer: scope === 'node' ? 'notify-closed-node' : 'notify-closed',
|
|
1373
|
+
cellId: scope === 'node' ? null : cellId,
|
|
1374
|
+
payload: { eventId, ts: Date.now() },
|
|
1375
|
+
});
|
|
1376
|
+
},
|
|
1377
|
+
readonly: proxyReadonly,
|
|
1378
|
+
log: opts.log || console.log,
|
|
1379
|
+
}));
|
|
1343
1380
|
api.use('/event-feed', createEventFeedRoutes({
|
|
1344
1381
|
nodesPath,
|
|
1345
1382
|
localNodeId: federatedNodeId,
|
|
@@ -1349,6 +1386,7 @@ function createServer(opts = {}) {
|
|
|
1349
1386
|
}),
|
|
1350
1387
|
eventFeed,
|
|
1351
1388
|
history: feedHistory,
|
|
1389
|
+
noticeDismissals,
|
|
1352
1390
|
asksStore,
|
|
1353
1391
|
fleetP,
|
|
1354
1392
|
cellForSession: ownerAskScope.cellForSession,
|
|
@@ -1377,6 +1415,60 @@ function createServer(opts = {}) {
|
|
|
1377
1415
|
// Il body va parsato QUI: il router `api` non monta un parser globale, ogni
|
|
1378
1416
|
// rotta porta il suo (`express.json`) — vedi /sessions, /notify, /asks.
|
|
1379
1417
|
// Senza, `req.body` e' undefined, il relay legge un body vuoto e risponde
|
|
1418
|
+
// Notice dismissals, client half: the relay that carries a dismissal to the
|
|
1419
|
+
// owner, the durable queue of the intents that could not be delivered yet, and
|
|
1420
|
+
// the drainer that retries them. Same channel as the ask answers (one
|
|
1421
|
+
// implementation of the chain), same binding as the imported dismissals: a
|
|
1422
|
+
// refusal stays blocked until the pairing to that owner changes.
|
|
1423
|
+
const noticeRelay = createNoticeRelay({
|
|
1424
|
+
nodesPath, slotVerifier: askReverseVerifier,
|
|
1425
|
+
loadStore: () => nodesStore.loadStore(nodesPath),
|
|
1426
|
+
peers: federatedPeers,
|
|
1427
|
+
localPort: () => (server && server.address() ? server.address().port : cfg.port),
|
|
1428
|
+
localToken: () => tokenHolder.value,
|
|
1429
|
+
log: opts.log || console.log,
|
|
1430
|
+
});
|
|
1431
|
+
const noticeDismissalQueue = createNoticeDismissalQueue({
|
|
1432
|
+
filePath: path.join(notifyDir, 'notice-dismissal-queue.json'),
|
|
1433
|
+
});
|
|
1434
|
+
// Durability signal for the operator's answer: the queue honours the intent
|
|
1435
|
+
// in memory when the disk refuses the write (that policy stands), but a
|
|
1436
|
+
// record that will not survive a restart is NOT the normal durable success,
|
|
1437
|
+
// so queued answers declare it instead of looking healthy. Empty = healthy.
|
|
1438
|
+
const queueDurability = () => {
|
|
1439
|
+
try {
|
|
1440
|
+
const st = noticeDismissalQueue.status();
|
|
1441
|
+
return st && st.degraded ? { durable: false, degraded: st.degraded } : {};
|
|
1442
|
+
} catch (_) { return {}; }
|
|
1443
|
+
};
|
|
1444
|
+
// One durable record per cleared notice. A refused write keeps the in-memory
|
|
1445
|
+
// truth (the intent was honoured here anyway) and only loses durability; a
|
|
1446
|
+
// refused enqueue means the intent was NOT recorded, so nothing is left behind.
|
|
1447
|
+
function enqueueNoticeDismissals(ownerId, eventIds, syncState) {
|
|
1448
|
+
const added = [];
|
|
1449
|
+
for (const eventId of eventIds) {
|
|
1450
|
+
const queued = noticeDismissalQueue.enqueue({ ownerId, eventId });
|
|
1451
|
+
if (!queued.ok) {
|
|
1452
|
+
for (const done of added) noticeDismissalQueue.confirm(ownerId, done);
|
|
1453
|
+
return false;
|
|
1454
|
+
}
|
|
1455
|
+
added.push(eventId);
|
|
1456
|
+
}
|
|
1457
|
+
if (syncState === 'blocked') {
|
|
1458
|
+
for (const eventId of eventIds) noticeDismissalQueue.update(ownerId, eventId, { syncState: 'blocked', lastReason: 'unsupported' });
|
|
1459
|
+
}
|
|
1460
|
+
return true;
|
|
1461
|
+
}
|
|
1462
|
+
const noticeDismissalDrainer = createNoticeDismissalDrainer({
|
|
1463
|
+
queue: noticeDismissalQueue, relay: noticeRelay,
|
|
1464
|
+
readonly: proxyReadonly, bindingForOwner: dismissalBinding,
|
|
1465
|
+
// Same seam as the feed client's poll: a test (or an installation with its
|
|
1466
|
+
// own rhythm) can shorten the retry interval without touching the policy.
|
|
1467
|
+
...(Number.isInteger(cfg.noticeDismissalDrainMs) && cfg.noticeDismissalDrainMs > 0
|
|
1468
|
+
? { intervalMs: cfg.noticeDismissalDrainMs } : {}),
|
|
1469
|
+
log: opts.log || console.log,
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1380
1472
|
// 400 «ownerId, askId e text richiesti» a OGNI richiesta: la risposta
|
|
1381
1473
|
// federata non ha mai potuto attraversare, e nessun test copriva questa
|
|
1382
1474
|
// rotta. Difetto preesistente, trovato scrivendo il test del ritorno federato.
|
|
@@ -1520,6 +1612,106 @@ function createServer(opts = {}) {
|
|
|
1520
1612
|
try { res.json({ attempts: askRelay.state() }); }
|
|
1521
1613
|
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1522
1614
|
});
|
|
1615
|
+
// Operator surface for the notice dismissals. LOCAL and authenticated, never in
|
|
1616
|
+
// the federation allowlist: the remote surface is the owner's (the routes under
|
|
1617
|
+
// /event-feed/notices). The intent is honoured HERE first — the card leaves this
|
|
1618
|
+
// node's view — and the owner's copy is either delivered or queued, with the
|
|
1619
|
+
// answer saying which.
|
|
1620
|
+
api.post('/notices-relay', express.json({ limit: '8kb' }), async (req, res) => {
|
|
1621
|
+
try {
|
|
1622
|
+
const b = req.body || {};
|
|
1623
|
+
if (proxyReadonly()) return res.status(403).json({ error: 'READONLY: mutation blocked' });
|
|
1624
|
+
if (req.headers[HOP_HEADER]) return res.status(403).json({ error: 'local operator required' });
|
|
1625
|
+
if (!b || Array.isArray(b) || typeof b.action !== 'string') return res.status(400).json({ error: 'invalid notices body' });
|
|
1626
|
+
if (b.action === 'dismiss') {
|
|
1627
|
+
if (Object.keys(b).some((key) => !['action', 'ownerId', 'eventId'].includes(key))
|
|
1628
|
+
|| !/^[a-f0-9]{32}$/.test(String(b.ownerId || ''))
|
|
1629
|
+
|| !/^[0-9a-f-]{36}$/.test(String(b.eventId || ''))
|
|
1630
|
+
|| b.ownerId === federatedNodeId()) {
|
|
1631
|
+
return res.status(400).json({ error: 'invalid local dismissal body' });
|
|
1632
|
+
}
|
|
1633
|
+
const out = await noticeRelay.relayNoticeDismiss({ ownerId: b.ownerId, eventId: b.eventId });
|
|
1634
|
+
if (out.ok) {
|
|
1635
|
+
if (typeof eventFeedClient.dismissNoticeConfirmed === 'function') eventFeedClient.dismissNoticeConfirmed(b.ownerId, b.eventId);
|
|
1636
|
+
return res.json({ dismissed: true, ...(out.idempotent ? { idempotent: true } : {}), ...(out.gone ? { gone: true } : {}) });
|
|
1637
|
+
}
|
|
1638
|
+
// The owner is not reachable NOW (or has no such surface at all): the
|
|
1639
|
+
// local intent stands, and the queue carries the rest. A reachable owner
|
|
1640
|
+
// that REFUSES is never queued — its answer is the decision.
|
|
1641
|
+
if (out.transient === true || out.reason === 'unsupported') {
|
|
1642
|
+
const queued = noticeDismissalQueue.enqueue({ ownerId: b.ownerId, eventId: b.eventId });
|
|
1643
|
+
if (!queued.ok) return res.status(503).json({ error: 'dismissal queue unavailable', reason: queued.reason });
|
|
1644
|
+
if (out.reason === 'unsupported') noticeDismissalQueue.update(b.ownerId, b.eventId, { syncState: 'blocked', lastReason: 'unsupported' });
|
|
1645
|
+
if (typeof eventFeedClient.dismissNoticeConfirmed === 'function') eventFeedClient.dismissNoticeConfirmed(b.ownerId, b.eventId);
|
|
1646
|
+
res.json({ dismissed: true, scope: 'local', ownerSync: out.reason === 'unsupported' ? 'blocked' : 'pending', ...queueDurability() });
|
|
1647
|
+
noticeDismissalDrainer.nudge();
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
return res.status(out.code || 500).json({ error: out.error || 'unknown', reason: out.reason });
|
|
1651
|
+
}
|
|
1652
|
+
if (b.action === 'dismiss-all') {
|
|
1653
|
+
if (Object.keys(b).some((key) => !['action', 'owners'].includes(key))
|
|
1654
|
+
|| !Array.isArray(b.owners) || b.owners.length === 0 || b.owners.length > 100
|
|
1655
|
+
|| b.owners.some((owner) => !/^[a-f0-9]{32}$/.test(String(owner || '')) || owner === federatedNodeId())) {
|
|
1656
|
+
return res.status(400).json({ error: 'invalid dismiss-all body' });
|
|
1657
|
+
}
|
|
1658
|
+
const results = {};
|
|
1659
|
+
for (const ownerId of [...new Set(b.owners)]) {
|
|
1660
|
+
// What this node is SHOWING for that owner: the whole visible set is
|
|
1661
|
+
// what the operator asked to clear, and it is the set whose local
|
|
1662
|
+
// intent has to outlive a reload or a restart of this node.
|
|
1663
|
+
const view = (eventFeedClient.state().views || []).find((v) => v.ownerId === ownerId);
|
|
1664
|
+
const affected = ((view && view.notifications) || [])
|
|
1665
|
+
.map((n) => n && n.eventId).filter((eventId) => typeof eventId === 'string' && eventId !== '');
|
|
1666
|
+
const out = await noticeRelay.relayNoticeDismissAll({ ownerId });
|
|
1667
|
+
if (out.ok) {
|
|
1668
|
+
// The owner took it: its own store stops serving these notices, so
|
|
1669
|
+
// the view forgets them here as well, exactly like the single route.
|
|
1670
|
+
for (const eventId of affected) eventFeedClient.dismissNoticeConfirmed(ownerId, eventId);
|
|
1671
|
+
results[ownerId] = { ok: true, dismissed: out.dismissed || 0 };
|
|
1672
|
+
continue;
|
|
1673
|
+
}
|
|
1674
|
+
// The owner is not reachable NOW (`pending`) or has no notices surface
|
|
1675
|
+
// at all (`blocked`): the local intent stands, durably — one queue
|
|
1676
|
+
// record per cleared notice is what keeps them out of the view on the
|
|
1677
|
+
// next snapshot — and the owner's copy is carried later, or never.
|
|
1678
|
+
const localSync = out.reason === 'unsupported' ? 'blocked' : (out.transient === true ? 'pending' : null);
|
|
1679
|
+
if (localSync === null) { results[ownerId] = { failed: out.reason || 'refused' }; continue; }
|
|
1680
|
+
if (!enqueueNoticeDismissals(ownerId, affected, localSync)) {
|
|
1681
|
+
results[ownerId] = { failed: 'queue-unavailable' };
|
|
1682
|
+
continue;
|
|
1683
|
+
}
|
|
1684
|
+
for (const eventId of affected) eventFeedClient.dismissNoticeConfirmed(ownerId, eventId);
|
|
1685
|
+
// Queued answers depend on the durable write: a queue that cannot
|
|
1686
|
+
// persist says so per owner, instead of looking like the healthy
|
|
1687
|
+
// pending/blocked that survives a restart.
|
|
1688
|
+
results[ownerId] = localSync === 'blocked'
|
|
1689
|
+
? { blocked: 'unsupported', ...queueDurability() }
|
|
1690
|
+
: { pending: affected.length, ...queueDurability() };
|
|
1691
|
+
if (localSync === 'pending') noticeDismissalDrainer.nudge();
|
|
1692
|
+
}
|
|
1693
|
+
return res.json({ results });
|
|
1694
|
+
}
|
|
1695
|
+
return res.status(400).json({ error: 'azione non riconosciuta' });
|
|
1696
|
+
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1697
|
+
});
|
|
1698
|
+
api.get('/notices-relay/capability', (req, res) => {
|
|
1699
|
+
const ownerId = typeof req.query.ownerId === 'string' ? req.query.ownerId : '';
|
|
1700
|
+
if (!/^[a-f0-9]{32}$/.test(ownerId)) return res.status(400).json({ error: 'ownerId richiesto' });
|
|
1701
|
+
let canDismiss = false;
|
|
1702
|
+
try {
|
|
1703
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
1704
|
+
const peer = st && (st.nodes || []).find((node) => node && node.nodeId === ownerId);
|
|
1705
|
+
const slot = peer && peer.reversePool && Array.isArray(peer.reversePool.slots)
|
|
1706
|
+
? peer.reversePool.slots[peer.reversePool.activeSlot] : null;
|
|
1707
|
+
canDismiss = !proxyReadonly() && !req.headers[HOP_HEADER] && !!peer && (
|
|
1708
|
+
(peer.direction === 'outbound' && !!peer.token && !!peer.localPort)
|
|
1709
|
+
|| (peer.direction === 'inbound' && peer.shared === true && peer.askReplyAccess === true
|
|
1710
|
+
&& peer.eventsAccess === true && peer.cellVisibility !== 'none'
|
|
1711
|
+
&& !!slot && Number.isInteger(slot.port)));
|
|
1712
|
+
} catch (_) { canDismiss = false; }
|
|
1713
|
+
res.json({ ownerId, canDismiss });
|
|
1714
|
+
});
|
|
1523
1715
|
// The client re-emits imported events into the local hub (deliverOnly: the
|
|
1524
1716
|
// notifier is bypassed on purpose). The alert relay hangs off that same call,
|
|
1525
1717
|
// so ONLY an imported notify can ring this device, and never through the
|
|
@@ -1548,6 +1740,13 @@ function createServer(opts = {}) {
|
|
|
1548
1740
|
if (!bridgeReadonly()) for (const ask of asks) askAlertRegistry.adopt(ownerId, ask, ask.ownerAskTs === undefined ? ask.ts : ask.ownerAskTs);
|
|
1549
1741
|
},
|
|
1550
1742
|
isAskDismissed: (ownerId, ask) => asksStore.isImportedDismissed(ownerId, ask),
|
|
1743
|
+
// A notice this node cleared locally but could not hand to the owner (queued
|
|
1744
|
+
// as pending, or blocked for good) must not return to the view: the record in
|
|
1745
|
+
// the queue is the node's intent, and it is what survives a restart. The
|
|
1746
|
+
// read is the same one the operator's answer is built from.
|
|
1747
|
+
isNoticeDismissed: (ownerId, eventId) => {
|
|
1748
|
+
try { return noticeDismissalQueue.get(ownerId, eventId) !== null; } catch (_) { return false; }
|
|
1749
|
+
},
|
|
1551
1750
|
onAskClosed: (ownerId, ownerAskId, outcome, ownerAskTs) => asksStore.closeImported({ ownerId, ownerAskId, outcome, ownerAskTs }),
|
|
1552
1751
|
loadStore: () => nodesStore.loadStore(nodesPath),
|
|
1553
1752
|
token: () => tokenHolder.value,
|
|
@@ -1794,6 +1993,7 @@ function createServer(opts = {}) {
|
|
|
1794
1993
|
// Store-root revision of the peer access policy: the CAS value the UI
|
|
1795
1994
|
// must send back with every preset write (stale revision -> 409).
|
|
1796
1995
|
importedDismissalDrainer.nudge();
|
|
1996
|
+
noticeDismissalDrainer.nudge();
|
|
1797
1997
|
res.json({ nodeId: view.nodeId, nodes, accessRevision: nodesStore.accessRevisionOf(st) });
|
|
1798
1998
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1799
1999
|
});
|
|
@@ -1925,14 +2125,14 @@ function createServer(opts = {}) {
|
|
|
1925
2125
|
diagnostics.record('info', 'server', 'SERVER_STARTED', 'NexusCrew server started', {
|
|
1926
2126
|
port: runtimePort(), platform: cfg.platform || process.platform,
|
|
1927
2127
|
});
|
|
1928
|
-
updater.start(); startManagedTunnels(); importedDismissalDrainer.start();
|
|
2128
|
+
updater.start(); startManagedTunnels(); importedDismissalDrainer.start(); noticeDismissalDrainer.start();
|
|
1929
2129
|
});
|
|
1930
2130
|
server.on('close', () => {
|
|
1931
2131
|
diagnostics.record('info', 'server', 'SERVER_STOPPED', 'NexusCrew server stopped', { reason: 'close' });
|
|
1932
2132
|
watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); ptyGrace.close();
|
|
1933
2133
|
// Nessun timer di ritentativo sopravvive alla chiusura del server.
|
|
1934
2134
|
if (closureRetry) { try { closureRetry.stop(); } catch (_) {} }
|
|
1935
|
-
importedDismissalDrainer.stop(); eventFeedClient.stop();
|
|
2135
|
+
importedDismissalDrainer.stop(); noticeDismissalDrainer.stop(); eventFeedClient.stop();
|
|
1936
2136
|
for (const timer of reverseWatchers.values()) clearInterval(timer);
|
|
1937
2137
|
reverseWatchers.clear(); rotatableReverse.clear(); void reverseSlotListeners?.closeAll();
|
|
1938
2138
|
// Il pannello non sopravvive al control plane: senza requireToken sopra,
|
package/package.json
CHANGED