@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.
@@ -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-CAgxp3qK.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-D8cf4AEo.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.57"}
1
+ {"version":"0.9.58"}
@@ -2285,6 +2285,42 @@ function customCatalogFor(spec, model, declaredModels, home) {
2285
2285
  return { catalogPath: target, contextWindow: entry.contextWindow || 128000 };
2286
2286
  }
2287
2287
 
2288
+ // Only this runtime version has a verified embedded MCP contract here. The
2289
+ // option is also probed on the resolved launch path: npm Node shims on Termux
2290
+ // must not accidentally interrogate a different executable through PATH.
2291
+ const CODEX_DAEMON_MIN_VERSION = '0.131.0-alpha.12';
2292
+ const CODEX_PROBE_TIMEOUT_MS = 10000;
2293
+ const codexCapabilityCache = new Map();
2294
+ const CODEX_MCP_CONTEXT_ENV = ['MCP_DEVICE', 'NEXUSCREW_MCP_SESSION', 'TMUX', 'TMUX_PANE'];
2295
+ function codexEmbeddedGate(binary, cfg) {
2296
+ const { spawnSync } = require('node:child_process');
2297
+ const { compareVersions, parseVersion } = require('../update/core.js');
2298
+ const unsupported = { ok: false, reason: 'Codex Termux embedded identity isolation is unsupported: runtime capability could not be verified' };
2299
+ let resolved, stat;
2300
+ try { resolved = fs.realpathSync(binary); stat = fs.statSync(resolved); } catch (_) { return unsupported; }
2301
+ const cached = codexCapabilityCache.get(resolved);
2302
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached.result;
2303
+ const remember = result => {
2304
+ codexCapabilityCache.set(resolved, { mtimeMs: stat.mtimeMs, size: stat.size, result });
2305
+ return result;
2306
+ };
2307
+ const explicitNode = needsExplicitNode(binary, cfg.platform || process.platform, cfg.env || process.env);
2308
+ const command = explicitNode ? cfg.nodeExecPath || process.execPath : binary;
2309
+ const probe = flag => {
2310
+ const result = spawnSync(command, [...(explicitNode ? [binary] : []), flag], {
2311
+ encoding: 'utf8', timeout: CODEX_PROBE_TIMEOUT_MS, maxBuffer: 256 * 1024,
2312
+ });
2313
+ return result.error || result.status !== 0 ? null : `${result.stdout || ''}${result.stderr || ''}`;
2314
+ };
2315
+ const version = probe('--version')?.match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)(?=\s|$)/)?.[1];
2316
+ if (!version || !parseVersion(version)) return unsupported;
2317
+ if (compareVersions(version, CODEX_DAEMON_MIN_VERSION) < 0) return remember({ ok: true, embedded: false });
2318
+ const help = probe('--help');
2319
+ if (help === null) return unsupported;
2320
+ if (!/(?:^|\s)--no-daemon(?:\s|$)/.test(help)) return remember(unsupported);
2321
+ return remember({ ok: true, embedded: true });
2322
+ }
2323
+
2288
2324
  function resolveManagedEngine(engine, cell, cfg = {}) {
2289
2325
  // `extraModels` DEVE arrivare fin qui. Threadarlo al chiamante e alla vista
2290
2326
  // non basta: la normalizzazione che decide se la cella PARTE e' questa, e
@@ -2511,6 +2547,27 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
2511
2547
  if (attivita) args.push(`--settings=${JSON.stringify({ hooks: attivita.hooks })}`);
2512
2548
  }
2513
2549
  } else if (spec.client === 'codex' || spec.client === 'codex-vl') {
2550
+ const termux = (cfg.platform || process.platform) === 'android'
2551
+ || termuxRuntimePaths(cfg.env || process.env, { platform: cfg.platform || process.platform, home }) !== null;
2552
+ if (spec.client === 'codex' && termux) {
2553
+ const gate = codexEmbeddedGate(info.binary, cfg);
2554
+ if (!gate.ok) return { ok: false, info, reason: gate.reason };
2555
+ if (gate.embedded) args.push('--no-daemon');
2556
+ if (gate.embedded) try {
2557
+ const { servers } = codexSurfaceTables(readCodexUserToml(path.join(home, '.codex')));
2558
+ const bridge = servers.get('nexuscrew');
2559
+ if (bridge && bridge.enabled !== false) {
2560
+ if (bridge.env && CODEX_MCP_CONTEXT_ENV.some(name => Object.prototype.hasOwnProperty.call(bridge.env, name))) {
2561
+ return { ok: false, info, reason: 'Codex embedded MCP identity cannot use static context overrides: remove the NexusCrew identity keys from mcp_servers.nexuscrew.env' };
2562
+ }
2563
+ const names = [...new Set([...(Array.isArray(bridge.env_vars) ? bridge.env_vars : []), ...CODEX_MCP_CONTEXT_ENV])];
2564
+ args.push('-c', `mcp_servers.nexuscrew.env_vars=${JSON.stringify(names)}`);
2565
+ }
2566
+ } catch (_) {
2567
+ return { ok: false, info, reason: 'Codex embedded MCP identity requires a readable, valid MCP configuration' };
2568
+ }
2569
+ }
2570
+
2514
2571
  // Canale di attivita' per le celle codex: gli stessi eventi che le celle
2515
2572
  // Claude pubblicano via `--settings`, qui come hook `-c` con la fiducia che
2516
2573
  // il client pretende (senza, apre un dialogo di revisione e la cella resta
@@ -201,6 +201,7 @@ function applyPreset(peer, name) {
201
201
  if (preset.cellVisibility === 'selected') out.cells = [...(preset.cells || [])];
202
202
  else delete out.cells;
203
203
  for (const key of BOOLEAN_GRANTS) out[key] = preset[key];
204
+ if (name === 'admin') out.eventsReceive = true;
204
205
  return out;
205
206
  }
206
207
 
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+ const crypto = require('node:crypto');
3
+ const { performance } = require('node:perf_hooks');
4
+ const { probeReverseSlot } = require('./reverse-slot-proof.js');
5
+
6
+ const PROOF_TTL_MS = 10000;
7
+ const MAX_PROOFS = 256;
8
+
9
+ function channel(peer) {
10
+ const pool = peer && peer.reversePool;
11
+ const slot = pool && Array.isArray(pool.slots) && pool.slots[pool.activeSlot];
12
+ if (!peer || peer.direction !== 'inbound' || peer.shared !== true || !peer.token || !peer.nodeId
13
+ || !slot || slot.state !== 'active' || !Number.isInteger(slot.port) || slot.port < 1 || slot.port > 65535
14
+ || !Number.isSafeInteger(pool.activeGeneration) || pool.activeGeneration < 1
15
+ || slot.generation !== pool.activeGeneration) return null;
16
+ return { port: slot.port, generation: pool.activeGeneration, instanceId: peer.nodeId };
17
+ }
18
+ function binding(store, peer) {
19
+ const selected = channel(peer);
20
+ if (!store || !selected) return null;
21
+ const key = JSON.stringify([store.nodeId, peer.name, peer.nodeId, selected.port, selected.generation]);
22
+ // The whole peer includes pool, credential, direction, sharing and ACL.
23
+ // Only an in-process fingerprint is retained; it never enters responses/logs.
24
+ const fingerprint = crypto.createHash('sha256').update(JSON.stringify([store.nodeId, peer])).digest('hex');
25
+ return { key, fingerprint, selected };
26
+ }
27
+
28
+ function createAskSlotVerifier({ now = () => performance.now() } = {}) {
29
+ const entries = new Map();
30
+ function sync(store, scope = store && store.nodeId) {
31
+ const at = now();
32
+ for (const [key, entry] of entries) {
33
+ if (entry.expiresAt <= at) { entries.delete(key); continue; }
34
+ if (entry.scope !== scope) continue;
35
+ const peer = store && (store.nodes || []).find(node => node.name === entry.name);
36
+ const current = binding(store, peer);
37
+ if (!current || current.key !== key || current.fingerprint !== entry.fingerprint) entries.delete(key);
38
+ }
39
+ }
40
+ function invalidate(key) { if (key) entries.delete(key); }
41
+ async function verify({ store, peer, loadStore, signal, scope = store && store.nodeId, probeImpl = probeReverseSlot } = {}) {
42
+ sync(store, scope);
43
+ const selected = binding(store, peer);
44
+ if (!selected || (signal && signal.aborted)) return { owned: false, code: 'reverse-slot-proof-unavailable' };
45
+ const cached = entries.get(selected.key);
46
+ if (cached && cached.fingerprint === selected.fingerprint) {
47
+ entries.delete(selected.key); entries.set(selected.key, cached); // LRU only: expiry never slides.
48
+ return { owned: true, key: selected.key, code: 'reverse-slot-owned' };
49
+ }
50
+ invalidate(selected.key);
51
+ let proof;
52
+ try { proof = await probeImpl({ port: selected.selected.port, secret: peer.token,
53
+ expected: { remotePort: selected.selected.port, generation: selected.selected.generation, instanceId: peer.nodeId },
54
+ timeoutMs: 6000, signal }); }
55
+ catch (_) { proof = { owned: false, code: 'reverse-slot-proof-unavailable' }; }
56
+ const freshStore = typeof loadStore === 'function' ? loadStore() : store;
57
+ sync(freshStore, scope);
58
+ const freshPeer = freshStore && (freshStore.nodes || []).find(node => node.name === peer.name);
59
+ const fresh = binding(freshStore, freshPeer);
60
+ if ((signal && signal.aborted) || !fresh || fresh.key !== selected.key || fresh.fingerprint !== selected.fingerprint
61
+ || !proof || proof.owned !== true) {
62
+ invalidate(selected.key);
63
+ return { owned: false, code: proof && proof.code || 'reverse-slot-proof-unavailable' };
64
+ }
65
+ entries.set(selected.key, { scope, name: peer.name, fingerprint: selected.fingerprint, expiresAt: now() + PROOF_TTL_MS });
66
+ while (entries.size > MAX_PROOFS) entries.delete(entries.keys().next().value);
67
+ return { owned: true, key: selected.key, code: 'reverse-slot-owned' };
68
+ }
69
+ return { verify, invalidate, sync };
70
+ }
71
+ const askSlotVerifier = createAskSlotVerifier();
72
+ module.exports = { channel, binding, createAskSlotVerifier, askSlotVerifier };
@@ -467,6 +467,10 @@ function nodesEdit(opts) {
467
467
  log(`nodes edit: access-role non valido (atteso: ${accessPresets.PRESET_NAMES.join('|')})`);
468
468
  return { code: 1, reason: 'invalid-access-role' };
469
469
  }
470
+ if (supplied.accessRole === 'admin' && supplied.eventsReceive === false) {
471
+ log('nodes edit: admin conflicts with eventsReceive=false');
472
+ return { code: 1, reason: 'admin-reception-conflict' };
473
+ }
470
474
  accessVector = { ...accessPresets.PRESETS[supplied.accessRole] };
471
475
  } else if (!accessRequested && touchesVector) {
472
476
  // Panel/live/scope edit on a configured peer: same vector, same rules.
@@ -496,9 +500,9 @@ function nodesEdit(opts) {
496
500
  const expected = Number.isInteger(opts.expectedRevision) ? opts.expectedRevision : store.accessRevisionOf(st);
497
501
  let res;
498
502
  try {
499
- res = store.setPeerAccessGrantsCas({
500
- filePath: nodesPath, name: node.name, grants: accessVector, expectedRevision: expected, extraPatch: patch,
501
- });
503
+ res = Object.hasOwn(supplied, 'accessRole')
504
+ ? store.setPeerAccessPresetCas({ filePath: nodesPath, name: node.name, presetName: supplied.accessRole, expectedRevision: expected, extraPatch: patch })
505
+ : store.setPeerAccessGrantsCas({ filePath: nodesPath, name: node.name, grants: accessVector, expectedRevision: expected, extraPatch: patch });
502
506
  } catch (e) { log(`nodes edit: ${e.message}`); return { code: 1, reason: 'invalid-patch' }; }
503
507
  if (!res.ok) {
504
508
  log(`nodes edit: revision delle concessioni superata (attuale ${res.revision}) — rileggi e riprova`);
@@ -86,31 +86,43 @@ function newProbe(expected, randomBytes = crypto.randomBytes) {
86
86
  };
87
87
  }
88
88
 
89
- async function probeReverseSlot({ port, secret, expected, fetchImpl = fetch, timeoutMs = 1500, randomBytes } = {}) {
89
+ async function probeReverseSlot({ port, secret, expected, fetchImpl = fetch, timeoutMs = 1500, randomBytes, signal } = {}) {
90
90
  if (!isPort(port) || typeof secret !== 'string' || !secret) return { owned: false, code: 'reverse-slot-proof-invalid-input' };
91
91
  const probe = newProbe(expected, randomBytes);
92
92
  if (!probe) return { owned: false, code: 'reverse-slot-proof-invalid-input' };
93
93
  const ctrl = new AbortController();
94
- const timer = setTimeout(() => ctrl.abort(), Math.max(1, Math.min(timeoutMs, 5000)));
94
+ let rejectAbort;
95
+ const aborted = new Promise((_resolve, reject) => {
96
+ rejectAbort = () => reject(new Error('reverse slot proof aborted'));
97
+ ctrl.signal.addEventListener('abort', rejectAbort, { once: true });
98
+ });
99
+ const onAbort = () => ctrl.abort();
100
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
101
+ const timer = setTimeout(onAbort, Math.max(1, Math.min(timeoutMs, 6000)));
102
+ if (signal && signal.aborted) onAbort();
95
103
  try {
96
- const response = await fetchImpl(`http://127.0.0.1:${port}/reverse-slot-proof`, {
104
+ const response = await Promise.race([fetchImpl(`http://127.0.0.1:${port}/reverse-slot-proof`, {
97
105
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(probe), signal: ctrl.signal,
98
- });
106
+ }), aborted]);
99
107
  // Un 409 e' la RISPOSTA di un listener corretto che ha valutato la tupla e
100
108
  // l'ha rifiutata: e' un esito, non un'assenza. Collassarlo in
101
109
  // "non ottenuta" lo renderebbe indistinguibile da un timeout, e chi
102
110
  // classifica gli errori tratterebbe come transitorio un mismatch reale.
103
111
  if (response && response.status === 409) {
104
- const failure = await response.json().catch(() => null);
112
+ const failure = await Promise.race([response.json().catch(() => null), aborted]);
105
113
  const code = failure && typeof failure.code === 'string' && /^[a-z0-9-]{1,64}$/.test(failure.code)
106
114
  ? failure.code : 'reverse-slot-proof-mismatch';
107
115
  return { owned: false, code };
108
116
  }
109
117
  if (!response || response.status !== 200) return { owned: false, code: 'reverse-slot-proof-unavailable' };
110
- const body = await response.json().catch(() => null);
118
+ const body = await Promise.race([response.json().catch(() => null), aborted]);
111
119
  return verifySlotProof({ secret, expected, challenge: probe, response: body });
112
120
  } catch (_) { return { owned: false, code: 'reverse-slot-proof-unavailable' }; }
113
- finally { clearTimeout(timer); }
121
+ finally {
122
+ clearTimeout(timer);
123
+ ctrl.signal.removeEventListener('abort', rejectAbort);
124
+ if (signal) signal.removeEventListener('abort', onAbort);
125
+ }
114
126
  }
115
127
 
116
128
  module.exports = {
@@ -461,8 +461,8 @@ function parseStore(raw) {
461
461
 
462
462
  if (![SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION].includes(d.schemaVersion)) return null;
463
463
  const rootKeys = d.schemaVersion === SCHEMA_VERSION
464
- ? new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous', 'reversePoolAnchor', 'reversePoolLedgerInitialized', 'accessRevision'])
465
- : new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous']);
464
+ ? new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous', 'reversePoolAnchor', 'reversePoolLedgerInitialized', 'accessRevision', 'adminReceptionMigration'])
465
+ : new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous', 'adminReceptionMigration']);
466
466
  if (Object.keys(d).some((key) => !rootKeys.has(key))) return null;
467
467
  if (typeof d.nodeId !== 'string' || !NODE_ID_RE.test(d.nodeId)) return null;
468
468
  if (!Array.isArray(d.nodes) || d.nodes.length > MAX_NODES) return null;
@@ -494,6 +494,10 @@ function parseStore(raw) {
494
494
  }
495
495
 
496
496
  const out = { schemaVersion: d.schemaVersion, nodeId: d.nodeId, nodes };
497
+ if (d.adminReceptionMigration !== undefined) {
498
+ if (d.adminReceptionMigration !== 1) return null;
499
+ out.adminReceptionMigration = d.adminReceptionMigration;
500
+ }
497
501
 
498
502
  // Revision of the peer access policy. Absent means 0: a store written
499
503
  // before this field existed is at revision 0 and the first explicit write
@@ -727,7 +731,9 @@ function setPeerAccessPreset(store, name, presetName) {
727
731
  const vector = { cellVisibility: applied.cellVisibility };
728
732
  if (applied.cells !== undefined) vector.cells = applied.cells;
729
733
  for (const key of accessPresets.BOOLEAN_GRANTS) vector[key] = applied[key];
730
- return setPeerAccessGrants(store, name, vector);
734
+ const out = setPeerAccessGrants(store, name, vector);
735
+ out.nodes[idx].eventsReceive = applied.eventsReceive === true;
736
+ return out;
731
737
  }
732
738
 
733
739
  // Read-only inventory: what every peer is allowed to do on THIS node, with the
@@ -778,12 +784,36 @@ function setPeerAccessPresetCas({ filePath, name, presetName, expectedRevision,
778
784
  if (!Number.isInteger(expectedRevision) || accessRevisionOf(current) !== expectedRevision) {
779
785
  return { ok: false, conflict: true, revision: accessRevisionOf(current) };
780
786
  }
787
+ if (presetName === 'admin' && extraPatch?.eventsReceive === false) {
788
+ throw new Error('admin preset conflicts with eventsReceive=false');
789
+ }
781
790
  let next = current;
782
791
  if (extraPatch && Object.keys(extraPatch).length > 0) next = updateNode(next, name, extraPatch);
783
792
  next = setPeerAccessPreset(next, name, presetName);
784
793
  const written = atomicWriteStore(filePath, next);
785
794
  const view = accessPresets.grantsOf(written.nodes.find((n) => n.name === name));
786
- return { ok: true, name, revision: accessRevisionOf(written), grants: view.grants, label: view.label };
795
+ return { ok: true, name, revision: accessRevisionOf(written), grants: view.grants, label: view.label,
796
+ eventsReceive: written.nodes.find((n) => n.name === name).eventsReceive === true };
797
+ }
798
+
799
+ // One startup migration; later operational reception edits remain authoritative.
800
+ function migrateAdminReception({ filePath, readonly = false }) {
801
+ if (readonly || process.env.NEXUSCREW_READONLY === '1') return { migrated: false };
802
+ const current = loadStoreStrict(filePath);
803
+ if (current.adminReceptionMigration === 1) return { migrated: false };
804
+ let next = current;
805
+ let changed = 0;
806
+ for (const peer of current.nodes) {
807
+ const view = accessPresets.grantsOf(peer);
808
+ if (view.configured && view.label === 'admin' && peer.eventsReceive !== true) {
809
+ next = updateNode(next, peer.name, { eventsReceive: true });
810
+ changed += 1;
811
+ }
812
+ }
813
+ if (changed) next = { ...next, schemaVersion: SCHEMA_VERSION,
814
+ accessRevision: accessRevisionOf(current) + 1 };
815
+ atomicWriteStore(filePath, { ...next, adminReceptionMigration: 1 });
816
+ return { migrated: true, changed };
787
817
  }
788
818
 
789
819
  // The upgrade is explicit: normal legacy pairing remains schema v2 and keeps
@@ -1026,7 +1056,7 @@ module.exports = {
1026
1056
  // mutazioni
1027
1057
  getNode, addNode, removeNode, setNodeToken, updateNode, upgradeToReversePoolSchema, setNodeReversePool,
1028
1058
  // concessioni federate (preset, CAS, inventario)
1029
- accessRevisionOf, setPeerAccessGrants, setPeerAccessPreset, accessInventory,
1059
+ accessRevisionOf, setPeerAccessGrants, setPeerAccessPreset, accessInventory, migrateAdminReception,
1030
1060
  setPeerAccessGrantsCas, setPeerAccessPresetCas,
1031
1061
  // redazione
1032
1062
  redactNode, redactStore, hasPairedPeers,
@@ -15,8 +15,8 @@ const crypto = require('node:crypto');
15
15
 
16
16
  const REQUEST_ID_RE = /^[0-9a-f-]{16,64}$/i;
17
17
 
18
- function createAskAnswerService({ asks, paste, receipts, onClosure, labelPrefix = 'reply' } = {}) {
19
- if (!asks || !paste) throw new Error('createAskAnswerService: asks e paste richiesti');
18
+ function createAskAnswerService({ asks, submit, receipts, onClosure, labelPrefix = 'reply' } = {}) {
19
+ if (!asks || typeof submit !== 'function') throw new Error('createAskAnswerService: asks and submit required');
20
20
  const emitClosure = typeof onClosure === 'function' ? onClosure : () => {};
21
21
 
22
22
  function pasteText(askId, text, label) {
@@ -50,14 +50,21 @@ function createAskAnswerService({ asks, paste, receipts, onClosure, labelPrefix
50
50
  if (claim.reason === 'answering') return { ok: false, code: 409, error: 'risposta gia\' in corso da un\'altra richiesta', reason: 'answering' };
51
51
  return { ok: false, code: 409, error: 'ask gia\' risposto', reason: 'answered' };
52
52
  }
53
- let pasted = false;
53
+ let submission;
54
54
  try {
55
- pasted = await paste(claim.ask.session, pasteText(askId, text, labelPrefix));
56
- } catch (_) { pasted = false; }
57
- if (!pasted) {
58
- asks.release(askId); // rollback: the ask stays open and contestable
59
- if (request && receipts) receipts.finalize(request.peerId, askId, request.requestId, 'failed', 'paste-failed');
60
- return { ok: false, code: 502, error: `paste fallito: sessione "${claim.ask.session}" non raggiungibile`, reason: 'paste-failed' };
55
+ const payload = pasteText(askId, text, labelPrefix);
56
+ submission = await submit(claim.ask.session, payload);
57
+ } catch (_) { submission = { outcome: 'delivery-unknown' }; }
58
+ if (!submission || !['submitted', 'pasted-not-submitted'].includes(submission.outcome)) {
59
+ const prePaste = submission && submission.outcome === 'failed-pre-paste';
60
+ if (request && receipts) {
61
+ receipts.finalize(request.peerId, askId, request.requestId,
62
+ prePaste ? 'failed' : 'delivery-unknown', submission && submission.reason);
63
+ }
64
+ // An uncertain attempt is durably blocked before the claim is released.
65
+ if (prePaste || (request && receipts)) asks.release(askId);
66
+ return { ok: false, code: 502, error: prePaste ? 'submission failed before paste' : 'submission outcome is unknown',
67
+ reason: prePaste ? 'paste-failed' : 'delivery-unknown' };
61
68
  }
62
69
  const committed = asks.commit(askId, text);
63
70
  if (request && receipts) receipts.finalize(request.peerId, askId, request.requestId, 'committed', null);
@@ -72,7 +79,12 @@ function createAskAnswerService({ asks, paste, receipts, onClosure, labelPrefix
72
79
 
73
80
  // LOCAL answer: validation and binding already happened in the route.
74
81
  async function answerLocal({ askId, text }) {
75
- return runAnswer({ askId, text });
82
+ if (!receipts) return runAnswer({ askId, text });
83
+ if (receipts.isBlocked(askId) || ['committed', 'delivered'].includes(receipts.answerStateFor(askId))) return runAnswer({ askId, text });
84
+ const request = { peerId: 'local', requestId: crypto.randomUUID() };
85
+ const receipt = receipts.attempt({ ...request, askId, text });
86
+ if (!receipt.ok) return { ok: false, code: 409, reason: 'delivery-unknown-block', error: 'answer attempt refused' };
87
+ return runAnswer({ askId, text, request });
76
88
  }
77
89
 
78
90
  // FEDERATED answer: the receipt lifecycle wraps the same core.
@@ -103,7 +115,7 @@ function createAskAnswerService({ asks, paste, receipts, onClosure, labelPrefix
103
115
  return { ok: false, code: 429, error: 'receipt store al cap', reason: 'receipt-cap' };
104
116
  }
105
117
  const out = await runAnswer({ askId, text, request: { peerId, requestId } });
106
- if (out.ok) return { ok: true, state: 'committed' };
118
+ if (out.ok) return { ok: true, state: 'committed', closure: out.closure };
107
119
  // Every refusal is answered as it is: a rejection BEFORE the paste (claim
108
120
  // lost, ask closed, locked) finalizes the attempt as failed, and a transport
109
121
  // failure does the same — the peer re-checks the status instead of blindly
@@ -100,6 +100,7 @@ function createAskReceipts({ filePath, now = Date.now } = {}) {
100
100
  if (!entry) return false;
101
101
  if (entry.state !== 'pending') { entry.replayedAt = now(); return true; }
102
102
  entry.state = finalState;
103
+ if (finalState === 'delivery-unknown') state.blockedAsks[askId] = true;
103
104
  entry.receipt = receipt === undefined ? null : receipt;
104
105
  entry.updatedAt = now();
105
106
  persist(state);