@mmmbuto/nexuscrew 0.9.56 → 0.9.58
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 +17 -0
- package/frontend/dist/assets/index-D8cf4AEo.js +169 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/fleet/managed.js +57 -0
- package/lib/nodes/access-presets.js +1 -0
- package/lib/nodes/ask-slot-verifier.js +72 -0
- package/lib/nodes/commands.js +7 -3
- package/lib/nodes/reverse-slot-proof.js +19 -7
- package/lib/nodes/store.js +35 -5
- package/lib/notify/ask-answer-service.js +23 -11
- package/lib/notify/ask-receipts.js +1 -0
- package/lib/notify/ask-relay.js +210 -73
- package/lib/notify/event-feed-asks-routes.js +62 -15
- package/lib/notify/event-feed-client.js +120 -46
- package/lib/notify/routes.js +32 -17
- package/lib/proxy/federation.js +112 -12
- package/lib/proxy/resource-acl.js +1 -0
- package/lib/server.js +53 -20
- package/lib/tmux/actions.js +14 -11
- package/package.json +1 -1
- package/frontend/dist/assets/index-CJqN29CA.js +0 -169
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
const BACKOFF_MIN_MS = 5000;
|
|
15
15
|
const BACKOFF_MAX_MS = 60000;
|
|
16
16
|
const IDLE_TIMEOUT_MS = 60000;
|
|
17
|
+
const ERROR_BODY_TIMEOUT_MS = 5000;
|
|
17
18
|
const MAX_CONSECUTIVE_RESETS = 3;
|
|
18
19
|
// `resync-exhausted` ferma il loop ma non deve parcheggiare la view
|
|
19
20
|
// per sempre. Al tappo la view prende un cooldown a gradini — 30 s → 2 min →
|
|
@@ -68,6 +69,12 @@ function contentTypeOf(r) {
|
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
function isNamedFeedReason(reason) {
|
|
73
|
+
return ['shape', 'owner-mismatch', 'snapshot-oversize', 'snapshot-element-oversize',
|
|
74
|
+
'snapshot-resync-required', 'snapshot-content-type'].includes(reason)
|
|
75
|
+
|| (typeof reason === 'string' && reason.startsWith('snapshot-schema:'));
|
|
76
|
+
}
|
|
77
|
+
|
|
71
78
|
function createEventFeedClient(opts = {}) {
|
|
72
79
|
// deps: { nodesPath, token(), eventsHub, now, fetchImpl, log }
|
|
73
80
|
const fetchImpl = opts.fetchImpl || fetch;
|
|
@@ -86,9 +93,40 @@ function createEventFeedClient(opts = {}) {
|
|
|
86
93
|
let stopped = false;
|
|
87
94
|
let generation = 0;
|
|
88
95
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
const retries = new Map(); // ownerId -> persistent retry state
|
|
97
|
+
const configurations = new Map();
|
|
98
|
+
function retryFor(ownerId) {
|
|
99
|
+
if (!retries.has(ownerId)) retries.set(ownerId, { fails: 0, nextRetryAt: 0, timer: null });
|
|
100
|
+
return retries.get(ownerId);
|
|
101
|
+
}
|
|
102
|
+
function clearRetry(ownerId, reset = false) {
|
|
103
|
+
const retry = retries.get(ownerId);
|
|
104
|
+
if (!retry) return;
|
|
105
|
+
if (retry.timer) clearTimeout(retry.timer);
|
|
106
|
+
retry.timer = null;
|
|
107
|
+
if (reset) { retry.fails = 0; retry.nextRetryAt = 0; }
|
|
108
|
+
}
|
|
109
|
+
function active(ownerId, slot) {
|
|
110
|
+
return !stopped && running.get(ownerId) === slot && !slot.controller.signal.aborted;
|
|
111
|
+
}
|
|
112
|
+
async function withDeadline(slot, milliseconds, operation) {
|
|
113
|
+
const controller = slot ? slot.controller : new AbortController();
|
|
114
|
+
const signal = controller.signal;
|
|
115
|
+
let rejectAbort;
|
|
116
|
+
const aborted = new Promise((_resolve, reject) => { rejectAbort = () => reject(new Error('acquisition aborted')); });
|
|
117
|
+
signal.addEventListener('abort', rejectAbort, { once: true });
|
|
118
|
+
const timer = setTimeout(() => controller.abort(), milliseconds);
|
|
119
|
+
try {
|
|
120
|
+
if (signal.aborted) throw new Error('acquisition aborted');
|
|
121
|
+
return await Promise.race([operation(signal), aborted]);
|
|
122
|
+
} finally {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
signal.removeEventListener('abort', rejectAbort);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function backoffMs(ownerId) {
|
|
128
|
+
const retry = retryFor(ownerId);
|
|
129
|
+
const fails = ++retry.fails;
|
|
92
130
|
const base = Math.min(BACKOFF_MAX_MS, BACKOFF_MIN_MS * Math.pow(2, fails - 1));
|
|
93
131
|
return Math.floor(base / 2 + Math.random() * (base / 2));
|
|
94
132
|
}
|
|
@@ -296,7 +334,7 @@ function createEventFeedClient(opts = {}) {
|
|
|
296
334
|
return Math.max(oldest + INGRESS_WINDOW_MS, t) + INGRESS_BLOCK_BACKOFF_MS;
|
|
297
335
|
}
|
|
298
336
|
|
|
299
|
-
async function streamOnce(peer, ownerId, store) {
|
|
337
|
+
async function streamOnce(peer, ownerId, store, slot) {
|
|
300
338
|
const view = viewFor(ownerId);
|
|
301
339
|
// The deadline expired: the peer is admitted again and the block is closed
|
|
302
340
|
// by the transition that reopens the stream, not by a silent timeout.
|
|
@@ -305,15 +343,16 @@ function createEventFeedClient(opts = {}) {
|
|
|
305
343
|
view.ingressBlockReason = null;
|
|
306
344
|
}
|
|
307
345
|
const after = view.cursor ? `?after=${encodeURIComponent(view.cursor)}` : '';
|
|
308
|
-
const ctrl =
|
|
309
|
-
running.set(ownerId, { abort: () => ctrl.abort() });
|
|
310
|
-
let timer = setTimeout(() => ctrl.abort(), IDLE_TIMEOUT_MS); // 60 s without bytes
|
|
346
|
+
const ctrl = slot.controller;
|
|
311
347
|
try {
|
|
312
|
-
const r = await fetchImpl(routeUrl(peer, '/event-feed', after), {
|
|
313
|
-
headers: identityHeaders(peer, store), signal
|
|
314
|
-
});
|
|
315
|
-
if (
|
|
316
|
-
|
|
348
|
+
const r = await withDeadline(slot, IDLE_TIMEOUT_MS, (signal) => fetchImpl(routeUrl(peer, '/event-feed', after), {
|
|
349
|
+
headers: identityHeaders(peer, store), signal,
|
|
350
|
+
}));
|
|
351
|
+
if (!active(ownerId, slot)) return;
|
|
352
|
+
if (r.status === 403) {
|
|
353
|
+
const body = await withDeadline(slot, ERROR_BODY_TIMEOUT_MS, () => r.json().catch(() => ({})));
|
|
354
|
+
if (!active(ownerId, slot)) return;
|
|
355
|
+
if (body.reason === 'events-disabled') { view.stale = true; return; }
|
|
317
356
|
}
|
|
318
357
|
if (r.status === 409) { // reset-required / cursor-future: resync from a fresh snapshot
|
|
319
358
|
view.cursor = null;
|
|
@@ -321,17 +360,15 @@ function createEventFeedClient(opts = {}) {
|
|
|
321
360
|
return;
|
|
322
361
|
}
|
|
323
362
|
if (!r.ok) { view.stale = true; throw new Error(`event-feed HTTP ${r.status}`); }
|
|
324
|
-
|
|
363
|
+
if (!active(ownerId, slot)) return;
|
|
325
364
|
const reader = r.body.getReader();
|
|
326
365
|
const dec = new TextDecoder();
|
|
327
366
|
let buffer = '';
|
|
328
367
|
for (;;) {
|
|
329
|
-
const { done, value } = await reader.read();
|
|
330
|
-
if (
|
|
331
|
-
|
|
332
|
-
//
|
|
333
|
-
clearTimeout(timer);
|
|
334
|
-
timer = setTimeout(() => ctrl.abort(), IDLE_TIMEOUT_MS);
|
|
368
|
+
const { done, value } = await withDeadline(slot, IDLE_TIMEOUT_MS, () => reader.read());
|
|
369
|
+
if (!active(ownerId, slot)) return;
|
|
370
|
+
if (done) throw new Error('event-feed EOF');
|
|
371
|
+
// Each read owns one deadline; no previous timeout survives the chunk.
|
|
335
372
|
const chunk = dec.decode(value, { stream: true });
|
|
336
373
|
buffer += chunk;
|
|
337
374
|
let idx;
|
|
@@ -376,35 +413,42 @@ function createEventFeedClient(opts = {}) {
|
|
|
376
413
|
view.consecutiveResets = 0; // a usable frame: the streak is over
|
|
377
414
|
view.resyncExhaustions = 0; // and a working stream: the cooldown ladder back to the first step
|
|
378
415
|
view.cursor = `${epochStr}:${sStr}`;
|
|
416
|
+
clearRetry(ownerId, true);
|
|
379
417
|
reemit(envelope);
|
|
380
418
|
}
|
|
381
419
|
}
|
|
382
420
|
} finally {
|
|
383
|
-
|
|
384
|
-
|
|
421
|
+
// The acquisition slot owns the connection throughout its lifecycle.
|
|
422
|
+
ctrl.abort();
|
|
385
423
|
}
|
|
386
424
|
}
|
|
387
425
|
|
|
388
426
|
// Capability BEFORE subscribing: an owner without the feed capability makes
|
|
389
427
|
// the view unsupported — no fallback, no infinite backoff.
|
|
390
|
-
async function checkCapability(peer, ownerId) {
|
|
428
|
+
async function checkCapability(peer, ownerId, slot) {
|
|
391
429
|
const view = viewFor(ownerId);
|
|
392
430
|
if (view.capabilityChecked) return true;
|
|
393
431
|
// The health endpoint of the OWNER is probed directly (same pattern as
|
|
394
432
|
// the tunnel health probe): /federation/health with the peer's token.
|
|
395
|
-
const
|
|
396
|
-
|
|
433
|
+
const body = await withDeadline(slot, 5000, async (signal) => {
|
|
434
|
+
const r = await fetchImpl(`http://127.0.0.1:${peer.localPort}/federation/health`, {
|
|
435
|
+
headers: { authorization: `Bearer ${peer.token}` }, signal,
|
|
436
|
+
});
|
|
437
|
+
if (!r.ok) throw new Error(`health HTTP ${r.status}`);
|
|
438
|
+
return r.json();
|
|
397
439
|
});
|
|
398
|
-
if (!
|
|
399
|
-
const body = await r.json();
|
|
440
|
+
if (!active(ownerId, slot)) throw new Error('obsolete acquisition');
|
|
400
441
|
if (body.eventFeedV1 !== true || body.instanceId !== ownerId) {
|
|
401
442
|
view.unsupported = true;
|
|
402
443
|
view.lastError = 'event-feed-unsupported';
|
|
444
|
+
view.capabilityRecheckAt = now() + BACKOFF_MAX_MS;
|
|
403
445
|
// One transition log, not one per tick: the poll gate keeps this
|
|
404
446
|
// owner out from here on.
|
|
405
447
|
log(`event-feed-client: ${ownerId.slice(0, 8)}… no feed capability, subscription refused`);
|
|
406
448
|
return false;
|
|
407
449
|
}
|
|
450
|
+
view.unsupported = false;
|
|
451
|
+
view.capabilityRecheckAt = null;
|
|
408
452
|
view.capabilityChecked = true;
|
|
409
453
|
return true;
|
|
410
454
|
}
|
|
@@ -518,7 +562,7 @@ function createEventFeedClient(opts = {}) {
|
|
|
518
562
|
// vista resta ferma e STALE con la sua causa — ma l'elenco asks torna
|
|
519
563
|
// comunque alla porta, che è autorizzata a decidere: un campo che la
|
|
520
564
|
// chiusura non legge non può tenere aperta una card chiusa dall'owner.
|
|
521
|
-
async function snapshotOnce(peer, ownerId, store, { profile = 'view' } = {}) {
|
|
565
|
+
async function snapshotOnce(peer, ownerId, store, { profile = 'view', slot = null } = {}) {
|
|
522
566
|
const view = viewFor(ownerId);
|
|
523
567
|
// INVARIANTE (punto d'ingresso, per owner): al massimo uno snapshot per
|
|
524
568
|
// finestra, da qualunque causa di resync. Se la finestra non è trascorsa
|
|
@@ -538,14 +582,14 @@ function createEventFeedClient(opts = {}) {
|
|
|
538
582
|
view.lastSnapshotAt = now();
|
|
539
583
|
view.nextSnapshotAllowedAt = null;
|
|
540
584
|
const gen = generation;
|
|
541
|
-
const r = await
|
|
542
|
-
|
|
585
|
+
const { r, text } = await withDeadline(slot, 8000, async (signal) => {
|
|
586
|
+
const r = await fetchImpl(routeUrl(peer, '/event-feed/snapshot'), {
|
|
587
|
+
headers: identityHeaders(peer, store), signal,
|
|
588
|
+
});
|
|
589
|
+
if (!r.ok) throw new Error(`snapshot HTTP ${r.status}`);
|
|
590
|
+
return { r, text: await r.text() };
|
|
543
591
|
});
|
|
544
|
-
if (!
|
|
545
|
-
// Size cap BEFORE JSON.parse: a body over the server budget is refused on
|
|
546
|
-
// sight, never parsed. La regola vive nel validatore unico; qui è la
|
|
547
|
-
// scorciatoia che evita di pagare il parse di un corpo già rifiutato.
|
|
548
|
-
const text = await r.text();
|
|
592
|
+
if ((slot && !active(ownerId, slot)) || gen !== generation) throw new Error('obsolete acquisition');
|
|
549
593
|
const bytes = Buffer.byteLength(text, 'utf8');
|
|
550
594
|
if (bytes > 3 * 1024 * 1024) {
|
|
551
595
|
view.stale = true; view.lastError = 'snapshot-oversize';
|
|
@@ -576,6 +620,7 @@ function createEventFeedClient(opts = {}) {
|
|
|
576
620
|
return { state: 'failed' };
|
|
577
621
|
}
|
|
578
622
|
applySnapshot(ownerId, snap, gen);
|
|
623
|
+
clearRetry(ownerId, true);
|
|
579
624
|
return { state: 'applied', asks: snap.asks, appliedToView: true };
|
|
580
625
|
}
|
|
581
626
|
|
|
@@ -586,15 +631,31 @@ function createEventFeedClient(opts = {}) {
|
|
|
586
631
|
if (!store) return;
|
|
587
632
|
const peers = (store.nodes || []).filter((n) => n && n.direction === 'outbound'
|
|
588
633
|
&& n.eventsReceive === true && n.token && n.localPort && n.nodeId);
|
|
634
|
+
const eligible = new Map(peers.map((peer) => [peer.nodeId, peer]));
|
|
635
|
+
const fingerprint = (peer) => JSON.stringify([peer.token, peer.localPort, peer.nodeId, peer.accessGrants, peer.accessPreset]);
|
|
636
|
+
for (const [ownerId, config] of configurations) {
|
|
637
|
+
const peer = eligible.get(ownerId);
|
|
638
|
+
if (!peer || fingerprint(peer) !== config) {
|
|
639
|
+
const slot = running.get(ownerId);
|
|
640
|
+
if (slot) { running.delete(ownerId); slot.abort(); }
|
|
641
|
+
clearRetry(ownerId, true);
|
|
642
|
+
configurations.delete(ownerId);
|
|
643
|
+
const view = views.get(ownerId);
|
|
644
|
+
if (view) { view.cursor = null; view.stale = true; view.capabilityChecked = false; view.unsupported = false; }
|
|
645
|
+
}
|
|
646
|
+
}
|
|
589
647
|
await Promise.all(peers.map(async (peer) => {
|
|
590
648
|
const ownerId = peer.nodeId;
|
|
649
|
+
configurations.set(ownerId, fingerprint(peer));
|
|
650
|
+
if (now() < retryFor(ownerId).nextRetryAt) return;
|
|
591
651
|
if (running.has(ownerId)) return; // one loop per pair
|
|
592
652
|
// Il guard vale per TUTTO il round, non solo per lo stream: due poll
|
|
593
653
|
// sovrapposti (tick + backoff, o macchina carica) facevano due snapshot
|
|
594
654
|
// nella stessa finestra e il secondo, senza il flag di recupero,
|
|
595
|
-
// saltava il riarma del cooldown.
|
|
596
|
-
//
|
|
597
|
-
const
|
|
655
|
+
// saltava il riarma del cooldown. Lo stesso controller resta attivo
|
|
656
|
+
// dalla prima acquisizione fino alla chiusura dello stream.
|
|
657
|
+
const controller = new AbortController();
|
|
658
|
+
const slot = { controller, abort: () => controller.abort() };
|
|
598
659
|
running.set(ownerId, slot);
|
|
599
660
|
const view = viewFor(ownerId);
|
|
600
661
|
try {
|
|
@@ -619,7 +680,11 @@ function createEventFeedClient(opts = {}) {
|
|
|
619
680
|
recovery = true;
|
|
620
681
|
log(`event-feed-client: ${ownerId.slice(0, 8)}… resync cooldown expired: one fresh snapshot attempt`);
|
|
621
682
|
}
|
|
622
|
-
if (view.unsupported
|
|
683
|
+
if (view.unsupported) {
|
|
684
|
+
if (now() < view.capabilityRecheckAt) return;
|
|
685
|
+
view.capabilityChecked = false;
|
|
686
|
+
}
|
|
687
|
+
if ((view.ingressBlockedUntil || 0) > Date.now()) return;
|
|
623
688
|
// La fase di ACQUISIZIONE del tentativo (capability + snapshot) ha UN
|
|
624
689
|
// SOLO punto di uscita per gli esiti non riusciti: throw, timeout,
|
|
625
690
|
// abort e 'failed' convergono tutti lì — non un ramo per tipo di
|
|
@@ -635,15 +700,16 @@ function createEventFeedClient(opts = {}) {
|
|
|
635
700
|
let snapState = null; // null = tentativo non fatto (cursor già valido)
|
|
636
701
|
try {
|
|
637
702
|
if (!view.capabilityChecked) {
|
|
638
|
-
const capOk = await checkCapability(peer, ownerId);
|
|
703
|
+
const capOk = await checkCapability(peer, ownerId, slot);
|
|
639
704
|
if (!capOk) return; // senza la capability il gate blocca: nessun tentativo fatto
|
|
640
705
|
}
|
|
641
706
|
if (!view.cursor) {
|
|
642
|
-
snapState = await snapshotOnce(peer, ownerId, store); // profilo view
|
|
707
|
+
snapState = await snapshotOnce(peer, ownerId, store, { slot }); // profilo view
|
|
643
708
|
}
|
|
644
709
|
} catch (e) {
|
|
645
710
|
attemptError = e;
|
|
646
711
|
}
|
|
712
|
+
if (stopped || running.get(ownerId) !== slot) return;
|
|
647
713
|
if (attemptError || (snapState && snapState.state === 'failed')) {
|
|
648
714
|
if (recovery) armResyncCooldown(view, attemptError ? 'recovery attempt failed' : 'recovery snapshot failed');
|
|
649
715
|
}
|
|
@@ -651,21 +717,27 @@ function createEventFeedClient(opts = {}) {
|
|
|
651
717
|
// Un tentativo fallito o posticipato ferma il round; NESSUN tentativo
|
|
652
718
|
// (cursor già valido) va dritto allo stream.
|
|
653
719
|
if (snapState && snapState.state !== 'applied') return;
|
|
654
|
-
await streamOnce(peer, ownerId, store);
|
|
720
|
+
await streamOnce(peer, ownerId, store, slot);
|
|
655
721
|
} catch (e) {
|
|
722
|
+
if (stopped || running.get(ownerId) !== slot) return;
|
|
723
|
+
view.cursor = null;
|
|
656
724
|
view.stale = true;
|
|
657
725
|
// A named feed reason (e.g. owner-mismatch) survives a later transport
|
|
658
726
|
// error: it is the reason the operator needs to see.
|
|
659
|
-
if (view.lastError
|
|
727
|
+
if (!isNamedFeedReason(view.lastError)) {
|
|
660
728
|
view.lastError = String((e && e.message) || e);
|
|
661
729
|
}
|
|
662
|
-
const delay = backoffMs(
|
|
730
|
+
const delay = backoffMs(ownerId);
|
|
731
|
+
const retry = retryFor(ownerId);
|
|
732
|
+
clearRetry(ownerId);
|
|
733
|
+
retry.nextRetryAt = now() + delay;
|
|
663
734
|
log(`event-feed-client: ${ownerId.slice(0, 8)}… retry in ${delay} ms (${view.lastError})`);
|
|
664
|
-
setTimeout(() => { void poll(); }, delay);
|
|
735
|
+
retry.timer = setTimeout(() => { retry.timer = null; void poll(); }, delay);
|
|
736
|
+
if (typeof retry.timer.unref === 'function') retry.timer.unref();
|
|
665
737
|
} finally {
|
|
666
|
-
//
|
|
667
|
-
// streamOnce, quello ha già messo (e alla fine tolto) il suo slot vero.
|
|
738
|
+
// Un round obsoleto non libera mai lo slot della nuova generazione.
|
|
668
739
|
if (running.get(ownerId) === slot) running.delete(ownerId);
|
|
740
|
+
slot.abort();
|
|
669
741
|
}
|
|
670
742
|
}));
|
|
671
743
|
}
|
|
@@ -685,7 +757,9 @@ function createEventFeedClient(opts = {}) {
|
|
|
685
757
|
function stop() {
|
|
686
758
|
stopped = true; generation += 1;
|
|
687
759
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
|
760
|
+
for (const ownerId of retries.keys()) clearRetry(ownerId, true);
|
|
688
761
|
for (const [, h] of running) { try { h.abort(); } catch (_) {} }
|
|
762
|
+
running.clear();
|
|
689
763
|
}
|
|
690
764
|
|
|
691
765
|
// Local aggregated read: connected views only, with the watermark the UI
|
package/lib/notify/routes.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// Il paste della risposta riusa ESATTAMENTE pasteToSession (bracketed literal,
|
|
19
19
|
// niente Invio, control char rifiutati): qui si sanifica il testo PRIMA.
|
|
20
20
|
const express = require('express');
|
|
21
|
+
const accessPresets = require('../nodes/access-presets.js');
|
|
21
22
|
const { createAskAnswerService } = require('./ask-answer-service.js');
|
|
22
23
|
// Gli insiemi degli esiti vivono nella coda: il fan-out decide COSA accodare con
|
|
23
24
|
// la stessa definizione con cui la coda decide cosa ritentare. Due copie della
|
|
@@ -116,23 +117,33 @@ function replyLabel(cfg) {
|
|
|
116
117
|
// peer autorizzati di questo nodo. L'enumerazione sta qui e non nel dispatcher
|
|
117
118
|
// perche' il dispatcher conosce solo il target esatto: una wildcard implicita
|
|
118
119
|
// sarebbe un modo per parlare a chi non si e' scelto.
|
|
119
|
-
async function
|
|
120
|
-
if (target
|
|
121
|
-
if (!peerTargets) return [];
|
|
120
|
+
async function resolveQualifiedFanTargets(peerTargets, target, self, log = () => {}) {
|
|
121
|
+
if (target === self) return { targets: [], known: true };
|
|
122
|
+
if (!peerTargets) return { targets: [], known: false };
|
|
122
123
|
let list;
|
|
123
|
-
try { list = await peerTargets(); } catch (_) { return []; }
|
|
124
|
-
if (!Array.isArray(list)) return [];
|
|
124
|
+
try { list = await peerTargets(); } catch (_) { return { targets: [], known: false }; }
|
|
125
|
+
if (!Array.isArray(list)) return { targets: [], known: false };
|
|
125
126
|
const seen = new Set();
|
|
126
127
|
const out = [];
|
|
128
|
+
let skipped = 0;
|
|
127
129
|
for (const raw of list) {
|
|
128
130
|
const id = raw && typeof raw === 'object' ? (raw.nodeId || raw.instanceId) : raw;
|
|
129
131
|
const clean = String(id || '');
|
|
130
|
-
if (!TARGET_RE.test(clean)) continue;
|
|
131
|
-
if (
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
if (!TARGET_RE.test(clean) || (self && clean === self) || seen.has(clean)) continue;
|
|
133
|
+
if (target !== undefined && clean !== String(target)) continue;
|
|
134
|
+
seen.add(clean);
|
|
135
|
+
// A routed destination is qualified by this node's own direct first hop.
|
|
136
|
+
const view = accessPresets.grantsOf(raw && typeof raw === 'object' ? (raw.firstHop || raw) : null);
|
|
137
|
+
if (!view.configured || view.label !== 'admin') { skipped++; continue; }
|
|
138
|
+
out.push(clean);
|
|
134
139
|
}
|
|
135
|
-
|
|
140
|
+
if (target !== undefined && !seen.has(String(target))) skipped++;
|
|
141
|
+
if (skipped) { try { log(`ask fan-out skipped: not admin (${skipped})`); } catch (_) {} }
|
|
142
|
+
return { targets: out, known: true };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function resolveFanTargets(peerTargets, target, self, log = () => {}) {
|
|
146
|
+
return (await resolveQualifiedFanTargets(peerTargets, target, self, log)).targets;
|
|
136
147
|
}
|
|
137
148
|
|
|
138
149
|
// La CHIUSURA di un ask segue la stessa strada dell'andata: chi ha ricevuto la
|
|
@@ -154,10 +165,13 @@ function createClosureFanout({
|
|
|
154
165
|
const self = localNodeId();
|
|
155
166
|
// `targets` esplicito = ritentativo: si va SOLO verso i pendenti, non di
|
|
156
167
|
// nuovo verso tutti. Senza, si enumerano i peer autorizzati come sempre.
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const
|
|
168
|
+
const inventory = await resolveQualifiedFanTargets(peerTargets, undefined, self, log);
|
|
169
|
+
const eligible = inventory.targets;
|
|
170
|
+
const allowed = new Set(eligible);
|
|
171
|
+
const targets2 = Array.isArray(targets) && targets.length ? targets.filter(target => allowed.has(target)) : eligible;
|
|
172
|
+
const out = Array.isArray(targets) ? targets.filter(target => !allowed.has(target))
|
|
173
|
+
.map(target => ({ target, status: inventory.known ? 'refused' : 'unknown',
|
|
174
|
+
reason: inventory.known ? 'ask-target-not-admin' : 'ask-targets-unavailable' })) : [];
|
|
161
175
|
for (const target of targets2) {
|
|
162
176
|
try {
|
|
163
177
|
const r = await dispatcher.dispatch({
|
|
@@ -191,7 +205,7 @@ function createClosureFanout({
|
|
|
191
205
|
}
|
|
192
206
|
|
|
193
207
|
function notifyRoutes({
|
|
194
|
-
cfg, notifier, push, asks,
|
|
208
|
+
cfg, notifier, push, asks, submit, sessionExists,
|
|
195
209
|
fleetP = null, instanceId = null, identityMode = 'legacy',
|
|
196
210
|
// Federazione delle notifiche. Assenti (test unitari, montaggi parziali) la
|
|
197
211
|
// route resta esattamente quella locale di prima: nessun percorso nuovo si
|
|
@@ -215,7 +229,8 @@ function notifyRoutes({
|
|
|
215
229
|
// not inject one: the local route and the federated surface must never drift
|
|
216
230
|
// into two different implementations.
|
|
217
231
|
const askService = answerService || createAskAnswerService({
|
|
218
|
-
asks,
|
|
232
|
+
asks,
|
|
233
|
+
submit, receipts,
|
|
219
234
|
labelPrefix: replyLabel(cfg),
|
|
220
235
|
onClosure: (kind, info) => { try { notifier.emitRaw({ type: kind, id: info.askId }); } catch (_) {} },
|
|
221
236
|
});
|
|
@@ -384,7 +399,7 @@ function notifyRoutes({
|
|
|
384
399
|
// perche' il dispatcher non conosce i broadcast: target esatto soltanto, e
|
|
385
400
|
// deve restare cosi' (una wildcard implicita e' un modo per parlare a chi non
|
|
386
401
|
// si e' scelto).
|
|
387
|
-
const askFanTargets = (target, self) => resolveFanTargets(peerTargets, target, self);
|
|
402
|
+
const askFanTargets = (target, self) => resolveFanTargets(peerTargets, target, self, log);
|
|
388
403
|
const closureFanout = closureFanoutDep || createClosureFanout({ dispatcher, peerTargets, localNodeId, log, retry: closureRetry });
|
|
389
404
|
|
|
390
405
|
// da revisione: gated READONLY (mutGate) — crea stato durevole (asks.json) e domande
|