@mmmbuto/nexuscrew 0.9.66 → 0.9.67
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 +15 -1
- package/docs/NODES.md +14 -3
- package/frontend/dist/assets/{index-DhZOap1-.js → index-D__76yWR.js} +42 -42
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/audio/dispatch.js +14 -1
- package/lib/audio/routes.js +2 -2
- package/lib/live-host/federation.js +10 -0
- package/lib/live-host/routes.js +12 -2
- package/lib/nodes/reverse-slot-listeners.js +4 -1
- package/lib/nodes/store.js +10 -2
- package/lib/proxy/federation.js +266 -14
- package/lib/proxy/node-proxy.js +86 -3
- package/lib/server.js +98 -7
- package/package.json +1 -1
package/lib/proxy/node-proxy.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// raw socket TCP (upgrade WS). resolveNode/httpRequest/connect iniettabili (test).
|
|
23
23
|
const http = require('node:http');
|
|
24
24
|
const net = require('node:net');
|
|
25
|
+
const { Transform } = require('node:stream');
|
|
25
26
|
const { NODE_NAME_RE } = require('../nodes/store.js');
|
|
26
27
|
const { bearerFrom } = require('../auth/middleware.js');
|
|
27
28
|
|
|
@@ -142,6 +143,77 @@ function sanitizeResponseHeaders(headers) {
|
|
|
142
143
|
return out;
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
// Answers of a host-only peer: the peer is published one way, so nothing it says
|
|
147
|
+
// may act on the origin of the PWA. Its cookies, its Clear-Site-Data and its CSP
|
|
148
|
+
// are dropped; the body is served sandboxed and without MIME sniffing.
|
|
149
|
+
const HOST_ONLY_STRIPPED_HEADERS = new Set([
|
|
150
|
+
'set-cookie', 'set-cookie2', 'clear-site-data', 'content-security-policy', 'content-security-policy-report-only',
|
|
151
|
+
'x-content-type-options', 'cross-origin-opener-policy', 'cross-origin-embedder-policy', 'cross-origin-resource-policy',
|
|
152
|
+
'location', 'content-location', 'refresh',
|
|
153
|
+
]);
|
|
154
|
+
function sanitizeHostOnlyResponseHeaders(headers) {
|
|
155
|
+
const out = {};
|
|
156
|
+
for (const [k, v] of Object.entries(sanitizeResponseHeaders(headers))) {
|
|
157
|
+
if (HOST_ONLY_STRIPPED_HEADERS.has(k.toLowerCase())) continue;
|
|
158
|
+
out[k] = v;
|
|
159
|
+
}
|
|
160
|
+
out['content-security-policy'] = "sandbox; default-src 'none'";
|
|
161
|
+
out['x-content-type-options'] = 'nosniff';
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The head of an upgrade answer from a host-only peer: the same headers dropped as on the HTTP
|
|
166
|
+
// answers (cookies, Clear-Site-Data, CSP). The head is parsed the way the most lenient consumer
|
|
167
|
+
// would read it, so what is dropped here cannot reappear there: the end of the head is the first
|
|
168
|
+
// empty line (CRLF or bare LF), lines end with CRLF or bare LF, folded (continuation) lines are
|
|
169
|
+
// unfolded, a header name is a strict token. Anything that does not parse that way — a bare CR, a
|
|
170
|
+
// NUL, a line without a colon or with a malformed name, a first line that is not a status line, a
|
|
171
|
+
// head over 16 KB — is refused (the stream errors, the connection is closed) instead of passed
|
|
172
|
+
// along. Bytes after the head pass as they are.
|
|
173
|
+
const HOST_ONLY_HEAD_DROPPED = new Set(['set-cookie', 'set-cookie2', 'clear-site-data', 'content-security-policy', 'content-security-policy-report-only']);
|
|
174
|
+
const HOST_ONLY_HEAD_MAX = 16384;
|
|
175
|
+
function sanitizeUpgradeHead(headText) {
|
|
176
|
+
if (/\0/.test(headText) || /\r(?!\n)/.test(headText)) throw new Error('upgrade head with a NUL or a bare CR');
|
|
177
|
+
const raw = headText.split(/\r?\n/);
|
|
178
|
+
// The ONLY answer to an upgrade that is passed along is a 101: a refusal, a 100 Continue (an interim answer
|
|
179
|
+
// that would let a second head slip through) or anything else is refused here.
|
|
180
|
+
if (!/^HTTP\/1\.[01] 101(?: [^\r\n]*)?$/.test(raw[0])) throw new Error('upgrade answer of a host-only peer is not a 101');
|
|
181
|
+
const logical = [];
|
|
182
|
+
for (const line of raw.slice(1)) {
|
|
183
|
+
if (/^[ \t]/.test(line)) { // obs-fold: continuation of the previous header
|
|
184
|
+
if (!logical.length) throw new Error('upgrade head with a continuation before any header');
|
|
185
|
+
logical[logical.length - 1] += ` ${line.trim()}`;
|
|
186
|
+
} else logical.push(line);
|
|
187
|
+
}
|
|
188
|
+
const kept = [raw[0]];
|
|
189
|
+
for (const line of logical) {
|
|
190
|
+
const i = line.indexOf(':');
|
|
191
|
+
if (i <= 0) throw new Error('upgrade head with a malformed header line');
|
|
192
|
+
const name = line.slice(0, i).trim();
|
|
193
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) throw new Error('upgrade head with a malformed header name');
|
|
194
|
+
if (HOST_ONLY_HEAD_DROPPED.has(name.toLowerCase())) continue;
|
|
195
|
+
kept.push(`${name}:${line.slice(i + 1)}`);
|
|
196
|
+
}
|
|
197
|
+
return `${kept.join('\r\n')}\r\n\r\n`;
|
|
198
|
+
}
|
|
199
|
+
function hostOnlyHeadFilter() {
|
|
200
|
+
let buf = Buffer.alloc(0); let done = false;
|
|
201
|
+
return new Transform({
|
|
202
|
+
transform(chunk, _enc, cb) {
|
|
203
|
+
if (done) return cb(null, chunk);
|
|
204
|
+
buf = Buffer.concat([buf, chunk]);
|
|
205
|
+
const window = buf.toString('latin1', 0, Math.min(buf.length, HOST_ONLY_HEAD_MAX + 4));
|
|
206
|
+
const m = /\r?\n\r?\n/.exec(window);
|
|
207
|
+
if (!m || m.index > HOST_ONLY_HEAD_MAX) return buf.length > HOST_ONLY_HEAD_MAX ? cb(new Error('upgrade head too large')) : cb();
|
|
208
|
+
let head;
|
|
209
|
+
try { head = sanitizeUpgradeHead(window.slice(0, m.index)); } catch (e) { return cb(e); }
|
|
210
|
+
const rest = buf.subarray(m.index + m[0].length);
|
|
211
|
+
done = true; buf = null;
|
|
212
|
+
return cb(null, Buffer.concat([Buffer.from(head, 'latin1'), rest]));
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
145
217
|
function notFound(res) { res.status(404).json({ error: 'not found' }); }
|
|
146
218
|
|
|
147
219
|
// --- HTTP proxy middleware --------------------------------------------------
|
|
@@ -186,7 +258,12 @@ function proxyHttp(req, res, node, rest, search, httpRequest, timeoutMs) {
|
|
|
186
258
|
let upstream;
|
|
187
259
|
try {
|
|
188
260
|
upstream = httpRequest(options, (up) => {
|
|
189
|
-
|
|
261
|
+
if (node.hostOnly === true && up.statusCode >= 300 && up.statusCode < 400) {
|
|
262
|
+
// A redirect from a host-only peer would be followed by the browser WITH the owner's Authorization.
|
|
263
|
+
up.resume();
|
|
264
|
+
return res.status(502).json({ error: 'redirect refused', reason: 'host-only-redirect' });
|
|
265
|
+
}
|
|
266
|
+
res.writeHead(up.statusCode, (node.hostOnly === true ? sanitizeHostOnlyResponseHeaders : sanitizeResponseHeaders)(up.headers));
|
|
190
267
|
up.pipe(res);
|
|
191
268
|
});
|
|
192
269
|
} catch (_) {
|
|
@@ -327,7 +404,13 @@ function handleNodeUpgrade(ctx) {
|
|
|
327
404
|
socket.on('error', () => { try { upstream.destroy(); } catch (_) {} });
|
|
328
405
|
upstream.on('error', () => { try { socket.destroy(); } catch (_) {} });
|
|
329
406
|
socket.pipe(upstream);
|
|
330
|
-
upstream
|
|
407
|
+
let down = upstream;
|
|
408
|
+
if (node.hostOnly === true) {
|
|
409
|
+
const filter = hostOnlyHeadFilter();
|
|
410
|
+
filter.on('error', () => { try { upstream.destroy(); } catch (_) {} abortUpgrade(socket, 502); });
|
|
411
|
+
down = upstream.pipe(filter);
|
|
412
|
+
}
|
|
413
|
+
down.pipe(socket);
|
|
331
414
|
});
|
|
332
415
|
upstream.on('error', () => { clearTimeout(timer); fail(502); });
|
|
333
416
|
}
|
|
@@ -340,7 +423,7 @@ module.exports = {
|
|
|
340
423
|
createNodeProxy,
|
|
341
424
|
handleNodeUpgrade,
|
|
342
425
|
// esposti per i test
|
|
343
|
-
splitNodePath, isTransitiveRest, sanitizeRequestHeaders, sanitizeResponseHeaders,
|
|
426
|
+
splitNodePath, isTransitiveRest, sanitizeRequestHeaders, sanitizeResponseHeaders, sanitizeHostOnlyResponseHeaders, hostOnlyHeadFilter,
|
|
344
427
|
buildUpgradeRequest, stripLocalTokenQuery, isStrippedRequestHeader, connectionOptions, bearerFromUpgrade,
|
|
345
428
|
MUTATING, HOP_BY_HOP, PROXY_TIMEOUT_MS,
|
|
346
429
|
};
|
package/lib/server.js
CHANGED
|
@@ -501,7 +501,7 @@ function createServer(opts = {}) {
|
|
|
501
501
|
if (!st) return null;
|
|
502
502
|
const node = nodesStore.getNode(st, name);
|
|
503
503
|
if (!node) return null;
|
|
504
|
-
return { localPort: node.localPort, token: node.token || null };
|
|
504
|
+
return { localPort: node.localPort, token: node.token || null, hostOnly: node.publishRemote === true };
|
|
505
505
|
}
|
|
506
506
|
|
|
507
507
|
const runtimePort = () => (server && server.address() ? server.address().port : cfg.port);
|
|
@@ -994,6 +994,15 @@ function createServer(opts = {}) {
|
|
|
994
994
|
const resource = req.url.slice(i + 2);
|
|
995
995
|
if (!/^\/panel\/[A-Za-z0-9._-]{1,32}(?:\/.*)?$/.test(resource)) return next();
|
|
996
996
|
if (req.method === 'POST' && /^\/panel\/[A-Za-z0-9._-]{1,32}\/ticket\/?$/.test(resource)) return next();
|
|
997
|
+
// The unauthenticated door exists only for a browser frame, which carries no
|
|
998
|
+
// header and arrives on this machine's own listener. It never opens for: a
|
|
999
|
+
// request with ANY Authorization (the local token goes through requireToken like
|
|
1000
|
+
// every other /api call; a peer's token is not the owner's); a request that came
|
|
1001
|
+
// in on a peer-bound reverse listener; a route that names a host-only peer at
|
|
1002
|
+
// any hop. Those fall through to requireToken, whatever the first hop is.
|
|
1003
|
+
const onPeerListener = reverseSlotListeners && reverseSlotListeners.isSlotPort(req.socket && req.socket.localPort);
|
|
1004
|
+
if (bearerFrom(req) || onPeerListener
|
|
1005
|
+
|| federation.touchesHostOnly(nodesPath, { route: federation.routeNamesOf(req.url) })) return next();
|
|
997
1006
|
panelFederatoRouter(req, res, next);
|
|
998
1007
|
});
|
|
999
1008
|
api.use(requireToken(tokenStore));
|
|
@@ -1161,12 +1170,28 @@ function createServer(opts = {}) {
|
|
|
1161
1170
|
}
|
|
1162
1171
|
return out;
|
|
1163
1172
|
};
|
|
1164
|
-
|
|
1173
|
+
// A host-only peer is named only to the readers it is published to; a federated
|
|
1174
|
+
// reader is identified by the verified hop proof, never by the bare header.
|
|
1175
|
+
const hostOnlyNodesFor = (req) => {
|
|
1176
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
1177
|
+
const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
|
|
1178
|
+
if (!st || vis.unreadable) return [];
|
|
1179
|
+
return st.nodes.filter((n) => n.publishRemote === true && !vis.hiddenNames.has(n.name)).map((n) => n.name);
|
|
1180
|
+
};
|
|
1181
|
+
const nodePanelPortsFor = (req) => {
|
|
1182
|
+
const out = nodePanelPorts();
|
|
1183
|
+
const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
|
|
1184
|
+
for (const name of vis.hiddenNames) delete out[name];
|
|
1185
|
+
if (vis.unreadable) return {};
|
|
1186
|
+
return out;
|
|
1187
|
+
};
|
|
1188
|
+
api.get('/config', (req, res) => res.json({
|
|
1165
1189
|
readonlyDefault: cfg.readonlyDefault, version: VERSION, uiVersion: uiBuildVersion(distDir),
|
|
1166
1190
|
bind: cfg.bind, port: cfg.port, panelPort: cfg.panelPort,
|
|
1167
1191
|
protectSharedTmuxServer: cfg.protectSharedTmuxServer !== false,
|
|
1168
1192
|
instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
|
|
1169
|
-
nodePanelPorts:
|
|
1193
|
+
nodePanelPorts: nodePanelPortsFor(req),
|
|
1194
|
+
hostOnlyNodes: hostOnlyNodesFor(req),
|
|
1170
1195
|
presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
|
|
1171
1196
|
// i parametri del terminale sono configurabili dal server e leggibili
|
|
1172
1197
|
// dal client (che ne ha comunque i default iniettabili).
|
|
@@ -1913,6 +1938,9 @@ function createServer(opts = {}) {
|
|
|
1913
1938
|
},
|
|
1914
1939
|
localPort: () => (server && server.address() ? server.address().port : cfg.port),
|
|
1915
1940
|
localToken: () => tokenHolder.value,
|
|
1941
|
+
// A federated request that terminates here is never re-sent to a host-only
|
|
1942
|
+
// peer (checked on the target and route actually resolved, store read now).
|
|
1943
|
+
federatedGuard: ({ target, route }) => federation.touchesHostOnly(nodesPath, { target, route }),
|
|
1916
1944
|
}),
|
|
1917
1945
|
getGroup: (name) => audioGroups.getGroup(audioCfg, name, audioHome),
|
|
1918
1946
|
}));
|
|
@@ -1971,7 +1999,15 @@ function createServer(opts = {}) {
|
|
|
1971
1999
|
peers: federatedPeers,
|
|
1972
2000
|
localPort: () => (server && server.address() ? server.address().port : cfg.port),
|
|
1973
2001
|
localToken: () => tokenHolder.value,
|
|
2002
|
+
// Never designate, query or bridge to a host-only peer, whatever the route.
|
|
2003
|
+
ownerGuard: ({ target, route }) => federation.touchesHostOnly(nodesPath, { target, route }),
|
|
1974
2004
|
}),
|
|
2005
|
+
// A designation that already points at a host-only peer is not shown to a
|
|
2006
|
+
// federated reader outside its audience (it would name the peer).
|
|
2007
|
+
ownerHiddenFor: (req, ownerId) => {
|
|
2008
|
+
const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
|
|
2009
|
+
return vis.unreadable || vis.hiddenIds.has(ownerId);
|
|
2010
|
+
},
|
|
1975
2011
|
originResolver: createOriginResolver({ localNodeId: federatedNodeId, hopSecret: () => hopSecret }),
|
|
1976
2012
|
federatedRate: createSpeakRateLimiter(),
|
|
1977
2013
|
}));
|
|
@@ -1981,6 +2017,30 @@ function createServer(opts = {}) {
|
|
|
1981
2017
|
api.use('/vl-nodes', vlNodeApiRoutes({
|
|
1982
2018
|
storePath: vlNodesPath, broker: vlNodeBroker, ownerId: vlOwnerId, readonly: proxyReadonly,
|
|
1983
2019
|
}));
|
|
2020
|
+
// A deck keeps references to nodes (tiles, floating windows): those that point at a host-only peer are not
|
|
2021
|
+
// served to a federated reader outside its audience, in any answer (the 409 carries the current deck too).
|
|
2022
|
+
api.use('/decks', (req, res, next) => {
|
|
2023
|
+
const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
|
|
2024
|
+
if (!vis.hiddenNames.size && !vis.hiddenIds.size && !vis.unreadable) return next();
|
|
2025
|
+
// A reader served a filtered deck must not be able to save it back and erase what it was not shown:
|
|
2026
|
+
// changing a deck that carries references it cannot see is refused (and says nothing about them).
|
|
2027
|
+
if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS') {
|
|
2028
|
+
const first = String(req.url || '').split('?')[0].split('/').filter(Boolean)[0];
|
|
2029
|
+
let target = null; try { target = first ? decodeURIComponent(first) : null; } catch (_) { target = ''; }
|
|
2030
|
+
if (vis.unreadable) return res.status(503).json({ error: 'non disponibile' });
|
|
2031
|
+
let hiddenContent = false;
|
|
2032
|
+
try {
|
|
2033
|
+
// creating a deck (no name in the path) overwrites nothing: only the deck being changed is looked at
|
|
2034
|
+
const st = target === null ? null : decksStore.loadStore(decksPath);
|
|
2035
|
+
const touched = (st ? st.decks : []).filter((d) => d.name === target);
|
|
2036
|
+
hiddenContent = touched.some((d) => JSON.stringify(federation.redactHostOnlyReferences(d, vis)) !== JSON.stringify(d));
|
|
2037
|
+
} catch (_) { return res.status(503).json({ error: 'non disponibile' }); }
|
|
2038
|
+
if (hiddenContent) return res.status(403).json({ error: 'deck non modificabile da questo nodo', reason: 'deck-has-hidden-content' });
|
|
2039
|
+
}
|
|
2040
|
+
const json = res.json.bind(res);
|
|
2041
|
+
res.json = (body) => json(vis.unreadable ? { error: 'non disponibile' } : federation.redactHostOnlyReferences(body, vis));
|
|
2042
|
+
return next();
|
|
2043
|
+
});
|
|
1984
2044
|
api.use('/decks', decksRoutes({ cfg, decksPath }));
|
|
1985
2045
|
api.use('/prefs', prefsRoutes({ cfg, prefsPath }));
|
|
1986
2046
|
api.use('/fs', fsRoutes({ home: os.homedir() })); // folder-picker del dialog new session
|
|
@@ -2067,9 +2127,37 @@ function createServer(opts = {}) {
|
|
|
2067
2127
|
audio: { adapter: audioAdapter, queue: audioQueue },
|
|
2068
2128
|
reverseSlots: { ensure: startRotatableReverse, close: stopRotatableReverse, verify: verifyRotatablePool },
|
|
2069
2129
|
}));
|
|
2130
|
+
// Records that name a host-only peer are served only to the readers it is
|
|
2131
|
+
// published to. The WHOLE record is inspected (name or id anywhere in it).
|
|
2132
|
+
api.use('/diagnostics', (req, res, next) => {
|
|
2133
|
+
const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
|
|
2134
|
+
if (!vis.hiddenNames.size && !vis.hiddenIds.size && !vis.unreadable) return next();
|
|
2135
|
+
const esc = (v) => String(v).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2136
|
+
const names = [...vis.hiddenNames].map((n) => new RegExp(`(^|[^A-Za-z0-9_-])${esc(n)}([^A-Za-z0-9_-]|$)`));
|
|
2137
|
+
const ids = [...vis.hiddenIds];
|
|
2138
|
+
const mentions = (rec) => { const t = JSON.stringify(rec); return ids.some((id) => t.includes(id)) || names.some((re) => re.test(t)); };
|
|
2139
|
+
const json = res.json.bind(res);
|
|
2140
|
+
res.json = (body) => {
|
|
2141
|
+
if (vis.unreadable) return json({ error: 'non disponibile' });
|
|
2142
|
+
if (body && Array.isArray(body.records)) return json({ ...body, records: body.records.filter((r) => !mentions(r)) });
|
|
2143
|
+
return json(body);
|
|
2144
|
+
};
|
|
2145
|
+
return next();
|
|
2146
|
+
});
|
|
2070
2147
|
api.use('/diagnostics', diagnosticsRoutes({ diagnostics, readonly: proxyReadonly }));
|
|
2071
|
-
api.get('/topology', async (
|
|
2072
|
-
try {
|
|
2148
|
+
api.get('/topology', async (req, res) => {
|
|
2149
|
+
try {
|
|
2150
|
+
// What was hidden when the request began stays hidden: a publication that ends during the wait does not
|
|
2151
|
+
// hand its leaf to a reader that was not allowed to see it when it asked.
|
|
2152
|
+
const visBefore = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
|
|
2153
|
+
const topo = await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath });
|
|
2154
|
+
// Decided AFTER the fan-out wait and on the final result, cache and stale
|
|
2155
|
+
// entries included: the policy is read again now, not before the await.
|
|
2156
|
+
const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
|
|
2157
|
+
if (vis.unreadable || visBefore.unreadable) return res.json({ instanceId: topo && topo.instanceId, nodes: [] });
|
|
2158
|
+
const merged = { hiddenNames: new Set([...visBefore.hiddenNames, ...vis.hiddenNames]), hiddenIds: new Set([...visBefore.hiddenIds, ...vis.hiddenIds]) };
|
|
2159
|
+
res.json({ ...topo, nodes: federation.hideHostOnlyEntries(topo && topo.nodes, merged) });
|
|
2160
|
+
}
|
|
2073
2161
|
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
2074
2162
|
});
|
|
2075
2163
|
api.use('/route', federation.localRouter({
|
|
@@ -2208,7 +2296,10 @@ function createServer(opts = {}) {
|
|
|
2208
2296
|
// emesso): transita e decide il proprietario, gate panelAccess compreso
|
|
2209
2297
|
// — stessa forma del bypass HTTP sulle panel-resource federate.
|
|
2210
2298
|
const panelFederato = /^\/api\/route\/[^?#]*\/_\/panel\/[A-Za-z0-9._-]{1,32}(?:\/.*)?$/.test(pathname)
|
|
2211
|
-
&& /(?:^|;\s*)npanel=/.test(String(req.headers.cookie || ''))
|
|
2299
|
+
&& /(?:^|;\s*)npanel=/.test(String(req.headers.cookie || ''))
|
|
2300
|
+
&& !bearerFrom(req) && !u.searchParams.get('token')
|
|
2301
|
+
&& !(reverseSlotListeners && reverseSlotListeners.isSlotPort(req.socket && req.socket.localPort))
|
|
2302
|
+
&& !federation.touchesHostOnly(nodesPath, { route: federation.routeNamesOf(pathname.slice('/api/route'.length)) });
|
|
2212
2303
|
if (!panelFederato && !verify(tokenHolder.value, given)) return socket.destroy();
|
|
2213
2304
|
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret, hubAttaches });
|
|
2214
2305
|
return;
|
|
@@ -2296,7 +2387,7 @@ function createServer(opts = {}) {
|
|
|
2296
2387
|
|
|
2297
2388
|
return {
|
|
2298
2389
|
app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP, updater, diagnostics,
|
|
2299
|
-
panelApp, panelServer, panelServerV6,
|
|
2390
|
+
panelApp, panelServer, panelServerV6, reverseSlotListeners,
|
|
2300
2391
|
// Test/observability seam: publish a normalized state event through the
|
|
2301
2392
|
// same per-peer projection the local producers use.
|
|
2302
2393
|
notify: { publishState: notifier.publishState },
|
package/package.json
CHANGED