@mmmbuto/nexuscrew 0.9.29 → 0.9.31

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 (78) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/docs/LIVE_PROMPT.md +1 -1
  3. package/docs/NOTIFICATIONS.md +37 -0
  4. package/frontend/dist/assets/index-DaAL7P-m.js +167 -0
  5. package/frontend/dist/assets/{index-CywSos2e.css → index-DqCfZFGi.css} +1 -1
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/dist/sw.js +91 -10
  8. package/frontend/dist/version.json +1 -1
  9. package/lib/audio/groups.js +1 -1
  10. package/lib/auth/token.js +4 -4
  11. package/lib/cli/commands.js +59 -12
  12. package/lib/cli/doctor.js +36 -7
  13. package/lib/cli/fleet-service.js +7 -7
  14. package/lib/cli/init.js +27 -27
  15. package/lib/cli/pidfile.js +1 -1
  16. package/lib/cli/service.js +6 -6
  17. package/lib/cli/stable-alias.js +4 -4
  18. package/lib/cli/url.js +1 -1
  19. package/lib/config.js +36 -6
  20. package/lib/decks/routes.js +11 -1
  21. package/lib/files/routes.js +1 -1
  22. package/lib/fleet/boot.js +2 -2
  23. package/lib/fleet/builtin.js +28 -19
  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 +21 -13
  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 +46 -36
  37. package/lib/fleet/prompt-delivery.js +13 -13
  38. package/lib/fleet/routes.js +15 -10
  39. package/lib/fleet/runtime.js +26 -26
  40. package/lib/live-host/bridge.js +36 -36
  41. package/lib/live-host/routes.js +2 -2
  42. package/lib/live-host/store.js +4 -4
  43. package/lib/mcp/server.js +14 -14
  44. package/lib/mcp/tools.js +5 -5
  45. package/lib/nodes/access-presets.js +258 -0
  46. package/lib/nodes/commands.js +198 -4
  47. package/lib/nodes/store.js +152 -9
  48. package/lib/nodes/tunnel.js +7 -7
  49. package/lib/notify/ask-answer-service.js +184 -0
  50. package/lib/notify/ask-receipts.js +197 -0
  51. package/lib/notify/ask-relay.js +152 -0
  52. package/lib/notify/asks.js +28 -3
  53. package/lib/notify/event-feed-acl.js +95 -0
  54. package/lib/notify/event-feed-asks-routes.js +144 -0
  55. package/lib/notify/event-feed-client.js +434 -0
  56. package/lib/notify/event-feed-history.js +115 -0
  57. package/lib/notify/event-feed-producers.js +175 -0
  58. package/lib/notify/event-feed-routes.js +254 -0
  59. package/lib/notify/event-feed.js +328 -0
  60. package/lib/notify/events.js +1 -1
  61. package/lib/notify/notifier.js +3 -3
  62. package/lib/notify/push-relay.js +240 -0
  63. package/lib/notify/push.js +8 -8
  64. package/lib/notify/routes.js +62 -23
  65. package/lib/proxy/federation.js +74 -3
  66. package/lib/proxy/node-proxy.js +13 -13
  67. package/lib/proxy/panel-auth.js +4 -4
  68. package/lib/proxy/panel-proxy.js +2 -2
  69. package/lib/proxy/resource-acl.js +145 -0
  70. package/lib/server.js +372 -39
  71. package/lib/settings/pairing-coordinator.js +1 -1
  72. package/lib/settings/routes.js +45 -32
  73. package/lib/update/manager.js +1 -1
  74. package/lib/update/runner.js +1 -1
  75. package/lib/voice/transcribe.js +1 -1
  76. package/lib/ws/bridge.js +3 -3
  77. package/package.json +1 -1
  78. package/frontend/dist/assets/index-B_THfiL5.js +0 -167
@@ -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
  }
@@ -229,6 +239,16 @@ function liveHostAllowedFor(ingress) {
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';
@@ -273,6 +293,13 @@ function allowedResource(resource, method = 'GET') {
273
293
  // Notify: solo POST. `/events`, `/asks` e `/push/*` restano NON federati —
274
294
  // SSE non puo' autenticarsi col Bearer e un ask ha un canale di ritorno che
275
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';
276
303
  if (resource === '/notify') return method === 'POST';
277
304
  // Audio: only capability read, speak and stop are exposed through Hydra.
278
305
  // audio.consent is a LOCAL mutation and MUST stay unreachable federated.
@@ -293,7 +320,27 @@ function readonlyBlocksFederated(resource, method) {
293
320
  return resource !== '/audio/speak/status' && resource !== '/audio/stop';
294
321
  }
295
322
 
323
+ const EVENT_FEED_CURSOR_RE = /^[0-9]{1,12}:[0-9]{1,12}$/;
324
+
296
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
+ }
297
344
  if (!resource.startsWith('/diagnostics/')) return true;
298
345
  const index = String(rawUrl || '').indexOf('?');
299
346
  if (index < 0) return true;
@@ -428,6 +475,20 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
428
475
  if (parsed && isLiveHostResource(parsed.resource) && !liveHostAllowedFor(ingress)) {
429
476
  return res.status(403).json({ error: 'cella ospite live non concessa da questo nodo', reason: 'live-host-not-granted' });
430
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
+ }
431
492
  if (!parsed || !allowedResource(parsed.resource, req.method)
432
493
  || !allowedQuery(parsed.resource, req.method, req.url)) return res.status(404).json({ error: 'not found' });
433
494
  if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
@@ -797,7 +858,7 @@ async function reconcilePeerShare(opts = {}) {
797
858
  throw lastError || new Error('riconciliazione Share fallita');
798
859
  }
799
860
 
800
- // Runner di riconciliazione Share OFF al boot (design piano §3.2.8). Per-peer,
861
+ // Share-off reconciliation runner at boot.
801
862
  // no-overlap (un solo runner attivo per nome), al massimo tre round per processo,
802
863
  // backoff nominato 0/1000/5000 ms (iniettabile). Re-read dello stato desiderato
803
864
  // (nodes.json) prima di ogni round: abort se non e' piu' shared:false (es. il peer
@@ -1044,7 +1105,11 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
1044
1105
  r.get('/health', (_req, res) => {
1045
1106
  const st = store.loadStore(nodesPath);
1046
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.
1047
1111
  res.json({ ok: true, instanceId: (st && st.nodeId) || null, version, readonly: !!readonly(),
1112
+ eventFeedV1: true,
1048
1113
  ...(advertisedRoles ? { roles: advertisedRoles } : {}) });
1049
1114
  });
1050
1115
  // Il client puo' verificare SOLO la reverse port che il pairing gli ha
@@ -1321,6 +1386,12 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1321
1386
  // frame arrivano da qui.
1322
1387
  if (!parsed || (parsed.resource !== '/ws' && !isPanelResource(parsed.resource))) return reject(socket, 404);
1323
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
+ }
1324
1395
  const st = store.loadStore(nodesPath);
1325
1396
  if (!st) return reject(socket, 503);
1326
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) => {
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+
3
+ // Classification of the resources reachable through the federation proxy.
4
+ //
5
+ // Every federated resource belongs to exactly one class, and each class demands
6
+ // explicit grants from the node that OWNS the resource. The class is decided on
7
+ // the resource and the method, BEFORE the request is dispatched: a route that is
8
+ // not classified is denied, so a new endpoint cannot become reachable by a
9
+ // limited peer just because nobody classified it. The gate reads grants — never
10
+ // a role string — and an owner-side request (no peer ingress) is unaffected.
11
+
12
+ const CLASSES = Object.freeze({
13
+ // Events of cells and of the node itself.
14
+ 'event-feed': Object.freeze({ grants: ['eventsAccess'] }),
15
+ 'node-event-feed': Object.freeze({ grants: ['eventsAccess', 'nodeEventsAccess'] }),
16
+ // Answering, dismissing or polling a single ask. Seeing the card does not
17
+ // grant the action.
18
+ 'ask-action': Object.freeze({ grants: ['eventsAccess', 'askReplyAccess'] }),
19
+ // Inventories: which cells, which sessions, the status of the fleet. Only the
20
+ // authorized inventory is projected, and a peer with no visible cell sees none
21
+ // of it.
22
+ inventory: Object.freeze({ grants: [], needsVisibility: true }),
23
+ // Reading shared files: list and download, filtered by the owning cell.
24
+ 'files-read': Object.freeze({ grants: ['filesReadAccess'] }),
25
+ // Choosing which cell of this node hosts the Live session.
26
+ 'live-host': Object.freeze({ grants: ['liveHostAccess'] }),
27
+ // Opening a panel: a browser with already-authenticated sessions behind it.
28
+ panel: Object.freeze({ grants: ['panelAccess', 'peerOperatorAccess'], needsVisibility: true }),
29
+ // Everything else that mutates: configuration, filesystem browsing,
30
+ // diagnostics, credentials, fleet topology, decks, terminals, uploads and
31
+ // deletions. Operator-level by definition.
32
+ operator: Object.freeze({ grants: ['peerOperatorAccess'] }),
33
+ });
34
+
35
+ const CLASS_NAMES = Object.freeze(Object.keys(CLASSES));
36
+
37
+ const CELL_ID = '[A-Za-z0-9._-]{1,32}';
38
+ const NODE_ID = '[a-f0-9]{32}';
39
+
40
+ // Resource -> class. Keep this table aligned with the federation allowlist: the
41
+ // coverage test reads both and fails when one has a resource the other ignores.
42
+ function classifyResource(resource, method = 'GET') {
43
+ if (typeof resource !== 'string' || resource === '') return null;
44
+ const m = String(method || 'GET').toUpperCase();
45
+
46
+ if (resource === '/event-feed' || resource === '/event-feed/snapshot') {
47
+ return m === 'GET' ? 'event-feed' : null;
48
+ }
49
+ if (new RegExp(`^/event-feed/${CELL_ID}$`).test(resource)) return m === 'GET' ? 'event-feed' : null;
50
+ if (new RegExp(`^/event-feed/node/${NODE_ID}$`).test(resource)) return m === 'GET' ? 'node-event-feed' : null;
51
+ if (new RegExp(`^/asks/${CELL_ID}/(answer|dismiss|status)$`).test(resource)) {
52
+ return m === 'POST' || m === 'GET' ? 'ask-action' : null;
53
+ }
54
+ // Federated ask surface: the ask id is the 8-hex local id, the
55
+ // attempt id a UUID. The routes live UNDER /event-feed because they are the
56
+ // feed's own write-back path; the class is the same ask-action.
57
+ if (/^\/event-feed\/asks\/[a-f0-9]{8}\/answer$/.test(resource)) return m === 'POST' ? 'ask-action' : null;
58
+ if (/^\/event-feed\/asks\/[a-f0-9]{8}$/.test(resource)) return m === 'DELETE' ? 'ask-action' : null;
59
+ if (/^\/event-feed\/asks\/[a-f0-9]{8}\/requests\/[0-9a-f-]{16,64}$/.test(resource)) return m === 'GET' ? 'ask-action' : null;
60
+
61
+ if (resource === '/cells') return m === 'GET' ? 'inventory' : null;
62
+ if (resource === '/sessions') return m === 'GET' ? 'inventory' : (m === 'POST' ? 'operator' : null);
63
+ if (/^\/sessions\/[\w.@%:+-]{1,128}$/.test(resource)) return m === 'DELETE' ? 'operator' : null;
64
+ if (/^\/sessions\/[\w.@%:+-]{1,128}\/visibility$/.test(resource)) return m === 'PATCH' ? 'operator' : null;
65
+ if (resource === '/vl-nodes') return m === 'GET' ? 'inventory' : null;
66
+ if (new RegExp(`^/vl-nodes/${NODE_ID}/events$`).test(resource)) return m === 'GET' ? 'inventory' : null;
67
+
68
+ if (resource === '/files') return m === 'GET' ? 'files-read' : (m === 'DELETE' ? 'operator' : null);
69
+ if (resource === '/files/download') return m === 'GET' ? 'files-read' : null;
70
+ if (resource === '/files/upload') return m === 'POST' ? 'operator' : null;
71
+
72
+ if (resource === '/live-host') return m === 'GET' ? 'live-host' : null;
73
+ if (resource === '/live-host/designate' || resource === '/live-host/clear' || resource === '/live-host/bridge') {
74
+ return m === 'POST' ? 'live-host' : null;
75
+ }
76
+ if (new RegExp(`^/panel/${CELL_ID}(?:/.*)?$`).test(resource)) {
77
+ return m === 'GET' || m === 'POST' ? 'panel' : null;
78
+ }
79
+
80
+ // Operator-level mutations and diagnostics, one by one.
81
+ if (resource === '/config') return m === 'GET' ? 'operator' : null;
82
+ if (resource === '/fs/dirs') return m === 'GET' ? 'operator' : null;
83
+ if (resource === '/cells/send') return m === 'POST' ? 'operator' : null;
84
+ if (resource === '/vl-nodes/invite') return m === 'POST' ? 'operator' : null;
85
+ if (new RegExp(`^/vl-nodes/${NODE_ID}/commands$`).test(resource)) return m === 'POST' ? 'operator' : null;
86
+ if (new RegExp(`^/vl-nodes/${NODE_ID}$`).test(resource)) return m === 'DELETE' ? 'operator' : null;
87
+ // Fleet: the allowlist splits the family in two — the read alternatives are
88
+ // GET, the rest are POST. A peer that is not an operator is refused by the
89
+ // class gate; an operator no longer gets "resource-not-classified".
90
+ if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) {
91
+ return m === 'GET' ? 'operator' : null;
92
+ }
93
+ if (/^\/fleet\/(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)) {
94
+ return m === 'POST' ? 'operator' : null;
95
+ }
96
+ if (resource === '/decks') return m === 'GET' || m === 'POST' ? 'operator' : null;
97
+ if (/^\/decks\/[a-z0-9-]{1,32}$/.test(resource)) {
98
+ return ['PUT', 'PATCH', 'DELETE'].includes(m) ? 'operator' : null;
99
+ }
100
+
101
+ // Topology, diagnostics, terminals and the notify/audio surface of this node:
102
+ // all of them act ON the owner's node, so they belong to the operator class.
103
+ if (resource === '/topology') return m === 'GET' ? 'operator' : null;
104
+ // Diagnostics: the allowlist accepts GET on status and logs, DELETE on logs
105
+ // and PATCH on verbose — the class follows those methods, not a wider one.
106
+ if (resource === '/diagnostics/status') return m === 'GET' ? 'operator' : null;
107
+ if (resource === '/diagnostics/logs') return m === 'GET' || m === 'DELETE' ? 'operator' : null;
108
+ if (resource === '/diagnostics/verbose') return m === 'PATCH' ? 'operator' : null;
109
+ if (resource === '/ws') return m === 'GET' ? 'operator' : null;
110
+ if (resource === '/notify') return m === 'POST' ? 'operator' : null;
111
+ if (resource === '/audio/capability') return m === 'GET' ? 'operator' : null;
112
+ if (resource === '/audio/speak') return m === 'POST' ? 'operator' : null;
113
+ if (resource === '/audio/speak/status') return m === 'POST' ? 'operator' : null;
114
+ if (resource === '/audio/stop') return m === 'POST' ? 'operator' : null;
115
+
116
+ // Not classified: denied until it is classified on purpose.
117
+ return null;
118
+ }
119
+
120
+ // True when the grants of the peer satisfy the class. `ingress` null is the
121
+ // owner of this node, who is not limited by these grants.
122
+ function allowedByClass(className, ingress) {
123
+ if (!ingress) return true;
124
+ const cls = CLASSES[className];
125
+ if (!cls) return false; // unknown class: never a reason to allow
126
+ for (const grant of cls.grants) {
127
+ if (ingress[grant] !== true) return false;
128
+ }
129
+ if (cls.needsVisibility === true && ingress.cellVisibility === 'none') return false;
130
+ return true;
131
+ }
132
+
133
+ // The grants a class demands, for diagnostics and for the coverage test.
134
+ function grantsForClass(className) {
135
+ const cls = CLASSES[className];
136
+ return cls ? [...cls.grants] : null;
137
+ }
138
+
139
+ module.exports = {
140
+ CLASSES,
141
+ CLASS_NAMES,
142
+ allowedByClass,
143
+ classifyResource,
144
+ grantsForClass,
145
+ };