@mmmbuto/nexuscrew 0.9.58 → 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.
@@ -11,7 +11,7 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-D8cf4AEo.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-CZFJnks_.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-fCIoVxeq.css">
16
16
  </head>
17
17
  <body>
@@ -1 +1 @@
1
- {"version":"0.9.58"}
1
+ {"version":"0.9.59"}
@@ -109,7 +109,7 @@ const LABEL_MAX = 64;
109
109
  const NODE_KEYS = new Set([
110
110
  'name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'identityFile',
111
111
  'roles', 'rolesKnown', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
112
- 'direction', 'reversePort', 'shared', 'label', 'reversePool',
112
+ 'direction', 'reversePort', 'shared', 'label', 'reversePool', 'pairingRevision',
113
113
  // Quali CELLE di questo nodo il peer puo' vedere. Distinto da `visibility`,
114
114
  // che governa il TRANSITO (attraverso chi passa il traffico) e non l'accesso.
115
115
  'cellVisibility', 'cells',
@@ -392,6 +392,11 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
392
392
  if (!validToken(n.acceptToken)) return null;
393
393
  out.acceptToken = n.acceptToken;
394
394
  }
395
+ // Public rotation counter, independent of the credential bytes.
396
+ if (n.pairingRevision !== undefined) {
397
+ if (!Number.isSafeInteger(n.pairingRevision) || n.pairingRevision < 0) return null;
398
+ out.pairingRevision = n.pairingRevision;
399
+ }
395
400
  if (n.nodeId !== undefined) {
396
401
  if (typeof n.nodeId !== 'string' || !NODE_ID_RE.test(n.nodeId)) return null;
397
402
  out.nodeId = n.nodeId;
@@ -570,6 +575,19 @@ function atomicWriteStore(p, data) {
570
575
  const parsed = parseStore(data);
571
576
  if (!parsed) throw new Error('nodes.json non valido: validazione fallita (schema strict)');
572
577
 
578
+ // Count rotations at the durable write boundary, including pairing paths
579
+ // that replace tokens directly. Never export a hash of the credentials.
580
+ const previous = loadStore(p);
581
+ for (const peer of parsed.nodes) {
582
+ const old = previous && previous.nodes.find(node => node.name === peer.name && node.nodeId === peer.nodeId);
583
+ if (!old) continue;
584
+ const revision = old.pairingRevision || 0;
585
+ const rotated = old.token !== peer.token || old.acceptToken !== peer.acceptToken;
586
+ if (rotated && revision === Number.MAX_SAFE_INTEGER) throw new Error('pairing rotation revision exhausted');
587
+ if (rotated || old.pairingRevision !== undefined) peer.pairingRevision = revision + (rotated ? 1 : 0);
588
+ else delete peer.pairingRevision;
589
+ }
590
+
573
591
  const dir = path.dirname(p);
574
592
  fs.mkdirSync(dir, { recursive: true });
575
593
  const tmp = path.join(dir, `.${path.basename(p)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
@@ -27,12 +27,13 @@ const OWNER_REASONS = new Set([
27
27
  'unknown', 'dismissed', 'answering', 'answered', 'store-unreadable',
28
28
  'delivery-unknown-block', 'already-delivered', 'paste-failed', 'bad-request-id',
29
29
  'no-receipts', 'request-conflict', 'receipt-cap', 'federation-peer-unreachable',
30
- 'federation-peer-timeout', 'federation-body-consumed',
30
+ 'federation-peer-timeout', 'federation-body-consumed', 'generation-mismatch', 'invalid-generation', 'unsupported',
31
31
  ]);
32
32
  function safeReason(reason) { return OWNER_REASONS.has(reason) ? reason : undefined; }
33
33
 
34
- function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {}, probeReverseSlotImpl = probeReverseSlot, peers = null, localPort = () => 0, localToken = () => '', nodesPath = null, slotVerifier = askSlotVerifier } = {}) {
34
+ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {}, probeReverseSlotImpl = probeReverseSlot, peers = null, localPort = () => 0, localToken = () => '', nodesPath = null, availabilityBinding = () => null, slotVerifier = askSlotVerifier } = {}) {
35
35
  // (ownerId|askId|requestId) -> {state: 'sent'|'committed'|'failed'|'uncertain', requestId}
36
+ const channelObservations = new Map();
36
37
  const attempts = new Map();
37
38
  const answerFlights = new Map();
38
39
 
@@ -123,7 +124,7 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
123
124
  return `http://127.0.0.1:${port}/federation/route/_/${path.replace(/^\//, '')}`;
124
125
  }
125
126
 
126
- async function forward(candidate, path, { method = 'GET', body, op } = {}) {
127
+ async function forward(candidate, path, { method = 'GET', body, expectedTs, op } = {}) {
127
128
  op.check();
128
129
  const st = loadStore();
129
130
  slotVerifier.sync(st, proofScope);
@@ -143,7 +144,8 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
143
144
  : { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': st && st.nodeId || '' };
144
145
  try {
145
146
  op.check(); op.delivered = true;
146
- const r = await op.wait(fetchImpl(url, { method, headers: { ...headers, 'content-type': 'application/json' },
147
+ const r = await op.wait(fetchImpl(url, { method, headers: { ...headers, 'content-type': 'application/json',
148
+ ...(expectedTs === undefined ? {} : { 'x-nexuscrew-ask-ts': String(expectedTs) }) },
147
149
  ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: op.controller.signal }));
148
150
  op.check();
149
151
  const payload = await op.wait(r.json().catch(error => {
@@ -290,7 +292,15 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
290
292
  }
291
293
 
292
294
  // Remote dismiss: same resolution rules as the answer (DELETE on the owner).
293
- async function relayDismissOnce({ ownerId, askId } = {}, op) {
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) {
294
304
  const candidate = await candidateOwner(ownerId, op);
295
305
  if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
296
306
  if (candidate.kind === 'inbound-unverified') {
@@ -303,9 +313,9 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
303
313
  const peer = candidate.node;
304
314
  const store = loadStore();
305
315
  try {
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 };
308
- return { ok: true };
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 } : {}) };
309
319
  } catch (e) { if (!op.active()) return deadlineResult(op, { ownerId, askId }, false); return { ok: false, code: 502, reason: safeReason(e.reason), error: 'owner unreachable' }; }
310
320
  }
311
321
 
@@ -343,32 +353,72 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
343
353
  const denied = (status, reason) => ({ ownerId, askId, canReply: false, status, ...(reason ? { reason } : {}) });
344
354
  const candidate = await candidateOwner(ownerId, op);
345
355
  if (!candidate) return denied('unreachable', 'owner-unknown');
346
- if (candidate.kind === 'inbound-unverified') return denied('unreachable', 'reverse-slot-unverified');
356
+ if (candidate.kind === 'inbound-unverified') { op.channelUnavailable = true; return denied('unreachable', 'reverse-slot-unverified'); }
347
357
  if (candidate.kind === 'inbound') {
348
358
  const gate = await verifyInboundChannel(candidate, op);
349
- if (!gate.ok) return denied('unreachable', gate.reason);
359
+ if (!gate.ok) { op.channelUnavailable = true; return denied('unreachable', gate.reason); }
350
360
  }
351
361
  try {
352
362
  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);
363
+ if (['reverse-slot-unverified', 'federation-peer-unreachable', 'federation-peer-timeout'].includes(body.reason)) { op.channelUnavailable = true; return denied('unreachable', body.reason); }
354
364
  if (r.status === 404 || r.status === 501) return denied('unsupported', body.reason);
355
365
  if (r.status === 403) return denied('denied', body.reason);
356
366
  if (!r.ok || body.ownerId !== ownerId || body.askId !== askId || typeof body.canReply !== 'boolean'
357
367
  || !['open', 'denied', 'answered', 'dismissed'].includes(body.status)
358
368
  || (body.canReply && body.status !== 'open')) return denied('unreachable');
359
369
  return { ownerId, askId, canReply: body.canReply, status: body.status };
360
- } catch (_) { return denied('unreachable', op.active() ? undefined : 'relay-deadline'); }
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' };
392
+ }
393
+ return { ok: true, status: body.status, ask, generationPrecondition: true };
361
394
  }
395
+ function inspectDismissal(args = {}) { return operation(args, op => inspectDismissalOnce(args, op)); }
362
396
 
363
397
  function relayDismiss(args = {}) { return operation(args, op => relayDismissOnce(args, op)); }
364
398
  function verifyStatus(args = {}) { return operation(args, op => verifyStatusOnce(args, op)); }
365
- function replyCapability(args = {}) { return operation(args, op => replyCapabilityOnce(args, op), true); }
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);
415
+ }
366
416
 
367
417
  function state() {
368
418
  return [...attempts.entries()].map(([key, a]) => ({ key, ...a }));
369
419
  }
370
420
 
371
- return { relayAnswer, relayDismiss, verifyStatus, replyCapability, state, isUncertain, requestId };
421
+ return { channelState, inspectDismissal, relayAnswer, relayDismiss, verifyStatus, replyCapability, state, isUncertain, requestId };
372
422
  }
373
423
 
374
424
  module.exports = { createAskRelay };
@@ -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 };
@@ -102,7 +102,9 @@ function createEventFeedAsksRoutes(deps) {
102
102
  const readonly = deps.readonly && deps.readonly();
103
103
  const status = !resolved || readonly ? 'denied'
104
104
  : resolved.ask.dismissed ? 'dismissed' : resolved.ask.answered ? 'answered' : 'open';
105
- return res.json({ ownerId: g.self, askId, canReply: status === 'open', status });
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 } } : {}) });
106
108
  });
107
109
 
108
110
  r.post('/:id/answer', json, async (req, res) => {
@@ -157,15 +159,26 @@ function createEventFeedAsksRoutes(deps) {
157
159
  const askId = String(req.params.id || '');
158
160
  const resolved = resolveAskForPeer(g.peer, askId);
159
161
  if (!resolved) return res.status(404).json({ error: 'ask inesistente' });
160
- // 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 });
161
174
  const out = deps.answerService.dismiss(askId);
162
175
  if (!out.ok) {
163
176
  if (out.reason === 'unknown') return res.status(404).json({ error: 'ask inesistente' });
164
- if (out.reason === 'answering') return res.status(409).json({ error: 'risposta in corso: non si scarta un ask in answering' });
165
- 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 });
166
179
  }
167
180
  if (out.closure) { try { await out.closure; } catch (_) {} }
168
- 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 });
169
182
  });
170
183
 
171
184
  r.get('/:id/requests/:requestId', async (req, res) => {
@@ -88,6 +88,8 @@ function createEventFeedClient(opts = {}) {
88
88
  : MIN_SNAPSHOT_INTERVAL_MS;
89
89
  // ownerId -> view state {cursor, generation, asks, notifications, fleetState,
90
90
  // stale, lastError}; generation guards against obsolete fetches.
91
+ const isAskDismissed = typeof opts.isAskDismissed === 'function' ? opts.isAskDismissed : () => false;
92
+ const visibleAsks = (ownerId, asks) => asks.filter(ask => !isAskDismissed(ownerId, ask));
91
93
  const views = new Map();
92
94
  const running = new Map(); // nodeId -> {abort}
93
95
  let stopped = false;
@@ -225,7 +227,7 @@ function createEventFeedClient(opts = {}) {
225
227
  // ask: the tombstone is what keeps it from coming back.
226
228
  view.asks = Array.isArray(snap.asks)
227
229
  ? snap.asks
228
- .filter((a) => a && !askIsClosed(ownerId, a.ownerAskId || a.id, snap.viewEpoch))
230
+ .filter((a) => a && !askIsClosed(ownerId, a.ownerAskId || a.id, snap.viewEpoch) && !isAskDismissed(ownerId, a))
229
231
  .map((a) => ({ ...a, ownerId }))
230
232
  : [];
231
233
  view.notifications = Array.isArray(snap.notifications) ? snap.notifications.map((e) => ({ ...e, ownerId })) : [];
@@ -264,15 +266,19 @@ function createEventFeedClient(opts = {}) {
264
266
  eventId: envelope.eventId, ts: Date.now(),
265
267
  });
266
268
  } else if (f.type === 'ask') {
269
+ const ask = { id: f.askId, question: f.question, options: f.options, session: f.session, ts: f.ts,
270
+ ownerAskTs: Number.isSafeInteger(f.askTs) && f.askTs > 0 ? f.askTs : null, ownerId: envelope.ownerId };
271
+ if (isAskDismissed(envelope.ownerId, ask)) return false;
267
272
  opts.eventsHub.broadcast({ type: 'ask', ownerId: envelope.ownerId, eventId: envelope.eventId,
268
- ask: { id: f.askId, question: f.question, options: f.options, session: f.session, ownerId: envelope.ownerId } });
273
+ ask });
269
274
  } else if (f.type === 'ask-closed') {
275
+ if (typeof opts.onAskClosed === 'function') opts.onAskClosed(envelope.ownerId, f.askId, f.outcome);
270
276
  // The owner says the ask is closed: the VIEW drops it too, not only the
271
277
  // UI, or the next state read hands the card back.
272
278
  const closedView = dropAskFromView(envelope.ownerId, f.askId);
273
279
  if (f.outcome === 'dismissed') noteAskClosed(envelope.ownerId, f.askId, closedView.viewEpoch);
274
280
  opts.eventsHub.broadcast({ type: f.outcome === 'dismissed' ? 'ask-dismissed' : 'ask-answered',
275
- id: f.askId, ownerId: envelope.ownerId, eventId: envelope.eventId });
281
+ id: f.askId, ownerAskId: f.askId, ownerId: envelope.ownerId, eventId: envelope.eventId });
276
282
  } else if (f.type === 'file-notice') {
277
283
  // Stessa forma della consegna locale (lib/files/routes.js): il nome del
278
284
  // file e' SEMPRE nel corpo, con la caption quando c'e'. Prima l'importato
@@ -780,7 +786,7 @@ function createEventFeedClient(opts = {}) {
780
786
  lastError: v.lastError || null,
781
787
  ingressBlockedUntil: v.ingressBlockedUntil || null,
782
788
  ingressBlockReason: v.ingressBlockReason || null,
783
- asks: v.asks, notifications: v.notifications, fleetState: v.fleetState,
789
+ asks: visibleAsks(ownerId, v.asks), notifications: v.notifications, fleetState: v.fleetState,
784
790
  })),
785
791
  };
786
792
  }
@@ -74,6 +74,7 @@ const PRODUCERS = {
74
74
  question: cleanStr(p.question, 3900) || '',
75
75
  ...(Array.isArray(p.options) ? { options: p.options.slice(0, 8).map((o) => cleanStr(o, 200) || '') } : {}),
76
76
  session: cleanStr(p.session, 128) || undefined,
77
+ ...(Number.isSafeInteger(p.askTs) && p.askTs > 0 ? { askTs: p.askTs } : {}),
77
78
  ts: typeof p.ts === 'number' ? p.ts : undefined,
78
79
  }),
79
80
  },
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+ // Durable receiver-to-owner synchronization. No attempt may submit an answer.
3
+ function createImportedDismissalDrainer({ store, relay, now = Date.now, random = Math.random,
4
+ setTimer = setTimeout, clearTimer = clearTimeout, intervalMs = 15000,
5
+ globalBudget = 4, ownerBudget = 1, budgetWindowMs = 60000, bindingForOwner = () => null, readonly = () => false, log = () => {} } = {}) {
6
+ const flights = new Set();
7
+ const globalHits = [];
8
+ const ownerHits = new Map();
9
+ let stopped = false;
10
+ let timer = null;
11
+ let running = null;
12
+ function defer(record, reason, blocked = false, retryAfterMs = 0) {
13
+ const attempts = (record.attempts || 0) + 1;
14
+ const delay = Math.max(retryAfterMs, Math.min(300000, 1000 * 2 ** Math.min(attempts, 9)) * (0.5 + random() * 0.5));
15
+ return store.updateImportedDismissal(record.ownerId, record.ownerAskId, {
16
+ syncState: blocked ? 'blocked' : 'pending', retryBinding: bindingForOwner(record.ownerId), attempts, lastReason: reason || 'owner-unreachable', nextRetryAt: now() + delay,
17
+ });
18
+ }
19
+ function rejected(record, result = {}) {
20
+ const reason = result.reason || 'owner-unreachable';
21
+ const transient = ['answering', 'delivery-unknown-block', 'reverse-slot-unverified', 'relay-deadline', 'owner-unreachable', 'federation-peer-unreachable'];
22
+ const pending = transient.includes(reason) || result.code === 429 || result.code >= 500;
23
+ return defer(record, reason, !pending, result.retryAfterMs || 0);
24
+ }
25
+ function confirmed(record, outcome) {
26
+ return store.updateImportedDismissal(record.ownerId, record.ownerAskId, {
27
+ syncState: outcome === 'answered' ? 'confirmed-answered' : 'confirmed-dismissed', ownerOutcome: outcome, lastReason: null,
28
+ });
29
+ }
30
+ async function attempt(record) {
31
+ const key = `${record.ownerId}|${record.ownerAskId}`;
32
+ if (flights.has(key) || stopped || readonly()) return;
33
+ flights.add(key);
34
+ try {
35
+ let current = store.getImportedDismissal(record.ownerId, record.ownerAskId);
36
+ if (!current || current.syncState !== 'pending') return;
37
+ const result = await relay.inspectDismissal({ ownerId: record.ownerId, askId: record.ownerAskId });
38
+ if (stopped || readonly()) return;
39
+ current = store.getImportedDismissal(record.ownerId, record.ownerAskId);
40
+ if (!current || current.syncState !== 'pending' || current.dismissedTs !== record.dismissedTs) return;
41
+ if (!result.ok) { rejected(current, result); return; }
42
+ if (result.generationPrecondition !== true) { defer(current, 'unsupported', true); return; }
43
+ if (!result.ask || (result.ask.ownerAskId || result.ask.id) !== current.ownerAskId
44
+ || store.askFingerprint(result.ask) !== current.ownerAskFingerprint
45
+ || !Number.isSafeInteger(result.ask.ts) || result.ask.ts <= 0
46
+ || (current.generation === 'known' && current.ownerAskTs !== result.ask.ts)) {
47
+ defer(current, 'generation-mismatch', true); return;
48
+ }
49
+ const adopted = store.adoptImportedDismissal(current.ownerId, current.ownerAskId, result.ask);
50
+ if (!adopted.ok) { log(`dismissal adoption refused: ${adopted.reason}`); return; }
51
+ current = adopted.record;
52
+ if (['answered', 'dismissed'].includes(result.status)) { confirmed(current, result.status); return; }
53
+ if (result.status !== 'open') { rejected(current, { code: 409, reason: result.status || 'unknown' }); return; }
54
+ // The timestamp is durable before the request and checked atomically by
55
+ // the owner. A lost response leaves the record pending for reconciliation.
56
+ const out = await relay.relayDismiss({ ownerId: current.ownerId, askId: current.ownerAskId, expectedTs: current.ownerAskTs });
57
+ if (stopped || readonly()) return;
58
+ const latest = store.getImportedDismissal(current.ownerId, current.ownerAskId);
59
+ if (!latest || latest.syncState !== 'pending' || latest.dismissedTs !== current.dismissedTs) return;
60
+ if (out.ok && ['answered', 'dismissed'].includes(out.outcome)) confirmed(latest, out.outcome);
61
+ else rejected(latest, out.ok ? { code: 409, reason: 'unsupported' } : out);
62
+ } catch (error) {
63
+ const current = store.getImportedDismissal(record.ownerId, record.ownerAskId);
64
+ if (!stopped && current && current.syncState === 'pending') defer(current, 'owner-unreachable');
65
+ log(`dismissal synchronization failed: ${String(error.message || error)}`);
66
+ } finally { flights.delete(key); }
67
+ }
68
+ async function run() {
69
+ if (stopped || readonly()) return;
70
+ const t = now();
71
+ const prune = hits => { while (hits.length && hits[0] <= t - budgetWindowMs) hits.shift(); };
72
+ prune(globalHits);
73
+ for (const [ownerId, hits] of ownerHits) { prune(hits); if (!hits.length) ownerHits.delete(ownerId); }
74
+ for (let record of store.listImportedDismissals()) {
75
+ if (globalHits.length >= globalBudget) break;
76
+ if (record.syncState === 'blocked' && record.lastReason !== 'generation-mismatch') {
77
+ const binding = bindingForOwner(record.ownerId);
78
+ if (binding !== null && binding !== record.retryBinding) {
79
+ const resumed = store.updateImportedDismissal(record.ownerId, record.ownerAskId, { syncState: 'pending', nextRetryAt: 0, retryBinding: binding });
80
+ if (resumed.ok) record = resumed.record;
81
+ }
82
+ }
83
+ if (record.syncState !== 'pending' || (record.nextRetryAt || 0) > now()) continue;
84
+ const hits = ownerHits.get(record.ownerId) || [];
85
+ if (hits.length >= ownerBudget) continue;
86
+ hits.push(t); ownerHits.set(record.ownerId, hits); globalHits.push(t);
87
+ await attempt(record);
88
+ }
89
+ }
90
+ function drain() {
91
+ if (running) return running;
92
+ const flight = run().finally(() => { if (running === flight) running = null; });
93
+ running = flight;
94
+ return flight;
95
+ }
96
+ function schedule() {
97
+ if (stopped || timer) return;
98
+ timer = setTimer(() => { timer = null; void drain().catch(error => log(String(error.message || error))).finally(schedule); }, intervalMs);
99
+ if (timer && typeof timer.unref === 'function') timer.unref();
100
+ }
101
+ function start() { schedule(); void drain().catch(error => log(String(error.message || error))); }
102
+ function stop() { stopped = true; if (timer) clearTimer(timer); timer = null; }
103
+ function nudge() { if (!stopped) void drain().catch(error => log(String(error.message || error))); }
104
+ return { start, stop, drain, nudge };
105
+ }
106
+ module.exports = { createImportedDismissalDrainer };