@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/docs/LIVE_PROMPT.md +1 -1
  3. package/docs/NOTIFICATIONS.md +37 -0
  4. package/docs/OPERATIONS.md +20 -0
  5. package/frontend/dist/assets/index-BbcjoZS0.js +167 -0
  6. package/frontend/dist/assets/index-DqCfZFGi.css +32 -0
  7. package/frontend/dist/index.html +2 -2
  8. package/frontend/dist/sw.js +91 -10
  9. package/frontend/dist/version.json +1 -1
  10. package/lib/audio/groups.js +1 -1
  11. package/lib/auth/token.js +4 -4
  12. package/lib/cli/commands.js +70 -15
  13. package/lib/cli/doctor.js +5 -5
  14. package/lib/cli/fleet-service.js +7 -7
  15. package/lib/cli/init.js +55 -31
  16. package/lib/cli/pidfile.js +1 -1
  17. package/lib/cli/service.js +124 -8
  18. package/lib/cli/stable-alias.js +4 -4
  19. package/lib/cli/url.js +1 -1
  20. package/lib/config.js +36 -6
  21. package/lib/files/routes.js +1 -1
  22. package/lib/fleet/boot.js +2 -2
  23. package/lib/fleet/builtin.js +15 -15
  24. package/lib/fleet/causes.js +1 -1
  25. package/lib/fleet/cell-exec.js +23 -23
  26. package/lib/fleet/cell-hold.js +6 -6
  27. package/lib/fleet/cell-lease-server.js +106 -106
  28. package/lib/fleet/cell-lease.js +21 -21
  29. package/lib/fleet/definitions.js +10 -10
  30. package/lib/fleet/identity-authority.js +1 -1
  31. package/lib/fleet/launch-broker.js +13 -13
  32. package/lib/fleet/launch.js +15 -15
  33. package/lib/fleet/lease-client.js +23 -23
  34. package/lib/fleet/lease-routes.js +3 -3
  35. package/lib/fleet/lease-verifier.js +22 -22
  36. package/lib/fleet/managed.js +35 -35
  37. package/lib/fleet/prompt-delivery.js +13 -13
  38. package/lib/fleet/routes.js +15 -10
  39. package/lib/fleet/runtime.js +24 -24
  40. package/lib/live-host/bridge.js +53 -43
  41. package/lib/live-host/federation.js +97 -0
  42. package/lib/live-host/routes.js +166 -8
  43. package/lib/live-host/store.js +44 -6
  44. package/lib/mcp/server.js +14 -14
  45. package/lib/mcp/tools.js +5 -5
  46. package/lib/nodes/access-presets.js +258 -0
  47. package/lib/nodes/commands.js +198 -4
  48. package/lib/nodes/store.js +152 -9
  49. package/lib/nodes/tunnel.js +7 -7
  50. package/lib/notify/ask-answer-service.js +184 -0
  51. package/lib/notify/ask-receipts.js +197 -0
  52. package/lib/notify/ask-relay.js +152 -0
  53. package/lib/notify/asks.js +28 -3
  54. package/lib/notify/event-feed-acl.js +95 -0
  55. package/lib/notify/event-feed-asks-routes.js +144 -0
  56. package/lib/notify/event-feed-client.js +434 -0
  57. package/lib/notify/event-feed-history.js +115 -0
  58. package/lib/notify/event-feed-producers.js +175 -0
  59. package/lib/notify/event-feed-routes.js +254 -0
  60. package/lib/notify/event-feed.js +328 -0
  61. package/lib/notify/events.js +1 -1
  62. package/lib/notify/notifier.js +3 -3
  63. package/lib/notify/push-relay.js +240 -0
  64. package/lib/notify/push.js +8 -8
  65. package/lib/notify/routes.js +62 -23
  66. package/lib/proxy/federation.js +132 -12
  67. package/lib/proxy/node-proxy.js +13 -13
  68. package/lib/proxy/panel-auth.js +4 -4
  69. package/lib/proxy/panel-proxy.js +2 -2
  70. package/lib/proxy/resource-acl.js +145 -0
  71. package/lib/server.js +396 -40
  72. package/lib/settings/pairing-coordinator.js +1 -1
  73. package/lib/settings/routes.js +45 -32
  74. package/lib/update/manager.js +1 -1
  75. package/lib/update/runner.js +1 -1
  76. package/lib/voice/transcribe.js +1 -1
  77. package/lib/ws/bridge.js +3 -3
  78. package/package.json +1 -1
  79. package/skills/live/SKILL.md +12 -0
  80. package/frontend/dist/assets/index-CWra6B79.js +0 -167
  81. 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
- // Ordine delle sorgenti (design §1, INVARIATO): $TMUX presente -> tmux
85
- // display-message (nome sessione reale); se fallisce/invalida -> fallback env
86
- // NEXUSCREW_MCP_SESSION; altrimenti null. I tool che RICHIEDONO la sessione
87
- // restano fail-closed. execFile argv diretto: mai shell.
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
- // P0: il nome da tmux si chiede con `-t $TMUX_PANE` — target esplicito al
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 (P0):
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
- // P0: pane stantio o non verificabile -> NON attribuire. source 'stale-pane'
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
- // P0/R2: tmux e fallback env dicono sessioni DIVERSE entrambe valide:
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
- // dato (`missing` storico o `stalePane` P0).
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
- // R2: se il fallback env è valido ma dice un'altra sessione, le due
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
- // nome non vuoto ma invalido: precedenza preservata, come da design §1.
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
- // V-69: il ramo vl di resolveManagedEngine compone le istruzioni companion
622
- // nel file di prompt per-cella — vl non ha client MCP, questo e' l'unica
623
- // superficie attraverso cui il testo lo raggiunge.
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 P0). Valori EXACT, non sensibili,
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
- // P0: il pane del chiamante non esiste più (o TMUX_PANE è assente/malformato):
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
- // P0/R2: tmux e NEXUSCREW_MCP_SESSION sono entrambi validi ma dicono sessioni
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 sui tool che non mutano nulla (§1).
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
- // --- lease Live del child (fetta 2b, contratto rev1 B5) ----------------------
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
+ };
@@ -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
- try { next = store.updateNode(st, node.name, patch); }
347
- catch (e) { log(`nodes edit: ${e.message}`); return { code: 1, reason: 'invalid-patch' }; }
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