@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,8 +1,8 @@
1
1
  'use strict';
2
- // lib/nodes/store.js — nodes.json secret store (design §4, §4b(4)).
2
+ // lib/nodes/store.js — nodes.json secret store.
3
3
  //
4
4
  // nodes.json e' un SECRET STORE, non config qualunque: contiene i token dei nodi
5
- // remoti (iniettati dal proxy in B1). Percio' riusa lo stesso hardening di
5
+ // Remoti (iniettati dal proxy in). Percio' riusa lo stesso hardening di
6
6
  // lib/fleet/definitions.js e del token file:
7
7
  // - permessi 0600, scrittura atomica (tmp stessa dir + rename), rifiuto symlink
8
8
  // - schema STRICT validato al load: garbage -> errore esplicito, MAI guess
@@ -19,6 +19,7 @@ const path = require('node:path');
19
19
  const crypto = require('node:crypto');
20
20
  const reversePool = require('./reverse-pool.js');
21
21
  const identity = require('./identity.js');
22
+ const accessPresets = require('./access-presets.js');
22
23
 
23
24
  const SCHEMA_VERSION = 3;
24
25
  const PREVIOUS_SCHEMA_VERSION = 2;
@@ -28,14 +29,14 @@ const MAX_TOKEN_LEN = 4096;
28
29
  const MAX_KEYPATH_LEN = 4096;
29
30
  const MAX_SSH_LEN = 320; // user(<=32) + '@' + host(<=255)
30
31
 
31
- // name: chiave strict usata anche come segmento path/route in B1 -> niente '.',
32
- // '/', maiuscole. Allineato al contratto §4b(2) (^[a-z0-9-]{1,32}$).
32
+ // name: strict key also used as a path/route segment -> no '.', no '/',
33
+ // no uppercase. Aligned with the strict name contract (^[a-z0-9-]{1,32}$).
33
34
  const NODE_NAME_RE = /^[a-z0-9-]{1,32}$/;
34
35
  // nodeId: id stabile per-installazione, hex casuale (crypto.randomBytes(16)).
35
36
  const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
36
37
  // user@host: entrambe le parti argv-safe (no spazi, no leading '-' -> mai
37
38
  // interpretabile come opzione ssh). Host: hostname/FQDN/IPv4 (niente ':' -> IPv6
38
- // va usato via Host alias in ~/.ssh/config, fuori scope B0).
39
+ // Va usato via Host alias in ~/.ssh/config, fuori scope).
39
40
  const SSH_USER_RE = /^[A-Za-z0-9._-]{1,32}$/;
40
41
  const SSH_HOST_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/;
41
42
 
@@ -112,6 +113,10 @@ const NODE_KEYS = new Set([
112
113
  // Quali CELLE di questo nodo il peer puo' vedere. Distinto da `visibility`,
113
114
  // che governa il TRANSITO (attraverso chi passa il traffico) e non l'accesso.
114
115
  'cellVisibility', 'cells',
116
+ // eventsReceive: the CLIENT's own local choice to import an owner's feed.
117
+ // It is NOT a grant the owner sets, it never travels in a preset, and it
118
+ // only ever decides what THIS node pulls — never what a peer may read here.
119
+ 'eventsReceive',
115
120
  // Se questo peer puo' aprire i PANNELLI delle celle di questo nodo. Distinto
116
121
  // da `cellVisibility`, che dice quali celle il peer VEDE: dietro un pannello
117
122
  // c'e' un browser con sessioni gia' autenticate, e un accesso di quel tipo non
@@ -132,13 +137,25 @@ const NODE_KEYS = new Set([
132
137
  // grant potra' pretendere la prima. `keyConflict` conserva una chiave DIVERSA
133
138
  // vista dopo il legame, senza sostituire quella legata.
134
139
  'publicKey', 'keySource', 'keyBoundAt', 'keyConflict',
135
- // P0 stessa-origin, meta' remota: la porta pannello del peer inoltrata sul
140
+ // Stessa-origin, meta' remota: la porta pannello del peer inoltrata sul
136
141
  // NOSTRO loopback (panelLocalPort) e la sua origine remota (panelRemotePort).
137
142
  // COPPIA obbligatoria: un -L ha due estremita', mezza coppia e' un forward
138
143
  // impossibile, non "meno pannello". Campi assenti = peer accoppiato prima di
139
144
  // questa negoziazione: via storica, nessun comportamento nuovo. Le porte non
140
145
  // sono segreti (come localPort/remotePort, che stanno qui accanto).
141
146
  'panelLocalPort', 'panelRemotePort',
147
+ // Federated access grants: what this peer may do with the resources this node
148
+ // OWNS (events, asks, files) beyond cell visibility. Same shape and same rule
149
+ // as panelAccess/liveHostAccess: default denied, one node at a time. A peer
150
+ // does not gain them by being paired, and the receiving client's own choice
151
+ // to receive events is NOT one of them (it is local to that client).
152
+ 'eventsAccess', 'nodeEventsAccess', 'askReplyAccess', 'filesReadAccess', 'peerOperatorAccess',
153
+ // Whether the grant vector was set EXPLICITLY. Grants alone cannot tell a
154
+ // legacy record (all defaults denied) from a deliberate vector that happens
155
+ // to deny everything: without this marker a legacy peer would read as a
156
+ // coherent "custom" preset instead of "not configured". It is provenance,
157
+ // not a permission — it grants nothing and handlers never read it as a role.
158
+ 'accessConfigured',
142
159
  ]);
143
160
 
144
161
  // Nome di cella: stessa forma usata da fleet/definitions.js, cells/routes.js e
@@ -248,6 +265,7 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
248
265
  const roles = parseRoles(n.roles);
249
266
  if (!roles) return null;
250
267
  if (n.rolesKnown !== undefined && typeof n.rolesKnown !== 'boolean') return null;
268
+ if (n.eventsReceive !== undefined && typeof n.eventsReceive !== 'boolean') return null;
251
269
  // Before 0.8.9 `roles` was filled with a local default and did not describe
252
270
  // the remote peer. Only the explicit marker makes role-based health safe.
253
271
  const rolesKnown = n.rolesKnown === true;
@@ -285,6 +303,16 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
285
303
  // Stessa regola, stesso motivo: un record senza il campo non acquisisce la
286
304
  // designazione live per anzianita' del pairing.
287
305
  liveHostAccess: n.liveHostAccess === undefined ? false : n.liveHostAccess,
306
+ // Le cinque concessioni federate seguono la stessa regola: assenti valgono
307
+ // NEGATE. Nessuna migrazione silenziosa al load: il vettore va impostato da
308
+ // un atto esplicito (preset o edit granulare), altrimenti resta negato.
309
+ eventsReceive: n.eventsReceive === undefined ? false : n.eventsReceive === true,
310
+ eventsAccess: n.eventsAccess === undefined ? false : n.eventsAccess,
311
+ nodeEventsAccess: n.nodeEventsAccess === undefined ? false : n.nodeEventsAccess,
312
+ askReplyAccess: n.askReplyAccess === undefined ? false : n.askReplyAccess,
313
+ filesReadAccess: n.filesReadAccess === undefined ? false : n.filesReadAccess,
314
+ peerOperatorAccess: n.peerOperatorAccess === undefined ? false : n.peerOperatorAccess,
315
+ accessConfigured: n.accessConfigured === true,
288
316
  visibility: n.visibility || 'network',
289
317
  };
290
318
  if (!['auto', 'ssh', 'autossh', 'inbound'].includes(out.transport)) return null;
@@ -294,6 +322,12 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
294
322
  if (typeof out.shared !== 'boolean') return null;
295
323
  if (typeof out.panelAccess !== 'boolean') return null;
296
324
  if (typeof out.liveHostAccess !== 'boolean') return null;
325
+ if (typeof out.eventsAccess !== 'boolean') return null;
326
+ if (typeof out.nodeEventsAccess !== 'boolean') return null;
327
+ if (typeof out.askReplyAccess !== 'boolean') return null;
328
+ if (typeof out.filesReadAccess !== 'boolean') return null;
329
+ if (typeof out.peerOperatorAccess !== 'boolean') return null;
330
+ if (typeof out.accessConfigured !== 'boolean') return null;
297
331
  if (!['network', 'relay-only', 'selected'].includes(out.visibility)) return null;
298
332
  if (identityFile) out.identityFile = identityFile;
299
333
  // Keep the old public field while reading v1 so old callers/tests and a
@@ -303,7 +337,7 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
303
337
  if (!isPort(n.reversePort)) return null;
304
338
  out.reversePort = n.reversePort;
305
339
  }
306
- // Coppia pannello (P0 stessa-origin remota): o entrambe le porte o nessuna.
340
+ // Coppia pannello (stessa-origin remota): o entrambe le porte o nessuna.
307
341
  // Una sola meta' non viene indovinata — sarebbe un -L monco. E la porta
308
342
  // locale del pannello non puo' stare su quella del control plane: ssh
309
343
  // cadrebbe con ExitOnForwardFailure portandosi via il tunnel intero.
@@ -427,7 +461,7 @@ function parseStore(raw) {
427
461
 
428
462
  if (![SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION].includes(d.schemaVersion)) return null;
429
463
  const rootKeys = d.schemaVersion === SCHEMA_VERSION
430
- ? new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous', 'reversePoolAnchor', 'reversePoolLedgerInitialized'])
464
+ ? new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous', 'reversePoolAnchor', 'reversePoolLedgerInitialized', 'accessRevision'])
431
465
  : new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous']);
432
466
  if (Object.keys(d).some((key) => !rootKeys.has(key))) return null;
433
467
  if (typeof d.nodeId !== 'string' || !NODE_ID_RE.test(d.nodeId)) return null;
@@ -461,6 +495,16 @@ function parseStore(raw) {
461
495
 
462
496
  const out = { schemaVersion: d.schemaVersion, nodeId: d.nodeId, nodes };
463
497
 
498
+ // Revision of the peer access policy. Absent means 0: a store written
499
+ // before this field existed is at revision 0 and the first explicit write
500
+ // advances it to 1. It is kept out of the parsed object when absent so a
501
+ // plain load never rewrites the file with a field nobody set.
502
+ if (d.accessRevision !== undefined) {
503
+ if (d.schemaVersion !== SCHEMA_VERSION) return null;
504
+ if (!Number.isInteger(d.accessRevision) || d.accessRevision < 0) return null;
505
+ out.accessRevision = d.accessRevision;
506
+ }
507
+
464
508
  if (d.rendezvous !== undefined && d.rendezvous !== null) {
465
509
  const rdv = parseRendezvous(d.rendezvous);
466
510
  if (!rdv) return null;
@@ -647,6 +691,101 @@ function updateNode(store, name, patch) {
647
691
  return { ...store, schemaVersion: mutationSchemaVersion(store), nodes };
648
692
  }
649
693
 
694
+ // --- Concessioni federate: preset, CAS, inventario -------------------------
695
+
696
+ function accessRevisionOf(store) {
697
+ return store && Number.isInteger(store.accessRevision) && store.accessRevision >= 0
698
+ ? store.accessRevision : 0;
699
+ }
700
+
701
+ // Pure: returns a new store with the peer's grant vector replaced by a
702
+ // validated one. The vector is validated as a whole, so an edit that keeps
703
+ // live hosting while dropping another admin grant is refused here too.
704
+ function setPeerAccessGrants(store, name, grants) {
705
+ const idx = store.nodes.findIndex((n) => n.name === name);
706
+ if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
707
+ const check = accessPresets.validateGrants(grants);
708
+ if (!check.ok) throw new Error(`concessioni non valide: ${check.errors.join('; ')}`);
709
+ const next = { ...store.nodes[idx] };
710
+ next.cellVisibility = grants.cellVisibility;
711
+ if (grants.cellVisibility === 'selected') next.cells = [...(grants.cells || [])];
712
+ else delete next.cells;
713
+ for (const key of accessPresets.BOOLEAN_GRANTS) next[key] = grants[key] === true;
714
+ next.accessConfigured = true;
715
+ const nodes = store.nodes.slice();
716
+ nodes[idx] = next;
717
+ const out = { ...store, schemaVersion: SCHEMA_VERSION, nodes };
718
+ out.accessRevision = accessRevisionOf(store) + 1;
719
+ return out;
720
+ }
721
+
722
+ // Pure: applies a named preset to one peer.
723
+ function setPeerAccessPreset(store, name, presetName) {
724
+ const idx = store.nodes.findIndex((n) => n.name === name);
725
+ if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
726
+ const applied = accessPresets.applyPreset(store.nodes[idx], presetName);
727
+ const vector = { cellVisibility: applied.cellVisibility };
728
+ if (applied.cells !== undefined) vector.cells = applied.cells;
729
+ for (const key of accessPresets.BOOLEAN_GRANTS) vector[key] = applied[key];
730
+ return setPeerAccessGrants(store, name, vector);
731
+ }
732
+
733
+ // Read-only inventory: what every peer is allowed to do on THIS node, with the
734
+ // label derived from the grants. Used by `nodes show`, by the settings view and
735
+ // by the explicit migration: a legacy peer shows up as not configured, never as
736
+ // a preset inferred from defaults.
737
+ function accessInventory(store) {
738
+ const peers = (store && store.nodes ? store.nodes : []).map((peer) => {
739
+ const view = accessPresets.grantsOf(peer);
740
+ return {
741
+ name: peer.name,
742
+ nodeId: peer.nodeId || null,
743
+ direction: peer.direction,
744
+ configured: view.configured,
745
+ label: view.label,
746
+ grants: view.grants,
747
+ };
748
+ });
749
+ return { revision: accessRevisionOf(store), peers };
750
+ }
751
+
752
+ // Compare-and-set write for the peer access policy: one validated read-modify-
753
+ // write over the store file. `expectedRevision` must be an integer that matches
754
+ // the current revision, otherwise the write is refused and the caller rereads
755
+ // and retries — never a permissive default. The whole path is synchronous, so
756
+ // two callers in this process cannot interleave between the read and the write.
757
+ function setPeerAccessGrantsCas({ filePath, name, grants, expectedRevision, extraPatch }) {
758
+ const current = loadStoreStrict(filePath);
759
+ if (!Number.isInteger(expectedRevision) || accessRevisionOf(current) !== expectedRevision) {
760
+ return { ok: false, conflict: true, revision: accessRevisionOf(current) };
761
+ }
762
+ let next = current;
763
+ if (extraPatch && Object.keys(extraPatch).length > 0) next = updateNode(next, name, extraPatch);
764
+ next = setPeerAccessGrants(next, name, grants);
765
+ const written = atomicWriteStore(filePath, next);
766
+ return {
767
+ ok: true,
768
+ name,
769
+ revision: accessRevisionOf(written),
770
+ grants: accessPresets.grantsOf(written.nodes.find((n) => n.name === name)).grants,
771
+ label: accessPresets.grantsOf(written.nodes.find((n) => n.name === name)).label,
772
+ };
773
+ }
774
+
775
+ // Same, by preset name.
776
+ function setPeerAccessPresetCas({ filePath, name, presetName, expectedRevision, extraPatch }) {
777
+ const current = loadStoreStrict(filePath);
778
+ if (!Number.isInteger(expectedRevision) || accessRevisionOf(current) !== expectedRevision) {
779
+ return { ok: false, conflict: true, revision: accessRevisionOf(current) };
780
+ }
781
+ let next = current;
782
+ if (extraPatch && Object.keys(extraPatch).length > 0) next = updateNode(next, name, extraPatch);
783
+ next = setPeerAccessPreset(next, name, presetName);
784
+ const written = atomicWriteStore(filePath, next);
785
+ const view = accessPresets.grantsOf(written.nodes.find((n) => n.name === name));
786
+ return { ok: true, name, revision: accessRevisionOf(written), grants: view.grants, label: view.label };
787
+ }
788
+
650
789
  // The upgrade is explicit: normal legacy pairing remains schema v2 and keeps
651
790
  // working with a previous NexusCrew binary. Enabling rotation is the only
652
791
  // path that writes the v3 anchor and therefore requires both peers to support
@@ -691,6 +830,7 @@ function redactNode(n) {
691
830
  rolesKnown: n.rolesKnown === true,
692
831
  transport: n.transport || 'ssh',
693
832
  autostart: !!n.autostart,
833
+ eventsReceive: n.eventsReceive === true,
694
834
  shared: n.shared === true,
695
835
  panelAccess: n.panelAccess === true,
696
836
  liveHostAccess: n.liveHostAccess === true,
@@ -758,7 +898,7 @@ function hasPairedPeers(store) {
758
898
  }
759
899
 
760
900
  // --- Migrazione esplicita da config.json (guarded, no-op se assente) ---------
761
- // Se config.json contiene un array `nodes` legacy (vecchio formato pre-B0),
901
+ // Se config.json contiene un array `nodes` legacy (vecchio formato pre-),
762
902
  // lo importa in nodes.json. Guarded: no-op se config.json non ha `nodes`, o se
763
903
  // nodes.json ha gia' dei nodi (mai overwrite). Strict: un nodo legacy malformato
764
904
  // -> throw esplicito (non importa silenziosamente spazzatura).
@@ -885,6 +1025,9 @@ module.exports = {
885
1025
  defaultNodesPath, loadStore, loadStoreStrict, initStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
886
1026
  // mutazioni
887
1027
  getNode, addNode, removeNode, setNodeToken, updateNode, upgradeToReversePoolSchema, setNodeReversePool,
1028
+ // concessioni federate (preset, CAS, inventario)
1029
+ accessRevisionOf, setPeerAccessGrants, setPeerAccessPreset, accessInventory,
1030
+ setPeerAccessGrantsCas, setPeerAccessPresetCas,
888
1031
  // redazione
889
1032
  redactNode, redactStore, hasPairedPeers,
890
1033
  // migrazione
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- // lib/nodes/tunnel.js — SSH tunnel manager (design §4b(1), §7).
2
+ // lib/nodes/tunnel.js — SSH tunnel manager.
3
3
  //
4
4
  // I processi ssh sono gestiti da un solo supervisor detached, avviato dal
5
5
  // lifecycle del servizio o dalle azioni PWA autenticate. argv puro (spawn con
@@ -10,7 +10,7 @@
10
10
  // unit-testabili senza lanciare ssh.
11
11
  // - lifecycle per-tunnel via pidfile (start/stop/restart/state): il processo
12
12
  // ssh e' detached, la sua liveness/porta stabile e' interrogabile da
13
- // `status --json` e (in B1) dal proxy. Porta STABILE da nodes.json; una
13
+ // `status --json` e (in) dal proxy. Porta STABILE da nodes.json; una
14
14
  // collisione fa fallire ExitOnForwardFailure -> si segnala, NON si riciclano
15
15
  // porte a caso.
16
16
  const fs = require('node:fs');
@@ -21,7 +21,7 @@ const { spawn, spawnSync } = require('node:child_process');
21
21
  const pidf = require('../cli/pidfile.js');
22
22
  const store = require('./store.js');
23
23
 
24
- // Opzioni SSH comuni ai due template (ordine e valori ESATTI dal design §4b(1)).
24
+ // SSH options shared by both templates (exact order and values).
25
25
  const SSH_BASE_OPTS = Object.freeze([
26
26
  '-N',
27
27
  '-o', 'ExitOnForwardFailure=yes',
@@ -44,7 +44,7 @@ function assertForwardSpec(node) {
44
44
  if (node.keyPath !== undefined && !store.isAbsPath(node.keyPath)) throw new Error('tunnel: keyPath non valido');
45
45
  if (node.reversePort !== undefined && !store.isPort(node.reversePort)) throw new Error('tunnel: reversePort non valida');
46
46
  if (node.localAppPort !== undefined && !store.isPort(node.localAppPort)) throw new Error('tunnel: localAppPort non valida');
47
- // Coppia pannello (P0 stessa-origin remota): o entrambe o nessuna, e mai
47
+ // Coppia pannello (stessa-origin remota): o entrambe o nessuna, e mai
48
48
  // sulla porta locale del control plane — il bind conflitto abbatterebbe il
49
49
  // tunnel intero con ExitOnForwardFailure.
50
50
  if (node.panelLocalPort !== undefined || node.panelRemotePort !== undefined) {
@@ -61,7 +61,7 @@ function assertForwardSpec(node) {
61
61
  if (!store.parseSshTarget(node.ssh)) throw new Error('tunnel: target ssh non valido');
62
62
  }
63
63
 
64
- // Forward client->nodo (ruolo client). §4b(1):
64
+ // Forward client->node (client role):
65
65
  // ssh -N -o ExitOnForwardFailure=yes -o ServerAliveInterval=30
66
66
  // -o ServerAliveCountMax=3 -o BatchMode=yes -i <key>
67
67
  // -L 127.0.0.1:<locale>:127.0.0.1:<remota> user@host
@@ -108,7 +108,7 @@ function buildReverseArgs(node, { remotePort, targetPort } = {}) {
108
108
  ]);
109
109
  }
110
110
 
111
- // Backoff esponenziale + jitter (design §7). Deterministico con rng iniettato.
111
+ // Exponential backoff + jitter. Deterministic with an injected rng.
112
112
  // delay = clamp(base * factor^attempt, 0..cap) * (1 +- jitter*(2*rand-1))
113
113
  // rng()=0.5 -> jitter nullo (ritorna il valore base clamperato); test deterministici.
114
114
  function backoffDelay(attempt, opts = {}) {
@@ -735,7 +735,7 @@ function authorizedKeysLine({ remotePort, panelRemotePort, pub } = {}) {
735
735
  // registrato in revisione: `null | stringa` comprimeva stati DIVERSI, e il
736
736
  // livello basso componeva frasi su fatti che non conosce — meta' della 0.9.5
737
737
  // e' nata li'). Gli esiti sono DATI, senza testo per l'utente: le frasi si
738
- // compongono al confine UI/log, mai qui. Si aggancia a P4: una chiave cifrata
738
+ // Compongono al confine UI/log, mai qui. Si aggancia a: una chiave cifrata
739
739
  // dava `null`, indistinguibile da «file illeggibile», e il pairing ometteva
740
740
  // la riga in silenzio.
741
741
  //
@@ -0,0 +1,184 @@
1
+ 'use strict';
2
+ // lib/notify/ask-answer-service.js — ONE answer cycle for BOTH the local route
3
+ // and the federated one. There is no second path: claim (synchronous) ->
4
+ // paste to the session FROM THE STORE (never from a body) -> commit or a
5
+ // declared failure -> a qualified closure event (askId, revision, cell).
6
+ //
7
+ // Federated attempts additionally carry a durable receipt (written BEFORE the
8
+ // paste) and an idempotency contract: the same requestId with the same text
9
+ // after a timeout returns the stored state WITHOUT pasting again; the same id
10
+ // with a different text is a 409. A locked ask (delivery-unknown from a
11
+ // previous crash) refuses new pastes on EVERY path until the operator
12
+ // reconciles explicitly: no silent retries, no TTL.
13
+
14
+ const crypto = require('node:crypto');
15
+
16
+ const REQUEST_ID_RE = /^[0-9a-f-]{16,64}$/i;
17
+
18
+ function createAskAnswerService({ asks, paste, receipts, onClosure, labelPrefix = 'reply' } = {}) {
19
+ if (!asks || !paste) throw new Error('createAskAnswerService: asks e paste richiesti');
20
+ const emitClosure = typeof onClosure === 'function' ? onClosure : () => {};
21
+
22
+ function pasteText(askId, text, label) {
23
+ return `[${label} reply · ask#${askId}] ${text}`;
24
+ }
25
+
26
+ // The shared core. Returns {ok:true} or {ok:false, code, error, reason}.
27
+ async function runAnswer({ askId, text, request = null }) {
28
+ // A locked ask refuses new pastes on every path until reconciliation.
29
+ if (receipts && receipts.isBlocked(askId)) {
30
+ return { ok: false, code: 409, error: 'ask con esito incerto: richiede riconciliazione dell\'operatore', reason: 'delivery-unknown-block' };
31
+ }
32
+ // The receipt is authoritative AT THE POINT OF PASTE: an ask whose durable
33
+ // answer state is already terminal cannot produce a second paste, whichever
34
+ // path got here — the ask store alone is not enough, because a reconciliation
35
+ // can land on the receipt without closing the ask.
36
+ const answerState = receipts && typeof receipts.answerStateFor === 'function'
37
+ ? receipts.answerStateFor(askId) : null;
38
+ if (answerState === 'committed' || answerState === 'delivered') {
39
+ return { ok: false, code: 409, error: 'ask gia\' consegnato: nessun nuovo paste', reason: 'already-delivered' };
40
+ }
41
+ const claim = asks.claim(askId);
42
+ if (!claim.ok) {
43
+ // Refused BEFORE any paste: the attempt is closed here. Left `pending`, it
44
+ // would come back as delivery-unknown after a restart and block the ask
45
+ // for a paste that never happened.
46
+ if (request && receipts) receipts.finalize(request.peerId, askId, request.requestId, 'failed', `refused:${claim.reason}`);
47
+ if (claim.reason === 'unknown') return { ok: false, code: 404, error: 'ask inesistente', reason: 'unknown' };
48
+ if (claim.reason === 'dismissed') return { ok: false, code: 409, error: 'ask gia\' scartato (dismissed)', reason: 'dismissed' };
49
+ if (claim.reason === 'answering') return { ok: false, code: 409, error: 'risposta gia\' in corso da un\'altra richiesta', reason: 'answering' };
50
+ return { ok: false, code: 409, error: 'ask gia\' risposto', reason: 'answered' };
51
+ }
52
+ let pasted = false;
53
+ try {
54
+ pasted = await paste(claim.ask.session, pasteText(askId, text, labelPrefix));
55
+ } catch (_) { pasted = false; }
56
+ if (!pasted) {
57
+ asks.release(askId); // rollback: the ask stays open and contestable
58
+ if (request && receipts) receipts.finalize(request.peerId, askId, request.requestId, 'failed', 'paste-failed');
59
+ return { ok: false, code: 502, error: `paste fallito: sessione "${claim.ask.session}" non raggiungibile`, reason: 'paste-failed' };
60
+ }
61
+ const committed = asks.commit(askId, text);
62
+ if (request && receipts) receipts.finalize(request.peerId, askId, request.requestId, 'committed', null);
63
+ const finalAsk = asks.get(askId);
64
+ emitClosure('ask-answered', { askId, revision: (finalAsk && finalAsk.revision) || 1, cellSession: finalAsk && finalAsk.session });
65
+ return { ok: true, committed };
66
+ }
67
+
68
+ // LOCAL answer: validation and binding already happened in the route.
69
+ async function answerLocal({ askId, text }) {
70
+ return runAnswer({ askId, text });
71
+ }
72
+
73
+ // FEDERATED answer: the receipt lifecycle wraps the same core.
74
+ async function answerFederated({ askId, text, peerId, requestId }) {
75
+ if (!requestId || !REQUEST_ID_RE.test(String(requestId))) {
76
+ return { ok: false, code: 400, error: 'requestId non valido', reason: 'bad-request-id' };
77
+ }
78
+ if (!receipts) return { ok: false, code: 503, error: 'receipt store non disponibile', reason: 'no-receipts' };
79
+ const existing = receipts.get(peerId, askId, requestId);
80
+ const digest = receipts.digestOf(text);
81
+ if (existing) {
82
+ // Same id, same text: the stored state, never a second paste.
83
+ if (existing.digest === digest) {
84
+ return { ok: true, replay: true, state: existing.state };
85
+ }
86
+ // Same id, different text: never answer twice with diverging payloads.
87
+ return { ok: false, code: 409, error: 'stesso requestId con testo diverso', reason: 'request-conflict' };
88
+ }
89
+ // Receipt BEFORE the paste: a failed receipt write means no paste at all.
90
+ const receipt = receipts.attempt({ peerId, askId, requestId, text });
91
+ if (!receipt.ok) {
92
+ if (receipt.reason === 'blocked') {
93
+ return { ok: false, code: 409, error: 'ask con esito incerto: richiede riconciliazione dell\'operatore', reason: 'delivery-unknown-block' };
94
+ }
95
+ if (receipt.reason === 'digest-conflict') {
96
+ return { ok: false, code: 409, error: 'stesso requestId con testo diverso', reason: 'request-conflict' };
97
+ }
98
+ return { ok: false, code: 429, error: 'receipt store al cap', reason: 'receipt-cap' };
99
+ }
100
+ const out = await runAnswer({ askId, text, request: { peerId, requestId } });
101
+ if (out.ok) return { ok: true, state: 'committed' };
102
+ // Every refusal is answered as it is: a rejection BEFORE the paste (claim
103
+ // lost, ask closed, locked) finalizes the attempt as failed, and a transport
104
+ // failure does the same — the peer re-checks the status instead of blindly
105
+ // retrying.
106
+ return out;
107
+ }
108
+
109
+ // Dismiss: idempotent, refused while an answer is in flight, and refused on an
110
+ // ask whose delivery is still unknown — dismissing the card would hide an
111
+ // outcome the operator still has to reconcile.
112
+ function dismiss(askId) {
113
+ if (receipts && receipts.isBlocked(askId)) {
114
+ return { ok: false, code: 409, error: 'ask con esito incerto: richiede riconciliazione dell\'operatore', reason: 'delivery-unknown-block' };
115
+ }
116
+ const out = asks.dismiss(askId);
117
+ if (!out.ok) return out;
118
+ emitClosure('ask-dismissed', { askId, revision: (out.ask && out.ask.revision) || 1, cellSession: out.ask && out.ask.session });
119
+ return out;
120
+ }
121
+
122
+ // Operator reconciliation of a delivery-unknown attempt. The revision is a
123
+ // MANDATORY compare-and-set: a reconciliation decided on a generation that is
124
+ // already gone must not unlock anything, and the transition itself advances
125
+ // the revision so the same token cannot be replayed.
126
+ function reconcile({ askId, decision, expectedRevision }) {
127
+ if (decision !== 'mark-delivered' && decision !== 'allow-new-attempt') {
128
+ return { ok: false, code: 400, error: 'decision deve essere mark-delivered|allow-new-attempt', reason: 'bad-decision' };
129
+ }
130
+ if (!receipts) return { ok: false, code: 503, error: 'receipt store non disponibile', reason: 'no-receipts' };
131
+ const ask = asks.get(askId);
132
+ if (!ask) return { ok: false, code: 404, error: 'ask inesistente', reason: 'unknown' };
133
+ if (expectedRevision === undefined || expectedRevision === null || expectedRevision === '') {
134
+ return { ok: false, code: 400, error: 'expectedRevision obbligatoria', reason: 'revision-required' };
135
+ }
136
+ if (Number(expectedRevision) !== (ask.revision || 0)) {
137
+ return { ok: false, code: 409, error: 'revision stantia', reason: 'revision-conflict' };
138
+ }
139
+ // A paste that is still in flight is not an outcome to reconcile: its claim
140
+ // is HERS, and deciding here would release a claim mid-paste and let a
141
+ // second paste through. Refused without touching the claim.
142
+ // The window between this check and markReconciled is SYNCHRONOUS (there is
143
+ // no await in between), so no new claim can appear once the refusal is not
144
+ // taken: that is what makes the guard sufficient, not the receipt alone.
145
+ const live = receipts.reconcilableFor(askId);
146
+ if ((typeof asks.isAnswering === 'function' && asks.isAnswering(askId)) || live.live > 0) {
147
+ return {
148
+ ok: false, code: 409, reason: 'answering',
149
+ error: 'paste in corso su questa ask: riconciliazione rifiutata, il claim non si tocca',
150
+ };
151
+ }
152
+ // Nothing unresolved: `ok:true, changed:0` would be a false success.
153
+ if (live.count === 0) {
154
+ return { ok: false, code: 409, reason: 'nothing-to-reconcile', error: 'nessun esito incerto da riconciliare' };
155
+ }
156
+ const expect = { count: live.count, generation: live.generation };
157
+
158
+ // Restrictive write FIRST, permissive one last: the ask-store transition
159
+ // closes the ask (mark-delivered) or just advances the revision
160
+ // (allow-new-attempt), while the receipt keeps blocking the paste until its
161
+ // own durable write lands. A partial failure therefore never leaves a state
162
+ // where a paste is possible: the residue is "ask closed" or "receipt still
163
+ // blocked", never more permissive than the decision.
164
+ const moved = asks.markReconciled(askId, decision);
165
+ if (!moved.ok) {
166
+ return { ok: false, code: 500, error: 'transizione non scritta: stato invariato', reason: 'ask-transition-failed' };
167
+ }
168
+ const out = receipts.reconcile(askId, decision, expect);
169
+ if (!out.ok) {
170
+ return {
171
+ ok: false, code: out.reason === 'nothing-to-reconcile' || out.reason === 'generation-changed' ? 409 : 500,
172
+ reason: out.reason,
173
+ error: decision === 'mark-delivered'
174
+ ? 'ricevuta non riconciliata: ask comunque chiuso (nessun paste possibile)'
175
+ : 'ricevuta non riconciliata: blocco ancora attivo (nessun paste possibile)',
176
+ };
177
+ }
178
+ return { ok: true, changed: out.changed, revision: (moved.ask && moved.ask.revision) || 0 };
179
+ }
180
+
181
+ return { answerLocal, answerFederated, dismiss, reconcile, pasteText };
182
+ }
183
+
184
+ module.exports = { createAskAnswerService, REQUEST_ID_RE };