@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/asks.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,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.
|
|
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
|
-
|
|
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
|
|
118
|
-
return res.status(500).json({ error: 'dismiss
|
|
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.
|
|
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
|
});
|