@mmmbuto/nexuscrew 0.9.31 → 0.9.33

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.
@@ -0,0 +1,201 @@
1
+ 'use strict';
2
+ // Coda di RITENTATIVI della chiusura di un ask, lato OWNER.
3
+ //
4
+ // Il problema che risolve: la chiusura di una domanda segue la stessa strada
5
+ // dell'andata, ma il destinatario puo' essere spento in quel momento. Il suo
6
+ // alias locale resta allora aperto — e ricompare a ogni reload — perche' la
7
+ // transizione e' avvenuta altrove. Il ricevente NON puo' rimediare da solo:
8
+ // nella topologia in cui l'owner si e' collegato a lui (peer `inbound`) non ha
9
+ // ne' rotta ne' credenziale per raggiungerlo. L'unico lato che puo' riprovare
10
+ // e' chi ha emesso la transizione.
11
+ //
12
+ // La coda e' DELIBERATAMENTE piccola e limitata su tre assi indipendenti:
13
+ // - numero di voci (tetto duro, si scarta la piu' vecchia);
14
+ // - tentativi per voce (backoff esponenziale, tetto esplicito);
15
+ // - eta' della voce (TTL: oltre, si rinuncia).
16
+ // Ogni timer e' `unref()`: una coda in attesa non tiene vivo il processo.
17
+ //
18
+ // Due modi per far ripartire un tentativo:
19
+ // 1) il timer di backoff (il caso normale, nessuno guarda);
20
+ // 2) una LETTURA locale dell'elenco degli ask: e' il momento in cui qualcuno
21
+ // sta guardando lo stato, quindi e' il momento naturale per riconciliare
22
+ // il recapito. Senza questa seconda via una coda con base di 1 s non
23
+ // coprirebbe mai una finestra di riavvio del peer di pochi millisecondi,
24
+ // e il backoff non deve essere accorciato fino a diventare un busy loop.
25
+
26
+ const BASE_MS = 1000; // primo ritardo fra i tentativi
27
+ const FACTOR = 2; // crescita esponenziale
28
+ const MAX_ATTEMPTS = 6; // tentativi oltre al primo dispatch
29
+ const TTL_MS = 5 * 60 * 1000; // oltre questa eta' si rinuncia
30
+ const MAX_ENTRIES = 256; // tetto duro sulle voci in coda
31
+ const NUDGE_FLOOR_MS = 500; // distanza minima fra due risvegli da lettura
32
+
33
+ // Esiti che NON meritano un ritentativo: il peer ha risposto, la chiusura e'
34
+ // arrivata (una seconda consegna della stessa chiusura e' un no-op sul peer,
35
+ // che risponde `delivered` con `closed:false`), oppure ha rifiutato — e un
36
+ // rifiuto non cambia da solo col tempo.
37
+ const DONE_STATUSES = new Set(['delivered', 'no-delivery']);
38
+ const FINAL_STATUSES = new Set(['refused']);
39
+
40
+ function createClosureRetryQueue({
41
+ run, // async ({askId, outcome, session}) -> [{target, status, reason}]
42
+ now = () => Date.now(),
43
+ setTimer = setTimeout,
44
+ clearTimer = clearTimeout,
45
+ baseMs = BASE_MS,
46
+ factor = FACTOR,
47
+ maxAttempts = MAX_ATTEMPTS,
48
+ ttlMs = TTL_MS,
49
+ maxEntries = MAX_ENTRIES,
50
+ nudgeFloorMs = NUDGE_FLOOR_MS,
51
+ log = () => {},
52
+ } = {}) {
53
+ if (typeof run !== 'function') throw new Error('createClosureRetryQueue: run richiesta');
54
+ const entries = [];
55
+ let stopped = false;
56
+ let lastNudge = null;
57
+
58
+ function clearEntry(entry) {
59
+ if (entry.timer) { try { clearTimer(entry.timer); } catch (_) {} entry.timer = null; }
60
+ }
61
+
62
+ function remove(entry) {
63
+ clearEntry(entry);
64
+ const i = entries.indexOf(entry);
65
+ if (i >= 0) entries.splice(i, 1);
66
+ }
67
+
68
+ // Ritardo del prossimo tentativo: esponenziale, con tetto sul TTL.
69
+ function schedule(entry) {
70
+ if (stopped) return;
71
+ const delay = baseMs * Math.pow(factor, entry.attempts);
72
+ entry.nextAt = now() + delay;
73
+ entry.timer = setTimer(() => { attempt(entry, 'backoff'); }, delay);
74
+ // Non blocca la chiusura del processo: una coda in attesa e' solo una coda.
75
+ if (entry.timer && typeof entry.timer.unref === 'function') entry.timer.unref();
76
+ }
77
+
78
+ function expired(entry) {
79
+ return entry.attempts >= maxAttempts || (now() - entry.createdAt) >= ttlMs;
80
+ }
81
+
82
+ async function attempt(entry, why = 'backoff') {
83
+ if (stopped || entry.inFlight) return;
84
+ clearEntry(entry);
85
+ if (expired(entry)) {
86
+ remove(entry);
87
+ try { log(`chiusura ask ${entry.askId}: rinuncio dopo ${entry.attempts} tentativi (${why})`); } catch (_) {}
88
+ return;
89
+ }
90
+ entry.inFlight = true;
91
+ let results = [];
92
+ // Si ritenta SOLO verso i target ancora pendenti: chi ha gia' risposto non
93
+ // riceve una seconda consegna inutile.
94
+ const targets = [...entry.targets];
95
+ try {
96
+ results = await run({ askId: entry.askId, outcome: entry.outcome, session: entry.session, targets });
97
+ } catch (e) {
98
+ results = targets.map((target) => ({ target, status: 'unknown', reason: 'dispatch-threw' }));
99
+ try { log(`chiusura ask ${entry.askId}: tentativo fallito (${String(e && e.message || e)})`); } catch (_) {}
100
+ } finally {
101
+ entry.inFlight = false;
102
+ }
103
+ const list = Array.isArray(results) ? results : [];
104
+ // IL SET SI AGGIORNA QUI, dal lato della coda: ogni target che ha risposto
105
+ // esce. `delivered` e `no-delivery` sono consegne, `refused` e' un rifiuto —
106
+ // in tutti e tre i casi riprovare non aggiunge nulla. Restano i pendenti, e
107
+ // la voce si chiude quando non ne resta nessuno.
108
+ for (const r of list) {
109
+ if (!r || !r.target) continue;
110
+ if (DONE_STATUSES.has(r.status) || FINAL_STATUSES.has(r.status)) entry.targets.delete(r.target);
111
+ }
112
+ const refused = list.filter((r) => r && FINAL_STATUSES.has(r.status));
113
+ if (refused.length) {
114
+ try { log(`chiusura ask ${entry.askId}: rifiutata (${refused.map((r) => r.reason || r.status).join(',')})`); } catch (_) {}
115
+ }
116
+ if (!entry.targets.size) { remove(entry); return; }
117
+ entry.attempts += 1;
118
+ if (expired(entry)) {
119
+ remove(entry);
120
+ try { log(`chiusura ask ${entry.askId}: tetto raggiunto, alias lasciato al peer`); } catch (_) {}
121
+ return;
122
+ }
123
+ schedule(entry);
124
+ }
125
+
126
+ // Accoda i target NON raggiunti di una chiusura. La voce e' la COPPIA
127
+ // (chiusura, insieme dei pendenti): un peer che risponde esce dall'insieme, e
128
+ // la voce si chiude quando l'insieme e' vuoto. Una seconda `enqueue` per la
129
+ // stessa chiusura UNISCE i target invece di essere buttata via: e' cio' che
130
+ // impedisce a un peer spento di sparire dalla coda quando un ALTRO peer
131
+ // risponde, o quando la stessa chiusura viene riprovata piu' tardi.
132
+ function enqueue({ askId, outcome, session, targets } = {}) {
133
+ if (stopped || !askId || !outcome) return { ok: false, reason: 'invalid' };
134
+ const nuovi = [...new Set((Array.isArray(targets) ? targets : []).map((t) => String(t)).filter(Boolean))];
135
+ if (!nuovi.length) return { ok: false, reason: 'no-targets' };
136
+ const existing = entries.find((e) => e.askId === askId && e.outcome === outcome);
137
+ if (existing) {
138
+ const prima = existing.targets.size;
139
+ for (const t of nuovi) existing.targets.add(t);
140
+ // Se il tentativo precedente era in volo, il nuovo target non era nella
141
+ // sua lista: la voce va risvegliata, o aspetterebbe il backoff per nulla.
142
+ if (existing.targets.size !== prima && !existing.inFlight && !existing.timer) schedule(existing);
143
+ return { ok: true, merged: true, added: existing.targets.size - prima, size: entries.length };
144
+ }
145
+ if (entries.length >= maxEntries) {
146
+ const oldest = entries.shift();
147
+ clearEntry(oldest);
148
+ try { log(`coda chiusure piena (${maxEntries}): scartata la piu' vecchia (${oldest.askId})`); } catch (_) {}
149
+ }
150
+ const entry = {
151
+ askId, outcome, session, targets: new Set(nuovi),
152
+ attempts: 0, createdAt: now(), nextAt: 0, timer: null, inFlight: false,
153
+ };
154
+ entries.push(entry);
155
+ schedule(entry);
156
+ return { ok: true, size: entries.length };
157
+ }
158
+
159
+ // Risveglio su domanda: chi legge lo stato vuole lo stato VERO. Si ritentano
160
+ // subito le voci in attesa, senza aspettare il backoff — ma non a ogni
161
+ // lettura: c'e' una distanza minima fra due risvegli, altrimenti un refresh
162
+ // ripetuto diventerebbe un martellamento del peer.
163
+ async function drain(why = 'read') {
164
+ if (stopped || !entries.length) return { attempted: 0 };
165
+ const t = now();
166
+ if (lastNudge !== null && (t - lastNudge) < nudgeFloorMs) return { attempted: 0, throttled: true };
167
+ lastNudge = t;
168
+ const snapshot = entries.slice();
169
+ for (const entry of snapshot) await attempt(entry, why);
170
+ return { attempted: snapshot.length, size: entries.length };
171
+ }
172
+
173
+ // Svuotamento alla chiusura del server: nessun timer sopravvive.
174
+ function stop() {
175
+ stopped = true;
176
+ for (const entry of entries.slice()) remove(entry);
177
+ return { cleared: true };
178
+ }
179
+
180
+ return {
181
+ enqueue, drain, stop,
182
+ pending: () => entries.map((e) => ({
183
+ askId: e.askId, outcome: e.outcome, attempts: e.attempts, nextAt: e.nextAt,
184
+ targets: [...e.targets],
185
+ })),
186
+ size: () => entries.length,
187
+ limits: { baseMs, factor, maxAttempts, ttlMs, maxEntries, nudgeFloorMs },
188
+ };
189
+ }
190
+
191
+ module.exports = {
192
+ createClosureRetryQueue,
193
+ CLOSURE_DONE_STATUSES: DONE_STATUSES,
194
+ CLOSURE_FINAL_STATUSES: FINAL_STATUSES,
195
+ CLOSURE_RETRY_BASE_MS: BASE_MS,
196
+ CLOSURE_RETRY_FACTOR: FACTOR,
197
+ CLOSURE_RETRY_MAX_ATTEMPTS: MAX_ATTEMPTS,
198
+ CLOSURE_RETRY_TTL_MS: TTL_MS,
199
+ CLOSURE_RETRY_MAX_ENTRIES: MAX_ENTRIES,
200
+ CLOSURE_RETRY_NUDGE_FLOOR_MS: NUDGE_FLOOR_MS,
201
+ };
@@ -88,6 +88,9 @@ function createEventFeedAsksRoutes(deps) {
88
88
  askId, text: body.text, peerId: g.peer.nodeId, requestId: body.requestId,
89
89
  });
90
90
  if (out.ok) {
91
+ // La chiusura nasce dal servizio; qui si aspetta il recapito, cosi' chi ha
92
+ // risposto non vede una risposta che precede la chiusura dei peer.
93
+ if (out.closure) { try { await out.closure; } catch (_) {} }
91
94
  return res.json({ status: out.replay ? out.state : 'committed', requestId: body.requestId });
92
95
  }
93
96
  return res.status(out.code || 500).json({ error: out.error, reason: out.reason });
@@ -114,6 +117,7 @@ function createEventFeedAsksRoutes(deps) {
114
117
  if (out.reason === 'answering') return res.status(409).json({ error: 'risposta in corso: non si scarta un ask in answering' });
115
118
  return res.status(500).json({ error: 'dismiss non riuscito' });
116
119
  }
120
+ if (out.closure) { try { await out.closure; } catch (_) {} }
117
121
  return res.json({ dismissed: true, id: askId, idempotent: out.idempotent === true });
118
122
  });
119
123
 
@@ -190,8 +190,16 @@ function createEventFeedRoutes(deps) {
190
190
  // Asks: open asks of VISIBLE cells only, capped.
191
191
  let asks = [];
192
192
  try {
193
+ const self = typeof deps.localNodeId === 'function' ? deps.localNodeId() : null;
193
194
  const open = deps.asksStore.list({ open: true }) || [];
194
195
  const visible = open
196
+ // SOLO gli ask di QUESTO nodo. Un ask importato appartiene al nodo che
197
+ // l'ha posto: pubblicarlo qui lo consegnerebbe a un terzo peer
198
+ // attribuito a noi, cioe' a un destinatario che chi ha chiesto non
199
+ // aveva scelto — la domanda uscirebbe dal perimetro del fan-out. La
200
+ // guardia e' esplicita sui campi di provenienza, non dedotta dalla
201
+ // visibilita' della cella.
202
+ .filter((a) => !a.originNode && !(a.ownerId && self && a.ownerId !== self))
195
203
  .map((a) => ({ id: a.id, question: a.question, options: a.options, session: a.session, ts: a.ts }))
196
204
  .filter((a) => peer.allows({ scope: 'cell', cellId: deps.cellForSession(a.session) }))
197
205
  .slice(0, SNAPSHOT_MAX_ASKS);
@@ -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'
@@ -203,7 +207,7 @@ function knownResource(resource) {
203
207
  || /^\/event-feed\/[A-Za-z0-9._-]{1,32}$/.test(resource)
204
208
  || /^\/event-feed\/node\/[a-f0-9]{32}$/.test(resource)
205
209
  || isPanelResource(resource)
206
- || /^\/fleet\/(status|schema|definitions|credentials\/status|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);
210
+ || /^\/fleet\/(status|schema|definitions|credentials\/status|credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|edit-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource);
207
211
  }
208
212
 
209
213
  // Il pannello di una cella e' l'unica risorsa federata con un gate PER-PEER a
@@ -289,10 +293,18 @@ function allowedResource(resource, method = 'GET') {
289
293
  // enum. Non genera testo, quindi non consuma token. Resta comunque una
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
- 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.
296
+ if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|edit-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) return method === 'POST';
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.