@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.
@@ -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 IDLE_MS = 15000;
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
- const store = loadStore();
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 out = nodes.find((n) => n && n.direction === 'outbound'
36
- && n.nodeId === ownerId && n.token && n.localPort);
37
- if (out) return { node: out, forwardPort: out.localPort, forwardToken: out.token, kind: 'outbound' };
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
- && (n.token || n.acceptToken)
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 || inbound.acceptToken;
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
- const { node, forwardPort } = candidate;
61
- const pool = node.reversePool;
62
- const slot = pool && Array.isArray(pool.slots) ? pool.slots[pool.activeSlot] : null;
63
- const proof = await probeReverseSlotImpl({
64
- port: forwardPort, secret: node.token,
65
- expected: {
66
- remotePort: forwardPort,
67
- generation: pool && Number.isInteger(pool.activeGeneration) ? pool.activeGeneration : undefined,
68
- instanceId: node.nodeId,
69
- },
70
- });
71
- if (proof && proof.owned === true) return { ok: true };
72
- return {
73
- ok: false, code: 404,
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 relayAnswer({ ownerId, askId, text, rid: providedRid } = {}) {
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 fetchImpl(ownerUrl(candidate.forwardPort, `/event-feed/asks/${askId}/answer`), {
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
- const body = await r.json().catch(() => ({}));
230
+ op.check();
141
231
  if (r.status === 200) {
142
- const state = body.status === 'answered' ? 'committed' : body.status || 'committed';
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: body.error || 'conflict', reason: body.reason };
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: body.error || `HTTP ${r.status}`, reason: body.reason };
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 relayDismiss({ ownerId, askId } = {}) {
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 fetchImpl(ownerUrl(candidate.forwardPort, `/event-feed/asks/${askId}`), {
193
- method: 'DELETE',
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, error: String((e && e.message) || e) }; }
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 verifyStatus({ ownerId, askId, requestId: rid }) {
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 fetchImpl(ownerUrl(candidate.forwardPort, `/event-feed/asks/${askId}/requests/${rid}`), {
216
- headers: { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
217
- });
218
- if (!r.ok) return { ok: false, code: r.status };
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
- return { ok: false, error: String((e && e.message) || e) };
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 is the SAME as the stream/snapshot gate (kill-switch -> proven
9
- // federated origin -> exact [clientId, ownerId] -> known peer -> eventsAccess)
10
- // PLUS askReplyAccess plus the ask's own cell scope read from the ask store.
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 { createFeedGate } = require('./event-feed-routes.js');
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
- prune(list, t); prune(global, t);
33
- if (list.length >= ANSWER_RATE_PER_MIN || global.length >= ANSWER_GLOBAL_PER_MIN) {
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 = createFeedGate(deps);
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.peer.nodeId, requestId: body.requestId,
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.peer.nodeId, askId, requestId);
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
  });