@mmmbuto/nexuscrew 0.9.29 → 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 +9 -0
- package/docs/LIVE_PROMPT.md +1 -1
- package/docs/NOTIFICATIONS.md +37 -0
- package/frontend/dist/assets/index-BbcjoZS0.js +167 -0
- package/frontend/dist/assets/{index-CywSos2e.css → index-DqCfZFGi.css} +1 -1
- 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 +59 -12
- package/lib/cli/doctor.js +5 -5
- package/lib/cli/fleet-service.js +7 -7
- package/lib/cli/init.js +27 -27
- package/lib/cli/pidfile.js +1 -1
- package/lib/cli/service.js +6 -6
- 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 +36 -36
- package/lib/live-host/routes.js +2 -2
- package/lib/live-host/store.js +4 -4
- 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 +74 -3
- 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 +372 -39
- 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/frontend/dist/assets/index-B_THfiL5.js +0 -167
package/lib/server.js
CHANGED
|
@@ -32,6 +32,7 @@ const { createLiveBridge } = require('./live-host/bridge.js');
|
|
|
32
32
|
const { createLiveHostFederation } = require('./live-host/federation.js');
|
|
33
33
|
const { fsRoutes } = require('./fs/routes.js');
|
|
34
34
|
const nodesStore = require('./nodes/store.js');
|
|
35
|
+
const accessPresets = require('./nodes/access-presets.js');
|
|
35
36
|
const nodesTunnel = require('./nodes/tunnel.js');
|
|
36
37
|
const nodesHealth = require('./nodes/health.js');
|
|
37
38
|
const { recordPeerTransition } = require('./nodes/peer-transitions.js');
|
|
@@ -67,8 +68,25 @@ const decksStore = require('./decks/store.js');
|
|
|
67
68
|
const { decksRoutes } = require('./decks/routes.js');
|
|
68
69
|
const { createEventsHub } = require('./notify/events.js');
|
|
69
70
|
const { createPushService } = require('./notify/push.js');
|
|
71
|
+
const { createPushRelay, createPushDedup } = require('./notify/push-relay.js');
|
|
70
72
|
const { createAsksStore } = require('./notify/asks.js');
|
|
71
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
|
+
}
|
|
72
90
|
const { notifyRoutes } = require('./notify/routes.js');
|
|
73
91
|
const { createNpmUpdater } = require('./update/manager.js');
|
|
74
92
|
const { createDiagnostics } = require('./diagnostics/store.js');
|
|
@@ -105,7 +123,7 @@ function createServer(opts = {}) {
|
|
|
105
123
|
};
|
|
106
124
|
const proxySockets = new Set();
|
|
107
125
|
// wss viene creato piu' sotto; closeSessions lo raggiunge a request-time (mai durante
|
|
108
|
-
// createServer)
|
|
126
|
+
// createServer) to close active WS sessions on token rotation.
|
|
109
127
|
let wss = null;
|
|
110
128
|
const ptyGrace = createPtyGraceStore({
|
|
111
129
|
graceMs: cfg.ptyGraceMs,
|
|
@@ -145,8 +163,166 @@ function createServer(opts = {}) {
|
|
|
145
163
|
dir: notifyDir, webpushImpl: cfg.webpushImpl,
|
|
146
164
|
readonly: bridgeReadonly, maxSubs: cfg.pushMaxSubs, lookupImpl: cfg.pushLookupImpl,
|
|
147
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
|
+
});
|
|
148
176
|
const asksStore = createAsksStore({ dir: notifyDir });
|
|
149
|
-
|
|
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
|
+
};
|
|
150
326
|
const diagnostics = opts.diagnostics || createDiagnostics();
|
|
151
327
|
// Contatore cadute WS (rolling 10 min): alimenta i meta dei log di bridge.
|
|
152
328
|
const wsDropCounter = createDropCounter();
|
|
@@ -172,7 +348,7 @@ function createServer(opts = {}) {
|
|
|
172
348
|
const identityOwnerInstanceId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
|
|
173
349
|
const fleetP = selectProvider({ ...cfg, ensureTmuxProtection, identityOwnerInstanceId }).then((p) => p.fleet);
|
|
174
350
|
|
|
175
|
-
// Multi-node
|
|
351
|
+
// Multi-node: nodes.json e' la fonte dati. Il proxy risolve <name>
|
|
176
352
|
// -> {localPort, token} leggendo lo store ad ogni richiesta (fresh: rotazione
|
|
177
353
|
// token / add-remove nodi visibili senza restart). token MAI redatto qui: e'
|
|
178
354
|
// il valore che il proxy inietta upstream, non esce mai verso il browser.
|
|
@@ -546,7 +722,7 @@ function createServer(opts = {}) {
|
|
|
546
722
|
}
|
|
547
723
|
|
|
548
724
|
const app = express();
|
|
549
|
-
// CSP frame-ancestors (
|
|
725
|
+
// CSP frame-ancestors (sicurezza 2026-08-16, rimedio 4 del report): la
|
|
550
726
|
// pagina dell'app non deve poter essere incorporata in un iframe altrui —
|
|
551
727
|
// clickjacking sul control plane. Ortogonale alla porta pannello: quello
|
|
552
728
|
// difende CHI E' NEL FRAME, questo difende l'app dall'ESSERE il frame.
|
|
@@ -642,7 +818,7 @@ function createServer(opts = {}) {
|
|
|
642
818
|
cell: entry.cell, reason: entry.reason, state: entry.outcome,
|
|
643
819
|
}),
|
|
644
820
|
}));
|
|
645
|
-
// Porta pannello dedicata (
|
|
821
|
+
// Porta pannello dedicata (sicurezza 2026-08-16): un iframe
|
|
646
822
|
// same-origin senza sandbox legge il localStorage del padre — il JS di un
|
|
647
823
|
// pannello ostile puo' prendersi il token e agire come l'operatore. La
|
|
648
824
|
// porta fa parte dell'origin: spostando SOLO il consumo (ticket in query,
|
|
@@ -846,8 +1022,8 @@ function createServer(opts = {}) {
|
|
|
846
1022
|
instanceId: apiNodeId,
|
|
847
1023
|
identityMode,
|
|
848
1024
|
}));
|
|
849
|
-
// MCP bridge
|
|
850
|
-
//
|
|
1025
|
+
// MCP bridge: /notify, /push/*, /asks — behind the same Bearer
|
|
1026
|
+
// of the /api router; READONLY gate on the mutators inside notifyRoutes.
|
|
851
1027
|
// Identita' e instradamento condivisi con l'audio: stessa fonte (node store),
|
|
852
1028
|
// stesso segreto di hop, stessa inventory. Due modelli di provenienza
|
|
853
1029
|
// divergenti sarebbero un bug latente.
|
|
@@ -866,8 +1042,9 @@ function createServer(opts = {}) {
|
|
|
866
1042
|
notifier,
|
|
867
1043
|
push: pushSvc,
|
|
868
1044
|
asks: asksStore,
|
|
869
|
-
paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
|
|
870
|
-
|
|
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)),
|
|
871
1048
|
fleetP,
|
|
872
1049
|
instanceId: apiNodeId,
|
|
873
1050
|
identityMode,
|
|
@@ -884,16 +1061,156 @@ function createServer(opts = {}) {
|
|
|
884
1061
|
peers: federatedPeers,
|
|
885
1062
|
localPort: () => (server && server.address() ? server.address().port : cfg.port),
|
|
886
1063
|
localToken: () => tokenHolder.value,
|
|
887
|
-
// 'no-delivery' (
|
|
1064
|
+
// 'no-delivery' (-): esito legittimo del target — richiesta accettata
|
|
888
1065
|
// ma nessun canale raggiunto (0 UI, 0 push). Senza questa voce forward()
|
|
889
1066
|
// lo degraderebbe a 'unknown/unreadable-endpoint-result', nascondendo
|
|
890
1067
|
// proprio il silenzio che l'esito esiste per rivelare.
|
|
891
1068
|
statuses: new Set(['delivered', 'no-delivery', 'refused', 'unreachable', 'unknown']),
|
|
892
1069
|
}),
|
|
893
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,
|
|
1090
|
+
}));
|
|
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
|
+
},
|
|
894
1212
|
}));
|
|
895
|
-
|
|
896
|
-
// Fetta 2b (D3): superficie child del lease Live via canale nativo del
|
|
1213
|
+
// Fetta 2b: superficie child del lease Live via canale nativo del
|
|
897
1214
|
// bridge (HTTP loopback + Bearer). La cella e' derivata dalla sessione; il
|
|
898
1215
|
// proof firmato dal verifier per-installazione autorizza refresh/recovery.
|
|
899
1216
|
api.use('/lease', leaseRoutes({
|
|
@@ -973,13 +1290,13 @@ function createServer(opts = {}) {
|
|
|
973
1290
|
})),
|
|
974
1291
|
readonly: proxyReadonly,
|
|
975
1292
|
}));
|
|
976
|
-
//
|
|
977
|
-
//
|
|
978
|
-
// via /node (local-only),
|
|
979
|
-
//
|
|
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).
|
|
980
1297
|
// Fetta 3: il ponte risolve il puntamento all'avvio di una Live (POST /bridge
|
|
981
1298
|
// nello stesso gruppo, stessa auth, stesso local-only). Isolabile da cfg
|
|
982
|
-
// (rev5
|
|
1299
|
+
// (rev5): spento non crea connessioni e la Live resta standard.
|
|
983
1300
|
// One store instance serves both sides of a remote designation: the routes
|
|
984
1301
|
// (owner side) and the forwarder that records the pointer to a peer (client
|
|
985
1302
|
// side). Two instances over the same file would work, one is honest.
|
|
@@ -1016,7 +1333,7 @@ function createServer(opts = {}) {
|
|
|
1016
1333
|
}));
|
|
1017
1334
|
api.use('/decks', decksRoutes({ cfg, decksPath }));
|
|
1018
1335
|
api.use('/fs', fsRoutes({ home: os.homedir() })); // folder-picker del dialog new session
|
|
1019
|
-
// /nodes (read-only, per la settings UI
|
|
1336
|
+
// /nodes (read-only, per la settings UI): stesso formato di `nodes list --json`
|
|
1020
1337
|
// (token SEMPRE redatti via redactStore) + health federato per-nodo. Il campo
|
|
1021
1338
|
// `tunnel` resta per retro-compatibilita' col frontend (derivato dal health).
|
|
1022
1339
|
// health = { transport, auth, reachability, status, detail, managed, at }
|
|
@@ -1038,9 +1355,16 @@ function createServer(opts = {}) {
|
|
|
1038
1355
|
// (lib/nodes/peer-transitions.js) — nessun probe in piu', nessun
|
|
1039
1356
|
// record se il peer resta invariato.
|
|
1040
1357
|
recordPeerTransition(n.name, h, diagnostics);
|
|
1041
|
-
|
|
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]) };
|
|
1042
1364
|
});
|
|
1043
|
-
|
|
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) });
|
|
1044
1368
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1045
1369
|
});
|
|
1046
1370
|
// Canonical management inventory: direct hubs/clients plus routed peers. The
|
|
@@ -1050,7 +1374,13 @@ function createServer(opts = {}) {
|
|
|
1050
1374
|
try {
|
|
1051
1375
|
const st = nodesStore.loadStore(nodesPath);
|
|
1052
1376
|
if (!st) return res.json({ nodeId: null, peers: [] });
|
|
1053
|
-
|
|
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
|
+
);
|
|
1054
1384
|
const healths = await nodesHealth.nodesHealth({
|
|
1055
1385
|
nodes: st.nodes, home: cfg.home || os.homedir(), fetchImpl: healthFetch, now: Date.now(),
|
|
1056
1386
|
failureThreshold: cfg.nodeHealthFailureThreshold,
|
|
@@ -1071,12 +1401,12 @@ function createServer(opts = {}) {
|
|
|
1071
1401
|
topology: topology && Array.isArray(topology.nodes) ? topology.nodes : [],
|
|
1072
1402
|
extras,
|
|
1073
1403
|
});
|
|
1074
|
-
res.json({ nodeId: st.nodeId, peers });
|
|
1404
|
+
res.json({ nodeId: st.nodeId, peers, accessRevision: nodesStore.accessRevisionOf(st) });
|
|
1075
1405
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1076
1406
|
});
|
|
1077
|
-
// Settings API
|
|
1078
|
-
// wizard/settings UI.
|
|
1079
|
-
//
|
|
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.
|
|
1080
1410
|
api.use('/settings', settingsRoutes({
|
|
1081
1411
|
cfg, nodesPath, tokenStore, closeSessions, updater, runtimePort,
|
|
1082
1412
|
// Stesso adapter/coda dell'API Audio: Settings puo' fare solo la prova
|
|
@@ -1108,10 +1438,10 @@ function createServer(opts = {}) {
|
|
|
1108
1438
|
res.json({ text: out.text || '' });
|
|
1109
1439
|
} catch (e) { res.status(e.status || 502).json({ error: e.message }); }
|
|
1110
1440
|
});
|
|
1111
|
-
// SSE
|
|
1112
|
-
//
|
|
1113
|
-
// loopback-only (
|
|
1114
|
-
//
|
|
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.
|
|
1115
1445
|
app.get('/api/events', (req, res) => {
|
|
1116
1446
|
const given = bearerFrom(req) || String(req.query.token || '');
|
|
1117
1447
|
if (!verify(tokenHolder.value, given)) {
|
|
@@ -1128,16 +1458,16 @@ function createServer(opts = {}) {
|
|
|
1128
1458
|
diagnostics,
|
|
1129
1459
|
}));
|
|
1130
1460
|
|
|
1131
|
-
//
|
|
1132
|
-
//
|
|
1133
|
-
//
|
|
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.
|
|
1134
1464
|
app.use('/node', requireToken(tokenStore), createNodeProxy({ resolveNode, readonly: proxyReadonly }));
|
|
1135
1465
|
|
|
1136
1466
|
app.use(express.static(distDir));
|
|
1137
|
-
//
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
// (
|
|
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).
|
|
1141
1471
|
const DECK_NAME_RE = /^[a-z0-9-]{1,32}$/;
|
|
1142
1472
|
const DECK_OWNER_RE = /^[a-f0-9]{16,64}$/;
|
|
1143
1473
|
// Cattura TUTTO cio' che sta sotto /deck/ (anche slash/segmenti extra o encoded)
|
|
@@ -1261,9 +1591,9 @@ function createServer(opts = {}) {
|
|
|
1261
1591
|
wss.on('connection', (ws, req) => {
|
|
1262
1592
|
ws.isAlive = true;
|
|
1263
1593
|
ws.on('pong', () => { ws.isAlive = true; });
|
|
1264
|
-
// Preauth via header (
|
|
1594
|
+
// Preauth via header (attach remoto): quando l'upgrade arriva dal proxy
|
|
1265
1595
|
// /node/<name> di un hub, il proxy ha gia' iniettato `Authorization: Bearer
|
|
1266
|
-
//
|
|
1596
|
+
// of THIS node> while the attach frame carries the token
|
|
1267
1597
|
// del hub. Un Bearer valido sull'upgrade vale come auth: e' lo STESSO
|
|
1268
1598
|
// verify dello stesso token locale, solo su un canale diverso. I browser
|
|
1269
1599
|
// non possono settare header sui WS -> il flusso locale resta identico
|
|
@@ -1308,6 +1638,9 @@ function createServer(opts = {}) {
|
|
|
1308
1638
|
return {
|
|
1309
1639
|
app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP, updater, diagnostics,
|
|
1310
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 },
|
|
1311
1644
|
};
|
|
1312
1645
|
}
|
|
1313
1646
|
|
|
@@ -1321,7 +1654,7 @@ function start(opts = {}) {
|
|
|
1321
1654
|
if (typeof opts.onListenError === 'function') return opts.onListenError(error);
|
|
1322
1655
|
throw error;
|
|
1323
1656
|
};
|
|
1324
|
-
// Porta pannello (
|
|
1657
|
+
// Porta pannello (sicurezza 2026-08-16): scelta libera con lo STESSO
|
|
1325
1658
|
// meccanismo di fallback della principale, ma piu' leggero — nessun peer
|
|
1326
1659
|
// dipende ancora da un valore stabile persistito (a differenza di cfg.port,
|
|
1327
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 (
|
|
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
|