@mmmbuto/nexuscrew 0.9.57 → 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 +13 -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-CAgxp3qK.js +0 -169
package/lib/notify/ask-relay.js
CHANGED
|
@@ -14,12 +14,61 @@
|
|
|
14
14
|
// refused locally until verification resolves the attempt.
|
|
15
15
|
|
|
16
16
|
const crypto = require('node:crypto');
|
|
17
|
+
const { NODE_NAME_RE } = require('../nodes/store.js');
|
|
17
18
|
const { probeReverseSlot } = require('../nodes/reverse-slot-proof.js');
|
|
18
|
-
const
|
|
19
|
+
const { askSlotVerifier, binding } = require('../nodes/ask-slot-verifier.js');
|
|
20
|
+
const RELAY_DEADLINE_MS = 8000;
|
|
21
|
+
const OWNER_REASONS = new Set([
|
|
22
|
+
'reverse-slot-unverified', 'peer-unknown', 'events-disabled', 'grant-required:ask-action',
|
|
23
|
+
'peer-visibility', 'origin-visibility', 'federated-origin-required', 'hop-chain',
|
|
24
|
+
'store-unavailable', 'unknown-peer', 'unknown-trust', 'no-delivering-peer',
|
|
25
|
+
'no-node-identity', 'bad-visited', 'bad-hop', 'self-hop', 'bad-attested-cell',
|
|
26
|
+
'origin-mismatch', 'fleet-unavailable', 'cell-not-active', 'answer-rate',
|
|
27
|
+
'unknown', 'dismissed', 'answering', 'answered', 'store-unreadable',
|
|
28
|
+
'delivery-unknown-block', 'already-delivered', 'paste-failed', 'bad-request-id',
|
|
29
|
+
'no-receipts', 'request-conflict', 'receipt-cap', 'federation-peer-unreachable',
|
|
30
|
+
'federation-peer-timeout', 'federation-body-consumed',
|
|
31
|
+
]);
|
|
32
|
+
function safeReason(reason) { return OWNER_REASONS.has(reason) ? reason : undefined; }
|
|
19
33
|
|
|
20
|
-
function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {}, probeReverseSlotImpl = probeReverseSlot } = {}) {
|
|
34
|
+
function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {}, probeReverseSlotImpl = probeReverseSlot, peers = null, localPort = () => 0, localToken = () => '', nodesPath = null, slotVerifier = askSlotVerifier } = {}) {
|
|
21
35
|
// (ownerId|askId|requestId) -> {state: 'sent'|'committed'|'failed'|'uncertain', requestId}
|
|
22
36
|
const attempts = new Map();
|
|
37
|
+
const answerFlights = new Map();
|
|
38
|
+
|
|
39
|
+
const proofScope = nodesPath || loadStore;
|
|
40
|
+
function deadlineResult(op, args, capability) {
|
|
41
|
+
if (capability) return { ownerId: args.ownerId, askId: args.askId, canReply: false, status: 'unreachable', reason: 'relay-deadline' };
|
|
42
|
+
if (op.answer && op.delivered && args.rid) {
|
|
43
|
+
return { ok: false, code: 200, uncertain: true, requestId: args.rid, reason: 'delivery-unknown', error: 'esito incerto: verifica lo stato prima di rispondere' };
|
|
44
|
+
}
|
|
45
|
+
return { ok: false, code: 504, reason: 'relay-deadline', error: 'relay deadline' };
|
|
46
|
+
}
|
|
47
|
+
async function operation(args, work, capability = false, answer = false) {
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const op = { controller, answer, delivered: false, expired: false, proofKey: null, proofBinding: null };
|
|
50
|
+
let rejectAbort;
|
|
51
|
+
const aborted = new Promise((_resolve, reject) => { rejectAbort = () => reject(new Error('relay deadline')); });
|
|
52
|
+
controller.signal.addEventListener('abort', rejectAbort, { once: true });
|
|
53
|
+
op.active = () => !controller.signal.aborted;
|
|
54
|
+
op.check = () => { if (!op.active()) throw new Error('relay deadline'); };
|
|
55
|
+
op.wait = promise => Promise.race([promise, aborted]);
|
|
56
|
+
const timer = setTimeout(() => { op.expired = true; controller.abort(); }, RELAY_DEADLINE_MS);
|
|
57
|
+
try { return await Promise.race([Promise.resolve().then(() => work(op)), aborted]); }
|
|
58
|
+
catch (_) {
|
|
59
|
+
slotVerifier.invalidate(op.proofKey);
|
|
60
|
+
if (op.answer && op.delivered && args.rid) {
|
|
61
|
+
const entry = attempts.get(attemptKey(args.ownerId, args.askId, args.rid));
|
|
62
|
+
if (entry) entry.state = 'uncertain';
|
|
63
|
+
}
|
|
64
|
+
if (op.expired) return deadlineResult(op, args, capability);
|
|
65
|
+
return capability
|
|
66
|
+
? { ownerId: args.ownerId, askId: args.askId, canReply: false, status: 'unreachable' }
|
|
67
|
+
: { ok: false, code: 502, error: 'owner unreachable' };
|
|
68
|
+
} finally {
|
|
69
|
+
clearTimeout(timer); controller.signal.removeEventListener('abort', rejectAbort); controller.abort();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
23
72
|
|
|
24
73
|
function requestId() { return crypto.randomUUID(); }
|
|
25
74
|
|
|
@@ -28,52 +77,102 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
28
77
|
// attivo. Per gli inbound la porta dello slot DEVE essere verificata con la
|
|
29
78
|
// stessa sonda dei terminali (secret del peer + instanceId atteso): nessun
|
|
30
79
|
// fallback sulla localPort senza proof.
|
|
31
|
-
function candidateOwner(ownerId) {
|
|
32
|
-
|
|
80
|
+
async function candidateOwner(ownerId, op) {
|
|
81
|
+
let store = loadStore();
|
|
82
|
+
if (!store) return null;
|
|
83
|
+
let routed = null;
|
|
84
|
+
if (typeof peers === 'function') {
|
|
85
|
+
let inventory;
|
|
86
|
+
try { inventory = await op.wait(peers()); } catch (_) { op.check(); return null; }
|
|
87
|
+
op.check();
|
|
88
|
+
const matches = (Array.isArray(inventory) ? inventory : [])
|
|
89
|
+
.filter((p) => p && (p.nodeId === ownerId || p.instanceId === ownerId));
|
|
90
|
+
if (matches.length !== 1 || matches[0].stale === true) return null;
|
|
91
|
+
const route = matches[0].route;
|
|
92
|
+
if (!Array.isArray(route) || !route.length || route.length > 4
|
|
93
|
+
|| route.some((name) => typeof name !== 'string' || !NODE_NAME_RE.test(name))
|
|
94
|
+
|| new Set(route).size !== route.length) return null;
|
|
95
|
+
routed = [...route];
|
|
96
|
+
}
|
|
97
|
+
op.check();
|
|
98
|
+
store = loadStore();
|
|
33
99
|
if (!store) return null;
|
|
100
|
+
slotVerifier.sync(store, proofScope);
|
|
34
101
|
const nodes = store.nodes || [];
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
102
|
+
const direct = nodes.filter((n) => n && n.nodeId === ownerId);
|
|
103
|
+
if (direct.length > 1) return null;
|
|
104
|
+
const out = direct.find((n) => n.direction === 'outbound' && n.token && n.localPort);
|
|
105
|
+
if (out) return { node: out, forwardPort: out.localPort, forwardToken: out.token, kind: 'outbound', route: routed };
|
|
106
|
+
if (!direct.length && routed) return { node: null, kind: 'routed', route: routed };
|
|
38
107
|
const inbound = nodes.find((n) => n && n.direction === 'inbound'
|
|
39
108
|
&& n.shared === true && n.nodeId === ownerId
|
|
40
|
-
&&
|
|
109
|
+
&& n.token
|
|
41
110
|
&& n.askReplyAccess === true && n.eventsAccess === true
|
|
42
111
|
&& n.cellVisibility !== 'none');
|
|
43
112
|
if (!inbound) return null;
|
|
44
|
-
const forwardToken = inbound.token
|
|
113
|
+
const forwardToken = inbound.token;
|
|
45
114
|
const pool = inbound.reversePool;
|
|
46
115
|
const slot = pool && Array.isArray(pool.slots) ? pool.slots[pool.activeSlot] : null;
|
|
47
116
|
if (!slot || !Number.isInteger(slot.port)) {
|
|
48
|
-
return { node: inbound, forwardPort: null, forwardToken, kind: 'inbound-unverified' };
|
|
117
|
+
return { node: inbound, forwardPort: null, forwardToken, kind: 'inbound-unverified', route: routed };
|
|
49
118
|
}
|
|
50
|
-
return { node: inbound, forwardPort: slot.port, forwardToken, kind: 'inbound' };
|
|
119
|
+
return { node: inbound, forwardPort: slot.port, forwardToken, kind: 'inbound', route: routed };
|
|
51
120
|
}
|
|
52
121
|
|
|
53
122
|
function ownerUrl(port, path) {
|
|
54
123
|
return `http://127.0.0.1:${port}/federation/route/_/${path.replace(/^\//, '')}`;
|
|
55
124
|
}
|
|
56
125
|
|
|
126
|
+
async function forward(candidate, path, { method = 'GET', body, op } = {}) {
|
|
127
|
+
op.check();
|
|
128
|
+
const st = loadStore();
|
|
129
|
+
slotVerifier.sync(st, proofScope);
|
|
130
|
+
if (candidate.kind === 'inbound' && !candidate.route) {
|
|
131
|
+
const current = st && (st.nodes || []).find(node => node.name === candidate.node.name);
|
|
132
|
+
const selected = binding(st, current);
|
|
133
|
+
if (!selected || selected.fingerprint !== op.proofBinding) {
|
|
134
|
+
slotVerifier.invalidate(op.proofKey);
|
|
135
|
+
const error = new Error('reverse slot changed'); error.reason = 'reverse-slot-unverified'; throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const routed = candidate.route;
|
|
139
|
+
const url = routed
|
|
140
|
+
? `http://127.0.0.1:${localPort()}/api/route/${routed.map(encodeURIComponent).join('/')}/_${path}`
|
|
141
|
+
: ownerUrl(candidate.forwardPort, path);
|
|
142
|
+
const headers = routed ? { authorization: `Bearer ${localToken()}` }
|
|
143
|
+
: { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': st && st.nodeId || '' };
|
|
144
|
+
try {
|
|
145
|
+
op.check(); op.delivered = true;
|
|
146
|
+
const r = await op.wait(fetchImpl(url, { method, headers: { ...headers, 'content-type': 'application/json' },
|
|
147
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: op.controller.signal }));
|
|
148
|
+
op.check();
|
|
149
|
+
const payload = await op.wait(r.json().catch(error => {
|
|
150
|
+
if ([403, 404, 501].includes(r.status)) return {};
|
|
151
|
+
throw error;
|
|
152
|
+
}));
|
|
153
|
+
op.check();
|
|
154
|
+
if (!r.ok) slotVerifier.invalidate(op.proofKey);
|
|
155
|
+
return { r, body: { ...payload, reason: safeReason(payload && payload.reason) } };
|
|
156
|
+
} catch (error) { slotVerifier.invalidate(op.proofKey); throw error; }
|
|
157
|
+
}
|
|
158
|
+
|
|
57
159
|
// Preflight del canale reverse per un peer inbound: rifiuto NETTO se lo slot
|
|
58
160
|
// non e' verificato (non e' un esito incerto: il forward non e' mai partito).
|
|
59
|
-
async function verifyInboundChannel(candidate) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
error: 'canale reverse non verificato: risposta non inoltrata',
|
|
75
|
-
reason: 'reverse-slot-unverified',
|
|
76
|
-
};
|
|
161
|
+
async function verifyInboundChannel(candidate, op) {
|
|
162
|
+
// REST routes prove the slot at their inbound proxy hop, never twice.
|
|
163
|
+
if (candidate.route) return { ok: true };
|
|
164
|
+
op.check();
|
|
165
|
+
const st = loadStore();
|
|
166
|
+
const proof = await op.wait(slotVerifier.verify({ store: st, peer: candidate.node, loadStore,
|
|
167
|
+
scope: proofScope, signal: op.controller.signal, probeImpl: probeReverseSlotImpl }));
|
|
168
|
+
op.check();
|
|
169
|
+
if (proof && proof.owned === true) {
|
|
170
|
+
op.proofKey = proof.key;
|
|
171
|
+
const selected = binding(st, candidate.node);
|
|
172
|
+
op.proofBinding = selected && selected.fingerprint;
|
|
173
|
+
return { ok: true };
|
|
174
|
+
}
|
|
175
|
+
return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
|
|
77
176
|
}
|
|
78
177
|
|
|
79
178
|
function attemptKey(ownerId, askId, rid) { return `${ownerId}|${askId}|${rid}`; }
|
|
@@ -99,17 +198,17 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
99
198
|
return sent.length ? sent[sent.length - 1].requestId : null;
|
|
100
199
|
}
|
|
101
200
|
|
|
102
|
-
async function
|
|
201
|
+
async function relayAnswerOnce({ ownerId, askId, text, rid: providedRid } = {}, op) {
|
|
103
202
|
if (!ownerId || !askId || typeof text !== 'string' || !text.trim()) {
|
|
104
203
|
return { ok: false, code: 400, error: 'ownerId, askId e text richiesti' };
|
|
105
204
|
}
|
|
106
|
-
const candidate = candidateOwner(ownerId);
|
|
205
|
+
const candidate = await candidateOwner(ownerId, op);
|
|
107
206
|
if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
|
|
108
207
|
if (candidate.kind === 'inbound-unverified') {
|
|
109
208
|
return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
|
|
110
209
|
}
|
|
111
210
|
if (candidate.kind === 'inbound') {
|
|
112
|
-
const gate = await verifyInboundChannel(candidate);
|
|
211
|
+
const gate = await verifyInboundChannel(candidate, op);
|
|
113
212
|
if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
|
|
114
213
|
}
|
|
115
214
|
const peer = candidate.node;
|
|
@@ -123,25 +222,22 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
123
222
|
const rid = providedRid || requestId();
|
|
124
223
|
const key = attemptKey(ownerId, askId, rid);
|
|
125
224
|
const store = loadStore();
|
|
126
|
-
attempts.set(key, { requestId: rid, state: 'sent', askId, ownerId });
|
|
127
|
-
const ctrl = new AbortController();
|
|
128
|
-
const timer = setTimeout(() => ctrl.abort(), IDLE_MS);
|
|
225
|
+
attempts.set(key, { requestId: rid, state: 'sent', askId, ownerId, digest: crypto.createHash('sha256').update(text).digest('hex') });
|
|
129
226
|
try {
|
|
130
|
-
const r = await
|
|
131
|
-
method: 'POST',
|
|
132
|
-
headers: {
|
|
133
|
-
authorization: `Bearer ${candidate.forwardToken}`,
|
|
134
|
-
'content-type': 'application/json',
|
|
135
|
-
'x-nexuscrew-visited': store.nodeId,
|
|
136
|
-
},
|
|
137
|
-
body: JSON.stringify({ text, requestId: rid }),
|
|
138
|
-
signal: ctrl.signal,
|
|
227
|
+
const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}/answer`, {
|
|
228
|
+
method: 'POST', body: { text, requestId: rid }, op,
|
|
139
229
|
});
|
|
140
|
-
|
|
230
|
+
op.check();
|
|
141
231
|
if (r.status === 200) {
|
|
142
|
-
const state = body.status === 'answered' ? 'committed' : body.status
|
|
232
|
+
const state = body.status === 'answered' ? 'committed' : body.status;
|
|
233
|
+
if (!['committed', 'failed'].includes(state)) {
|
|
234
|
+
attempts.get(key).state = 'uncertain';
|
|
235
|
+
return { ok: false, code: 200, uncertain: true, requestId: rid, reason: 'delivery-unknown' };
|
|
236
|
+
}
|
|
143
237
|
attempts.get(key).state = body.replay ? (body.state || state) : state;
|
|
144
|
-
return { ok: true, status: attempts.get(key).state, requestId: rid
|
|
238
|
+
return { ok: true, status: attempts.get(key).state, requestId: rid,
|
|
239
|
+
...(body.ownerId ? { ownerId: body.ownerId } : {}), ...(body.askId ? { askId: body.askId } : {}),
|
|
240
|
+
...(body.actor ? { actor: body.actor } : {}), ...(body.receipt ? { receipt: body.receipt } : {}) };
|
|
145
241
|
}
|
|
146
242
|
const entry = attempts.get(key);
|
|
147
243
|
// 409 request-conflict / answering are definitive; anything else that
|
|
@@ -155,7 +251,7 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
155
251
|
originalRequestId: originalSentRequestId(ownerId, askId, rid), requestId: rid,
|
|
156
252
|
error: 'esito incerto: bloccato da un invio precedente' };
|
|
157
253
|
}
|
|
158
|
-
return { ok: false, code: 409, error:
|
|
254
|
+
return { ok: false, code: 409, error: 'conflict', reason: body.reason };
|
|
159
255
|
}
|
|
160
256
|
if (r.status === 502) {
|
|
161
257
|
if (entry) entry.state = 'uncertain';
|
|
@@ -163,75 +259,116 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
163
259
|
error: 'esito incerto: verifica lo stato prima di rispondere' };
|
|
164
260
|
}
|
|
165
261
|
if (entry) entry.state = 'failed';
|
|
166
|
-
return { ok: false, code: r.status, error:
|
|
167
|
-
} catch (
|
|
262
|
+
return { ok: false, code: r.status, error: `HTTP ${r.status}`, reason: body.reason };
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (!op.active()) return deadlineResult(op, { ownerId, askId, rid }, false);
|
|
265
|
+
if (!op.delivered) {
|
|
266
|
+
const entry = attempts.get(key); if (entry) entry.state = 'failed';
|
|
267
|
+
return { ok: false, code: 404, reason: error.reason || 'reverse-slot-unverified', error: 'risposta non inoltrata' };
|
|
268
|
+
}
|
|
168
269
|
// The request may or may not have pasted: uncertain, never retried.
|
|
169
270
|
const entry = attempts.get(key);
|
|
170
271
|
if (entry) entry.state = 'uncertain';
|
|
171
272
|
return { ok: false, code: 200, uncertain: true, reason: 'delivery-unknown', requestId: rid,
|
|
172
273
|
error: 'esito incerto: verifica lo stato prima di rispondere' };
|
|
173
|
-
} finally {
|
|
174
|
-
clearTimeout(timer);
|
|
175
274
|
}
|
|
176
275
|
}
|
|
177
276
|
|
|
277
|
+
async function relayAnswer(args = {}) {
|
|
278
|
+
const rid = args.rid || requestId();
|
|
279
|
+
const key = attemptKey(args.ownerId, args.askId, rid);
|
|
280
|
+
const old = attempts.get(key);
|
|
281
|
+
const digest = crypto.createHash('sha256').update(String(args.text || '')).digest('hex');
|
|
282
|
+
if (old && old.digest !== digest) return { ok: false, code: 409, reason: 'request-conflict', error: 'stesso requestId con testo diverso' };
|
|
283
|
+
const pending = answerFlights.get(key);
|
|
284
|
+
if (pending && pending.digest !== digest) return { ok: false, code: 409, reason: 'request-conflict', error: 'stesso requestId con testo diverso' };
|
|
285
|
+
if (pending) return pending.promise;
|
|
286
|
+
const flight = operation({ ...args, rid }, op => relayAnswerOnce({ ...args, rid }, op), false, true);
|
|
287
|
+
const slot = { promise: flight, digest };
|
|
288
|
+
answerFlights.set(key, slot);
|
|
289
|
+
try { return await flight; } finally { if (answerFlights.get(key) === slot) answerFlights.delete(key); }
|
|
290
|
+
}
|
|
291
|
+
|
|
178
292
|
// Remote dismiss: same resolution rules as the answer (DELETE on the owner).
|
|
179
|
-
async function
|
|
180
|
-
const candidate = candidateOwner(ownerId);
|
|
293
|
+
async function relayDismissOnce({ ownerId, askId } = {}, op) {
|
|
294
|
+
const candidate = await candidateOwner(ownerId, op);
|
|
181
295
|
if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
|
|
182
296
|
if (candidate.kind === 'inbound-unverified') {
|
|
183
297
|
return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
|
|
184
298
|
}
|
|
185
299
|
if (candidate.kind === 'inbound') {
|
|
186
|
-
const gate = await verifyInboundChannel(candidate);
|
|
300
|
+
const gate = await verifyInboundChannel(candidate, op);
|
|
187
301
|
if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
|
|
188
302
|
}
|
|
189
303
|
const peer = candidate.node;
|
|
190
304
|
const store = loadStore();
|
|
191
305
|
try {
|
|
192
|
-
const r = await
|
|
193
|
-
|
|
194
|
-
headers: { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
|
|
195
|
-
});
|
|
196
|
-
const body = await r.json().catch(() => ({}));
|
|
197
|
-
if (!r.ok) return { ok: false, code: r.status, error: body.error || `HTTP ${r.status}` };
|
|
306
|
+
const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}`, { method: 'DELETE', op });
|
|
307
|
+
if (!r.ok) return { ok: false, code: r.status, error: 'owner refused', reason: body.reason };
|
|
198
308
|
return { ok: true };
|
|
199
|
-
} catch (e) { return { ok: false,
|
|
309
|
+
} catch (e) { if (!op.active()) return deadlineResult(op, { ownerId, askId }, false); return { ok: false, code: 502, reason: safeReason(e.reason), error: 'owner unreachable' }; }
|
|
200
310
|
}
|
|
201
311
|
|
|
202
|
-
async function
|
|
203
|
-
const candidate = candidateOwner(ownerId);
|
|
312
|
+
async function verifyStatusOnce({ ownerId, askId, requestId: rid }, op) {
|
|
313
|
+
const candidate = await candidateOwner(ownerId, op);
|
|
204
314
|
if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
|
|
205
315
|
if (candidate.kind === 'inbound-unverified') {
|
|
206
316
|
return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
|
|
207
317
|
}
|
|
208
318
|
if (candidate.kind === 'inbound') {
|
|
209
|
-
const gate = await verifyInboundChannel(candidate);
|
|
319
|
+
const gate = await verifyInboundChannel(candidate, op);
|
|
210
320
|
if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
|
|
211
321
|
}
|
|
212
322
|
const peer = candidate.node;
|
|
213
323
|
const store = loadStore();
|
|
214
324
|
try {
|
|
215
|
-
const r = await
|
|
216
|
-
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const body = await r.json();
|
|
325
|
+
const { r, body } = await forward(candidate,
|
|
326
|
+
`/event-feed/asks/${encodeURIComponent(askId)}/requests/${encodeURIComponent(rid)}`, { op });
|
|
327
|
+
if (!r.ok) return { ok: false, code: r.status, reason: body.reason, error: 'owner refused' };
|
|
328
|
+
op.check();
|
|
220
329
|
const key = attemptKey(ownerId, askId, rid);
|
|
221
330
|
if (attempts.has(key) && (body.state === 'committed' || body.state === 'failed')) {
|
|
222
331
|
attempts.get(key).state = body.state;
|
|
223
332
|
}
|
|
224
|
-
return { ok: true, state: body.state || 'unknown'
|
|
333
|
+
return { ok: true, state: body.state || 'unknown',
|
|
334
|
+
...(body.ownerId ? { ownerId: body.ownerId } : {}), ...(body.askId ? { askId: body.askId } : {}),
|
|
335
|
+
...(body.actor ? { actor: body.actor } : {}), ...(body.receipt ? { receipt: body.receipt } : {}) };
|
|
225
336
|
} catch (e) {
|
|
226
|
-
|
|
337
|
+
if (!op.active()) return deadlineResult(op, { ownerId, askId }, false);
|
|
338
|
+
return { ok: false, code: 502, reason: safeReason(e.reason), error: 'owner unreachable' };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function replyCapabilityOnce({ ownerId, askId } = {}, op) {
|
|
343
|
+
const denied = (status, reason) => ({ ownerId, askId, canReply: false, status, ...(reason ? { reason } : {}) });
|
|
344
|
+
const candidate = await candidateOwner(ownerId, op);
|
|
345
|
+
if (!candidate) return denied('unreachable', 'owner-unknown');
|
|
346
|
+
if (candidate.kind === 'inbound-unverified') return denied('unreachable', 'reverse-slot-unverified');
|
|
347
|
+
if (candidate.kind === 'inbound') {
|
|
348
|
+
const gate = await verifyInboundChannel(candidate, op);
|
|
349
|
+
if (!gate.ok) return denied('unreachable', gate.reason);
|
|
227
350
|
}
|
|
351
|
+
try {
|
|
352
|
+
const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}/capability`, { op });
|
|
353
|
+
if (body.reason === 'reverse-slot-unverified') return denied('unreachable', body.reason);
|
|
354
|
+
if (r.status === 404 || r.status === 501) return denied('unsupported', body.reason);
|
|
355
|
+
if (r.status === 403) return denied('denied', body.reason);
|
|
356
|
+
if (!r.ok || body.ownerId !== ownerId || body.askId !== askId || typeof body.canReply !== 'boolean'
|
|
357
|
+
|| !['open', 'denied', 'answered', 'dismissed'].includes(body.status)
|
|
358
|
+
|| (body.canReply && body.status !== 'open')) return denied('unreachable');
|
|
359
|
+
return { ownerId, askId, canReply: body.canReply, status: body.status };
|
|
360
|
+
} catch (_) { return denied('unreachable', op.active() ? undefined : 'relay-deadline'); }
|
|
228
361
|
}
|
|
229
362
|
|
|
363
|
+
function relayDismiss(args = {}) { return operation(args, op => relayDismissOnce(args, op)); }
|
|
364
|
+
function verifyStatus(args = {}) { return operation(args, op => verifyStatusOnce(args, op)); }
|
|
365
|
+
function replyCapability(args = {}) { return operation(args, op => replyCapabilityOnce(args, op), true); }
|
|
366
|
+
|
|
230
367
|
function state() {
|
|
231
368
|
return [...attempts.entries()].map(([key, a]) => ({ key, ...a }));
|
|
232
369
|
}
|
|
233
370
|
|
|
234
|
-
return { relayAnswer, relayDismiss, verifyStatus, state, isUncertain, requestId };
|
|
371
|
+
return { relayAnswer, relayDismiss, verifyStatus, replyCapability, state, isUncertain, requestId };
|
|
235
372
|
}
|
|
236
373
|
|
|
237
374
|
module.exports = { createAskRelay };
|
|
@@ -5,46 +5,81 @@
|
|
|
5
5
|
// DELETE /event-feed/asks/:id (dismiss)
|
|
6
6
|
// GET /event-feed/asks/:id/requests/:requestId (attempt status)
|
|
7
7
|
//
|
|
8
|
-
// The gate
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// The action gate verifies the bounded origin chain and the delivering peer,
|
|
9
|
+
// then intersects fresh ASK grants and the authoritative ask cell scope.
|
|
10
|
+
// It is independent of the stream/snapshot gate and local feed reception.
|
|
11
11
|
// Unknown and out-of-scope ids get the SAME opaque 404: an id that does not
|
|
12
12
|
// exist and an id the peer may not see must be indistinguishable. Answer and
|
|
13
13
|
// dismiss run on a DEDICATED budget (6/min/peer, 30/min global) that shares
|
|
14
14
|
// nothing with notify/audio/local ask creation.
|
|
15
15
|
|
|
16
16
|
const express = require('express');
|
|
17
|
-
const
|
|
17
|
+
const nodesStore = require('../nodes/store.js');
|
|
18
|
+
const accessPresets = require('../nodes/access-presets.js');
|
|
19
|
+
const { createAudioAcl } = require('../audio/acl.js');
|
|
20
|
+
const { visibilityAllows } = require('./event-feed-acl.js');
|
|
18
21
|
|
|
19
22
|
const ANSWER_RATE_PER_MIN = 6;
|
|
20
23
|
const ANSWER_GLOBAL_PER_MIN = 30;
|
|
21
24
|
|
|
22
25
|
function createAskRateLimiter({ now = Date.now } = {}) {
|
|
23
26
|
const perPeer = new Map();
|
|
27
|
+
const perOrigin = new Map();
|
|
24
28
|
const global = [];
|
|
25
29
|
function prune(list, t) {
|
|
26
30
|
const cutoff = t - 60000;
|
|
27
31
|
while (list.length > 0 && list[0] <= cutoff) list.shift();
|
|
28
32
|
}
|
|
29
|
-
function check(peerId) {
|
|
33
|
+
function check(peerId, actorId = peerId) {
|
|
30
34
|
const t = now();
|
|
31
35
|
const list = perPeer.get(peerId) || [];
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
const origin = perOrigin.get(actorId) || [];
|
|
37
|
+
prune(list, t); prune(origin, t); prune(global, t);
|
|
38
|
+
if (list.length >= ANSWER_RATE_PER_MIN || origin.length >= ANSWER_RATE_PER_MIN || global.length >= ANSWER_GLOBAL_PER_MIN) {
|
|
34
39
|
return { allowed: false, reason: 'answer-rate' };
|
|
35
40
|
}
|
|
36
|
-
list.push(t); perPeer.set(peerId, list); global.push(t);
|
|
41
|
+
list.push(t); perPeer.set(peerId, list); origin.push(t); perOrigin.set(actorId, origin); global.push(t);
|
|
37
42
|
return { allowed: true };
|
|
38
43
|
}
|
|
39
44
|
return { check };
|
|
40
45
|
}
|
|
41
46
|
|
|
47
|
+
function createAskActionGate(deps) {
|
|
48
|
+
return async (req, res) => {
|
|
49
|
+
const refuse = (code, reason) => { res.status(code).json({ error: 'forbidden', reason }); return null; };
|
|
50
|
+
if (deps.eventsEnabled() !== true) return refuse(403, 'events-disabled');
|
|
51
|
+
const resolved = await deps.originResolver.resolve(req, { requireCell: false });
|
|
52
|
+
if (!resolved.ok) return refuse(403, resolved.reason);
|
|
53
|
+
if (resolved.trust !== 'federated') return refuse(403, 'federated-origin-required');
|
|
54
|
+
const visited = resolved.visited || [];
|
|
55
|
+
const self = deps.localNodeId();
|
|
56
|
+
if (visited.length < 2 || visited.at(-1) !== self) return refuse(403, 'hop-chain');
|
|
57
|
+
const st = nodesStore.loadStore(deps.nodesPath);
|
|
58
|
+
if (!st) return refuse(503, 'store-unavailable');
|
|
59
|
+
const deliveringId = visited.at(-2);
|
|
60
|
+
const delivering = (st.nodes || []).filter((n) => n && n.nodeId === deliveringId);
|
|
61
|
+
if (delivering.length !== 1) return refuse(403, 'peer-unknown');
|
|
62
|
+
const acl = createAudioAcl({ nodesPath: deps.nodesPath, loadStoreImpl: () => st });
|
|
63
|
+
const visibility = acl.allows(resolved);
|
|
64
|
+
if (!visibility.allowed) return refuse(403, visibility.reason);
|
|
65
|
+
const actor = resolved.origin.node;
|
|
66
|
+
const origins = actor === deliveringId ? [] : (st.nodes || []).filter((n) => n && n.nodeId === actor);
|
|
67
|
+
if (origins.length > 1) return refuse(403, 'peer-unknown');
|
|
68
|
+
const grants = [delivering[0], ...origins].map((node) => accessPresets.grantsOf(node));
|
|
69
|
+
if (grants.some((view) => !view.configured || view.grants.eventsAccess !== true || view.grants.askReplyAccess !== true)) {
|
|
70
|
+
return refuse(403, 'grant-required:ask-action');
|
|
71
|
+
}
|
|
72
|
+
return { self, actor, peer: { nodeId: deliveringId, grants: grants[0].grants,
|
|
73
|
+
allows: ({ cellId }) => grants.every((view) => visibilityAllows(view.grants, cellId)) } };
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
42
77
|
function createEventFeedAsksRoutes(deps) {
|
|
43
78
|
const r = express.Router();
|
|
44
79
|
const json = express.json({ limit: '16kb' });
|
|
45
80
|
// deps: { nodesPath, localNodeId, originResolver, eventsEnabled, asks,
|
|
46
81
|
// answerService, cellForSession, log }
|
|
47
|
-
const gate =
|
|
82
|
+
const gate = createAskActionGate(deps);
|
|
48
83
|
const rate = createAskRateLimiter({ now: deps.now });
|
|
49
84
|
const log = typeof deps.log === 'function' ? deps.log : () => {};
|
|
50
85
|
|
|
@@ -59,10 +94,21 @@ function createEventFeedAsksRoutes(deps) {
|
|
|
59
94
|
return { ask, cellId };
|
|
60
95
|
}
|
|
61
96
|
|
|
97
|
+
r.get('/:id/capability', async (req, res) => {
|
|
98
|
+
const g = await gate(req, res);
|
|
99
|
+
if (!g) return;
|
|
100
|
+
const askId = String(req.params.id || '');
|
|
101
|
+
const resolved = resolveAskForPeer(g.peer, askId);
|
|
102
|
+
const readonly = deps.readonly && deps.readonly();
|
|
103
|
+
const status = !resolved || readonly ? 'denied'
|
|
104
|
+
: resolved.ask.dismissed ? 'dismissed' : resolved.ask.answered ? 'answered' : 'open';
|
|
105
|
+
return res.json({ ownerId: g.self, askId, canReply: status === 'open', status });
|
|
106
|
+
});
|
|
107
|
+
|
|
62
108
|
r.post('/:id/answer', json, async (req, res) => {
|
|
63
109
|
const g = await gate(req, res);
|
|
64
110
|
if (!g) return;
|
|
65
|
-
const verdict = rate.check(g.peer.nodeId);
|
|
111
|
+
const verdict = rate.check(g.peer.nodeId, g.actor);
|
|
66
112
|
if (!verdict.allowed) return res.status(429).json({ error: 'answer rate', reason: verdict.reason });
|
|
67
113
|
if (deps.readonly && deps.readonly()) {
|
|
68
114
|
return res.status(403).json({ error: 'READONLY: mutazione bloccata' });
|
|
@@ -85,13 +131,14 @@ function createEventFeedAsksRoutes(deps) {
|
|
|
85
131
|
const resolved = resolveAskForPeer(g.peer, askId);
|
|
86
132
|
if (!resolved) return res.status(404).json({ error: 'ask inesistente' });
|
|
87
133
|
const out = await deps.answerService.answerFederated({
|
|
88
|
-
askId, text: body.text, peerId: g.
|
|
134
|
+
askId, text: body.text, peerId: g.actor, requestId: body.requestId,
|
|
89
135
|
});
|
|
90
136
|
if (out.ok) {
|
|
91
137
|
// La chiusura nasce dal servizio; qui si aspetta il recapito, cosi' chi ha
|
|
92
138
|
// risposto non vede una risposta che precede la chiusura dei peer.
|
|
93
139
|
if (out.closure) { try { await out.closure; } catch (_) {} }
|
|
94
|
-
return res.json({ status: out.replay ? out.state : 'committed', requestId: body.requestId
|
|
140
|
+
return res.json({ status: out.replay ? out.state : 'committed', requestId: body.requestId,
|
|
141
|
+
ownerId: g.self, askId, actor: g.actor });
|
|
95
142
|
}
|
|
96
143
|
return res.status(out.code || 500).json({ error: out.error, reason: out.reason });
|
|
97
144
|
});
|
|
@@ -99,7 +146,7 @@ function createEventFeedAsksRoutes(deps) {
|
|
|
99
146
|
r.delete('/:id', async (req, res) => {
|
|
100
147
|
const g = await gate(req, res);
|
|
101
148
|
if (!g) return;
|
|
102
|
-
const verdict = rate.check(g.peer.nodeId);
|
|
149
|
+
const verdict = rate.check(g.peer.nodeId, g.actor);
|
|
103
150
|
if (!verdict.allowed) return res.status(429).json({ error: 'answer rate', reason: verdict.reason });
|
|
104
151
|
if (deps.readonly && deps.readonly()) {
|
|
105
152
|
return res.status(403).json({ error: 'READONLY: mutazione bloccata' });
|
|
@@ -133,10 +180,10 @@ function createEventFeedAsksRoutes(deps) {
|
|
|
133
180
|
if (!resolved) return res.status(404).json({ error: 'ask inesistente' });
|
|
134
181
|
// Scoped: a peer sees ONLY its own attempts, and never the answer text of
|
|
135
182
|
// anyone — the receipt carries a digest, not a payload.
|
|
136
|
-
const entry = deps.receipts.get(g.
|
|
183
|
+
const entry = deps.receipts.get(g.actor, askId, requestId);
|
|
137
184
|
if (!entry) return res.status(404).json({ error: 'ask inesistente' });
|
|
138
185
|
return res.json({
|
|
139
|
-
requestId, askId, state: entry.state,
|
|
186
|
+
requestId, askId, ownerId: g.self, actor: g.actor, state: entry.state,
|
|
140
187
|
...(entry.receipt ? { receipt: entry.receipt } : {}),
|
|
141
188
|
...(entry.updatedAt ? { updatedAt: entry.updatedAt } : {}),
|
|
142
189
|
});
|