@mmmbuto/nexuscrew 0.9.56 → 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.
- package/CHANGELOG.md +17 -0
- package/frontend/dist/assets/index-D8cf4AEo.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 +35 -5
- package/lib/notify/ask-answer-service.js +23 -11
- package/lib/notify/ask-receipts.js +1 -0
- package/lib/notify/ask-relay.js +210 -73
- package/lib/notify/event-feed-asks-routes.js +62 -15
- package/lib/notify/event-feed-client.js +120 -46
- package/lib/notify/routes.js +32 -17
- package/lib/proxy/federation.js +112 -12
- package/lib/proxy/resource-acl.js +1 -0
- package/lib/server.js +53 -20
- package/lib/tmux/actions.js +14 -11
- package/package.json +1 -1
- package/frontend/dist/assets/index-CJqN29CA.js +0 -169
package/frontend/dist/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
1
|
+
{"version":"0.9.58"}
|
package/lib/fleet/managed.js
CHANGED
|
@@ -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 };
|
package/lib/nodes/commands.js
CHANGED
|
@@ -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 =
|
|
500
|
-
filePath: nodesPath, name: node.name,
|
|
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
|
-
|
|
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 {
|
|
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 = {
|
package/lib/nodes/store.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
19
|
-
if (!asks ||
|
|
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
|
|
53
|
+
let submission;
|
|
54
54
|
try {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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);
|