@mmmbuto/nexuscrew 0.9.57 → 0.9.59
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 +20 -0
- package/README.md +32 -8
- package/frontend/dist/assets/index-CZFJnks_.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 +54 -6
- package/lib/notify/ask-answer-service.js +23 -11
- package/lib/notify/ask-receipts.js +1 -0
- package/lib/notify/ask-relay.js +261 -74
- package/lib/notify/asks.js +117 -9
- package/lib/notify/event-feed-asks-routes.js +79 -19
- package/lib/notify/event-feed-client.js +130 -50
- package/lib/notify/event-feed-producers.js +1 -0
- package/lib/notify/imported-dismissals.js +106 -0
- package/lib/notify/routes.js +39 -20
- package/lib/proxy/federation.js +112 -12
- package/lib/proxy/resource-acl.js +1 -0
- package/lib/server.js +161 -23
- 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,62 @@
|
|
|
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', 'generation-mismatch', 'invalid-generation', 'unsupported',
|
|
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, availabilityBinding = () => null, slotVerifier = askSlotVerifier } = {}) {
|
|
21
35
|
// (ownerId|askId|requestId) -> {state: 'sent'|'committed'|'failed'|'uncertain', requestId}
|
|
36
|
+
const channelObservations = new Map();
|
|
22
37
|
const attempts = new Map();
|
|
38
|
+
const answerFlights = new Map();
|
|
39
|
+
|
|
40
|
+
const proofScope = nodesPath || loadStore;
|
|
41
|
+
function deadlineResult(op, args, capability) {
|
|
42
|
+
if (capability) return { ownerId: args.ownerId, askId: args.askId, canReply: false, status: 'unreachable', reason: 'relay-deadline' };
|
|
43
|
+
if (op.answer && op.delivered && args.rid) {
|
|
44
|
+
return { ok: false, code: 200, uncertain: true, requestId: args.rid, reason: 'delivery-unknown', error: 'esito incerto: verifica lo stato prima di rispondere' };
|
|
45
|
+
}
|
|
46
|
+
return { ok: false, code: 504, reason: 'relay-deadline', error: 'relay deadline' };
|
|
47
|
+
}
|
|
48
|
+
async function operation(args, work, capability = false, answer = false) {
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
const op = { controller, answer, delivered: false, expired: false, proofKey: null, proofBinding: null };
|
|
51
|
+
let rejectAbort;
|
|
52
|
+
const aborted = new Promise((_resolve, reject) => { rejectAbort = () => reject(new Error('relay deadline')); });
|
|
53
|
+
controller.signal.addEventListener('abort', rejectAbort, { once: true });
|
|
54
|
+
op.active = () => !controller.signal.aborted;
|
|
55
|
+
op.check = () => { if (!op.active()) throw new Error('relay deadline'); };
|
|
56
|
+
op.wait = promise => Promise.race([promise, aborted]);
|
|
57
|
+
const timer = setTimeout(() => { op.expired = true; controller.abort(); }, RELAY_DEADLINE_MS);
|
|
58
|
+
try { return await Promise.race([Promise.resolve().then(() => work(op)), aborted]); }
|
|
59
|
+
catch (_) {
|
|
60
|
+
slotVerifier.invalidate(op.proofKey);
|
|
61
|
+
if (op.answer && op.delivered && args.rid) {
|
|
62
|
+
const entry = attempts.get(attemptKey(args.ownerId, args.askId, args.rid));
|
|
63
|
+
if (entry) entry.state = 'uncertain';
|
|
64
|
+
}
|
|
65
|
+
if (op.expired) return deadlineResult(op, args, capability);
|
|
66
|
+
return capability
|
|
67
|
+
? { ownerId: args.ownerId, askId: args.askId, canReply: false, status: 'unreachable' }
|
|
68
|
+
: { ok: false, code: 502, error: 'owner unreachable' };
|
|
69
|
+
} finally {
|
|
70
|
+
clearTimeout(timer); controller.signal.removeEventListener('abort', rejectAbort); controller.abort();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
23
73
|
|
|
24
74
|
function requestId() { return crypto.randomUUID(); }
|
|
25
75
|
|
|
@@ -28,52 +78,103 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
28
78
|
// attivo. Per gli inbound la porta dello slot DEVE essere verificata con la
|
|
29
79
|
// stessa sonda dei terminali (secret del peer + instanceId atteso): nessun
|
|
30
80
|
// fallback sulla localPort senza proof.
|
|
31
|
-
function candidateOwner(ownerId) {
|
|
32
|
-
|
|
81
|
+
async function candidateOwner(ownerId, op) {
|
|
82
|
+
let store = loadStore();
|
|
33
83
|
if (!store) return null;
|
|
84
|
+
let routed = null;
|
|
85
|
+
if (typeof peers === 'function') {
|
|
86
|
+
let inventory;
|
|
87
|
+
try { inventory = await op.wait(peers()); } catch (_) { op.check(); return null; }
|
|
88
|
+
op.check();
|
|
89
|
+
const matches = (Array.isArray(inventory) ? inventory : [])
|
|
90
|
+
.filter((p) => p && (p.nodeId === ownerId || p.instanceId === ownerId));
|
|
91
|
+
if (matches.length !== 1 || matches[0].stale === true) return null;
|
|
92
|
+
const route = matches[0].route;
|
|
93
|
+
if (!Array.isArray(route) || !route.length || route.length > 4
|
|
94
|
+
|| route.some((name) => typeof name !== 'string' || !NODE_NAME_RE.test(name))
|
|
95
|
+
|| new Set(route).size !== route.length) return null;
|
|
96
|
+
routed = [...route];
|
|
97
|
+
}
|
|
98
|
+
op.check();
|
|
99
|
+
store = loadStore();
|
|
100
|
+
if (!store) return null;
|
|
101
|
+
slotVerifier.sync(store, proofScope);
|
|
34
102
|
const nodes = store.nodes || [];
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
103
|
+
const direct = nodes.filter((n) => n && n.nodeId === ownerId);
|
|
104
|
+
if (direct.length > 1) return null;
|
|
105
|
+
const out = direct.find((n) => n.direction === 'outbound' && n.token && n.localPort);
|
|
106
|
+
if (out) return { node: out, forwardPort: out.localPort, forwardToken: out.token, kind: 'outbound', route: routed };
|
|
107
|
+
if (!direct.length && routed) return { node: null, kind: 'routed', route: routed };
|
|
38
108
|
const inbound = nodes.find((n) => n && n.direction === 'inbound'
|
|
39
109
|
&& n.shared === true && n.nodeId === ownerId
|
|
40
|
-
&&
|
|
110
|
+
&& n.token
|
|
41
111
|
&& n.askReplyAccess === true && n.eventsAccess === true
|
|
42
112
|
&& n.cellVisibility !== 'none');
|
|
43
113
|
if (!inbound) return null;
|
|
44
|
-
const forwardToken = inbound.token
|
|
114
|
+
const forwardToken = inbound.token;
|
|
45
115
|
const pool = inbound.reversePool;
|
|
46
116
|
const slot = pool && Array.isArray(pool.slots) ? pool.slots[pool.activeSlot] : null;
|
|
47
117
|
if (!slot || !Number.isInteger(slot.port)) {
|
|
48
|
-
return { node: inbound, forwardPort: null, forwardToken, kind: 'inbound-unverified' };
|
|
118
|
+
return { node: inbound, forwardPort: null, forwardToken, kind: 'inbound-unverified', route: routed };
|
|
49
119
|
}
|
|
50
|
-
return { node: inbound, forwardPort: slot.port, forwardToken, kind: 'inbound' };
|
|
120
|
+
return { node: inbound, forwardPort: slot.port, forwardToken, kind: 'inbound', route: routed };
|
|
51
121
|
}
|
|
52
122
|
|
|
53
123
|
function ownerUrl(port, path) {
|
|
54
124
|
return `http://127.0.0.1:${port}/federation/route/_/${path.replace(/^\//, '')}`;
|
|
55
125
|
}
|
|
56
126
|
|
|
127
|
+
async function forward(candidate, path, { method = 'GET', body, expectedTs, op } = {}) {
|
|
128
|
+
op.check();
|
|
129
|
+
const st = loadStore();
|
|
130
|
+
slotVerifier.sync(st, proofScope);
|
|
131
|
+
if (candidate.kind === 'inbound' && !candidate.route) {
|
|
132
|
+
const current = st && (st.nodes || []).find(node => node.name === candidate.node.name);
|
|
133
|
+
const selected = binding(st, current);
|
|
134
|
+
if (!selected || selected.fingerprint !== op.proofBinding) {
|
|
135
|
+
slotVerifier.invalidate(op.proofKey);
|
|
136
|
+
const error = new Error('reverse slot changed'); error.reason = 'reverse-slot-unverified'; throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const routed = candidate.route;
|
|
140
|
+
const url = routed
|
|
141
|
+
? `http://127.0.0.1:${localPort()}/api/route/${routed.map(encodeURIComponent).join('/')}/_${path}`
|
|
142
|
+
: ownerUrl(candidate.forwardPort, path);
|
|
143
|
+
const headers = routed ? { authorization: `Bearer ${localToken()}` }
|
|
144
|
+
: { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': st && st.nodeId || '' };
|
|
145
|
+
try {
|
|
146
|
+
op.check(); op.delivered = true;
|
|
147
|
+
const r = await op.wait(fetchImpl(url, { method, headers: { ...headers, 'content-type': 'application/json',
|
|
148
|
+
...(expectedTs === undefined ? {} : { 'x-nexuscrew-ask-ts': String(expectedTs) }) },
|
|
149
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: op.controller.signal }));
|
|
150
|
+
op.check();
|
|
151
|
+
const payload = await op.wait(r.json().catch(error => {
|
|
152
|
+
if ([403, 404, 501].includes(r.status)) return {};
|
|
153
|
+
throw error;
|
|
154
|
+
}));
|
|
155
|
+
op.check();
|
|
156
|
+
if (!r.ok) slotVerifier.invalidate(op.proofKey);
|
|
157
|
+
return { r, body: { ...payload, reason: safeReason(payload && payload.reason) } };
|
|
158
|
+
} catch (error) { slotVerifier.invalidate(op.proofKey); throw error; }
|
|
159
|
+
}
|
|
160
|
+
|
|
57
161
|
// Preflight del canale reverse per un peer inbound: rifiuto NETTO se lo slot
|
|
58
162
|
// 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
|
-
};
|
|
163
|
+
async function verifyInboundChannel(candidate, op) {
|
|
164
|
+
// REST routes prove the slot at their inbound proxy hop, never twice.
|
|
165
|
+
if (candidate.route) return { ok: true };
|
|
166
|
+
op.check();
|
|
167
|
+
const st = loadStore();
|
|
168
|
+
const proof = await op.wait(slotVerifier.verify({ store: st, peer: candidate.node, loadStore,
|
|
169
|
+
scope: proofScope, signal: op.controller.signal, probeImpl: probeReverseSlotImpl }));
|
|
170
|
+
op.check();
|
|
171
|
+
if (proof && proof.owned === true) {
|
|
172
|
+
op.proofKey = proof.key;
|
|
173
|
+
const selected = binding(st, candidate.node);
|
|
174
|
+
op.proofBinding = selected && selected.fingerprint;
|
|
175
|
+
return { ok: true };
|
|
176
|
+
}
|
|
177
|
+
return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
|
|
77
178
|
}
|
|
78
179
|
|
|
79
180
|
function attemptKey(ownerId, askId, rid) { return `${ownerId}|${askId}|${rid}`; }
|
|
@@ -99,17 +200,17 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
99
200
|
return sent.length ? sent[sent.length - 1].requestId : null;
|
|
100
201
|
}
|
|
101
202
|
|
|
102
|
-
async function
|
|
203
|
+
async function relayAnswerOnce({ ownerId, askId, text, rid: providedRid } = {}, op) {
|
|
103
204
|
if (!ownerId || !askId || typeof text !== 'string' || !text.trim()) {
|
|
104
205
|
return { ok: false, code: 400, error: 'ownerId, askId e text richiesti' };
|
|
105
206
|
}
|
|
106
|
-
const candidate = candidateOwner(ownerId);
|
|
207
|
+
const candidate = await candidateOwner(ownerId, op);
|
|
107
208
|
if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
|
|
108
209
|
if (candidate.kind === 'inbound-unverified') {
|
|
109
210
|
return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
|
|
110
211
|
}
|
|
111
212
|
if (candidate.kind === 'inbound') {
|
|
112
|
-
const gate = await verifyInboundChannel(candidate);
|
|
213
|
+
const gate = await verifyInboundChannel(candidate, op);
|
|
113
214
|
if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
|
|
114
215
|
}
|
|
115
216
|
const peer = candidate.node;
|
|
@@ -123,25 +224,22 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
123
224
|
const rid = providedRid || requestId();
|
|
124
225
|
const key = attemptKey(ownerId, askId, rid);
|
|
125
226
|
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);
|
|
227
|
+
attempts.set(key, { requestId: rid, state: 'sent', askId, ownerId, digest: crypto.createHash('sha256').update(text).digest('hex') });
|
|
129
228
|
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,
|
|
229
|
+
const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}/answer`, {
|
|
230
|
+
method: 'POST', body: { text, requestId: rid }, op,
|
|
139
231
|
});
|
|
140
|
-
|
|
232
|
+
op.check();
|
|
141
233
|
if (r.status === 200) {
|
|
142
|
-
const state = body.status === 'answered' ? 'committed' : body.status
|
|
234
|
+
const state = body.status === 'answered' ? 'committed' : body.status;
|
|
235
|
+
if (!['committed', 'failed'].includes(state)) {
|
|
236
|
+
attempts.get(key).state = 'uncertain';
|
|
237
|
+
return { ok: false, code: 200, uncertain: true, requestId: rid, reason: 'delivery-unknown' };
|
|
238
|
+
}
|
|
143
239
|
attempts.get(key).state = body.replay ? (body.state || state) : state;
|
|
144
|
-
return { ok: true, status: attempts.get(key).state, requestId: rid
|
|
240
|
+
return { ok: true, status: attempts.get(key).state, requestId: rid,
|
|
241
|
+
...(body.ownerId ? { ownerId: body.ownerId } : {}), ...(body.askId ? { askId: body.askId } : {}),
|
|
242
|
+
...(body.actor ? { actor: body.actor } : {}), ...(body.receipt ? { receipt: body.receipt } : {}) };
|
|
145
243
|
}
|
|
146
244
|
const entry = attempts.get(key);
|
|
147
245
|
// 409 request-conflict / answering are definitive; anything else that
|
|
@@ -155,7 +253,7 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
155
253
|
originalRequestId: originalSentRequestId(ownerId, askId, rid), requestId: rid,
|
|
156
254
|
error: 'esito incerto: bloccato da un invio precedente' };
|
|
157
255
|
}
|
|
158
|
-
return { ok: false, code: 409, error:
|
|
256
|
+
return { ok: false, code: 409, error: 'conflict', reason: body.reason };
|
|
159
257
|
}
|
|
160
258
|
if (r.status === 502) {
|
|
161
259
|
if (entry) entry.state = 'uncertain';
|
|
@@ -163,75 +261,164 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
163
261
|
error: 'esito incerto: verifica lo stato prima di rispondere' };
|
|
164
262
|
}
|
|
165
263
|
if (entry) entry.state = 'failed';
|
|
166
|
-
return { ok: false, code: r.status, error:
|
|
167
|
-
} catch (
|
|
264
|
+
return { ok: false, code: r.status, error: `HTTP ${r.status}`, reason: body.reason };
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (!op.active()) return deadlineResult(op, { ownerId, askId, rid }, false);
|
|
267
|
+
if (!op.delivered) {
|
|
268
|
+
const entry = attempts.get(key); if (entry) entry.state = 'failed';
|
|
269
|
+
return { ok: false, code: 404, reason: error.reason || 'reverse-slot-unverified', error: 'risposta non inoltrata' };
|
|
270
|
+
}
|
|
168
271
|
// The request may or may not have pasted: uncertain, never retried.
|
|
169
272
|
const entry = attempts.get(key);
|
|
170
273
|
if (entry) entry.state = 'uncertain';
|
|
171
274
|
return { ok: false, code: 200, uncertain: true, reason: 'delivery-unknown', requestId: rid,
|
|
172
275
|
error: 'esito incerto: verifica lo stato prima di rispondere' };
|
|
173
|
-
} finally {
|
|
174
|
-
clearTimeout(timer);
|
|
175
276
|
}
|
|
176
277
|
}
|
|
177
278
|
|
|
279
|
+
async function relayAnswer(args = {}) {
|
|
280
|
+
const rid = args.rid || requestId();
|
|
281
|
+
const key = attemptKey(args.ownerId, args.askId, rid);
|
|
282
|
+
const old = attempts.get(key);
|
|
283
|
+
const digest = crypto.createHash('sha256').update(String(args.text || '')).digest('hex');
|
|
284
|
+
if (old && old.digest !== digest) return { ok: false, code: 409, reason: 'request-conflict', error: 'stesso requestId con testo diverso' };
|
|
285
|
+
const pending = answerFlights.get(key);
|
|
286
|
+
if (pending && pending.digest !== digest) return { ok: false, code: 409, reason: 'request-conflict', error: 'stesso requestId con testo diverso' };
|
|
287
|
+
if (pending) return pending.promise;
|
|
288
|
+
const flight = operation({ ...args, rid }, op => relayAnswerOnce({ ...args, rid }, op), false, true);
|
|
289
|
+
const slot = { promise: flight, digest };
|
|
290
|
+
answerFlights.set(key, slot);
|
|
291
|
+
try { return await flight; } finally { if (answerFlights.get(key) === slot) answerFlights.delete(key); }
|
|
292
|
+
}
|
|
293
|
+
|
|
178
294
|
// Remote dismiss: same resolution rules as the answer (DELETE on the owner).
|
|
179
|
-
|
|
180
|
-
const
|
|
295
|
+
function retryAfterMs(response) {
|
|
296
|
+
const value = response.headers?.get?.('retry-after');
|
|
297
|
+
if (typeof value !== 'string') return 0;
|
|
298
|
+
const seconds = /^\d+(?:\.\d+)?$/.test(value.trim()) ? Number(value) : null;
|
|
299
|
+
const delay = seconds === null ? Date.parse(value) - now() : seconds * 1000;
|
|
300
|
+
return Number.isFinite(delay) && delay > 0 ? delay : 0;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function relayDismissOnce({ ownerId, askId, expectedTs } = {}, op) {
|
|
304
|
+
const candidate = await candidateOwner(ownerId, op);
|
|
181
305
|
if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
|
|
182
306
|
if (candidate.kind === 'inbound-unverified') {
|
|
183
307
|
return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
|
|
184
308
|
}
|
|
185
309
|
if (candidate.kind === 'inbound') {
|
|
186
|
-
const gate = await verifyInboundChannel(candidate);
|
|
310
|
+
const gate = await verifyInboundChannel(candidate, op);
|
|
187
311
|
if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
|
|
188
312
|
}
|
|
189
313
|
const peer = candidate.node;
|
|
190
314
|
const store = loadStore();
|
|
191
315
|
try {
|
|
192
|
-
const r = await
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const body = await r.json().catch(() => ({}));
|
|
197
|
-
if (!r.ok) return { ok: false, code: r.status, error: body.error || `HTTP ${r.status}` };
|
|
198
|
-
return { ok: true };
|
|
199
|
-
} catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
|
|
316
|
+
const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}`, { method: 'DELETE', expectedTs, op });
|
|
317
|
+
if (!r.ok) return { ok: false, code: r.status, error: 'owner refused', reason: body.reason, retryAfterMs: retryAfterMs(r) };
|
|
318
|
+
return { ok: true, ...(body.outcome ? { outcome: body.outcome } : {}) };
|
|
319
|
+
} catch (e) { if (!op.active()) return deadlineResult(op, { ownerId, askId }, false); return { ok: false, code: 502, reason: safeReason(e.reason), error: 'owner unreachable' }; }
|
|
200
320
|
}
|
|
201
321
|
|
|
202
|
-
async function
|
|
203
|
-
const candidate = candidateOwner(ownerId);
|
|
322
|
+
async function verifyStatusOnce({ ownerId, askId, requestId: rid }, op) {
|
|
323
|
+
const candidate = await candidateOwner(ownerId, op);
|
|
204
324
|
if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
|
|
205
325
|
if (candidate.kind === 'inbound-unverified') {
|
|
206
326
|
return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
|
|
207
327
|
}
|
|
208
328
|
if (candidate.kind === 'inbound') {
|
|
209
|
-
const gate = await verifyInboundChannel(candidate);
|
|
329
|
+
const gate = await verifyInboundChannel(candidate, op);
|
|
210
330
|
if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
|
|
211
331
|
}
|
|
212
332
|
const peer = candidate.node;
|
|
213
333
|
const store = loadStore();
|
|
214
334
|
try {
|
|
215
|
-
const r = await
|
|
216
|
-
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const body = await r.json();
|
|
335
|
+
const { r, body } = await forward(candidate,
|
|
336
|
+
`/event-feed/asks/${encodeURIComponent(askId)}/requests/${encodeURIComponent(rid)}`, { op });
|
|
337
|
+
if (!r.ok) return { ok: false, code: r.status, reason: body.reason, error: 'owner refused' };
|
|
338
|
+
op.check();
|
|
220
339
|
const key = attemptKey(ownerId, askId, rid);
|
|
221
340
|
if (attempts.has(key) && (body.state === 'committed' || body.state === 'failed')) {
|
|
222
341
|
attempts.get(key).state = body.state;
|
|
223
342
|
}
|
|
224
|
-
return { ok: true, state: body.state || 'unknown'
|
|
343
|
+
return { ok: true, state: body.state || 'unknown',
|
|
344
|
+
...(body.ownerId ? { ownerId: body.ownerId } : {}), ...(body.askId ? { askId: body.askId } : {}),
|
|
345
|
+
...(body.actor ? { actor: body.actor } : {}), ...(body.receipt ? { receipt: body.receipt } : {}) };
|
|
225
346
|
} catch (e) {
|
|
226
|
-
|
|
347
|
+
if (!op.active()) return deadlineResult(op, { ownerId, askId }, false);
|
|
348
|
+
return { ok: false, code: 502, reason: safeReason(e.reason), error: 'owner unreachable' };
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function replyCapabilityOnce({ ownerId, askId } = {}, op) {
|
|
353
|
+
const denied = (status, reason) => ({ ownerId, askId, canReply: false, status, ...(reason ? { reason } : {}) });
|
|
354
|
+
const candidate = await candidateOwner(ownerId, op);
|
|
355
|
+
if (!candidate) return denied('unreachable', 'owner-unknown');
|
|
356
|
+
if (candidate.kind === 'inbound-unverified') { op.channelUnavailable = true; return denied('unreachable', 'reverse-slot-unverified'); }
|
|
357
|
+
if (candidate.kind === 'inbound') {
|
|
358
|
+
const gate = await verifyInboundChannel(candidate, op);
|
|
359
|
+
if (!gate.ok) { op.channelUnavailable = true; return denied('unreachable', gate.reason); }
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}/capability`, { op });
|
|
363
|
+
if (['reverse-slot-unverified', 'federation-peer-unreachable', 'federation-peer-timeout'].includes(body.reason)) { op.channelUnavailable = true; return denied('unreachable', body.reason); }
|
|
364
|
+
if (r.status === 404 || r.status === 501) return denied('unsupported', body.reason);
|
|
365
|
+
if (r.status === 403) return denied('denied', body.reason);
|
|
366
|
+
if (!r.ok || body.ownerId !== ownerId || body.askId !== askId || typeof body.canReply !== 'boolean'
|
|
367
|
+
|| !['open', 'denied', 'answered', 'dismissed'].includes(body.status)
|
|
368
|
+
|| (body.canReply && body.status !== 'open')) return denied('unreachable');
|
|
369
|
+
return { ownerId, askId, canReply: body.canReply, status: body.status };
|
|
370
|
+
} catch (_) { op.channelUnavailable = true; return denied('unreachable', op.active() ? undefined : 'relay-deadline'); }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function inspectDismissalOnce({ ownerId, askId } = {}, op) {
|
|
374
|
+
const candidate = await candidateOwner(ownerId, op);
|
|
375
|
+
if (!candidate) return { ok: false, code: 404, reason: 'owner-unknown' };
|
|
376
|
+
if (candidate.kind === 'inbound-unverified') return { ok: false, code: 404, reason: 'reverse-slot-unverified' };
|
|
377
|
+
if (candidate.kind === 'inbound') {
|
|
378
|
+
const proof = await verifyInboundChannel(candidate, op);
|
|
379
|
+
if (!proof.ok) return proof;
|
|
380
|
+
}
|
|
381
|
+
const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}/capability`, { op });
|
|
382
|
+
if (!r.ok) return { ok: false, code: r.status, reason: body.reason || 'unknown', retryAfterMs: retryAfterMs(r) };
|
|
383
|
+
if (body.ownerId !== ownerId || body.askId !== askId || !['open', 'answered', 'dismissed'].includes(body.status)) {
|
|
384
|
+
return { ok: false, code: 403, reason: 'unknown' };
|
|
385
|
+
}
|
|
386
|
+
if (body.generationPrecondition !== true) return { ok: false, code: 501, reason: 'unsupported' };
|
|
387
|
+
const ask = body.ask;
|
|
388
|
+
if (!ask || ask.id !== askId || !Number.isSafeInteger(ask.ts) || ask.ts <= 0
|
|
389
|
+
|| typeof ask.question !== 'string' || typeof ask.session !== 'string'
|
|
390
|
+
|| (ask.options !== undefined && (!Array.isArray(ask.options) || ask.options.some(option => typeof option !== 'string')))) {
|
|
391
|
+
return { ok: false, code: 409, reason: 'generation-mismatch' };
|
|
227
392
|
}
|
|
393
|
+
return { ok: true, status: body.status, ask, generationPrecondition: true };
|
|
394
|
+
}
|
|
395
|
+
function inspectDismissal(args = {}) { return operation(args, op => inspectDismissalOnce(args, op)); }
|
|
396
|
+
|
|
397
|
+
function relayDismiss(args = {}) { return operation(args, op => relayDismissOnce(args, op)); }
|
|
398
|
+
function verifyStatus(args = {}) { return operation(args, op => verifyStatusOnce(args, op)); }
|
|
399
|
+
function channelBinding(ownerId) {
|
|
400
|
+
const store = loadStore();
|
|
401
|
+
// Kept only in process memory; credentials never enter the dismissal store.
|
|
402
|
+
return crypto.createHash('sha256').update(JSON.stringify([store, availabilityBinding(ownerId)])).digest('hex');
|
|
403
|
+
}
|
|
404
|
+
function channelState(ownerId) {
|
|
405
|
+
const observation = channelObservations.get(ownerId);
|
|
406
|
+
return { unavailable: !!(observation && now() - observation.at < 30000 && observation.binding === channelBinding(ownerId) && observation.unavailable) };
|
|
407
|
+
}
|
|
408
|
+
function replyCapability(args = {}) {
|
|
409
|
+
const initial = channelBinding(args.ownerId);
|
|
410
|
+
return operation(args, async op => {
|
|
411
|
+
const result = await replyCapabilityOnce(args, op);
|
|
412
|
+
channelObservations.set(args.ownerId, { at: now(), binding: initial, unavailable: op.channelUnavailable === true });
|
|
413
|
+
return result;
|
|
414
|
+
}, true);
|
|
228
415
|
}
|
|
229
416
|
|
|
230
417
|
function state() {
|
|
231
418
|
return [...attempts.entries()].map(([key, a]) => ({ key, ...a }));
|
|
232
419
|
}
|
|
233
420
|
|
|
234
|
-
return { relayAnswer, relayDismiss, verifyStatus, state, isUncertain, requestId };
|
|
421
|
+
return { channelState, inspectDismissal, relayAnswer, relayDismiss, verifyStatus, replyCapability, state, isUncertain, requestId };
|
|
235
422
|
}
|
|
236
423
|
|
|
237
424
|
module.exports = { createAskRelay };
|