@mmmbuto/nexuscrew 0.9.63 → 0.9.65
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 +14 -0
- package/README.md +9 -21
- package/docs/NOTIFICATIONS.md +34 -0
- package/docs/SECURITY.md +13 -12
- package/frontend/dist/assets/index-DkYRC1vV.js +169 -0
- package/frontend/dist/assets/{index-D1cTtNED.css → index-Dwd37Z_R.css} +2 -2
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cells/routes.js +37 -3
- package/lib/cli/commands.js +1 -1
- package/lib/config.js +1 -1
- package/lib/fleet/builtin.js +3 -0
- package/lib/fleet/cell-lease-server.js +1 -1
- package/lib/fleet/cell-lease.js +1 -1
- package/lib/fleet/lease-client.js +1 -1
- package/lib/fleet/lease-verifier.js +1 -1
- package/lib/live-host/attest.js +39 -0
- package/lib/live-host/bridge.js +81 -21
- package/lib/live-host/entry.js +49 -0
- package/lib/live-host/registry.js +111 -0
- package/lib/live-host/reserved.js +12 -0
- package/lib/mcp/cells.js +37 -0
- package/lib/mcp/server.js +7 -1
- package/lib/mcp/tools.js +7 -1
- package/lib/notify/ask-relay.js +36 -166
- package/lib/notify/event-feed-asks-routes.js +18 -3
- package/lib/notify/event-feed-client.js +85 -10
- package/lib/notify/event-feed-notices-routes.js +135 -0
- package/lib/notify/event-feed-producers.js +21 -0
- package/lib/notify/event-feed-routes.js +30 -11
- package/lib/notify/notice-dismissal-queue.js +336 -0
- package/lib/notify/notice-dismissals.js +178 -0
- package/lib/notify/notice-relay.js +128 -0
- package/lib/notify/owner-channel.js +172 -0
- package/lib/proxy/federation.js +9 -0
- package/lib/proxy/resource-acl.js +6 -0
- package/lib/server.js +229 -12
- package/lib/tmux/actions.js +1 -1
- package/package.json +2 -2
- package/frontend/dist/assets/index-maPH0v_e.js +0 -169
package/lib/server.js
CHANGED
|
@@ -31,6 +31,9 @@ const { cellsRoutes, publicCells } = require('./cells/routes.js');
|
|
|
31
31
|
const { liveHostRoutes } = require('./live-host/routes.js');
|
|
32
32
|
const { createLiveHostStore, liveHostPath } = require('./live-host/store.js');
|
|
33
33
|
const { createLiveBridge } = require('./live-host/bridge.js');
|
|
34
|
+
const { createLiveThreadRegistry, liveThreadsPath } = require('./live-host/registry.js');
|
|
35
|
+
const { createLiveAttestation } = require('./live-host/attest.js');
|
|
36
|
+
const { buildLiveEntry } = require('./live-host/entry.js');
|
|
34
37
|
const { createLiveHostFederation } = require('./live-host/federation.js');
|
|
35
38
|
const { fsRoutes } = require('./fs/routes.js');
|
|
36
39
|
const nodesStore = require('./nodes/store.js');
|
|
@@ -88,7 +91,11 @@ const { createEventFeedClient } = require('./notify/event-feed-client.js');
|
|
|
88
91
|
const { createAskReceipts } = require('./notify/ask-receipts.js');
|
|
89
92
|
const { createAskAnswerService } = require('./notify/ask-answer-service.js');
|
|
90
93
|
const { createEventFeedAsksRoutes } = require('./notify/event-feed-asks-routes.js');
|
|
94
|
+
const { createEventFeedNoticesRoutes } = require('./notify/event-feed-notices-routes.js');
|
|
95
|
+
const { createNoticeDismissals } = require('./notify/notice-dismissals.js');
|
|
91
96
|
const { createAskRelay } = require('./notify/ask-relay.js');
|
|
97
|
+
const { createNoticeRelay } = require('./notify/notice-relay.js');
|
|
98
|
+
const { createNoticeDismissalQueue, createNoticeDismissalDrainer } = require('./notify/notice-dismissal-queue.js');
|
|
92
99
|
const { createImportedDismissalDrainer } = require('./notify/imported-dismissals.js');
|
|
93
100
|
const { createAskSlotVerifier } = require('./nodes/ask-slot-verifier.js');
|
|
94
101
|
const { readFederationEventsEnabled } = require('./config.js');
|
|
@@ -218,6 +225,12 @@ function createServer(opts = {}) {
|
|
|
218
225
|
const feedHistory = createEventFeedHistory({
|
|
219
226
|
filePath: path.join(notifyDir, 'event-feed-history.json'),
|
|
220
227
|
});
|
|
228
|
+
// Notices the OWNER cleared: ids and timestamps only, expiring with the
|
|
229
|
+
// history they filter. This is what makes a cleanup true on every device —
|
|
230
|
+
// the owner's snapshot stops carrying the entry, so a reload cannot revive it.
|
|
231
|
+
const noticeDismissals = createNoticeDismissals({
|
|
232
|
+
filePath: path.join(notifyDir, 'notice-dismissals.json'),
|
|
233
|
+
});
|
|
221
234
|
// Kill switch: strict boolean, read FRESH on every use so flipping it in
|
|
222
235
|
// config.json acts on live streams without a restart.
|
|
223
236
|
const eventsEnabled = () => readFederationEventsEnabled({ configJsonPath: cfg.configPath, federationEventsEnabled: cfg.federationEventsEnabled });
|
|
@@ -1017,7 +1030,7 @@ function createServer(opts = {}) {
|
|
|
1017
1030
|
return cellScoper.resolve({ trust: 'federated', visited: [] });
|
|
1018
1031
|
}
|
|
1019
1032
|
};
|
|
1020
|
-
// Scope celle
|
|
1033
|
+
// Scope celle: un punto solo decide cosa un peer federato vede e puo'
|
|
1021
1034
|
// toccare. Sta qui, prima di ogni route, perche' i canali da cui una cella
|
|
1022
1035
|
// trapela sono molti e crescono: filtrarli uno per uno significa dimenticarne
|
|
1023
1036
|
// uno, e un permesso che vale su quasi tutti i canali non e' un permesso.
|
|
@@ -1337,6 +1350,33 @@ function createServer(opts = {}) {
|
|
|
1337
1350
|
cellForSession: ownerAskScope.cellForSession,
|
|
1338
1351
|
readonly: proxyReadonly,
|
|
1339
1352
|
}));
|
|
1353
|
+
// Owner-side notice dismissals: the same action gate and the same chain as
|
|
1354
|
+
// the ask write-back, with its own rate instance. Mounted beside the asks,
|
|
1355
|
+
// before the feed router, because it is a write-back path of the feed.
|
|
1356
|
+
api.use('/event-feed/notices', createEventFeedNoticesRoutes({
|
|
1357
|
+
nodesPath,
|
|
1358
|
+
localNodeId: federatedNodeId,
|
|
1359
|
+
originResolver: createOriginResolver({
|
|
1360
|
+
localNodeId: federatedNodeId,
|
|
1361
|
+
hopSecret: () => hopSecret,
|
|
1362
|
+
}),
|
|
1363
|
+
eventsEnabled,
|
|
1364
|
+
history: feedHistory,
|
|
1365
|
+
dismissals: noticeDismissals,
|
|
1366
|
+
// The closure inherits scope and cell of the original notice, so it is
|
|
1367
|
+
// delivered to exactly the peers that could see it. It enters the history
|
|
1368
|
+
// too: a peer that missed the live frame recovers it from the snapshot,
|
|
1369
|
+
// where toRemoteNotice drops it by type.
|
|
1370
|
+
closeNotice: ({ scope, cellId, eventId }) => {
|
|
1371
|
+
recordAndPublish({
|
|
1372
|
+
producer: scope === 'node' ? 'notify-closed-node' : 'notify-closed',
|
|
1373
|
+
cellId: scope === 'node' ? null : cellId,
|
|
1374
|
+
payload: { eventId, ts: Date.now() },
|
|
1375
|
+
});
|
|
1376
|
+
},
|
|
1377
|
+
readonly: proxyReadonly,
|
|
1378
|
+
log: opts.log || console.log,
|
|
1379
|
+
}));
|
|
1340
1380
|
api.use('/event-feed', createEventFeedRoutes({
|
|
1341
1381
|
nodesPath,
|
|
1342
1382
|
localNodeId: federatedNodeId,
|
|
@@ -1346,6 +1386,7 @@ function createServer(opts = {}) {
|
|
|
1346
1386
|
}),
|
|
1347
1387
|
eventFeed,
|
|
1348
1388
|
history: feedHistory,
|
|
1389
|
+
noticeDismissals,
|
|
1349
1390
|
asksStore,
|
|
1350
1391
|
fleetP,
|
|
1351
1392
|
cellForSession: ownerAskScope.cellForSession,
|
|
@@ -1374,6 +1415,60 @@ function createServer(opts = {}) {
|
|
|
1374
1415
|
// Il body va parsato QUI: il router `api` non monta un parser globale, ogni
|
|
1375
1416
|
// rotta porta il suo (`express.json`) — vedi /sessions, /notify, /asks.
|
|
1376
1417
|
// Senza, `req.body` e' undefined, il relay legge un body vuoto e risponde
|
|
1418
|
+
// Notice dismissals, client half: the relay that carries a dismissal to the
|
|
1419
|
+
// owner, the durable queue of the intents that could not be delivered yet, and
|
|
1420
|
+
// the drainer that retries them. Same channel as the ask answers (one
|
|
1421
|
+
// implementation of the chain), same binding as the imported dismissals: a
|
|
1422
|
+
// refusal stays blocked until the pairing to that owner changes.
|
|
1423
|
+
const noticeRelay = createNoticeRelay({
|
|
1424
|
+
nodesPath, slotVerifier: askReverseVerifier,
|
|
1425
|
+
loadStore: () => nodesStore.loadStore(nodesPath),
|
|
1426
|
+
peers: federatedPeers,
|
|
1427
|
+
localPort: () => (server && server.address() ? server.address().port : cfg.port),
|
|
1428
|
+
localToken: () => tokenHolder.value,
|
|
1429
|
+
log: opts.log || console.log,
|
|
1430
|
+
});
|
|
1431
|
+
const noticeDismissalQueue = createNoticeDismissalQueue({
|
|
1432
|
+
filePath: path.join(notifyDir, 'notice-dismissal-queue.json'),
|
|
1433
|
+
});
|
|
1434
|
+
// Durability signal for the operator's answer: the queue honours the intent
|
|
1435
|
+
// in memory when the disk refuses the write (that policy stands), but a
|
|
1436
|
+
// record that will not survive a restart is NOT the normal durable success,
|
|
1437
|
+
// so queued answers declare it instead of looking healthy. Empty = healthy.
|
|
1438
|
+
const queueDurability = () => {
|
|
1439
|
+
try {
|
|
1440
|
+
const st = noticeDismissalQueue.status();
|
|
1441
|
+
return st && st.degraded ? { durable: false, degraded: st.degraded } : {};
|
|
1442
|
+
} catch (_) { return {}; }
|
|
1443
|
+
};
|
|
1444
|
+
// One durable record per cleared notice. A refused write keeps the in-memory
|
|
1445
|
+
// truth (the intent was honoured here anyway) and only loses durability; a
|
|
1446
|
+
// refused enqueue means the intent was NOT recorded, so nothing is left behind.
|
|
1447
|
+
function enqueueNoticeDismissals(ownerId, eventIds, syncState) {
|
|
1448
|
+
const added = [];
|
|
1449
|
+
for (const eventId of eventIds) {
|
|
1450
|
+
const queued = noticeDismissalQueue.enqueue({ ownerId, eventId });
|
|
1451
|
+
if (!queued.ok) {
|
|
1452
|
+
for (const done of added) noticeDismissalQueue.confirm(ownerId, done);
|
|
1453
|
+
return false;
|
|
1454
|
+
}
|
|
1455
|
+
added.push(eventId);
|
|
1456
|
+
}
|
|
1457
|
+
if (syncState === 'blocked') {
|
|
1458
|
+
for (const eventId of eventIds) noticeDismissalQueue.update(ownerId, eventId, { syncState: 'blocked', lastReason: 'unsupported' });
|
|
1459
|
+
}
|
|
1460
|
+
return true;
|
|
1461
|
+
}
|
|
1462
|
+
const noticeDismissalDrainer = createNoticeDismissalDrainer({
|
|
1463
|
+
queue: noticeDismissalQueue, relay: noticeRelay,
|
|
1464
|
+
readonly: proxyReadonly, bindingForOwner: dismissalBinding,
|
|
1465
|
+
// Same seam as the feed client's poll: a test (or an installation with its
|
|
1466
|
+
// own rhythm) can shorten the retry interval without touching the policy.
|
|
1467
|
+
...(Number.isInteger(cfg.noticeDismissalDrainMs) && cfg.noticeDismissalDrainMs > 0
|
|
1468
|
+
? { intervalMs: cfg.noticeDismissalDrainMs } : {}),
|
|
1469
|
+
log: opts.log || console.log,
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1377
1472
|
// 400 «ownerId, askId e text richiesti» a OGNI richiesta: la risposta
|
|
1378
1473
|
// federata non ha mai potuto attraversare, e nessun test copriva questa
|
|
1379
1474
|
// rotta. Difetto preesistente, trovato scrivendo il test del ritorno federato.
|
|
@@ -1517,6 +1612,106 @@ function createServer(opts = {}) {
|
|
|
1517
1612
|
try { res.json({ attempts: askRelay.state() }); }
|
|
1518
1613
|
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1519
1614
|
});
|
|
1615
|
+
// Operator surface for the notice dismissals. LOCAL and authenticated, never in
|
|
1616
|
+
// the federation allowlist: the remote surface is the owner's (the routes under
|
|
1617
|
+
// /event-feed/notices). The intent is honoured HERE first — the card leaves this
|
|
1618
|
+
// node's view — and the owner's copy is either delivered or queued, with the
|
|
1619
|
+
// answer saying which.
|
|
1620
|
+
api.post('/notices-relay', express.json({ limit: '8kb' }), async (req, res) => {
|
|
1621
|
+
try {
|
|
1622
|
+
const b = req.body || {};
|
|
1623
|
+
if (proxyReadonly()) return res.status(403).json({ error: 'READONLY: mutation blocked' });
|
|
1624
|
+
if (req.headers[HOP_HEADER]) return res.status(403).json({ error: 'local operator required' });
|
|
1625
|
+
if (!b || Array.isArray(b) || typeof b.action !== 'string') return res.status(400).json({ error: 'invalid notices body' });
|
|
1626
|
+
if (b.action === 'dismiss') {
|
|
1627
|
+
if (Object.keys(b).some((key) => !['action', 'ownerId', 'eventId'].includes(key))
|
|
1628
|
+
|| !/^[a-f0-9]{32}$/.test(String(b.ownerId || ''))
|
|
1629
|
+
|| !/^[0-9a-f-]{36}$/.test(String(b.eventId || ''))
|
|
1630
|
+
|| b.ownerId === federatedNodeId()) {
|
|
1631
|
+
return res.status(400).json({ error: 'invalid local dismissal body' });
|
|
1632
|
+
}
|
|
1633
|
+
const out = await noticeRelay.relayNoticeDismiss({ ownerId: b.ownerId, eventId: b.eventId });
|
|
1634
|
+
if (out.ok) {
|
|
1635
|
+
if (typeof eventFeedClient.dismissNoticeConfirmed === 'function') eventFeedClient.dismissNoticeConfirmed(b.ownerId, b.eventId);
|
|
1636
|
+
return res.json({ dismissed: true, ...(out.idempotent ? { idempotent: true } : {}), ...(out.gone ? { gone: true } : {}) });
|
|
1637
|
+
}
|
|
1638
|
+
// The owner is not reachable NOW (or has no such surface at all): the
|
|
1639
|
+
// local intent stands, and the queue carries the rest. A reachable owner
|
|
1640
|
+
// that REFUSES is never queued — its answer is the decision.
|
|
1641
|
+
if (out.transient === true || out.reason === 'unsupported') {
|
|
1642
|
+
const queued = noticeDismissalQueue.enqueue({ ownerId: b.ownerId, eventId: b.eventId });
|
|
1643
|
+
if (!queued.ok) return res.status(503).json({ error: 'dismissal queue unavailable', reason: queued.reason });
|
|
1644
|
+
if (out.reason === 'unsupported') noticeDismissalQueue.update(b.ownerId, b.eventId, { syncState: 'blocked', lastReason: 'unsupported' });
|
|
1645
|
+
if (typeof eventFeedClient.dismissNoticeConfirmed === 'function') eventFeedClient.dismissNoticeConfirmed(b.ownerId, b.eventId);
|
|
1646
|
+
res.json({ dismissed: true, scope: 'local', ownerSync: out.reason === 'unsupported' ? 'blocked' : 'pending', ...queueDurability() });
|
|
1647
|
+
noticeDismissalDrainer.nudge();
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
return res.status(out.code || 500).json({ error: out.error || 'unknown', reason: out.reason });
|
|
1651
|
+
}
|
|
1652
|
+
if (b.action === 'dismiss-all') {
|
|
1653
|
+
if (Object.keys(b).some((key) => !['action', 'owners'].includes(key))
|
|
1654
|
+
|| !Array.isArray(b.owners) || b.owners.length === 0 || b.owners.length > 100
|
|
1655
|
+
|| b.owners.some((owner) => !/^[a-f0-9]{32}$/.test(String(owner || '')) || owner === federatedNodeId())) {
|
|
1656
|
+
return res.status(400).json({ error: 'invalid dismiss-all body' });
|
|
1657
|
+
}
|
|
1658
|
+
const results = {};
|
|
1659
|
+
for (const ownerId of [...new Set(b.owners)]) {
|
|
1660
|
+
// What this node is SHOWING for that owner: the whole visible set is
|
|
1661
|
+
// what the operator asked to clear, and it is the set whose local
|
|
1662
|
+
// intent has to outlive a reload or a restart of this node.
|
|
1663
|
+
const view = (eventFeedClient.state().views || []).find((v) => v.ownerId === ownerId);
|
|
1664
|
+
const affected = ((view && view.notifications) || [])
|
|
1665
|
+
.map((n) => n && n.eventId).filter((eventId) => typeof eventId === 'string' && eventId !== '');
|
|
1666
|
+
const out = await noticeRelay.relayNoticeDismissAll({ ownerId });
|
|
1667
|
+
if (out.ok) {
|
|
1668
|
+
// The owner took it: its own store stops serving these notices, so
|
|
1669
|
+
// the view forgets them here as well, exactly like the single route.
|
|
1670
|
+
for (const eventId of affected) eventFeedClient.dismissNoticeConfirmed(ownerId, eventId);
|
|
1671
|
+
results[ownerId] = { ok: true, dismissed: out.dismissed || 0 };
|
|
1672
|
+
continue;
|
|
1673
|
+
}
|
|
1674
|
+
// The owner is not reachable NOW (`pending`) or has no notices surface
|
|
1675
|
+
// at all (`blocked`): the local intent stands, durably — one queue
|
|
1676
|
+
// record per cleared notice is what keeps them out of the view on the
|
|
1677
|
+
// next snapshot — and the owner's copy is carried later, or never.
|
|
1678
|
+
const localSync = out.reason === 'unsupported' ? 'blocked' : (out.transient === true ? 'pending' : null);
|
|
1679
|
+
if (localSync === null) { results[ownerId] = { failed: out.reason || 'refused' }; continue; }
|
|
1680
|
+
if (!enqueueNoticeDismissals(ownerId, affected, localSync)) {
|
|
1681
|
+
results[ownerId] = { failed: 'queue-unavailable' };
|
|
1682
|
+
continue;
|
|
1683
|
+
}
|
|
1684
|
+
for (const eventId of affected) eventFeedClient.dismissNoticeConfirmed(ownerId, eventId);
|
|
1685
|
+
// Queued answers depend on the durable write: a queue that cannot
|
|
1686
|
+
// persist says so per owner, instead of looking like the healthy
|
|
1687
|
+
// pending/blocked that survives a restart.
|
|
1688
|
+
results[ownerId] = localSync === 'blocked'
|
|
1689
|
+
? { blocked: 'unsupported', ...queueDurability() }
|
|
1690
|
+
: { pending: affected.length, ...queueDurability() };
|
|
1691
|
+
if (localSync === 'pending') noticeDismissalDrainer.nudge();
|
|
1692
|
+
}
|
|
1693
|
+
return res.json({ results });
|
|
1694
|
+
}
|
|
1695
|
+
return res.status(400).json({ error: 'azione non riconosciuta' });
|
|
1696
|
+
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1697
|
+
});
|
|
1698
|
+
api.get('/notices-relay/capability', (req, res) => {
|
|
1699
|
+
const ownerId = typeof req.query.ownerId === 'string' ? req.query.ownerId : '';
|
|
1700
|
+
if (!/^[a-f0-9]{32}$/.test(ownerId)) return res.status(400).json({ error: 'ownerId richiesto' });
|
|
1701
|
+
let canDismiss = false;
|
|
1702
|
+
try {
|
|
1703
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
1704
|
+
const peer = st && (st.nodes || []).find((node) => node && node.nodeId === ownerId);
|
|
1705
|
+
const slot = peer && peer.reversePool && Array.isArray(peer.reversePool.slots)
|
|
1706
|
+
? peer.reversePool.slots[peer.reversePool.activeSlot] : null;
|
|
1707
|
+
canDismiss = !proxyReadonly() && !req.headers[HOP_HEADER] && !!peer && (
|
|
1708
|
+
(peer.direction === 'outbound' && !!peer.token && !!peer.localPort)
|
|
1709
|
+
|| (peer.direction === 'inbound' && peer.shared === true && peer.askReplyAccess === true
|
|
1710
|
+
&& peer.eventsAccess === true && peer.cellVisibility !== 'none'
|
|
1711
|
+
&& !!slot && Number.isInteger(slot.port)));
|
|
1712
|
+
} catch (_) { canDismiss = false; }
|
|
1713
|
+
res.json({ ownerId, canDismiss });
|
|
1714
|
+
});
|
|
1520
1715
|
// The client re-emits imported events into the local hub (deliverOnly: the
|
|
1521
1716
|
// notifier is bypassed on purpose). The alert relay hangs off that same call,
|
|
1522
1717
|
// so ONLY an imported notify can ring this device, and never through the
|
|
@@ -1545,6 +1740,13 @@ function createServer(opts = {}) {
|
|
|
1545
1740
|
if (!bridgeReadonly()) for (const ask of asks) askAlertRegistry.adopt(ownerId, ask, ask.ownerAskTs === undefined ? ask.ts : ask.ownerAskTs);
|
|
1546
1741
|
},
|
|
1547
1742
|
isAskDismissed: (ownerId, ask) => asksStore.isImportedDismissed(ownerId, ask),
|
|
1743
|
+
// A notice this node cleared locally but could not hand to the owner (queued
|
|
1744
|
+
// as pending, or blocked for good) must not return to the view: the record in
|
|
1745
|
+
// the queue is the node's intent, and it is what survives a restart. The
|
|
1746
|
+
// read is the same one the operator's answer is built from.
|
|
1747
|
+
isNoticeDismissed: (ownerId, eventId) => {
|
|
1748
|
+
try { return noticeDismissalQueue.get(ownerId, eventId) !== null; } catch (_) { return false; }
|
|
1749
|
+
},
|
|
1548
1750
|
onAskClosed: (ownerId, ownerAskId, outcome, ownerAskTs) => asksStore.closeImported({ ownerId, ownerAskId, outcome, ownerAskTs }),
|
|
1549
1751
|
loadStore: () => nodesStore.loadStore(nodesPath),
|
|
1550
1752
|
token: () => tokenHolder.value,
|
|
@@ -1691,6 +1893,23 @@ function createServer(opts = {}) {
|
|
|
1691
1893
|
}),
|
|
1692
1894
|
getGroup: (name) => audioGroups.getGroup(audioCfg, name, audioHome),
|
|
1693
1895
|
}));
|
|
1896
|
+
// One store and one bridge serve the cell directory (the Live entry and the
|
|
1897
|
+
// verified label), the live-host routes and the federation forwarder. The
|
|
1898
|
+
// registry of Live threads is persisted next to the designation so a restart
|
|
1899
|
+
// does not make a living Live look absent.
|
|
1900
|
+
const liveHostStore = createLiveHostStore({ filePath: liveHostPath(cfg) });
|
|
1901
|
+
const liveRegistry = createLiveThreadRegistry({ filePath: liveThreadsPath(cfg) });
|
|
1902
|
+
const liveBridge = createLiveBridge({
|
|
1903
|
+
cfg,
|
|
1904
|
+
fleetP,
|
|
1905
|
+
tokenGet: () => tokenHolder.value,
|
|
1906
|
+
filesRoot: cfg.filesRoot,
|
|
1907
|
+
log: opts.log || console.log,
|
|
1908
|
+
registry: liveRegistry,
|
|
1909
|
+
});
|
|
1910
|
+
// Startup reconciliation never blocks the server and never throws: it only
|
|
1911
|
+
// drops entries the daemon definitely reports as gone.
|
|
1912
|
+
Promise.resolve().then(() => liveBridge.reconcile()).catch(() => {});
|
|
1694
1913
|
api.use('/cells', cellsRoutes({
|
|
1695
1914
|
fleetP,
|
|
1696
1915
|
diagnostics,
|
|
@@ -1700,6 +1919,10 @@ function createServer(opts = {}) {
|
|
|
1700
1919
|
engine: meta && meta.engine,
|
|
1701
1920
|
})),
|
|
1702
1921
|
readonly: proxyReadonly,
|
|
1922
|
+
liveEntry: ({ instanceId: id, now: clock }) => buildLiveEntry({
|
|
1923
|
+
store: liveHostStore, bridge: liveBridge, fleetP, instanceId: id, now: clock,
|
|
1924
|
+
}),
|
|
1925
|
+
liveAttest: createLiveAttestation({ registry: liveRegistry, bridge: liveBridge }),
|
|
1703
1926
|
}));
|
|
1704
1927
|
// Live host cell: one hostCell per node with CAS. The store
|
|
1705
1928
|
// lives next to the token (same isolated dir in tests); the proxy refuses
|
|
@@ -1707,22 +1930,15 @@ function createServer(opts = {}) {
|
|
|
1707
1930
|
// behind a per-peer permission denied by default (liveHostAccess).
|
|
1708
1931
|
// Fetta 3: il ponte risolve il puntamento all'avvio di una Live (POST /bridge
|
|
1709
1932
|
// nello stesso gruppo, stessa auth, stesso local-only). Isolabile da cfg
|
|
1710
|
-
//
|
|
1933
|
+
// Spento non crea connessioni e la Live resta standard.
|
|
1711
1934
|
// One store instance serves both sides of a remote designation: the routes
|
|
1712
1935
|
// (owner side) and the forwarder that records the pointer to a peer (client
|
|
1713
1936
|
// side). Two instances over the same file would work, one is honest.
|
|
1714
|
-
const liveHostStore = createLiveHostStore({ filePath: liveHostPath(cfg) });
|
|
1715
1937
|
api.use('/live-host', liveHostRoutes({
|
|
1716
1938
|
fleetP,
|
|
1717
1939
|
store: liveHostStore,
|
|
1718
1940
|
readonly: proxyReadonly,
|
|
1719
|
-
bridge:
|
|
1720
|
-
cfg,
|
|
1721
|
-
fleetP,
|
|
1722
|
-
tokenGet: () => tokenHolder.value,
|
|
1723
|
-
filesRoot: cfg.filesRoot,
|
|
1724
|
-
log: opts.log || console.log,
|
|
1725
|
-
}),
|
|
1941
|
+
bridge: liveBridge,
|
|
1726
1942
|
// Client side: read the owner's state and forward the bridge resolution to
|
|
1727
1943
|
// the node the designation points at. Owner side: accept a forwarded
|
|
1728
1944
|
// resolution behind the same per-peer gate and the same rate limiting as
|
|
@@ -1777,6 +1993,7 @@ function createServer(opts = {}) {
|
|
|
1777
1993
|
// Store-root revision of the peer access policy: the CAS value the UI
|
|
1778
1994
|
// must send back with every preset write (stale revision -> 409).
|
|
1779
1995
|
importedDismissalDrainer.nudge();
|
|
1996
|
+
noticeDismissalDrainer.nudge();
|
|
1780
1997
|
res.json({ nodeId: view.nodeId, nodes, accessRevision: nodesStore.accessRevisionOf(st) });
|
|
1781
1998
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1782
1999
|
});
|
|
@@ -1908,14 +2125,14 @@ function createServer(opts = {}) {
|
|
|
1908
2125
|
diagnostics.record('info', 'server', 'SERVER_STARTED', 'NexusCrew server started', {
|
|
1909
2126
|
port: runtimePort(), platform: cfg.platform || process.platform,
|
|
1910
2127
|
});
|
|
1911
|
-
updater.start(); startManagedTunnels(); importedDismissalDrainer.start();
|
|
2128
|
+
updater.start(); startManagedTunnels(); importedDismissalDrainer.start(); noticeDismissalDrainer.start();
|
|
1912
2129
|
});
|
|
1913
2130
|
server.on('close', () => {
|
|
1914
2131
|
diagnostics.record('info', 'server', 'SERVER_STOPPED', 'NexusCrew server stopped', { reason: 'close' });
|
|
1915
2132
|
watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); ptyGrace.close();
|
|
1916
2133
|
// Nessun timer di ritentativo sopravvive alla chiusura del server.
|
|
1917
2134
|
if (closureRetry) { try { closureRetry.stop(); } catch (_) {} }
|
|
1918
|
-
importedDismissalDrainer.stop(); eventFeedClient.stop();
|
|
2135
|
+
importedDismissalDrainer.stop(); noticeDismissalDrainer.stop(); eventFeedClient.stop();
|
|
1919
2136
|
for (const timer of reverseWatchers.values()) clearInterval(timer);
|
|
1920
2137
|
reverseWatchers.clear(); rotatableReverse.clear(); void reverseSlotListeners?.closeAll();
|
|
1921
2138
|
// Il pannello non sopravvive al control plane: senza requireToken sopra,
|
package/lib/tmux/actions.js
CHANGED
|
@@ -161,7 +161,7 @@ async function submitToSession(tmuxBin, session, text, opts = {}) {
|
|
|
161
161
|
// then the pane is revalidated before the separate Enter. Other clients
|
|
162
162
|
// still receive a short separation between paste and submit.
|
|
163
163
|
//
|
|
164
|
-
//
|
|
164
|
+
// L'ATTESA SCALA COL PAYLOAD, e prima era una costante. Il tempo che
|
|
165
165
|
// un TUI impiega a ingerire un bracketed paste cresce con la lunghezza —
|
|
166
166
|
// Claude Code sopra una certa taglia lo COLLASSA («paste again to expand»)
|
|
167
167
|
// — mentre l'attesa era fissa. Oltre quella soglia l'Enter arriva mentre il
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmmbuto/nexuscrew",
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"description": "Faithful browser tmux client
|
|
3
|
+
"version": "0.9.65",
|
|
4
|
+
"description": "Faithful browser tmux client \u2014 attach to live sessions over a real PTY, localhost-only, mobile-easy",
|
|
5
5
|
"main": "lib/server.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"nexuscrew": "bin/nexuscrew.js"
|