@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/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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.64",
3
+ "version": "0.9.65",
4
4
  "description": "Faithful browser tmux client \u2014 attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {