@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.
- package/CHANGELOG.md +11 -0
- package/docs/OPERATIONS.md +20 -0
- package/frontend/dist/assets/index-B_THfiL5.js +167 -0
- package/frontend/dist/assets/index-CywSos2e.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +11 -3
- package/lib/cli/init.js +28 -4
- package/lib/cli/service.js +118 -2
- package/lib/live-host/bridge.js +17 -7
- package/lib/live-host/federation.js +97 -0
- package/lib/live-host/routes.js +164 -6
- package/lib/live-host/store.js +40 -2
- package/lib/proxy/federation.js +58 -9
- package/lib/server.js +24 -1
- package/package.json +1 -1
- package/skills/live/SKILL.md +12 -0
- package/frontend/dist/assets/index-KTiUwAV1.js +0 -167
- package/frontend/dist/assets/index-ir61K1bH.css +0 -32
package/lib/live-host/routes.js
CHANGED
|
@@ -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' });
|
package/lib/live-host/store.js
CHANGED
|
@@ -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 };
|
package/lib/proxy/federation.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
package/skills/live/SKILL.md
CHANGED
|
@@ -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
|