@mmmbuto/nexuscrew 0.9.65 → 0.9.66
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 +10 -0
- package/docs/NODES.md +44 -0
- package/docs/SECURITY.md +40 -0
- package/frontend/dist/assets/{index-DkYRC1vV.js → index-DhZOap1-.js} +1 -1
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +31 -0
- package/lib/nodes/access-presets.js +27 -7
- package/lib/nodes/commands.js +63 -4
- package/lib/nodes/store.js +197 -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 +43 -4
- package/lib/server.js +28 -5
- package/lib/settings/routes.js +6 -0
- package/package.json +1 -1
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-DhZOap1-.js"></script>
|
|
15
15
|
<link rel="stylesheet" crossorigin href="/assets/index-Dwd37Z_R.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.9.
|
|
1
|
+
{"version":"0.9.66"}
|
package/lib/cli/commands.js
CHANGED
|
@@ -85,6 +85,8 @@ Usage:
|
|
|
85
85
|
nexuscrew nodes cells <name|nodeId> all|none|Cella1,Cella2
|
|
86
86
|
[--selected NODE_ID,...]
|
|
87
87
|
nexuscrew nodes share <name|nodeId> on|off [--json]
|
|
88
|
+
nexuscrew nodes publish-remote <name|nodeId> on --audience <instanceId,...> [--json]
|
|
89
|
+
nexuscrew nodes publish-remote <name|nodeId> off|status [--json]
|
|
88
90
|
nexuscrew nodes panel <name|nodeId> on|off
|
|
89
91
|
nexuscrew nodes live-host <name|nodeId> on|off
|
|
90
92
|
nexuscrew nodes edit <name|nodeId> --access-role admin|user|nexushost
|
|
@@ -118,6 +120,7 @@ const CLI_VALUE_FLAGS = new Set([
|
|
|
118
120
|
'name', 'local-name', 'local-label', 'identity-file', 'port', 'dir', 'events-receive',
|
|
119
121
|
'access-role', 'access-revision',
|
|
120
122
|
'events-access', 'node-events-access', 'ask-reply-access', 'files-read-access', 'peer-operator-access',
|
|
123
|
+
'audience',
|
|
121
124
|
]);
|
|
122
125
|
|
|
123
126
|
// valueFlags (Set|array opzionale): flag che consumano il token successivo nella
|
|
@@ -1367,6 +1370,34 @@ async function dispatchNodes(rest, flags, opts = {}) {
|
|
|
1367
1370
|
return { code: 0 };
|
|
1368
1371
|
} catch (error) { log(`nodes share: ${error.message}`); return { code: 1 }; }
|
|
1369
1372
|
}
|
|
1373
|
+
// Host-only publication of a remote peer, one way, to an explicit audience of
|
|
1374
|
+
// our own admin nodes. NOT Share: it never calls the share path.
|
|
1375
|
+
if (sub === 'publish-remote') {
|
|
1376
|
+
const arg = rest[3];
|
|
1377
|
+
if (arg !== undefined && arg !== 'on' && arg !== 'off' && arg !== 'status') {
|
|
1378
|
+
log('nodes publish-remote: uso `nodes publish-remote <nodo> on --audience <id,id>|off|status`');
|
|
1379
|
+
return { code: 1 };
|
|
1380
|
+
}
|
|
1381
|
+
if (arg === 'on') {
|
|
1382
|
+
if (typeof flags.audience !== 'string' || !flags.audience.trim()) {
|
|
1383
|
+
log('nodes publish-remote: `on` richiede --audience <instanceId,instanceId,...> (gli id dei nodi admin che vedranno il peer)');
|
|
1384
|
+
return { code: 1 };
|
|
1385
|
+
}
|
|
1386
|
+
} else if (flags.audience !== undefined) {
|
|
1387
|
+
log('nodes publish-remote: --audience si usa solo con `on`');
|
|
1388
|
+
return { code: 1 };
|
|
1389
|
+
}
|
|
1390
|
+
const audience = arg === 'on' ? String(flags.audience).split(',').map((x) => x.trim()).filter(Boolean) : undefined;
|
|
1391
|
+
const expectedRevision = flags['access-revision'] === undefined ? undefined : Number(flags['access-revision']);
|
|
1392
|
+
if (expectedRevision !== undefined && !Number.isInteger(expectedRevision)) {
|
|
1393
|
+
log('nodes publish-remote: --access-revision vuole un intero (la revision letta da `nodes access`)');
|
|
1394
|
+
return { code: 1 };
|
|
1395
|
+
}
|
|
1396
|
+
return { code: nodesCmds.nodesPublishRemote({
|
|
1397
|
+
...opts, log, ref, json: flags.json === true, audience, expectedRevision,
|
|
1398
|
+
enabled: arg === 'on' ? true : (arg === 'off' ? false : undefined),
|
|
1399
|
+
}).code };
|
|
1400
|
+
}
|
|
1370
1401
|
if (sub === 'invite') {
|
|
1371
1402
|
const body = {
|
|
1372
1403
|
...(flags.ssh !== undefined ? { ssh: flags.ssh } : {}),
|
|
@@ -225,15 +225,34 @@ function grantsOf(peer) {
|
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
228
|
+
// The vector the resource gate APPLIES to a record: the stored booleans and the
|
|
229
|
+
// cell scope, read exactly the way resource-acl.allowedByClass reads them — no
|
|
230
|
+
// `accessConfigured` marker, no coherence check. This is what a VIEW must show.
|
|
231
|
+
// `grantsOf` is different on purpose: it answers "was this vector set
|
|
232
|
+
// deliberately?" and feeds decisions that demand a configured record (the event
|
|
233
|
+
// stream, the ASK target check); it must keep denying for a legacy record.
|
|
234
|
+
// An absent scope is `all`: that is what the parser stores and what the gate
|
|
235
|
+
// sees (it only closes the cell-scoped classes on an explicit `none`).
|
|
236
|
+
function effectiveGrants(peer) {
|
|
237
|
+
const p = isPlainObject(peer) ? peer : {};
|
|
238
|
+
const grants = { cellVisibility: CELL_VISIBILITY.includes(p.cellVisibility) ? p.cellVisibility : 'all' };
|
|
239
|
+
if (grants.cellVisibility === 'selected') grants.cells = Array.isArray(p.cells) ? [...p.cells] : [];
|
|
240
|
+
for (const key of BOOLEAN_GRANTS) grants[key] = p[key] === true;
|
|
241
|
+
return grants;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Read view of ONE peer for listing endpoints and the settings UI: the derived
|
|
245
|
+
// label and the configured marker (PROVENANCE: how the vector was set) and the
|
|
246
|
+
// EFFECTIVE grant vector (what the gate applies, see effectiveGrants). The
|
|
247
|
+
// vector is flat and always complete, so a caller never has to guess defaults,
|
|
248
|
+
// and no token or secret is ever included: grants are decisions, not
|
|
249
|
+
// credentials. A legacy record with a grant and no marker reads "unconfigured"
|
|
250
|
+
// AND shows the grant on, because both are true.
|
|
233
251
|
function accessView(peer) {
|
|
234
252
|
const view = grantsOf(peer);
|
|
235
|
-
const
|
|
236
|
-
|
|
253
|
+
const effective = effectiveGrants(peer);
|
|
254
|
+
const access = { cellVisibility: effective.cellVisibility };
|
|
255
|
+
for (const key of BOOLEAN_GRANTS) access[key] = effective[key];
|
|
237
256
|
return { accessLabel: view.label, accessConfigured: view.configured, access };
|
|
238
257
|
}
|
|
239
258
|
|
|
@@ -252,6 +271,7 @@ module.exports = {
|
|
|
252
271
|
accessView,
|
|
253
272
|
applyPreset,
|
|
254
273
|
deriveLabel,
|
|
274
|
+
effectiveGrants,
|
|
255
275
|
grantsOf,
|
|
256
276
|
isCompleteAdminVector,
|
|
257
277
|
isConfigured,
|
package/lib/nodes/commands.js
CHANGED
|
@@ -336,9 +336,22 @@ function nodesInspect(opts) {
|
|
|
336
336
|
// inferred from defaults.
|
|
337
337
|
if (peer.kind === 'direct') {
|
|
338
338
|
const view = accessPresets.grantsOf(peer);
|
|
339
|
+
// The label says how the vector was set; the vector is what the resource
|
|
340
|
+
// gate applies, so it is shown as stored even for a legacy record.
|
|
341
|
+
const effective = accessPresets.effectiveGrants(peer);
|
|
339
342
|
log(`accesso: ${view.label}`);
|
|
340
|
-
log(`grant: ${
|
|
341
|
-
log(`scope: celle ${
|
|
343
|
+
log(`grant: ${ACCESS_VIEW_KEYS.map((key) => `${key}=${effective[key] ? 'on' : 'off'}`).join(' ')}`);
|
|
344
|
+
log(`scope: celle ${effective.cellVisibility}`);
|
|
345
|
+
if (!view.configured) {
|
|
346
|
+
log('nota: record non configurato: i grant mostrati sono quelli che il gate delle risorse applica; il feed eventi richiede un vettore configurato');
|
|
347
|
+
}
|
|
348
|
+
if (peer.publishRemote === true) {
|
|
349
|
+
const names = (peer.publishRemoteAudience || []).map((id) => {
|
|
350
|
+
const reader = out.nodes.find((n) => n.nodeId === id);
|
|
351
|
+
return reader ? `${reader.name} (${id})` : id;
|
|
352
|
+
});
|
|
353
|
+
log(`pubblicato: solo-host, audience: ${names.join(', ')}`);
|
|
354
|
+
}
|
|
342
355
|
for (const line of feedLinesFor(opts.feedDiagnostics, peer.nodeId)) log(line);
|
|
343
356
|
}
|
|
344
357
|
log(`azioni: ${Object.keys(peer.actions || {}).filter((key) => peer.actions[key]).join(', ')}`);
|
|
@@ -351,6 +364,8 @@ function nodesInspect(opts) {
|
|
|
351
364
|
// validated write. The label (admin/user/nexushost/custom) is derived from the
|
|
352
365
|
// grants; nothing here stores or reads a role.
|
|
353
366
|
const ACCESS_GRANT_KEYS = ['eventsAccess', 'nodeEventsAccess', 'askReplyAccess', 'filesReadAccess', 'peerOperatorAccess'];
|
|
367
|
+
// What the views print: the five grant flags of the edit commands plus the two the panel and live-host classes read.
|
|
368
|
+
const ACCESS_VIEW_KEYS = [...ACCESS_GRANT_KEYS, 'panelAccess', 'liveHostAccess'];
|
|
354
369
|
const ACCESS_EDIT_KEYS = new Set(['accessRole', ...ACCESS_GRANT_KEYS]);
|
|
355
370
|
|
|
356
371
|
const ACCESS_VECTOR_FIELDS = ['cellVisibility', 'cells', 'panelAccess', 'liveHostAccess'];
|
|
@@ -545,6 +560,50 @@ function nodesEdit(opts) {
|
|
|
545
560
|
return { code: 0, name: node.name, node: edited, restarted: wasUp };
|
|
546
561
|
}
|
|
547
562
|
|
|
563
|
+
// --- nodes publish-remote ---------------------------------------------------
|
|
564
|
+
// Host-only publication of a REMOTE peer: `nodes publish-remote <peer> on
|
|
565
|
+
// --audience <instanceId,...>` / `off`. Separate from Share on purpose: it
|
|
566
|
+
// never calls the share path, never restarts the tunnel and never touches the
|
|
567
|
+
// reverse pool. It edits the store (one validated, compare-and-set write, after
|
|
568
|
+
// a full backup on `on`); the running server reads the store per request.
|
|
569
|
+
function nodesPublishRemote(opts) {
|
|
570
|
+
const log = opts.log || console.log;
|
|
571
|
+
const { nodesPath } = resolveNodePaths(opts);
|
|
572
|
+
let st;
|
|
573
|
+
try { st = store.loadStoreStrict(nodesPath); }
|
|
574
|
+
catch (e) { log(`nodes publish-remote: ${e.message}`); return { code: 1, reason: 'store invalido', status: e.status || 500, errorCode: e.code || null }; }
|
|
575
|
+
const node = resolveStoredNode(st, opts.ref || opts.name);
|
|
576
|
+
if (!node) { log(`nodes publish-remote: nodo sconosciuto "${opts.ref || opts.name || ''}"`); return { code: 1, reason: 'unknown-node' }; }
|
|
577
|
+
if (opts.enabled === undefined) {
|
|
578
|
+
// Read-only status.
|
|
579
|
+
const state = node.publishRemote === true
|
|
580
|
+
? { name: node.name, enabled: true, audience: [...node.publishRemoteAudience] }
|
|
581
|
+
: { name: node.name, enabled: false, audience: [] };
|
|
582
|
+
if (opts.json) log(JSON.stringify(state, null, 2));
|
|
583
|
+
else log(`nodes publish-remote [${node.name}]: ${state.enabled ? `on · audience ${state.audience.join(', ')}` : 'off'}`);
|
|
584
|
+
return { code: 0, ...state };
|
|
585
|
+
}
|
|
586
|
+
if (isReadonly(opts)) { log('nodes publish-remote: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
|
|
587
|
+
let res;
|
|
588
|
+
try {
|
|
589
|
+
res = store.setPublishRemoteCas({
|
|
590
|
+
filePath: nodesPath, name: node.name, enabled: opts.enabled === true,
|
|
591
|
+
audience: opts.audience, expectedRevision: opts.expectedRevision,
|
|
592
|
+
});
|
|
593
|
+
} catch (e) { log(`nodes publish-remote: ${e.message}`); return { code: 1, reason: 'refused', error: e.message }; }
|
|
594
|
+
if (!res.ok) {
|
|
595
|
+
log(`nodes publish-remote: revision delle concessioni superata (attuale ${res.revision}) — rileggi e riprova`);
|
|
596
|
+
return { code: 1, reason: 'revision-conflict', revision: res.revision };
|
|
597
|
+
}
|
|
598
|
+
if (opts.json) log(JSON.stringify(res, null, 2));
|
|
599
|
+
else if (res.enabled) {
|
|
600
|
+
log(`nodes publish-remote [${res.name}]: on · audience ${res.audience.join(', ')}`);
|
|
601
|
+
log(' grant verso il peer azzerati (celle none, eventi/file/ask/operatore/pannello/live host off, eventsReceive off); store a schema 4');
|
|
602
|
+
if (res.backup) log(` backup dello store: ${res.backup.path} (sha256 ${res.backup.sha256})`);
|
|
603
|
+
} else log(`nodes publish-remote [${res.name}]: off${res.unchanged ? ' (era gia\' spento)' : ' · i grant restano azzerati'}`);
|
|
604
|
+
return { code: 0, ...res };
|
|
605
|
+
}
|
|
606
|
+
|
|
548
607
|
// --- nodes access: read-only inventory --------------------------------------
|
|
549
608
|
// Shows what every peer may currently do on this node, with the revision of
|
|
550
609
|
// the policy. Peers whose grants were never configured are listed as such:
|
|
@@ -566,7 +625,7 @@ function nodesAccess(opts = {}) {
|
|
|
566
625
|
if (view.peers.length === 0) log('nessun peer diretto');
|
|
567
626
|
for (const peer of view.peers) {
|
|
568
627
|
log(`${peer.name}${peer.nodeId ? ` (${peer.nodeId})` : ''}: ${peer.label}`);
|
|
569
|
-
log(` ${
|
|
628
|
+
log(` ${ACCESS_VIEW_KEYS.map((key) => `${key}=${peer.grants[key] ? 'on' : 'off'}`).join(' ')} · celle ${peer.grants.cellVisibility}`);
|
|
570
629
|
}
|
|
571
630
|
if (legacy.length > 0) log(`da configurare esplicitamente (${legacy.length}): ${legacy.map((p) => p.name).join(', ')}`);
|
|
572
631
|
return { code: 0, ...view, unconfigured: legacy.map((p) => p.name) };
|
|
@@ -880,7 +939,7 @@ function nodesSetToken(opts) {
|
|
|
880
939
|
module.exports = {
|
|
881
940
|
fetchFeedDiagnostics, feedLinesFor,
|
|
882
941
|
|
|
883
|
-
nodesAccess, ACCESS_GRANT_KEYS,
|
|
942
|
+
nodesAccess, nodesPublishRemote, ACCESS_GRANT_KEYS,
|
|
884
943
|
nodesAdd, nodesList, nodesInspect, nodesEdit, nodesRemove, nodesTest, nodesTestAll,
|
|
885
944
|
nodesUp, nodesDown, nodesRestart, nodesSetToken,
|
|
886
945
|
// helper esposti per test/riuso
|
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,100 @@ 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
|
+
const target = `${filePath}.pre-publish-remote-${stamp}-${sha256.slice(0, 8)}.bak`;
|
|
962
|
+
fs.writeFileSync(target, data, { mode: 0o600, flag: 'wx' }); // wx: never overwrite a previous backup
|
|
963
|
+
fs.chmodSync(target, 0o600);
|
|
964
|
+
return { path: target, sha256 };
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Compare-and-set write of the mode, keyed on the same revision as the access
|
|
968
|
+
// grants (turning it on changes them). `on` takes the backup BEFORE writing.
|
|
969
|
+
function setPublishRemoteCas({ filePath, name, enabled, audience, expectedRevision }) {
|
|
970
|
+
const current = loadStoreStrict(filePath);
|
|
971
|
+
if (expectedRevision !== undefined && (!Number.isInteger(expectedRevision) || accessRevisionOf(current) !== expectedRevision)) {
|
|
972
|
+
return { ok: false, conflict: true, revision: accessRevisionOf(current) };
|
|
973
|
+
}
|
|
974
|
+
const next = enabled ? enablePublishRemote(current, name, audience) : disablePublishRemote(current, name);
|
|
975
|
+
if (next === current) return { ok: true, name, enabled: false, unchanged: true, revision: accessRevisionOf(current), schemaVersion: current.schemaVersion };
|
|
976
|
+
const backup = enabled ? backupStoreFile(filePath) : null;
|
|
977
|
+
const written = atomicWriteStore(filePath, next);
|
|
978
|
+
const node = written.nodes.find((n) => n.name === name);
|
|
979
|
+
return {
|
|
980
|
+
ok: true, name, enabled: node.publishRemote === true,
|
|
981
|
+
audience: node.publishRemote === true ? [...node.publishRemoteAudience] : [],
|
|
982
|
+
revision: accessRevisionOf(written), schemaVersion: written.schemaVersion, backup,
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
|
|
817
986
|
// One startup migration; later operational reception edits remain authoritative.
|
|
818
987
|
function migrateAdminReception({ filePath, readonly = false }) {
|
|
819
988
|
if (readonly || process.env.NEXUSCREW_READONLY === '1') return { migrated: false };
|
|
@@ -828,7 +997,7 @@ function migrateAdminReception({ filePath, readonly = false }) {
|
|
|
828
997
|
changed += 1;
|
|
829
998
|
}
|
|
830
999
|
}
|
|
831
|
-
if (changed) next = { ...next, schemaVersion:
|
|
1000
|
+
if (changed) next = { ...next, schemaVersion: atLeastV3(current),
|
|
832
1001
|
accessRevision: accessRevisionOf(current) + 1 };
|
|
833
1002
|
atomicWriteStore(filePath, { ...next, adminReceptionMigration: 1 });
|
|
834
1003
|
return { migrated: true, changed };
|
|
@@ -845,7 +1014,7 @@ function upgradeToReversePoolSchema(store, anchor = undefined) {
|
|
|
845
1014
|
if (anchor !== undefined && !parsedAnchor) throw new Error('reverse pool anchor non valida');
|
|
846
1015
|
const next = {
|
|
847
1016
|
...current,
|
|
848
|
-
schemaVersion:
|
|
1017
|
+
schemaVersion: atLeastV3(current),
|
|
849
1018
|
...(parsedAnchor ? { reversePoolLedgerInitialized: true, reversePoolAnchor: parsedAnchor } : {}),
|
|
850
1019
|
};
|
|
851
1020
|
const parsed = parseStore(next);
|
|
@@ -855,7 +1024,7 @@ function upgradeToReversePoolSchema(store, anchor = undefined) {
|
|
|
855
1024
|
|
|
856
1025
|
function setNodeReversePool(store, name, value) {
|
|
857
1026
|
const current = store && getNode(store, name);
|
|
858
|
-
if (!store || store.schemaVersion
|
|
1027
|
+
if (!store || store.schemaVersion < SCHEMA_VERSION || !current) {
|
|
859
1028
|
throw new Error('reverse pool richiede schema v3');
|
|
860
1029
|
}
|
|
861
1030
|
if (current.direction === 'inbound' && !parseReversePoolAnchor(store.reversePoolAnchor)) {
|
|
@@ -883,10 +1052,24 @@ function redactNode(n) {
|
|
|
883
1052
|
panelAccess: n.panelAccess === true,
|
|
884
1053
|
liveHostAccess: n.liveHostAccess === true,
|
|
885
1054
|
visibility: n.visibility || 'network',
|
|
1055
|
+
// The grants this node concedes to the peer. They are decisions, not
|
|
1056
|
+
// credentials, and consumers (the CLI `nodes inspect`, the inventory) derive
|
|
1057
|
+
// the label and the effective vector from THESE fields: leaving them out made
|
|
1058
|
+
// `inspect` read an "unconfigured" peer and print every grant as off.
|
|
1059
|
+
accessConfigured: n.accessConfigured === true,
|
|
1060
|
+
eventsAccess: n.eventsAccess === true,
|
|
1061
|
+
nodeEventsAccess: n.nodeEventsAccess === true,
|
|
1062
|
+
askReplyAccess: n.askReplyAccess === true,
|
|
1063
|
+
filesReadAccess: n.filesReadAccess === true,
|
|
1064
|
+
peerOperatorAccess: n.peerOperatorAccess === true,
|
|
886
1065
|
hasToken: !!n.token, // presenza, non il valore
|
|
887
1066
|
paired: !!(n.token && n.acceptToken),
|
|
888
1067
|
};
|
|
889
1068
|
if (n.identityFile || n.keyPath) out.hasIdentity = true;
|
|
1069
|
+
if (n.publishRemote === true) {
|
|
1070
|
+
out.publishRemote = true;
|
|
1071
|
+
out.publishRemoteAudience = [...(n.publishRemoteAudience || [])];
|
|
1072
|
+
}
|
|
890
1073
|
if (n.visibility === 'selected') out.selected = [...(n.selected || [])];
|
|
891
1074
|
// Lo scope celle non e' un segreto: e' una decisione dell'operatore e va
|
|
892
1075
|
// mostrata, come `selected`. La Settings UI deve poterla leggere per dirla.
|
|
@@ -1076,6 +1259,8 @@ module.exports = {
|
|
|
1076
1259
|
// concessioni federate (preset, CAS, inventario)
|
|
1077
1260
|
accessRevisionOf, setPeerAccessGrants, setPeerAccessPreset, accessInventory, migrateAdminReception,
|
|
1078
1261
|
setPeerAccessGrantsCas, setPeerAccessPresetCas,
|
|
1262
|
+
// host-only publication of a remote peer (schema 4, opt-in)
|
|
1263
|
+
hostOnlyViolation, enablePublishRemote, disablePublishRemote, setPublishRemoteCas, backupStoreFile,
|
|
1079
1264
|
// redazione
|
|
1080
1265
|
redactNode, redactStore, hasPairedPeers,
|
|
1081
1266
|
// migrazione
|
|
@@ -1083,5 +1268,5 @@ module.exports = {
|
|
|
1083
1268
|
// label / slug
|
|
1084
1269
|
nodeLabel, validLabel, sanitizeLabel, toSlug, deriveNodeHandle, suggestNodeName, LABEL_MAX,
|
|
1085
1270
|
// costanti
|
|
1086
|
-
SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION, MAX_NODES, MAX_TOKEN_LEN, NODE_NAME_RE, NODE_ID_RE,
|
|
1271
|
+
SCHEMA_VERSION, HOST_ONLY_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION, MAX_NODES, MAX_TOKEN_LEN, NODE_NAME_RE, NODE_ID_RE,
|
|
1087
1272
|
};
|
|
@@ -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 };
|