@mmmbuto/nexuscrew 0.9.30 → 0.9.32

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
@@ -87,7 +87,8 @@ function sanitizeReplyLabel(raw) {
87
87
  return String(raw === undefined || raw === null ? 'human' : raw)
88
88
  .replace(/[\x00-\x1f\x7f\]]/g, '').slice(0, 32) || 'human';
89
89
  }
90
- const { notifyRoutes } = require('./notify/routes.js');
90
+ const { notifyRoutes, createClosureFanout } = require('./notify/routes.js');
91
+ const { createClosureRetryQueue } = require('./notify/closure-retry.js');
91
92
  const { createNpmUpdater } = require('./update/manager.js');
92
93
  const { createDiagnostics } = require('./diagnostics/store.js');
93
94
  const { createDropCounter } = require('./ws/drop-counter.js');
@@ -200,11 +201,36 @@ function createServer(opts = {}) {
200
201
  // federated ask surface. The closure event flows through the notifier
201
202
  // wrapper so the feed gets a qualified ask-closed envelope.
202
203
  const askReceipts = createAskReceipts({ filePath: path.join(notifyDir, 'ask-receipts.json') });
204
+ // Recapito della chiusura di un ask verso i peer che ne hanno un alias.
205
+ // Assegnato piu' sotto, quando esistono dispatcher e topologia; l'hook lo
206
+ // legge a runtime, quindi la dichiarazione in avanti basta.
207
+ let closureFanout = null;
208
+ // Recapito recuperabile della chiusura (lato owner): la voce si crea dove
209
+ // esiste il fan-out, ma la coda serve prima, sulla lettura degli ask.
210
+ let closureRetry = null;
203
211
  const askAnswerService = createAskAnswerService({
204
212
  asks: asksStore,
205
213
  paste: cfg.pasteSeam || ((session, text) => pasteToSession(cfg.tmuxBin, session, text)),
206
214
  receipts: askReceipts,
207
- onClosure: (kind, info) => { try { notifier.emitRaw({ type: kind, id: info.askId }); } catch (_) {} },
215
+ // La chiusura esce da QUI: e' il punto in cui OGNI transizione autorevole
216
+ // passa — risposta e scarto, rotta locale e rotta federata. Emetterla dalle
217
+ // due route locali lasciava fuori proprio il caso normale, la risposta che
218
+ // arriva da un altro nodo. Il fan-out e' lo stesso dell'andata e NON parte
219
+ // per un ask importato: la chiusura di quello la decide il suo owner.
220
+ onClosure: (kind, info) => {
221
+ try { notifier.emitRaw({ type: kind, id: info.askId }); } catch (_) {}
222
+ try {
223
+ const ask = asksStore.get(info.askId);
224
+ if (ask && !ask.originNode && closureFanout) {
225
+ return closureFanout.dispatch({
226
+ askId: info.askId,
227
+ outcome: kind === 'ask-dismissed' ? 'dismissed' : 'answered',
228
+ session: (ask && ask.session) || info.cellSession,
229
+ });
230
+ }
231
+ } catch (_) {}
232
+ return null;
233
+ },
208
234
  labelPrefix: sanitizeReplyLabel(cfg && cfg.replyLabel),
209
235
  });
210
236
  const feedSignatures = new Map(); // peerNodeId -> grant signature last seen
@@ -1037,6 +1063,86 @@ function createServer(opts = {}) {
1037
1063
  topology: topology && Array.isArray(topology.nodes) ? topology.nodes : [],
1038
1064
  });
1039
1065
  };
1066
+ // UN SOLO dispatcher per le domande e per le chiusure: sono la stessa strada
1067
+ // percorsa in due momenti, e il recapito della chiusura deve usare lo stesso
1068
+ // instradamento dell'andata, non una copia con la sua topologia.
1069
+ const askDispatcher = createDispatcher({
1070
+ localNodeId: federatedNodeId,
1071
+ peers: federatedPeers,
1072
+ localPort: () => (server && server.address() ? server.address().port : cfg.port),
1073
+ localToken: () => tokenHolder.value,
1074
+ // 'no-delivery' (-): esito legittimo del target — richiesta accettata
1075
+ // ma nessun canale raggiunto (0 UI, 0 push). Senza questa voce forward()
1076
+ // lo degraderebbe a 'unknown/unreadable-endpoint-result', nascondendo
1077
+ // proprio il silenzio che l'esito esiste per rivelare.
1078
+ statuses: new Set(['delivered', 'no-delivery', 'refused', 'unreachable', 'unknown']),
1079
+ });
1080
+ const askPeerTargets = async () => {
1081
+ const list = await federatedPeers();
1082
+ return (Array.isArray(list) ? list : []).map((p) => p && (p.nodeId || p.instanceId)).filter(Boolean);
1083
+ };
1084
+ // Riconciliazione del RICEVENTE: per ogni alias ancora aperto si chiede
1085
+ // all'owner quali domande sono ancora aperte. Se l'alias non c'e' piu' la'
1086
+ // (l'owner ha risposto o scartato mentre questo nodo era spento), l'alias si
1087
+ // chiude qui. E' la via minima al recapito recuperabile: niente coda
1088
+ // persistente con backoff, un confronto con la fonte autorevole. Sotto il
1089
+ // tetto dello snapshot non si conclude nulla, perche' un'assenza potrebbe
1090
+ // essere un taglio della lista e non una chiusura.
1091
+ const SNAPSHOT_FULL = 100;
1092
+ async function reconcileImportedAsks() {
1093
+ const store = nodesStore.loadStore(nodesPath);
1094
+ if (!store) return;
1095
+ const open = asksStore.list({ open: true }).filter((a) => a.originNode && a.ownerId && a.ownerAskId);
1096
+ if (!open.length) return;
1097
+ const byOwner = new Map();
1098
+ for (const a of open) {
1099
+ if (!byOwner.has(a.ownerId)) byOwner.set(a.ownerId, []);
1100
+ byOwner.get(a.ownerId).push(a);
1101
+ }
1102
+ for (const [ownerId, aliases] of byOwner) {
1103
+ const peer = (store.nodes || []).find((n) => n && n.nodeId === ownerId && n.token && n.localPort);
1104
+ if (!peer) continue;
1105
+ let snap = null;
1106
+ try {
1107
+ const res = await fetch(`http://127.0.0.1:${peer.localPort}/federation/route/_/event-feed/snapshot`, {
1108
+ headers: { authorization: `Bearer ${peer.token}` }, signal: AbortSignal.timeout(3000),
1109
+ });
1110
+ if (!res.ok) continue;
1111
+ snap = await res.json();
1112
+ } catch (_) { continue; }
1113
+ const live = new Set((snap && Array.isArray(snap.asks) ? snap.asks : []).map((x) => String(x.id)));
1114
+ if (live.size >= SNAPSHOT_FULL) continue;
1115
+ for (const alias of aliases) {
1116
+ if (live.has(String(alias.ownerAskId))) continue;
1117
+ try {
1118
+ const closed = asksStore.closeImported({ ownerId, ownerAskId: alias.ownerAskId, outcome: 'dismissed' });
1119
+ if (closed.changed && closed.ask) {
1120
+ try { notifier.deliverOnlyRaw({ type: 'ask-dismissed', id: closed.ask.id, ownerId }); } catch (_) {}
1121
+ }
1122
+ } catch (_) { /* un alias non chiudibile non ferma gli altri */ }
1123
+ }
1124
+ }
1125
+ }
1126
+ // Assegnato ORA che dispatcher e topologia esistono (l'hook lo legge a runtime).
1127
+ closureRetry = createClosureRetryQueue({
1128
+ log: opts.log || console.log,
1129
+ // Il tentativo nasce DALLA coda: non si riaccoda (sarebbe autoalimentata).
1130
+ // `targets` e' l'insieme dei pendenti della voce: il ritentativo va SOLO
1131
+ // verso quelli. Senza, ogni ritentativo rimanderebbe la chiusura anche a chi
1132
+ // l'ha gia' ricevuta — rumore, non danno, ma nemmeno una coda che sa cosa
1133
+ // le manca.
1134
+ run: ({ askId, outcome, session, targets }) => (closureFanout
1135
+ ? closureFanout.dispatch({ askId, outcome, session, retryOnFailure: false, targets })
1136
+ : Promise.resolve([])),
1137
+ });
1138
+ closureFanout = createClosureFanout({
1139
+ dispatcher: askDispatcher,
1140
+ peerTargets: askPeerTargets,
1141
+ localNodeId: federatedNodeId,
1142
+ log: opts.log || console.log,
1143
+ retry: closureRetry,
1144
+ });
1145
+
1040
1146
  api.use(notifyRoutes({
1041
1147
  cfg,
1042
1148
  notifier,
@@ -1056,20 +1162,24 @@ function createServer(opts = {}) {
1056
1162
  hopSecret: () => hopSecret,
1057
1163
  }),
1058
1164
  acl: createAudioAcl({ nodesPath }),
1059
- dispatcher: createDispatcher({
1060
- localNodeId: federatedNodeId,
1061
- peers: federatedPeers,
1062
- localPort: () => (server && server.address() ? server.address().port : cfg.port),
1063
- localToken: () => tokenHolder.value,
1064
- // 'no-delivery' (-): esito legittimo del target — richiesta accettata
1065
- // ma nessun canale raggiunto (0 UI, 0 push). Senza questa voce forward()
1066
- // lo degraderebbe a 'unknown/unreadable-endpoint-result', nascondendo
1067
- // proprio il silenzio che l'esito esiste per rivelare.
1068
- statuses: new Set(['delivered', 'no-delivery', 'refused', 'unreachable', 'unknown']),
1069
- }),
1165
+ dispatcher: askDispatcher,
1166
+ closureFanout,
1167
+ peerTargets: askPeerTargets,
1168
+ reconcileImported: reconcileImportedAsks,
1169
+ closureRetry,
1070
1170
  federatedRate: createSpeakRateLimiter(),
1071
1171
  answerService: askAnswerService,
1072
1172
  receipts: askReceipts,
1173
+ // Elenco dei peer autorizzati per il fan-out di default della domanda
1174
+ // (nessun `target` → tutti i nodi dell'owner). Il
1175
+ // dispatcher resta a target esatto: l'enumerazione la fa la route.
1176
+ peerTargets: async () => {
1177
+ const list = await federatedPeers();
1178
+ return (Array.isArray(list) ? list : [])
1179
+ .map((p) => p && (p.nodeId || p.instanceId))
1180
+ .filter(Boolean);
1181
+ },
1182
+ log: opts.log || console.log,
1073
1183
  }));
1074
1184
  // Event feed: the closed SSE/JSON surface, mounted inside the
1075
1185
  // authenticated API. Its own gate demands a proven federated origin and an
@@ -1113,7 +1223,13 @@ function createServer(opts = {}) {
1113
1223
  loadStore: () => nodesStore.loadStore(nodesPath),
1114
1224
  log: opts.log || console.log,
1115
1225
  });
1116
- api.post('/asks-relay', async (req, res) => {
1226
+ // Il body va parsato QUI: il router `api` non monta un parser globale, ogni
1227
+ // rotta porta il suo (`express.json`) — vedi /sessions, /notify, /asks.
1228
+ // Senza, `req.body` e' undefined, il relay legge un body vuoto e risponde
1229
+ // 400 «ownerId, askId e text richiesti» a OGNI richiesta: la risposta
1230
+ // federata non ha mai potuto attraversare, e nessun test copriva questa
1231
+ // rotta. Difetto preesistente, trovato scrivendo il test del ritorno federato.
1232
+ api.post('/asks-relay', express.json({ limit: '8kb' }), async (req, res) => {
1117
1233
  try {
1118
1234
  const b = req.body || {};
1119
1235
  if (b.action === 'dismiss') {
@@ -1500,6 +1616,8 @@ function createServer(opts = {}) {
1500
1616
  server.on('close', () => {
1501
1617
  diagnostics.record('info', 'server', 'SERVER_STOPPED', 'NexusCrew server stopped', { reason: 'close' });
1502
1618
  watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); ptyGrace.close();
1619
+ // Nessun timer di ritentativo sopravvive alla chiusura del server.
1620
+ if (closureRetry) { try { closureRetry.stop(); } catch (_) {} }
1503
1621
  for (const timer of reverseWatchers.values()) clearInterval(timer);
1504
1622
  reverseWatchers.clear(); rotatableReverse.clear(); void reverseSlotListeners?.closeAll();
1505
1623
  // 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.30",
3
+ "version": "0.9.32",
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": {