@mmmbuto/nexuscrew 0.9.28 → 0.9.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/docs/LIVE_PROMPT.md +1 -1
- package/docs/NOTIFICATIONS.md +37 -0
- package/docs/OPERATIONS.md +20 -0
- package/frontend/dist/assets/index-BbcjoZS0.js +167 -0
- package/frontend/dist/assets/index-DqCfZFGi.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +91 -10
- package/frontend/dist/version.json +1 -1
- package/lib/audio/groups.js +1 -1
- package/lib/auth/token.js +4 -4
- package/lib/cli/commands.js +70 -15
- package/lib/cli/doctor.js +5 -5
- package/lib/cli/fleet-service.js +7 -7
- package/lib/cli/init.js +55 -31
- package/lib/cli/pidfile.js +1 -1
- package/lib/cli/service.js +124 -8
- package/lib/cli/stable-alias.js +4 -4
- package/lib/cli/url.js +1 -1
- package/lib/config.js +36 -6
- package/lib/files/routes.js +1 -1
- package/lib/fleet/boot.js +2 -2
- package/lib/fleet/builtin.js +15 -15
- package/lib/fleet/causes.js +1 -1
- package/lib/fleet/cell-exec.js +23 -23
- package/lib/fleet/cell-hold.js +6 -6
- package/lib/fleet/cell-lease-server.js +106 -106
- package/lib/fleet/cell-lease.js +21 -21
- package/lib/fleet/definitions.js +10 -10
- package/lib/fleet/identity-authority.js +1 -1
- package/lib/fleet/launch-broker.js +13 -13
- package/lib/fleet/launch.js +15 -15
- package/lib/fleet/lease-client.js +23 -23
- package/lib/fleet/lease-routes.js +3 -3
- package/lib/fleet/lease-verifier.js +22 -22
- package/lib/fleet/managed.js +35 -35
- package/lib/fleet/prompt-delivery.js +13 -13
- package/lib/fleet/routes.js +15 -10
- package/lib/fleet/runtime.js +24 -24
- package/lib/live-host/bridge.js +53 -43
- package/lib/live-host/federation.js +97 -0
- package/lib/live-host/routes.js +166 -8
- package/lib/live-host/store.js +44 -6
- package/lib/mcp/server.js +14 -14
- package/lib/mcp/tools.js +5 -5
- package/lib/nodes/access-presets.js +258 -0
- package/lib/nodes/commands.js +198 -4
- package/lib/nodes/store.js +152 -9
- package/lib/nodes/tunnel.js +7 -7
- package/lib/notify/ask-answer-service.js +184 -0
- package/lib/notify/ask-receipts.js +197 -0
- package/lib/notify/ask-relay.js +152 -0
- package/lib/notify/asks.js +28 -3
- package/lib/notify/event-feed-acl.js +95 -0
- package/lib/notify/event-feed-asks-routes.js +144 -0
- package/lib/notify/event-feed-client.js +434 -0
- package/lib/notify/event-feed-history.js +115 -0
- package/lib/notify/event-feed-producers.js +175 -0
- package/lib/notify/event-feed-routes.js +254 -0
- package/lib/notify/event-feed.js +328 -0
- package/lib/notify/events.js +1 -1
- package/lib/notify/notifier.js +3 -3
- package/lib/notify/push-relay.js +240 -0
- package/lib/notify/push.js +8 -8
- package/lib/notify/routes.js +62 -23
- package/lib/proxy/federation.js +132 -12
- package/lib/proxy/node-proxy.js +13 -13
- package/lib/proxy/panel-auth.js +4 -4
- package/lib/proxy/panel-proxy.js +2 -2
- package/lib/proxy/resource-acl.js +145 -0
- package/lib/server.js +396 -40
- package/lib/settings/pairing-coordinator.js +1 -1
- package/lib/settings/routes.js +45 -32
- package/lib/update/manager.js +1 -1
- package/lib/update/runner.js +1 -1
- package/lib/voice/transcribe.js +1 -1
- package/lib/ws/bridge.js +3 -3
- package/package.json +1 -1
- package/skills/live/SKILL.md +12 -0
- package/frontend/dist/assets/index-CWra6B79.js +0 -167
- package/frontend/dist/assets/index-D6CIfCjv.css +0 -32
package/lib/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)
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
|
849
|
-
//
|
|
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
|
-
|
|
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' (
|
|
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('/
|
|
895
|
-
|
|
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
|
-
//
|
|
976
|
-
//
|
|
977
|
-
// via /node (local-only),
|
|
978
|
-
//
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1061
|
-
// wizard/settings UI.
|
|
1062
|
-
//
|
|
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
|
|
1089
|
-
//
|
|
1090
|
-
// loopback-only (
|
|
1091
|
-
//
|
|
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
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
//
|
|
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
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
// (
|
|
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 (
|
|
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
|
-
//
|
|
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 (
|
|
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 (
|
|
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
|