@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/server.js CHANGED
@@ -29,8 +29,10 @@ const { cellsRoutes } = require('./cells/routes.js');
29
29
  const { liveHostRoutes } = require('./live-host/routes.js');
30
30
  const { createLiveHostStore, liveHostPath } = require('./live-host/store.js');
31
31
  const { createLiveBridge } = require('./live-host/bridge.js');
32
+ const { createLiveHostFederation } = require('./live-host/federation.js');
32
33
  const { fsRoutes } = require('./fs/routes.js');
33
34
  const nodesStore = require('./nodes/store.js');
35
+ const accessPresets = require('./nodes/access-presets.js');
34
36
  const nodesTunnel = require('./nodes/tunnel.js');
35
37
  const nodesHealth = require('./nodes/health.js');
36
38
  const { recordPeerTransition } = require('./nodes/peer-transitions.js');
@@ -66,8 +68,25 @@ const decksStore = require('./decks/store.js');
66
68
  const { decksRoutes } = require('./decks/routes.js');
67
69
  const { createEventsHub } = require('./notify/events.js');
68
70
  const { createPushService } = require('./notify/push.js');
71
+ const { createPushRelay, createPushDedup } = require('./notify/push-relay.js');
69
72
  const { createAsksStore } = require('./notify/asks.js');
70
73
  const { createNotifier } = require('./notify/notifier.js');
74
+ const { createEventFeed } = require('./notify/event-feed.js');
75
+ const { createEventFeedHistory } = require('./notify/event-feed-history.js');
76
+ const { buildEnvelope } = require('./notify/event-feed-producers.js');
77
+ const { createEventFeedRoutes } = require('./notify/event-feed-routes.js');
78
+ const eventFeedAcl = require('./notify/event-feed-acl.js');
79
+ const { createEventFeedClient } = require('./notify/event-feed-client.js');
80
+ const { createAskReceipts } = require('./notify/ask-receipts.js');
81
+ const { createAskAnswerService } = require('./notify/ask-answer-service.js');
82
+ const { createEventFeedAsksRoutes } = require('./notify/event-feed-asks-routes.js');
83
+ const { createAskRelay } = require('./notify/ask-relay.js');
84
+ const { readFederationEventsEnabled } = require('./config.js');
85
+
86
+ function sanitizeReplyLabel(raw) {
87
+ return String(raw === undefined || raw === null ? 'human' : raw)
88
+ .replace(/[\x00-\x1f\x7f\]]/g, '').slice(0, 32) || 'human';
89
+ }
71
90
  const { notifyRoutes } = require('./notify/routes.js');
72
91
  const { createNpmUpdater } = require('./update/manager.js');
73
92
  const { createDiagnostics } = require('./diagnostics/store.js');
@@ -104,7 +123,7 @@ function createServer(opts = {}) {
104
123
  };
105
124
  const proxySockets = new Set();
106
125
  // wss viene creato piu' sotto; closeSessions lo raggiunge a request-time (mai durante
107
- // createServer) per chiudere le sessioni WS attive sulla rotazione token (§4b(3)).
126
+ // createServer) to close active WS sessions on token rotation.
108
127
  let wss = null;
109
128
  const ptyGrace = createPtyGraceStore({
110
129
  graceMs: cfg.ptyGraceMs,
@@ -144,8 +163,166 @@ function createServer(opts = {}) {
144
163
  dir: notifyDir, webpushImpl: cfg.webpushImpl,
145
164
  readonly: bridgeReadonly, maxSubs: cfg.pushMaxSubs, lookupImpl: cfg.pushLookupImpl,
146
165
  });
166
+ // OS alerts for IMPORTED notifications: the same push service (endpoint/DNS/
167
+ // SSRF checks, 404/410 cleanup, READONLY floor) with its OWN queue, its own
168
+ // alert budget and a durable (owner, eventId) registry — an event already
169
+ // alerted never rings twice, across a replay or a restart. It is fed only by
170
+ // the import hub below, so a local emission never takes this path.
171
+ const pushRelay = createPushRelay({
172
+ pushDedup: createPushDedup({ filePath: path.join(notifyDir, 'push-relay-dedup.json') }),
173
+ send: (payload) => pushSvc.sendToAll(payload),
174
+ log: opts.log || console.log,
175
+ });
147
176
  const asksStore = createAsksStore({ dir: notifyDir });
148
- const notifier = createNotifier({ hub: eventsHub, push: pushSvc });
177
+ // --- Event feed (owner side) -------------------------------------------------
178
+ // The notifier stays the single emission point; the feed is a SIDE EFFECT of
179
+ // a local emission, never a separate path. Federated ingress and feed import
180
+ // call deliverOnly(): local delivery that does NOT re-enter the publisher —
181
+ // an imported event can never be re-exported, so A→B→A dies by construction.
182
+ const notifierBase = createNotifier({ hub: eventsHub, push: pushSvc });
183
+ const feedHistory = createEventFeedHistory({
184
+ filePath: path.join(notifyDir, 'event-feed-history.json'),
185
+ });
186
+ // Kill switch: strict boolean, read FRESH on every use so flipping it in
187
+ // config.json acts on live streams without a restart.
188
+ const eventsEnabled = () => readFederationEventsEnabled({ configJsonPath: cfg.configPath, federationEventsEnabled: cfg.federationEventsEnabled });
189
+ const eventFeed = createEventFeed({
190
+ log: opts.log || console.log,
191
+ // A heartbeat is also a revoke checkpoint: grants re-read without events.
192
+ validatePeer: (peerNodeId) => {
193
+ if (eventsEnabled() !== true) return false;
194
+ const st = nodesStore.loadStore(nodesPath);
195
+ const grants = st ? eventFeedAcl.peerGrants(st, peerNodeId) : null;
196
+ return !!(grants && grants.configured && grants.eventsAccess === true);
197
+ },
198
+ });
199
+ // Durable receipts + the ONE answer cycle shared by the local route and the
200
+ // federated ask surface. The closure event flows through the notifier
201
+ // wrapper so the feed gets a qualified ask-closed envelope.
202
+ const askReceipts = createAskReceipts({ filePath: path.join(notifyDir, 'ask-receipts.json') });
203
+ const askAnswerService = createAskAnswerService({
204
+ asks: asksStore,
205
+ paste: cfg.pasteSeam || ((session, text) => pasteToSession(cfg.tmuxBin, session, text)),
206
+ receipts: askReceipts,
207
+ onClosure: (kind, info) => { try { notifier.emitRaw({ type: kind, id: info.askId }); } catch (_) {} },
208
+ labelPrefix: sanitizeReplyLabel(cfg && cfg.replyLabel),
209
+ });
210
+ const feedSignatures = new Map(); // peerNodeId -> grant signature last seen
211
+ const apiOwnerId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
212
+
213
+ function publishEnvelopeToPeers(envelope) {
214
+ if (eventsEnabled() !== true) {
215
+ eventFeed.invalidateAll('events-disabled');
216
+ return;
217
+ }
218
+ const store = nodesStore.loadStore(nodesPath);
219
+ if (!store) return;
220
+ for (const node of store.nodes) {
221
+ if (!node || node.direction !== 'inbound' || !node.nodeId) continue;
222
+ const grants = eventFeedAcl.peerGrants(store, node.nodeId);
223
+ const usable = grants && grants.configured && grants.eventsAccess === true;
224
+ if (!usable) {
225
+ // Lost the grants: the view dies BEFORE anything else is served.
226
+ if (eventFeed.status(node.nodeId)) {
227
+ eventFeed.invalidatePeer(node.nodeId, 'revoked');
228
+ eventFeed.disablePeer(node.nodeId);
229
+ feedSignatures.delete(node.nodeId);
230
+ }
231
+ continue;
232
+ }
233
+ const seen = feedSignatures.get(node.nodeId);
234
+ if (seen !== undefined && seen !== grants.signature) {
235
+ eventFeed.invalidatePeer(node.nodeId, 'grants-changed');
236
+ }
237
+ feedSignatures.set(node.nodeId, grants.signature);
238
+ if (!eventFeedAcl.allowsEnvelope(grants, envelope)) continue;
239
+ // Per-peer projection BEFORE the ring: a node frame may carry cell
240
+ // lists, and a hidden cell never reaches the peer's view in any form
241
+ // (no names, no counts) — node events do not bypass cell visibility.
242
+ let projected = envelope;
243
+ if (envelope.scope === 'node' && Array.isArray(envelope.frame && envelope.frame.cells)) {
244
+ projected = {
245
+ ...envelope,
246
+ frame: { ...envelope.frame, cells: eventFeedAcl.projectCells(grants, envelope.frame.cells) },
247
+ };
248
+ }
249
+ try {
250
+ if (!eventFeed.status(node.nodeId)) eventFeed.enablePeer(node.nodeId);
251
+ eventFeed.publish(node.nodeId, projected);
252
+ } catch (_) { /* one peer's budget never blocks the others */ }
253
+ }
254
+ }
255
+
256
+ // Local emission → envelope + durable history + per-peer publication.
257
+ function recordAndPublish({ producer, cellId, payload }) {
258
+ const ownerId = apiOwnerId();
259
+ if (!ownerId) return null;
260
+ let envelope;
261
+ try {
262
+ envelope = buildEnvelope({ type: producer, payload, ownerId, cellId, now: Date.now });
263
+ } catch (e) {
264
+ try { diagnostics.record('warn', 'notify', 'EVENT_FEED_PRODUCER_REFUSED', String(e.message || e), { producer }); } catch (_) {}
265
+ return null;
266
+ }
267
+ try { feedHistory.record(envelope); } catch (_) { /* history degrades, notify still lives */ }
268
+ publishEnvelopeToPeers(envelope);
269
+ return envelope;
270
+ }
271
+
272
+ function cellIdForFrame(frame) {
273
+ try { return cellIdFromTmuxSession(frame && frame.session); } catch (_) { return null; }
274
+ }
275
+
276
+ const notifier = {
277
+ // Local emission: UI + push + feed. `federated: true` is an INTERNAL flag
278
+ // of the caller, never a body field — a remote notify cannot ask to be
279
+ // treated as local.
280
+ async emit(frame, opts = {}) {
281
+ const result = await notifierBase.emit(frame);
282
+ if (opts.federated !== true) {
283
+ const cellId = cellIdForFrame(frame);
284
+ recordAndPublish({
285
+ producer: cellId ? 'notify' : 'notify-node',
286
+ cellId,
287
+ payload: { ...frame, ts: Date.now() },
288
+ });
289
+ }
290
+ return result;
291
+ },
292
+ // Raw UI frames: ask cards and their closures carry the ask id into the
293
+ // feed so both sides see the same object.
294
+ emitRaw(frame) {
295
+ const out = notifierBase.emitRaw(frame);
296
+ try {
297
+ if (frame && frame.type === 'ask' && frame.ask) {
298
+ const ask = frame.ask;
299
+ const cellId = cellIdFromTmuxSession(ask.session);
300
+ if (cellId) {
301
+ recordAndPublish({
302
+ producer: 'ask', cellId,
303
+ payload: { askId: ask.id, revision: ask.revision || 0, question: ask.question, options: ask.options, session: ask.session, ts: Date.now() },
304
+ });
305
+ }
306
+ } else if (frame && (frame.type === 'ask-answered' || frame.type === 'ask-dismissed')) {
307
+ const all = asksStore.list({ open: false }).concat(asksStore.list({ open: true }));
308
+ const ask = all.find((a) => a && a.id === frame.id);
309
+ const cellId = ask ? cellIdFromTmuxSession(ask.session) : null;
310
+ if (cellId) {
311
+ recordAndPublish({
312
+ producer: 'ask-closed', cellId,
313
+ payload: { askId: frame.id, revision: (ask && ask.revision) || 0, outcome: frame.type === 'ask-dismissed' ? 'dismissed' : 'answered', ts: Date.now() },
314
+ });
315
+ }
316
+ }
317
+ } catch (_) { /* the local raw frame must not fail because of the feed */ }
318
+ return out;
319
+ },
320
+ // Delivery-only: federated ingress and feed import land here. NO history,
321
+ // NO publisher — an imported event is never re-exported.
322
+ async deliverOnly(frame) { return notifierBase.emit(frame); },
323
+ deliverOnlyRaw(frame) { return notifierBase.emitRaw(frame); },
324
+ publishState: (producer, payload) => recordAndPublish({ producer, cellId: null, payload }),
325
+ };
149
326
  const diagnostics = opts.diagnostics || createDiagnostics();
150
327
  // Contatore cadute WS (rolling 10 min): alimenta i meta dei log di bridge.
151
328
  const wsDropCounter = createDropCounter();
@@ -171,7 +348,7 @@ function createServer(opts = {}) {
171
348
  const identityOwnerInstanceId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
172
349
  const fleetP = selectProvider({ ...cfg, ensureTmuxProtection, identityOwnerInstanceId }).then((p) => p.fleet);
173
350
 
174
- // Multi-node (B1): nodes.json e' la fonte dati (B0). Il proxy risolve <name>
351
+ // Multi-node: nodes.json e' la fonte dati. Il proxy risolve <name>
175
352
  // -> {localPort, token} leggendo lo store ad ogni richiesta (fresh: rotazione
176
353
  // token / add-remove nodi visibili senza restart). token MAI redatto qui: e'
177
354
  // il valore che il proxy inietta upstream, non esce mai verso il browser.
@@ -545,7 +722,7 @@ function createServer(opts = {}) {
545
722
  }
546
723
 
547
724
  const app = express();
548
- // CSP frame-ancestors (P0 sicurezza 2026-08-16, rimedio 4 del report): la
725
+ // CSP frame-ancestors (sicurezza 2026-08-16, rimedio 4 del report): la
549
726
  // pagina dell'app non deve poter essere incorporata in un iframe altrui —
550
727
  // clickjacking sul control plane. Ortogonale alla porta pannello: quello
551
728
  // difende CHI E' NEL FRAME, questo difende l'app dall'ESSERE il frame.
@@ -641,7 +818,7 @@ function createServer(opts = {}) {
641
818
  cell: entry.cell, reason: entry.reason, state: entry.outcome,
642
819
  }),
643
820
  }));
644
- // Porta pannello dedicata (P0 sicurezza 2026-08-16): un iframe
821
+ // Porta pannello dedicata (sicurezza 2026-08-16): un iframe
645
822
  // same-origin senza sandbox legge il localStorage del padre — il JS di un
646
823
  // pannello ostile puo' prendersi il token e agire come l'operatore. La
647
824
  // porta fa parte dell'origin: spostando SOLO il consumo (ticket in query,
@@ -845,8 +1022,8 @@ function createServer(opts = {}) {
845
1022
  instanceId: apiNodeId,
846
1023
  identityMode,
847
1024
  }));
848
- // MCP bridge (design §2): /notify, /push/*, /asks — dietro lo stesso Bearer
849
- // del router /api; gate READONLY sui mutanti dentro notifyRoutes.
1025
+ // MCP bridge: /notify, /push/*, /asks — behind the same Bearer
1026
+ // of the /api router; READONLY gate on the mutators inside notifyRoutes.
850
1027
  // Identita' e instradamento condivisi con l'audio: stessa fonte (node store),
851
1028
  // stesso segreto di hop, stessa inventory. Due modelli di provenienza
852
1029
  // divergenti sarebbero un bug latente.
@@ -865,8 +1042,9 @@ function createServer(opts = {}) {
865
1042
  notifier,
866
1043
  push: pushSvc,
867
1044
  asks: asksStore,
868
- paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
869
- sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
1045
+ paste: cfg.pasteSeam || ((session, text) => pasteToSession(cfg.tmuxBin, session, text)),
1046
+ // Stesso seam dell'attach: i test non devono parlare con tmux vero.
1047
+ sessionExists: cfg.sessionExistsSeam || ((name) => sessionExists(cfg.tmuxBin, name)),
870
1048
  fleetP,
871
1049
  instanceId: apiNodeId,
872
1050
  identityMode,
@@ -883,16 +1061,156 @@ function createServer(opts = {}) {
883
1061
  peers: federatedPeers,
884
1062
  localPort: () => (server && server.address() ? server.address().port : cfg.port),
885
1063
  localToken: () => tokenHolder.value,
886
- // 'no-delivery' (R31-A3): esito legittimo del target — richiesta accettata
1064
+ // 'no-delivery' (-): esito legittimo del target — richiesta accettata
887
1065
  // ma nessun canale raggiunto (0 UI, 0 push). Senza questa voce forward()
888
1066
  // lo degraderebbe a 'unknown/unreadable-endpoint-result', nascondendo
889
1067
  // proprio il silenzio che l'esito esiste per rivelare.
890
1068
  statuses: new Set(['delivered', 'no-delivery', 'refused', 'unreachable', 'unknown']),
891
1069
  }),
892
1070
  federatedRate: createSpeakRateLimiter(),
1071
+ answerService: askAnswerService,
1072
+ receipts: askReceipts,
1073
+ }));
1074
+ // Event feed: the closed SSE/JSON surface, mounted inside the
1075
+ // authenticated API. Its own gate demands a proven federated origin and an
1076
+ // exact two-hop chain — there is no local fallback.
1077
+ api.use('/event-feed/asks', createEventFeedAsksRoutes({
1078
+ nodesPath,
1079
+ localNodeId: federatedNodeId,
1080
+ originResolver: createOriginResolver({
1081
+ localNodeId: federatedNodeId,
1082
+ hopSecret: () => hopSecret,
1083
+ }),
1084
+ eventsEnabled,
1085
+ asks: asksStore,
1086
+ answerService: askAnswerService,
1087
+ receipts: askReceipts,
1088
+ cellForSession: (session) => cellIdFromTmuxSession(session),
1089
+ readonly: proxyReadonly,
893
1090
  }));
894
- api.use('/fleet', fleetRoutes(fleetP, { ...cfg, diagnostics }));
895
- // Fetta 2b (D3): superficie child del lease Live via canale nativo del
1091
+ api.use('/event-feed', createEventFeedRoutes({
1092
+ nodesPath,
1093
+ localNodeId: federatedNodeId,
1094
+ originResolver: createOriginResolver({
1095
+ localNodeId: federatedNodeId,
1096
+ hopSecret: () => hopSecret,
1097
+ }),
1098
+ eventFeed,
1099
+ history: feedHistory,
1100
+ asksStore,
1101
+ fleetP,
1102
+ cellForSession: (session) => cellIdFromTmuxSession(session),
1103
+ eventsEnabled,
1104
+ }));
1105
+ // Client-side subscriber: for every outbound peer the
1106
+ // operator flagged eventsReceive, pull the owner's feed (snapshot first),
1107
+ // re-emit imported events LOCALLY with owner attribution. Imports never
1108
+ // touch the notifier wrapper, so a client never re-exports what it imports.
1109
+ // Ask relay (federated asks, client side): the PWA posts here, the relay
1110
+ // resolves the owner from the authorized store and forwards with the peer's
1111
+ // own pair token. Uncertain outcomes are parked, never retried silently.
1112
+ const askRelay = createAskRelay({
1113
+ loadStore: () => nodesStore.loadStore(nodesPath),
1114
+ log: opts.log || console.log,
1115
+ });
1116
+ api.post('/asks-relay', async (req, res) => {
1117
+ try {
1118
+ const b = req.body || {};
1119
+ if (b.action === 'dismiss') {
1120
+ const out = await askRelay.relayDismiss({ ownerId: b.ownerId, askId: b.askId });
1121
+ return res.status(out.ok ? 200 : out.code || 500).json(out.ok ? { dismissed: true } : { error: out.error || 'unknown' });
1122
+ }
1123
+ if (b.action === 'verify') {
1124
+ const out = await askRelay.verifyStatus({ ownerId: b.ownerId, askId: b.askId, requestId: b.requestId });
1125
+ return res.status(out.ok ? 200 : out.code || 500).json(out.ok ? { state: out.state } : { error: out.error || 'unknown', reason: out.reason });
1126
+ }
1127
+ const out = await askRelay.relayAnswer({ ownerId: b.ownerId, askId: b.askId, text: b.text });
1128
+ return res.status(out.ok ? 200 : out.code || 500).json({
1129
+ ...(out.ok ? { status: out.status } : {}),
1130
+ requestId: out.requestId || b.requestId,
1131
+ ...(out.uncertain ? { uncertain: true, reason: out.reason } : {}),
1132
+ ...(!out.ok && !out.uncertain ? { error: out.error, reason: out.reason } : {}),
1133
+ });
1134
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1135
+ });
1136
+ // The client re-emits imported events into the local hub (deliverOnly: the
1137
+ // notifier is bypassed on purpose). The alert relay hangs off that same call,
1138
+ // so ONLY an imported notify can ring this device, and never through the
1139
+ // owner-side publisher. READONLY keeps the relay off: its registry is a
1140
+ // durable write.
1141
+ const importHub = {
1142
+ broadcast: (event) => {
1143
+ eventsHub.broadcast(event);
1144
+ if (!bridgeReadonly()) pushRelay.considerImported(event);
1145
+ },
1146
+ };
1147
+ const eventFeedClient = createEventFeedClient({
1148
+ loadStore: () => nodesStore.loadStore(nodesPath),
1149
+ token: () => tokenHolder.value,
1150
+ eventsHub: importHub,
1151
+ log: opts.log || console.log,
1152
+ pollMs: cfg.eventFeedClientPollMs,
1153
+ });
1154
+ eventFeedClient.start();
1155
+ // Local aggregated read for the UI: connected views only, with the cursor
1156
+ // watermark so the first fetch cannot race the live stream. Local
1157
+ // authenticated route, NOT in the federation allowlist.
1158
+ api.get('/feed-state', (_req, res) => {
1159
+ try { res.json(eventFeedClient.state()); }
1160
+ catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1161
+ });
1162
+
1163
+ // Local, authenticated observability for the feed (doctor / nodes show feed
1164
+ // it). Deliberately NOT in the federation allowlist: diagnostics stay local.
1165
+ api.get('/diagnostics/events', (_req, res) => {
1166
+ try {
1167
+ // Read-only alert-relay counters for the peers the operator is looking
1168
+ // at: numbers and reasons only — never an endpoint, a subscription or a
1169
+ // key, and nothing here opens a connection anywhere.
1170
+ const relayStatus = pushRelay.status();
1171
+ const pushRelaySection = {
1172
+ global: { ...relayStatus.counters, queued: relayStatus.queued, active: relayStatus.active },
1173
+ byOwner: relayStatus.byOwner,
1174
+ };
1175
+ const store = nodesStore.loadStore(nodesPath);
1176
+ const peers = [];
1177
+ if (store) {
1178
+ for (const node of store.nodes) {
1179
+ if (!node || node.direction !== 'inbound' || !node.nodeId) continue;
1180
+ const grants = eventFeedAcl.peerGrants(store, node.nodeId);
1181
+ peers.push({
1182
+ name: node.name,
1183
+ nodeId: node.nodeId,
1184
+ accessLabel: grants ? grants.label : 'unknown',
1185
+ grants: grants ? {
1186
+ eventsAccess: grants.eventsAccess,
1187
+ nodeEventsAccess: grants.nodeEventsAccess,
1188
+ cellVisibility: grants.cellVisibility,
1189
+ cells: grants.cells,
1190
+ configured: grants.configured,
1191
+ } : null,
1192
+ accessRevision: nodesStore.accessRevisionOf(store),
1193
+ feed: eventFeed.status(node.nodeId),
1194
+ });
1195
+ }
1196
+ }
1197
+ res.json({ enabled: eventsEnabled(), peers, pushRelay: pushRelaySection });
1198
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1199
+ });
1200
+ api.use('/fleet', fleetRoutes(fleetP, {
1201
+ ...cfg, diagnostics,
1202
+ // Fleet state changes are events too: the owner resolves the cell and
1203
+ // publishes the normalized delta; grants are applied per peer on publish.
1204
+ onFleetStateChange: (delta) => {
1205
+ try {
1206
+ notifier.publishState('fleet-state', {
1207
+ cells: [{ cell: delta && delta.cell, active: delta && delta.active === true }],
1208
+ ts: Date.now(),
1209
+ });
1210
+ } catch (_) { /* observability must never break the action */ }
1211
+ },
1212
+ }));
1213
+ // Fetta 2b: superficie child del lease Live via canale nativo del
896
1214
  // bridge (HTTP loopback + Bearer). La cella e' derivata dalla sessione; il
897
1215
  // proof firmato dal verifier per-installazione autorizza refresh/recovery.
898
1216
  api.use('/lease', leaseRoutes({
@@ -972,16 +1290,20 @@ function createServer(opts = {}) {
972
1290
  })),
973
1291
  readonly: proxyReadonly,
974
1292
  }));
975
- // Cella ospite Live (contratto rev6 §2): hostCell unico per nodo con CAS. Lo store
976
- // vive accanto al token (stessa dir isolata nei test); il proxy nega /api/live-host
977
- // via /node (local-only), ma la via /api/route la instrada (0.9.1) dietro un
978
- // permesso per-peer negato di default (liveHostAccess, v. lib/proxy/federation.js).
1293
+ // Live host cell: one hostCell per node with CAS. The store
1294
+ // lives next to the token (same isolated dir in tests); the proxy refuses
1295
+ // /api/live-host via /node (local-only), but the /api/route path routes it
1296
+ // behind a per-peer permission denied by default (liveHostAccess).
979
1297
  // Fetta 3: il ponte risolve il puntamento all'avvio di una Live (POST /bridge
980
1298
  // nello stesso gruppo, stessa auth, stesso local-only). Isolabile da cfg
981
- // (rev5 MC0): spento non crea connessioni e la Live resta standard.
1299
+ // (rev5): spento non crea connessioni e la Live resta standard.
1300
+ // One store instance serves both sides of a remote designation: the routes
1301
+ // (owner side) and the forwarder that records the pointer to a peer (client
1302
+ // side). Two instances over the same file would work, one is honest.
1303
+ const liveHostStore = createLiveHostStore({ filePath: liveHostPath(cfg) });
982
1304
  api.use('/live-host', liveHostRoutes({
983
1305
  fleetP,
984
- store: createLiveHostStore({ filePath: liveHostPath(cfg) }),
1306
+ store: liveHostStore,
985
1307
  readonly: proxyReadonly,
986
1308
  bridge: createLiveBridge({
987
1309
  cfg,
@@ -990,6 +1312,18 @@ function createServer(opts = {}) {
990
1312
  filesRoot: cfg.filesRoot,
991
1313
  log: opts.log || console.log,
992
1314
  }),
1315
+ // Client side: read the owner's state and forward the bridge resolution to
1316
+ // the node the designation points at. Owner side: accept a forwarded
1317
+ // resolution behind the same per-peer gate and the same rate limiting as
1318
+ // the other federated routes.
1319
+ federation: createLiveHostFederation({
1320
+ localNodeId: federatedNodeId,
1321
+ peers: federatedPeers,
1322
+ localPort: () => (server && server.address() ? server.address().port : cfg.port),
1323
+ localToken: () => tokenHolder.value,
1324
+ }),
1325
+ originResolver: createOriginResolver({ localNodeId: federatedNodeId, hopSecret: () => hopSecret }),
1326
+ federatedRate: createSpeakRateLimiter(),
993
1327
  }));
994
1328
  // Pannello per-cella: montato con il suo ingresso dedicato (ticket+cookie)
995
1329
  // PRIMA del requireToken generale — vedi il blocco sopra, dove nascono
@@ -999,7 +1333,7 @@ function createServer(opts = {}) {
999
1333
  }));
1000
1334
  api.use('/decks', decksRoutes({ cfg, decksPath }));
1001
1335
  api.use('/fs', fsRoutes({ home: os.homedir() })); // folder-picker del dialog new session
1002
- // /nodes (read-only, per la settings UI B2): stesso formato di `nodes list --json`
1336
+ // /nodes (read-only, per la settings UI): stesso formato di `nodes list --json`
1003
1337
  // (token SEMPRE redatti via redactStore) + health federato per-nodo. Il campo
1004
1338
  // `tunnel` resta per retro-compatibilita' col frontend (derivato dal health).
1005
1339
  // health = { transport, auth, reachability, status, detail, managed, at }
@@ -1021,9 +1355,16 @@ function createServer(opts = {}) {
1021
1355
  // (lib/nodes/peer-transitions.js) — nessun probe in piu', nessun
1022
1356
  // record se il peer resta invariato.
1023
1357
  recordPeerTransition(n.name, h, diagnostics);
1024
- return { ...n, tunnel: nodesHealth.tunnelFromHealth(h), health: h };
1358
+ // Federated access read view: derived label,
1359
+ // configured marker and the effective grant vector. `view.nodes[i]`
1360
+ // is the redacted twin of `st.nodes[i]` (same order, redaction keeps
1361
+ // tokens out), while grants are decisions, not secrets, so they are
1362
+ // derived from the raw record and never contain a credential.
1363
+ return { ...n, tunnel: nodesHealth.tunnelFromHealth(h), health: h, ...accessPresets.accessView(st.nodes[i]) };
1025
1364
  });
1026
- res.json({ nodeId: view.nodeId, nodes });
1365
+ // Store-root revision of the peer access policy: the CAS value the UI
1366
+ // must send back with every preset write (stale revision -> 409).
1367
+ res.json({ nodeId: view.nodeId, nodes, accessRevision: nodesStore.accessRevisionOf(st) });
1027
1368
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1028
1369
  });
1029
1370
  // Canonical management inventory: direct hubs/clients plus routed peers. The
@@ -1033,7 +1374,13 @@ function createServer(opts = {}) {
1033
1374
  try {
1034
1375
  const st = nodesStore.loadStore(nodesPath);
1035
1376
  if (!st) return res.json({ nodeId: null, peers: [] });
1036
- const direct = nodesStore.redactStore(st).nodes;
1377
+ // Same access read view as /api/nodes: the settings UI consumes this
1378
+ // endpoint, so direct peers carry the derived label, the configured
1379
+ // marker and the effective grants here too (the inventory spreads the
1380
+ // record as-is). No duplication: one helper, two endpoints.
1381
+ const direct = nodesStore.redactStore(st).nodes.map(
1382
+ (n, i) => ({ ...n, ...accessPresets.accessView(st.nodes[i]) }),
1383
+ );
1037
1384
  const healths = await nodesHealth.nodesHealth({
1038
1385
  nodes: st.nodes, home: cfg.home || os.homedir(), fetchImpl: healthFetch, now: Date.now(),
1039
1386
  failureThreshold: cfg.nodeHealthFailureThreshold,
@@ -1054,12 +1401,12 @@ function createServer(opts = {}) {
1054
1401
  topology: topology && Array.isArray(topology.nodes) ? topology.nodes : [],
1055
1402
  extras,
1056
1403
  });
1057
- res.json({ nodeId: st.nodeId, peers });
1404
+ res.json({ nodeId: st.nodeId, peers, accessRevision: nodesStore.accessRevisionOf(st) });
1058
1405
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1059
1406
  });
1060
- // Settings API B2 (design §4b(6)): read-only GET + mutanti lista chiusa per il
1061
- // wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
1062
- // READONLY route-level e la redazione token vivono dentro settingsRoutes.
1407
+ // Settings API: read-only GET + a closed list of mutators for the
1408
+ // wizard/settings UI. Behind the same requireToken of the /api router; the
1409
+ // route-level READONLY gate and token redaction live inside settingsRoutes.
1063
1410
  api.use('/settings', settingsRoutes({
1064
1411
  cfg, nodesPath, tokenStore, closeSessions, updater, runtimePort,
1065
1412
  // Stesso adapter/coda dell'API Audio: Settings puo' fare solo la prova
@@ -1075,6 +1422,12 @@ function createServer(opts = {}) {
1075
1422
  api.use('/route', federation.localRouter({
1076
1423
  nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
1077
1424
  hopSecret: () => hopSecret,
1425
+ // A designation forwarded from here to a peer is recorded by the answer
1426
+ // the peer gives, not by a local guess: see liveHostLedgerCapture.
1427
+ liveHostLedger: {
1428
+ recordRemote: (entry) => liveHostStore.setRemoteDesignation(entry),
1429
+ clearRemote: (ownerId) => liveHostStore.clearForOwner(ownerId),
1430
+ },
1078
1431
  }));
1079
1432
  api.get('/voice/status', (_req, res) => res.json({ serverSttConfigured: !!cfg.voiceUrl }));
1080
1433
  api.post('/voice/transcribe',
@@ -1085,10 +1438,10 @@ function createServer(opts = {}) {
1085
1438
  res.json({ text: out.text || '' });
1086
1439
  } catch (e) { res.status(e.status || 502).json({ error: e.message }); }
1087
1440
  });
1088
- // SSE eventi UI (notify/ask, MCP bridge §2a): EventSource non puo' settare
1089
- // header -> il token e' accettato anche in query, SOLO perche' il bind e'
1090
- // loopback-only (stesso pattern dell'upgrade WS del proxy /node). Montata
1091
- // PRIMA del router /api (che e' Bearer-only) e sempre sul token live.
1441
+ // SSE UI events (notify/ask, MCP bridge): EventSource cannot set
1442
+ // headers -> the token is accepted in query too, ONLY because the bind is
1443
+ // loopback-only (same pattern as the /node proxy WS upgrade). Mounted
1444
+ // BEFORE the /api router (which is Bearer-only) and always on the live token.
1092
1445
  app.get('/api/events', (req, res) => {
1093
1446
  const given = bearerFrom(req) || String(req.query.token || '');
1094
1447
  if (!verify(tokenHolder.value, given)) {
@@ -1105,16 +1458,16 @@ function createServer(opts = {}) {
1105
1458
  diagnostics,
1106
1459
  }));
1107
1460
 
1108
- // Reverse-proxy single-origin /node/<name>/… (design §4b(2)). Auth locale PRIMA
1109
- // di risolvere <name>: requireToken(token) davanti al router, nessuna route
1110
- // proxy montata prima del middleware auth. Montato PRIMA dello static/catch-all.
1461
+ // Single-origin reverse proxy for /node/<name>/… . Local auth FIRST
1462
+ // before resolving <name>: requireToken(token) in front of the router, no proxy
1463
+ // route mounted before the auth middleware. Mounted BEFORE the static/catch-all.
1111
1464
  app.use('/node', requireToken(tokenStore), createNodeProxy({ resolveNode, readonly: proxyReadonly }));
1112
1465
 
1113
1466
  app.use(express.static(distDir));
1114
- // Deck multi-finestra (§5b): /deck/<name> serve la STESSA SPA (stesso origin,
1115
- // stesso token via fragment). <name> e' una chiave strict client-side, mai usata
1116
- // per costruire path: validazione ^[a-z0-9-]{1,32}$, nome invalido → 404 secco
1117
- // (niente traversal, niente fallback silenzioso alla SPA su nomi sporchi).
1467
+ // Multi-window deck: /deck/<name> serves the SAME SPA (same origin,
1468
+ // same token via fragment). <name> is a strict client-side key, never used
1469
+ // to build paths: validation ^[a-z0-9-]{1,32}$, invalid name -> hard 404
1470
+ // (no traversal, no silent SPA fallback on dirty names).
1118
1471
  const DECK_NAME_RE = /^[a-z0-9-]{1,32}$/;
1119
1472
  const DECK_OWNER_RE = /^[a-f0-9]{16,64}$/;
1120
1473
  // Cattura TUTTO cio' che sta sotto /deck/ (anche slash/segmenti extra o encoded)
@@ -1238,9 +1591,9 @@ function createServer(opts = {}) {
1238
1591
  wss.on('connection', (ws, req) => {
1239
1592
  ws.isAlive = true;
1240
1593
  ws.on('pong', () => { ws.isAlive = true; });
1241
- // Preauth via header (B2 attach remoto): quando l'upgrade arriva dal proxy
1594
+ // Preauth via header (attach remoto): quando l'upgrade arriva dal proxy
1242
1595
  // /node/<name> di un hub, il proxy ha gia' iniettato `Authorization: Bearer
1243
- // <token di QUESTO nodo>` (§4b(2)#3) mentre il frame attach porta il token
1596
+ // of THIS node> while the attach frame carries the token
1244
1597
  // del hub. Un Bearer valido sull'upgrade vale come auth: e' lo STESSO
1245
1598
  // verify dello stesso token locale, solo su un canale diverso. I browser
1246
1599
  // non possono settare header sui WS -> il flusso locale resta identico
@@ -1285,6 +1638,9 @@ function createServer(opts = {}) {
1285
1638
  return {
1286
1639
  app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP, updater, diagnostics,
1287
1640
  panelApp, panelServer, panelServerV6,
1641
+ // Test/observability seam: publish a normalized state event through the
1642
+ // same per-peer projection the local producers use.
1643
+ notify: { publishState: notifier.publishState },
1288
1644
  };
1289
1645
  }
1290
1646
 
@@ -1298,7 +1654,7 @@ function start(opts = {}) {
1298
1654
  if (typeof opts.onListenError === 'function') return opts.onListenError(error);
1299
1655
  throw error;
1300
1656
  };
1301
- // Porta pannello (P0 sicurezza 2026-08-16): scelta libera con lo STESSO
1657
+ // Porta pannello (sicurezza 2026-08-16): scelta libera con lo STESSO
1302
1658
  // meccanismo di fallback della principale, ma piu' leggero — nessun peer
1303
1659
  // dipende ancora da un valore stabile persistito (a differenza di cfg.port,
1304
1660
  // che i tunnel pairati referenziano), quindi niente scrittura su
@@ -302,7 +302,7 @@ function createPairHandler(deps) {
302
302
  // reversePort resta negoziata per un futuro Share opt-in, ma il builder
303
303
  // non emette -R finche' shared non diventa true.
304
304
  //
305
- // Porta pannello del peer (P0 stessa-origin, meta' remota): se il peer
305
+ // Porta pannello del peer (stessa-origin, meta' remota): se il peer
306
306
  // l'ha annunciata nel join, si riserva una controparte locale e la
307
307
  // coppia entra nel record — il supervisor finale inoltrera' entrambe.
308
308
  // Nessuna porta disponibile NON fa fallire il pairing: il pannello e