@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.
@@ -17,6 +17,22 @@ const MAX_OPEN = 100;
17
17
  // che arrivano da un peer e quelle poste da questo nodo sono due classi
18
18
  // distinte, e le prime non devono poter esaurire il budget delle seconde.
19
19
  const MAX_OPEN_IMPORTED = 100;
20
+ const MAX_IMPORTED_DISMISSALS = 1000;
21
+ const SYNC_STATES = new Set(['pending', 'confirmed-dismissed', 'confirmed-answered', 'blocked']);
22
+ function askFingerprint(ask) {
23
+ return crypto.createHash('sha256').update(JSON.stringify([String(ask.question || ''), Array.isArray(ask.options) ? ask.options : [], String(ask.session || '')])).digest('hex');
24
+ }
25
+ function validDismissals(value) {
26
+ return value && typeof value === 'object' && !Array.isArray(value) && Object.entries(value).every(([key, r]) =>
27
+ r && key === `${r.ownerId}|${r.ownerAskId}` && /^[a-f0-9]{32}$/.test(r.ownerId) && /^[a-f0-9]{8}$/.test(r.ownerAskId)
28
+ && /^[a-f0-9]{64}$/.test(r.ownerAskFingerprint) && SYNC_STATES.has(r.syncState)
29
+ && ['known', 'unknown'].includes(r.generation)
30
+ && (r.generation === 'unknown' ? r.ownerAskTs === null : Number.isSafeInteger(r.ownerAskTs) && r.ownerAskTs > 0)
31
+ && (r.cellId === undefined || (typeof r.cellId === 'string' && /^[A-Za-z0-9._-]{1,32}$/.test(r.cellId)))
32
+ && r.dismissed === true && Number.isSafeInteger(r.attempts) && r.attempts >= 0
33
+ && Number.isFinite(r.nextRetryAt) && r.nextRetryAt >= 0
34
+ && r.dismissedReason === 'owner-unreachable' && Number.isFinite(r.dismissedTs));
35
+ }
20
36
  const MAX_KEEP = 100; // ask totali persistiti (i piu' vecchi answered si potano)
21
37
  const MAX_QUESTION = 2000;
22
38
  const MAX_OPTIONS = 8;
@@ -39,11 +55,14 @@ function createAsksStore(opts = {}) {
39
55
  // per non sovrascrivere il file malformato con un defaults vuoto, che
40
56
  // cancellerebbe le domande aperte.
41
57
  let asks = null;
58
+ let importedDismissals = null;
59
+ const dismissalCap = opts.maxImportedDismissals || MAX_IMPORTED_DISMISSALS;
42
60
  function load() {
43
61
  if (asks) return asks;
44
62
  const r = readJsonStrict(filePath);
45
- if (r.state === 'absent') { asks = []; return asks; }
46
- if (r.state === 'ok' && Array.isArray(r.value.asks)) {
63
+ if (r.state === 'absent') { asks = []; importedDismissals = {}; return asks; }
64
+ if (r.state === 'ok' && Array.isArray(r.value.asks) && (r.value.importedDismissals === undefined || validDismissals(r.value.importedDismissals))) {
65
+ importedDismissals = r.value.importedDismissals || {};
47
66
  asks = r.value.asks.filter((a) => a && typeof a === 'object' && typeof a.id === 'string');
48
67
  return asks;
49
68
  }
@@ -58,7 +77,8 @@ function createAsksStore(opts = {}) {
58
77
  if (asks) return { readable: true };
59
78
  const r = readJsonStrict(filePath);
60
79
  if (r.state === 'absent') return { readable: true };
61
- if (r.state === 'ok' && Array.isArray(r.value.asks)) return { readable: true };
80
+ if (r.state === 'ok' && Array.isArray(r.value.asks) && (r.value.importedDismissals === undefined || validDismissals(r.value.importedDismissals))) return { readable: true };
81
+ if (r.state === 'ok' && r.value.importedDismissals !== undefined && !validDismissals(r.value.importedDismissals)) return { readable: false, reason: 'malformed imported dismissals' };
62
82
  const reason = r.state === 'malformed'
63
83
  ? r.reason
64
84
  : `forma invalida: 'asks' non e' un array (${r.value && r.value.asks !== undefined ? typeof r.value.asks : 'assente'})`;
@@ -86,7 +106,7 @@ function createAsksStore(opts = {}) {
86
106
  const drop = new Set(answered.slice(0, excess).map((a) => a.id));
87
107
  asks = list.filter((a) => !drop.has(a.id));
88
108
  }
89
- atomicWriteJson(filePath, { asks: load() });
109
+ atomicWriteJson(filePath, { asks: load(), importedDismissals: importedDismissals || {} });
90
110
  }
91
111
 
92
112
  // Validazione input fail-closed. Ritorna {ok:false,error} o {ok:true,value}.
@@ -121,7 +141,7 @@ function createAsksStore(opts = {}) {
121
141
  // L'`id` locale e' nostro e all'owner non dice nulla; e' la coppia che nomina
122
142
  // lo STESSO oggetto sui due nodi, quindi e' la chiave con cui si riconcilia.
123
143
  function findImported(ownerId, ownerAskId) {
124
- return load().find((a) => isImported(a) && a.ownerId === String(ownerId)
144
+ return load().slice().reverse().find((a) => isImported(a) && a.ownerId === String(ownerId)
125
145
  && a.ownerAskId === String(ownerAskId)) || null;
126
146
  }
127
147
 
@@ -132,6 +152,11 @@ function createAsksStore(opts = {}) {
132
152
  const ur = unreadableGuard();
133
153
  if (ur) return { ok: false, reason: 'store-unreadable', changed: false, ask: null };
134
154
  const ask = findImported(ownerId, ownerAskId);
155
+ const record = getImportedDismissal(ownerId, ownerAskId);
156
+ if (record) {
157
+ const synced = updateImportedDismissal(ownerId, ownerAskId, { syncState: outcome === 'answered' ? 'confirmed-answered' : 'confirmed-dismissed', ownerOutcome: outcome });
158
+ if (!synced.ok) return { ...synced, changed: false, ask: null };
159
+ }
135
160
  if (!ask) return { ok: true, changed: false, ask: null };
136
161
  if (ask.answered || ask.dismissed) return { ok: true, changed: false, ask };
137
162
  if (outcome === 'answered') { ask.answered = true; ask.answeredReconciled = true; }
@@ -141,7 +166,7 @@ function createAsksStore(opts = {}) {
141
166
  return { ok: true, changed: true, ask };
142
167
  }
143
168
 
144
- function create({ question, options, session, ownerId, ownerAskId, originNode, originCell }) {
169
+ function create({ question, options, session, ownerId, ownerAskId, originNode, originCell, ownerAskTs }) {
145
170
  const ur = unreadableGuard();
146
171
  if (ur) return { ok: false, reason: 'store-unreadable', error: `store illeggibile (${ur}): creazione rifiutata` };
147
172
  const v = validate({ question, options });
@@ -152,7 +177,12 @@ function createAsksStore(opts = {}) {
152
177
  // se ne crea un secondo.
153
178
  if (originNode && ownerId && ownerAskId) {
154
179
  const existing = findImported(ownerId, ownerAskId);
155
- if (existing) return { ok: true, ask: existing, deduped: true };
180
+ const incoming = { id: ownerAskId, question: v.value.question, options: v.value.options, session: String(session || ''), originNode, ownerAskTs };
181
+ if (isImportedDismissed(ownerId, incoming)) return { ok: true, ask: existing, suppressed: true, deduped: !!existing };
182
+ if (existing && askFingerprint(existing) === askFingerprint(incoming)
183
+ && (generationTs(existing) === null || generationTs(incoming) === null || generationTs(existing) === generationTs(incoming))) {
184
+ return { ok: true, ask: existing, deduped: true };
185
+ }
156
186
  }
157
187
  // Cap duro sugli aperti, SEPARATO PER CLASSE: gli importati non consumano il
158
188
  // budget dei locali. Con un cap unico, cento domande ricevute da un peer
@@ -188,6 +218,7 @@ function createAsksStore(opts = {}) {
188
218
  // ri-esportato (nessun loop A->B->A). Un ask locale non ha questo campo.
189
219
  ...(originNode ? { originNode: String(originNode) } : {}),
190
220
  ...(originCell ? { originCell: String(originCell) } : {}),
221
+ ...(Number.isSafeInteger(ownerAskTs) && ownerAskTs > 0 ? { ownerAskTs } : {}),
191
222
  ts: now(),
192
223
  revision: 0,
193
224
  answered: false,
@@ -208,6 +239,83 @@ function createAsksStore(opts = {}) {
208
239
  .map((a) => ({ ...a, ...(a.options ? { options: a.options.slice() } : {}) }));
209
240
  }
210
241
 
242
+ function getImportedDismissal(ownerId, askId) {
243
+ load();
244
+ const record = importedDismissals && importedDismissals[`${ownerId}|${askId}`];
245
+ return record ? { ...record } : null;
246
+ }
247
+ function listImportedDismissals() {
248
+ load();
249
+ return Object.values(importedDismissals || {}).map(r => ({ ...r }));
250
+ }
251
+ function generationTs(ask) {
252
+ const ts = Object.hasOwn(ask, 'ownerAskTs') ? ask.ownerAskTs : ask.originNode ? null : ask.ts;
253
+ return Number.isSafeInteger(ts) && ts > 0 ? ts : null;
254
+ }
255
+ function sameImportedGeneration(a, b) {
256
+ const at = generationTs(a), bt = generationTs(b);
257
+ return askFingerprint(a) === askFingerprint(b) && (at === null || bt === null || at === bt);
258
+ }
259
+ function isImportedDismissed(ownerId, ask) {
260
+ const record = getImportedDismissal(ownerId, ask.ownerAskId || ask.id);
261
+ if (!record || record.ownerAskFingerprint !== askFingerprint(ask)) return false;
262
+ // Historical imports have no original owner timestamp. Reusing an id with
263
+ // identical content remains suppressed; this is not proof of historical identity.
264
+ const ts = generationTs(ask);
265
+ return record.generation === 'unknown' || ts === null || record.ownerAskTs === ts;
266
+ }
267
+ function dismissalTransaction(change) {
268
+ const ur = unreadableGuard();
269
+ if (ur) return { ok: false, reason: 'store-unreadable' };
270
+ load();
271
+ const beforeAsks = asks.map(a => ({ ...a }));
272
+ const beforeRecords = Object.fromEntries(Object.entries(importedDismissals).map(([k, r]) => [k, { ...r }]));
273
+ try { const result = change(); if (!result.ok) return result; save(); return result; }
274
+ catch (_) { asks = beforeAsks; importedDismissals = beforeRecords; return { ok: false, reason: 'persist-failed' }; }
275
+ }
276
+ function dismissImported({ ownerId, ownerAskId, ask, cellId, retryBinding, syncState = 'pending' } = {}) {
277
+ return dismissalTransaction(() => {
278
+ if (!/^[a-f0-9]{32}$/.test(ownerId) || !/^[a-f0-9]{8}$/.test(ownerAskId) || !ask || (ask.ownerAskId || ask.id) !== ownerAskId) return { ok: false, reason: 'unknown' };
279
+ const candidate = findImported(ownerId, ownerAskId);
280
+ const alias = candidate && sameImportedGeneration(candidate, ask) ? candidate : null;
281
+ if (alias && answering.has(alias.id)) return { ok: false, reason: 'answering' };
282
+ if (ask.answered || (alias && alias.answered)) return { ok: false, reason: 'answered' };
283
+ const key = `${ownerId}|${ownerAskId}`;
284
+ const existing = importedDismissals[key];
285
+ if (existing && isImportedDismissed(ownerId, ask)) return { ok: true, record: { ...existing }, idempotent: true };
286
+ if (!existing && Object.keys(importedDismissals).length >= dismissalCap) return { ok: false, reason: 'dismissal-cap' };
287
+ const ts = generationTs(ask);
288
+ const record = { ownerId, ownerAskId, ...(cellId ? { cellId } : {}), ...(alias ? { localAliasId: alias.id } : {}),
289
+ dismissed: true, dismissedReason: 'owner-unreachable', dismissedTs: now(),
290
+ generation: ts === null ? 'unknown' : 'known', ownerAskTs: ts,
291
+ ownerAskFingerprint: askFingerprint(ask), syncState, attempts: 0, nextRetryAt: 0,
292
+ ...(syncState === 'blocked' ? { lastReason: 'permission-denied', ...(retryBinding ? { retryBinding } : {}) } : {}) };
293
+ importedDismissals[key] = record;
294
+ if (alias) {
295
+ if (!alias.dismissed) { alias.dismissed = true; alias.dismissedTs = now(); alias.revision = (alias.revision || 0) + 1; }
296
+ alias.dismissedReason = 'owner-unreachable';
297
+ }
298
+ return { ok: true, record: { ...record } };
299
+ });
300
+ }
301
+ function updateImportedDismissal(ownerId, askId, patch) {
302
+ return dismissalTransaction(() => {
303
+ const record = importedDismissals[`${ownerId}|${askId}`];
304
+ if (!record) return { ok: false, reason: 'unknown' };
305
+ const next = { ...record, ...patch };
306
+ if (!validDismissals({ [`${ownerId}|${askId}`]: next })) return { ok: false, reason: 'invalid' };
307
+ importedDismissals[`${ownerId}|${askId}`] = next;
308
+ return { ok: true, record: { ...next } };
309
+ });
310
+ }
311
+ function adoptImportedDismissal(ownerId, askId, ask) {
312
+ const record = getImportedDismissal(ownerId, askId);
313
+ const ts = generationTs(ask);
314
+ if (!record || (ask.ownerAskId || ask.id) !== askId || ts === null || record.ownerAskFingerprint !== askFingerprint(ask)
315
+ || (record.generation === 'known' && record.ownerAskTs !== ts)) return { ok: false, reason: 'generation-mismatch' };
316
+ return updateImportedDismissal(ownerId, askId, { generation: 'known', ownerAskTs: ts });
317
+ }
318
+
211
319
  // --- ciclo answer (da revisione): claim atomico open -> answering ---------------
212
320
  // Node e' single-threaded ma il paste e' un await: due answer concorrenti
213
321
  // superavano entrambe il check `answered` prima che una marcasse. Il claim
@@ -301,7 +409,7 @@ function createAsksStore(opts = {}) {
301
409
  return commit(id, text);
302
410
  }
303
411
 
304
- return { create, get, list, openCount, isImported, findImported, closeImported, claim, release, commit, markAnswered, markReconciled, isAnswering, dismiss, validate, health, filePath, MAX_OPEN, MAX_OPEN_IMPORTED };
412
+ return { sameImportedGeneration, dismissImported, getImportedDismissal, listImportedDismissals, isImportedDismissed, updateImportedDismissal, adoptImportedDismissal, askFingerprint, create, get, list, openCount, isImported, findImported, closeImported, claim, release, commit, markAnswered, markReconciled, isAnswering, dismiss, validate, health, filePath, MAX_OPEN, MAX_OPEN_IMPORTED };
305
413
  }
306
414
 
307
- module.exports = { createAsksStore };
415
+ module.exports = { createAsksStore, askFingerprint };
@@ -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,23 @@ 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
+ ...(resolved && !readonly ? { generationPrecondition: true, ask: { id: resolved.ask.id, ts: resolved.ask.ts, question: resolved.ask.question,
107
+ ...(resolved.ask.options ? { options: resolved.ask.options } : {}), session: resolved.ask.session } } : {}) });
108
+ });
109
+
62
110
  r.post('/:id/answer', json, async (req, res) => {
63
111
  const g = await gate(req, res);
64
112
  if (!g) return;
65
- const verdict = rate.check(g.peer.nodeId);
113
+ const verdict = rate.check(g.peer.nodeId, g.actor);
66
114
  if (!verdict.allowed) return res.status(429).json({ error: 'answer rate', reason: verdict.reason });
67
115
  if (deps.readonly && deps.readonly()) {
68
116
  return res.status(403).json({ error: 'READONLY: mutazione bloccata' });
@@ -85,13 +133,14 @@ function createEventFeedAsksRoutes(deps) {
85
133
  const resolved = resolveAskForPeer(g.peer, askId);
86
134
  if (!resolved) return res.status(404).json({ error: 'ask inesistente' });
87
135
  const out = await deps.answerService.answerFederated({
88
- askId, text: body.text, peerId: g.peer.nodeId, requestId: body.requestId,
136
+ askId, text: body.text, peerId: g.actor, requestId: body.requestId,
89
137
  });
90
138
  if (out.ok) {
91
139
  // La chiusura nasce dal servizio; qui si aspetta il recapito, cosi' chi ha
92
140
  // risposto non vede una risposta che precede la chiusura dei peer.
93
141
  if (out.closure) { try { await out.closure; } catch (_) {} }
94
- return res.json({ status: out.replay ? out.state : 'committed', requestId: body.requestId });
142
+ return res.json({ status: out.replay ? out.state : 'committed', requestId: body.requestId,
143
+ ownerId: g.self, askId, actor: g.actor });
95
144
  }
96
145
  return res.status(out.code || 500).json({ error: out.error, reason: out.reason });
97
146
  });
@@ -99,7 +148,7 @@ function createEventFeedAsksRoutes(deps) {
99
148
  r.delete('/:id', async (req, res) => {
100
149
  const g = await gate(req, res);
101
150
  if (!g) return;
102
- const verdict = rate.check(g.peer.nodeId);
151
+ const verdict = rate.check(g.peer.nodeId, g.actor);
103
152
  if (!verdict.allowed) return res.status(429).json({ error: 'answer rate', reason: verdict.reason });
104
153
  if (deps.readonly && deps.readonly()) {
105
154
  return res.status(403).json({ error: 'READONLY: mutazione bloccata' });
@@ -110,15 +159,26 @@ function createEventFeedAsksRoutes(deps) {
110
159
  const askId = String(req.params.id || '');
111
160
  const resolved = resolveAskForPeer(g.peer, askId);
112
161
  if (!resolved) return res.status(404).json({ error: 'ask inesistente' });
113
- // Local answer in flight wins: 409, and a dismissed ask is not answerable.
162
+ const expected = req.headers['x-nexuscrew-ask-ts'];
163
+ if (expected !== undefined && (typeof expected !== 'string' || !/^[1-9][0-9]{0,15}$/.test(expected)
164
+ || !Number.isSafeInteger(Number(expected)))) {
165
+ return res.status(400).json({ error: 'invalid generation precondition', reason: 'invalid-generation' });
166
+ }
167
+ // Keep the generation and terminal guards next to the synchronous service:
168
+ // authorization may await, but no yield is allowed between this check and dismiss.
169
+ if (expected !== undefined && resolved.ask.ts !== Number(expected)) {
170
+ return res.status(409).json({ error: 'ask generation changed', reason: 'generation-mismatch' });
171
+ }
172
+ if (resolved.ask.answered) return res.json({ dismissed: false, id: askId, outcome: 'answered', idempotent: true });
173
+ if (resolved.ask.dismissed) return res.json({ dismissed: true, id: askId, outcome: 'dismissed', idempotent: true });
114
174
  const out = deps.answerService.dismiss(askId);
115
175
  if (!out.ok) {
116
176
  if (out.reason === 'unknown') return res.status(404).json({ error: 'ask inesistente' });
117
- if (out.reason === 'answering') return res.status(409).json({ error: 'risposta in corso: non si scarta un ask in answering' });
118
- return res.status(500).json({ error: 'dismiss non riuscito' });
177
+ if (['answering', 'delivery-unknown-block'].includes(out.reason)) return res.status(409).json({ error: out.error || 'ask cannot be dismissed', reason: out.reason });
178
+ return res.status(out.code || 500).json({ error: out.error || 'dismiss failed', reason: out.reason });
119
179
  }
120
180
  if (out.closure) { try { await out.closure; } catch (_) {} }
121
- return res.json({ dismissed: true, id: askId, idempotent: out.idempotent === true });
181
+ return res.json({ dismissed: true, id: askId, outcome: 'dismissed', idempotent: out.idempotent === true });
122
182
  });
123
183
 
124
184
  r.get('/:id/requests/:requestId', async (req, res) => {
@@ -133,10 +193,10 @@ function createEventFeedAsksRoutes(deps) {
133
193
  if (!resolved) return res.status(404).json({ error: 'ask inesistente' });
134
194
  // Scoped: a peer sees ONLY its own attempts, and never the answer text of
135
195
  // anyone — the receipt carries a digest, not a payload.
136
- const entry = deps.receipts.get(g.peer.nodeId, askId, requestId);
196
+ const entry = deps.receipts.get(g.actor, askId, requestId);
137
197
  if (!entry) return res.status(404).json({ error: 'ask inesistente' });
138
198
  return res.json({
139
- requestId, askId, state: entry.state,
199
+ requestId, askId, ownerId: g.self, actor: g.actor, state: entry.state,
140
200
  ...(entry.receipt ? { receipt: entry.receipt } : {}),
141
201
  ...(entry.updatedAt ? { updatedAt: entry.updatedAt } : {}),
142
202
  });