@mmmbuto/nexuscrew 0.9.31 → 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.
@@ -19,6 +19,10 @@
19
19
  // niente Invio, control char rifiutati): qui si sanifica il testo PRIMA.
20
20
  const express = require('express');
21
21
  const { createAskAnswerService } = require('./ask-answer-service.js');
22
+ // Gli insiemi degli esiti vivono nella coda: il fan-out decide COSA accodare con
23
+ // la stessa definizione con cui la coda decide cosa ritentare. Due copie della
24
+ // stessa regola sono due regole che prima o poi divergono.
25
+ const { CLOSURE_DONE_STATUSES, CLOSURE_FINAL_STATUSES } = require('./closure-retry.js');
22
26
  const { isValidSession } = require('../files/store.js');
23
27
  const { normalizeNotificationLang } = require('./language.js');
24
28
  const { HOP_HEADER } = require('../proxy/hop-proof.js');
@@ -34,7 +38,12 @@ const NOTIFY_KEYS = new Set(['title', 'body', 'urgency', 'session', 'lang', 'tar
34
38
  // Chiavi accettate SOLO su un ingresso federato provato: le mette il
35
39
  // dispatcher del nodo di origine, non un chiamante locale.
36
40
  const FEDERATED_KEYS = new Set(['originCell', 'originNode']);
37
- const ASK_KEYS = new Set(['question', 'options', 'session']);
41
+ const ASK_KEYS = new Set(['question', 'options', 'session', 'target']);
42
+ // Chiavi accettate SOLO su un ingresso federato provato: le mette il dispatcher
43
+ // del nodo di origine, non un chiamante locale (stessa regola di FEDERATED_KEYS).
44
+ // `ownerNode`/`originNode` qualificano l'identita', `askId` e' l'id con cui
45
+ // l'owner conosce la domanda (serve alla risposta per tornare sul bersaglio).
46
+ const FEDERATED_ASK_KEYS = new Set(['askId', 'ownerNode', 'originNode', 'originCell', 'closeOutcome']);
38
47
  const RATE_MAX = 6;
39
48
  const RATE_WINDOW_MS = 60 * 1000;
40
49
  const RATE_MAX_BUCKETS = 64;
@@ -103,6 +112,84 @@ function replyLabel(cfg) {
103
112
  return clean || 'human';
104
113
  }
105
114
 
115
+ // Destinatari di una domanda: il `target` esplicito se c'e', altrimenti TUTTI i
116
+ // peer autorizzati di questo nodo. L'enumerazione sta qui e non nel dispatcher
117
+ // perche' il dispatcher conosce solo il target esatto: una wildcard implicita
118
+ // sarebbe un modo per parlare a chi non si e' scelto.
119
+ async function resolveFanTargets(peerTargets, target, self) {
120
+ if (target !== undefined) return target === self ? [] : [String(target)];
121
+ if (!peerTargets) return [];
122
+ let list;
123
+ try { list = await peerTargets(); } catch (_) { return []; }
124
+ if (!Array.isArray(list)) return [];
125
+ const seen = new Set();
126
+ const out = [];
127
+ for (const raw of list) {
128
+ const id = raw && typeof raw === 'object' ? (raw.nodeId || raw.instanceId) : raw;
129
+ const clean = String(id || '');
130
+ if (!TARGET_RE.test(clean)) continue;
131
+ if (self && clean === self) continue;
132
+ if (seen.has(clean)) continue;
133
+ seen.add(clean); out.push(clean);
134
+ }
135
+ return out;
136
+ }
137
+
138
+ // La CHIUSURA di un ask segue la stessa strada dell'andata: chi ha ricevuto la
139
+ // domanda deve sapere che e' stata chiusa, altrimenti il suo alias resta aperto
140
+ // e ricompare a ogni reload. Vive come funzione a se' — non su una rotta —
141
+ // perche' deve partire dal punto in cui la transizione e' AUTOREVOLE (il
142
+ // servizio: risposta o scarto, locale o federata) e non da una delle sue porte:
143
+ // legarla alle due route locali lasciava fuori la via federata, che e' il caso
144
+ // normale quando a rispondere e' un altro nodo.
145
+ function createClosureFanout({
146
+ dispatcher = null, peerTargets = null, localNodeId = () => null, log = () => {},
147
+ // Recapito RECUPERABILE: un peer spento in questo istante non e' una chiusura
148
+ // persa. La consegna fallita entra nella coda di ritentativi (lato owner,
149
+ // l'unico lato che puo' riprovare: il ricevente non ha rotta verso l'owner).
150
+ retry = null,
151
+ } = {}) {
152
+ async function dispatch({ askId, outcome, session, retryOnFailure = true, targets = null }) {
153
+ if (!dispatcher) return [];
154
+ const self = localNodeId();
155
+ // `targets` esplicito = ritentativo: si va SOLO verso i pendenti, non di
156
+ // nuovo verso tutti. Senza, si enumerano i peer autorizzati come sempre.
157
+ const targets2 = Array.isArray(targets) && targets.length
158
+ ? targets
159
+ : await resolveFanTargets(peerTargets, undefined, self);
160
+ const out = [];
161
+ for (const target of targets2) {
162
+ try {
163
+ const r = await dispatcher.dispatch({
164
+ resource: '/asks',
165
+ target,
166
+ origin: { node: self, cell: session || 'unknown' },
167
+ payload: { askId, closeOutcome: outcome, ownerNode: self },
168
+ });
169
+ out.push({ target, status: r && r.status, ...(r && r.reason ? { reason: r.reason } : {}) });
170
+ } catch (e) {
171
+ out.push({ target, status: 'unknown', reason: 'dispatch-threw' });
172
+ try { log(`chiusura ask ${askId} verso ${target} fallita: ${String(e && e.message || e)}`); } catch (_) {}
173
+ }
174
+ }
175
+ // Si accoda ogni chiusura con recapito PARZIALE, con i SOLI target pendenti.
176
+ // Prima si accodava solo se NESSUNO aveva ricevuto: bastava che un peer
177
+ // rispondesse perche' il peer spento sparisse dalla coda — e la sua copia
178
+ // restava aperta per sempre, perche' da quel lato non c'e' modo di
179
+ // rimediare (il ricevente non ha rotta verso l'owner).
180
+ // Il tentativo nato DALLA coda non si riaccoda (sarebbe autoalimentata): la
181
+ // coda aggiorna da se' il proprio insieme con gli esiti che riceve.
182
+ if (retryOnFailure && retry && out.length) {
183
+ const pendenti = out
184
+ .filter((r) => !CLOSURE_DONE_STATUSES.has(r.status) && !CLOSURE_FINAL_STATUSES.has(r.status))
185
+ .map((r) => r.target);
186
+ try { retry.enqueue({ askId, outcome, session, targets: pendenti }); } catch (_) {}
187
+ }
188
+ return out;
189
+ }
190
+ return { dispatch };
191
+ }
192
+
106
193
  function notifyRoutes({
107
194
  cfg, notifier, push, asks, paste, sessionExists,
108
195
  fleetP = null, instanceId = null, identityMode = 'legacy',
@@ -110,8 +197,19 @@ function notifyRoutes({
110
197
  // route resta esattamente quella locale di prima: nessun percorso nuovo si
111
198
  // apre per omissione.
112
199
  localNodeId = () => null, originResolver = null, acl = null, dispatcher = null,
200
+ // Elenco dei peer autorizzati di questo nodo, per il fan-out di default.
201
+ peerTargets = null,
113
202
  federatedRate = null,
114
- answerService = null, receipts = null,
203
+ answerService = null, receipts = null, log = () => {},
204
+ // Recapito della chiusura: iniettato dal server perche' e' lo STESSO oggetto
205
+ // che il servizio usa nell'hook di transizione (una sola implementazione).
206
+ closureFanout: closureFanoutDep = null,
207
+ // Riconciliazione degli alias importati, iniettata dal server: chiede
208
+ // all'owner lo stato delle domande ancora aperte per questa copia.
209
+ reconcileImported = null,
210
+ // Coda dei recapiti di chiusura non riusciti (lato owner). Iniettata dal
211
+ // server: la stessa che il fan-out alimenta.
212
+ closureRetry = null,
115
213
  }) {
116
214
  // The shared answer cycle is built from the local deps when the caller does
117
215
  // not inject one: the local route and the federated surface must never drift
@@ -279,6 +377,16 @@ function notifyRoutes({
279
377
  });
280
378
 
281
379
  // --- asks ------------------------------------------------------------------
380
+ // Destinatari di una domanda (nessun `target` = tutti i nodi dell'owner): il
381
+ // `target` esplicito se
382
+ // c'e', altrimenti TUTTI i peer autorizzati di questo nodo — una domanda e'
383
+ // per l'utente, ovunque sia. L'enumerazione sta QUI e non nel dispatcher
384
+ // perche' il dispatcher non conosce i broadcast: target esatto soltanto, e
385
+ // deve restare cosi' (una wildcard implicita e' un modo per parlare a chi non
386
+ // si e' scelto).
387
+ const askFanTargets = (target, self) => resolveFanTargets(peerTargets, target, self);
388
+ const closureFanout = closureFanoutDep || createClosureFanout({ dispatcher, peerTargets, localNodeId, log, retry: closureRetry });
389
+
282
390
  // da revisione: gated READONLY (mutGate) — crea stato durevole (asks.json) e domande
283
391
  // che lo stesso server vieterebbe di rispondere. da revisione: rate-limit creazione
284
392
  // (globale per token + per sessione) + cap duro dello store -> 429.
@@ -288,25 +396,160 @@ function notifyRoutes({
288
396
  if (!b || typeof b !== 'object' || Array.isArray(b)) {
289
397
  return res.status(400).json({ error: 'body deve essere un oggetto JSON' });
290
398
  }
399
+ // Come per /notify: un ingresso e' federato solo se porta la prova di hop,
400
+ // e si stabilisce PRIMA di guardare il body — quali chiavi sono lecite
401
+ // dipende da come la richiesta e' arrivata, non da cosa dichiara.
402
+ const federated = !!(originResolver && req.headers && req.headers[HOP_HEADER]);
291
403
  for (const k of Object.keys(b)) {
292
- if (!ASK_KEYS.has(k)) return res.status(400).json({ error: `chiave non ammessa: "${k}" (schema: question, options?, session)` });
404
+ if (ASK_KEYS.has(k)) continue;
405
+ if (federated && FEDERATED_ASK_KEYS.has(k)) continue;
406
+ return res.status(400).json({ error: `chiave non ammessa: "${k}" (schema: question, options?, session, target?)` });
293
407
  }
408
+ if (b.target !== undefined && !TARGET_RE.test(String(b.target))) {
409
+ return res.status(400).json({ error: 'target deve essere un instanceId di nodo' });
410
+ }
411
+ // Validazione del contenuto PRIMA del rate check: gli input invalidi (400)
412
+ // non consumano budget; il rate scatta solo su richieste ben formate.
413
+ // Una CHIUSURA non porta domanda: non c'e' contenuto da validare.
414
+ const isClosure = b.closeOutcome === 'dismissed' || b.closeOutcome === 'answered';
415
+ if (!isClosure) {
416
+ const v = asks.validate({ question: b.question, options: b.options });
417
+ if (!v.ok) return res.status(400).json({ error: v.error });
418
+ }
419
+ const self = localNodeId();
420
+
421
+ // --- ingresso FEDERATO: la domanda arriva da un altro nodo -------------
422
+ if (federated) {
423
+ const resolved = await originResolver.resolve(req, { requireCell: true });
424
+ if (!resolved.ok) return res.status(403).json({ status: 'refused', reason: resolved.reason });
425
+ // Il target e' esatto e va confermato QUI: una route puo' consegnare a
426
+ // un nodo diverso da quello che il mittente credeva.
427
+ if (!self || b.target !== self) {
428
+ return res.status(404).json({ status: 'refused', reason: 'wrong-target' });
429
+ }
430
+ const verdict = acl ? acl.allows(resolved) : { allowed: false, reason: 'acl-unavailable' };
431
+ if (!verdict.allowed) return res.status(403).json({ status: 'refused', reason: verdict.reason });
432
+ // Budget SEPARATO da quello locale: senza, un peer rumoroso affamerebbe
433
+ // le domande delle celle di casa, che condividono lo stesso bucket.
434
+ // Una CHIUSURA non consuma quella quota: non e' una domanda nuova, e
435
+ // far pagare anche a lei il budget di creazione significa che un burst
436
+ // di domande legittimo (6 in un minuto, ammesso dal prodotto) lascia
437
+ // gli alias aperti sui peer — il successo locale nasconderebbe lo stato
438
+ // falso. Le chiusure sono limitate dal proprio percorso di recapito.
439
+ if (federatedRate && !isClosure) {
440
+ const quota = federatedRate.check({ origin: resolved.origin, target: self, urgency: 'high' });
441
+ if (!quota.allowed) {
442
+ return res.status(429).json({ status: 'refused', reason: `rate-${quota.bucket}` });
443
+ }
444
+ }
445
+ // --- chiusura di un ask importato: l'owner ha risposto o scartato ---
446
+ // L'alias locale va chiuso DUREVOLMENTE: senza, la domanda resta aperta
447
+ // qui e ricompare a ogni reload, e resterebbe pure risponibile su una
448
+ // cella che non e' la sua.
449
+ if (isClosure) {
450
+ const owner = resolved.origin.node;
451
+ const closed = asks.closeImported({
452
+ ownerId: owner, ownerAskId: b.askId, outcome: b.closeOutcome,
453
+ });
454
+ if (closed.changed && closed.ask) {
455
+ // Il frame porta l'id LOCALE: e' quello con cui questa UI identifica
456
+ // la card, e senza di esso la card resterebbe a schermo.
457
+ notifier.deliverOnlyRaw({
458
+ type: b.closeOutcome === 'dismissed' ? 'ask-dismissed' : 'ask-answered',
459
+ id: closed.ask.id,
460
+ ownerId: owner,
461
+ });
462
+ }
463
+ return res.json({ status: 'delivered', closed: closed.changed });
464
+ }
465
+ // La `session` dichiarata NON e' verificabile qui — la cella vive sul
466
+ // nodo di origine, e `sessionExists` guarderebbe il tmux di QUESTO nodo.
467
+ // Non si pretende quindi una sessione locale: si registra per
468
+ // attribuzione, e il paste avverra' sulla cella dell'owner via ask-relay.
469
+ const owner = resolved.origin.node;
470
+ const out = asks.create({
471
+ question: b.question,
472
+ options: b.options,
473
+ session: b.session || resolved.origin.cell || 'unknown',
474
+ // L'ask e' di un ALTRO nodo: `ownerId` e' quello che fa instradare la
475
+ // risposta al proprietario invece di incollarla qui.
476
+ ownerId: owner,
477
+ // L'id con cui l'OWNER conosce la domanda: e' quello che la risposta
478
+ // deve citare. Il nostro `id` locale resta nostro.
479
+ ownerAskId: b.askId,
480
+ originNode: owner,
481
+ originCell: resolved.origin.cell,
482
+ });
483
+ if (!out.ok) {
484
+ return res.status(out.reason === 'cap' ? 429 : 400).json({ status: 'refused', reason: out.reason, error: out.error });
485
+ }
486
+ const ask = out.ask;
487
+ // deliverOnly: consegna locale alla UI, NIENTE pubblicazione sul feed e
488
+ // niente ri-esportazione. E' l'invariante che uccide il loop A->B->A per
489
+ // costruzione — un ask importato non torna mai indietro, esattamente
490
+ // come una notify federata.
491
+ notifier.deliverOnlyRaw({ type: 'ask', ask });
492
+ await notifier.deliverOnly({
493
+ title: `domanda da ${ask.session}`,
494
+ body: ask.question,
495
+ urgency: 'high',
496
+ session: ask.session,
497
+ lang: 'it',
498
+ askId: ask.id,
499
+ url: `/#ask=${ask.id}`,
500
+ });
501
+ return res.json({ status: 'delivered', id: ask.id, ownerId: ask.ownerId });
502
+ }
503
+
504
+ // --- domanda locale ----------------------------------------------------
294
505
  // session obbligatoria E viva: la risposta va incollata li' — un ask senza
295
506
  // recapito verificabile e' fail-closed subito, non al momento dell'answer.
296
507
  if (!isValidSession(b.session)) return res.status(400).json({ error: 'session non valida' });
297
508
  if (!sessionExists(b.session)) return res.status(404).json({ error: 'sessione tmux inesistente' });
298
- // Validazione del contenuto PRIMA del rate check: gli input invalidi (400)
299
- // non consumano budget; il rate scatta solo su richieste ben formate.
300
- const v = asks.validate({ question: b.question, options: b.options });
301
- if (!v.ok) return res.status(400).json({ error: v.error });
302
509
  const binding = await guardBinding(req, b.session);
303
510
  if (binding instanceof Error) return bindingRejected(res, binding);
304
511
  if (!allowAsk(b.session)) {
305
512
  return res.status(429).json({ error: 'rate limit ask superato (limite globale per token + per sessione)' });
306
513
  }
514
+ // NB: un ask LOCALE non porta `ownerId`. Il campo significa «questa
515
+ // domanda appartiene a un altro nodo», ed e' quello che fa scegliere alla
516
+ // UI il ritorno federato (ask-relay) invece del paste locale: metterlo
517
+ // anche qui manderebbe la risposta di casa a cercare un owner inesistente.
307
518
  const out = asks.create({ question: b.question, options: b.options, session: b.session });
308
519
  if (!out.ok) return res.status(out.reason === 'cap' ? 429 : 400).json({ error: out.error });
309
520
  const ask = out.ask;
521
+ // FAN-OUT **prima** dell'emissione locale. Un peer irraggiungibile e'
522
+ // un esito da riportare, mai un blocco: la domanda deve nascere su questo
523
+ // nodo comunque, e il chiamante vede chi ha accettato e chi no.
524
+ const targets = dispatcher ? await askFanTargets(b.target, self) : [];
525
+ const fanout = [];
526
+ for (const target of targets) {
527
+ try {
528
+ const r = await dispatcher.dispatch({
529
+ resource: '/asks',
530
+ target,
531
+ // La cella di origine e' quella DICHIARATA dal chiamante locale:
532
+ // viaggia come attestazione, e il target la trattera' come tale.
533
+ origin: { node: self, cell: b.session },
534
+ payload: {
535
+ question: ask.question,
536
+ ...(ask.options ? { options: ask.options } : {}),
537
+ session: ask.session,
538
+ askId: ask.id,
539
+ ownerNode: self,
540
+ },
541
+ });
542
+ fanout.push({ target, status: r && r.status, ...(r && r.reason ? { reason: r.reason } : {}) });
543
+ } catch (e) {
544
+ fanout.push({ target, status: 'unknown', reason: 'dispatch-threw' });
545
+ try { log(`ask fan-out verso ${target} fallito: ${String(e && e.message || e)}`); } catch (_) {}
546
+ }
547
+ }
548
+ if (fanout.length) {
549
+ try {
550
+ log(`ask ${ask.id}: fan-out verso ${fanout.length} peer → ${fanout.map((f) => `${f.target.slice(0, 8)}:${f.status}`).join(', ')}`);
551
+ } catch (_) {}
552
+ }
310
553
  // Frame dedicato per le UI aperte (card/badge live, senza aspettare il poll)…
311
554
  notifier.emitRaw({ type: 'ask', ask });
312
555
  // …e ogni ask emette anche notify (UI+push, urgency high) con deep-link.
@@ -319,13 +562,26 @@ function notifyRoutes({
319
562
  askId: ask.id,
320
563
  url: `/#ask=${ask.id}`,
321
564
  });
322
- res.status(201).json({ id: ask.id });
565
+ res.status(201).json({ id: ask.id, ...(fanout.length ? { fanout } : {}) });
323
566
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
324
567
  });
325
568
 
326
- r.get('/asks', (req, res) => {
327
- try { res.json({ asks: asks.list({ open: String(req.query.open || '') === '1' }) }); }
328
- catch (e) { res.status(500).json({ error: String(e.message || e) }); }
569
+ r.get('/asks', async (req, res) => {
570
+ try {
571
+ // Riconciliazione del ricevente: prima di servire l'elenco si chiede
572
+ // all'owner lo stato degli alias aperti. E' il percorso che recupera una
573
+ // chiusura mai recapitata (peer spento mentre l'owner chiudeva): senza,
574
+ // l'alias resta aperto per sempre e la card mente. Best-effort: un owner
575
+ // irraggiungibile non chiude nulla e non fa fallire la lettura.
576
+ if (reconcileImported) { try { await reconcileImported(); } catch (_) {} }
577
+ // Recapito recuperabile, lato OWNER: i peer che erano spenti quando la
578
+ // chiusura e' partita vengono ritentati ADESSO. Chi legge lo stato sta
579
+ // guardando le card, ed e' esattamente il momento in cui una card
580
+ // rimasta aperta per un recapito fallito va rimessa in pari. Best-effort:
581
+ // un peer ancora irraggiungibile resta in coda, non fa fallire la lettura.
582
+ if (closureRetry) { try { await closureRetry.drain('read'); } catch (_) {} }
583
+ res.json({ asks: asks.list({ open: String(req.query.open || '') === '1' }) });
584
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
329
585
  });
330
586
 
331
587
  // Dismiss (scarta domanda): NON cancella la riga, la marca `dismissed` (lo
@@ -349,6 +605,10 @@ function notifyRoutes({
349
605
  return res.status(500).json({ error: 'dismiss non riuscito' });
350
606
  }
351
607
  notifier.emitRaw({ type: 'ask-dismissed', id });
608
+ // La chiusura nasce nel SERVIZIO (punto comune di transizione); qui si
609
+ // aspetta il suo recapito, cosi' la risposta non precede la chiusura sui
610
+ // peer e un burst di scarti non lascia alias aperti.
611
+ if (out.closure) { try { await out.closure; } catch (_) {} }
352
612
  res.json({ dismissed: true, id });
353
613
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
354
614
  });
@@ -388,6 +648,8 @@ function notifyRoutes({
388
648
  // closure event is emitted by the service, not here.
389
649
  const out = await askService.answerLocal({ askId: id, text });
390
650
  if (!out.ok) return res.status(out.code || 500).json({ error: out.error });
651
+ // Come per il dismiss: la chiusura nasce nel servizio, qui si aspetta.
652
+ if (out.closure) { try { await out.closure; } catch (_) {} }
391
653
  res.json({ answered: true, id });
392
654
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
393
655
  });
@@ -443,4 +705,4 @@ function notifyRoutes({
443
705
  return r;
444
706
  }
445
707
 
446
- module.exports = { notifyRoutes, createRateLimiter, sanitizePasteText, replyLabel };
708
+ module.exports = { notifyRoutes, createRateLimiter, sanitizePasteText, replyLabel, createClosureFanout, resolveFanTargets };
@@ -181,6 +181,10 @@ function knownResource(resource) {
181
181
  // defers an execution, this one shows text and executes nothing.
182
182
  // non esegue nulla. `/audio/speak` esce da un altoparlante in una stanza
183
183
  // fisica ed e' gia' federato: una notifica e' meno invasiva di cosi'.
184
+ // La DOMANDA federata (nc_ask): stessa classe operatore di /notify — mostra
185
+ // testo sui nodi dell'owner e non esegue nulla. La risposta torna per la
186
+ // write-back degli ask, che era gia' federata.
187
+ || resource === '/asks'
184
188
  || resource === '/notify'
185
189
  || resource === '/audio/capability'
186
190
  || resource === '/audio/speak'
@@ -290,9 +294,17 @@ function allowedResource(resource, method = 'GET') {
290
294
  // mutazione ai fini di READONLY (sotto): un nodo dichiarato di sola lettura
291
295
  // non emette richieste autenticate su comando di un peer.
292
296
  if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) return method === 'POST';
293
- // Notify: solo POST. `/events`, `/asks` e `/push/*` restano NON federati —
294
- // SSE non puo' autenticarsi col Bearer e un ask ha un canale di ritorno che
295
- // e' un paste nel tmux locale, quindi non attraverserebbe comunque.
297
+ // Notify: solo POST. `/events` e `/push/*` restano NON federati — SSE non
298
+ // puo' autenticarsi col Bearer.
299
+ //
300
+ // La DOMANDA (`/asks`) invece ORA attraversa, e per la stessa ragione per cui
301
+ // attraversa /notify: e' testo che appare sulla UI dell'owner, non
302
+ // un'esecuzione. La obiezione di prima — «un ask ha un canale di ritorno che
303
+ // e' un paste nel tmux locale, quindi non attraverserebbe comunque» — valeva
304
+ // per la RISPOSTA, non per la domanda: il ritorno era gia' federato
305
+ // (`ask-relay.js` → `/event-feed/asks/<id>/answer`). A mancare era solo
306
+ // l'andata, ed e' quella che questa voce apre. POST e basta: la GET resta
307
+ // locale, perche' lo snapshot degli ask e' autorevole solo sul proprio nodo.
296
308
  // Event feed: GET only, no mutation, no upgrade.
297
309
  if (resource === '/event-feed' || resource === '/event-feed/snapshot') return method === 'GET';
298
310
  if (/^\/event-feed\/[A-Za-z0-9._-]{1,32}$/.test(resource)) return method === 'GET';
@@ -300,6 +312,7 @@ function allowedResource(resource, method = 'GET') {
300
312
  if (/^\/event-feed\/asks\/[a-f0-9]{8}\/answer$/.test(resource)) return method === 'POST';
301
313
  if (/^\/event-feed\/asks\/[a-f0-9]{8}$/.test(resource)) return method === 'DELETE';
302
314
  if (/^\/event-feed\/asks\/[a-f0-9]{8}\/requests\/[0-9a-f-]{16,64}$/.test(resource)) return method === 'GET';
315
+ if (resource === '/asks') return method === 'POST';
303
316
  if (resource === '/notify') return method === 'POST';
304
317
  // Audio: only capability read, speak and stop are exposed through Hydra.
305
318
  // audio.consent is a LOCAL mutation and MUST stay unreachable federated.
@@ -107,6 +107,12 @@ function classifyResource(resource, method = 'GET') {
107
107
  if (resource === '/diagnostics/logs') return m === 'GET' || m === 'DELETE' ? 'operator' : null;
108
108
  if (resource === '/diagnostics/verbose') return m === 'PATCH' ? 'operator' : null;
109
109
  if (resource === '/ws') return m === 'GET' ? 'operator' : null;
110
+ // La DOMANDA federata (nc_ask): `/asks` e' la superficie operatore come
111
+ // `/notify` — mostra testo sui nodi dell'owner e non esegue nulla. Solo POST:
112
+ // la GET resta locale, lo snapshot degli ask e' autorevole solo sul nodo che
113
+ // li possiede. NB: non va confusa con `^/asks/<cellId>/(answer|dismiss|status)$`
114
+ // qui sopra, che e' la write-back verso la CELLA e ha un'altra classe.
115
+ if (resource === '/asks') return m === 'POST' ? 'operator' : null;
110
116
  if (resource === '/notify') return m === 'POST' ? 'operator' : null;
111
117
  if (resource === '/audio/capability') return m === 'GET' ? 'operator' : null;
112
118
  if (resource === '/audio/speak') return m === 'POST' ? 'operator' : null;
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.31",
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": {