@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.
@@ -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 === SCHEMA_VERSION
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 !== SCHEMA_VERSION) return null;
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 === SCHEMA_VERSION) {
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
- return store && store.schemaVersion === SCHEMA_VERSION ? SCHEMA_VERSION : PREVIOUS_SCHEMA_VERSION;
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 parsed = parseNode({ ...store.nodes[idx], ...patch }, mutationSchemaVersion(store));
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: SCHEMA_VERSION, nodes };
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
- grants: view.grants,
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: SCHEMA_VERSION,
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: SCHEMA_VERSION,
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 !== SCHEMA_VERSION || !current) {
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) => ({ ...a, ownerId }))
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
- .map((a) => ({ id: a.id, question: a.question, options: a.options, session: a.session, ts: a.ts }))
245
- .filter((a) => peer.allows({ scope: 'cell', cellId: cellForSession(a.session) }));
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) {