@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.
- package/CHANGELOG.md +16 -0
- package/docs/LIVE_PROMPT.md +1 -1
- package/docs/NOTIFICATIONS.md +37 -0
- package/docs/OPERATIONS.md +20 -0
- package/frontend/dist/assets/index-BbcjoZS0.js +167 -0
- package/frontend/dist/assets/index-DqCfZFGi.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +91 -10
- package/frontend/dist/version.json +1 -1
- package/lib/audio/groups.js +1 -1
- package/lib/auth/token.js +4 -4
- package/lib/cli/commands.js +70 -15
- package/lib/cli/doctor.js +5 -5
- package/lib/cli/fleet-service.js +7 -7
- package/lib/cli/init.js +55 -31
- package/lib/cli/pidfile.js +1 -1
- package/lib/cli/service.js +124 -8
- package/lib/cli/stable-alias.js +4 -4
- package/lib/cli/url.js +1 -1
- package/lib/config.js +36 -6
- package/lib/files/routes.js +1 -1
- package/lib/fleet/boot.js +2 -2
- package/lib/fleet/builtin.js +15 -15
- package/lib/fleet/causes.js +1 -1
- package/lib/fleet/cell-exec.js +23 -23
- package/lib/fleet/cell-hold.js +6 -6
- package/lib/fleet/cell-lease-server.js +106 -106
- package/lib/fleet/cell-lease.js +21 -21
- package/lib/fleet/definitions.js +10 -10
- package/lib/fleet/identity-authority.js +1 -1
- package/lib/fleet/launch-broker.js +13 -13
- package/lib/fleet/launch.js +15 -15
- package/lib/fleet/lease-client.js +23 -23
- package/lib/fleet/lease-routes.js +3 -3
- package/lib/fleet/lease-verifier.js +22 -22
- package/lib/fleet/managed.js +35 -35
- package/lib/fleet/prompt-delivery.js +13 -13
- package/lib/fleet/routes.js +15 -10
- package/lib/fleet/runtime.js +24 -24
- package/lib/live-host/bridge.js +53 -43
- package/lib/live-host/federation.js +97 -0
- package/lib/live-host/routes.js +166 -8
- package/lib/live-host/store.js +44 -6
- package/lib/mcp/server.js +14 -14
- package/lib/mcp/tools.js +5 -5
- package/lib/nodes/access-presets.js +258 -0
- package/lib/nodes/commands.js +198 -4
- package/lib/nodes/store.js +152 -9
- package/lib/nodes/tunnel.js +7 -7
- package/lib/notify/ask-answer-service.js +184 -0
- package/lib/notify/ask-receipts.js +197 -0
- package/lib/notify/ask-relay.js +152 -0
- package/lib/notify/asks.js +28 -3
- package/lib/notify/event-feed-acl.js +95 -0
- package/lib/notify/event-feed-asks-routes.js +144 -0
- package/lib/notify/event-feed-client.js +434 -0
- package/lib/notify/event-feed-history.js +115 -0
- package/lib/notify/event-feed-producers.js +175 -0
- package/lib/notify/event-feed-routes.js +254 -0
- package/lib/notify/event-feed.js +328 -0
- package/lib/notify/events.js +1 -1
- package/lib/notify/notifier.js +3 -3
- package/lib/notify/push-relay.js +240 -0
- package/lib/notify/push.js +8 -8
- package/lib/notify/routes.js +62 -23
- package/lib/proxy/federation.js +132 -12
- package/lib/proxy/node-proxy.js +13 -13
- package/lib/proxy/panel-auth.js +4 -4
- package/lib/proxy/panel-proxy.js +2 -2
- package/lib/proxy/resource-acl.js +145 -0
- package/lib/server.js +396 -40
- package/lib/settings/pairing-coordinator.js +1 -1
- package/lib/settings/routes.js +45 -32
- package/lib/update/manager.js +1 -1
- package/lib/update/runner.js +1 -1
- package/lib/voice/transcribe.js +1 -1
- package/lib/ws/bridge.js +3 -3
- package/package.json +1 -1
- package/skills/live/SKILL.md +12 -0
- package/frontend/dist/assets/index-CWra6B79.js +0 -167
- package/frontend/dist/assets/index-D6CIfCjv.css +0 -32
package/lib/live-host/bridge.js
CHANGED
|
@@ -1,27 +1,37 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// lib/live-host/bridge.js —
|
|
2
|
+
// lib/live-host/bridge.js — the Live bridge (contract revision rev6 + rev4 LC).
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
29
|
+
// Il prompt per-cella (LIVE_PROMPT.md accanto ai canonici della
|
|
20
30
|
// cella) viaggia su developerInstructions di thread/start e SOSTITUISCE
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
40
|
-
//
|
|
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
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
188
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
216
|
-
// Il ponte ha la designazione IN MANO
|
|
217
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
592
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
652
|
+
// Il puntamento è visibile lato nostro — log con cella, thread e
|
|
643
653
|
// prompt applicato. È il "dirottamento dichiarato" del contratto. Il
|
|
644
|
-
//
|
|
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 };
|
package/lib/live-host/routes.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
168
|
-
// condizione
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
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
|
|
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
|
package/lib/live-host/store.js
CHANGED
|
@@ -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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|