@mmmbuto/nexuscrew 0.9.66 → 0.9.68

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.
@@ -11,7 +11,7 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-DhZOap1-.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-Bq57nMaf.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-Dwd37Z_R.css">
16
16
  </head>
17
17
  <body>
@@ -1 +1 @@
1
- {"version":"0.9.66"}
1
+ {"version":"0.9.68"}
@@ -30,6 +30,10 @@ function createDispatcher(opts = {}) {
30
30
  // stesso percorso e rispondono `delivered`. Il default resta quello audio,
31
31
  // cosi' nessun chiamante esistente cambia comportamento.
32
32
  const statuses = opts.statuses instanceof Set ? opts.statuses : PER_ENDPOINT_STATUS;
33
+ // Policy for requests that arrived FEDERATED and terminate here: re-read on the
34
+ // resolution used for the I/O, just before the request leaves. Local callers are
35
+ // never subject to it.
36
+ const federatedGuard = typeof opts.federatedGuard === 'function' ? opts.federatedGuard : null;
33
37
 
34
38
  function isLocal(target) {
35
39
  const self = localNodeId();
@@ -143,10 +147,19 @@ function createDispatcher(opts = {}) {
143
147
  resolveRoute,
144
148
  probeCapability,
145
149
  // dispatch(): unico ingresso per un target remoto.
146
- async dispatch({ resource, target, origin, payload }) {
150
+ async dispatch({ resource, target, origin, payload, federated = false }) {
151
+ // What the policy says when the request begins counts too: a publication that ends while the topology is
152
+ // awaited does not turn the request into an allowed one.
153
+ let deniedBefore = false;
154
+ if (federated && federatedGuard) { try { deniedBefore = federatedGuard({ target, route: [] }) !== false; } catch (_) { deniedBefore = true; } }
147
155
  const resolved = await resolveRoute(target);
148
156
  if (resolved.error === 'invalid-target') return { status: 'refused', reason: 'invalid-target' };
149
157
  if (resolved.error) return { status: 'unreachable', reason: resolved.error };
158
+ if (federated && federatedGuard) {
159
+ let denied = true;
160
+ try { denied = federatedGuard({ target, route: resolved.route, peer: resolved.peer }) !== false; } catch (_) { denied = true; }
161
+ if (denied || deniedBefore) return { status: 'refused', reason: 'host-only-target' };
162
+ }
150
163
  return forward(resource, resolved.route, {
151
164
  ...payload,
152
165
  target,
@@ -251,7 +251,7 @@ function audioRoutes(deps = {}) {
251
251
  origin, target, status: 'accepted', utteranceId, attested: trust === 'federated',
252
252
  });
253
253
  const out = await dispatcher.dispatch({
254
- resource: '/audio/speak', target, origin,
254
+ resource: '/audio/speak', target, origin, federated: trust === 'federated',
255
255
  payload: {
256
256
  text: parsed.text, lang: parsed.lang, voice: parsed.voice, urgency: parsed.urgency,
257
257
  utteranceId: local.utteranceId,
@@ -285,7 +285,7 @@ function audioRoutes(deps = {}) {
285
285
  const { target } = parsed;
286
286
  if (dispatcher && isValidInstance(target) && !dispatcher.isLocal(target)) {
287
287
  const out = await dispatcher.dispatch({
288
- resource: '/audio/stop', target, origin,
288
+ resource: '/audio/stop', target, origin, federated: resolved.trust === 'federated',
289
289
  payload: { ...(parsed.utteranceId ? { utteranceId: parsed.utteranceId } : {}) },
290
290
  });
291
291
  return send(res, out.status === 'accepted' ? 200 : out.status === 'unreachable' ? 503 : 422, out);
@@ -30,6 +30,9 @@ function createLiveHostFederation({
30
30
  localToken = () => '',
31
31
  fetchImpl = fetch,
32
32
  timeoutMs = FED_TIMEOUT_MS,
33
+ // Refuses an owner that is (or routes through) a host-only peer. Read on the
34
+ // resolution used for the I/O, just before the request leaves.
35
+ ownerGuard = null,
33
36
  } = {}) {
34
37
  async function resolveRoute(target) {
35
38
  if (!INSTANCE_ID_RE.test(String(target || ''))) return { error: 'invalid-target' };
@@ -48,11 +51,18 @@ function createLiveHostFederation({
48
51
  }
49
52
 
50
53
  async function forward(target, resource, method, body) {
54
+ let deniedBefore = false;
55
+ if (typeof ownerGuard === 'function') { try { deniedBefore = ownerGuard({ target, route: [] }) !== false; } catch (_) { deniedBefore = true; } }
51
56
  const resolved = await resolveRoute(target);
52
57
  if (resolved.error) return { ok: false, reason: resolved.error === 'invalid-target' ? 'invalid-target' : 'live-host-owner-unreachable' };
53
58
  const port = localPort();
54
59
  const token = localToken();
55
60
  if (!port || !token) return { ok: false, reason: 'live-host-owner-unreachable' };
61
+ if (typeof ownerGuard === 'function') {
62
+ let denied = true;
63
+ try { denied = ownerGuard({ target, route: resolved.route }) !== false; } catch (_) { denied = true; }
64
+ if (denied || deniedBefore) return { ok: false, reason: 'live-host-owner-unreachable' };
65
+ }
56
66
  const url = `http://127.0.0.1:${port}/api/route/${resolved.route.join('/')}/_${resource}`;
57
67
  let res;
58
68
  try {
@@ -95,7 +95,10 @@ function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Dat
95
95
  // verifica (prova di hop + catena `visited` costruita dal server), e il budget
96
96
  // del ponte si applica per NODO mittente ATTESTATO — stessa regola e stessi
97
97
  // limiti dell'audio (lib/audio/rate-limit.js).
98
- federation = null, originResolver = null, federatedRate = null }) {
98
+ federation = null, originResolver = null, federatedRate = null,
99
+ // (req, ownerId) => true when this reader must not learn that the designation
100
+ // points at a host-only peer.
101
+ ownerHiddenFor = null }) {
99
102
  const r = express.Router();
100
103
 
101
104
  // Una designazione con `ownerId` e' REMOTA: il puntamento vive qui,
@@ -124,11 +127,18 @@ function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Dat
124
127
  // hostCell e revision vengono dallo store (preservato); eligible e' la verita'
125
128
  // COMPOSTA roster+lease (si veda hostLeaseState sopra); host.lease espone lo
126
129
  // stato del lease della cella designata, distinto, perche' chi legge distingue.
127
- r.get('/', async (_req, res) => {
130
+ r.get('/', async (req, res) => {
128
131
  try {
129
132
  const snap = store.snapshot();
133
+ if (snap.hostCell != null && snap.ownerId && ownerHiddenFor && ownerHiddenFor(req, snap.ownerId)) {
134
+ return res.json({ hostCell: null, revision: snap.revision, eligible: false, threadStatus: 'absent', host: { lease: null }, at: now() });
135
+ }
130
136
  if (snap.hostCell != null && snap.ownerId) {
131
137
  const remote = await remoteOwnerState(snap);
138
+ // the reader is judged again after the wait: a publication that changed meanwhile must not name the owner
139
+ if (ownerHiddenFor && ownerHiddenFor(req, snap.ownerId)) {
140
+ return res.json({ hostCell: null, revision: snap.revision, eligible: false, threadStatus: 'absent', host: { lease: null }, at: now() });
141
+ }
132
142
  return res.json({
133
143
  hostCell: snap.hostCell, revision: snap.revision, ownerId: snap.ownerId, remote: true,
134
144
  eligible: remote.state ? remote.state.eligible : false,
@@ -83,7 +83,10 @@ function createReverseSlotListeners({ app, createServerImpl = http.createServer,
83
83
  return true;
84
84
  }
85
85
 
86
- return { open, closePort, closeAll, respond, size: () => listeners.size };
86
+ // A request that arrived on one of these listeners comes from a peer's channel,
87
+ // never from this machine's own browser: callers use it to tell the two apart.
88
+ const isSlotPort = (localPort) => listeners.has(localPort);
89
+ return { open, closePort, closeAll, respond, isSlotPort, size: () => listeners.size };
87
90
  }
88
91
 
89
92
  module.exports = { createReverseSlotListeners };
@@ -958,8 +958,16 @@ function backupStoreFile(filePath, now = new Date()) {
958
958
  const data = fs.readFileSync(filePath);
959
959
  const sha256 = crypto.createHash('sha256').update(data).digest('hex');
960
960
  const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
961
- const target = `${filePath}.pre-publish-remote-${stamp}-${sha256.slice(0, 8)}.bak`;
962
- fs.writeFileSync(target, data, { mode: 0o600, flag: 'wx' }); // wx: never overwrite a previous backup
961
+ // wx: never overwrite a previous backup. Two backups of the same bytes in the same second get distinct names
962
+ // (a numeric suffix) instead of failing the opt-in.
963
+ let target = '';
964
+ for (let n = 0; ; n += 1) {
965
+ target = `${filePath}.pre-publish-remote-${stamp}-${sha256.slice(0, 8)}${n ? `-${n}` : ''}.bak`;
966
+ try { fs.writeFileSync(target, data, { mode: 0o600, flag: 'wx' }); break; } catch (e) {
967
+ if (e && e.code === 'EEXIST' && n < 1000) continue;
968
+ throw e;
969
+ }
970
+ }
963
971
  fs.chmodSync(target, 0o600);
964
972
  return { path: target, sha256 };
965
973
  }
@@ -3,6 +3,8 @@
3
3
  const { createAskSlotVerifier, askSlotVerifier, binding: askSlotBinding } = require('../nodes/ask-slot-verifier.js');
4
4
  const http = require('node:http');
5
5
  const net = require('node:net');
6
+ const fs = require('node:fs');
7
+ const path = require('node:path');
6
8
  const crypto = require('node:crypto');
7
9
  const express = require('express');
8
10
  const { WebSocketServer } = require('ws');
@@ -15,9 +17,9 @@ const { bearerFrom } = require('../auth/middleware.js');
15
17
  const { safeEqual } = require('../nodes/peering.js');
16
18
  const { probeReverseSlot } = require('../nodes/reverse-slot-proof.js');
17
19
  const {
18
- sanitizeRequestHeaders, sanitizeResponseHeaders, stripLocalTokenQuery,
20
+ sanitizeRequestHeaders, sanitizeResponseHeaders, sanitizeHostOnlyResponseHeaders, hostOnlyHeadFilter, stripLocalTokenQuery,
19
21
  } = require('./node-proxy.js');
20
- const { signHop, HOP_HEADER } = require('./hop-proof.js');
22
+ const { signHop, verifyHop, HOP_HEADER } = require('./hop-proof.js');
21
23
  const { COOKIE_NAME: PANEL_COOKIE_NAME } = require('./panel-auth.js');
22
24
 
23
25
  const MAX_HOPS = 4;
@@ -144,6 +146,175 @@ function canTransit(ingress, egress) {
144
146
  && peerAllows(ingress, egress.nodeId) && peerAllows(egress, ingress.nodeId);
145
147
  }
146
148
 
149
+ // The audience decision for a CHAIN, not only for the last hop. A host-only peer
150
+ // is published to its audience: every node that took part in the request (the
151
+ // ones in `visited`, this node excluded) must be an audience member that still
152
+ // holds the operator grant. An id this node does not know is refused — the chain
153
+ // is what the peers declared, so only what the local store can vouch for counts.
154
+ function chainInAudience(st, chain, egress) {
155
+ if (!isHostOnlyPeer(egress)) return true;
156
+ if (!st || !Array.isArray(st.nodes) || !Array.isArray(chain)) return false;
157
+ for (const id of chain) {
158
+ if (id === st.nodeId) continue;
159
+ const reader = st.nodes.find((n) => n && n.nodeId === id);
160
+ if (!reader || !audienceAllows(reader, egress)) return false;
161
+ }
162
+ return true;
163
+ }
164
+
165
+ // Does this target, or any hop of the route to it, resolve to a host-only peer
166
+ // of THIS node? Read from the store at the time of the call, never from a cache.
167
+ function touchesHostOnly(nodesPath, { target = null, route = [] } = {}) {
168
+ const st = store.loadStore(nodesPath);
169
+ if (!st) return true; // fail closed: an unreadable store vouches for nothing
170
+ const names = Array.isArray(route) ? route : [];
171
+ return st.nodes.some((n) => isHostOnlyPeer(n) && ((target && n.nodeId === target) || names.includes(n.name)));
172
+ }
173
+
174
+ // The first segments of `/<hop>/<hop>/_/...` (what is left after the /route mount).
175
+ function routeNamesOf(rest) {
176
+ const parts = String(rest || '').split('?')[0].split('/').filter(Boolean);
177
+ const idx = parts.indexOf(ROUTE_DELIMITER);
178
+ return idx < 0 ? [] : parts.slice(0, idx);
179
+ }
180
+
181
+ // The identity of the caller of a LOCAL API handler that is the last hop of a
182
+ // federated route. It exists only when the hop header verifies (HMAC over method,
183
+ // path and chain, the same proof audio/origin.js reads); the bare presence of the
184
+ // header proves nothing. Returns the verified chain, or null.
185
+ function verifiedHopChain(req, st, hopSecret) {
186
+ const headers = (req && req.headers) || {};
187
+ const proof = headers[HOP_HEADER];
188
+ const secret = typeof hopSecret === 'function' ? hopSecret() : hopSecret;
189
+ const visited = String(headers['x-nexuscrew-visited'] || '').split(',').filter(Boolean);
190
+ if (!secret || !st || typeof proof !== 'string' || visited.length < 2 || visited.length > MAX_HOPS + 1) return null;
191
+ if (visited.some((id) => !store.NODE_ID_RE.test(id)) || new Set(visited).size !== visited.length) return null;
192
+ if (visited.at(-1) !== st.nodeId) return null;
193
+ if (!verifyHop(secret, { method: req.method || 'GET', path: req.originalUrl || req.url, visited }, proof)) return null;
194
+ return visited;
195
+ }
196
+
197
+ // Which host-only peers a local API caller must NOT learn about. The owner (a
198
+ // request without the hop header) sees everything; a federated caller sees only
199
+ // the peers whose audience contains its whole verified chain; any header that
200
+ // does not verify, or an unknown id, hides all of them. Read fresh each time.
201
+ function hostOnlyVisibility(nodesPath, req, hopSecret) {
202
+ const hiddenNames = new Set(); const hiddenIds = new Set();
203
+ const federated = Boolean(req && req.headers && req.headers[HOP_HEADER] !== undefined);
204
+ // The owner (no hop header) is never filtered, whatever the state of the store.
205
+ if (!federated) return { hiddenNames, hiddenIds, unreadable: false };
206
+ const st = store.loadStore(nodesPath);
207
+ if (!st) return { hiddenNames, hiddenIds, unreadable: true };
208
+ const chain = verifiedHopChain(req, st, hopSecret);
209
+ for (const n of st.nodes) {
210
+ if (!isHostOnlyPeer(n)) continue;
211
+ const visible = chain !== null && chainInAudience(st, chain, n);
212
+ if (!visible) { hiddenNames.add(n.name); if (n.nodeId) hiddenIds.add(n.nodeId); }
213
+ }
214
+ return { hiddenNames, hiddenIds, unreadable: false };
215
+ }
216
+
217
+ // Topology entries of hidden host-only peers, and anything announced behind one.
218
+ function hideHostOnlyEntries(nodes, vis) {
219
+ if (!Array.isArray(nodes) || (!vis.hiddenNames.size && !vis.hiddenIds.size)) return nodes;
220
+ return nodes.filter((e) => !vis.hiddenIds.has(e && e.instanceId)
221
+ && !(e && Array.isArray(e.route) && e.route.some((name) => vis.hiddenNames.has(name))));
222
+ }
223
+
224
+ // Removes, from any JSON value, the objects that point at a hidden host-only peer: by route name (`node`, a path of
225
+ // names, or `route`), or by instance id (`ownerId`, `nodeId`, `instanceId`). Used on the answers of handlers that
226
+ // store references to nodes (decks).
227
+ function redactHostOnlyReferences(value, vis) {
228
+ if (!vis || (!vis.hiddenNames.size && !vis.hiddenIds.size)) return value;
229
+ const points = (o) => o && typeof o === 'object' && !Array.isArray(o) && (
230
+ (typeof o.node === 'string' && o.node.split('/').some((n) => vis.hiddenNames.has(n)))
231
+ || ['ownerId', 'nodeId', 'instanceId'].some((k) => typeof o[k] === 'string' && vis.hiddenIds.has(o[k]))
232
+ || (Array.isArray(o.route) && o.route.some((n) => vis.hiddenNames.has(n))));
233
+ const walk = (v) => {
234
+ if (Array.isArray(v)) return v.filter((e) => !points(e)).map(walk);
235
+ if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
236
+ return v;
237
+ };
238
+ return walk(value);
239
+ }
240
+
241
+ // Streams (SSE, WebSocket) opened towards a host-only peer for a peer caller:
242
+ // the authorization is re-read about every second and a stream that no longer
243
+ // passes (audience, operator grant, mode off, peer or reader removed, store
244
+ // unreadable) is closed. Owner streams are not tracked.
245
+ const HOST_ONLY_RECHECK_MS = 1000;
246
+ const hostOnlyStreams = new Set();
247
+ let hostOnlyTimer = null;
248
+ const credentialFingerprint = (v) => crypto.createHash('sha256').update(String(v || '')).digest('hex');
249
+ // The generation of the publication a stream was opened under. Every opt-in takes its
250
+ // own full backup next to the store (never overwritten), so the SET of those backups
251
+ // changes whenever the mode is turned on again — even with the same audience, even
252
+ // within the same second (names are not ordered in time, so the whole set is read, not
253
+ // the "newest"). A removed backup changes it too. Unreadable directory: a value that
254
+ // never matches (fail closed).
255
+ function publicationGeneration(nodesPath) {
256
+ try {
257
+ const base = `${path.basename(nodesPath)}.pre-publish-remote-`;
258
+ const names = fs.readdirSync(path.dirname(nodesPath)).filter((n) => n.startsWith(base) && n.endsWith('.bak')).sort();
259
+ return crypto.createHash('sha256').update(names.join('\n')).digest('hex');
260
+ } catch (_) { return `unreadable:${Date.now()}:${Math.random()}`; }
261
+ }
262
+ const audienceFingerprint = (egress) => crypto.createHash('sha256').update(JSON.stringify([...(egress.publishRemoteAudience || [])].sort())).digest('hex');
263
+ function hostOnlyGuardFor(nodesPath, ingress, egress, visited) {
264
+ return {
265
+ nodesPath, ingressNodeId: ingress.nodeId, ingressFp: credentialFingerprint(ingress.acceptToken),
266
+ egressName: egress.name, egressNodeId: egress.nodeId, egressPort: egress.localPort,
267
+ egressFp: credentialFingerprint(egress.token),
268
+ audienceFp: audienceFingerprint(egress), generation: publicationGeneration(nodesPath), visited,
269
+ };
270
+ }
271
+ // A stream opened through a peer that was NOT host-only keeps the behaviour it always had, with one exception: when
272
+ // the publication is turned ON for that peer, every federated stream in flight through it ends.
273
+ function plainStreamGuardFor(nodesPath, egress) {
274
+ return { plain: true, nodesPath, egressName: egress.name, egressNodeId: egress.nodeId };
275
+ }
276
+ function streamGuardFor(nodesPath, ingress, egress, visited) {
277
+ return isHostOnlyPeer(egress) ? hostOnlyGuardFor(nodesPath, ingress, egress, visited) : plainStreamGuardFor(nodesPath, egress);
278
+ }
279
+ function hostOnlyStreamAllowed(guard, loaded) {
280
+ const st = loaded === undefined ? store.loadStore(guard.nodesPath) : loaded;
281
+ if (guard.plain) {
282
+ if (!st) return true; // a plain stream is not judged on an unreadable store
283
+ return !st.nodes.some((n) => n && n.nodeId === guard.egressNodeId && isHostOnlyPeer(n));
284
+ }
285
+ if (!st) return false;
286
+ const egress = store.getNode(st, guard.egressName);
287
+ const reader = st.nodes.find((n) => n && n.nodeId === guard.ingressNodeId);
288
+ if (!egress || !reader || !egress.token || !canTransit(reader, egress)) return false;
289
+ // The stream was opened under a host-only publication: it lives only while THAT publication
290
+ // is what authorizes it. Legacy sharing (shared=true after an off) is a different authorization.
291
+ if (!isHostOnlyPeer(egress)) return false;
292
+ if (audienceFingerprint(egress) !== guard.audienceFp || publicationGeneration(guard.nodesPath) !== guard.generation) return false;
293
+ // Same identities and credentials that were authenticated when the stream opened.
294
+ if (credentialFingerprint(reader.acceptToken) !== guard.ingressFp || credentialFingerprint(egress.token) !== guard.egressFp
295
+ || egress.nodeId !== guard.egressNodeId || egress.localPort !== guard.egressPort) return false;
296
+ return chainInAudience(st, guard.visited, egress);
297
+ }
298
+ function trackHostOnlyStream(guard, close) {
299
+ if (!guard) return () => {};
300
+ const entry = { guard, close };
301
+ hostOnlyStreams.add(entry);
302
+ if (!hostOnlyTimer) {
303
+ hostOnlyTimer = setInterval(() => {
304
+ const loaded = new Map(); // the store is read once per file per tick
305
+ const load = (p) => { if (!loaded.has(p)) { let v = null; try { v = store.loadStore(p); } catch (_) { v = null; } loaded.set(p, v); } return loaded.get(p); };
306
+ for (const e of [...hostOnlyStreams]) {
307
+ let ok = false;
308
+ try { ok = hostOnlyStreamAllowed(e.guard, load(e.guard.nodesPath)); } catch (_) { ok = e.guard.plain === true; }
309
+ if (!ok) { hostOnlyStreams.delete(e); try { e.close(); } catch (_) { /* gone */ } }
310
+ }
311
+ if (!hostOnlyStreams.size) { clearInterval(hostOnlyTimer); hostOnlyTimer = null; }
312
+ }, HOST_ONLY_RECHECK_MS);
313
+ if (typeof hostOnlyTimer.unref === 'function') hostOnlyTimer.unref();
314
+ }
315
+ return () => { hostOnlyStreams.delete(entry); };
316
+ }
317
+
147
318
  function parseRoute(raw) {
148
319
  const parts = String(raw || '').split('?')[0].split('/').filter(Boolean);
149
320
  const idx = parts.indexOf(ROUTE_DELIMITER);
@@ -515,7 +686,7 @@ function liveHostLedgerCapture({ ledger, ownerId, resource }) {
515
686
  const SSE_IDLE_TIMEOUT_MS = 90000;
516
687
  const SSE_CONTENT_TYPE_RE = /^text\/event-stream/i;
517
688
 
518
- function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null, setCookiePrefix = null, panelCookie = null, capture = null, getTimeoutMs = 10000, sseIdleMs = SSE_IDLE_TIMEOUT_MS, onForwardFailure = null, abortWithClient = false }) {
689
+ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null, setCookiePrefix = null, panelCookie = null, hostOnlyEgress = false, streamGuard = null, capture = null, getTimeoutMs = 10000, sseIdleMs = SSE_IDLE_TIMEOUT_MS, onForwardFailure = null, abortWithClient = false }) {
519
690
  // A body a local middleware has already read is no longer in the stream: the
520
691
  // headers would announce bytes that nothing sends, and the peer would wait for
521
692
  // a body that never arrives. Say it at once, with a name, instead of letting
@@ -533,8 +704,23 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
533
704
  if (r.statusCode < 200 || r.statusCode >= 300) failed();
534
705
  r.once('aborted', failed); r.once('error', failed);
535
706
  const headers = setCookiePrefix ? rewriteSetCookiePath(r.headers, setCookiePrefix) : r.headers;
707
+ const cleanResponse = hostOnlyEgress ? sanitizeHostOnlyResponseHeaders : sanitizeResponseHeaders;
708
+ if (hostOnlyEgress && r.statusCode >= 300 && r.statusCode < 400) {
709
+ // A redirect from a host-only peer would be followed by the browser WITH the owner's Authorization,
710
+ // towards any route of this node or of a trusted peer: it is never passed along.
711
+ r.resume();
712
+ return res.status(502).json({ error: 'redirect refused', reason: 'host-only-redirect' });
713
+ }
714
+ // host-only: every answer; a plain peer: only the long-lived (event streams)
715
+ if (streamGuard && (!streamGuard.plain || SSE_CONTENT_TYPE_RE.test(String(r.headers['content-type'] || '')))) {
716
+ const untrack = trackHostOnlyStream(streamGuard, () => {
717
+ try { up.destroy(); } catch (_) { /* gone */ }
718
+ try { res.destroy(); } catch (_) { /* gone */ }
719
+ });
720
+ res.once('close', untrack); up.once('close', untrack);
721
+ }
536
722
  if (!capture) {
537
- res.writeHead(r.statusCode, sanitizeResponseHeaders(headers));
723
+ res.writeHead(r.statusCode, cleanResponse(headers));
538
724
  if (SSE_CONTENT_TYPE_RE.test(String(r.headers['content-type'] || ''))) {
539
725
  // The response is open: no inactivity timeout any more (the 504 below
540
726
  // still covers a peer that never answers), and the watchdog is rearmed
@@ -568,7 +754,7 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
568
754
  const body = Buffer.concat(chunks);
569
755
  try { capture(r.statusCode, body.toString('utf8')); } catch (_) { /* the ledger never breaks the forward */ }
570
756
  if (res.writableEnded) return;
571
- res.writeHead(r.statusCode, sanitizeResponseHeaders(headers));
757
+ res.writeHead(r.statusCode, cleanResponse(headers));
572
758
  res.end(body);
573
759
  });
574
760
  });
@@ -668,7 +854,16 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
668
854
  }
669
855
  const next = st && store.getNode(st, parsed.route[0]);
670
856
  const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
671
- if (!next || !next.token || privateInbound || (ingress && !canTransit(ingress, next))) return rejectAskRequest(403, { error: 'route non consentita' });
857
+ // A host-only peer has no panel and no Live host to offer: refused for the
858
+ // owner as well, and before any other decision.
859
+ if (next && isHostOnlyPeer(next) && (isPanelResource(parsed.resource) || isLiveHostResource(parsed.resource))) {
860
+ return res.status(403).json({
861
+ error: 'risorsa non disponibile verso un nodo solo-host',
862
+ reason: isPanelResource(parsed.resource) ? 'host-only-no-panel' : 'host-only-no-live-host',
863
+ });
864
+ }
865
+ if (!next || !next.token || privateInbound
866
+ || (ingress && (!canTransit(ingress, next) || !chainInAudience(st, visited, next)))) return rejectAskRequest(403, { error: 'route non consentita' });
672
867
  if (parsed.resource === '/asks' && req.method === 'POST') {
673
868
  const view = accessPresets.grantsOf(next);
674
869
  if (!view.configured || view.label !== 'admin') {
@@ -739,7 +934,7 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
739
934
  if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) {
740
935
  return rejectForward(403, { error: 'READONLY: federated mutation blocked' });
741
936
  }
742
- if (!currentNext.token || currentNext.shared !== true || (currentIngress && !canTransit(currentIngress, currentNext))) {
937
+ if (!currentNext.token || currentNext.shared !== true || (currentIngress && (!canTransit(currentIngress, currentNext) || !chainInAudience(current, visited, currentNext)))) {
743
938
  return rejectForward(403, { error: 'route non consentita' });
744
939
  }
745
940
  if (!controlledVisited(req, currentIngress, current.nodeId)) return rejectForward(409, { error: 'federation cycle rejected' });
@@ -751,6 +946,8 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
751
946
  try { JSON.parse(text); } catch (_) { invalidateProof(); }
752
947
  } : capture;
753
948
  proxyHttp(req, res, { port: forwardPort, path, credential: next.token, visited, setCookiePrefix, panelCookie, capture: actionCapture,
949
+ hostOnlyEgress: isHostOnlyPeer(next),
950
+ streamGuard: ingress ? streamGuardFor(nodesPath, ingress, next, visited) : null,
754
951
  onForwardFailure: isAskAction ? invalidateProof : null, abortWithClient: isAskAction, getTimeoutMs: forwardGetTimeoutMs, sseIdleMs: forwardSseIdleMs });
755
952
  };
756
953
  }
@@ -1226,7 +1423,7 @@ async function collectTopologyDetailed({
1226
1423
  // clients become part of Hydra only after their explicit Share toggle.
1227
1424
  if (!n.nodeId || seen.has(n.nodeId)
1228
1425
  || (!ingress && n.direction === 'inbound' && n.shared !== true)
1229
- || (ingress && !canTransit(ingress, n))) continue;
1426
+ || (ingress && (!canTransit(ingress, n) || !chainInAudience(st, [...seen, ingress.nodeId], n)))) continue;
1230
1427
  // `name` e' lo slug locale con cui si indirizza; `label` e' il nome
1231
1428
  // leggibile. Senza quest'ultimo un nodo raggiunto in transito arriva agli
1232
1429
  // altri come uno slug scelto da qualcun altro, e cinque installazioni
@@ -1289,12 +1486,38 @@ async function collectTopologyDetailed({
1289
1486
  });
1290
1487
  }
1291
1488
  }
1292
- const ids = new Set(); const routes = new Set(); const unique = [];
1489
+ const ids = new Set(); const routes = new Set(); let unique = [];
1293
1490
  for (const n of out.sort((a, b) => a.route.length - b.route.length)) {
1294
1491
  const routeKey = n.route.join('/');
1295
1492
  if (ids.has(n.instanceId) || routes.has(routeKey)) continue;
1296
1493
  ids.add(n.instanceId); routes.add(routeKey); unique.push(n);
1297
1494
  }
1495
+ if (ingress) {
1496
+ // The fan-out above waited: whatever authorized the reader at the start is read AGAIN now, before the
1497
+ // answer leaves. A host-only peer stays only if the SAME reader (same credential) still passes.
1498
+ const now = store.loadStore(nodesPath);
1499
+ const reader = now && now.nodes.find((n) => n && n.nodeId === ingress.nodeId
1500
+ && credentialFingerprint(n.acceptToken) === credentialFingerprint(ingress.acceptToken));
1501
+ const chain = [...seen, ingress.nodeId];
1502
+ // A peer is judged by the FULL rule NOW if it was host-only when the walk started or is host-only now: one that
1503
+ // stopped being host-only meanwhile (the publication ended during the wait) does not keep its leaf, and neither
1504
+ // does one whose mode, audience or chain no longer passes. Unreadable store: nothing is vouched for.
1505
+ const wasHostOnly = new Map(st.nodes.filter((n) => isHostOnlyPeer(n)).map((n) => [n.nodeId, audienceFingerprint(n)]));
1506
+ const candidates = new Map();
1507
+ for (const n of st.nodes) if (isHostOnlyPeer(n)) candidates.set(n.nodeId, n);
1508
+ for (const n of (now ? now.nodes : [])) if (isHostOnlyPeer(n)) candidates.set(n.nodeId, n);
1509
+ const closed = [...candidates.values()].filter((n) => {
1510
+ if (!now || !reader) return true;
1511
+ const cur = now.nodes.find((x) => x.nodeId === n.nodeId);
1512
+ if (!cur || !isHostOnlyPeer(cur)) return true; // the publication ended (or the peer is gone)
1513
+ if (wasHostOnly.has(n.nodeId) && wasHostOnly.get(n.nodeId) !== audienceFingerprint(cur)) return true; // another audience
1514
+ return !(canTransit(reader, cur) && chainInAudience(now, chain, cur));
1515
+ });
1516
+ if (closed.length) {
1517
+ const closedIds = new Set(closed.map((n) => n.nodeId)); const closedNames = new Set(closed.map((n) => n.name));
1518
+ unique = unique.filter((e) => !closedIds.has(e.instanceId) && !(e.direct && closedNames.has(e.name)) && !e.route.some((name, i) => i === 0 && closedNames.has(name)));
1519
+ }
1520
+ }
1298
1521
  return { instanceId: st.nodeId, nodes: unique, authoritative };
1299
1522
  }
1300
1523
 
@@ -1722,10 +1945,15 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1722
1945
  let port = typeof localPort === 'function' ? localPort() : localPort; let credential = localCredential();
1723
1946
  // Ultimo hop: `/ws` entra com'e', il pannello rientra nell'API locale.
1724
1947
  let path = parsed.resource === '/ws' ? '/ws' : `/api${parsed.resource}`;
1948
+ let hostOnlyEgress = false; let hostOnlyGuard = null;
1725
1949
  if (parsed.route.length) {
1726
1950
  const next = store.getNode(st, parsed.route[0]);
1727
1951
  const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
1728
- if (!next || !next.token || privateInbound || (ingress && !canTransit(ingress, next))) return reject(socket, 403);
1952
+ if (next && isHostOnlyPeer(next) && isPanelResource(parsed.resource)) return reject(socket, 403);
1953
+ if (!next || !next.token || privateInbound
1954
+ || (ingress && (!canTransit(ingress, next) || !chainInAudience(st, visited, next)))) return reject(socket, 403);
1955
+ hostOnlyEgress = isHostOnlyPeer(next);
1956
+ if (ingress) hostOnlyGuard = streamGuardFor(nodesPath, ingress, next, visited);
1729
1957
  port = next.localPort; credential = next.token;
1730
1958
  const rest = parsed.route.slice(1);
1731
1959
  path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}${parsed.resource}`;
@@ -1746,6 +1974,13 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1746
1974
  // cambiare), l'output va scritto grezzo (i frame li ha gia' incorniciati
1747
1975
  // l'upstream).
1748
1976
  try { socket.write(handshake); } catch (_) { /* morto */ }
1977
+ if (hostOnlyGuard) {
1978
+ const untrackTaken = trackHostOnlyStream(hostOnlyGuard, () => {
1979
+ try { socket.destroy(); } catch (_) { /* gone */ }
1980
+ try { record.up.destroy(); } catch (_) { /* gone */ }
1981
+ });
1982
+ record.up.once('close', untrackTaken);
1983
+ }
1749
1984
  // Gli eventuali byte gia' arrivati dopo la richiesta di upgrade vanno
1750
1985
  // inoltrati come nel ramo normale, non persi.
1751
1986
  if (head && head.length) { try { record.up.write(head); } catch (_) { /* morto */ } }
@@ -1768,6 +2003,14 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1768
2003
  }
1769
2004
  }
1770
2005
  const up = net.connect({ host: '127.0.0.1', port });
2006
+ // What we read from the peer: for a host-only peer the head of its answer is
2007
+ // filtered like its HTTP answers.
2008
+ let upRead = up;
2009
+ if (hostOnlyEgress) {
2010
+ const headFilter = hostOnlyHeadFilter();
2011
+ headFilter.on('error', () => { try { up.destroy(); } catch (_) { /* gone */ } }); // refused head: upstreamGone() answers the client with our own bytes
2012
+ upRead = up.pipe(headFilter);
2013
+ }
1771
2014
  // ECONNREFUSED sull'hop locale (tunnel che si riavvia) arriva PRIMA del
1772
2015
  // 'connect': nel ramo hubMode il listener 'error' era registrato solo dentro
1773
2016
  // up.once('connect', …) e l'evento restava senza gestore → Unhandled 'error'
@@ -1791,7 +2034,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1791
2034
  let saw101 = false;
1792
2035
  let headBuf = Buffer.alloc(0);
1793
2036
  let gone = false;
1794
- up.on('data', (chunk) => {
2037
+ upRead.on('data', (chunk) => {
1795
2038
  if (saw101 || headBuf.length >= 1024) return;
1796
2039
  headBuf = Buffer.concat([headBuf, chunk.subarray(0, Math.max(0, 1024 - headBuf.length))]);
1797
2040
  if (headBuf.includes(' 101')) saw101 = true;
@@ -1837,6 +2080,15 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1837
2080
  const remove = () => { activeSockets.delete(socket); activeSockets.delete(up); };
1838
2081
  socket.once('close', remove); up.once('close', remove);
1839
2082
  }
2083
+ if (hostOnlyGuard) {
2084
+ // Released when the UPSTREAM goes: a held attach keeps `up` after the
2085
+ // client has gone, and it must still be closed on revocation.
2086
+ const untrack = trackHostOnlyStream(hostOnlyGuard, () => {
2087
+ try { socket.destroy(); } catch (_) { /* gone */ }
2088
+ try { up.destroy(); } catch (_) { /* gone */ }
2089
+ });
2090
+ up.once('close', untrack);
2091
+ }
1840
2092
  if (hubMode) {
1841
2093
  // Attach trattenuta: l'output passa dal ring quando il client non c'è,
1842
2094
  // e il record entra nello store sotto la sua identità.
@@ -1844,7 +2096,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1844
2096
  attachId, tokenHash: tokenFingerprint(attachToken), route: attachRoute, session: attachSession, up,
1845
2097
  });
1846
2098
  heldRecord = record;
1847
- up.on('data', (chunk) => hubAttaches.feed(record, chunk));
2099
+ upRead.on('data', (chunk) => hubAttaches.feed(record, chunk));
1848
2100
  up.on('close', () => hubAttaches.destroy(record));
1849
2101
  up.on('error', () => hubAttaches.destroy(record));
1850
2102
  // end:false: la caduta del CLIENT non deve chiudere l'upstream — è
@@ -1870,7 +2122,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1870
2122
  socket.pipe(up);
1871
2123
  // end:false: la chiusura verso il browser la governiamo noi, o la pipe
1872
2124
  // chiuderebbe il socket PRIMA che il frame di close 4402 possa partire.
1873
- up.pipe(socket, { end: false });
2125
+ upRead.pipe(socket, { end: false });
1874
2126
  });
1875
2127
  if (!hubMode) {
1876
2128
  // Nel ramo trattenuto la morte dell'upstream la governa lo store (destroy
@@ -2113,7 +2365,7 @@ function acceptWsThenClose(socket, req, code, reason) {
2113
2365
  module.exports = {
2114
2366
  isPanelResource, panelAllowedFor, isLiveHostResource, liveHostAllowedFor, classGateCheck, routeHandler,
2115
2367
  MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure, activeReversePort,
2116
- peerFromToken, peerAllows, canTransit, isHostOnlyPeer, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
2368
+ peerFromToken, peerAllows, canTransit, isHostOnlyPeer, chainInAudience, touchesHostOnly, routeNamesOf, hostOnlyVisibility, hideHostOnlyEntries, redactHostOnlyReferences, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
2117
2369
  collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade, createHubAttachStore,
2118
2370
  probeHealth, waitForHealthyPeer, canListenLoopback, preflightHubReverse, notifyHubShare, reconcilePeerShare, runShareRevokeBoot, probeReverseOwner,
2119
2371
  hubPoolRequest, verifyHubPoolSlot, reserveHubPoolSlot, commitHubPoolSlot, settleHubPoolSlot, abortHubPoolSlot, getHubPoolStatus,