@mmmbuto/nexuscrew 0.9.65 → 0.9.67
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 +24 -0
- package/docs/NODES.md +55 -0
- package/docs/SECURITY.md +40 -0
- package/frontend/dist/assets/{index-DkYRC1vV.js → index-D__76yWR.js} +42 -42
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/audio/dispatch.js +14 -1
- package/lib/audio/routes.js +2 -2
- package/lib/cli/commands.js +31 -0
- package/lib/live-host/federation.js +10 -0
- package/lib/live-host/routes.js +12 -2
- package/lib/nodes/access-presets.js +27 -7
- package/lib/nodes/commands.js +63 -4
- package/lib/nodes/reverse-slot-listeners.js +4 -1
- package/lib/nodes/store.js +205 -12
- package/lib/notify/ask-cell.js +53 -0
- package/lib/notify/event-feed-client.js +40 -2
- package/lib/notify/event-feed-routes.js +8 -2
- package/lib/proxy/federation.js +308 -17
- package/lib/proxy/node-proxy.js +86 -3
- package/lib/server.js +126 -12
- package/lib/settings/routes.js +6 -0
- package/package.json +1 -1
package/lib/nodes/store.js
CHANGED
|
@@ -20,8 +20,16 @@ const crypto = require('node:crypto');
|
|
|
20
20
|
const reversePool = require('./reverse-pool.js');
|
|
21
21
|
const identity = require('./identity.js');
|
|
22
22
|
const accessPresets = require('./access-presets.js');
|
|
23
|
+
const { allowedByClass } = require('../proxy/resource-acl.js');
|
|
23
24
|
|
|
24
25
|
const SCHEMA_VERSION = 3;
|
|
26
|
+
// Schema 4 = schema 3 + the host-only publication fields of a peer
|
|
27
|
+
// (`publishRemote`, `publishRemoteAudience`). It is written ONLY when that
|
|
28
|
+
// mode is first enabled, after a full backup of the store: until then every
|
|
29
|
+
// store stays at the schema it already has, and a 0.9.65 binary keeps reading
|
|
30
|
+
// it. A 0.9.65 binary cannot read a schema 4 store (closed schema): going back
|
|
31
|
+
// means restoring the backup taken at the opt-in, never a silent downgrade.
|
|
32
|
+
const HOST_ONLY_SCHEMA_VERSION = 4;
|
|
25
33
|
const PREVIOUS_SCHEMA_VERSION = 2;
|
|
26
34
|
const LEGACY_SCHEMA_VERSION = 1;
|
|
27
35
|
const MAX_NODES = 64;
|
|
@@ -156,6 +164,11 @@ const NODE_KEYS = new Set([
|
|
|
156
164
|
// coherent "custom" preset instead of "not configured". It is provenance,
|
|
157
165
|
// not a permission — it grants nothing and handlers never read it as a role.
|
|
158
166
|
'accessConfigured',
|
|
167
|
+
// Host-only publication of a REMOTE peer (schema 4). Not Share: Share
|
|
168
|
+
// publishes THIS node on the peer's hub through a reverse (-R) channel; this
|
|
169
|
+
// publishes the peer to an explicit audience of our own admin nodes, over the
|
|
170
|
+
// existing private -L, one way only (see hostOnlyViolation).
|
|
171
|
+
'publishRemote', 'publishRemoteAudience',
|
|
159
172
|
]);
|
|
160
173
|
|
|
161
174
|
// Nome di cella: stessa forma usata da fleet/definitions.js, cells/routes.js e
|
|
@@ -247,6 +260,28 @@ function reversePoolDefault(base, { verification = 'unverifiable', generation =
|
|
|
247
260
|
};
|
|
248
261
|
}
|
|
249
262
|
|
|
263
|
+
// Invariants of a host-only peer, on a node with its defaults applied.
|
|
264
|
+
// Returns the first violation as text, or null. This is the single source of
|
|
265
|
+
// truth: parseNode refuses a record that breaks it (so no write path can
|
|
266
|
+
// persist one) and the mutators use it to explain WHY they refuse.
|
|
267
|
+
// The vector is the one the authorization code reads: allowedByClass looks at
|
|
268
|
+
// these exact fields, never at a label.
|
|
269
|
+
function hostOnlyViolation(node) {
|
|
270
|
+
if (!node || node.publishRemote !== true) return null;
|
|
271
|
+
if (node.direction !== 'outbound') return 'vale solo per un peer in uscita (outbound)';
|
|
272
|
+
if (node.shared === true) return 'e\' incompatibile con Share acceso su questo peer';
|
|
273
|
+
const audience = node.publishRemoteAudience;
|
|
274
|
+
if (!Array.isArray(audience) || audience.length === 0) return 'l\'audience non puo\' essere vuota';
|
|
275
|
+
if (node.nodeId && audience.includes(node.nodeId)) return 'l\'audience non puo\' contenere il peer pubblicato';
|
|
276
|
+
if (node.eventsReceive === true) return 'eventsReceive deve restare spento';
|
|
277
|
+
if (node.cellVisibility !== 'none' || (Array.isArray(node.cells) && node.cells.length > 0)) {
|
|
278
|
+
return 'le celle di questo nodo devono restare invisibili al peer (cellVisibility none)';
|
|
279
|
+
}
|
|
280
|
+
const open = accessPresets.BOOLEAN_GRANTS.filter((key) => node[key] === true);
|
|
281
|
+
if (open.length > 0) return `i grant verso il peer devono restare tutti a zero (aperti: ${open.join(', ')})`;
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
250
285
|
function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
251
286
|
if (!n || typeof n !== 'object' || Array.isArray(n)) return null;
|
|
252
287
|
for (const k of Object.keys(n)) { if (!NODE_KEYS.has(k)) return null; } // schema chiuso
|
|
@@ -434,6 +469,28 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
434
469
|
out.keyConflict = { seen: c.seen, at: c.at, source: c.source };
|
|
435
470
|
}
|
|
436
471
|
if (label) out.label = label;
|
|
472
|
+
if (n.publishRemote !== undefined || n.publishRemoteAudience !== undefined) {
|
|
473
|
+
// Only a schema 4 store may carry the fields: a store written at 1-3 with
|
|
474
|
+
// them is not something this code ever produced.
|
|
475
|
+
if (schemaVersion < HOST_ONLY_SCHEMA_VERSION) return null;
|
|
476
|
+
if (n.publishRemote !== undefined && typeof n.publishRemote !== 'boolean') return null;
|
|
477
|
+
let audience;
|
|
478
|
+
if (n.publishRemoteAudience !== undefined) {
|
|
479
|
+
if (!Array.isArray(n.publishRemoteAudience) || n.publishRemoteAudience.length > MAX_NODES) return null;
|
|
480
|
+
audience = [...new Set(n.publishRemoteAudience)];
|
|
481
|
+
if (audience.some((id) => typeof id !== 'string' || !NODE_ID_RE.test(id))) return null;
|
|
482
|
+
audience.sort();
|
|
483
|
+
}
|
|
484
|
+
if (n.publishRemote === true) {
|
|
485
|
+
out.publishRemote = true;
|
|
486
|
+
out.publishRemoteAudience = audience || [];
|
|
487
|
+
if (hostOnlyViolation(out) !== null) return null;
|
|
488
|
+
} else {
|
|
489
|
+
// Off: no audience may linger (a stale list would read as a grant).
|
|
490
|
+
if (audience && audience.length > 0) return null;
|
|
491
|
+
if (n.publishRemote === false) out.publishRemote = false;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
437
494
|
return out;
|
|
438
495
|
}
|
|
439
496
|
|
|
@@ -464,8 +521,8 @@ function parseStore(raw) {
|
|
|
464
521
|
return null;
|
|
465
522
|
}
|
|
466
523
|
|
|
467
|
-
if (![SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION].includes(d.schemaVersion)) return null;
|
|
468
|
-
const rootKeys = d.schemaVersion
|
|
524
|
+
if (![HOST_ONLY_SCHEMA_VERSION, SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION].includes(d.schemaVersion)) return null;
|
|
525
|
+
const rootKeys = d.schemaVersion >= SCHEMA_VERSION
|
|
469
526
|
? new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous', 'reversePoolAnchor', 'reversePoolLedgerInitialized', 'accessRevision', 'adminReceptionMigration'])
|
|
470
527
|
: new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous', 'adminReceptionMigration']);
|
|
471
528
|
if (Object.keys(d).some((key) => !rootKeys.has(key))) return null;
|
|
@@ -509,7 +566,7 @@ function parseStore(raw) {
|
|
|
509
566
|
// advances it to 1. It is kept out of the parsed object when absent so a
|
|
510
567
|
// plain load never rewrites the file with a field nobody set.
|
|
511
568
|
if (d.accessRevision !== undefined) {
|
|
512
|
-
if (d.schemaVersion
|
|
569
|
+
if (d.schemaVersion < SCHEMA_VERSION) return null;
|
|
513
570
|
if (!Number.isInteger(d.accessRevision) || d.accessRevision < 0) return null;
|
|
514
571
|
out.accessRevision = d.accessRevision;
|
|
515
572
|
}
|
|
@@ -519,7 +576,7 @@ function parseStore(raw) {
|
|
|
519
576
|
if (!rdv) return null;
|
|
520
577
|
out.rendezvous = rdv;
|
|
521
578
|
}
|
|
522
|
-
if (d.schemaVersion
|
|
579
|
+
if (d.schemaVersion >= SCHEMA_VERSION) {
|
|
523
580
|
// This marker distinguishes a fresh v3 peer (which may have received a
|
|
524
581
|
// pool from another hub) from a hub that has once owned allocations. If
|
|
525
582
|
// the latter loses its anchor, it must not silently start a new ledger.
|
|
@@ -665,7 +722,14 @@ function getNode(store, name) {
|
|
|
665
722
|
}
|
|
666
723
|
|
|
667
724
|
function mutationSchemaVersion(store) {
|
|
668
|
-
|
|
725
|
+
// A schema 3 or 4 store keeps ITS schema: writing 3 over a 4 store would
|
|
726
|
+
// silently drop the host-only fields (and parse would refuse the record).
|
|
727
|
+
return store && store.schemaVersion >= SCHEMA_VERSION ? store.schemaVersion : PREVIOUS_SCHEMA_VERSION;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// The schema a write that needs at least v3 must keep: never lower than the store's own.
|
|
731
|
+
function atLeastV3(store) {
|
|
732
|
+
return store && store.schemaVersion > SCHEMA_VERSION ? store.schemaVersion : SCHEMA_VERSION;
|
|
669
733
|
}
|
|
670
734
|
|
|
671
735
|
function addNode(store, entry) {
|
|
@@ -706,7 +770,12 @@ function setNodeToken(store, name, token) {
|
|
|
706
770
|
function updateNode(store, name, patch) {
|
|
707
771
|
const idx = store.nodes.findIndex((n) => n.name === name);
|
|
708
772
|
if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
|
|
709
|
-
const
|
|
773
|
+
const probe = { ...store.nodes[idx], ...patch };
|
|
774
|
+
// While the host-only mode is on, an edit that would break its invariants is
|
|
775
|
+
// refused WITH the reason (parseNode alone would only say "invalid").
|
|
776
|
+
const why = hostOnlyViolation(probe);
|
|
777
|
+
if (why) throw new Error(`publish-remote attivo su "${name}": ${why}`);
|
|
778
|
+
const parsed = parseNode(probe, mutationSchemaVersion(store));
|
|
710
779
|
if (!parsed) throw new Error('aggiornamento nodo non valido');
|
|
711
780
|
const nodes = store.nodes.slice();
|
|
712
781
|
nodes[idx] = parsed;
|
|
@@ -728,6 +797,9 @@ function setPeerAccessGrants(store, name, grants) {
|
|
|
728
797
|
if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
|
|
729
798
|
const check = accessPresets.validateGrants(grants);
|
|
730
799
|
if (!check.ok) throw new Error(`concessioni non valide: ${check.errors.join('; ')}`);
|
|
800
|
+
if (store.nodes[idx].publishRemote === true) {
|
|
801
|
+
throw new Error(`publish-remote attivo su "${name}": i grant verso questo nodo restano azzerati finche' la modalita' e' accesa (spegnila con \`nodes publish-remote ${name} off\`)`);
|
|
802
|
+
}
|
|
731
803
|
const next = { ...store.nodes[idx] };
|
|
732
804
|
next.cellVisibility = grants.cellVisibility;
|
|
733
805
|
if (grants.cellVisibility === 'selected') next.cells = [...(grants.cells || [])];
|
|
@@ -736,7 +808,7 @@ function setPeerAccessGrants(store, name, grants) {
|
|
|
736
808
|
next.accessConfigured = true;
|
|
737
809
|
const nodes = store.nodes.slice();
|
|
738
810
|
nodes[idx] = next;
|
|
739
|
-
const out = { ...store, schemaVersion:
|
|
811
|
+
const out = { ...store, schemaVersion: atLeastV3(store), nodes };
|
|
740
812
|
out.accessRevision = accessRevisionOf(store) + 1;
|
|
741
813
|
return out;
|
|
742
814
|
}
|
|
@@ -765,9 +837,12 @@ function accessInventory(store) {
|
|
|
765
837
|
name: peer.name,
|
|
766
838
|
nodeId: peer.nodeId || null,
|
|
767
839
|
direction: peer.direction,
|
|
840
|
+
// Provenance: was the vector set on purpose? Not what is granted.
|
|
768
841
|
configured: view.configured,
|
|
769
842
|
label: view.label,
|
|
770
|
-
|
|
843
|
+
// What the resource gate applies, also for a legacy record (see
|
|
844
|
+
// accessPresets.effectiveGrants).
|
|
845
|
+
grants: accessPresets.effectiveGrants(peer),
|
|
771
846
|
};
|
|
772
847
|
});
|
|
773
848
|
return { revision: accessRevisionOf(store), peers };
|
|
@@ -814,6 +889,108 @@ function setPeerAccessPresetCas({ filePath, name, presetName, expectedRevision,
|
|
|
814
889
|
eventsReceive: written.nodes.find((n) => n.name === name).eventsReceive === true };
|
|
815
890
|
}
|
|
816
891
|
|
|
892
|
+
// --- Host-only publication of a remote peer ---------------------------------
|
|
893
|
+
// `nodes publish-remote <peer> on --audience <ids>|off`. NOT Share: nothing
|
|
894
|
+
// here touches the tunnel, the reverse pool or the peer's SSH session.
|
|
895
|
+
//
|
|
896
|
+
// The audience is an explicit list of instanceIds of OUR direct peers that
|
|
897
|
+
// already hold the operator grant (the same predicate the authorization code
|
|
898
|
+
// uses: allowedByClass('operator', peer)). Unknown ids, ids of peers without
|
|
899
|
+
// that grant and the published peer itself are refused.
|
|
900
|
+
function enablePublishRemote(st, name, audience) {
|
|
901
|
+
const idx = st.nodes.findIndex((n) => n.name === name);
|
|
902
|
+
if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
|
|
903
|
+
const node = st.nodes[idx];
|
|
904
|
+
if (node.direction !== 'outbound') throw new Error(`"${name}" non e' un peer in uscita: publish-remote vale solo per quelli`);
|
|
905
|
+
if (!node.token) throw new Error(`"${name}" non e' accoppiato (manca il token): niente da pubblicare`);
|
|
906
|
+
if (node.shared === true) throw new Error(`Share e' acceso su "${name}": publish-remote e' un'altra cosa, spegni prima Share`);
|
|
907
|
+
if (node.publishRemote === true) throw new Error(`publish-remote e' gia' acceso su "${name}": spegnilo per cambiare l'audience`);
|
|
908
|
+
if (!Array.isArray(audience) || audience.length === 0) {
|
|
909
|
+
throw new Error('audience vuota: indica gli instanceId dei nodi admin che possono vedere il peer (--audience id,id,...)');
|
|
910
|
+
}
|
|
911
|
+
const ids = [...new Set(audience)];
|
|
912
|
+
if (ids.length > MAX_NODES) throw new Error('audience troppo lunga');
|
|
913
|
+
for (const id of ids) {
|
|
914
|
+
if (typeof id !== 'string' || !NODE_ID_RE.test(id)) throw new Error(`instanceId non valido nell'audience: "${String(id).slice(0, 40)}"`);
|
|
915
|
+
if (id === node.nodeId) throw new Error(`l'audience non puo' contenere il peer pubblicato (${name})`);
|
|
916
|
+
const reader = st.nodes.find((n) => n.nodeId === id);
|
|
917
|
+
if (!reader) throw new Error(`instanceId sconosciuto nell'audience: ${id} (non e' un peer diretto di questo nodo)`);
|
|
918
|
+
if (reader.publishRemote === true) throw new Error(`"${reader.name}" e' a sua volta un peer solo-host: non puo' stare nell'audience`);
|
|
919
|
+
if (!allowedByClass('operator', reader)) {
|
|
920
|
+
throw new Error(`"${reader.name}" (${id}) non e' un nodo admin: manca peerOperatorAccess`);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
// ONE record, ONE save: the publication and the zeroed vector travel together,
|
|
924
|
+
// so there is no instant at which the peer is published with open grants.
|
|
925
|
+
const next = { ...node, cellVisibility: 'none', eventsReceive: false, accessConfigured: true,
|
|
926
|
+
publishRemote: true, publishRemoteAudience: ids.sort() };
|
|
927
|
+
delete next.cells;
|
|
928
|
+
for (const key of accessPresets.BOOLEAN_GRANTS) next[key] = false;
|
|
929
|
+
const nodes = st.nodes.slice();
|
|
930
|
+
nodes[idx] = next;
|
|
931
|
+
const out = parseStore({ ...st, schemaVersion: Math.max(HOST_ONLY_SCHEMA_VERSION, st.schemaVersion), nodes,
|
|
932
|
+
accessRevision: accessRevisionOf(st) + 1 });
|
|
933
|
+
if (!out) throw new Error('publish-remote: lo store risultante non e\' valido (schema strict)');
|
|
934
|
+
return out;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Off: the publication ends at once; the grants stay at zero (they are NOT
|
|
938
|
+
// restored to whatever they were before: re-opening anything is an explicit act).
|
|
939
|
+
function disablePublishRemote(st, name) {
|
|
940
|
+
const idx = st.nodes.findIndex((n) => n.name === name);
|
|
941
|
+
if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
|
|
942
|
+
const node = st.nodes[idx];
|
|
943
|
+
if (node.publishRemote !== true) return st;
|
|
944
|
+
const next = { ...node, publishRemote: false };
|
|
945
|
+
delete next.publishRemoteAudience;
|
|
946
|
+
const nodes = st.nodes.slice();
|
|
947
|
+
nodes[idx] = next;
|
|
948
|
+
const out = parseStore({ ...st, nodes, accessRevision: accessRevisionOf(st) + 1 });
|
|
949
|
+
if (!out) throw new Error('publish-remote: lo store risultante non e\' valido (schema strict)');
|
|
950
|
+
return out;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Full, protected copy of the store taken right before the opt-in write. It is
|
|
954
|
+
// the rollback path: a 0.9.65 binary cannot read a schema 4 store.
|
|
955
|
+
function backupStoreFile(filePath, now = new Date()) {
|
|
956
|
+
const st = fs.lstatSync(filePath);
|
|
957
|
+
if (st.isSymbolicLink() || !st.isFile()) throw new Error('backup rifiutato: nodes.json non e\' un file regolare');
|
|
958
|
+
const data = fs.readFileSync(filePath);
|
|
959
|
+
const sha256 = crypto.createHash('sha256').update(data).digest('hex');
|
|
960
|
+
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
961
|
+
// wx: never overwrite a previous backup. Two backups of the same bytes in the same second get distinct names
|
|
962
|
+
// (a numeric suffix) instead of failing the opt-in.
|
|
963
|
+
let target = '';
|
|
964
|
+
for (let n = 0; ; n += 1) {
|
|
965
|
+
target = `${filePath}.pre-publish-remote-${stamp}-${sha256.slice(0, 8)}${n ? `-${n}` : ''}.bak`;
|
|
966
|
+
try { fs.writeFileSync(target, data, { mode: 0o600, flag: 'wx' }); break; } catch (e) {
|
|
967
|
+
if (e && e.code === 'EEXIST' && n < 1000) continue;
|
|
968
|
+
throw e;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
fs.chmodSync(target, 0o600);
|
|
972
|
+
return { path: target, sha256 };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// Compare-and-set write of the mode, keyed on the same revision as the access
|
|
976
|
+
// grants (turning it on changes them). `on` takes the backup BEFORE writing.
|
|
977
|
+
function setPublishRemoteCas({ filePath, name, enabled, audience, expectedRevision }) {
|
|
978
|
+
const current = loadStoreStrict(filePath);
|
|
979
|
+
if (expectedRevision !== undefined && (!Number.isInteger(expectedRevision) || accessRevisionOf(current) !== expectedRevision)) {
|
|
980
|
+
return { ok: false, conflict: true, revision: accessRevisionOf(current) };
|
|
981
|
+
}
|
|
982
|
+
const next = enabled ? enablePublishRemote(current, name, audience) : disablePublishRemote(current, name);
|
|
983
|
+
if (next === current) return { ok: true, name, enabled: false, unchanged: true, revision: accessRevisionOf(current), schemaVersion: current.schemaVersion };
|
|
984
|
+
const backup = enabled ? backupStoreFile(filePath) : null;
|
|
985
|
+
const written = atomicWriteStore(filePath, next);
|
|
986
|
+
const node = written.nodes.find((n) => n.name === name);
|
|
987
|
+
return {
|
|
988
|
+
ok: true, name, enabled: node.publishRemote === true,
|
|
989
|
+
audience: node.publishRemote === true ? [...node.publishRemoteAudience] : [],
|
|
990
|
+
revision: accessRevisionOf(written), schemaVersion: written.schemaVersion, backup,
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
817
994
|
// One startup migration; later operational reception edits remain authoritative.
|
|
818
995
|
function migrateAdminReception({ filePath, readonly = false }) {
|
|
819
996
|
if (readonly || process.env.NEXUSCREW_READONLY === '1') return { migrated: false };
|
|
@@ -828,7 +1005,7 @@ function migrateAdminReception({ filePath, readonly = false }) {
|
|
|
828
1005
|
changed += 1;
|
|
829
1006
|
}
|
|
830
1007
|
}
|
|
831
|
-
if (changed) next = { ...next, schemaVersion:
|
|
1008
|
+
if (changed) next = { ...next, schemaVersion: atLeastV3(current),
|
|
832
1009
|
accessRevision: accessRevisionOf(current) + 1 };
|
|
833
1010
|
atomicWriteStore(filePath, { ...next, adminReceptionMigration: 1 });
|
|
834
1011
|
return { migrated: true, changed };
|
|
@@ -845,7 +1022,7 @@ function upgradeToReversePoolSchema(store, anchor = undefined) {
|
|
|
845
1022
|
if (anchor !== undefined && !parsedAnchor) throw new Error('reverse pool anchor non valida');
|
|
846
1023
|
const next = {
|
|
847
1024
|
...current,
|
|
848
|
-
schemaVersion:
|
|
1025
|
+
schemaVersion: atLeastV3(current),
|
|
849
1026
|
...(parsedAnchor ? { reversePoolLedgerInitialized: true, reversePoolAnchor: parsedAnchor } : {}),
|
|
850
1027
|
};
|
|
851
1028
|
const parsed = parseStore(next);
|
|
@@ -855,7 +1032,7 @@ function upgradeToReversePoolSchema(store, anchor = undefined) {
|
|
|
855
1032
|
|
|
856
1033
|
function setNodeReversePool(store, name, value) {
|
|
857
1034
|
const current = store && getNode(store, name);
|
|
858
|
-
if (!store || store.schemaVersion
|
|
1035
|
+
if (!store || store.schemaVersion < SCHEMA_VERSION || !current) {
|
|
859
1036
|
throw new Error('reverse pool richiede schema v3');
|
|
860
1037
|
}
|
|
861
1038
|
if (current.direction === 'inbound' && !parseReversePoolAnchor(store.reversePoolAnchor)) {
|
|
@@ -883,10 +1060,24 @@ function redactNode(n) {
|
|
|
883
1060
|
panelAccess: n.panelAccess === true,
|
|
884
1061
|
liveHostAccess: n.liveHostAccess === true,
|
|
885
1062
|
visibility: n.visibility || 'network',
|
|
1063
|
+
// The grants this node concedes to the peer. They are decisions, not
|
|
1064
|
+
// credentials, and consumers (the CLI `nodes inspect`, the inventory) derive
|
|
1065
|
+
// the label and the effective vector from THESE fields: leaving them out made
|
|
1066
|
+
// `inspect` read an "unconfigured" peer and print every grant as off.
|
|
1067
|
+
accessConfigured: n.accessConfigured === true,
|
|
1068
|
+
eventsAccess: n.eventsAccess === true,
|
|
1069
|
+
nodeEventsAccess: n.nodeEventsAccess === true,
|
|
1070
|
+
askReplyAccess: n.askReplyAccess === true,
|
|
1071
|
+
filesReadAccess: n.filesReadAccess === true,
|
|
1072
|
+
peerOperatorAccess: n.peerOperatorAccess === true,
|
|
886
1073
|
hasToken: !!n.token, // presenza, non il valore
|
|
887
1074
|
paired: !!(n.token && n.acceptToken),
|
|
888
1075
|
};
|
|
889
1076
|
if (n.identityFile || n.keyPath) out.hasIdentity = true;
|
|
1077
|
+
if (n.publishRemote === true) {
|
|
1078
|
+
out.publishRemote = true;
|
|
1079
|
+
out.publishRemoteAudience = [...(n.publishRemoteAudience || [])];
|
|
1080
|
+
}
|
|
890
1081
|
if (n.visibility === 'selected') out.selected = [...(n.selected || [])];
|
|
891
1082
|
// Lo scope celle non e' un segreto: e' una decisione dell'operatore e va
|
|
892
1083
|
// mostrata, come `selected`. La Settings UI deve poterla leggere per dirla.
|
|
@@ -1076,6 +1267,8 @@ module.exports = {
|
|
|
1076
1267
|
// concessioni federate (preset, CAS, inventario)
|
|
1077
1268
|
accessRevisionOf, setPeerAccessGrants, setPeerAccessPreset, accessInventory, migrateAdminReception,
|
|
1078
1269
|
setPeerAccessGrantsCas, setPeerAccessPresetCas,
|
|
1270
|
+
// host-only publication of a remote peer (schema 4, opt-in)
|
|
1271
|
+
hostOnlyViolation, enablePublishRemote, disablePublishRemote, setPublishRemoteCas, backupStoreFile,
|
|
1079
1272
|
// redazione
|
|
1080
1273
|
redactNode, redactStore, hasPairedPeers,
|
|
1081
1274
|
// migrazione
|
|
@@ -1083,5 +1276,5 @@ module.exports = {
|
|
|
1083
1276
|
// label / slug
|
|
1084
1277
|
nodeLabel, validLabel, sanitizeLabel, toSlug, deriveNodeHandle, suggestNodeName, LABEL_MAX,
|
|
1085
1278
|
// costanti
|
|
1086
|
-
SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION, MAX_NODES, MAX_TOKEN_LEN, NODE_NAME_RE, NODE_ID_RE,
|
|
1279
|
+
SCHEMA_VERSION, HOST_ONLY_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION, MAX_NODES, MAX_TOKEN_LEN, NODE_NAME_RE, NODE_ID_RE,
|
|
1087
1280
|
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/notify/ask-cell.js — which cell an IMPORTED ask belongs to.
|
|
3
|
+
//
|
|
4
|
+
// The owner of an ask is the only node that knows its cell: it resolves the
|
|
5
|
+
// ask's tmux session through its own cell definitions (a cell may declare a
|
|
6
|
+
// custom tmux session, so the name is not always `cloud-<cell>`). Decoding the
|
|
7
|
+
// session name here is therefore only a FALLBACK for the canonical names; for
|
|
8
|
+
// any other name the cell has to come from the owner.
|
|
9
|
+
//
|
|
10
|
+
// Two authenticated sources carry what the owner resolved, and nothing else is
|
|
11
|
+
// trusted:
|
|
12
|
+
// - `ownerCellId`: set by the event-feed client from the owner's authenticated
|
|
13
|
+
// snapshot item or live envelope (never copied from a wire field of that name);
|
|
14
|
+
// - `originCell`: the cell the origin resolver attested for an ask pushed by the
|
|
15
|
+
// owner (written by the server when the alias is created).
|
|
16
|
+
// Never derived by stripping a prefix from a string, never from the local cell
|
|
17
|
+
// definitions (those describe THIS node, not the owner), never from a request body.
|
|
18
|
+
const { cellIdFromTmuxSession } = require('../fleet/definitions.js');
|
|
19
|
+
|
|
20
|
+
const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
|
|
21
|
+
function isCellId(value) { return typeof value === 'string' && CELL_ID_RE.test(value); }
|
|
22
|
+
|
|
23
|
+
// -> { cellId, source, conflict? }. `source` is 'owner-feed' | 'origin-attested' |
|
|
24
|
+
// 'canonical-session' | null. Two authenticated sources that disagree resolve to
|
|
25
|
+
// nothing (fail closed): there is no way to tell which one is stale.
|
|
26
|
+
function resolveAskCell(ask) {
|
|
27
|
+
if (!ask || typeof ask !== 'object') return { cellId: null, source: null };
|
|
28
|
+
const fromFeed = isCellId(ask.ownerCellId) ? ask.ownerCellId : null;
|
|
29
|
+
const attested = isCellId(ask.originCell) ? ask.originCell : null;
|
|
30
|
+
if (fromFeed && attested && fromFeed !== attested) return { cellId: null, source: null, conflict: true };
|
|
31
|
+
if (fromFeed) return { cellId: fromFeed, source: 'owner-feed' };
|
|
32
|
+
if (attested) return { cellId: attested, source: 'origin-attested' };
|
|
33
|
+
const decoded = cellIdFromTmuxSession(ask.session);
|
|
34
|
+
return decoded ? { cellId: decoded, source: 'canonical-session' } : { cellId: null, source: null };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The same ask can be known from two places at once (the alias created when the
|
|
38
|
+
// owner pushed it, the card of the owner's feed). Call this with the candidates
|
|
39
|
+
// that are the SAME generation of the SAME ask. Authenticated sources outrank the
|
|
40
|
+
// session decode; two authenticated sources that disagree resolve to nothing; a
|
|
41
|
+
// source that knows the cell completes one that does not.
|
|
42
|
+
function resolveAskCellFrom(asks) {
|
|
43
|
+
const results = asks.filter(Boolean).map(resolveAskCell);
|
|
44
|
+
const authenticated = results.filter((r) => r.cellId && r.source !== 'canonical-session');
|
|
45
|
+
const ids = new Set(authenticated.map((r) => r.cellId));
|
|
46
|
+
if (results.some((r) => r.conflict) || ids.size > 1) return { cellId: null, source: null, conflict: true };
|
|
47
|
+
if (ids.size === 1) return authenticated[0];
|
|
48
|
+
const decoded = new Set(results.filter((r) => r.cellId).map((r) => r.cellId));
|
|
49
|
+
if (decoded.size === 1) return results.find((r) => r.cellId);
|
|
50
|
+
return decoded.size > 1 ? { cellId: null, source: null, conflict: true } : { cellId: null, source: null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { resolveAskCell, resolveAskCellFrom, isCellId, CELL_ID_RE };
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// dies by construction. Transport failures back off with jitter 5→60 s; a
|
|
12
12
|
// stream silent for 60 s is aborted and resynced.
|
|
13
13
|
|
|
14
|
+
const askCell = require('./ask-cell.js');
|
|
15
|
+
|
|
14
16
|
const BACKOFF_MIN_MS = 5000;
|
|
15
17
|
const BACKOFF_MAX_MS = 60000;
|
|
16
18
|
const IDLE_TIMEOUT_MS = 60000;
|
|
@@ -259,6 +261,28 @@ function createEventFeedClient(opts = {}) {
|
|
|
259
261
|
}
|
|
260
262
|
}
|
|
261
263
|
|
|
264
|
+
// An ask of a snapshot, attributed to its owner, with the cell the OWNER
|
|
265
|
+
// resolved for it. `cellId` on the wire becomes `ownerCellId` here (validated by
|
|
266
|
+
// the schema check) and any `ownerCellId`/`originCell` the wire carried is
|
|
267
|
+
// dropped: those two names are ours, set only by authenticated ingress. When the
|
|
268
|
+
// snapshot has no cell for the ask, the cell the view already held for the SAME
|
|
269
|
+
// generation (same owner timestamp and content) is kept; a new generation never
|
|
270
|
+
// inherits it.
|
|
271
|
+
function withOwnerCell(wire, ownerId, knownCells) {
|
|
272
|
+
const { cellId, ownerCellId: _forged, originCell: _forgedOrigin, ...ask } = wire;
|
|
273
|
+
const out = { ...ask, ownerId };
|
|
274
|
+
if (askCell.isCellId(cellId)) return { ...out, ownerCellId: cellId };
|
|
275
|
+
const hit = knownCells.get(out.ownerAskId || out.id);
|
|
276
|
+
if (hit) {
|
|
277
|
+
const { askFingerprint } = require('./asks.js');
|
|
278
|
+
const priorTs = Object.hasOwn(hit.prior, 'ownerAskTs') ? hit.prior.ownerAskTs : hit.prior.ts;
|
|
279
|
+
const nextTs = Object.hasOwn(out, 'ownerAskTs') ? out.ownerAskTs : out.ts;
|
|
280
|
+
if (Number.isSafeInteger(priorTs) && priorTs > 0 && priorTs === nextTs
|
|
281
|
+
&& askFingerprint(hit.prior) === askFingerprint(out)) return { ...out, ownerCellId: hit.cellId };
|
|
282
|
+
}
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
|
|
262
286
|
// Apply a fetched snapshot ONLY if it belongs to the current generation:
|
|
263
287
|
// an obsolete fetch must never overwrite a newer view (the snapshot
|
|
264
288
|
// WINS over the previous view of the same owner).
|
|
@@ -268,10 +292,18 @@ function createEventFeedClient(opts = {}) {
|
|
|
268
292
|
view.cursor = snap.cursor || null;
|
|
269
293
|
// A snapshot taken before the owner processed a dismiss still carries the
|
|
270
294
|
// ask: the tombstone is what keeps it from coming back.
|
|
295
|
+
// What this view already knows about the cell of each ask, taken BEFORE the
|
|
296
|
+
// view is replaced: an owner that does not publish the cell in its snapshot
|
|
297
|
+
// (older release) must not make a card forget what its live frame said.
|
|
298
|
+
const knownCells = new Map();
|
|
299
|
+
for (const prior of [...view.asks, ...(view.alertAsks ? view.alertAsks.values() : [])]) {
|
|
300
|
+
const resolved = askCell.resolveAskCell({ ownerCellId: prior.ownerCellId, originCell: prior.originCell });
|
|
301
|
+
if (resolved.cellId) knownCells.set(prior.ownerAskId || prior.id, { prior, cellId: resolved.cellId });
|
|
302
|
+
}
|
|
271
303
|
view.asks = Array.isArray(snap.asks)
|
|
272
304
|
? snap.asks
|
|
273
305
|
.filter((a) => a && !askIsClosed(ownerId, a.ownerAskId || a.id, snap.viewEpoch, a) && !isAskDismissed(ownerId, a))
|
|
274
|
-
.map((a) => (
|
|
306
|
+
.map((a) => withOwnerCell(a, ownerId, knownCells))
|
|
275
307
|
: [];
|
|
276
308
|
view.alertAsks = new Map(); // fresh snapshot replaces all live-only alert sources
|
|
277
309
|
// A snapshot taken before the owner processed the dismissal still carries
|
|
@@ -405,7 +437,10 @@ function createEventFeedClient(opts = {}) {
|
|
|
405
437
|
if (historicalAsk && typeof opts.onAuthoritativeAsks === 'function') opts.onAuthoritativeAsks(envelope.ownerId, [historicalAsk]);
|
|
406
438
|
} else if (f.type === 'ask') {
|
|
407
439
|
const ask = { id: f.askId, question: f.question, options: f.options, session: f.session, ts: f.ts,
|
|
408
|
-
ownerAskTs: Number.isSafeInteger(f.askTs) && f.askTs > 0 ? f.askTs : null, ownerId: envelope.ownerId
|
|
440
|
+
ownerAskTs: Number.isSafeInteger(f.askTs) && f.askTs > 0 ? f.askTs : null, ownerId: envelope.ownerId,
|
|
441
|
+
// The envelope of an ask frame is scoped to the cell the owner resolved;
|
|
442
|
+
// it is the owner's statement, carried on the authenticated stream.
|
|
443
|
+
...(envelope.scope === 'cell' && askCell.isCellId(envelope.cellId) ? { ownerCellId: envelope.cellId } : {}) };
|
|
409
444
|
if (!rememberLiveAsk(envelope.ownerId, ask)) return false;
|
|
410
445
|
if (typeof opts.onAuthoritativeAsks === 'function') opts.onAuthoritativeAsks(envelope.ownerId, [ask]);
|
|
411
446
|
opts.eventsHub.broadcast({ type: 'ask', ownerId: envelope.ownerId, eventId: envelope.eventId,
|
|
@@ -712,6 +747,9 @@ function createEventFeedClient(opts = {}) {
|
|
|
712
747
|
&& (a.question === undefined || typeof a.question === 'string')
|
|
713
748
|
&& (a.options === undefined || Array.isArray(a.options))
|
|
714
749
|
&& (a.session === undefined || typeof a.session === 'string')
|
|
750
|
+
// The cell the OWNER resolved for the ask: absent (an older owner, or a
|
|
751
|
+
// session the owner could not resolve) or a well-formed cell id, never anything else.
|
|
752
|
+
&& (a.cellId === undefined || askCell.isCellId(a.cellId))
|
|
715
753
|
&& (a.ts === undefined || typeof a.ts === 'number');
|
|
716
754
|
if (!snap.asks.every(askFieldsOk)) return schema('asks');
|
|
717
755
|
return { ok: true };
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
const express = require('express');
|
|
15
15
|
const nodesStore = require('../nodes/store.js');
|
|
16
16
|
const eventFeedAcl = require('./event-feed-acl.js');
|
|
17
|
+
const askCell = require('./ask-cell.js');
|
|
17
18
|
|
|
18
19
|
const SNAPSHOT_MAX_BYTES = 3 * 1024 * 1024;
|
|
19
20
|
const SNAPSHOT_MAX_ASKS = 100;
|
|
@@ -241,8 +242,13 @@ function createEventFeedRoutes(deps) {
|
|
|
241
242
|
// guardia e' esplicita sui campi di provenienza, non dedotta dalla
|
|
242
243
|
// visibilita' della cella.
|
|
243
244
|
.filter((a) => !a.originNode && !(a.ownerId && self && a.ownerId !== self))
|
|
244
|
-
|
|
245
|
-
|
|
245
|
+
// The cell is resolved ONCE, by this owner, through its own definitions, and
|
|
246
|
+
// it travels with the ask: a session with a custom tmux name does not decode
|
|
247
|
+
// to a cell on the receiving side, and the receiver must not guess it. Only
|
|
248
|
+
// a well-formed id is published; an unresolved one is simply absent.
|
|
249
|
+
.map((a) => ({ id: a.id, question: a.question, options: a.options, session: a.session, ts: a.ts, cell: cellForSession(a.session) }))
|
|
250
|
+
.filter((a) => peer.allows({ scope: 'cell', cellId: a.cell }))
|
|
251
|
+
.map(({ cell, ...ask }) => (askCell.isCellId(cell) ? { ...ask, cellId: cell } : ask));
|
|
246
252
|
askSourceCount = visible.length;
|
|
247
253
|
asks = visible.slice(0, SNAPSHOT_MAX_ASKS);
|
|
248
254
|
} catch (e) {
|