@mmmbuto/nexuscrew 0.9.64 → 0.9.66
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 +18 -0
- package/docs/NODES.md +44 -0
- package/docs/NOTIFICATIONS.md +34 -0
- package/docs/SECURITY.md +40 -0
- package/frontend/dist/assets/index-DhZOap1-.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/cli/commands.js +31 -0
- package/lib/nodes/access-presets.js +27 -7
- package/lib/nodes/commands.js +63 -4
- package/lib/nodes/store.js +197 -12
- package/lib/notify/ask-cell.js +53 -0
- 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 +125 -12
- 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 +37 -12
- 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 +52 -4
- package/lib/proxy/resource-acl.js +6 -0
- package/lib/server.js +230 -7
- package/lib/settings/routes.js +6 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-oUd5O8W8.js +0 -169
package/lib/server.js
CHANGED
|
@@ -58,6 +58,7 @@ const { createCellScopeGuard } = require('./cells/scope-guard.js');
|
|
|
58
58
|
const { createCellScope } = require('./cells/scope.js');
|
|
59
59
|
const { cellIdFromTmuxSession } = require('./fleet/definitions.js');
|
|
60
60
|
const { createOwnerAskScope } = require('./notify/owner-ask-scope.js');
|
|
61
|
+
const askCell = require('./notify/ask-cell.js');
|
|
61
62
|
// Budget a tre finestre, riusato dalle notifiche federate: e' gia' scritto per
|
|
62
63
|
// limitare il danno di un nodo che mente sulla propria cella (il tetto per
|
|
63
64
|
// target si calcola sul NODO, che la catena visited verifica).
|
|
@@ -91,7 +92,11 @@ const { createEventFeedClient } = require('./notify/event-feed-client.js');
|
|
|
91
92
|
const { createAskReceipts } = require('./notify/ask-receipts.js');
|
|
92
93
|
const { createAskAnswerService } = require('./notify/ask-answer-service.js');
|
|
93
94
|
const { createEventFeedAsksRoutes } = require('./notify/event-feed-asks-routes.js');
|
|
95
|
+
const { createEventFeedNoticesRoutes } = require('./notify/event-feed-notices-routes.js');
|
|
96
|
+
const { createNoticeDismissals } = require('./notify/notice-dismissals.js');
|
|
94
97
|
const { createAskRelay } = require('./notify/ask-relay.js');
|
|
98
|
+
const { createNoticeRelay } = require('./notify/notice-relay.js');
|
|
99
|
+
const { createNoticeDismissalQueue, createNoticeDismissalDrainer } = require('./notify/notice-dismissal-queue.js');
|
|
95
100
|
const { createImportedDismissalDrainer } = require('./notify/imported-dismissals.js');
|
|
96
101
|
const { createAskSlotVerifier } = require('./nodes/ask-slot-verifier.js');
|
|
97
102
|
const { readFederationEventsEnabled } = require('./config.js');
|
|
@@ -221,6 +226,12 @@ function createServer(opts = {}) {
|
|
|
221
226
|
const feedHistory = createEventFeedHistory({
|
|
222
227
|
filePath: path.join(notifyDir, 'event-feed-history.json'),
|
|
223
228
|
});
|
|
229
|
+
// Notices the OWNER cleared: ids and timestamps only, expiring with the
|
|
230
|
+
// history they filter. This is what makes a cleanup true on every device —
|
|
231
|
+
// the owner's snapshot stops carrying the entry, so a reload cannot revive it.
|
|
232
|
+
const noticeDismissals = createNoticeDismissals({
|
|
233
|
+
filePath: path.join(notifyDir, 'notice-dismissals.json'),
|
|
234
|
+
});
|
|
224
235
|
// Kill switch: strict boolean, read FRESH on every use so flipping it in
|
|
225
236
|
// config.json acts on live streams without a restart.
|
|
226
237
|
const eventsEnabled = () => readFederationEventsEnabled({ configJsonPath: cfg.configPath, federationEventsEnabled: cfg.federationEventsEnabled });
|
|
@@ -1340,6 +1351,33 @@ function createServer(opts = {}) {
|
|
|
1340
1351
|
cellForSession: ownerAskScope.cellForSession,
|
|
1341
1352
|
readonly: proxyReadonly,
|
|
1342
1353
|
}));
|
|
1354
|
+
// Owner-side notice dismissals: the same action gate and the same chain as
|
|
1355
|
+
// the ask write-back, with its own rate instance. Mounted beside the asks,
|
|
1356
|
+
// before the feed router, because it is a write-back path of the feed.
|
|
1357
|
+
api.use('/event-feed/notices', createEventFeedNoticesRoutes({
|
|
1358
|
+
nodesPath,
|
|
1359
|
+
localNodeId: federatedNodeId,
|
|
1360
|
+
originResolver: createOriginResolver({
|
|
1361
|
+
localNodeId: federatedNodeId,
|
|
1362
|
+
hopSecret: () => hopSecret,
|
|
1363
|
+
}),
|
|
1364
|
+
eventsEnabled,
|
|
1365
|
+
history: feedHistory,
|
|
1366
|
+
dismissals: noticeDismissals,
|
|
1367
|
+
// The closure inherits scope and cell of the original notice, so it is
|
|
1368
|
+
// delivered to exactly the peers that could see it. It enters the history
|
|
1369
|
+
// too: a peer that missed the live frame recovers it from the snapshot,
|
|
1370
|
+
// where toRemoteNotice drops it by type.
|
|
1371
|
+
closeNotice: ({ scope, cellId, eventId }) => {
|
|
1372
|
+
recordAndPublish({
|
|
1373
|
+
producer: scope === 'node' ? 'notify-closed-node' : 'notify-closed',
|
|
1374
|
+
cellId: scope === 'node' ? null : cellId,
|
|
1375
|
+
payload: { eventId, ts: Date.now() },
|
|
1376
|
+
});
|
|
1377
|
+
},
|
|
1378
|
+
readonly: proxyReadonly,
|
|
1379
|
+
log: opts.log || console.log,
|
|
1380
|
+
}));
|
|
1343
1381
|
api.use('/event-feed', createEventFeedRoutes({
|
|
1344
1382
|
nodesPath,
|
|
1345
1383
|
localNodeId: federatedNodeId,
|
|
@@ -1349,6 +1387,7 @@ function createServer(opts = {}) {
|
|
|
1349
1387
|
}),
|
|
1350
1388
|
eventFeed,
|
|
1351
1389
|
history: feedHistory,
|
|
1390
|
+
noticeDismissals,
|
|
1352
1391
|
asksStore,
|
|
1353
1392
|
fleetP,
|
|
1354
1393
|
cellForSession: ownerAskScope.cellForSession,
|
|
@@ -1377,6 +1416,60 @@ function createServer(opts = {}) {
|
|
|
1377
1416
|
// Il body va parsato QUI: il router `api` non monta un parser globale, ogni
|
|
1378
1417
|
// rotta porta il suo (`express.json`) — vedi /sessions, /notify, /asks.
|
|
1379
1418
|
// Senza, `req.body` e' undefined, il relay legge un body vuoto e risponde
|
|
1419
|
+
// Notice dismissals, client half: the relay that carries a dismissal to the
|
|
1420
|
+
// owner, the durable queue of the intents that could not be delivered yet, and
|
|
1421
|
+
// the drainer that retries them. Same channel as the ask answers (one
|
|
1422
|
+
// implementation of the chain), same binding as the imported dismissals: a
|
|
1423
|
+
// refusal stays blocked until the pairing to that owner changes.
|
|
1424
|
+
const noticeRelay = createNoticeRelay({
|
|
1425
|
+
nodesPath, slotVerifier: askReverseVerifier,
|
|
1426
|
+
loadStore: () => nodesStore.loadStore(nodesPath),
|
|
1427
|
+
peers: federatedPeers,
|
|
1428
|
+
localPort: () => (server && server.address() ? server.address().port : cfg.port),
|
|
1429
|
+
localToken: () => tokenHolder.value,
|
|
1430
|
+
log: opts.log || console.log,
|
|
1431
|
+
});
|
|
1432
|
+
const noticeDismissalQueue = createNoticeDismissalQueue({
|
|
1433
|
+
filePath: path.join(notifyDir, 'notice-dismissal-queue.json'),
|
|
1434
|
+
});
|
|
1435
|
+
// Durability signal for the operator's answer: the queue honours the intent
|
|
1436
|
+
// in memory when the disk refuses the write (that policy stands), but a
|
|
1437
|
+
// record that will not survive a restart is NOT the normal durable success,
|
|
1438
|
+
// so queued answers declare it instead of looking healthy. Empty = healthy.
|
|
1439
|
+
const queueDurability = () => {
|
|
1440
|
+
try {
|
|
1441
|
+
const st = noticeDismissalQueue.status();
|
|
1442
|
+
return st && st.degraded ? { durable: false, degraded: st.degraded } : {};
|
|
1443
|
+
} catch (_) { return {}; }
|
|
1444
|
+
};
|
|
1445
|
+
// One durable record per cleared notice. A refused write keeps the in-memory
|
|
1446
|
+
// truth (the intent was honoured here anyway) and only loses durability; a
|
|
1447
|
+
// refused enqueue means the intent was NOT recorded, so nothing is left behind.
|
|
1448
|
+
function enqueueNoticeDismissals(ownerId, eventIds, syncState) {
|
|
1449
|
+
const added = [];
|
|
1450
|
+
for (const eventId of eventIds) {
|
|
1451
|
+
const queued = noticeDismissalQueue.enqueue({ ownerId, eventId });
|
|
1452
|
+
if (!queued.ok) {
|
|
1453
|
+
for (const done of added) noticeDismissalQueue.confirm(ownerId, done);
|
|
1454
|
+
return false;
|
|
1455
|
+
}
|
|
1456
|
+
added.push(eventId);
|
|
1457
|
+
}
|
|
1458
|
+
if (syncState === 'blocked') {
|
|
1459
|
+
for (const eventId of eventIds) noticeDismissalQueue.update(ownerId, eventId, { syncState: 'blocked', lastReason: 'unsupported' });
|
|
1460
|
+
}
|
|
1461
|
+
return true;
|
|
1462
|
+
}
|
|
1463
|
+
const noticeDismissalDrainer = createNoticeDismissalDrainer({
|
|
1464
|
+
queue: noticeDismissalQueue, relay: noticeRelay,
|
|
1465
|
+
readonly: proxyReadonly, bindingForOwner: dismissalBinding,
|
|
1466
|
+
// Same seam as the feed client's poll: a test (or an installation with its
|
|
1467
|
+
// own rhythm) can shorten the retry interval without touching the policy.
|
|
1468
|
+
...(Number.isInteger(cfg.noticeDismissalDrainMs) && cfg.noticeDismissalDrainMs > 0
|
|
1469
|
+
? { intervalMs: cfg.noticeDismissalDrainMs } : {}),
|
|
1470
|
+
log: opts.log || console.log,
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1380
1473
|
// 400 «ownerId, askId e text richiesti» a OGNI richiesta: la risposta
|
|
1381
1474
|
// federata non ha mai potuto attraversare, e nessun test copriva questa
|
|
1382
1475
|
// rotta. Difetto preesistente, trovato scrivendo il test del ritorno federato.
|
|
@@ -1427,15 +1520,32 @@ function createServer(opts = {}) {
|
|
|
1427
1520
|
// Guards and alias mutation belong only to the selected generation.
|
|
1428
1521
|
const alias = storedAlias && ask && asksStore.sameImportedGeneration(storedAlias, ask) ? storedAlias : null;
|
|
1429
1522
|
const grants = peer && accessPresets.grantsOf(peer);
|
|
1430
|
-
|
|
1431
|
-
|
|
1523
|
+
// The cell comes from what the OWNER resolved (feed or attested origin) and,
|
|
1524
|
+
// only for canonical names, from the session; see lib/notify/ask-cell.js. A
|
|
1525
|
+
// body field is never consulted: the dismissal request carries no cell.
|
|
1526
|
+
// The selected ask keeps its OWN cell. The other source (alias <-> feed card)
|
|
1527
|
+
// lends its cell only when it is bound to the same generation by an explicit,
|
|
1528
|
+
// equal owner timestamp and the same content: a generation that is unknown
|
|
1529
|
+
// on either side is not "the same" for a cell (sameImportedGeneration stays
|
|
1530
|
+
// permissive about it, because guards and alias mutation want that). If both
|
|
1531
|
+
// sources are authenticated and disagree there is no cell at all.
|
|
1532
|
+
const boundToSelected = item => item === ask || (tsOf(item) !== null && tsOf(item) === tsOf(ask)
|
|
1533
|
+
&& asksStore.askFingerprint(item) === asksStore.askFingerprint(ask));
|
|
1534
|
+
const sameAsk = ask && [storedAlias, feedAsk].filter(item => item && boundToSelected(item));
|
|
1535
|
+
const cellId = ask ? askCell.resolveAskCellFrom(sameAsk).cellId : previous && previous.cellId;
|
|
1536
|
+
const grantsAllowFeed = !!(grants && grants.configured && grants.grants.eventsAccess === true);
|
|
1537
|
+
const visible = !!((ask || previous) && cellId && grantsAllowFeed
|
|
1432
1538
|
&& eventFeedAcl.visibilityAllows(grants.grants, cellId));
|
|
1539
|
+
// The ask is known and this node may read the owner's feed, but no
|
|
1540
|
+
// authenticated source tells which cell it belongs to (a card persisted by an
|
|
1541
|
+
// older release, an owner that does not publish the cell): said, not guessed.
|
|
1542
|
+
const cellUnresolved = !!(ask && !cellId && grantsAllowFeed);
|
|
1433
1543
|
const blocked = !!((ask && ask.answered) || (alias && (asksStore.isAnswering(alias.id) || askReceipts.isBlocked(alias.id)))
|
|
1434
1544
|
|| askRelay.state().some(attempt => attempt.ownerId === ownerId && attempt.askId === askId && ['sent', 'uncertain'].includes(attempt.state)));
|
|
1435
1545
|
const slot = peer && peer.reversePool && peer.reversePool.slots && peer.reversePool.slots[peer.reversePool.activeSlot];
|
|
1436
1546
|
const unavailable = !!(peer && peer.direction === 'inbound' && (!peer.shared || !slot || !Number.isInteger(slot.port)))
|
|
1437
1547
|
|| askRelay.channelState(ownerId).unavailable;
|
|
1438
|
-
return { peer, alias, previous, ask, grants, cellId, visible, blocked: blocked || ambiguous, unavailable, ambiguous };
|
|
1548
|
+
return { peer, alias, previous, ask, grants, cellId, visible, cellUnresolved, blocked: blocked || ambiguous, unavailable, ambiguous };
|
|
1439
1549
|
}
|
|
1440
1550
|
api.post('/asks-relay', express.json({ limit: '8kb' }), async (req, res) => {
|
|
1441
1551
|
try {
|
|
@@ -1449,8 +1559,13 @@ function createServer(opts = {}) {
|
|
|
1449
1559
|
}
|
|
1450
1560
|
try { if (!asksStore.health().readable) return res.status(503).json({ error: 'dismissal store unavailable' }); }
|
|
1451
1561
|
catch (_) { return res.status(503).json({ error: 'dismissal store unavailable' }); }
|
|
1452
|
-
const { peer, alias, previous, ask, grants, cellId, visible, unavailable, ambiguous } = localDismissalEligibility(b.ownerId, b.askId);
|
|
1453
|
-
if (!visible)
|
|
1562
|
+
const { peer, alias, previous, ask, grants, cellId, visible, cellUnresolved, unavailable, ambiguous } = localDismissalEligibility(b.ownerId, b.askId);
|
|
1563
|
+
if (!visible) {
|
|
1564
|
+
if (cellUnresolved) {
|
|
1565
|
+
return res.status(409).json({ error: 'the cell of this ask is not known from an authenticated owner source; refresh the owner view', reason: 'cell-unresolved' });
|
|
1566
|
+
}
|
|
1567
|
+
return res.status(404).json({ error: 'unknown or invisible ask' });
|
|
1568
|
+
}
|
|
1454
1569
|
if (ambiguous) return res.status(409).json({ error: 'ask generation is ambiguous; refresh the owner view', reason: 'generation-ambiguous' });
|
|
1455
1570
|
if (ask && ask.answered) return res.status(409).json({ error: 'ask already answered', reason: 'answered' });
|
|
1456
1571
|
if ((alias && (asksStore.isAnswering(alias.id) || askReceipts.isBlocked(alias.id)))
|
|
@@ -1520,6 +1635,106 @@ function createServer(opts = {}) {
|
|
|
1520
1635
|
try { res.json({ attempts: askRelay.state() }); }
|
|
1521
1636
|
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1522
1637
|
});
|
|
1638
|
+
// Operator surface for the notice dismissals. LOCAL and authenticated, never in
|
|
1639
|
+
// the federation allowlist: the remote surface is the owner's (the routes under
|
|
1640
|
+
// /event-feed/notices). The intent is honoured HERE first — the card leaves this
|
|
1641
|
+
// node's view — and the owner's copy is either delivered or queued, with the
|
|
1642
|
+
// answer saying which.
|
|
1643
|
+
api.post('/notices-relay', express.json({ limit: '8kb' }), async (req, res) => {
|
|
1644
|
+
try {
|
|
1645
|
+
const b = req.body || {};
|
|
1646
|
+
if (proxyReadonly()) return res.status(403).json({ error: 'READONLY: mutation blocked' });
|
|
1647
|
+
if (req.headers[HOP_HEADER]) return res.status(403).json({ error: 'local operator required' });
|
|
1648
|
+
if (!b || Array.isArray(b) || typeof b.action !== 'string') return res.status(400).json({ error: 'invalid notices body' });
|
|
1649
|
+
if (b.action === 'dismiss') {
|
|
1650
|
+
if (Object.keys(b).some((key) => !['action', 'ownerId', 'eventId'].includes(key))
|
|
1651
|
+
|| !/^[a-f0-9]{32}$/.test(String(b.ownerId || ''))
|
|
1652
|
+
|| !/^[0-9a-f-]{36}$/.test(String(b.eventId || ''))
|
|
1653
|
+
|| b.ownerId === federatedNodeId()) {
|
|
1654
|
+
return res.status(400).json({ error: 'invalid local dismissal body' });
|
|
1655
|
+
}
|
|
1656
|
+
const out = await noticeRelay.relayNoticeDismiss({ ownerId: b.ownerId, eventId: b.eventId });
|
|
1657
|
+
if (out.ok) {
|
|
1658
|
+
if (typeof eventFeedClient.dismissNoticeConfirmed === 'function') eventFeedClient.dismissNoticeConfirmed(b.ownerId, b.eventId);
|
|
1659
|
+
return res.json({ dismissed: true, ...(out.idempotent ? { idempotent: true } : {}), ...(out.gone ? { gone: true } : {}) });
|
|
1660
|
+
}
|
|
1661
|
+
// The owner is not reachable NOW (or has no such surface at all): the
|
|
1662
|
+
// local intent stands, and the queue carries the rest. A reachable owner
|
|
1663
|
+
// that REFUSES is never queued — its answer is the decision.
|
|
1664
|
+
if (out.transient === true || out.reason === 'unsupported') {
|
|
1665
|
+
const queued = noticeDismissalQueue.enqueue({ ownerId: b.ownerId, eventId: b.eventId });
|
|
1666
|
+
if (!queued.ok) return res.status(503).json({ error: 'dismissal queue unavailable', reason: queued.reason });
|
|
1667
|
+
if (out.reason === 'unsupported') noticeDismissalQueue.update(b.ownerId, b.eventId, { syncState: 'blocked', lastReason: 'unsupported' });
|
|
1668
|
+
if (typeof eventFeedClient.dismissNoticeConfirmed === 'function') eventFeedClient.dismissNoticeConfirmed(b.ownerId, b.eventId);
|
|
1669
|
+
res.json({ dismissed: true, scope: 'local', ownerSync: out.reason === 'unsupported' ? 'blocked' : 'pending', ...queueDurability() });
|
|
1670
|
+
noticeDismissalDrainer.nudge();
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
return res.status(out.code || 500).json({ error: out.error || 'unknown', reason: out.reason });
|
|
1674
|
+
}
|
|
1675
|
+
if (b.action === 'dismiss-all') {
|
|
1676
|
+
if (Object.keys(b).some((key) => !['action', 'owners'].includes(key))
|
|
1677
|
+
|| !Array.isArray(b.owners) || b.owners.length === 0 || b.owners.length > 100
|
|
1678
|
+
|| b.owners.some((owner) => !/^[a-f0-9]{32}$/.test(String(owner || '')) || owner === federatedNodeId())) {
|
|
1679
|
+
return res.status(400).json({ error: 'invalid dismiss-all body' });
|
|
1680
|
+
}
|
|
1681
|
+
const results = {};
|
|
1682
|
+
for (const ownerId of [...new Set(b.owners)]) {
|
|
1683
|
+
// What this node is SHOWING for that owner: the whole visible set is
|
|
1684
|
+
// what the operator asked to clear, and it is the set whose local
|
|
1685
|
+
// intent has to outlive a reload or a restart of this node.
|
|
1686
|
+
const view = (eventFeedClient.state().views || []).find((v) => v.ownerId === ownerId);
|
|
1687
|
+
const affected = ((view && view.notifications) || [])
|
|
1688
|
+
.map((n) => n && n.eventId).filter((eventId) => typeof eventId === 'string' && eventId !== '');
|
|
1689
|
+
const out = await noticeRelay.relayNoticeDismissAll({ ownerId });
|
|
1690
|
+
if (out.ok) {
|
|
1691
|
+
// The owner took it: its own store stops serving these notices, so
|
|
1692
|
+
// the view forgets them here as well, exactly like the single route.
|
|
1693
|
+
for (const eventId of affected) eventFeedClient.dismissNoticeConfirmed(ownerId, eventId);
|
|
1694
|
+
results[ownerId] = { ok: true, dismissed: out.dismissed || 0 };
|
|
1695
|
+
continue;
|
|
1696
|
+
}
|
|
1697
|
+
// The owner is not reachable NOW (`pending`) or has no notices surface
|
|
1698
|
+
// at all (`blocked`): the local intent stands, durably — one queue
|
|
1699
|
+
// record per cleared notice is what keeps them out of the view on the
|
|
1700
|
+
// next snapshot — and the owner's copy is carried later, or never.
|
|
1701
|
+
const localSync = out.reason === 'unsupported' ? 'blocked' : (out.transient === true ? 'pending' : null);
|
|
1702
|
+
if (localSync === null) { results[ownerId] = { failed: out.reason || 'refused' }; continue; }
|
|
1703
|
+
if (!enqueueNoticeDismissals(ownerId, affected, localSync)) {
|
|
1704
|
+
results[ownerId] = { failed: 'queue-unavailable' };
|
|
1705
|
+
continue;
|
|
1706
|
+
}
|
|
1707
|
+
for (const eventId of affected) eventFeedClient.dismissNoticeConfirmed(ownerId, eventId);
|
|
1708
|
+
// Queued answers depend on the durable write: a queue that cannot
|
|
1709
|
+
// persist says so per owner, instead of looking like the healthy
|
|
1710
|
+
// pending/blocked that survives a restart.
|
|
1711
|
+
results[ownerId] = localSync === 'blocked'
|
|
1712
|
+
? { blocked: 'unsupported', ...queueDurability() }
|
|
1713
|
+
: { pending: affected.length, ...queueDurability() };
|
|
1714
|
+
if (localSync === 'pending') noticeDismissalDrainer.nudge();
|
|
1715
|
+
}
|
|
1716
|
+
return res.json({ results });
|
|
1717
|
+
}
|
|
1718
|
+
return res.status(400).json({ error: 'azione non riconosciuta' });
|
|
1719
|
+
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1720
|
+
});
|
|
1721
|
+
api.get('/notices-relay/capability', (req, res) => {
|
|
1722
|
+
const ownerId = typeof req.query.ownerId === 'string' ? req.query.ownerId : '';
|
|
1723
|
+
if (!/^[a-f0-9]{32}$/.test(ownerId)) return res.status(400).json({ error: 'ownerId richiesto' });
|
|
1724
|
+
let canDismiss = false;
|
|
1725
|
+
try {
|
|
1726
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
1727
|
+
const peer = st && (st.nodes || []).find((node) => node && node.nodeId === ownerId);
|
|
1728
|
+
const slot = peer && peer.reversePool && Array.isArray(peer.reversePool.slots)
|
|
1729
|
+
? peer.reversePool.slots[peer.reversePool.activeSlot] : null;
|
|
1730
|
+
canDismiss = !proxyReadonly() && !req.headers[HOP_HEADER] && !!peer && (
|
|
1731
|
+
(peer.direction === 'outbound' && !!peer.token && !!peer.localPort)
|
|
1732
|
+
|| (peer.direction === 'inbound' && peer.shared === true && peer.askReplyAccess === true
|
|
1733
|
+
&& peer.eventsAccess === true && peer.cellVisibility !== 'none'
|
|
1734
|
+
&& !!slot && Number.isInteger(slot.port)));
|
|
1735
|
+
} catch (_) { canDismiss = false; }
|
|
1736
|
+
res.json({ ownerId, canDismiss });
|
|
1737
|
+
});
|
|
1523
1738
|
// The client re-emits imported events into the local hub (deliverOnly: the
|
|
1524
1739
|
// notifier is bypassed on purpose). The alert relay hangs off that same call,
|
|
1525
1740
|
// so ONLY an imported notify can ring this device, and never through the
|
|
@@ -1548,6 +1763,13 @@ function createServer(opts = {}) {
|
|
|
1548
1763
|
if (!bridgeReadonly()) for (const ask of asks) askAlertRegistry.adopt(ownerId, ask, ask.ownerAskTs === undefined ? ask.ts : ask.ownerAskTs);
|
|
1549
1764
|
},
|
|
1550
1765
|
isAskDismissed: (ownerId, ask) => asksStore.isImportedDismissed(ownerId, ask),
|
|
1766
|
+
// A notice this node cleared locally but could not hand to the owner (queued
|
|
1767
|
+
// as pending, or blocked for good) must not return to the view: the record in
|
|
1768
|
+
// the queue is the node's intent, and it is what survives a restart. The
|
|
1769
|
+
// read is the same one the operator's answer is built from.
|
|
1770
|
+
isNoticeDismissed: (ownerId, eventId) => {
|
|
1771
|
+
try { return noticeDismissalQueue.get(ownerId, eventId) !== null; } catch (_) { return false; }
|
|
1772
|
+
},
|
|
1551
1773
|
onAskClosed: (ownerId, ownerAskId, outcome, ownerAskTs) => asksStore.closeImported({ ownerId, ownerAskId, outcome, ownerAskTs }),
|
|
1552
1774
|
loadStore: () => nodesStore.loadStore(nodesPath),
|
|
1553
1775
|
token: () => tokenHolder.value,
|
|
@@ -1794,6 +2016,7 @@ function createServer(opts = {}) {
|
|
|
1794
2016
|
// Store-root revision of the peer access policy: the CAS value the UI
|
|
1795
2017
|
// must send back with every preset write (stale revision -> 409).
|
|
1796
2018
|
importedDismissalDrainer.nudge();
|
|
2019
|
+
noticeDismissalDrainer.nudge();
|
|
1797
2020
|
res.json({ nodeId: view.nodeId, nodes, accessRevision: nodesStore.accessRevisionOf(st) });
|
|
1798
2021
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1799
2022
|
});
|
|
@@ -1925,14 +2148,14 @@ function createServer(opts = {}) {
|
|
|
1925
2148
|
diagnostics.record('info', 'server', 'SERVER_STARTED', 'NexusCrew server started', {
|
|
1926
2149
|
port: runtimePort(), platform: cfg.platform || process.platform,
|
|
1927
2150
|
});
|
|
1928
|
-
updater.start(); startManagedTunnels(); importedDismissalDrainer.start();
|
|
2151
|
+
updater.start(); startManagedTunnels(); importedDismissalDrainer.start(); noticeDismissalDrainer.start();
|
|
1929
2152
|
});
|
|
1930
2153
|
server.on('close', () => {
|
|
1931
2154
|
diagnostics.record('info', 'server', 'SERVER_STOPPED', 'NexusCrew server stopped', { reason: 'close' });
|
|
1932
2155
|
watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); ptyGrace.close();
|
|
1933
2156
|
// Nessun timer di ritentativo sopravvive alla chiusura del server.
|
|
1934
2157
|
if (closureRetry) { try { closureRetry.stop(); } catch (_) {} }
|
|
1935
|
-
importedDismissalDrainer.stop(); eventFeedClient.stop();
|
|
2158
|
+
importedDismissalDrainer.stop(); noticeDismissalDrainer.stop(); eventFeedClient.stop();
|
|
1936
2159
|
for (const timer of reverseWatchers.values()) clearInterval(timer);
|
|
1937
2160
|
reverseWatchers.clear(); rotatableReverse.clear(); void reverseSlotListeners?.closeAll();
|
|
1938
2161
|
// Il pannello non sopravvive al control plane: senza requireToken sopra,
|
package/lib/settings/routes.js
CHANGED
|
@@ -710,6 +710,12 @@ function settingsRoutes(deps = {}) {
|
|
|
710
710
|
if (!node) return send(res, 404, { error: `nodo sconosciuto "${name}"` });
|
|
711
711
|
if (node.direction !== 'outbound') return send(res, 409, { error: 'Share si attiva sul dispositivo che possiede la connessione SSH' });
|
|
712
712
|
if (!node.token || !node.nodeId) return send(res, 409, { error: 'nodo non associato: ripeti il pairing' });
|
|
713
|
+
// A host-only peer (publish-remote) is published ONE WAY to our own admin
|
|
714
|
+
// nodes; Share would publish THIS node on it. Refuse before any SSH restart
|
|
715
|
+
// or reverse negotiation is attempted (turning Share off stays possible).
|
|
716
|
+
if (node.publishRemote === true && body.shared) {
|
|
717
|
+
return send(res, 409, { error: 'publish-remote attivo su questo peer: Share resta spento (spegni prima publish-remote)', reason: 'publish-remote-active' });
|
|
718
|
+
}
|
|
713
719
|
if (body.shared && !nodesStore.isPort(node.reversePort)) {
|
|
714
720
|
return send(res, 409, { error: 'canale share non negoziato: ripeti il pairing' });
|
|
715
721
|
}
|
package/package.json
CHANGED