@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.
@@ -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
- // Il frame porta l'id LOCALE: e' quello con cui questa UI identifica
485
- // la card, e senza di esso la card resterebbe a schermo.
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 { res.json(await askRelay.replyCapability({ ownerId, askId })); }
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.58",
3
+ "version": "0.9.59",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {