@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/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-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.
|
|
1
|
+
{"version":"0.9.59"}
|
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
|
@@ -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;
|
|
@@ -461,8 +466,8 @@ function parseStore(raw) {
|
|
|
461
466
|
|
|
462
467
|
if (![SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION].includes(d.schemaVersion)) return null;
|
|
463
468
|
const rootKeys = d.schemaVersion === SCHEMA_VERSION
|
|
464
|
-
? new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous', 'reversePoolAnchor', 'reversePoolLedgerInitialized', 'accessRevision'])
|
|
465
|
-
: new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous']);
|
|
469
|
+
? new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous', 'reversePoolAnchor', 'reversePoolLedgerInitialized', 'accessRevision', 'adminReceptionMigration'])
|
|
470
|
+
: new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous', 'adminReceptionMigration']);
|
|
466
471
|
if (Object.keys(d).some((key) => !rootKeys.has(key))) return null;
|
|
467
472
|
if (typeof d.nodeId !== 'string' || !NODE_ID_RE.test(d.nodeId)) return null;
|
|
468
473
|
if (!Array.isArray(d.nodes) || d.nodes.length > MAX_NODES) return null;
|
|
@@ -494,6 +499,10 @@ function parseStore(raw) {
|
|
|
494
499
|
}
|
|
495
500
|
|
|
496
501
|
const out = { schemaVersion: d.schemaVersion, nodeId: d.nodeId, nodes };
|
|
502
|
+
if (d.adminReceptionMigration !== undefined) {
|
|
503
|
+
if (d.adminReceptionMigration !== 1) return null;
|
|
504
|
+
out.adminReceptionMigration = d.adminReceptionMigration;
|
|
505
|
+
}
|
|
497
506
|
|
|
498
507
|
// Revision of the peer access policy. Absent means 0: a store written
|
|
499
508
|
// before this field existed is at revision 0 and the first explicit write
|
|
@@ -566,6 +575,19 @@ function atomicWriteStore(p, data) {
|
|
|
566
575
|
const parsed = parseStore(data);
|
|
567
576
|
if (!parsed) throw new Error('nodes.json non valido: validazione fallita (schema strict)');
|
|
568
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
|
+
|
|
569
591
|
const dir = path.dirname(p);
|
|
570
592
|
fs.mkdirSync(dir, { recursive: true });
|
|
571
593
|
const tmp = path.join(dir, `.${path.basename(p)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
@@ -727,7 +749,9 @@ function setPeerAccessPreset(store, name, presetName) {
|
|
|
727
749
|
const vector = { cellVisibility: applied.cellVisibility };
|
|
728
750
|
if (applied.cells !== undefined) vector.cells = applied.cells;
|
|
729
751
|
for (const key of accessPresets.BOOLEAN_GRANTS) vector[key] = applied[key];
|
|
730
|
-
|
|
752
|
+
const out = setPeerAccessGrants(store, name, vector);
|
|
753
|
+
out.nodes[idx].eventsReceive = applied.eventsReceive === true;
|
|
754
|
+
return out;
|
|
731
755
|
}
|
|
732
756
|
|
|
733
757
|
// Read-only inventory: what every peer is allowed to do on THIS node, with the
|
|
@@ -778,12 +802,36 @@ function setPeerAccessPresetCas({ filePath, name, presetName, expectedRevision,
|
|
|
778
802
|
if (!Number.isInteger(expectedRevision) || accessRevisionOf(current) !== expectedRevision) {
|
|
779
803
|
return { ok: false, conflict: true, revision: accessRevisionOf(current) };
|
|
780
804
|
}
|
|
805
|
+
if (presetName === 'admin' && extraPatch?.eventsReceive === false) {
|
|
806
|
+
throw new Error('admin preset conflicts with eventsReceive=false');
|
|
807
|
+
}
|
|
781
808
|
let next = current;
|
|
782
809
|
if (extraPatch && Object.keys(extraPatch).length > 0) next = updateNode(next, name, extraPatch);
|
|
783
810
|
next = setPeerAccessPreset(next, name, presetName);
|
|
784
811
|
const written = atomicWriteStore(filePath, next);
|
|
785
812
|
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
|
|
813
|
+
return { ok: true, name, revision: accessRevisionOf(written), grants: view.grants, label: view.label,
|
|
814
|
+
eventsReceive: written.nodes.find((n) => n.name === name).eventsReceive === true };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// One startup migration; later operational reception edits remain authoritative.
|
|
818
|
+
function migrateAdminReception({ filePath, readonly = false }) {
|
|
819
|
+
if (readonly || process.env.NEXUSCREW_READONLY === '1') return { migrated: false };
|
|
820
|
+
const current = loadStoreStrict(filePath);
|
|
821
|
+
if (current.adminReceptionMigration === 1) return { migrated: false };
|
|
822
|
+
let next = current;
|
|
823
|
+
let changed = 0;
|
|
824
|
+
for (const peer of current.nodes) {
|
|
825
|
+
const view = accessPresets.grantsOf(peer);
|
|
826
|
+
if (view.configured && view.label === 'admin' && peer.eventsReceive !== true) {
|
|
827
|
+
next = updateNode(next, peer.name, { eventsReceive: true });
|
|
828
|
+
changed += 1;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (changed) next = { ...next, schemaVersion: SCHEMA_VERSION,
|
|
832
|
+
accessRevision: accessRevisionOf(current) + 1 };
|
|
833
|
+
atomicWriteStore(filePath, { ...next, adminReceptionMigration: 1 });
|
|
834
|
+
return { migrated: true, changed };
|
|
787
835
|
}
|
|
788
836
|
|
|
789
837
|
// The upgrade is explicit: normal legacy pairing remains schema v2 and keeps
|
|
@@ -1026,7 +1074,7 @@ module.exports = {
|
|
|
1026
1074
|
// mutazioni
|
|
1027
1075
|
getNode, addNode, removeNode, setNodeToken, updateNode, upgradeToReversePoolSchema, setNodeReversePool,
|
|
1028
1076
|
// concessioni federate (preset, CAS, inventario)
|
|
1029
|
-
accessRevisionOf, setPeerAccessGrants, setPeerAccessPreset, accessInventory,
|
|
1077
|
+
accessRevisionOf, setPeerAccessGrants, setPeerAccessPreset, accessInventory, migrateAdminReception,
|
|
1030
1078
|
setPeerAccessGrantsCas, setPeerAccessPresetCas,
|
|
1031
1079
|
// redazione
|
|
1032
1080
|
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);
|