@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.
- package/CHANGELOG.md +10 -0
- package/docs/CONFIGURATION.md +39 -0
- package/frontend/dist/assets/{index-DaAL7P-m.js → index-CH2iAu6y.js} +13 -13
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/doctor.js +74 -2
- package/lib/fleet/builtin.js +84 -8
- package/lib/fleet/endpoint-probe.js +146 -0
- package/lib/fleet/managed.js +164 -10
- package/lib/fleet/model-probe.js +110 -6
- package/lib/fleet/runtime.js +29 -2
- package/lib/notify/ask-answer-service.js +10 -4
- package/lib/notify/asks.js +67 -7
- package/lib/notify/closure-retry.js +201 -0
- package/lib/notify/event-feed-asks-routes.js +4 -0
- package/lib/notify/event-feed-routes.js +8 -0
- package/lib/notify/routes.js +274 -12
- package/lib/proxy/federation.js +16 -3
- package/lib/proxy/resource-acl.js +6 -0
- package/lib/server.js +132 -14
- package/package.json +1 -1
package/lib/notify/routes.js
CHANGED
|
@@ -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 (
|
|
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 {
|
|
328
|
-
|
|
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 };
|
package/lib/proxy/federation.js
CHANGED
|
@@ -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
|
|
294
|
-
//
|
|
295
|
-
//
|
|
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
|
-
|
|
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:
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
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
|
|
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,
|