@mmmbuto/nexuscrew 0.8.52-rc.2 → 0.8.52-rc.21
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/frontend/dist/assets/index-CemRuvGr.js +93 -0
- package/frontend/dist/assets/{index-DNQLAGoa.css → index-_kotgT8Z.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +11 -0
- package/frontend/dist/version.json +1 -1
- package/lib/audio/dispatch.js +6 -1
- package/lib/cells/scope-guard.js +239 -0
- package/lib/cells/scope.js +127 -0
- package/lib/cli/commands.js +23 -1
- package/lib/mcp/tools.js +17 -0
- package/lib/nodes/commands.js +86 -3
- package/lib/nodes/store.js +35 -0
- package/lib/notify/asks.js +23 -3
- package/lib/notify/notifier.js +7 -0
- package/lib/notify/routes.js +95 -3
- package/lib/proxy/federation.js +77 -11
- package/lib/server.js +100 -4
- package/lib/settings/routes.js +1 -1
- package/lib/vl-nodes/broker.js +140 -3
- package/lib/vl-nodes/routes.js +38 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-TqepO9Zm.js +0 -93
package/lib/proxy/federation.js
CHANGED
|
@@ -60,6 +60,22 @@ function classifyShareFailure(health) {
|
|
|
60
60
|
return { code: 'share-peer-unreachable', detail };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// Porta del canale reverse realmente in gioco, vista DALL'HUB: lo slot attivo
|
|
64
|
+
// del pool quando c'e', altrimenti la `localPort` del peer — che e' il punto
|
|
65
|
+
// su cui l'hub sonda davvero (cfr. nodes test, ramo inbound). NON
|
|
66
|
+
// `reversePort`: quello e' il campo negoziato che vive sul CLIENT, e sull'hub
|
|
67
|
+
// e' assente. Il test del comportamento ha trovato questo scambio; il valore
|
|
68
|
+
// e' il dato che manca per capire un rifiuto, quindi sbagliarlo lo renderebbe
|
|
69
|
+
// peggio che inutile, mandando a cercare sulla porta sbagliata.
|
|
70
|
+
function activeReversePort(peer) {
|
|
71
|
+
const pool = peer && peer.reversePool;
|
|
72
|
+
if (pool && Array.isArray(pool.slots)) {
|
|
73
|
+
const slot = pool.slots[pool.activeSlot];
|
|
74
|
+
if (slot && Number.isInteger(slot.port)) return slot.port;
|
|
75
|
+
}
|
|
76
|
+
return peer && Number.isInteger(peer.localPort) ? peer.localPort : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
function peerFromToken(nodesPath, token) {
|
|
64
80
|
const st = store.loadStore(nodesPath);
|
|
65
81
|
if (!st || !token) return null;
|
|
@@ -111,19 +127,28 @@ function knownResource(resource) {
|
|
|
111
127
|
//
|
|
112
128
|
// These four were briefly removed on 2026-08-05 over `update_candidate`,
|
|
113
129
|
// which names the URL a device fetches its own update from and accepts
|
|
114
|
-
// `http:`. The removal was wrong on
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
130
|
+
// `http:`. The removal was wrong on two counts, one absolute and one
|
|
131
|
+
// conditional.
|
|
132
|
+
//
|
|
133
|
+
// Incoherent, always: a paired peer is trusted as its owner
|
|
134
|
+
// (docs/SECURITY.md), and singling out VL nodes made them the only
|
|
135
|
+
// unfederated resource in their own control plane.
|
|
136
|
+
//
|
|
137
|
+
// Ineffective, but ONLY where fleet is available: `/fleet/define-engine` +
|
|
138
|
+
// `/fleet/define-cell` + `/fleet/up` are federated, and a managed shell
|
|
139
|
+
// engine with a raw `commands[shell]` resolves to `bash -lic "<raw>"` — so
|
|
140
|
+
// a peer can already run anything on the host, including a call to these
|
|
141
|
+
// endpoints locally. VL is orthogonal to fleet: on an owner with VL enabled
|
|
142
|
+
// and `/fleet/*` unavailable that path does not exist, and federating these
|
|
143
|
+
// four DOES add capability. It stays within the trust model, so it is not a
|
|
144
|
+
// violation — but do not repeat "it grants nothing" without that condition.
|
|
120
145
|
//
|
|
121
146
|
// `update_candidate` is still a real defect. It belongs to the command, not
|
|
122
147
|
// to this allowlist: bind the update channel to something the receiving
|
|
123
148
|
// owner controls. Fixing it here only hid it.
|
|
124
149
|
|| resource === '/vl-nodes'
|
|
125
150
|
|| resource === '/vl-nodes/invite'
|
|
126
|
-
|| /^\/vl-nodes\/[a-f0-9]{32}(?:\/commands)?$/.test(resource)
|
|
151
|
+
|| /^\/vl-nodes\/[a-f0-9]{32}(?:\/commands|\/events)?$/.test(resource)
|
|
127
152
|
|| resource === '/decks'
|
|
128
153
|
|| /^\/decks\/[a-z0-9-]{1,32}$/.test(resource)
|
|
129
154
|
|| resource === '/topology'
|
|
@@ -142,6 +167,13 @@ function knownResource(resource) {
|
|
|
142
167
|
// ADMITS a further node, so leaving it federated would let a peer break the
|
|
143
168
|
// very boundary the owner chose. Minting an invite is done on the hub.
|
|
144
169
|
|| resource === '/ws'
|
|
170
|
+
// Un avviso all'operatore attraversa la federazione con la stessa spina
|
|
171
|
+
// dorsale dell'audio: origine provata dalla catena `visited`, ACL del
|
|
172
|
+
// target, budget dedicato. NON e' la coda di NC-NEXT-07 (§5 del modello di
|
|
173
|
+
// autorita'): quella differisce un'esecuzione, questo mostra del testo e
|
|
174
|
+
// non esegue nulla. `/audio/speak` esce da un altoparlante in una stanza
|
|
175
|
+
// fisica ed e' gia' federato: una notifica e' meno invasiva di cosi'.
|
|
176
|
+
|| resource === '/notify'
|
|
145
177
|
|| resource === '/audio/capability'
|
|
146
178
|
|| resource === '/audio/speak'
|
|
147
179
|
|| resource === '/audio/speak/status'
|
|
@@ -163,6 +195,10 @@ function allowedResource(resource, method = 'GET') {
|
|
|
163
195
|
if (resource === '/vl-nodes') return method === 'GET';
|
|
164
196
|
if (resource === '/vl-nodes/invite') return method === 'POST';
|
|
165
197
|
if (/^\/vl-nodes\/[a-f0-9]{32}\/commands$/.test(resource)) return method === 'POST';
|
|
198
|
+
// Sola lettura del ring: la conversazione si vede da qualunque peer
|
|
199
|
+
// autorizzato, come ogni altra risorsa. GET e basta — questo canale non
|
|
200
|
+
// deve mai diventare un canale comandi implicito.
|
|
201
|
+
if (/^\/vl-nodes\/[a-f0-9]{32}\/events$/.test(resource)) return method === 'GET';
|
|
166
202
|
if (/^\/vl-nodes\/[a-f0-9]{32}$/.test(resource)) return method === 'DELETE';
|
|
167
203
|
if (resource === '/decks') return method === 'GET' || method === 'POST';
|
|
168
204
|
if (/^\/decks\/[a-z0-9-]{1,32}$/.test(resource)) {
|
|
@@ -175,6 +211,10 @@ function allowedResource(resource, method = 'GET') {
|
|
|
175
211
|
if (resource === '/ws') return method === 'GET';
|
|
176
212
|
if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) return method === 'GET';
|
|
177
213
|
if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) return method === 'POST';
|
|
214
|
+
// Notify: solo POST. `/events`, `/asks` e `/push/*` restano NON federati —
|
|
215
|
+
// SSE non puo' autenticarsi col Bearer e un ask ha un canale di ritorno che
|
|
216
|
+
// e' un paste nel tmux locale, quindi non attraverserebbe comunque.
|
|
217
|
+
if (resource === '/notify') return method === 'POST';
|
|
178
218
|
// Audio: only capability read, speak and stop are exposed through Hydra.
|
|
179
219
|
// audio.consent is a LOCAL mutation and MUST stay unreachable federated.
|
|
180
220
|
if (resource === '/audio/capability') return method === 'GET';
|
|
@@ -793,7 +833,7 @@ async function probeReverseOwner(peer, fetchImpl = fetch) {
|
|
|
793
833
|
});
|
|
794
834
|
}
|
|
795
835
|
|
|
796
|
-
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null }) {
|
|
836
|
+
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null, diagnostics = null }) {
|
|
797
837
|
const r = express.Router();
|
|
798
838
|
r.use((req, res, next) => {
|
|
799
839
|
const peer = peerFromToken(nodesPath, bearerFrom(req));
|
|
@@ -1029,6 +1069,21 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
1029
1069
|
// guasto definitivo senza interpretare una stringa. Solo il canale
|
|
1030
1070
|
// non ancora salito e' ritentabile.
|
|
1031
1071
|
const failure = classifyShareFailure(health);
|
|
1072
|
+
// L'hub sa di aver rifiutato e sa perche', e finora non lo scriveva
|
|
1073
|
+
// da nessuna parte: l'errore viveva solo nel toast del dispositivo,
|
|
1074
|
+
// che e' l'unico posto dove chi amministra l'hub non puo' guardarlo.
|
|
1075
|
+
// Un rifiuto ripetuto diventava cosi' un mistero — e' costato ore su
|
|
1076
|
+
// un caso reale, risolto solo leggendo i log di sshd con i privilegi
|
|
1077
|
+
// di root. Meta bounded: nome nodo, codice tipizzato, porta tentata.
|
|
1078
|
+
// Nessun testo remoto, nessuna credenziale.
|
|
1079
|
+
if (diagnostics && typeof diagnostics.record === 'function') {
|
|
1080
|
+
diagnostics.record('warn', 'share', 'SHARE_CHANNEL_REFUSED',
|
|
1081
|
+
'Share refused: the reverse channel did not come up', {
|
|
1082
|
+
node: req.peer.name,
|
|
1083
|
+
code: failure.code,
|
|
1084
|
+
port: activeReversePort(req.peer),
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1032
1087
|
return res.status(409).json({
|
|
1033
1088
|
error: 'canale share non raggiungibile',
|
|
1034
1089
|
code: failure.code,
|
|
@@ -1060,7 +1115,7 @@ function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecre
|
|
|
1060
1115
|
return r;
|
|
1061
1116
|
}
|
|
1062
1117
|
|
|
1063
|
-
function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredential, ingress, readonly = () => false, activeSockets = null }) {
|
|
1118
|
+
function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredential, ingress, readonly = () => false, activeSockets = null, hopSecret = null }) {
|
|
1064
1119
|
if (readonly()) return reject(socket, 403);
|
|
1065
1120
|
const parsed = parseRoute(req.url.replace(/^\/(?:api\/route|federation\/route)/, ''));
|
|
1066
1121
|
if (!parsed || parsed.resource !== '/ws') return reject(socket, 404);
|
|
@@ -1079,7 +1134,18 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1079
1134
|
}
|
|
1080
1135
|
const up = net.connect({ host: '127.0.0.1', port });
|
|
1081
1136
|
up.once('connect', () => {
|
|
1082
|
-
|
|
1137
|
+
// Ultimo hop: l'upgrade rientra nell'API locale col Bearer locale e da li'
|
|
1138
|
+
// in poi sarebbe indistinguibile da un attach diretto di chi possiede quel
|
|
1139
|
+
// token. La catena PIU' la prova di hop sono cio' che lo distingue —
|
|
1140
|
+
// esattamente come fa routeHandler per il percorso HTTP. Senza, il nodo che
|
|
1141
|
+
// possiede la sessione non sa quale peer stia aprendo il suo PTY, e ogni
|
|
1142
|
+
// permesso per-cella diventa decorativo: `/ws` attacca per NOME di
|
|
1143
|
+
// sessione, quindi basta indovinarlo.
|
|
1144
|
+
const secret = typeof hopSecret === 'function' ? hopSecret() : hopSecret;
|
|
1145
|
+
const hopProof = !parsed.route.length && secret
|
|
1146
|
+
? signHop(secret, { method: req.method || 'GET', path, visited })
|
|
1147
|
+
: null;
|
|
1148
|
+
const headers = cleanHeaders(req.headers, credential, visited, hopProof);
|
|
1083
1149
|
const lines = [`GET ${path} HTTP/1.1`, `Host: 127.0.0.1:${port}`];
|
|
1084
1150
|
for (const [k, v] of Object.entries(headers)) lines.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
|
|
1085
1151
|
lines.push('Connection: Upgrade', 'Upgrade: websocket', '', '');
|
|
@@ -1097,7 +1163,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1097
1163
|
function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
|
|
1098
1164
|
|
|
1099
1165
|
module.exports = {
|
|
1100
|
-
MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure,
|
|
1166
|
+
MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure, activeReversePort,
|
|
1101
1167
|
peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
|
|
1102
1168
|
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
|
|
1103
1169
|
probeHealth, waitForHealthyPeer, canListenLoopback, preflightHubReverse, notifyHubShare, reconcilePeerShare, runShareRevokeBoot, probeReverseOwner,
|
package/lib/server.js
CHANGED
|
@@ -39,12 +39,20 @@ const { isConsent: isAudioConsent } = require('./audio/consent.js');
|
|
|
39
39
|
const { createOriginResolver } = require('./audio/origin.js');
|
|
40
40
|
const { createAudioAcl } = require('./audio/acl.js');
|
|
41
41
|
const { createDispatcher } = require('./audio/dispatch.js');
|
|
42
|
+
const { createCellScopeGuard } = require('./cells/scope-guard.js');
|
|
43
|
+
const { createCellScope } = require('./cells/scope.js');
|
|
44
|
+
const { cellIdFromTmuxSession } = require('./fleet/definitions.js');
|
|
45
|
+
// Budget a tre finestre, riusato dalle notifiche federate: e' gia' scritto per
|
|
46
|
+
// limitare il danno di un nodo che mente sulla propria cella (il tetto per
|
|
47
|
+
// target si calcola sul NODO, che la catena visited verifica).
|
|
48
|
+
const { createSpeakRateLimiter } = require('./audio/rate-limit.js');
|
|
42
49
|
const { createSpeakQueue } = require('./audio/queue.js');
|
|
43
50
|
const { createReceiptStore } = require('./audio/receipt.js');
|
|
44
51
|
const audioAdapters = require('./audio/adapters.js');
|
|
45
52
|
const audioGroups = require('./audio/groups.js');
|
|
46
53
|
const { bridgeSecretPath, loadOrCreateBridgeSecret, createNonceCache } = require('./audio/bridge-auth.js');
|
|
47
|
-
const { createHopSecret } = require('./proxy/hop-proof.js');
|
|
54
|
+
const { createHopSecret, verifyHop, HOP_HEADER } = require('./proxy/hop-proof.js');
|
|
55
|
+
const { parseVisitedChain } = require('./audio/origin.js');
|
|
48
56
|
const { settingsRoutes, publicPeeringRoutes } = require('./settings/routes.js');
|
|
49
57
|
const decksStore = require('./decks/store.js');
|
|
50
58
|
const { decksRoutes } = require('./decks/routes.js');
|
|
@@ -105,6 +113,10 @@ function createServer(opts = {}) {
|
|
|
105
113
|
// alla porta sbagliata (bug reale: 401/"unauthorized" dal servizio su cfg.port).
|
|
106
114
|
let server = null;
|
|
107
115
|
const previews = createPreviewSampler(cfg.tmuxBin);
|
|
116
|
+
// Seam di test per l'esistenza di una sessione: senza, un test sullo scope
|
|
117
|
+
// dell'attach non discrimina, perche' ogni sessione risulta assente e il
|
|
118
|
+
// rifiuto arriverebbe comunque — un verde che non prova nulla.
|
|
119
|
+
const sessionExistsImpl = cfg.sessionExistsSeam || ((name) => sessionExists(cfg.tmuxBin, name));
|
|
108
120
|
// MCP bridge (notify/ask/push): lo stato vive accanto al token (dirname del
|
|
109
121
|
// tokenPath = ~/.nexuscrew di default) cosi' le istanze isolate via opts/env
|
|
110
122
|
// nei test NON scrivono mai nella home reale. Tutto lazy: vapid.json/asks.json
|
|
@@ -541,6 +553,51 @@ function createServer(opts = {}) {
|
|
|
541
553
|
// ma il token chiude anche altri processi locali della stessa macchina.
|
|
542
554
|
const api = express.Router();
|
|
543
555
|
api.use(requireToken(tokenStore));
|
|
556
|
+
// Origine per lo scope celle: stessa prova di hop usata da audio e notify.
|
|
557
|
+
// requireCell:false perche' qui interessa QUALE NODO parla, non quale cella
|
|
558
|
+
// dichiari — la cella e' solo attestata e non deve decidere un permesso.
|
|
559
|
+
const cellScopeOrigin = createOriginResolver({
|
|
560
|
+
localNodeId: () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
|
|
561
|
+
hopSecret: () => hopSecret,
|
|
562
|
+
});
|
|
563
|
+
const cellScoper = createCellScope({
|
|
564
|
+
nodesPath,
|
|
565
|
+
cellForSession: (session) => cellIdFromTmuxSession(session),
|
|
566
|
+
});
|
|
567
|
+
// Variante SINCRONA per l'upgrade WebSocket: verifica la prova di hop e la
|
|
568
|
+
// catena senza passare dal resolver async. Un upgrade non federato — o con
|
|
569
|
+
// una prova che non regge — resta senza restrizioni, esattamente come una
|
|
570
|
+
// richiesta locale: la prova la costruisce il proxy, un peer non puo' ne'
|
|
571
|
+
// toglierla ne' falsificarla.
|
|
572
|
+
const wsCellScope = (req) => {
|
|
573
|
+
try {
|
|
574
|
+
const headers = (req && req.headers) || {};
|
|
575
|
+
const proof = headers[HOP_HEADER];
|
|
576
|
+
if (!proof) return cellScoper.resolve({ trust: 'local-bridge' });
|
|
577
|
+
const self = (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
|
|
578
|
+
const visited = parseVisitedChain(headers['x-nexuscrew-visited'], self);
|
|
579
|
+
if (!visited) return cellScoper.resolve({ trust: 'federated', visited: [] });
|
|
580
|
+
const ok = verifyHop(hopSecret, {
|
|
581
|
+
method: req.method || 'GET', path: '/ws', visited,
|
|
582
|
+
}, proof);
|
|
583
|
+
if (!ok) return cellScoper.resolve({ trust: 'federated', visited: [] });
|
|
584
|
+
return cellScoper.resolve({ trust: 'federated', visited });
|
|
585
|
+
} catch (_) {
|
|
586
|
+
// Fail-closed: se non si riesce a stabilire l'origine, nessuna sessione.
|
|
587
|
+
return cellScoper.resolve({ trust: 'federated', visited: [] });
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
// Scope celle (NC-E): un punto solo decide cosa un peer federato vede e puo'
|
|
591
|
+
// toccare. Sta qui, prima di ogni route, perche' i canali da cui una cella
|
|
592
|
+
// trapela sono molti e crescono: filtrarli uno per uno significa dimenticarne
|
|
593
|
+
// uno, e un permesso che vale su quasi tutti i canali non e' un permesso.
|
|
594
|
+
// Le richieste locali non lo attraversano: il proprietario della macchina non
|
|
595
|
+
// si limita da solo.
|
|
596
|
+
api.use(createCellScopeGuard({
|
|
597
|
+
nodesPath,
|
|
598
|
+
cellForSession: (session) => cellIdFromTmuxSession(session),
|
|
599
|
+
resolveOrigin: (req) => cellScopeOrigin.resolve(req, { requireCell: false }),
|
|
600
|
+
}));
|
|
544
601
|
api.get('/sessions', async (_req, res) => {
|
|
545
602
|
try {
|
|
546
603
|
const sessions = await listSessions(cfg.tmuxBin);
|
|
@@ -615,6 +672,19 @@ function createServer(opts = {}) {
|
|
|
615
672
|
}));
|
|
616
673
|
// MCP bridge (design §2): /notify, /push/*, /asks — dietro lo stesso Bearer
|
|
617
674
|
// del router /api; gate READONLY sui mutanti dentro notifyRoutes.
|
|
675
|
+
// Identita' e instradamento condivisi con l'audio: stessa fonte (node store),
|
|
676
|
+
// stesso segreto di hop, stessa inventory. Due modelli di provenienza
|
|
677
|
+
// divergenti sarebbero un bug latente.
|
|
678
|
+
const federatedNodeId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
|
|
679
|
+
const federatedPeers = async () => {
|
|
680
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
681
|
+
if (!st) return [];
|
|
682
|
+
const topology = await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath, fetchImpl: healthFetch });
|
|
683
|
+
return nodesInventory.buildInventory({
|
|
684
|
+
direct: nodesStore.redactStore(st).nodes,
|
|
685
|
+
topology: topology && Array.isArray(topology.nodes) ? topology.nodes : [],
|
|
686
|
+
});
|
|
687
|
+
};
|
|
618
688
|
api.use(notifyRoutes({
|
|
619
689
|
cfg,
|
|
620
690
|
notifier,
|
|
@@ -622,6 +692,22 @@ function createServer(opts = {}) {
|
|
|
622
692
|
asks: asksStore,
|
|
623
693
|
paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
|
|
624
694
|
sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
|
|
695
|
+
localNodeId: federatedNodeId,
|
|
696
|
+
// requireCell resta true: una notifica federata porta sempre una cella
|
|
697
|
+
// attestata, che serve ad attribuirla e a calcolarne il budget.
|
|
698
|
+
originResolver: createOriginResolver({
|
|
699
|
+
localNodeId: federatedNodeId,
|
|
700
|
+
hopSecret: () => hopSecret,
|
|
701
|
+
}),
|
|
702
|
+
acl: createAudioAcl({ nodesPath }),
|
|
703
|
+
dispatcher: createDispatcher({
|
|
704
|
+
localNodeId: federatedNodeId,
|
|
705
|
+
peers: federatedPeers,
|
|
706
|
+
localPort: () => (server && server.address() ? server.address().port : cfg.port),
|
|
707
|
+
localToken: () => tokenHolder.value,
|
|
708
|
+
statuses: new Set(['delivered', 'refused', 'unreachable', 'unknown']),
|
|
709
|
+
}),
|
|
710
|
+
federatedRate: createSpeakRateLimiter(),
|
|
625
711
|
}));
|
|
626
712
|
api.use('/fleet', fleetRoutes(fleetP, { ...cfg, diagnostics }));
|
|
627
713
|
// Audio Share. L'identita' del nodo NON e' un campo di cfg: si legge dal node
|
|
@@ -787,6 +873,7 @@ function createServer(opts = {}) {
|
|
|
787
873
|
nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
|
|
788
874
|
fetchImpl: healthFetch, hopSecret: () => hopSecret,
|
|
789
875
|
roles: () => require('./cli/commands.js').readRoles(cfg.configPath || configJsonPath()),
|
|
876
|
+
diagnostics,
|
|
790
877
|
}));
|
|
791
878
|
|
|
792
879
|
// Reverse-proxy single-origin /node/<name>/… (design §4b(2)). Auth locale PRIMA
|
|
@@ -868,13 +955,13 @@ function createServer(opts = {}) {
|
|
|
868
955
|
let u; try { u = new URL(req.url, 'http://127.0.0.1'); } catch (_) { return socket.destroy(); }
|
|
869
956
|
const given = bearerFrom(req) || u.searchParams.get('token') || '';
|
|
870
957
|
if (!verify(tokenHolder.value, given)) return socket.destroy();
|
|
871
|
-
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets });
|
|
958
|
+
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret });
|
|
872
959
|
return;
|
|
873
960
|
}
|
|
874
961
|
if (pathname.startsWith('/federation/route/')) {
|
|
875
962
|
const ingress = federation.peerFromToken(nodesPath, bearerFrom(req));
|
|
876
963
|
if (!ingress) return socket.destroy();
|
|
877
|
-
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets });
|
|
964
|
+
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret });
|
|
878
965
|
return;
|
|
879
966
|
}
|
|
880
967
|
if (pathname === '/node' || pathname.startsWith('/node/')) {
|
|
@@ -900,10 +987,19 @@ function createServer(opts = {}) {
|
|
|
900
987
|
// non possono settare header sui WS -> il flusso locale resta identico
|
|
901
988
|
// (token nel frame attach, mai in URL).
|
|
902
989
|
const preauth = req ? verify(tokenHolder.value, bearerFrom(req)) : false;
|
|
990
|
+
// Scope celle sull'ATTACH. Calcolato in modo sincrono di proposito: rendere
|
|
991
|
+
// async questo handler rischierebbe di perdere i frame che arrivano prima
|
|
992
|
+
// di bindWs. `/ws` attacca per NOME DI SESSIONE e senza questo gate ogni
|
|
993
|
+
// filtro sugli elenchi sarebbe decorativo — basterebbe indovinare
|
|
994
|
+
// `cloud-Dev`.
|
|
995
|
+
const wsScope = wsCellScope(req);
|
|
903
996
|
bindWs(ws, {
|
|
904
997
|
openAttach,
|
|
905
998
|
verifyToken: (t) => preauth || verify(tokenHolder.value, t),
|
|
906
|
-
|
|
999
|
+
// Una sessione fuori scope viene trattata come inesistente: il client
|
|
1000
|
+
// riceve 4404, lo stesso codice di una sessione che non c'e'. Dire "esiste
|
|
1001
|
+
// ma non puoi" rivelerebbe proprio cio' che lo scope nasconde.
|
|
1002
|
+
isValidSession: (name) => sessionExistsImpl(name) && wsScope.allowsSession(name),
|
|
907
1003
|
runAction: (sess, action) => runAction(cfg.tmuxBin, sess, action),
|
|
908
1004
|
countClients: (sess) => attachedClients(cfg.tmuxBin, sess),
|
|
909
1005
|
defaults: { readonlyDefault: cfg.readonlyDefault, tmuxBin: cfg.tmuxBin },
|
package/lib/settings/routes.js
CHANGED
|
@@ -72,7 +72,7 @@ const { publicPeeringRoutes } = require('./public-peering-routes.js');
|
|
|
72
72
|
const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate', 'alternateScreen']);
|
|
73
73
|
const ROLE_KEYS = new Set(['client', 'node']);
|
|
74
74
|
const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
|
|
75
|
-
const EDIT_KEYS = new Set(['label', 'ssh', 'sshPort', 'autostart', 'visibility', 'selected']);
|
|
75
|
+
const EDIT_KEYS = new Set(['label', 'ssh', 'sshPort', 'autostart', 'visibility', 'selected', 'cellVisibility', 'cells']);
|
|
76
76
|
const LOCAL_AUDIO_TEST_TEXT = 'NexusCrew audio test.';
|
|
77
77
|
|
|
78
78
|
// Default sensato per il "nome dispositivo" proposto nei form (pairing/invite).
|
package/lib/vl-nodes/broker.js
CHANGED
|
@@ -39,6 +39,98 @@ function sanitizeHealth(raw) {
|
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// --- eventi di sessione (passo 2) ------------------------------------------
|
|
43
|
+
// I kind ammessi sono una WHITELIST, non una blacklist: un kind sconosciuto
|
|
44
|
+
// cade fuori da solo. `tool_args` e `tool_result` non sono nell'elenco per una
|
|
45
|
+
// ragione precisa — contengono file, output di comandi e potenziali segreti, e
|
|
46
|
+
// viaggeranno solo su richiesta esplicita (passo 3). Non troncarli: escluderli.
|
|
47
|
+
const EVENT_KINDS = new Set([
|
|
48
|
+
'text', 'thinking', 'tool_start', 'tool_end', 'usage',
|
|
49
|
+
'turn_end', 'done', 'error', 'gap', 'truncate', 'writer_epoch',
|
|
50
|
+
]);
|
|
51
|
+
// Questi non si perdono mai: senza di loro la UI mostra un turno che non
|
|
52
|
+
// finisce, indistinguibile da un nodo bloccato.
|
|
53
|
+
const TERMINAL_KINDS = new Set(['turn_end', 'done', 'error']);
|
|
54
|
+
// 16 KiB e non 64: il poll e' montato con express.json({ limit: '20kb' }) e
|
|
55
|
+
// oltre quel tetto la route risponde 413, cioe' il device si scollega.
|
|
56
|
+
const MAX_EVENTS_BYTES = 16_384;
|
|
57
|
+
const MAX_EVENT_TEXT = 4_096;
|
|
58
|
+
// Il ring vive in memoria e si perde al restart: la copia durevole e' il
|
|
59
|
+
// journal sul device. L'hub non accumula la conversazione nel tempo, e non la
|
|
60
|
+
// scrive su disco: la visibilita' e' viva, non e' un archivio.
|
|
61
|
+
const RING_MAX_EVENTS = 512;
|
|
62
|
+
const RING_MAX_BYTES = 262_144;
|
|
63
|
+
|
|
64
|
+
function sanitizeEvent(raw) {
|
|
65
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
66
|
+
if (!Number.isSafeInteger(raw.seq) || raw.seq < 0) return null;
|
|
67
|
+
const kind = typeof raw.kind === 'string' ? raw.kind : '';
|
|
68
|
+
if (!EVENT_KINDS.has(kind)) return null;
|
|
69
|
+
const event = { seq: raw.seq, kind };
|
|
70
|
+
const at = boundedInteger(raw.at, 0, Number.MAX_SAFE_INTEGER);
|
|
71
|
+
if (at) event.at = at;
|
|
72
|
+
const text = boundedString(raw.text, MAX_EVENT_TEXT);
|
|
73
|
+
if (text) event.text = text;
|
|
74
|
+
const name = boundedString(raw.name, 128);
|
|
75
|
+
if (name) event.name = name;
|
|
76
|
+
if (raw.isError === true) event.isError = true;
|
|
77
|
+
const count = boundedInteger(raw.count, 0, Number.MAX_SAFE_INTEGER);
|
|
78
|
+
if (count) event.count = count;
|
|
79
|
+
return event;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sanitizeEvents(raw) {
|
|
83
|
+
if (!Array.isArray(raw)) return { events: [], dropped: 0 };
|
|
84
|
+
const clean = [];
|
|
85
|
+
for (const item of raw.slice(0, RING_MAX_EVENTS * 2)) {
|
|
86
|
+
const event = sanitizeEvent(item);
|
|
87
|
+
if (event) clean.push(event);
|
|
88
|
+
}
|
|
89
|
+
clean.sort((a, b) => a.seq - b.seq);
|
|
90
|
+
|
|
91
|
+
// I terminali sono messi da parte PRIMA di spendere il budget, cosi' un
|
|
92
|
+
// overflow di testo non puo' mangiarseli.
|
|
93
|
+
const terminals = clean.filter((e) => TERMINAL_KINDS.has(e.kind));
|
|
94
|
+
const rest = clean.filter((e) => !TERMINAL_KINDS.has(e.kind));
|
|
95
|
+
const reserve = JSON.stringify(terminals).length + 64;
|
|
96
|
+
const budget = MAX_EVENTS_BYTES - reserve;
|
|
97
|
+
|
|
98
|
+
const kept = [];
|
|
99
|
+
let used = 2;
|
|
100
|
+
let dropped = 0;
|
|
101
|
+
let firstDroppedSeq = 0;
|
|
102
|
+
for (const event of rest) {
|
|
103
|
+
const size = JSON.stringify(event).length + 1;
|
|
104
|
+
if (used + size <= budget) {
|
|
105
|
+
kept.push(event);
|
|
106
|
+
used += size;
|
|
107
|
+
} else {
|
|
108
|
+
dropped += 1;
|
|
109
|
+
if (!firstDroppedSeq) firstDroppedSeq = event.seq;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const events = kept.concat(terminals);
|
|
114
|
+
// Lo scarto e' SEMPRE dichiarato: un buco silenzioso in UI si legge come
|
|
115
|
+
// "non e' successo niente", che e' peggio del buco.
|
|
116
|
+
if (dropped > 0) events.push({ seq: firstDroppedSeq, kind: 'gap', count: dropped });
|
|
117
|
+
events.sort((a, b) => a.seq - b.seq);
|
|
118
|
+
return { events, dropped };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// La sessione VL dichiarata dal nodo (sidebar: «N900 · 1 sessione»). Solo
|
|
122
|
+
// attach + label del profilo, mai contenuto: qualunque campo extra viene
|
|
123
|
+
// potato, non troncato — stessa disciplina della whitelist eventi. Assente o
|
|
124
|
+
// invalida -> null: un binario piu' vecchio non dichiara sessioni che non ha
|
|
125
|
+
// e non sparisce dalla lista.
|
|
126
|
+
function sanitizeSession(raw) {
|
|
127
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
128
|
+
if (raw.attached !== true && raw.attached !== false) return null;
|
|
129
|
+
const profile = boundedString(raw.profile, 64);
|
|
130
|
+
if (!profile) return null;
|
|
131
|
+
return { attached: raw.attached, profile };
|
|
132
|
+
}
|
|
133
|
+
|
|
42
134
|
function sanitizeHeartbeat(raw) {
|
|
43
135
|
if (!raw || raw.protocol !== PROTOCOL || !SESSION_RE.test(String(raw.sessionId || ''))
|
|
44
136
|
|| !Number.isSafeInteger(raw.seq) || raw.seq < 0) return null;
|
|
@@ -53,6 +145,7 @@ function sanitizeHeartbeat(raw) {
|
|
|
53
145
|
capabilities,
|
|
54
146
|
health: sanitizeHealth(raw.health),
|
|
55
147
|
ack: sanitizeAck(raw.ack),
|
|
148
|
+
session: sanitizeSession(raw.session),
|
|
56
149
|
};
|
|
57
150
|
}
|
|
58
151
|
|
|
@@ -76,6 +169,9 @@ function createBroker(options = {}) {
|
|
|
76
169
|
waiter: null,
|
|
77
170
|
inflight: null,
|
|
78
171
|
lastAck: null,
|
|
172
|
+
events: [],
|
|
173
|
+
eventsCursor: 0,
|
|
174
|
+
eventsBytes: 0,
|
|
79
175
|
};
|
|
80
176
|
states.set(node.nodeId, state);
|
|
81
177
|
}
|
|
@@ -94,6 +190,34 @@ function createBroker(options = {}) {
|
|
|
94
190
|
return true;
|
|
95
191
|
}
|
|
96
192
|
|
|
193
|
+
// At-least-once: il device rimanda dall'ultimo cursore confermato, quindi
|
|
194
|
+
// rivedere un seq gia' accettato e' normale, non un errore. Il dedup e' qui.
|
|
195
|
+
function ingestEvents(state, raw) {
|
|
196
|
+
const incoming = sanitizeEvents(raw);
|
|
197
|
+
if (!incoming.events.length) return;
|
|
198
|
+
for (const event of incoming.events) {
|
|
199
|
+
if (event.seq <= state.eventsCursor) continue;
|
|
200
|
+
state.events.push(event);
|
|
201
|
+
state.eventsBytes += JSON.stringify(event).length;
|
|
202
|
+
state.eventsCursor = event.seq;
|
|
203
|
+
}
|
|
204
|
+
while (state.events.length > RING_MAX_EVENTS || state.eventsBytes > RING_MAX_BYTES) {
|
|
205
|
+
const evicted = state.events.shift();
|
|
206
|
+
if (!evicted) break;
|
|
207
|
+
state.eventsBytes -= JSON.stringify(evicted).length;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function events(nodeId, { after = 0 } = {}) {
|
|
212
|
+
const state = states.get(nodeId);
|
|
213
|
+
if (!state) return { events: [], cursor: 0 };
|
|
214
|
+
const from = Number.isSafeInteger(after) && after > 0 ? after : 0;
|
|
215
|
+
return {
|
|
216
|
+
events: from ? state.events.filter((e) => e.seq > from) : state.events.slice(),
|
|
217
|
+
cursor: state.eventsCursor,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
97
221
|
function observe(node, raw) {
|
|
98
222
|
const heartbeat = sanitizeHeartbeat(raw);
|
|
99
223
|
if (!heartbeat) return { ok: false, code: 'invalid-heartbeat' };
|
|
@@ -120,6 +244,7 @@ function createBroker(options = {}) {
|
|
|
120
244
|
}
|
|
121
245
|
state.lastSeen = now();
|
|
122
246
|
state.heartbeat = heartbeat;
|
|
247
|
+
ingestEvents(state, raw && raw.events);
|
|
123
248
|
let acknowledged = false;
|
|
124
249
|
if (heartbeat.ack && state.inflight && heartbeat.ack.id === state.inflight.id) {
|
|
125
250
|
state.lastAck = { ...heartbeat.ack, at: state.lastSeen };
|
|
@@ -146,7 +271,9 @@ function createBroker(options = {}) {
|
|
|
146
271
|
const state = observed.state;
|
|
147
272
|
finishWaiter(state, { type: 'superseded', generation: state.generation });
|
|
148
273
|
if (observed.acknowledged) {
|
|
149
|
-
return Promise.resolve({
|
|
274
|
+
return Promise.resolve({
|
|
275
|
+
type: 'acknowledged', generation: state.generation, eventsCursor: state.eventsCursor,
|
|
276
|
+
});
|
|
150
277
|
}
|
|
151
278
|
const boundedWait = Math.max(1, Math.min(MAX_WAIT_MS, Number(waitMs) || MAX_WAIT_MS));
|
|
152
279
|
return new Promise((resolve) => {
|
|
@@ -167,7 +294,9 @@ function createBroker(options = {}) {
|
|
|
167
294
|
waiter.cleanup = () => signal.removeEventListener('abort', onAbort);
|
|
168
295
|
}
|
|
169
296
|
state.waiter = waiter;
|
|
170
|
-
|
|
297
|
+
// Il cursore viaggia su OGNI risposta, anche su un poll scaduto a vuoto:
|
|
298
|
+
// e' cosi' che il device sa quando puo' liberare il proprio buffer.
|
|
299
|
+
}).then((value) => ({ ...value, eventsCursor: state.eventsCursor }));
|
|
171
300
|
}
|
|
172
301
|
|
|
173
302
|
function dispatch(nodeId, command) {
|
|
@@ -207,13 +336,14 @@ function createBroker(options = {}) {
|
|
|
207
336
|
version: state?.heartbeat?.version || '',
|
|
208
337
|
capabilities: state?.heartbeat?.capabilities || [],
|
|
209
338
|
health: state?.heartbeat?.health || null,
|
|
339
|
+
session: state?.heartbeat?.session ?? null,
|
|
210
340
|
inflight: state?.inflight || null,
|
|
211
341
|
lastAck: state?.lastAck || null,
|
|
212
342
|
};
|
|
213
343
|
});
|
|
214
344
|
}
|
|
215
345
|
|
|
216
|
-
return { poll, dispatch, forget, list, observe, _states: states };
|
|
346
|
+
return { poll, dispatch, forget, list, observe, events, _states: states };
|
|
217
347
|
}
|
|
218
348
|
|
|
219
349
|
module.exports = {
|
|
@@ -224,5 +354,12 @@ module.exports = {
|
|
|
224
354
|
sanitizeHeartbeat,
|
|
225
355
|
sanitizeHealth,
|
|
226
356
|
sanitizeAck,
|
|
357
|
+
sanitizeSession,
|
|
358
|
+
sanitizeEvents,
|
|
359
|
+
EVENT_KINDS,
|
|
360
|
+
TERMINAL_KINDS,
|
|
361
|
+
MAX_EVENTS_BYTES,
|
|
362
|
+
RING_MAX_EVENTS,
|
|
363
|
+
RING_MAX_BYTES,
|
|
227
364
|
createBroker,
|
|
228
365
|
};
|
package/lib/vl-nodes/routes.js
CHANGED
|
@@ -10,6 +10,10 @@ const SIMPLE_COMMANDS = new Set([
|
|
|
10
10
|
'status', 'health', 'start', 'stop', 'restart', 'version', 'capabilities', 'unpair',
|
|
11
11
|
]);
|
|
12
12
|
const MAX_CANDIDATE_BYTES = 2_621_440;
|
|
13
|
+
/// Stesso tetto dichiarato dal device (`MAX_PROMPT_TEXT` in protocol.rs): due
|
|
14
|
+
/// numeri diversi qui e la' vorrebbero dire consegnare comandi che il nodo
|
|
15
|
+
/// rifiuta.
|
|
16
|
+
const MAX_PROMPT_TEXT = 4 * 1024;
|
|
13
17
|
|
|
14
18
|
function commandOf(body) {
|
|
15
19
|
if (!body || typeof body.kind !== 'string') return null;
|
|
@@ -18,6 +22,18 @@ function commandOf(body) {
|
|
|
18
22
|
|| Array.isArray(body.args) || Object.keys(body.args).length > 0)) return null;
|
|
19
23
|
return { kind: body.kind, args: {} };
|
|
20
24
|
}
|
|
25
|
+
// L'unico verbo che porta testo dell'utente fino alla sessione del device.
|
|
26
|
+
// Il tetto e' lo stesso che il nodo dichiara (4 KiB): rifiutare QUI cio' che
|
|
27
|
+
// il device rifiuterebbe comunque evita un comando in volo destinato a
|
|
28
|
+
// fallire, e un `submitted` che l'operatore leggerebbe come «e' partito».
|
|
29
|
+
if (body.kind === 'prompt') {
|
|
30
|
+
const args = body.args;
|
|
31
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) return null;
|
|
32
|
+
if (Object.keys(args).join(',') !== 'text') return null;
|
|
33
|
+
if (typeof args.text !== 'string') return null;
|
|
34
|
+
if (args.text.length < 1 || Buffer.byteLength(args.text, 'utf8') > MAX_PROMPT_TEXT) return null;
|
|
35
|
+
return { kind: 'prompt', args: { text: args.text } };
|
|
36
|
+
}
|
|
21
37
|
if (body.kind === 'logs') {
|
|
22
38
|
const limit = body.args && body.args.limit;
|
|
23
39
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) return null;
|
|
@@ -96,6 +112,13 @@ function publicRoutes({ storePath, broker, ownerId }) {
|
|
|
96
112
|
signal: abort.signal,
|
|
97
113
|
});
|
|
98
114
|
if (res.headersSent || res.destroyed) return undefined;
|
|
115
|
+
// Il cursore degli eventi viaggia in un HEADER, non nel corpo: la risposta
|
|
116
|
+
// a un poll scaduto e' un 204 senza corpo, e senza header il device non
|
|
117
|
+
// saprebbe mai fin dove sono stati accettati i suoi eventi — continuerebbe
|
|
118
|
+
// a rimandarli. L'header sta su OGNI risposta, anche su quelle vuote.
|
|
119
|
+
if (Number.isSafeInteger(result.eventsCursor)) {
|
|
120
|
+
res.set('x-vl-events-cursor', String(result.eventsCursor));
|
|
121
|
+
}
|
|
99
122
|
if (result.type === 'command') return res.json({ protocol: PROTOCOL, command: result.command });
|
|
100
123
|
if (result.type === 'idle' || result.type === 'aborted' || result.type === 'acknowledged') {
|
|
101
124
|
return res.status(204).end();
|
|
@@ -138,6 +161,21 @@ function apiRoutes({ storePath, broker, ownerId, readonly = () => false }) {
|
|
|
138
161
|
return res.json({ instanceId: localOwner, protocol: PROTOCOL, nodes });
|
|
139
162
|
});
|
|
140
163
|
|
|
164
|
+
// Lettura pura del ring in memoria: nessun gate READONLY, perche' non muta
|
|
165
|
+
// nulla. `after` e' il cursore della UI, cosi' non riscarica tutto a ogni
|
|
166
|
+
// giro. Cio' che non c'e' piu' nel ring non si recupera da qui: la copia
|
|
167
|
+
// durevole e' il journal sul device (contratto passo 2, punto 5).
|
|
168
|
+
r.get('/:nodeId/events', (req, res) => {
|
|
169
|
+
if (!store.NODE_ID_RE.test(String(req.params.nodeId || ''))
|
|
170
|
+
|| !store.listNodes(storePath).some((node) => node.nodeId === req.params.nodeId)) {
|
|
171
|
+
return res.status(404).json({ error: 'node not paired' });
|
|
172
|
+
}
|
|
173
|
+
const rawAfter = req.query.after === undefined ? 0 : Number(req.query.after);
|
|
174
|
+
const after = Number.isSafeInteger(rawAfter) && rawAfter > 0 ? rawAfter : 0;
|
|
175
|
+
const out = broker.events(req.params.nodeId, { after });
|
|
176
|
+
return res.json({ protocol: PROTOCOL, nodeId: req.params.nodeId, ...out });
|
|
177
|
+
});
|
|
178
|
+
|
|
141
179
|
r.post('/invite', express.json({ limit: '4kb' }), (req, res) => {
|
|
142
180
|
if (readonly()) return res.status(403).json({ error: 'READONLY: invite blocked' });
|
|
143
181
|
const localOwner = ownerId();
|
package/package.json
CHANGED