@mmmbuto/nexuscrew 0.9.28 → 0.9.30
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 +16 -0
- package/docs/LIVE_PROMPT.md +1 -1
- package/docs/NOTIFICATIONS.md +37 -0
- package/docs/OPERATIONS.md +20 -0
- package/frontend/dist/assets/index-BbcjoZS0.js +167 -0
- package/frontend/dist/assets/index-DqCfZFGi.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +91 -10
- package/frontend/dist/version.json +1 -1
- package/lib/audio/groups.js +1 -1
- package/lib/auth/token.js +4 -4
- package/lib/cli/commands.js +70 -15
- package/lib/cli/doctor.js +5 -5
- package/lib/cli/fleet-service.js +7 -7
- package/lib/cli/init.js +55 -31
- package/lib/cli/pidfile.js +1 -1
- package/lib/cli/service.js +124 -8
- package/lib/cli/stable-alias.js +4 -4
- package/lib/cli/url.js +1 -1
- package/lib/config.js +36 -6
- package/lib/files/routes.js +1 -1
- package/lib/fleet/boot.js +2 -2
- package/lib/fleet/builtin.js +15 -15
- package/lib/fleet/causes.js +1 -1
- package/lib/fleet/cell-exec.js +23 -23
- package/lib/fleet/cell-hold.js +6 -6
- package/lib/fleet/cell-lease-server.js +106 -106
- package/lib/fleet/cell-lease.js +21 -21
- package/lib/fleet/definitions.js +10 -10
- package/lib/fleet/identity-authority.js +1 -1
- package/lib/fleet/launch-broker.js +13 -13
- package/lib/fleet/launch.js +15 -15
- package/lib/fleet/lease-client.js +23 -23
- package/lib/fleet/lease-routes.js +3 -3
- package/lib/fleet/lease-verifier.js +22 -22
- package/lib/fleet/managed.js +35 -35
- package/lib/fleet/prompt-delivery.js +13 -13
- package/lib/fleet/routes.js +15 -10
- package/lib/fleet/runtime.js +24 -24
- package/lib/live-host/bridge.js +53 -43
- package/lib/live-host/federation.js +97 -0
- package/lib/live-host/routes.js +166 -8
- package/lib/live-host/store.js +44 -6
- package/lib/mcp/server.js +14 -14
- package/lib/mcp/tools.js +5 -5
- package/lib/nodes/access-presets.js +258 -0
- package/lib/nodes/commands.js +198 -4
- package/lib/nodes/store.js +152 -9
- package/lib/nodes/tunnel.js +7 -7
- package/lib/notify/ask-answer-service.js +184 -0
- package/lib/notify/ask-receipts.js +197 -0
- package/lib/notify/ask-relay.js +152 -0
- package/lib/notify/asks.js +28 -3
- package/lib/notify/event-feed-acl.js +95 -0
- package/lib/notify/event-feed-asks-routes.js +144 -0
- package/lib/notify/event-feed-client.js +434 -0
- package/lib/notify/event-feed-history.js +115 -0
- package/lib/notify/event-feed-producers.js +175 -0
- package/lib/notify/event-feed-routes.js +254 -0
- package/lib/notify/event-feed.js +328 -0
- package/lib/notify/events.js +1 -1
- package/lib/notify/notifier.js +3 -3
- package/lib/notify/push-relay.js +240 -0
- package/lib/notify/push.js +8 -8
- package/lib/notify/routes.js +62 -23
- package/lib/proxy/federation.js +132 -12
- package/lib/proxy/node-proxy.js +13 -13
- package/lib/proxy/panel-auth.js +4 -4
- package/lib/proxy/panel-proxy.js +2 -2
- package/lib/proxy/resource-acl.js +145 -0
- package/lib/server.js +396 -40
- package/lib/settings/pairing-coordinator.js +1 -1
- package/lib/settings/routes.js +45 -32
- package/lib/update/manager.js +1 -1
- package/lib/update/runner.js +1 -1
- package/lib/voice/transcribe.js +1 -1
- package/lib/ws/bridge.js +3 -3
- package/package.json +1 -1
- package/skills/live/SKILL.md +12 -0
- package/frontend/dist/assets/index-CWra6B79.js +0 -167
- package/frontend/dist/assets/index-D6CIfCjv.css +0 -32
package/lib/mcp/server.js
CHANGED
|
@@ -81,12 +81,12 @@ function companionInstructions() {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
// --- identita' cella mittente ------------------------------------------------
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
84
|
+
// Source order (UNCHANGED): $TMUX present -> tmux display-message (real
|
|
85
|
+
// session name); if it fails/is invalid -> env fallback NEXUSCREW_MCP_SESSION;
|
|
86
|
+
// otherwise null. The tools that REQUIRE the session stay fail-closed.
|
|
87
|
+
// execFile direct argv: never a shell.
|
|
88
88
|
//
|
|
89
|
-
//
|
|
89
|
+
// Il nome da tmux si chiede con `-t $TMUX_PANE` — target esplicito al
|
|
90
90
|
// PANE del chiamante, deterministico e indipendente dall'environ ereditato.
|
|
91
91
|
// Senza `-t` il CLI tmux risolve il pane dall'ENVIRON DEL PROCESSO FIGLIO:
|
|
92
92
|
// se quel pane è vivo risponde correttamente, ma se è morto (environ stale,
|
|
@@ -95,7 +95,7 @@ function companionInstructions() {
|
|
|
95
95
|
// Comportamento misurato su tmux 3.4 con `-t`: pane morto -> rc=0 e stdout
|
|
96
96
|
// VUOTO (non un errore): il vuoto è il segnale dello stantio.
|
|
97
97
|
//
|
|
98
|
-
// `resolveIdentity` rende OSSERVABILE la sorgente della risoluzione
|
|
98
|
+
// `resolveIdentity` rende OSSERVABILE la sorgente della risoluzione:
|
|
99
99
|
// ritorna { session, source, code, envPresence, requiredEnvVars, remediation }
|
|
100
100
|
// senza cambiare la precedenza e senza esporre valori/segreti. `resolveSession`
|
|
101
101
|
// resta il wrapper pubblico Promise<string|null> invariato (compatibilita').
|
|
@@ -174,13 +174,13 @@ function resolveIdentity({ env, tmuxBin, execFileImpl }) {
|
|
|
174
174
|
session: null, source: 'missing', code: codeWhenMissing(),
|
|
175
175
|
envPresence, requiredEnvVars: IDENTITY_ALL_REQUIRED_ENV_VARS, remediation: IDENTITY_REMEDIATION,
|
|
176
176
|
});
|
|
177
|
-
//
|
|
177
|
+
// Pane stantio o non verificabile -> NON attribuire. source 'stale-pane'
|
|
178
178
|
// nomina il problema; code STALE_PANE (tools.js).
|
|
179
179
|
const stalePane = () => ({
|
|
180
180
|
session: null, source: 'stale-pane', code: IDENTITY_CODE.STALE_PANE,
|
|
181
181
|
envPresence, requiredEnvVars: IDENTITY_ALL_REQUIRED_ENV_VARS, remediation: IDENTITY_REMEDIATION,
|
|
182
182
|
});
|
|
183
|
-
//
|
|
183
|
+
// : tmux e fallback env dicono sessioni DIVERSE entrambe valide:
|
|
184
184
|
// identità ambigua -> NON attribuire (nemmeno il fallback: è parte del
|
|
185
185
|
// conflitto). Il code nomina il mismatch, che INVALID non direbbe.
|
|
186
186
|
const sessionMismatch = () => ({
|
|
@@ -190,7 +190,7 @@ function resolveIdentity({ env, tmuxBin, execFileImpl }) {
|
|
|
190
190
|
|
|
191
191
|
return new Promise((resolve) => {
|
|
192
192
|
// Precedenza preservata: prima il fallback env valido, poi l'esito negativo
|
|
193
|
-
//
|
|
193
|
+
// Dato (`missing` storico o `stalePane`).
|
|
194
194
|
const settle = (otherwise) => {
|
|
195
195
|
const fb = tryFallback();
|
|
196
196
|
resolve(typeof fb === 'string' ? ok(fb, 'NEXUSCREW_MCP_SESSION') : otherwise());
|
|
@@ -217,13 +217,13 @@ function resolveIdentity({ env, tmuxBin, execFileImpl }) {
|
|
|
217
217
|
// rc=0 e stdout VUOTO. Il vuoto è il segnale dello stantio.
|
|
218
218
|
if (!name) return settle(stalePane);
|
|
219
219
|
if (isValidSession(name)) {
|
|
220
|
-
//
|
|
220
|
+
// Se il fallback env è valido ma dice un'altra sessione, le due
|
|
221
221
|
// fonti si contraddicono -> ambiguo, non si attribuisce.
|
|
222
222
|
const fb = tryFallback();
|
|
223
223
|
if (typeof fb === 'string' && fb !== name) return resolve(sessionMismatch());
|
|
224
224
|
return resolve(ok(name, 'tmux'));
|
|
225
225
|
}
|
|
226
|
-
//
|
|
226
|
+
// non-empty but invalid name: precedence preserved, by design.
|
|
227
227
|
settle(missing);
|
|
228
228
|
});
|
|
229
229
|
} catch (_) {
|
|
@@ -618,9 +618,9 @@ function startMcp(opts = {}) {
|
|
|
618
618
|
|
|
619
619
|
module.exports = {
|
|
620
620
|
createMcpServer, startMcp, resolveSession, resolveIdentity, normalizeIdentityContext, TOOLS,
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
621
|
+
// The vl branch of resolveManagedEngine composes the companion instructions
|
|
622
|
+
// into the per-cell prompt file — vl has no MCP client, this is the only
|
|
623
|
+
// surface through which the text reaches it.
|
|
624
624
|
companionInstructions,
|
|
625
625
|
PROTOCOL_FALLBACK, HTTP_TIMEOUT_MS, HTTP_TIMEOUT_CODE, HTTP_UNREACHABLE_CODE, transportError,
|
|
626
626
|
IDENTITY_CONTEXT_MISSING, IDENTITY_CONTEXT_UNVERIFIED, IDENTITY_CONTEXT_FROM_MISMATCH,
|
package/lib/mcp/tools.js
CHANGED
|
@@ -30,7 +30,7 @@ function argString(args, key, { required = false, max = 4096 } = {}) {
|
|
|
30
30
|
return v;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
// Codici stabili di identita' MCP (contratto
|
|
33
|
+
// Codici stabili di identita' MCP (contratto). Valori EXACT, non sensibili,
|
|
34
34
|
// usati sia nella diagnostica read-only (`nc_identity`) sia nel messaggio umano
|
|
35
35
|
// dei tool identity-gated (isError=true preservato dal server). Non espongono
|
|
36
36
|
// valori/env/sessioni: solo la categoria del problema.
|
|
@@ -38,10 +38,10 @@ const IDENTITY_CODE = Object.freeze({
|
|
|
38
38
|
OK: 'OK',
|
|
39
39
|
MISSING: 'NEXUSCREW_MCP_IDENTITY_MISSING',
|
|
40
40
|
INVALID: 'NEXUSCREW_MCP_IDENTITY_INVALID',
|
|
41
|
-
//
|
|
41
|
+
// Il pane del chiamante non esiste più (o TMUX_PANE è assente/malformato):
|
|
42
42
|
// l'identità non viene attribuita via tmux.
|
|
43
43
|
STALE_PANE: 'NEXUSCREW_MCP_IDENTITY_STALE_PANE',
|
|
44
|
-
//
|
|
44
|
+
// : tmux e NEXUSCREW_MCP_SESSION sono entrambi validi ma dicono sessioni
|
|
45
45
|
// diverse: identità ambigua, non si attribuisce nessuna delle due.
|
|
46
46
|
SESSION_MISMATCH: 'NEXUSCREW_MCP_IDENTITY_SESSION_MISMATCH',
|
|
47
47
|
// una fonte verified (NEXUSCREW_VERIFIED_*) presente ma non
|
|
@@ -303,7 +303,7 @@ function vlOwnerPath(owner, leaf = '') {
|
|
|
303
303
|
}
|
|
304
304
|
|
|
305
305
|
// --- definizione tool (prefisso nc_ anti-collisione) ---------------------------
|
|
306
|
-
// annotations.readOnlyHint:true
|
|
306
|
+
// annotations.readOnlyHint:true on the tools that mutate nothing.
|
|
307
307
|
const TOOLS = [
|
|
308
308
|
{
|
|
309
309
|
name: 'nc_notify',
|
|
@@ -920,7 +920,7 @@ const TOOLS = [
|
|
|
920
920
|
return { receipt };
|
|
921
921
|
},
|
|
922
922
|
},
|
|
923
|
-
//
|
|
923
|
+
// -- lease Live del child (fetta 2b, contratto rev1) ----------------------
|
|
924
924
|
// Tre metodi DISTINTI perche' i valori di ritorno non mentono l'uno con l'altro:
|
|
925
925
|
// solo register puo' rispondere {status:'pending'} (join non ancora tracciato);
|
|
926
926
|
// refresh su registration viva risponde {status:'live', proof} e non ha stati
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Access presets for paired peers.
|
|
4
|
+
//
|
|
5
|
+
// A preset is a vector of grants written in ONE validated write on the node
|
|
6
|
+
// that owns the resources. There is no persistent `role` field: the label
|
|
7
|
+
// (`admin`, `user`, `nexushost`, `custom`, `unconfigured`) is DERIVED from the
|
|
8
|
+
// effective grants, and request handlers check grants, never the label.
|
|
9
|
+
//
|
|
10
|
+
// The owner node decides what a peer may do with its own resources. A grant
|
|
11
|
+
// that is missing or malformed is denied, and a record written before these
|
|
12
|
+
// fields existed stays denied until an explicit migration sets them: being
|
|
13
|
+
// paired is not, by itself, a permission.
|
|
14
|
+
|
|
15
|
+
const MAX_CELLS = 128;
|
|
16
|
+
const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
|
|
17
|
+
|
|
18
|
+
const CELL_VISIBILITY = Object.freeze(['all', 'selected', 'none']);
|
|
19
|
+
|
|
20
|
+
// Grants owned by the local node towards one peer.
|
|
21
|
+
const BOOLEAN_GRANTS = Object.freeze([
|
|
22
|
+
'eventsAccess',
|
|
23
|
+
'nodeEventsAccess',
|
|
24
|
+
'askReplyAccess',
|
|
25
|
+
'filesReadAccess',
|
|
26
|
+
'liveHostAccess',
|
|
27
|
+
'panelAccess',
|
|
28
|
+
'peerOperatorAccess',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
// `eventsReceive` is the receiving client's local choice, so it is excluded
|
|
32
|
+
// from every preset: applying a preset never toggles who receives events.
|
|
33
|
+
const EXCLUDED_GRANTS = Object.freeze(['eventsReceive']);
|
|
34
|
+
|
|
35
|
+
const GRANT_FIELDS = Object.freeze(['cellVisibility', 'cells', ...BOOLEAN_GRANTS]);
|
|
36
|
+
|
|
37
|
+
// Provenance marker of an explicitly configured vector. It is not a grant and
|
|
38
|
+
// never a role: it only separates "set on purpose" from "defaulted".
|
|
39
|
+
const CONFIGURED_MARKER = 'accessConfigured';
|
|
40
|
+
|
|
41
|
+
const ADMIN_GRANTS = Object.freeze({
|
|
42
|
+
cellVisibility: 'all',
|
|
43
|
+
eventsAccess: true,
|
|
44
|
+
nodeEventsAccess: true,
|
|
45
|
+
askReplyAccess: true,
|
|
46
|
+
filesReadAccess: true,
|
|
47
|
+
liveHostAccess: true,
|
|
48
|
+
panelAccess: true,
|
|
49
|
+
peerOperatorAccess: true,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const USER_GRANTS = Object.freeze({
|
|
53
|
+
cellVisibility: 'all',
|
|
54
|
+
eventsAccess: true,
|
|
55
|
+
nodeEventsAccess: true,
|
|
56
|
+
askReplyAccess: false,
|
|
57
|
+
filesReadAccess: true,
|
|
58
|
+
liveHostAccess: false,
|
|
59
|
+
panelAccess: false,
|
|
60
|
+
peerOperatorAccess: false,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const NEXUSHOST_GRANTS = Object.freeze({
|
|
64
|
+
cellVisibility: 'none',
|
|
65
|
+
eventsAccess: false,
|
|
66
|
+
nodeEventsAccess: false,
|
|
67
|
+
askReplyAccess: false,
|
|
68
|
+
filesReadAccess: false,
|
|
69
|
+
liveHostAccess: false,
|
|
70
|
+
panelAccess: false,
|
|
71
|
+
peerOperatorAccess: false,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const PRESETS = Object.freeze({
|
|
75
|
+
admin: ADMIN_GRANTS,
|
|
76
|
+
user: USER_GRANTS,
|
|
77
|
+
nexushost: NEXUSHOST_GRANTS,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const PRESET_NAMES = Object.freeze(Object.keys(PRESETS));
|
|
81
|
+
|
|
82
|
+
// Every grant denied: what an unknown peer gets.
|
|
83
|
+
const DENIED_GRANTS = Object.freeze({
|
|
84
|
+
cellVisibility: 'none',
|
|
85
|
+
...Object.fromEntries(BOOLEAN_GRANTS.map((k) => [k, false])),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
function isPlainObject(v) {
|
|
89
|
+
return !!v && typeof v === 'object' && !Array.isArray(v);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isCellId(v) {
|
|
93
|
+
return typeof v === 'string' && CELL_ID_RE.test(v);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The admin vector is all-or-nothing: live hosting is an administrative act
|
|
97
|
+
// on the owner's own host, so it is granted only together with the complete
|
|
98
|
+
// admin set. A granular edit that keeps live hosting while dropping any other
|
|
99
|
+
// admin grant is refused.
|
|
100
|
+
function isCompleteAdminVector(g) {
|
|
101
|
+
if (!isPlainObject(g)) return false;
|
|
102
|
+
if (g.cellVisibility !== 'all') return false;
|
|
103
|
+
if (Array.isArray(g.cells) && g.cells.length !== 0) return false;
|
|
104
|
+
return BOOLEAN_GRANTS.every((k) => g[k] === true);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Validates a grant vector. Returns { ok, errors }: `ok` means the vector is
|
|
108
|
+
// complete and coherent, so a label may be derived from it. A vector that is
|
|
109
|
+
// not ok is DENIED by callers; it is never completed with defaults.
|
|
110
|
+
function validateGrants(input) {
|
|
111
|
+
const errors = [];
|
|
112
|
+
if (!isPlainObject(input)) {
|
|
113
|
+
return { ok: false, errors: ['grants must be an object'] };
|
|
114
|
+
}
|
|
115
|
+
for (const key of Object.keys(input)) {
|
|
116
|
+
if (key === 'role') {
|
|
117
|
+
errors.push('role is not a grant: the label is derived from the grants');
|
|
118
|
+
} else if (EXCLUDED_GRANTS.includes(key)) {
|
|
119
|
+
errors.push(`${key} is a local client choice and is not part of a preset`);
|
|
120
|
+
} else if (!GRANT_FIELDS.includes(key)) {
|
|
121
|
+
errors.push(`unknown grant field: ${key}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!CELL_VISIBILITY.includes(input.cellVisibility)) {
|
|
126
|
+
errors.push('cellVisibility must be one of all|selected|none');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let cells = [];
|
|
130
|
+
if (input.cells !== undefined) {
|
|
131
|
+
if (!Array.isArray(input.cells)) {
|
|
132
|
+
errors.push('cells must be an array');
|
|
133
|
+
} else if (input.cells.length > MAX_CELLS) {
|
|
134
|
+
errors.push(`cells must hold at most ${MAX_CELLS} entries`);
|
|
135
|
+
} else if (!input.cells.every(isCellId)) {
|
|
136
|
+
errors.push('cells entries must be cell names');
|
|
137
|
+
} else if (new Set(input.cells).size !== input.cells.length) {
|
|
138
|
+
errors.push('cells must not repeat an entry');
|
|
139
|
+
} else {
|
|
140
|
+
cells = input.cells;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (input.cellVisibility === 'selected' && cells.length === 0) {
|
|
144
|
+
errors.push('cellVisibility selected needs at least one cell');
|
|
145
|
+
}
|
|
146
|
+
if ((input.cellVisibility === 'all' || input.cellVisibility === 'none') && cells.length > 0) {
|
|
147
|
+
errors.push('cells are allowed only with cellVisibility selected');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const key of BOOLEAN_GRANTS) {
|
|
151
|
+
if (!(key in input)) errors.push(`missing grant: ${key}`);
|
|
152
|
+
else if (typeof input[key] !== 'boolean') errors.push(`grant must be a boolean: ${key}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (input.liveHostAccess === true && !isCompleteAdminVector(input)) {
|
|
156
|
+
errors.push('liveHostAccess requires the complete admin grant set');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { ok: errors.length === 0, errors };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A record is configured only when its grant vector is complete and coherent.
|
|
163
|
+
// Records written before these fields existed, or damaged ones, are denied.
|
|
164
|
+
function isConfigured(input) {
|
|
165
|
+
return validateGrants(input).ok;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function sameBooleans(a, b) {
|
|
169
|
+
return BOOLEAN_GRANTS.every((k) => a[k] === b[k]);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Derives the label from the effective grants. `custom` means a coherent
|
|
173
|
+
// vector that matches no preset; `unconfigured` means the vector is not
|
|
174
|
+
// complete, so no preset may be inferred from it.
|
|
175
|
+
function deriveLabel(input) {
|
|
176
|
+
if (!isConfigured(input)) return 'unconfigured';
|
|
177
|
+
if (sameBooleans(input, ADMIN_GRANTS) && input.cellVisibility === 'all') return 'admin';
|
|
178
|
+
if (sameBooleans(input, NEXUSHOST_GRANTS) && input.cellVisibility === 'none') return 'nexushost';
|
|
179
|
+
// The owner may narrow the visible cells of a user peer without granting
|
|
180
|
+
// anything new, so any of the three visibility values still reads `user`.
|
|
181
|
+
if (sameBooleans(input, USER_GRANTS) && CELL_VISIBILITY.includes(input.cellVisibility)) return 'user';
|
|
182
|
+
return 'custom';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Returns a copy of the peer record with the grants of the named preset.
|
|
186
|
+
// The input is never mutated; `role` is dropped, because it is not a field
|
|
187
|
+
// that this model persists.
|
|
188
|
+
function applyPreset(peer, name) {
|
|
189
|
+
if (!isPlainObject(peer)) throw new Error('peer must be an object');
|
|
190
|
+
if (!Object.prototype.hasOwnProperty.call(PRESETS, name)) {
|
|
191
|
+
throw new Error(`unknown preset: ${name}`);
|
|
192
|
+
}
|
|
193
|
+
const out = { ...peer };
|
|
194
|
+
delete out.role;
|
|
195
|
+
const preset = PRESETS[name];
|
|
196
|
+
// Applying a preset IS the explicit act: the record is marked as configured
|
|
197
|
+
// so a legacy peer with defaulted (denied) grants is never mistaken for a
|
|
198
|
+
// deliberate vector that denies everything.
|
|
199
|
+
out.accessConfigured = true;
|
|
200
|
+
out.cellVisibility = preset.cellVisibility;
|
|
201
|
+
if (preset.cellVisibility === 'selected') out.cells = [...(preset.cells || [])];
|
|
202
|
+
else delete out.cells;
|
|
203
|
+
for (const key of BOOLEAN_GRANTS) out[key] = preset[key];
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Extracts the grant vector from a peer record. An unconfigured record yields
|
|
208
|
+
// every grant denied.
|
|
209
|
+
function grantsOf(peer) {
|
|
210
|
+
if (!isPlainObject(peer)) return { configured: false, label: 'unconfigured', grants: { ...DENIED_GRANTS } };
|
|
211
|
+
const grants = {};
|
|
212
|
+
grants.cellVisibility = peer.cellVisibility;
|
|
213
|
+
if (peer.cells !== undefined) grants.cells = peer.cells;
|
|
214
|
+
for (const key of BOOLEAN_GRANTS) grants[key] = peer[key];
|
|
215
|
+
// A record is configured only when its vector is coherent AND the vector was
|
|
216
|
+
// set explicitly. Grants alone cannot tell a legacy record (everything
|
|
217
|
+
// defaulted to denied) from a deliberate vector that denies everything.
|
|
218
|
+
const configured = peer.accessConfigured === true && isConfigured(grants);
|
|
219
|
+
return {
|
|
220
|
+
configured,
|
|
221
|
+
label: configured ? deriveLabel(grants) : 'unconfigured',
|
|
222
|
+
grants: configured ? grants : { ...DENIED_GRANTS },
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
// Read view of ONE peer for listing endpoints and the settings UI: the
|
|
228
|
+
// derived label, the configured marker and the EFFECTIVE grant vector. The
|
|
229
|
+
// vector is flat and always complete (denied for records that are not
|
|
230
|
+
// configured), so a caller never has to guess defaults and no token or
|
|
231
|
+
// secret is ever included: grants are decisions, not credentials.
|
|
232
|
+
function accessView(peer) {
|
|
233
|
+
const view = grantsOf(peer);
|
|
234
|
+
const access = { cellVisibility: view.grants.cellVisibility };
|
|
235
|
+
for (const key of BOOLEAN_GRANTS) access[key] = view.grants[key] === true;
|
|
236
|
+
return { accessLabel: view.label, accessConfigured: view.configured, access };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = {
|
|
240
|
+
ADMIN_GRANTS,
|
|
241
|
+
BOOLEAN_GRANTS,
|
|
242
|
+
CELL_VISIBILITY,
|
|
243
|
+
CONFIGURED_MARKER,
|
|
244
|
+
DENIED_GRANTS,
|
|
245
|
+
EXCLUDED_GRANTS,
|
|
246
|
+
GRANT_FIELDS,
|
|
247
|
+
NEXUSHOST_GRANTS,
|
|
248
|
+
PRESETS,
|
|
249
|
+
PRESET_NAMES,
|
|
250
|
+
USER_GRANTS,
|
|
251
|
+
accessView,
|
|
252
|
+
applyPreset,
|
|
253
|
+
deriveLabel,
|
|
254
|
+
grantsOf,
|
|
255
|
+
isCompleteAdminVector,
|
|
256
|
+
isConfigured,
|
|
257
|
+
validateGrants,
|
|
258
|
+
};
|
package/lib/nodes/commands.js
CHANGED
|
@@ -19,6 +19,7 @@ const tunnel = require('./tunnel.js');
|
|
|
19
19
|
const reversePool = require('./reverse-pool.js');
|
|
20
20
|
const topologyCache = require('./topology-cache.js');
|
|
21
21
|
const inventory = require('./inventory.js');
|
|
22
|
+
const accessPresets = require('./access-presets.js');
|
|
22
23
|
const federation = require('../proxy/federation.js');
|
|
23
24
|
const { resolvePaths, DEFAULT_PORT } = require('../cli/url.js');
|
|
24
25
|
|
|
@@ -248,6 +249,63 @@ function nodesList(opts) {
|
|
|
248
249
|
return { code: 0, nodes, peers: visiblePeers };
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
// --- Event feed observability -------------------------------------------------
|
|
253
|
+
// Best-effort LOCAL read of the running server's feed diagnostics. The CLI
|
|
254
|
+
// never reads runtime files for this: if the local server is unreachable it
|
|
255
|
+
// says so explicitly instead of reporting zero deliveries.
|
|
256
|
+
// One line for the OS alert relay of that peer: same local diagnostics payload,
|
|
257
|
+
// counters and last reason only. A peer nobody tried to alert stays honest with
|
|
258
|
+
// zeros instead of disappearing.
|
|
259
|
+
function relayLineFor(body, peerNodeId) {
|
|
260
|
+
const section = (body && body.pushRelay) || {};
|
|
261
|
+
const own = (section.byOwner && section.byOwner[peerNodeId]) || {};
|
|
262
|
+
const last = own.lastReason ? ` · ultimo motivo ${own.lastReason}` : '';
|
|
263
|
+
return `relay: avvisi OS in coda ${own.queued || 0} · inviati ${own.pushed || 0} · scartati ${own.dropped || 0} · errori ${own.sendFailed || 0}${last}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function feedLinesFor(diag, peerNodeId) {
|
|
267
|
+
if (!diag || diag.ok !== true) {
|
|
268
|
+
return [`feed: diagnostica non disponibile (${(diag && diag.reason) || 'sconosciuto'}) — il server locale non risponde`];
|
|
269
|
+
}
|
|
270
|
+
const entry = (diag.body && Array.isArray(diag.body.peers) ? diag.body.peers : [])
|
|
271
|
+
.find((p) => p.nodeId === peerNodeId);
|
|
272
|
+
if (!entry) return ['feed: nessuna vista per questo peer'];
|
|
273
|
+
const enabled = diag.body.enabled === true;
|
|
274
|
+
const counters = (entry.feed && entry.feed.counters) || {};
|
|
275
|
+
const lastProblem = (entry.feed && (entry.feed.lastDropReason || entry.feed.lastRejectReason)) || 'none';
|
|
276
|
+
const grants = entry.grants || {};
|
|
277
|
+
return [
|
|
278
|
+
// The kill-switch state is its OWN line: it is the operator's first
|
|
279
|
+
// question when the feed is silent.
|
|
280
|
+
`feed: kill-switch ${enabled ? 'attivo (feed abilitato)' : 'OFF: feed disabilitato'} · revision ${entry.accessRevision} · label ${entry.accessLabel}`,
|
|
281
|
+
`feed: grant events=${grants.eventsAccess === true ? 'on' : 'off'} node-events=${grants.nodeEventsAccess === true ? 'on' : 'off'} · scope celle ${grants.cellVisibility || 'none'}${Array.isArray(grants.cells) && grants.cells.length ? ` (${grants.cells.join(', ')})` : ''}`,
|
|
282
|
+
`feed: cursore ${(entry.feed && entry.feed.lastCursor) || '-'} · stream ${(entry.feed && entry.feed.streams) || 0} · ultimo mancato: ${lastProblem}`,
|
|
283
|
+
`feed: contatori delivered ${(counters.delivered || 0)} · dropped ${(counters.dropped || 0)} · replayed ${(counters.replayed || 0)} · rejected ${(counters.rejected || 0)}`,
|
|
284
|
+
relayLineFor(diag.body, peerNodeId),
|
|
285
|
+
];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function fetchFeedDiagnostics(opts = {}) {
|
|
289
|
+
let port = Number(opts.localAppPort) || null;
|
|
290
|
+
if (!port && opts.configPath) {
|
|
291
|
+
try { port = Number(JSON.parse(fs.readFileSync(opts.configPath, 'utf8')).port) || null; } catch (_) { port = null; }
|
|
292
|
+
}
|
|
293
|
+
let token = '';
|
|
294
|
+
try { token = fs.readFileSync(opts.tokenPath, 'utf8').trim(); } catch (_) { return { ok: false, reason: 'no-token' }; }
|
|
295
|
+
if (!port) return { ok: false, reason: 'no-port' };
|
|
296
|
+
try {
|
|
297
|
+
// The local diagnostics live under the authenticated API mount: without
|
|
298
|
+
// the /api prefix the CLI would read the static handler and report the
|
|
299
|
+
// server as unreachable while it is answering fine.
|
|
300
|
+
const r = await (opts.fetchImpl || fetch)(`http://127.0.0.1:${port}/api/diagnostics/events`, {
|
|
301
|
+
headers: { authorization: `Bearer ${token}` },
|
|
302
|
+
signal: AbortSignal.timeout(1500),
|
|
303
|
+
});
|
|
304
|
+
if (!r.ok) return { ok: false, reason: `http-${r.status}` };
|
|
305
|
+
return { ok: true, body: await r.json() };
|
|
306
|
+
} catch (_) { return { ok: false, reason: 'unreachable' }; }
|
|
307
|
+
}
|
|
308
|
+
|
|
251
309
|
function nodesInspect(opts) {
|
|
252
310
|
const log = opts.log || console.log;
|
|
253
311
|
const out = loadPeerInventory(opts);
|
|
@@ -273,11 +331,46 @@ function nodesInspect(opts) {
|
|
|
273
331
|
const cells = Array.isArray(peer.cells) ? peer.cells : [];
|
|
274
332
|
log(`celle: ${cells.length ? cells.join(', ') : 'nessuna (elenco vuoto)'} (scope: selected)`);
|
|
275
333
|
}
|
|
334
|
+
// Effective federated access: the label is DERIVED from the grants, and a
|
|
335
|
+
// record whose grants were never set says so instead of showing a preset
|
|
336
|
+
// inferred from defaults.
|
|
337
|
+
if (peer.kind === 'direct') {
|
|
338
|
+
const view = accessPresets.grantsOf(peer);
|
|
339
|
+
log(`accesso: ${view.label}`);
|
|
340
|
+
log(`grant: ${ACCESS_GRANT_KEYS.map((key) => `${key}=${view.grants[key] ? 'on' : 'off'}`).join(' ')}`);
|
|
341
|
+
log(`scope: celle ${view.grants.cellVisibility}`);
|
|
342
|
+
for (const line of feedLinesFor(opts.feedDiagnostics, peer.nodeId)) log(line);
|
|
343
|
+
}
|
|
276
344
|
log(`azioni: ${Object.keys(peer.actions || {}).filter((key) => peer.actions[key]).join(', ')}`);
|
|
277
345
|
}
|
|
278
346
|
return { code: 0, peer: found.peer };
|
|
279
347
|
}
|
|
280
348
|
|
|
349
|
+
// --- Federated access grants -------------------------------------------------
|
|
350
|
+
// The grant vector of a peer is set on the node that OWNS the resources, in one
|
|
351
|
+
// validated write. The label (admin/user/nexushost/custom) is derived from the
|
|
352
|
+
// grants; nothing here stores or reads a role.
|
|
353
|
+
const ACCESS_GRANT_KEYS = ['eventsAccess', 'nodeEventsAccess', 'askReplyAccess', 'filesReadAccess', 'peerOperatorAccess'];
|
|
354
|
+
const ACCESS_EDIT_KEYS = new Set(['accessRole', ...ACCESS_GRANT_KEYS]);
|
|
355
|
+
|
|
356
|
+
const ACCESS_VECTOR_FIELDS = ['cellVisibility', 'cells', 'panelAccess', 'liveHostAccess'];
|
|
357
|
+
|
|
358
|
+
// The prospective vector of a peer after applying `overrides`. It covers every
|
|
359
|
+
// field the invariants talk about, so a granular edit cannot leave the record
|
|
360
|
+
// in a state the preset validator would refuse (live hosting without the
|
|
361
|
+
// complete admin set being the case that matters).
|
|
362
|
+
function accessVectorFrom(peer, overrides) {
|
|
363
|
+
const visibility = Object.hasOwn(overrides, 'cellVisibility') ? overrides.cellVisibility : peer.cellVisibility;
|
|
364
|
+
const vector = { cellVisibility: visibility };
|
|
365
|
+
const cells = Object.hasOwn(overrides, 'cells') ? overrides.cells : peer.cells;
|
|
366
|
+
if (visibility === 'selected') vector.cells = Array.isArray(cells) ? cells : [];
|
|
367
|
+
const all = [...ACCESS_GRANT_KEYS, 'panelAccess', 'liveHostAccess'];
|
|
368
|
+
for (const key of all) {
|
|
369
|
+
vector[key] = Object.hasOwn(overrides, key) ? overrides[key] === true : peer[key] === true;
|
|
370
|
+
}
|
|
371
|
+
return vector;
|
|
372
|
+
}
|
|
373
|
+
|
|
281
374
|
function nodesEdit(opts) {
|
|
282
375
|
const log = opts.log || console.log;
|
|
283
376
|
if (isReadonly(opts)) { log('nodes edit: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
|
|
@@ -295,9 +388,12 @@ function nodesEdit(opts) {
|
|
|
295
388
|
// ai PANNELLI di questo nodo, non chi ha aperto il tunnel — quindi vale in
|
|
296
389
|
// entrambe le direzioni. `liveHostAccess` e' identica: riguarda chi puo'
|
|
297
390
|
// designare la cella ospite Live di QUESTO nodo, non chi ha aperto il tunnel.
|
|
391
|
+
// eventsReceive is the node's OWN choice to import a peer's feed: it is a
|
|
392
|
+
// local client decision in BOTH directions and never part of the owner's
|
|
393
|
+
// grant presets.
|
|
298
394
|
const allowed = node.direction === 'inbound'
|
|
299
|
-
? new Set(['label', 'visibility', 'selected', 'cellVisibility', 'cells', 'panelAccess', 'liveHostAccess'])
|
|
300
|
-
: new Set(['label', 'ssh', 'sshPort', 'autostart', 'cellVisibility', 'cells', 'panelAccess', 'liveHostAccess']);
|
|
395
|
+
? new Set(['label', 'visibility', 'selected', 'cellVisibility', 'cells', 'panelAccess', 'liveHostAccess', 'accessRole', 'eventsReceive', ...ACCESS_GRANT_KEYS])
|
|
396
|
+
: new Set(['label', 'ssh', 'sshPort', 'autostart', 'cellVisibility', 'cells', 'panelAccess', 'liveHostAccess', 'accessRole', 'eventsReceive', ...ACCESS_GRANT_KEYS]);
|
|
301
397
|
const keys = Object.keys(supplied);
|
|
302
398
|
const invalid = keys.find((key) => !allowed.has(key));
|
|
303
399
|
if (invalid) {
|
|
@@ -308,6 +404,10 @@ function nodesEdit(opts) {
|
|
|
308
404
|
log('nodes edit: panelAccess vuole un booleano');
|
|
309
405
|
return { code: 1, reason: 'invalid-panel-access' };
|
|
310
406
|
}
|
|
407
|
+
if (Object.hasOwn(supplied, 'eventsReceive') && typeof supplied.eventsReceive !== 'boolean') {
|
|
408
|
+
log('nodes edit: eventsReceive vuole un booleano');
|
|
409
|
+
return { code: 1, reason: 'invalid-events-receive' };
|
|
410
|
+
}
|
|
311
411
|
if (Object.hasOwn(supplied, 'liveHostAccess') && typeof supplied.liveHostAccess !== 'boolean') {
|
|
312
412
|
log('nodes edit: liveHostAccess vuole un booleano');
|
|
313
413
|
return { code: 1, reason: 'invalid-live-host-access' };
|
|
@@ -342,9 +442,73 @@ function nodesEdit(opts) {
|
|
|
342
442
|
// ricostruisce il nodo da zero.
|
|
343
443
|
if (Object.hasOwn(patch, 'cellVisibility') && patch.cellVisibility !== 'selected') patch.cells = undefined;
|
|
344
444
|
|
|
445
|
+
const accessRequested = keys.some((key) => ACCESS_EDIT_KEYS.has(key));
|
|
446
|
+
const touchesVector = keys.some((key) => ACCESS_VECTOR_FIELDS.includes(key));
|
|
447
|
+
// A peer whose grants were never configured keeps its historical behaviour
|
|
448
|
+
// for panel/live flags; once a vector IS configured, every edit of that
|
|
449
|
+
// vector passes through the same validator and the same single write.
|
|
450
|
+
const enforceVector = accessRequested || (touchesVector && node.accessConfigured === true);
|
|
451
|
+
let accessVector = null;
|
|
452
|
+
if (enforceVector) {
|
|
453
|
+
const hasRole = Object.hasOwn(supplied, 'accessRole');
|
|
454
|
+
const granular = ACCESS_GRANT_KEYS.filter((key) => Object.hasOwn(supplied, key));
|
|
455
|
+
if (hasRole && granular.length > 0) {
|
|
456
|
+
log('nodes edit: --access-role e i grant granulari non si combinano nella stessa richiesta');
|
|
457
|
+
return { code: 1, reason: 'preset-and-grants' };
|
|
458
|
+
}
|
|
459
|
+
for (const key of granular) {
|
|
460
|
+
if (typeof supplied[key] !== 'boolean') {
|
|
461
|
+
log(`nodes edit: ${key} vuole un booleano`);
|
|
462
|
+
return { code: 1, reason: 'invalid-grant' };
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (hasRole) {
|
|
466
|
+
if (typeof supplied.accessRole !== 'string' || !accessPresets.PRESET_NAMES.includes(supplied.accessRole)) {
|
|
467
|
+
log(`nodes edit: access-role non valido (atteso: ${accessPresets.PRESET_NAMES.join('|')})`);
|
|
468
|
+
return { code: 1, reason: 'invalid-access-role' };
|
|
469
|
+
}
|
|
470
|
+
accessVector = { ...accessPresets.PRESETS[supplied.accessRole] };
|
|
471
|
+
} else if (!accessRequested && touchesVector) {
|
|
472
|
+
// Panel/live/scope edit on a configured peer: same vector, same rules.
|
|
473
|
+
accessVector = accessVectorFrom(node, supplied);
|
|
474
|
+
} else {
|
|
475
|
+
// A granular edit only refines a vector that was set explicitly: a peer
|
|
476
|
+
// whose grants were never configured stays denied, and a partial edit
|
|
477
|
+
// must not promote it.
|
|
478
|
+
if (node.accessConfigured !== true) {
|
|
479
|
+
log('nodes edit: peer non configurato — applica prima un preset (--access-role)');
|
|
480
|
+
return { code: 1, reason: 'access-not-configured' };
|
|
481
|
+
}
|
|
482
|
+
accessVector = accessVectorFrom(node, supplied);
|
|
483
|
+
}
|
|
484
|
+
const check = accessPresets.validateGrants(accessVector);
|
|
485
|
+
if (!check.ok) {
|
|
486
|
+
log(`nodes edit: concessioni rifiutate — ${check.errors.join('; ')}`);
|
|
487
|
+
return { code: 1, reason: 'invalid-grants' };
|
|
488
|
+
}
|
|
489
|
+
for (const key of [...ACCESS_EDIT_KEYS, ...ACCESS_VECTOR_FIELDS]) delete patch[key];
|
|
490
|
+
}
|
|
491
|
+
|
|
345
492
|
let next;
|
|
346
|
-
|
|
347
|
-
|
|
493
|
+
if (enforceVector) {
|
|
494
|
+
// One validated read-modify-write against the policy revision: a concurrent
|
|
495
|
+
// edit loses the race and rereads instead of overwriting it.
|
|
496
|
+
const expected = Number.isInteger(opts.expectedRevision) ? opts.expectedRevision : store.accessRevisionOf(st);
|
|
497
|
+
let res;
|
|
498
|
+
try {
|
|
499
|
+
res = store.setPeerAccessGrantsCas({
|
|
500
|
+
filePath: nodesPath, name: node.name, grants: accessVector, expectedRevision: expected, extraPatch: patch,
|
|
501
|
+
});
|
|
502
|
+
} catch (e) { log(`nodes edit: ${e.message}`); return { code: 1, reason: 'invalid-patch' }; }
|
|
503
|
+
if (!res.ok) {
|
|
504
|
+
log(`nodes edit: revision delle concessioni superata (attuale ${res.revision}) — rileggi e riprova`);
|
|
505
|
+
return { code: 1, reason: 'revision-conflict', revision: res.revision };
|
|
506
|
+
}
|
|
507
|
+
next = store.loadStoreStrict(nodesPath);
|
|
508
|
+
} else {
|
|
509
|
+
try { next = store.updateNode(st, node.name, patch); }
|
|
510
|
+
catch (e) { log(`nodes edit: ${e.message}`); return { code: 1, reason: 'invalid-patch' }; }
|
|
511
|
+
}
|
|
348
512
|
|
|
349
513
|
const transportChanged = node.direction === 'outbound'
|
|
350
514
|
&& ['ssh', 'sshPort'].some((key) => Object.hasOwn(patch, key));
|
|
@@ -377,6 +541,33 @@ function nodesEdit(opts) {
|
|
|
377
541
|
return { code: 0, name: node.name, node: edited, restarted: wasUp };
|
|
378
542
|
}
|
|
379
543
|
|
|
544
|
+
// --- nodes access: read-only inventory --------------------------------------
|
|
545
|
+
// Shows what every peer may currently do on this node, with the revision of
|
|
546
|
+
// the policy. Peers whose grants were never configured are listed as such:
|
|
547
|
+
// they are the ones an explicit migration has to set, and nothing promotes
|
|
548
|
+
// them by name or by age of pairing.
|
|
549
|
+
function nodesAccess(opts = {}) {
|
|
550
|
+
const log = opts.log || console.log;
|
|
551
|
+
const { nodesPath } = resolveNodePaths(opts);
|
|
552
|
+
let st;
|
|
553
|
+
try { st = store.loadStoreStrict(nodesPath); }
|
|
554
|
+
catch (e) { log(`nodes access: ${e.message}`); return { code: 1, reason: 'store invalido', status: e.status || 500 }; }
|
|
555
|
+
const view = store.accessInventory(st);
|
|
556
|
+
const legacy = view.peers.filter((peer) => !peer.configured);
|
|
557
|
+
if (opts.json) {
|
|
558
|
+
log(JSON.stringify({ revision: view.revision, peers: view.peers, unconfigured: legacy.map((p) => p.name) }, null, 2));
|
|
559
|
+
return { code: 0, ...view, unconfigured: legacy.map((p) => p.name) };
|
|
560
|
+
}
|
|
561
|
+
log(`revision concessioni: ${view.revision}`);
|
|
562
|
+
if (view.peers.length === 0) log('nessun peer diretto');
|
|
563
|
+
for (const peer of view.peers) {
|
|
564
|
+
log(`${peer.name}${peer.nodeId ? ` (${peer.nodeId})` : ''}: ${peer.label}`);
|
|
565
|
+
log(` ${ACCESS_GRANT_KEYS.map((key) => `${key}=${peer.grants[key] ? 'on' : 'off'}`).join(' ')} · celle ${peer.grants.cellVisibility}`);
|
|
566
|
+
}
|
|
567
|
+
if (legacy.length > 0) log(`da configurare esplicitamente (${legacy.length}): ${legacy.map((p) => p.name).join(', ')}`);
|
|
568
|
+
return { code: 0, ...view, unconfigured: legacy.map((p) => p.name) };
|
|
569
|
+
}
|
|
570
|
+
|
|
380
571
|
// --- nodes remove ----------------------------------------------------------
|
|
381
572
|
function nodesRemove(opts) {
|
|
382
573
|
const log = opts.log || console.log;
|
|
@@ -683,6 +874,9 @@ function nodesSetToken(opts) {
|
|
|
683
874
|
}
|
|
684
875
|
|
|
685
876
|
module.exports = {
|
|
877
|
+
fetchFeedDiagnostics, feedLinesFor,
|
|
878
|
+
|
|
879
|
+
nodesAccess, ACCESS_GRANT_KEYS,
|
|
686
880
|
nodesAdd, nodesList, nodesInspect, nodesEdit, nodesRemove, nodesTest, nodesTestAll,
|
|
687
881
|
nodesUp, nodesDown, nodesRestart, nodesSetToken,
|
|
688
882
|
// helper esposti per test/riuso
|