@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,5 +1,5 @@
1
1
  'use strict';
2
- // Route del MCP bridge (design §2): notify, web-push, asks. Montate dentro il
2
+ // Routes of the MCP bridge: notify, web-push, asks. Mounted inside the
3
3
  // router /api gia' dietro requireToken (server.js) — qui restano solo:
4
4
  // - READONLY come FLOOR (da revisione): sono gated 403 TUTTI i mutanti — answer
5
5
  // (scrittura PTY via paste), push subscribe/unsubscribe (push.json) e la
@@ -18,6 +18,7 @@
18
18
  // Il paste della risposta riusa ESATTAMENTE pasteToSession (bracketed literal,
19
19
  // niente Invio, control char rifiutati): qui si sanifica il testo PRIMA.
20
20
  const express = require('express');
21
+ const { createAskAnswerService } = require('./ask-answer-service.js');
21
22
  const { isValidSession } = require('../files/store.js');
22
23
  const { normalizeNotificationLang } = require('./language.js');
23
24
  const { HOP_HEADER } = require('../proxy/hop-proof.js');
@@ -110,7 +111,16 @@ function notifyRoutes({
110
111
  // apre per omissione.
111
112
  localNodeId = () => null, originResolver = null, acl = null, dispatcher = null,
112
113
  federatedRate = null,
114
+ answerService = null, receipts = null,
113
115
  }) {
116
+ // The shared answer cycle is built from the local deps when the caller does
117
+ // not inject one: the local route and the federated surface must never drift
118
+ // into two different implementations.
119
+ const askService = answerService || createAskAnswerService({
120
+ asks, paste,
121
+ labelPrefix: replyLabel(cfg),
122
+ onClosure: (kind, info) => { try { notifier.emitRaw({ type: kind, id: info.askId }); } catch (_) {} },
123
+ });
114
124
  const r = express.Router();
115
125
  const json = express.json({ limit: '16kb' });
116
126
  const bindingGuard = createIdentityBindingGuard({
@@ -196,7 +206,9 @@ function notifyRoutes({
196
206
  return res.status(429).json({ status: 'refused', reason: `rate-${quota.bucket}` });
197
207
  }
198
208
  }
199
- const delivered = await notifier.emit({
209
+ // Delivery ONLY: a federated notify is delivered to this node's UI
210
+ // and never re-enters the event publisher (no loop, no re-export).
211
+ const delivered = await notifier.deliverOnly({
200
212
  title: b.title.trim(), body: b.body, urgency: b.urgency, lang,
201
213
  // Il mittente NON e' `b.session`: quel campo lo dichiara il chiamante.
202
214
  // Qui vale solo cio' che la catena ha provato, piu' la cella che il
@@ -204,7 +216,7 @@ function notifyRoutes({
204
216
  originNode: resolved.origin.node,
205
217
  originCell: resolved.origin.cell,
206
218
  });
207
- // R31-A3: lo status e' DERIVATO dai conteggi, non dichiarato a parte.
219
+ // : lo status e' DERIVATO dai conteggi, non dichiarato a parte.
208
220
  // `emit` e' best-effort — push fallito → 0, `ui` conta i write SSE
209
221
  // riusciti — e il dispatcher propaga SOLO l'etichetta (i conteggi
210
222
  // muoiono in forward(), rilievo da revisione): per la cella mittente e'
@@ -304,6 +316,7 @@ function notifyRoutes({
304
316
  urgency: 'high',
305
317
  session: ask.session,
306
318
  lang: 'it',
319
+ askId: ask.id,
307
320
  url: `/#ask=${ask.id}`,
308
321
  });
309
322
  res.status(201).json({ id: ask.id });
@@ -326,9 +339,12 @@ function notifyRoutes({
326
339
  const ask = asks.get(id);
327
340
  const binding = await guardBinding(req, ask && ask.session);
328
341
  if (binding instanceof Error) return bindingRejected(res, binding);
329
- const out = asks.dismiss(id);
342
+ // Same service as the federated path: one authoritative state, so the
343
+ // pending/unknown constraint cannot be bypassed from here.
344
+ const out = askService.dismiss(id);
330
345
  if (!out.ok) {
331
346
  if (out.reason === 'unknown') return res.status(404).json({ error: 'ask inesistente' });
347
+ if (out.reason === 'delivery-unknown-block') return res.status(409).json({ error: out.error, reason: out.reason });
332
348
  if (out.reason === 'answering') return res.status(409).json({ error: 'risposta in corso: non si scarta un ask in answering' });
333
349
  return res.status(500).json({ error: 'dismiss non riuscito' });
334
350
  }
@@ -348,7 +364,7 @@ function notifyRoutes({
348
364
  // Validazione del testo PRIMA del claim: nessun claim da rilasciare su 400.
349
365
  const raw = req.body && req.body.text;
350
366
  if (typeof raw !== 'string') return res.status(400).json({ error: 'text deve essere una stringa' });
351
- // R27: oltre il tetto si RIFIUTA, non si tronca. I due tetti vicini in casa
367
+ // Oltre il tetto si RIFIUTA, non si tronca. I due tetti vicini in casa
352
368
  // (title/body della notifica, nc_send_cell) rifiutano da sempre; qui invece
353
369
  // la textarea non ha limite, la route troncava a MAX_ANSWER e rispondeva
354
370
  // {answered:true}: l'operatore incollava una config e la cella riceveva la
@@ -358,7 +374,7 @@ function notifyRoutes({
358
374
  // La lunghezza misurata ENTRA nel messaggio: la route la conosce, e senza
359
375
  // di essa chi ha incollato 12000 caratteri sa che c'e' un tetto ma non di
360
376
  // quanto deve tagliare. Dirti che hai sbagliato senza dirti di quanto e'
361
- // la stessa meta' di difetto che R27 corregge altrove.
377
+ // La stessa meta' di difetto che corregge altrove.
362
378
  if (sanitized.length > MAX_ANSWER) {
363
379
  return res.status(400).json({ error: `text troppo lungo: ${sanitized.length} caratteri, il massimo e' ${MAX_ANSWER}` });
364
380
  }
@@ -367,25 +383,48 @@ function notifyRoutes({
367
383
  const ask = asks.get(id);
368
384
  const binding = await guardBinding(req, ask && ask.session);
369
385
  if (binding instanceof Error) return bindingRejected(res, binding);
370
- const claim = asks.claim(id);
371
- if (!claim.ok) {
372
- if (claim.reason === 'unknown') return res.status(404).json({ error: 'ask inesistente' });
373
- if (claim.reason === 'dismissed') return res.status(409).json({ error: 'ask gia\' scartato (dismissed)' });
374
- if (claim.reason === 'answering') return res.status(409).json({ error: 'risposta gia\' in corso da un\'altra richiesta' });
375
- return res.status(409).json({ error: 'ask gia\' risposto' });
386
+ // ONE answer cycle for local and federated: the service owns the
387
+ // claim -> paste -> commit flow AND the delivery-unknown lock. The
388
+ // closure event is emitted by the service, not here.
389
+ const out = await askService.answerLocal({ askId: id, text });
390
+ if (!out.ok) return res.status(out.code || 500).json({ error: out.error });
391
+ res.json({ answered: true, id });
392
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
393
+ });
394
+
395
+ // Explicit reconciliation: local and authenticated only, NEVER
396
+ // federata (la route non e' in allowlist). Decide l'esito dei tentativi
397
+ // delivery-unknown e sblocca l'ask; CAS facoltativa sulla revisione.
398
+ r.post('/asks/:id/reconcile', mutGate, json, (req, res) => {
399
+ try {
400
+ const id = String(req.params.id || '');
401
+ const ask = asks.get(id);
402
+ if (!ask) return res.status(404).json({ error: 'ask inesistente' });
403
+ const body = req.body || {};
404
+ const decision = body.decision;
405
+ if (decision !== 'mark-delivered' && decision !== 'allow-new-attempt') {
406
+ return res.status(400).json({ error: 'decision deve essere mark-delivered|allow-new-attempt' });
376
407
  }
377
- let pasted = false;
378
- try {
379
- pasted = await paste(claim.ask.session, `[${replyLabel(cfg)} reply · ask#${id}] ${text}`);
380
- } catch (_) { pasted = false; }
381
- if (!pasted) {
382
- asks.release(id); // rollback: l'ask resta open e contendibile
383
- return res.status(502).json({ error: `paste fallito: sessione "${claim.ask.session}" non raggiungibile` });
408
+ const out = askService.reconcile({ askId: id, decision, expectedRevision: body.expectedRevision });
409
+ if (!out.ok) {
410
+ if (out.reason === 'revision-required' || out.reason === 'bad-decision') {
411
+ return res.status(400).json({ error: out.error, reason: out.reason });
412
+ }
413
+ if (out.reason === 'revision-conflict') return res.status(409).json({ error: out.error, reason: out.reason });
414
+ if (out.reason === 'unknown') return res.status(404).json({ error: out.error, reason: out.reason });
415
+ if (out.reason === 'persist-failed') {
416
+ return res.status(500).json({ error: 'reconcile non persistito: ask ancora bloccato', reason: out.reason });
417
+ }
418
+ // Il servizio dichiara il proprio codice: il rifiuto di una
419
+ // riconciliazione su un paste VIVO (answering) o senza alcun esito
420
+ // incerto da riconciliare (nothing-to-reconcile, generation-changed)
421
+ // e' una decisione dell'operatore rifiutata, non un errore interno.
422
+ if (out.code === 409) {
423
+ return res.status(409).json({ error: out.error, reason: out.reason });
424
+ }
425
+ return res.status(500).json({ error: out.error || 'reconcile non riuscito', reason: out.reason });
384
426
  }
385
- asks.commit(id, text);
386
- // Le altre UI aperte tolgono la card/badge senza aspettare il poll.
387
- notifier.emitRaw({ type: 'ask-answered', id });
388
- res.json({ answered: true, id });
427
+ res.json({ reconciled: true, askId: id, decision, attempts: out.changed, revision: out.revision });
389
428
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
390
429
  });
391
430
 
@@ -5,6 +5,7 @@ const net = require('node:net');
5
5
  const express = require('express');
6
6
  const store = require('../nodes/store.js');
7
7
  const topologyCache = require('../nodes/topology-cache.js');
8
+ const { allowedByClass, classifyResource } = require('./resource-acl.js');
8
9
  const reverseRotation = require('../nodes/reverse-rotation.js');
9
10
  const { bearerFrom } = require('../auth/middleware.js');
10
11
  const { safeEqual } = require('../nodes/peering.js');
@@ -176,8 +177,8 @@ function knownResource(resource) {
176
177
  || resource === '/ws'
177
178
  // Un avviso all'operatore attraversa la federazione con la stessa spina
178
179
  // dorsale dell'audio: origine provata dalla catena `visited`, ACL del
179
- // target, budget dedicato. NON e' la coda di NC-NEXT-07 (§5 del modello di
180
- // autorita'): quella differisce un'esecuzione, questo mostra del testo e
180
+ // target, dedicated budget. It is NOT the authority-model queue: that one
181
+ // defers an execution, this one shows text and executes nothing.
181
182
  // non esegue nulla. `/audio/speak` esce da un altoparlante in una stanza
182
183
  // fisica ed e' gia' federato: una notifica e' meno invasiva di cosi'.
183
184
  || resource === '/notify'
@@ -192,6 +193,15 @@ function knownResource(resource) {
192
193
  // significava poter creare a distanza un engine che non si puo' rendere
193
194
  // avviabile a distanza — un'asimmetria che rompeva la modifica remota a
194
195
  // meta' strada, senza proteggere nulla.
196
+ // Event feed: a closed GET surface inside the authenticated API.
197
+ // The handler demands a proven last hop on top of the class gate — the
198
+ // allowlist only admits the resources exist at all.
199
+ || resource === '/event-feed'
200
+ || resource === '/event-feed/snapshot'
201
+ // Federated ask write-back: answer, dismiss, attempt status.
202
+ || /^\/event-feed\/asks\/[a-f0-9]{8}(?:\/answer|\/requests\/[0-9a-f-]{16,64})?$/.test(resource)
203
+ || /^\/event-feed\/[A-Za-z0-9._-]{1,32}$/.test(resource)
204
+ || /^\/event-feed\/node\/[a-f0-9]{32}$/.test(resource)
195
205
  || isPanelResource(resource)
196
206
  || /^\/fleet\/(status|schema|definitions|credentials\/status|credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource);
197
207
  }
@@ -221,18 +231,31 @@ function panelAllowedFor(ingress) {
221
231
  // che la possiede, non un dato che ogni peer pairato vede gia' — stesso gate
222
232
  // per-peer del pannello, stesso default negato. A differenza del pannello (un
223
233
  // sottoalbero /panel/<cella>/*, perche' serve HTML/JS/asset), qui l'insieme
224
- // delle risorse e' chiuso: tre endpoint fissi, non un percorso arbitrario.
225
- const LIVE_HOST_RESOURCES = new Set(['/live-host', '/live-host/designate', '/live-host/clear']);
234
+ // delle risorse e' chiuso: quattro endpoint fissi, non un percorso arbitrario.
235
+ const LIVE_HOST_RESOURCES = new Set(['/live-host', '/live-host/designate', '/live-host/clear', '/live-host/bridge']);
226
236
  function isLiveHostResource(resource) { return LIVE_HOST_RESOURCES.has(resource); }
227
237
  function liveHostAllowedFor(ingress) {
228
238
  if (!ingress) return true;
229
239
  return ingress.liveHostAccess === true;
230
240
  }
231
241
 
242
+ // One decision, used by BOTH federated entry points: the HTTP relay and the
243
+ // WebSocket upgrade. A resource whose class is unknown, or whose grants the
244
+ // owner has not conceded, is refused before anything is dispatched.
245
+ function classGateCheck(resource, method, ingress) {
246
+ const cls = classifyResource(resource, method);
247
+ if (!cls) return { ok: false, status: 404, reason: 'resource-not-classified' };
248
+ if (!allowedByClass(cls, ingress)) return { ok: false, status: 403, reason: `grant-required:${cls}` };
249
+ return { ok: true, className: cls };
250
+ }
251
+
232
252
  function allowedResource(resource, method = 'GET') {
233
253
  if (isPanelResource(resource)) return method === 'GET' || method === 'POST';
234
254
  if (resource === '/live-host') return method === 'GET';
235
255
  if (resource === '/live-host/designate' || resource === '/live-host/clear') return method === 'POST';
256
+ // The bridge resolution travels to the owner without parameters: the
257
+ // designation picks the target, so a forwarded call cannot add one.
258
+ if (resource === '/live-host/bridge') return method === 'POST';
236
259
  if (resource === '/sessions') return method === 'GET' || method === 'POST';
237
260
  if (/^\/sessions\/[\w.@%:+-]{1,128}$/.test(resource)) return method === 'DELETE';
238
261
  if (/^\/sessions\/[\w.@%:+-]{1,128}\/visibility$/.test(resource)) return method === 'PATCH';
@@ -270,6 +293,13 @@ function allowedResource(resource, method = 'GET') {
270
293
  // Notify: solo POST. `/events`, `/asks` e `/push/*` restano NON federati —
271
294
  // SSE non puo' autenticarsi col Bearer e un ask ha un canale di ritorno che
272
295
  // e' un paste nel tmux locale, quindi non attraverserebbe comunque.
296
+ // Event feed: GET only, no mutation, no upgrade.
297
+ if (resource === '/event-feed' || resource === '/event-feed/snapshot') return method === 'GET';
298
+ if (/^\/event-feed\/[A-Za-z0-9._-]{1,32}$/.test(resource)) return method === 'GET';
299
+ if (/^\/event-feed\/node\/[a-f0-9]{32}$/.test(resource)) return method === 'GET';
300
+ if (/^\/event-feed\/asks\/[a-f0-9]{8}\/answer$/.test(resource)) return method === 'POST';
301
+ if (/^\/event-feed\/asks\/[a-f0-9]{8}$/.test(resource)) return method === 'DELETE';
302
+ if (/^\/event-feed\/asks\/[a-f0-9]{8}\/requests\/[0-9a-f-]{16,64}$/.test(resource)) return method === 'GET';
273
303
  if (resource === '/notify') return method === 'POST';
274
304
  // Audio: only capability read, speak and stop are exposed through Hydra.
275
305
  // audio.consent is a LOCAL mutation and MUST stay unreachable federated.
@@ -290,7 +320,27 @@ function readonlyBlocksFederated(resource, method) {
290
320
  return resource !== '/audio/speak/status' && resource !== '/audio/stop';
291
321
  }
292
322
 
323
+ const EVENT_FEED_CURSOR_RE = /^[0-9]{1,12}:[0-9]{1,12}$/;
324
+
293
325
  function allowedQuery(resource, method, rawUrl) {
326
+ // The event feed has a CLOSED query surface: only `after=<epoch>:<seq>` on
327
+ // the SSE stream, nothing at all on the snapshot. A token in a query is
328
+ // refused on these paths whatever it looks like.
329
+ if (resource === '/event-feed' || resource === '/event-feed/snapshot'
330
+ || /^\/event-feed\/[A-Za-z0-9._-]{1,32}$/.test(resource)
331
+ || /^\/event-feed\/node\/[a-f0-9]{32}$/.test(resource)) {
332
+ const index = String(rawUrl || '').indexOf('?');
333
+ if (index < 0) return true;
334
+ const params = new URLSearchParams(String(rawUrl).slice(index + 1));
335
+ const keys = [...params.keys()];
336
+ if (keys.some((k) => !/^[A-Za-z0-9_]+$/.test(k))) return false;
337
+ if (params.has('token') || params.has('access_token')) return false;
338
+ if (resource !== '/event-feed') return keys.length === 0;
339
+ if (params.getAll('after').length > 1) return false;
340
+ if (keys.length === 0) return true;
341
+ if (keys.length !== 1 || !params.has('after')) return false;
342
+ return EVENT_FEED_CURSOR_RE.test(params.get('after') || '');
343
+ }
294
344
  if (!resource.startsWith('/diagnostics/')) return true;
295
345
  const index = String(rawUrl || '').indexOf('?');
296
346
  if (index < 0) return true;
@@ -364,17 +414,57 @@ function rewriteSetCookiePath(headers, prefix) {
364
414
  return out;
365
415
  }
366
416
 
367
- function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null, setCookiePrefix = null, panelCookie = null }) {
417
+ // What this node records when a designation is created or removed on a PEER
418
+ // through it: `ownerId` is the peer the forward went to, and the revision is
419
+ // the owner's own, taken from its 2xx answer — never a local guess, because the
420
+ // owner is the one that decides. A non-2xx answer, or a body without
421
+ // `{hostCell, revision}`, records nothing: an absent pointer is honest, an
422
+ // invented one is not.
423
+ function liveHostLedgerCapture({ ledger, ownerId, resource }) {
424
+ return (status, text) => {
425
+ if (!ledger || !(status >= 200 && status < 300)) return;
426
+ if (resource === '/live-host/clear') {
427
+ if (typeof ledger.clearRemote === 'function') ledger.clearRemote(ownerId);
428
+ return;
429
+ }
430
+ let body = null;
431
+ try { body = JSON.parse(String(text || '')); } catch (_) { return; }
432
+ if (!body || typeof body.hostCell !== 'string' || !Number.isInteger(body.revision)) return;
433
+ if (typeof ledger.recordRemote === 'function') {
434
+ ledger.recordRemote({ ownerId, hostCell: body.hostCell, revision: body.revision });
435
+ }
436
+ };
437
+ }
438
+
439
+ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null, setCookiePrefix = null, panelCookie = null, capture = null }) {
440
+ // A body a local middleware has already read is no longer in the stream: the
441
+ // headers would announce bytes that nothing sends, and the peer would wait for
442
+ // a body that never arrives. Say it at once, with a name, instead of letting
443
+ // the forward sit until the upstream timeout expires.
444
+ if (Number(req.headers['content-length'] || 0) > 0 && req.readableEnded) {
445
+ return res.status(502).json({ error: 'request body already read by a local middleware', reason: 'federation-body-consumed' });
446
+ }
368
447
  const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited, hopProof, panelCookie) }, (r) => {
369
448
  const headers = setCookiePrefix ? rewriteSetCookiePath(r.headers, setCookiePrefix) : r.headers;
370
- res.writeHead(r.statusCode, sanitizeResponseHeaders(headers)); r.pipe(res);
449
+ if (!capture) { res.writeHead(r.statusCode, sanitizeResponseHeaders(headers)); r.pipe(res); return; }
450
+ // Buffered only where a capture asks for it (the two live-host mutations):
451
+ // the owner's answer carries the revision that has to be recorded.
452
+ const chunks = [];
453
+ r.on('data', (chunk) => chunks.push(chunk));
454
+ r.on('end', () => {
455
+ const body = Buffer.concat(chunks);
456
+ try { capture(r.statusCode, body.toString('utf8')); } catch (_) { /* the ledger never breaks the forward */ }
457
+ if (res.writableEnded) return;
458
+ res.writeHead(r.statusCode, sanitizeResponseHeaders(headers));
459
+ res.end(body);
460
+ });
371
461
  });
372
462
  up.setTimeout(30000, () => up.destroy());
373
- up.on('error', () => { if (!res.headersSent) res.status(502).json({ error: 'peer non raggiungibile' }); else res.destroy(); });
463
+ up.on('error', () => { if (!res.headersSent) res.status(502).json({ error: 'peer non raggiungibile', reason: 'federation-peer-unreachable' }); else res.destroy(); });
374
464
  req.pipe(up);
375
465
  }
376
466
 
377
- function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null }) {
467
+ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null, liveHostLedger = null }) {
378
468
  return (req, res) => {
379
469
  const parsed = parseRoute(req.url);
380
470
  // Prima dell'allowlist: un peer senza permesso non deve nemmeno sapere se la
@@ -385,6 +475,20 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
385
475
  if (parsed && isLiveHostResource(parsed.resource) && !liveHostAllowedFor(ingress)) {
386
476
  return res.status(403).json({ error: 'cella ospite live non concessa da questo nodo', reason: 'live-host-not-granted' });
387
477
  }
478
+ // Class gate, before the dispatch: every federated resource belongs to a
479
+ // class, and the class decides which grants the owner must have conceded.
480
+ // A resource that is allowlisted but not classified is DENIED — a new route
481
+ // must not reach a limited peer by accident — and the coverage test fails
482
+ // when the allowlist grows without the classification.
483
+ if (parsed && allowedResource(parsed.resource, req.method)) {
484
+ const gate = classGateCheck(parsed.resource, req.method, ingress);
485
+ if (!gate.ok) {
486
+ return res.status(gate.status).json({
487
+ error: gate.status === 404 ? 'not found' : 'risorsa non concessa a questo nodo',
488
+ reason: gate.reason,
489
+ });
490
+ }
491
+ }
388
492
  if (!parsed || !allowedResource(parsed.resource, req.method)
389
493
  || !allowedQuery(parsed.resource, req.method, req.url)) return res.status(404).json({ error: 'not found' });
390
494
  if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
@@ -422,7 +526,13 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
422
526
  const setCookiePrefix = !ingress && isPanelResource(parsed.resource)
423
527
  ? `/api/route/${parsed.route.join('/')}/${ROUTE_DELIMITER}`
424
528
  : null;
425
- proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited, setCookiePrefix, panelCookie });
529
+ // A designation forwarded from here lands on the owner: what this node ends
530
+ // up pointing at is decided THERE, so the pointer is recorded from the
531
+ // owner's answer (liveHostLedgerCapture) and not from a local guess.
532
+ const capture = liveHostLedger && (parsed.resource === '/live-host/designate' || parsed.resource === '/live-host/clear')
533
+ ? liveHostLedgerCapture({ ledger: liveHostLedger, ownerId: next.nodeId, resource: parsed.resource })
534
+ : null;
535
+ proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited, setCookiePrefix, panelCookie, capture });
426
536
  };
427
537
  }
428
538
 
@@ -748,7 +858,7 @@ async function reconcilePeerShare(opts = {}) {
748
858
  throw lastError || new Error('riconciliazione Share fallita');
749
859
  }
750
860
 
751
- // Runner di riconciliazione Share OFF al boot (design piano §3.2.8). Per-peer,
861
+ // Share-off reconciliation runner at boot.
752
862
  // no-overlap (un solo runner attivo per nome), al massimo tre round per processo,
753
863
  // backoff nominato 0/1000/5000 ms (iniettabile). Re-read dello stato desiderato
754
864
  // (nodes.json) prima di ogni round: abort se non e' piu' shared:false (es. il peer
@@ -995,7 +1105,11 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
995
1105
  r.get('/health', (_req, res) => {
996
1106
  const st = store.loadStore(nodesPath);
997
1107
  const advertisedRoles = typeof roles === 'function' ? roles() : null;
1108
+ // eventFeedV1: the peer checks this capability BEFORE subscribing to the
1109
+ // event feed. An older node reports no capability and the client marks the
1110
+ // feed unsupported instead of retrying forever.
998
1111
  res.json({ ok: true, instanceId: (st && st.nodeId) || null, version, readonly: !!readonly(),
1112
+ eventFeedV1: true,
999
1113
  ...(advertisedRoles ? { roles: advertisedRoles } : {}) });
1000
1114
  });
1001
1115
  // Il client puo' verificare SOLO la reverse port che il pairing gli ha
@@ -1257,9 +1371,9 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
1257
1371
  return r;
1258
1372
  }
1259
1373
 
1260
- function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecret = null }) {
1374
+ function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecret = null, liveHostLedger = null }) {
1261
1375
  const r = express.Router();
1262
- r.use((req, res) => routeHandler({ nodesPath, localPort, localCredential, readonly, hopSecret })(req, res));
1376
+ r.use((req, res) => routeHandler({ nodesPath, localPort, localCredential, readonly, hopSecret, liveHostLedger })(req, res));
1263
1377
  return r;
1264
1378
  }
1265
1379
 
@@ -1272,6 +1386,12 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1272
1386
  // frame arrivano da qui.
1273
1387
  if (!parsed || (parsed.resource !== '/ws' && !isPanelResource(parsed.resource))) return reject(socket, 404);
1274
1388
  if (isPanelResource(parsed.resource) && !panelAllowedFor(ingress)) return reject(socket, 403);
1389
+ // The same class decision as the HTTP relay: an upgrade is an entry point too,
1390
+ // and a limited peer must not reach an operator-class channel through it.
1391
+ {
1392
+ const gate = classGateCheck(parsed.resource, 'GET', ingress);
1393
+ if (!gate.ok) return reject(socket, gate.status === 404 ? 404 : 403);
1394
+ }
1275
1395
  const st = store.loadStore(nodesPath);
1276
1396
  if (!st) return reject(socket, 503);
1277
1397
  const visited = controlledVisited(req, ingress, st.nodeId);
@@ -1,13 +1,13 @@
1
1
  'use strict';
2
- // lib/proxy/node-proxy.js — reverse-proxy single-origin /node/<name> (design §4, §4b(2)).
2
+ // lib/proxy/node-proxy.js — single-origin reverse proxy for /node/<name>.
3
3
  //
4
- // La superficie PIU' security-critical del progetto. Contratti duri (§4b(2)):
4
+ // The MOST security-critical surface of the project. Hard contracts:
5
5
  // 1. Il token LOCALE (Bearer) si verifica PRIMA di risolvere <name>. Il router
6
6
  // HTTP e' montato DIETRO requireToken; l'upgrade WS verifica il token in testa
7
7
  // a handleNodeUpgrade, prima di qualunque parsing/resolve.
8
8
  // 2. <name> = chiave strict di nodes.json (^[a-z0-9-]{1,32}$), MAI usata per
9
9
  // costruire path/URL filesystem; nome non in config -> 404 secco.
10
- // 3. Il token del nodo remoto lo inietta SOLO il proxy (da nodes.json via B0); il
10
+ // 3. Il token del nodo remoto lo inietta SOLO il proxy (da nodes.json via); il
11
11
  // browser non lo vede MAI. Header hop-by-hop e Authorization/cookie/host/
12
12
  // x-forwarded/proxy-* client-supplied STRIPPATI, mai inoltrati upstream.
13
13
  // 4. Upstream consentito: ESCLUSIVAMENTE 127.0.0.1:<localPort> del tunnel da
@@ -25,7 +25,7 @@ const net = require('node:net');
25
25
  const { NODE_NAME_RE } = require('../nodes/store.js');
26
26
  const { bearerFrom } = require('../auth/middleware.js');
27
27
 
28
- // Timeout upstream: irraggiungibile/lento -> 502, mai spinner infinito (§8).
28
+ // Upstream timeouts: unreachable/slow -> 502, never an endless spinner.
29
29
  const PROXY_TIMEOUT_MS = 30000;
30
30
  const CONNECT_TIMEOUT_MS = 10000;
31
31
 
@@ -52,7 +52,7 @@ const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
52
52
  // forma di difetto che continuiamo a trovare.
53
53
  const LOCAL_ONLY_PREFIXES = ['/api/live-host', '/api/panel', '/api/route', '/federation/route'];
54
54
 
55
- // Hop-by-hop (RFC 7230 §6.1) + Proxy-*: mai inoltrati end-to-end.
55
+ // Hop-by-hop (RFC 7230 section 6.1) + Proxy-*: never forwarded end-to-end.
56
56
  const HOP_BY_HOP = new Set([
57
57
  'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
58
58
  'te', 'trailer', 'transfer-encoding', 'upgrade',
@@ -153,9 +153,9 @@ function createNodeProxy(deps) {
153
153
  const { resolveNode, readonly = () => false, httpRequest = http.request } = deps;
154
154
  return function nodeProxy(req, res) {
155
155
  const parsed = splitNodePath(req.url);
156
- if (!parsed) return notFound(res); // §4b(2)#2 no name
157
- if (!NODE_NAME_RE.test(parsed.name)) return notFound(res); // §4b(2)#2 strict/traversal
158
- if (isTransitiveRest(parsed.rest)) return notFound(res); // §4b(2)#7 no transitive
156
+ if (!parsed) return notFound(res); // no name
157
+ if (!NODE_NAME_RE.test(parsed.name)) return notFound(res); // strict/traversal
158
+ if (isTransitiveRest(parsed.rest)) return notFound(res); // no transitive rest
159
159
  if (isLocalOnly(parsed.rest)) {
160
160
  return res.status(403).json({ error: 'local-only: azione non instradabile via federazione' });
161
161
  }
@@ -170,7 +170,7 @@ function createNodeProxy(deps) {
170
170
 
171
171
  function proxyHttp(req, res, node, rest, search, httpRequest) {
172
172
  const options = {
173
- host: '127.0.0.1', // §4b(2)#4 upstream SOLO loopback da config
173
+ host: '127.0.0.1', // upstream loopback ONLY, from config
174
174
  port: node.localPort,
175
175
  method: req.method,
176
176
  // parita' col path WS: il token LOCALE eventualmente in query (?token=) non
@@ -278,10 +278,10 @@ function handleNodeUpgrade(ctx) {
278
278
  const node = resolveNode(parsed.name);
279
279
  if (!node) return abortUpgrade(socket, 404);
280
280
 
281
- // §9d: in READONLY locale il WS proxy si nega in toto — il piping raw non puo'
282
- // applicare un readonly frame-level e un attach WS e' un canale di scrittura
283
- // (PTY remoto). Il nodo remoto applica il PROPRIO READONLY ai suoi client;
284
- // qui vale quello locale, come per i metodi HTTP mutanti.
281
+ // In local READONLY the WS proxy refuses wholesale — raw piping cannot
282
+ // apply a frame-level readonly and a WS attach is a write channel (remote
283
+ // PTY). The remote node applies ITS OWN READONLY to its clients;
284
+ // here the local one rules, as for mutating HTTP methods.
285
285
  if (readonly()) return abortUpgrade(socket, 403);
286
286
 
287
287
  // (3)(4) inject token remoto, upstream SOLO loopback da config
@@ -17,7 +17,7 @@
17
17
  // browser, nei log del proxy o in un Referer.
18
18
  // 2. IL TICKET SI CONSUMA ALLA PRIMA RICHIESTA e la risposta imposta un cookie
19
19
  // HttpOnly SameSite Strict con Path=ESATTAMENTE il mount da cui la
20
- // richiesta e' entrata + la cella (R22, 2026-08-17): `/api/panel/<cella>`
20
+ // Richiesta e' entrata + la cella (2026-08-17): `/api/panel/<cella>`
21
21
  // sul control plane, `/panel/<cella>` sulla porta pannello dedicata — MAI
22
22
  // una costante unica, perche' i due mount servono path diversi e un cookie
23
23
  // scritto per l'uno non verrebbe mai mandato dal browser sotto l'altro
@@ -159,7 +159,7 @@ function createPanelAuth({
159
159
  return !!rec && rec.exp > now() && rec.cell === cellId;
160
160
  }
161
161
 
162
- // R22: il Path deve valere per il mount da cui la richiesta e' ENTRATA, non
162
+ // Il Path deve valere per il mount da cui la richiesta e' ENTRATA, non
163
163
  // per una costante. Whitelist stretta invece di un default permissivo — un
164
164
  // prefisso non riconosciuto rifiuta (vedi mountPrefixOf) invece di produrre
165
165
  // un cookie con uno scope che nessuno ha verificato.
@@ -237,7 +237,7 @@ function createPanelAuth({
237
237
  const qi = url.indexOf('?');
238
238
  if (qi !== -1) qTicket = new URLSearchParams(url.slice(qi + 1)).get('ticket');
239
239
  if (qTicket) {
240
- // R22: il mount si verifica PRIMA di toccare il ticket — un mount non
240
+ // Il mount si verifica PRIMA di toccare il ticket — un mount non
241
241
  // riconosciuto non deve bruciare un ticket monouso per un errore che
242
242
  // non dipende da chi lo presenta.
243
243
  const mountPrefix = mountPrefixOf(req);
@@ -318,7 +318,7 @@ function createPanelAuth({
318
318
  consumeCore(req, res, next, cellId, provenienza);
319
319
  }
320
320
 
321
- // —— SOLO consumo: per la porta pannello (P0 sicurezza 2026-08-16). Nessuna
321
+ // — SOLO consumo: per la porta pannello (sicurezza 2026-08-16). Nessuna
322
322
  // emissione qui — quella resta dietro requireToken sul control plane, mai
323
323
  // su una porta che non verifica un token. Un POST .../ticket arrivato qui
324
324
  // (non dovrebbe mai: frameUrl non lo costruisce) cade nel consumo e viene
@@ -43,7 +43,7 @@ const { CELL_ID_RE } = require('../live-host/store.js');
43
43
 
44
44
  const PANEL_TIMEOUT_MS = 30000;
45
45
 
46
- // Hop-by-hop (RFC 7230 §6.1) + Proxy-*: mai inoltrati end-to-end.
46
+ // Hop-by-hop (RFC 7230 section 6.1) + Proxy-*: never forwarded end-to-end.
47
47
  const HOP_BY_HOP = new Set([
48
48
  'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
49
49
  'te', 'trailer', 'transfer-encoding', 'upgrade',
@@ -295,7 +295,7 @@ function handlePanelUpgrade({ req, socket, head, resolveCellPanel, verifyToken,
295
295
  return kill(401, 'Unauthorized');
296
296
  }
297
297
  // Il prefisso varia con la porta: /api/panel sul control plane (compat),
298
- // /panel nudo sulla porta pannello dedicata (P0 sicurezza 2026-08-16).
298
+ // /panel nudo sulla porta pannello dedicata (sicurezza 2026-08-16).
299
299
  const parsed = splitPanelPath(req.url.replace(/^\/(?:api\/)?panel/, ''));
300
300
  if (!parsed) return kill(404, 'Not Found');
301
301
  Promise.resolve(resolveTarget(resolveCellPanel, parsed.cellId)).then((target) => {