@mmmbuto/nexuscrew 0.9.27 → 0.9.29

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.
@@ -26,6 +26,14 @@
26
26
 
27
27
  const express = require('express');
28
28
  const { CELL_ID_RE } = require('./store.js');
29
+ const { HOP_HEADER } = require('../proxy/hop-proof.js');
30
+
31
+ // Il ponte federato non nasce da una cella: nasce dal nodo che ospita la
32
+ // pagina/app-server. L'identita' che si puo' PROVARE e' quindi il nodo, e il
33
+ // budget si conta per nodo mittente; questa costante tiene il posto della cella
34
+ // nella chiave (v. lib/audio/rate-limit.js: originKey vuole {node, cell}) e vale
35
+ // anche come nome del target quando il nodo non ha una designazione da nominare.
36
+ const LIVE_HOST_BUCKET = 'live-host';
29
37
 
30
38
  // Ricava l'elenco celle LOCALI dal fleet (definizioni, attive e non). Una cella
31
39
  // federata non compare qui: e' il check che chiude "designa solo una cella di questo
@@ -76,9 +84,40 @@ function eligibleOf(fleet, cell, hostCell) {
76
84
  return isActive(cell) && leaseState === 'live';
77
85
  }
78
86
 
79
- function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Date.now(), bridge = null }) {
87
+ function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Date.now(), bridge = null,
88
+ // Federazione della designazione remota. `federation` (client seam:
89
+ // getOwnerState/bridgeForward/ownerRoute) e' presente solo sul nodo CLIENT;
90
+ // `originResolver` + `federatedRate` attivano la via federata DEL PROPRIETARIO:
91
+ // una richiesta con prova di hop puo' portare `{expect}` (unico campo) e deve
92
+ // coincidere con la designazione locale, altrimenti 409 nominato. Assenti
93
+ // (test parziali) le route restano esattamente quelle locali di prima.
94
+ // Sul ramo federato l'origine NON e' un campo del body: e' il risultato di una
95
+ // verifica (prova di hop + catena `visited` costruita dal server), e il budget
96
+ // del ponte si applica per NODO mittente ATTESTATO — stessa regola e stessi
97
+ // limiti dell'audio (lib/audio/rate-limit.js).
98
+ federation = null, originResolver = null, federatedRate = null }) {
80
99
  const r = express.Router();
81
100
 
101
+ // Una designazione con `ownerId` e' REMOTA: il puntamento vive qui,
102
+ // la verita' (thread/eligible/lease) vive sul proprietario. Lo stato del
103
+ // proprietario arriva via GET federata; irraggiungibile => threadStatus
104
+ // 'unknown' con reason NOMINATO, mai un fallback su una cella locale.
105
+ async function remoteOwnerState(snap) {
106
+ if (!federation || typeof federation.getOwnerState !== 'function') {
107
+ return { reason: 'live-host-federation-unavailable' };
108
+ }
109
+ const out = await federation.getOwnerState({ ownerId: snap.ownerId }).catch(() => ({ ok: false, reason: 'live-host-owner-unreachable' }));
110
+ if (!out || out.ok !== true || !out.body) return { reason: 'live-host-owner-unreachable' };
111
+ const b = out.body;
112
+ return {
113
+ state: {
114
+ eligible: b.eligible === true,
115
+ threadStatus: b.threadStatus || 'unknown',
116
+ host: b.host || null,
117
+ },
118
+ };
119
+ }
120
+
82
121
  // GET /api/live-host — { hostCell, revision, eligible, threadStatus,
83
122
  // host: {lease}, at }. threadStatus misura il runtime del thread ponte:
84
123
  // absent/present/active/unknown; non dichiara la presenza del client Live.
@@ -88,6 +127,17 @@ function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Dat
88
127
  r.get('/', async (_req, res) => {
89
128
  try {
90
129
  const snap = store.snapshot();
130
+ if (snap.hostCell != null && snap.ownerId) {
131
+ const remote = await remoteOwnerState(snap);
132
+ return res.json({
133
+ hostCell: snap.hostCell, revision: snap.revision, ownerId: snap.ownerId, remote: true,
134
+ eligible: remote.state ? remote.state.eligible : false,
135
+ threadStatus: remote.state ? remote.state.threadStatus : 'unknown',
136
+ host: remote.state ? remote.state.host : null,
137
+ ...(remote.reason ? { reason: remote.reason } : {}),
138
+ at: now(),
139
+ });
140
+ }
91
141
  let eligible = false;
92
142
  let lease = null;
93
143
  let threadStatus = 'absent';
@@ -164,13 +214,121 @@ function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Dat
164
214
  });
165
215
 
166
216
  // POST /api/live-host/bridge — risolve il puntamento per l'avvio di una Live
167
- // (fetta 3). La richiesta NON è parametrizzabile: la designazione è la
168
- // condizione (LC3) e nessun chiamante può scegliere il target (MC3.4). Il
169
- // ponte risponde sempre 200: i `none` con reason sono esiti legittimi e
170
- // distinti (nessuna designazione / cella non idonea / fallback), non errori.
171
- // Body opzionale e vuoto: un body con campi è un 400, non viene ignorato.
217
+ // (fetta 3). La richiesta NON è parametrizzabile dal chiamante: la
218
+ // designazione è la condizione, e nessun chiamante sceglie il target — ma la
219
+ // designazione ora può puntare FUORI nodo, e in
220
+ // quel caso questo nodo (client) INOLTRA la risoluzione al proprietario
221
+ // (stessa richiesta, senza parametri di scelta; `expect` è l'attestazione di
222
+ // cosa il client sta puntando, verificata dal proprietario). Il ponte
223
+ // risponde sempre 200: i `none` con reason sono esiti legittimi e distinti
224
+ // (nessuna designazione / cella non idonea / fallback), non errori.
225
+ // Body opzionale e vuoto: un body con campi è un 400, non viene ignorato —
226
+ // eccezione: sul PERCORSO FEDERATO (prova di hop) il proprietario accetta
227
+ // `{expect:{hostCell,revision}}` e lo verifica contro la propria
228
+ // designazione (mismatch = 409 nominato); il gate `liveHostAccess` del peer
229
+ // è già stato applicato dall'ingresso federato (federation.js).
172
230
  r.post('/bridge', express.json({ limit: '1kb' }), async (req, res) => {
173
231
  const body = req.body || {};
232
+ const federated = !!(originResolver && req.headers && req.headers[HOP_HEADER]);
233
+ if (federated) {
234
+ const keys = Object.keys(body);
235
+ if (keys.length > 1 || (keys.length === 1 && keys[0] !== 'expect')) {
236
+ return res.status(400).json({ error: 'la risoluzione non accetta parametri' });
237
+ }
238
+ // Budget federato. L'origine la da' l'originResolver, che verifica la prova
239
+ // di hop e la catena controllata dal server: la stringa `visited` letta a
240
+ // mano non e' un'attribuzione, e un limiter interrogato con la forma
241
+ // sbagliata (`allow` invece di `check`) non limita nulla — era il difetto.
242
+ // Una richiesta la cui origine non e' verificabile viene rifiutata qui,
243
+ // non servita senza budget: fail-closed, mai un limite saltato in silenzio.
244
+ if (federatedRate) {
245
+ const resolved = typeof originResolver.resolve === 'function'
246
+ ? await originResolver.resolve(req, { requireCell: false })
247
+ : { ok: false, reason: 'no-origin-resolver' };
248
+ if (!resolved || resolved.ok !== true) {
249
+ return res.status(403).json({
250
+ error: 'origine federata non verificabile', reason: 'live-host-origin-unverified',
251
+ detail: (resolved && resolved.reason) || 'unknown',
252
+ });
253
+ }
254
+ const quota = federatedRate.check({
255
+ origin: { node: resolved.origin.node, cell: LIVE_HOST_BUCKET },
256
+ target: store.snapshot().hostCell || LIVE_HOST_BUCKET,
257
+ urgency: 'normal',
258
+ });
259
+ if (!quota.allowed) {
260
+ return res.status(429).json({
261
+ error: 'troppo frequente', reason: 'rate-limited',
262
+ bucket: quota.bucket, retryInMs: quota.retryInMs,
263
+ });
264
+ }
265
+ }
266
+ const snap = store.snapshot();
267
+ if (keys.length === 1) {
268
+ const expect = body.expect || {};
269
+ const okExpect = expect && typeof expect.hostCell === 'string'
270
+ && Number.isInteger(expect.revision)
271
+ && expect.hostCell === snap.hostCell && expect.revision === snap.revision;
272
+ if (!okExpect) {
273
+ return res.status(409).json({
274
+ error: 'la designazione del proprietario non coincide con quella dichiarata dal client',
275
+ reason: 'live-host-expectation-mismatch',
276
+ revision: snap.revision, hostCell: snap.hostCell, at: now(),
277
+ });
278
+ }
279
+ }
280
+ if (readonly()) return res.json({ mode: 'none', reason: 'readonly', at: now() });
281
+ if (!bridge) return res.status(503).json({ error: 'ponte Live non configurato su questo nodo' });
282
+ try {
283
+ const result = await bridge.resolveForLive();
284
+ res.json(result);
285
+ } catch (e) {
286
+ res.json({ mode: 'none', reason: 'bridge-error', detail: String(e && e.message || e), at: now() });
287
+ }
288
+ return;
289
+ }
290
+ const snap = store.snapshot();
291
+ if (snap.hostCell != null && snap.ownerId) {
292
+ // Nodo CLIENT con host remoto: inoltra al proprietario, mai un
293
+ // fallback su una cella locale (il chiamante non sceglie il target: lo
294
+ // sceglie la designazione, che punta fuori nodo).
295
+ if (!federation || typeof federation.bridgeForward !== 'function') {
296
+ return res.status(503).json({ mode: 'none', reason: 'live-host-federation-unavailable', at: now() });
297
+ }
298
+ const out = await federation.bridgeForward({
299
+ ownerId: snap.ownerId,
300
+ expect: { hostCell: snap.hostCell, revision: snap.revision },
301
+ }).catch(() => ({ ok: false, reason: 'live-host-owner-unreachable' }));
302
+ if (!out) {
303
+ return res.status(502).json({ mode: 'none', reason: 'live-host-owner-unreachable', at: now() });
304
+ }
305
+ // The owner's DECIDABLE answers come first: a refusal and a moved
306
+ // designation are decisions, not failures. Flattening them into
307
+ // "unreachable" would hide exactly what the operator has to fix.
308
+ if (out.status === 403 || out.reason === 'live-host-not-granted') {
309
+ return res.status(403).json({ mode: 'none', reason: 'live-host-not-granted', at: now() });
310
+ }
311
+ if (out.status === 409 || out.reason === 'live-host-expectation-mismatch') {
312
+ return res.status(409).json({
313
+ error: 'la designazione del proprietario non coincide con quella dichiarata dal client',
314
+ reason: 'live-host-expectation-mismatch', at: now(),
315
+ });
316
+ }
317
+ if (out.ok !== true) {
318
+ // Whatever else the owner answered: our own name plus the status we saw,
319
+ // never a peer string we did not check — and never "unreachable" for a
320
+ // node that did answer.
321
+ const reason = out.reason === 'live-host-owner-unreachable' ? 'live-host-owner-unreachable' : 'live-host-owner-rejected';
322
+ return res.status(502).json({
323
+ mode: 'none', reason,
324
+ ...(Number.isInteger(out.status) ? { ownerStatus: out.status } : {}), at: now(),
325
+ });
326
+ }
327
+ const ownerBody = out.body || { mode: 'none', reason: 'bridge-error' };
328
+ const route = federation.ownerRoute
329
+ ? await federation.ownerRoute(snap.ownerId).catch(() => null) : null;
330
+ return res.json({ ...ownerBody, owner: snap.ownerId, ...(Array.isArray(route) && route.length ? { route } : {}), at: now() });
331
+ }
174
332
  if (Object.keys(body).length > 0) return res.status(400).json({ error: 'la risoluzione non accetta parametri' });
175
333
  if (readonly()) return res.json({ mode: 'none', reason: 'readonly', at: now() });
176
334
  if (!bridge) return res.status(503).json({ error: 'ponte Live non configurato su questo nodo' });
@@ -17,6 +17,7 @@ const os = require('node:os');
17
17
  const { readJsonSafe, atomicWriteJson } = require('../notify/persist.js');
18
18
 
19
19
  const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
20
+ const NODE_ID_RE = /^[a-f0-9]{32}$/i;
20
21
 
21
22
  // Path del file di stato: override esplicito -> dir del token (~/.nexuscrew) -> home.
22
23
  // Stessa convenzione di consentPath/groupsPath, cosi' i test isolano lo stato via
@@ -34,7 +35,13 @@ function normalize(raw) {
34
35
  const revision = raw && Number.isInteger(raw.revision) && raw.revision >= 0 ? raw.revision : 0;
35
36
  const hostCell = raw && typeof raw.hostCell === 'string' && CELL_ID_RE.test(raw.hostCell)
36
37
  ? raw.hostCell : null;
37
- return { revision, hostCell };
38
+ // La designazione puo' puntare a una cella di un ALTRO nodo. `ownerId`
39
+ // assente/null = cella locale (comportamento storico); presente = puntamento
40
+ // remoto: `revision` e' quella del PROPRIETARIO (il client non la avanza,
41
+ // rispecchia lo stato di chi possiede la cella).
42
+ const ownerId = raw && typeof raw.ownerId === 'string' && NODE_ID_RE.test(raw.ownerId)
43
+ ? raw.ownerId.toLowerCase() : null;
44
+ return { revision, hostCell, ownerId };
38
45
  }
39
46
 
40
47
  function readLiveHost(filePath) {
@@ -90,7 +97,38 @@ function createLiveHostStore({ filePath, now = () => Date.now() } = {}) {
90
97
  });
91
98
  }
92
99
 
93
- return { snapshot, compareAndSet, filePath };
100
+ // Registra/rispecchia il puntamento remoto dopo che il PROPRIETARIO ha
101
+ // accettato la designazione (via federata). Nessun CAS locale: la revision
102
+ // e' quella del proprietario, il client la rispecchia.
103
+ function setRemoteDesignation({ ownerId, hostCell, revision }) {
104
+ return serialize(() => {
105
+ if (!NODE_ID_RE.test(String(ownerId || ''))) {
106
+ return { ok: false, conflict: true, revision: 0, hostCell: null };
107
+ }
108
+ const cell = typeof hostCell === 'string' && CELL_ID_RE.test(hostCell) ? hostCell : null;
109
+ const rev = Number.isInteger(revision) && revision >= 0 ? revision : 0;
110
+ const revised = { revision: rev, hostCell: cell, ownerId: cell == null ? null : ownerId.toLowerCase(), updatedAt: now() };
111
+ atomicWriteJson(filePath, revised);
112
+ return { ok: true, revision: revised.revision, hostCell: revised.hostCell, ownerId: revised.ownerId, at: revised.updatedAt };
113
+ });
114
+ }
115
+
116
+ // Cancella il puntamento remoto SOLO se appartiene a quel proprietario
117
+ // (una designazione locale non toccata da un clear federato altrui).
118
+ function clearForOwner(ownerId) {
119
+ return serialize(() => {
120
+ const cur = readLiveHost(filePath);
121
+ if (cur.ownerId && NODE_ID_RE.test(String(ownerId || '')) && cur.ownerId !== String(ownerId).toLowerCase()) {
122
+ return { ok: false, revision: cur.revision, hostCell: cur.hostCell, ownerId: cur.ownerId };
123
+ }
124
+ if (!cur.ownerId) return { ok: true, revision: cur.revision, hostCell: cur.hostCell, ownerId: null };
125
+ const revised = { revision: 0, hostCell: null, updatedAt: now() };
126
+ atomicWriteJson(filePath, revised);
127
+ return { ok: true, revision: 0, hostCell: null, ownerId: null, at: revised.updatedAt };
128
+ });
129
+ }
130
+
131
+ return { snapshot, compareAndSet, setRemoteDesignation, clearForOwner, filePath };
94
132
  }
95
133
 
96
134
  module.exports = { createLiveHostStore, liveHostPath, readLiveHost, normalize, CELL_ID_RE };
@@ -221,8 +221,8 @@ function panelAllowedFor(ingress) {
221
221
  // che la possiede, non un dato che ogni peer pairato vede gia' — stesso gate
222
222
  // per-peer del pannello, stesso default negato. A differenza del pannello (un
223
223
  // sottoalbero /panel/<cella>/*, perche' serve HTML/JS/asset), qui l'insieme
224
- // delle risorse e' chiuso: tre endpoint fissi, non un percorso arbitrario.
225
- const LIVE_HOST_RESOURCES = new Set(['/live-host', '/live-host/designate', '/live-host/clear']);
224
+ // delle risorse e' chiuso: quattro endpoint fissi, non un percorso arbitrario.
225
+ const LIVE_HOST_RESOURCES = new Set(['/live-host', '/live-host/designate', '/live-host/clear', '/live-host/bridge']);
226
226
  function isLiveHostResource(resource) { return LIVE_HOST_RESOURCES.has(resource); }
227
227
  function liveHostAllowedFor(ingress) {
228
228
  if (!ingress) return true;
@@ -233,6 +233,9 @@ function allowedResource(resource, method = 'GET') {
233
233
  if (isPanelResource(resource)) return method === 'GET' || method === 'POST';
234
234
  if (resource === '/live-host') return method === 'GET';
235
235
  if (resource === '/live-host/designate' || resource === '/live-host/clear') return method === 'POST';
236
+ // The bridge resolution travels to the owner without parameters: the
237
+ // designation picks the target, so a forwarded call cannot add one.
238
+ if (resource === '/live-host/bridge') return method === 'POST';
236
239
  if (resource === '/sessions') return method === 'GET' || method === 'POST';
237
240
  if (/^\/sessions\/[\w.@%:+-]{1,128}$/.test(resource)) return method === 'DELETE';
238
241
  if (/^\/sessions\/[\w.@%:+-]{1,128}\/visibility$/.test(resource)) return method === 'PATCH';
@@ -364,17 +367,57 @@ function rewriteSetCookiePath(headers, prefix) {
364
367
  return out;
365
368
  }
366
369
 
367
- function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null, setCookiePrefix = null, panelCookie = null }) {
370
+ // What this node records when a designation is created or removed on a PEER
371
+ // through it: `ownerId` is the peer the forward went to, and the revision is
372
+ // the owner's own, taken from its 2xx answer — never a local guess, because the
373
+ // owner is the one that decides. A non-2xx answer, or a body without
374
+ // `{hostCell, revision}`, records nothing: an absent pointer is honest, an
375
+ // invented one is not.
376
+ function liveHostLedgerCapture({ ledger, ownerId, resource }) {
377
+ return (status, text) => {
378
+ if (!ledger || !(status >= 200 && status < 300)) return;
379
+ if (resource === '/live-host/clear') {
380
+ if (typeof ledger.clearRemote === 'function') ledger.clearRemote(ownerId);
381
+ return;
382
+ }
383
+ let body = null;
384
+ try { body = JSON.parse(String(text || '')); } catch (_) { return; }
385
+ if (!body || typeof body.hostCell !== 'string' || !Number.isInteger(body.revision)) return;
386
+ if (typeof ledger.recordRemote === 'function') {
387
+ ledger.recordRemote({ ownerId, hostCell: body.hostCell, revision: body.revision });
388
+ }
389
+ };
390
+ }
391
+
392
+ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null, setCookiePrefix = null, panelCookie = null, capture = null }) {
393
+ // A body a local middleware has already read is no longer in the stream: the
394
+ // headers would announce bytes that nothing sends, and the peer would wait for
395
+ // a body that never arrives. Say it at once, with a name, instead of letting
396
+ // the forward sit until the upstream timeout expires.
397
+ if (Number(req.headers['content-length'] || 0) > 0 && req.readableEnded) {
398
+ return res.status(502).json({ error: 'request body already read by a local middleware', reason: 'federation-body-consumed' });
399
+ }
368
400
  const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited, hopProof, panelCookie) }, (r) => {
369
401
  const headers = setCookiePrefix ? rewriteSetCookiePath(r.headers, setCookiePrefix) : r.headers;
370
- res.writeHead(r.statusCode, sanitizeResponseHeaders(headers)); r.pipe(res);
402
+ if (!capture) { res.writeHead(r.statusCode, sanitizeResponseHeaders(headers)); r.pipe(res); return; }
403
+ // Buffered only where a capture asks for it (the two live-host mutations):
404
+ // the owner's answer carries the revision that has to be recorded.
405
+ const chunks = [];
406
+ r.on('data', (chunk) => chunks.push(chunk));
407
+ r.on('end', () => {
408
+ const body = Buffer.concat(chunks);
409
+ try { capture(r.statusCode, body.toString('utf8')); } catch (_) { /* the ledger never breaks the forward */ }
410
+ if (res.writableEnded) return;
411
+ res.writeHead(r.statusCode, sanitizeResponseHeaders(headers));
412
+ res.end(body);
413
+ });
371
414
  });
372
415
  up.setTimeout(30000, () => up.destroy());
373
- up.on('error', () => { if (!res.headersSent) res.status(502).json({ error: 'peer non raggiungibile' }); else res.destroy(); });
416
+ up.on('error', () => { if (!res.headersSent) res.status(502).json({ error: 'peer non raggiungibile', reason: 'federation-peer-unreachable' }); else res.destroy(); });
374
417
  req.pipe(up);
375
418
  }
376
419
 
377
- function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null }) {
420
+ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null, liveHostLedger = null }) {
378
421
  return (req, res) => {
379
422
  const parsed = parseRoute(req.url);
380
423
  // Prima dell'allowlist: un peer senza permesso non deve nemmeno sapere se la
@@ -422,7 +465,13 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
422
465
  const setCookiePrefix = !ingress && isPanelResource(parsed.resource)
423
466
  ? `/api/route/${parsed.route.join('/')}/${ROUTE_DELIMITER}`
424
467
  : null;
425
- proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited, setCookiePrefix, panelCookie });
468
+ // A designation forwarded from here lands on the owner: what this node ends
469
+ // up pointing at is decided THERE, so the pointer is recorded from the
470
+ // owner's answer (liveHostLedgerCapture) and not from a local guess.
471
+ const capture = liveHostLedger && (parsed.resource === '/live-host/designate' || parsed.resource === '/live-host/clear')
472
+ ? liveHostLedgerCapture({ ledger: liveHostLedger, ownerId: next.nodeId, resource: parsed.resource })
473
+ : null;
474
+ proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited, setCookiePrefix, panelCookie, capture });
426
475
  };
427
476
  }
428
477
 
@@ -1257,9 +1306,9 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
1257
1306
  return r;
1258
1307
  }
1259
1308
 
1260
- function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecret = null }) {
1309
+ function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecret = null, liveHostLedger = null }) {
1261
1310
  const r = express.Router();
1262
- r.use((req, res) => routeHandler({ nodesPath, localPort, localCredential, readonly, hopSecret })(req, res));
1311
+ r.use((req, res) => routeHandler({ nodesPath, localPort, localCredential, readonly, hopSecret, liveHostLedger })(req, res));
1263
1312
  return r;
1264
1313
  }
1265
1314
 
package/lib/server.js CHANGED
@@ -29,6 +29,7 @@ const { cellsRoutes } = require('./cells/routes.js');
29
29
  const { liveHostRoutes } = require('./live-host/routes.js');
30
30
  const { createLiveHostStore, liveHostPath } = require('./live-host/store.js');
31
31
  const { createLiveBridge } = require('./live-host/bridge.js');
32
+ const { createLiveHostFederation } = require('./live-host/federation.js');
32
33
  const { fsRoutes } = require('./fs/routes.js');
33
34
  const nodesStore = require('./nodes/store.js');
34
35
  const nodesTunnel = require('./nodes/tunnel.js');
@@ -979,9 +980,13 @@ function createServer(opts = {}) {
979
980
  // Fetta 3: il ponte risolve il puntamento all'avvio di una Live (POST /bridge
980
981
  // nello stesso gruppo, stessa auth, stesso local-only). Isolabile da cfg
981
982
  // (rev5 MC0): spento non crea connessioni e la Live resta standard.
983
+ // One store instance serves both sides of a remote designation: the routes
984
+ // (owner side) and the forwarder that records the pointer to a peer (client
985
+ // side). Two instances over the same file would work, one is honest.
986
+ const liveHostStore = createLiveHostStore({ filePath: liveHostPath(cfg) });
982
987
  api.use('/live-host', liveHostRoutes({
983
988
  fleetP,
984
- store: createLiveHostStore({ filePath: liveHostPath(cfg) }),
989
+ store: liveHostStore,
985
990
  readonly: proxyReadonly,
986
991
  bridge: createLiveBridge({
987
992
  cfg,
@@ -990,6 +995,18 @@ function createServer(opts = {}) {
990
995
  filesRoot: cfg.filesRoot,
991
996
  log: opts.log || console.log,
992
997
  }),
998
+ // Client side: read the owner's state and forward the bridge resolution to
999
+ // the node the designation points at. Owner side: accept a forwarded
1000
+ // resolution behind the same per-peer gate and the same rate limiting as
1001
+ // the other federated routes.
1002
+ federation: createLiveHostFederation({
1003
+ localNodeId: federatedNodeId,
1004
+ peers: federatedPeers,
1005
+ localPort: () => (server && server.address() ? server.address().port : cfg.port),
1006
+ localToken: () => tokenHolder.value,
1007
+ }),
1008
+ originResolver: createOriginResolver({ localNodeId: federatedNodeId, hopSecret: () => hopSecret }),
1009
+ federatedRate: createSpeakRateLimiter(),
993
1010
  }));
994
1011
  // Pannello per-cella: montato con il suo ingresso dedicato (ticket+cookie)
995
1012
  // PRIMA del requireToken generale — vedi il blocco sopra, dove nascono
@@ -1075,6 +1092,12 @@ function createServer(opts = {}) {
1075
1092
  api.use('/route', federation.localRouter({
1076
1093
  nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
1077
1094
  hopSecret: () => hopSecret,
1095
+ // A designation forwarded from here to a peer is recorded by the answer
1096
+ // the peer gives, not by a local guess: see liveHostLedgerCapture.
1097
+ liveHostLedger: {
1098
+ recordRemote: (entry) => liveHostStore.setRemoteDesignation(entry),
1099
+ clearRemote: (ownerId) => liveHostStore.clearForOwner(ownerId),
1100
+ },
1078
1101
  }));
1079
1102
  api.get('/voice/status', (_req, res) => res.json({ serverSttConfigured: !!cfg.voiceUrl }));
1080
1103
  api.post('/voice/transcribe',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.27",
3
+ "version": "0.9.29",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {
@@ -33,6 +33,18 @@ read from the roster and the designated cell's lease state
33
33
  (`live`/`grace`/`expired`/`none`/`unavailable`) — a designated-but-not-eligible
34
34
  host is a distinct, readable state, not an error to explain away.
35
35
 
36
+ ## The rule is per node — and the cell may live on another node
37
+
38
+ One host cell **per node** is the rule, and it keeps holding when the designated
39
+ cell belongs to a peer: the node serving the page keeps the pointer locally
40
+ (`ownerId` plus the owner's own `revision`, and `remote: true` on `GET`) and
41
+ forwards the bridge resolution to the owner instead of resolving it locally.
42
+ The caller still chooses nothing — the designation is the choice, and it can now
43
+ point off-node. An unreachable owner, or a peer that was never granted
44
+ `liveHostAccess`, is a **named** outcome (`live-host-owner-unreachable`,
45
+ `live-host-not-granted`), never a silent fallback to a local cell. `clear`
46
+ removes the pointer on both sides.
47
+
36
48
  ## Command the node that owns the cell, not the one serving the page
37
49
 
38
50
  This is the point of the feature: **the request must reach the node whose