@mmmbuto/nexuscrew 0.9.28 → 0.9.30

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/docs/LIVE_PROMPT.md +1 -1
  3. package/docs/NOTIFICATIONS.md +37 -0
  4. package/docs/OPERATIONS.md +20 -0
  5. package/frontend/dist/assets/index-BbcjoZS0.js +167 -0
  6. package/frontend/dist/assets/index-DqCfZFGi.css +32 -0
  7. package/frontend/dist/index.html +2 -2
  8. package/frontend/dist/sw.js +91 -10
  9. package/frontend/dist/version.json +1 -1
  10. package/lib/audio/groups.js +1 -1
  11. package/lib/auth/token.js +4 -4
  12. package/lib/cli/commands.js +70 -15
  13. package/lib/cli/doctor.js +5 -5
  14. package/lib/cli/fleet-service.js +7 -7
  15. package/lib/cli/init.js +55 -31
  16. package/lib/cli/pidfile.js +1 -1
  17. package/lib/cli/service.js +124 -8
  18. package/lib/cli/stable-alias.js +4 -4
  19. package/lib/cli/url.js +1 -1
  20. package/lib/config.js +36 -6
  21. package/lib/files/routes.js +1 -1
  22. package/lib/fleet/boot.js +2 -2
  23. package/lib/fleet/builtin.js +15 -15
  24. package/lib/fleet/causes.js +1 -1
  25. package/lib/fleet/cell-exec.js +23 -23
  26. package/lib/fleet/cell-hold.js +6 -6
  27. package/lib/fleet/cell-lease-server.js +106 -106
  28. package/lib/fleet/cell-lease.js +21 -21
  29. package/lib/fleet/definitions.js +10 -10
  30. package/lib/fleet/identity-authority.js +1 -1
  31. package/lib/fleet/launch-broker.js +13 -13
  32. package/lib/fleet/launch.js +15 -15
  33. package/lib/fleet/lease-client.js +23 -23
  34. package/lib/fleet/lease-routes.js +3 -3
  35. package/lib/fleet/lease-verifier.js +22 -22
  36. package/lib/fleet/managed.js +35 -35
  37. package/lib/fleet/prompt-delivery.js +13 -13
  38. package/lib/fleet/routes.js +15 -10
  39. package/lib/fleet/runtime.js +24 -24
  40. package/lib/live-host/bridge.js +53 -43
  41. package/lib/live-host/federation.js +97 -0
  42. package/lib/live-host/routes.js +166 -8
  43. package/lib/live-host/store.js +44 -6
  44. package/lib/mcp/server.js +14 -14
  45. package/lib/mcp/tools.js +5 -5
  46. package/lib/nodes/access-presets.js +258 -0
  47. package/lib/nodes/commands.js +198 -4
  48. package/lib/nodes/store.js +152 -9
  49. package/lib/nodes/tunnel.js +7 -7
  50. package/lib/notify/ask-answer-service.js +184 -0
  51. package/lib/notify/ask-receipts.js +197 -0
  52. package/lib/notify/ask-relay.js +152 -0
  53. package/lib/notify/asks.js +28 -3
  54. package/lib/notify/event-feed-acl.js +95 -0
  55. package/lib/notify/event-feed-asks-routes.js +144 -0
  56. package/lib/notify/event-feed-client.js +434 -0
  57. package/lib/notify/event-feed-history.js +115 -0
  58. package/lib/notify/event-feed-producers.js +175 -0
  59. package/lib/notify/event-feed-routes.js +254 -0
  60. package/lib/notify/event-feed.js +328 -0
  61. package/lib/notify/events.js +1 -1
  62. package/lib/notify/notifier.js +3 -3
  63. package/lib/notify/push-relay.js +240 -0
  64. package/lib/notify/push.js +8 -8
  65. package/lib/notify/routes.js +62 -23
  66. package/lib/proxy/federation.js +132 -12
  67. package/lib/proxy/node-proxy.js +13 -13
  68. package/lib/proxy/panel-auth.js +4 -4
  69. package/lib/proxy/panel-proxy.js +2 -2
  70. package/lib/proxy/resource-acl.js +145 -0
  71. package/lib/server.js +396 -40
  72. package/lib/settings/pairing-coordinator.js +1 -1
  73. package/lib/settings/routes.js +45 -32
  74. package/lib/update/manager.js +1 -1
  75. package/lib/update/runner.js +1 -1
  76. package/lib/voice/transcribe.js +1 -1
  77. package/lib/ws/bridge.js +3 -3
  78. package/package.json +1 -1
  79. package/skills/live/SKILL.md +12 -0
  80. package/frontend/dist/assets/index-CWra6B79.js +0 -167
  81. package/frontend/dist/assets/index-D6CIfCjv.css +0 -32
@@ -1,27 +1,37 @@
1
1
  'use strict';
2
- // lib/live-host/bridge.js — il ponte Live (fetta 3, contratto rev5 + rev4 LC).
2
+ // lib/live-host/bridge.js — the Live bridge (contract revision rev6 + rev4 LC).
3
3
  //
4
- // Esposizione: POST /api/live-host/bridge (montata in routes.js, stesso
5
- // requireToken e stessa policy local-only del proxy: LOCAL_ONLY_PREFIXES copre
6
- // l'intero prefisso /api/live-host, quindi nessun peer federato può innescare
7
- // il ponte). La chiamata rappresenta l'avvio di una Live sul nodo: chi la fa è
8
- // il lato app-server della stessa feature (non costruito qui), e il risultato
9
- // dice su che cosa quella Live va a operare.
4
+ // Exposure: POST /api/live-host/bridge, mounted in routes.js. Reaching
5
+ // /api/live-host directly stays a local-only path (LOCAL_ONLY_PREFIXES covers
6
+ // the whole prefix), so a peer cannot knock on the bridge that way. A peer CAN
7
+ // ask for it through the federated route /api/route/<nodes>/_/live-host/bridge:
8
+ // that is the allowlisted path of the federation, the node that owns the cell
9
+ // grants the per-peer permission (liveHostAccess, denied by default), and the
10
+ // request carries the hop proof the server signs for the last hop. A federated
11
+ // body may add one single field, `{expect}`: what the caller believes it is
12
+ // pointing at, checked by the owner against its own saved designation — a
13
+ // mismatch is a named 409.
14
+ //
15
+ // The resolution itself is unchanged: it is the SAVED designation, read where
16
+ // it lives, so no caller picks a target, locally or across nodes. The call
17
+ // represents starting a Live on the node: the app-server side of the same
18
+ // feature makes it (not built here), and the answer says what that Live is
19
+ // going to operate on.
10
20
  //
11
21
  // Invarianti (contratto fetta 3):
12
- // - MC1: la designazione si legge con UNA GET su loopback verso
22
+ // La designazione si legge con UNA GET su loopback verso
13
23
  // /api/live-host, autenticata col token del nodo. Nessun accesso diretto
14
24
  // allo store: la route è l'unica verità, `eligible` non si ricalcola.
15
- // - MC1.5 / JC3.3: nessuna attesa introdotta. Ogni fase ha il limite
25
+ // Nessuna attesa introdotta. Ogni fase ha il limite
16
26
  // dichiarato cfg.liveBridgeTimeoutMs; oltre quello, o su qualunque
17
27
  // fallimento, la risposta è `none` col motivo: la Live parte senza
18
28
  // puntamento, comportamento standard. Un `none` non è un errore HTTP.
19
- // - MC2: il prompt per-cella (LIVE_PROMPT.md accanto ai canonici della
29
+ // Il prompt per-cella (LIVE_PROMPT.md accanto ai canonici della
20
30
  // cella) viaggia su developerInstructions di thread/start e SOSTITUISCE
21
- // le developer instructions della config per quella Live (rev4 LC2
22
- // emendata da rev5 MC2). La riga che decide è in codex-rs
31
+ // Le developer instructions della config per quella Live (rev4
32
+ // Emendata da rev5). La riga che decide è in codex-rs
23
33
  // core/src/config/mod.rs: `developer_instructions.or(cfg.developer_
24
- // instructions)` — l'override Some scarta il valore di config. R2
34
+ // Instructions)` — l'override Some scarta il valore di config.
25
35
  // (verso corretto dopo revisione pre-release): l'identità della
26
36
  // cella designata viaggia SEMPRE come intestazione anteposta al campo,
27
37
  // anche senza prompt. Il campo NON è additivo: una cella senza
@@ -31,18 +41,18 @@
31
41
  // il world state (fragment user, canale separato) e il prompt base.
32
42
  // La via designata per le istruzioni di lavoro della Live è il
33
43
  // LIVE_PROMPT.md della cella: viaggia nello stesso campo.
34
- // - MC3: il ponte crea le proprie conversazioni con thread/start e non
44
+ // Il ponte crea le proprie conversazioni con thread/start e non
35
45
  // tocca MAI la thread di una TUI — né turn/start né thread/resume. La sonda
36
46
  // thread/read e' separata e sola lettura: non modifica il thread ponte ne'
37
47
  // quello della TUI. Per questo l'aggancio funziona anche su una cella che
38
48
  // sta già processando un turno: conversazioni separate, nessuna
39
- // interruzione (rev1 HC2/rev2 JC4).
40
- // - MC3.3: la connessione al socket di controllo è ON-DEMAND (connect →
49
+ // Interruzione (rev1 /rev2).
50
+ // La connessione al socket di controllo è ON-DEMAND (connect →
41
51
  // handshake → thread/start → close), mai permanente: la fuga notifiche
42
52
  // notata in rev5 riguarda i client permanenti.
43
- // - MC3.4: il ponte opera SOLO sulla cella designata — non accetta target
44
- // dal chiamante, la designazione è la condizione (LC3).
45
- // - MC0: isolabile — cfg.liveBridgeEnabled=false e il ponte non si connette
53
+ // Il ponte opera SOLO sulla cella designata — non accetta target
54
+ // Dal chiamante, la designazione è la condizione.
55
+ // Isolabile — cfg.liveBridgeEnabled=false e il ponte non si connette
46
56
  // mai, non fa GET, risponde `none` senza toccare nulla.
47
57
  //
48
58
  // Protocollo del socket di controllo (misurato sul runtime 2026-08-15):
@@ -51,7 +61,7 @@
51
61
  // (senza params). Poi `thread/start` {cwd, developerInstructions?} → response
52
62
  // {thread:{id}, cwd}, oppure `thread/read` {threadId, includeTurns:false} per
53
63
  // leggere il runtime. Il socket è 0600 dell'utente: il confine è quello
54
- // (MC1.3), non c'è autenticazione applicativa.
64
+ // Non c'è autenticazione applicativa.
55
65
 
56
66
  const fs = require('node:fs');
57
67
  const path = require('node:path');
@@ -164,10 +174,10 @@ function queryThreadStatusOnControlSocket({
164
174
  });
165
175
  }
166
176
 
167
- // —— Prompt per-cella (rev4 LC2, nome fisso confermato il 2026-08-15) ——
177
+ // — Prompt per-cella (rev4, nome fisso confermato il 2026-08-15) ——
168
178
  // Collocazione: filesRoot/<tmuxSession>/LIVE_PROMPT.md — la sessione tmux
169
179
  // ESATTA che il roster dichiara per la cella designata, la stessa fonte gia'
170
- // usata per l'intestazione R2 (identityHeader). NON un prefisso ricostruito a
180
+ // Usata per l'intestazione (identityHeader). NON un prefisso ricostruito a
171
181
  // mano: fino al 2026-08-16 questa funzione anteponeva 'cloud-' come default
172
182
  // universale quando il cellId non ce l'aveva gia' — su un device che chiama
173
183
  // le proprie sessioni con un prefisso diverso il file non veniva MAI trovato,
@@ -184,8 +194,8 @@ function queryThreadStatusOnControlSocket({
184
194
  // costruito (mai un prefisso indovinato),
185
195
  // quindi non si tenta nemmeno la lettura
186
196
  // applied:false, reason missing → ENOENT sul path dichiarato: assenza
187
- // legittima (LC2.3), si procede senza
188
- // PROMPT (R2: l'intestazione identità
197
+ // Legittima, si procede senza
198
+ // PROMPT: the identity header
189
199
  // viaggia comunque). ATTENZIONE: il campo
190
200
  // developerInstructions viene comunque
191
201
  // inviato per via dell'intestazione, e il
@@ -193,7 +203,7 @@ function queryThreadStatusOnControlSocket({
193
203
  // configurazione invece di sommarlo — chi
194
204
  // non ha prompt per-cella non riceve le
195
205
  // developer instructions globali che
196
- // riceveva prima (vedi MC2, verificato
206
+ // Riceveva prima (verificato
197
207
  // sulla riga che decide)
198
208
  // applied:false, reason unreadable|empty → presente ma inutilizzabile: va
199
209
  // dichiarato, mai silenziato
@@ -212,14 +222,14 @@ function readCellPrompt(filesRoot, tmuxSession) {
212
222
  return { applied: true, source: 'LIVE_PROMPT.md', text };
213
223
  }
214
224
 
215
- // —— Identità della Live (R2, 2026-08-16): la porta il ponte, non il prompt ——
216
- // Il ponte ha la designazione IN MANO (MC3.4: è la sua condizione di
217
- // funzionamento) e un prompt può legittimamente mancare (MC2.4): se
225
+ // — Identità della Live (2026-08-16): la porta il ponte, non il prompt ——
226
+ // Il ponte ha la designazione IN MANO: è la sua condizione di
227
+ // Funzionamento) e un prompt può legittimamente mancare: se
218
228
  // l'identità dipendesse dal prompt, l'assenza del prompt diventerebbe assenza
219
229
  // di identità — è esattamente il difetto visto sul campo (la voce andava a
220
230
  // leggere tmux per capire dove si trovava).
221
231
  //
222
- // R30 (v2, 2026-08-19): l'intestazione dice anche COME raggiungere i tool
232
+ // (v2, 2026-08-19): the header also states HOW to reach the tools
223
233
  // NexusCrew. Il daemon app-server espone UN solo insieme di server MCP a
224
234
  // tutte le Live, con l'ambiente del daemon: nexuscrew è l'unico che prende
225
235
  // l'identità dall'ambiente ereditato, quindi i suoi tool con sessione
@@ -343,7 +353,7 @@ function startThreadOnControlSocket({
343
353
  title: 'NexusCrew Live Bridge',
344
354
  version: bridgeVersion(),
345
355
  // Identita della connessione: la sessione gia' risolta della cella
346
- // designata (mai env grezzi, mai guess — roster MC3.4). Se assente
356
+ // Designata (mai env grezzi, mai guess — roster). Se assente
347
357
  // il campo SI OMETTE (Option None lato protocollo): il resolver a
348
358
  // valle classifica MISSING nominando la causa; sanitizzare qui la
349
359
  // duplicherebbe senza guadagno.
@@ -390,7 +400,7 @@ function startThreadOnControlSocket({
390
400
  return;
391
401
  }
392
402
  // Notifiche broadcast e risposte non attese: ignorate (connessione
393
- // on-demand, la finestra di esposizione alla fuga MC3.3 è minima).
403
+ // On-demand, la finestra di esposizione alla fuga è minima).
394
404
  });
395
405
 
396
406
  ws.on('error', (e) => done(Object.assign(new Error(`control socket: ${e.message}`), { code: 'ESOCKET' })));
@@ -416,7 +426,7 @@ function createLiveBridge({
416
426
  const threadIdsByCell = new Map();
417
427
  const threadStatusCache = new Map();
418
428
  const threadStatusInFlight = new Map();
419
- // R4: riserve in-process per l'avvio Live. La chiave è la cella host: una
429
+ // Riserve in-process per l'avvio Live. La chiave è la cella host: una
420
430
  // sola start provvisoria per cella alla volta, e il commit ricontrolla la
421
431
  // tupla congelata in riserva prima di accettare il thread creato.
422
432
  const pendingLiveByCell = new Map();
@@ -425,7 +435,7 @@ function createLiveBridge({
425
435
 
426
436
  const none = (reason, extra) => ({ mode: 'none', reason, ...(extra || {}), at: now() });
427
437
 
428
- // MC1: la designazione si legge dalla ROUTE, con il token del nodo, entro
438
+ // La designazione si legge dalla ROUTE, con il token del nodo, entro
429
439
  // il limite dichiarato. retry no, cache no: una lettura per avvio Live.
430
440
  async function readDesignation() {
431
441
  const ctrl = new AbortController();
@@ -543,7 +553,7 @@ function createLiveBridge({
543
553
  if (cell.active !== true) return none('host-cell-inactive');
544
554
  if (typeof cell.cwd !== 'string' || !cell.cwd) return none('cell-cwd-unknown');
545
555
 
546
- // JC2: la modalità è una funzione dell'engine, non una scelta. Nativa solo
556
+ // La modalità è una funzione dell'engine, non una scelta. Nativa solo
547
557
  // su engine codex-vl (il thread ponte vive nell'app-server del fork); per
548
558
  // qualunque altro engine la Live lavora ATTRAVERSO la cella e il ponte non
549
559
  // ha nulla da creare qui.
@@ -555,7 +565,7 @@ function createLiveBridge({
555
565
  if (!engine.startsWith('codex-vl')) {
556
566
  const out = {
557
567
  mode: 'tmux', cell: snap.hostCell, engine: cell.engine || null, cwd: cell.cwd,
558
- // JC5.5: in modalità tmux le regole le applica la cella; nessuna
568
+ // In modalità tmux le regole le applica la cella; nessuna
559
569
  // iniezione da parte del ponte.
560
570
  prompt: { applied: false, reason: 'tmux-mode' },
561
571
  at: now(),
@@ -564,16 +574,16 @@ function createLiveBridge({
564
574
  return out;
565
575
  }
566
576
 
567
- // R2: l'identità viaggia SEMPRE, anteposta al prompt quando c'è. Il campo
577
+ // L'identità viaggia SEMPRE, anteposta al prompt quando c'è. Il campo
568
578
  // non è mai più assente: senza LIVE_PROMPT.md porta la sola intestazione
569
579
  // — e poiché il campo SOSTITUISCE le developer instructions della config
570
- // (vedi MC2: la .or() in config/mod.rs), quella cella non le riceve più.
580
+ // (see: the .or() in config/mod.rs), that cell no longer receives them.
571
581
  const intestazione = identityHeader(snap.hostCell, cell.tmuxSession);
572
582
  const developerInstructions = prompt.applied
573
583
  ? `${intestazione}\n\n${prompt.text}`
574
584
  : intestazione;
575
585
 
576
- // R4: riserva della tupla e start provvisorio. Finché il commit non
586
+ // Riserva della tupla e start provvisorio. Finché il commit non
577
587
  // ricontrolla la stessa tupla, il thread NON viene accettato: nessun tool
578
588
  // può attraversarlo, perché il ponte non ne registra l'id.
579
589
  if (pendingLiveByCell.has(snap.hostCell)) return none('reservation-in-flight');
@@ -588,8 +598,8 @@ function createLiveBridge({
588
598
  cwd: cell.cwd,
589
599
  developerInstructions,
590
600
  // Stessa fonte dell'intestazione e del prompt per-cella (roster:
591
- // MC3.4). Dichiarata per connessione: batte qualunque ambiente
592
- // ereditato, anche quando e' popolato ma stantio (B1-bis).
601
+ //). Dichiarata per connessione: batte qualunque ambiente
602
+ // Ereditato, anche quando e' popolato ma stantio (-bis).
593
603
  declaredSession: cell.tmuxSession,
594
604
  timeoutMs: cfg.liveBridgeTimeoutMs,
595
605
  WebSocket,
@@ -602,7 +612,7 @@ function createLiveBridge({
602
612
  return none(reason, { cell: snap.hostCell, detail: String(e.message) });
603
613
  }
604
614
 
605
- // R4: commit. La designazione e il lease vengono riletti e confrontati con
615
+ // Commit. La designazione e il lease vengono riletti e confrontati con
606
616
  // la tupla riservata: se qualcosa è cambiato durante lo start, il thread
607
617
  // resta scartato (zero dispatch) e l'esito lo dichiara con l'id scartato.
608
618
  let commitSnap;
@@ -639,9 +649,9 @@ function createLiveBridge({
639
649
  };
640
650
  threadIdsByCell.set(snap.hostCell, started.threadId);
641
651
  threadStatusCache.delete(snap.hostCell);
642
- // LC1.4: il puntamento è visibile lato nostro — log con cella, thread e
652
+ // Il puntamento è visibile lato nostro — log con cella, thread e
643
653
  // prompt applicato. È il "dirottamento dichiarato" del contratto. Il
644
- // campo SOSTITUISCE le developer instructions della config (MC2, la
654
+ // Campo SOSTITUISCE le developer instructions della config (la
645
655
  // .or() in config/mod.rs): il log lo dichiara, perché chi lo legge sappia
646
656
  // cosa quella Live NON riceve.
647
657
  log(`[live-bridge] Live puntata su ${snap.hostCell}: thread ${started.threadId} (cwd ${started.cwd}, identità nell'intestazione, prompt ${prompt.applied ? 'per-cella applicato' : `non applicato (${promptEcho.reason})`}, sostituisce le developer instructions di config)`);
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+ // lib/live-host/federation.js — client-side seam per la designazione remota.
3
+ //
4
+ // Un nodo CLIENT (la pagina e il ponte vivono qui, la cella designata no) deve
5
+ // poter LEGGERE lo stato del proprietario e INOLTRARE la risoluzione del ponte,
6
+ // senza che nessun chiamante scelga il target: il target e' il proprietario
7
+ // della designazione registrata, risolto sulla topologia autoritativa (stessa
8
+ // regola del dispatcher audio/notify). Nessun fallback locale: se il
9
+ // proprietario non e' raggiungibile l'esito e' nominato, non sostituito.
10
+ //
11
+ // Il transito usa la stessa via di tutti: /api/route/<route>/_<resource> col
12
+ // Bearer locale — il primo hop e' questo stesso server, che applica peering,
13
+ // gate liveHostAccess e prova di hop come per qualunque altro inoltro.
14
+ const { resolvePeer } = require('../nodes/inventory.js');
15
+
16
+ const INSTANCE_ID_RE = /^[a-f0-9]{32}$/i;
17
+ // Refusal names the owner may send back. The caller must not relabel "not
18
+ // granted" as "unreachable": a refusal is a decision, an unreachable peer is a
19
+ // failure. Anything outside this list stays unnamed, not guessed.
20
+ const NAMED_PEER_REASONS = new Set(['live-host-not-granted', 'live-host-expectation-mismatch']);
21
+ // Name THIS node's own proxy gives to "I could not reach the peer": from here it
22
+ // means the owner is unreachable, not that the owner refused.
23
+ const PEER_UNREACHABLE = 'federation-peer-unreachable';
24
+ const FED_TIMEOUT_MS = 8000;
25
+
26
+ function createLiveHostFederation({
27
+ localNodeId = () => null,
28
+ peers = async () => [],
29
+ localPort = () => 0,
30
+ localToken = () => '',
31
+ fetchImpl = fetch,
32
+ timeoutMs = FED_TIMEOUT_MS,
33
+ } = {}) {
34
+ async function resolveRoute(target) {
35
+ if (!INSTANCE_ID_RE.test(String(target || ''))) return { error: 'invalid-target' };
36
+ let list;
37
+ try { list = await peers(); } catch (_) { return { error: 'topology-unavailable' }; }
38
+ if (!Array.isArray(list) || !list.length) return { error: 'unknown-target' };
39
+ const found = resolvePeer(list.filter((p) => p && (p.nodeId || p.instanceId)), String(target));
40
+ if (found.error || !found.peer) return { error: 'unknown-target' };
41
+ const route = Array.isArray(found.peer.route) ? found.peer.route.filter(Boolean) : [];
42
+ if (!route.length) return { error: 'unknown-target' };
43
+ return { route, peer: found.peer };
44
+ }
45
+
46
+ function ownerRoute(target) {
47
+ return resolveRoute(target).then((r) => (r.route ? r.route : null)).catch(() => null);
48
+ }
49
+
50
+ async function forward(target, resource, method, body) {
51
+ const resolved = await resolveRoute(target);
52
+ if (resolved.error) return { ok: false, reason: resolved.error === 'invalid-target' ? 'invalid-target' : 'live-host-owner-unreachable' };
53
+ const port = localPort();
54
+ const token = localToken();
55
+ if (!port || !token) return { ok: false, reason: 'live-host-owner-unreachable' };
56
+ const url = `http://127.0.0.1:${port}/api/route/${resolved.route.join('/')}/_${resource}`;
57
+ let res;
58
+ try {
59
+ res = await fetchImpl(url, {
60
+ method,
61
+ headers: { authorization: `Bearer ${token}`, ...(body !== undefined ? { 'content-type': 'application/json' } : {}) },
62
+ body: body !== undefined ? JSON.stringify(body) : undefined,
63
+ signal: AbortSignal.timeout(timeoutMs),
64
+ });
65
+ } catch (_) {
66
+ return { ok: false, reason: 'live-host-owner-unreachable' };
67
+ }
68
+ let payload = null;
69
+ try { payload = await res.json(); } catch (_) { payload = null; }
70
+ const named = payload && NAMED_PEER_REASONS.has(payload.reason) ? payload.reason : null;
71
+ const ok = res.status >= 200 && res.status < 300;
72
+ // A refusal we can name stays the owner's own word for it; our own proxy's
73
+ // "peer unreachable" means the owner is unreachable; anything else the owner
74
+ // answers with comes back under OUR generic name plus the status we saw — a
75
+ // peer string is never passed on as if we had checked it.
76
+ const reason = ok ? null
77
+ : (payload && payload.reason === PEER_UNREACHABLE ? 'live-host-owner-unreachable'
78
+ : (named || 'live-host-owner-rejected'));
79
+ return { ok, status: res.status, body: payload, route: resolved.route, ...(reason ? { reason } : {}) };
80
+ }
81
+
82
+ // Stato del proprietario per la GET locale (threadStatus/eligible/lease).
83
+ function getOwnerState({ ownerId } = {}) {
84
+ return forward(ownerId, '/live-host', 'GET');
85
+ }
86
+
87
+ // Inoltro della risoluzione del ponte. Il body e' SOLO `{expect}` (unico
88
+ // campo ammesso dal percorso federato): coincide con la designazione
89
+ // registrata localmente, quindi il chiamante non sceglie nulla.
90
+ function bridgeForward({ ownerId, expect } = {}) {
91
+ return forward(ownerId, '/live-host/bridge', 'POST', { expect });
92
+ }
93
+
94
+ return { getOwnerState, bridgeForward, ownerRoute };
95
+ }
96
+
97
+ module.exports = { createLiveHostFederation };
@@ -10,7 +10,7 @@
10
10
  // chiede. Senza quel permesso il peer riceve un rifiuto che nomina la causa
11
11
  // (`live-host-not-granted`), non un silenzio.
12
12
  //
13
- // Invarianti (contratto rev6 §2, §9):
13
+ // Invariants (host-cell designation contract):
14
14
  // - hostCell unico per nodo, CAS su revision (due designazioni concorrenti non
15
15
  // lasciano due celle rosse).
16
16
  // - API-first: la route e' l'unica autorita'; il frontend riflette la risposta e
@@ -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' });
@@ -178,7 +336,7 @@ function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Dat
178
336
  const result = await bridge.resolveForLive();
179
337
  res.json(result);
180
338
  } catch (e) {
181
- // Il contratto (MC1.5) vuole che un guasto del ponte non fermi la Live:
339
+ // Il contratto vuole che un guasto del ponte non fermi la Live:
182
340
  // anche l'inaspettato collassa in `none` dichiarato, mai un 500.
183
341
  // `bridge-error` e' l'ultima rete: un'eccezione che nessun ramo previsto ha
184
342
  // classificato. Va NOMINATA come le altre, non lasciata fuori dall'elenco
@@ -1,10 +1,10 @@
1
1
  'use strict';
2
2
  // lib/live-host/store.js — stato della designazione "cella ospite Live" di un nodo.
3
3
  //
4
- // Un solo hostCell per nodo (contratto rev6 §2.2): chiave unica, non una convenzione
5
- // ripetuta in N punti. Lo stato vive su disco (sopravvive a riavvii di cella e di
6
- // NexusCrew) e l'aggiornamento e' un CAS su `revision`: due designazioni concorrenti
7
- // non possono lasciare due celle rosse — il perdente rilegge la revision e rinuncia.
4
+ // One hostCell per node (unique key, not a repeated convention):
5
+ // the state lives on disk (survives cell and NexusCrew restarts) and the
6
+ // update is a CAS on `revision`: two concurrent designations cannot leave
7
+ // two red cells — the loser re-reads the revision and gives up.
8
8
  //
9
9
  // La designazione e' PURAMENTE un marker di scelta del nodo. L'eligibilita' (la cella
10
10
  // e' anche attiva in questo momento?) e' derivata dal roster Fleet e non si persiste
@@ -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 };