@mmmbuto/nexuscrew 0.9.30 → 0.9.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/docs/CONFIGURATION.md +39 -0
- package/frontend/dist/assets/index-CH2iAu6y.js +167 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/doctor.js +105 -4
- package/lib/decks/routes.js +11 -1
- package/lib/fleet/builtin.js +96 -11
- package/lib/fleet/definitions.js +11 -3
- package/lib/fleet/endpoint-probe.js +146 -0
- package/lib/fleet/managed.js +175 -11
- package/lib/fleet/model-probe.js +110 -6
- package/lib/fleet/runtime.js +30 -3
- 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/frontend/dist/assets/index-BbcjoZS0.js +0 -167
|
@@ -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);
|
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;
|