@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
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+ // lib/notify/event-feed-acl.js — per-peer projection of the event feed.
3
+ //
4
+ // The grants live in the node store and are the ONLY source of truth, read
5
+ // fresh before every batch (a cached grant is a stale grant). Scope rules:
6
+ // - scope 'cell': eventsAccess AND the cell must be visible under
7
+ // cellVisibility. A hidden cell stays hidden through events.
8
+ // - scope 'node': eventsAccess AND nodeEventsAccess. nodeEventsAccess NEVER
9
+ // bypasses cellVisibility: cell lists inside node frames are projected by
10
+ // the same visibility rule, and hidden cells contribute neither names nor
11
+ // counts.
12
+
13
+ const nodesStore = require('../nodes/store.js');
14
+ const accessPresets = require('../nodes/access-presets.js');
15
+
16
+ const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
17
+
18
+ function visibilityAllows(grants, cellId) {
19
+ const mode = grants.cellVisibility;
20
+ if (mode === 'all') return true;
21
+ if (mode === 'none') return false;
22
+ if (mode === 'selected') {
23
+ return Array.isArray(grants.cells) && typeof cellId === 'string' && grants.cells.includes(cellId);
24
+ }
25
+ return false; // malformed: denied, never guessed
26
+ }
27
+
28
+ // Effective grants of ONE peer, read fresh from the store. Unconfigured or
29
+ // malformed records are denied everywhere (a paired peer is not, by itself,
30
+ // allowed to read events). `revision` is the store-root access revision: the
31
+ // signature lets the publisher detect grant changes cheaply.
32
+ function peerGrants(store, peerNodeId) {
33
+ const node = (store && Array.isArray(store.nodes) ? store.nodes : [])
34
+ .find((n) => n && n.nodeId === peerNodeId && n.direction === 'inbound');
35
+ if (!node) return null;
36
+ const view = accessPresets.grantsOf(node);
37
+ const grants = {
38
+ eventsAccess: view.configured === true && node.eventsAccess === true,
39
+ nodeEventsAccess: view.configured === true && node.nodeEventsAccess === true,
40
+ askReplyAccess: view.configured === true && node.askReplyAccess === true,
41
+ cellVisibility: view.configured === true ? node.cellVisibility : 'none',
42
+ cells: Array.isArray(node.cells) ? [...node.cells] : [],
43
+ configured: view.configured === true,
44
+ label: view.label,
45
+ };
46
+ grants.signature = JSON.stringify({
47
+ e: grants.eventsAccess, n: grants.nodeEventsAccess, a: grants.askReplyAccess,
48
+ v: grants.cellVisibility, c: grants.cells, r: nodesStore.accessRevisionOf(store),
49
+ });
50
+ return grants;
51
+ }
52
+
53
+ // May this peer receive this envelope at all? The envelope's cellId is trusted
54
+ // here because it was resolved by the OWNER (binding/roster), never taken from
55
+ // a request body.
56
+ function allowsEnvelope(grants, envelope) {
57
+ if (!grants || grants.configured !== true) return false;
58
+ if (grants.eventsAccess !== true) return false;
59
+ if (!envelope || typeof envelope !== 'object') return false;
60
+ if (envelope.scope === 'cell') {
61
+ if (!(typeof envelope.cellId === 'string' && CELL_ID_RE.test(envelope.cellId))) return false;
62
+ return visibilityAllows(grants, envelope.cellId);
63
+ }
64
+ if (envelope.scope === 'node') return grants.nodeEventsAccess === true;
65
+ return false;
66
+ }
67
+
68
+ // Project a list of cells (fleet frames, snapshot state) to what the peer may
69
+ // see. Hidden cells contribute NOTHING: no names, no counts, no "there were N
70
+ // more" hints.
71
+ function projectCells(grants, cells) {
72
+ if (!Array.isArray(cells)) return [];
73
+ if (!grants || grants.eventsAccess !== true) return [];
74
+ if (grants.cellVisibility === 'all') return cells.slice(0, 1000);
75
+ if (grants.cellVisibility === 'selected') {
76
+ const allowed = new Set(Array.isArray(grants.cells) ? grants.cells : []);
77
+ return cells.filter((c) => c && allowed.has(c.cell));
78
+ }
79
+ return [];
80
+ }
81
+
82
+ // Full per-request decision + projection context. Returns null when the peer
83
+ // record does not exist (unknown origin): the caller refuses.
84
+ function resolvePeer(store, peerNodeId) {
85
+ const grants = peerGrants(store, peerNodeId);
86
+ if (!grants) return null;
87
+ return {
88
+ nodeId: peerNodeId,
89
+ grants,
90
+ allows: (envelope) => allowsEnvelope(grants, envelope),
91
+ projectCells: (cells) => projectCells(grants, cells),
92
+ };
93
+ }
94
+
95
+ module.exports = { peerGrants, allowsEnvelope, projectCells, visibilityAllows, resolvePeer, CELL_ID_RE };
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+ // lib/notify/event-feed-asks-routes.js — federated ask ANSWER surface.
3
+ //
4
+ // POST /event-feed/asks/:id/answer {text, requestId} (closed body)
5
+ // DELETE /event-feed/asks/:id (dismiss)
6
+ // GET /event-feed/asks/:id/requests/:requestId (attempt status)
7
+ //
8
+ // The gate is the SAME as the stream/snapshot gate (kill-switch -> proven
9
+ // federated origin -> exact [clientId, ownerId] -> known peer -> eventsAccess)
10
+ // PLUS askReplyAccess plus the ask's own cell scope read from the ask store.
11
+ // Unknown and out-of-scope ids get the SAME opaque 404: an id that does not
12
+ // exist and an id the peer may not see must be indistinguishable. Answer and
13
+ // dismiss run on a DEDICATED budget (6/min/peer, 30/min global) that shares
14
+ // nothing with notify/audio/local ask creation.
15
+
16
+ const express = require('express');
17
+ const { createFeedGate } = require('./event-feed-routes.js');
18
+
19
+ const ANSWER_RATE_PER_MIN = 6;
20
+ const ANSWER_GLOBAL_PER_MIN = 30;
21
+
22
+ function createAskRateLimiter({ now = Date.now } = {}) {
23
+ const perPeer = new Map();
24
+ const global = [];
25
+ function prune(list, t) {
26
+ const cutoff = t - 60000;
27
+ while (list.length > 0 && list[0] <= cutoff) list.shift();
28
+ }
29
+ function check(peerId) {
30
+ const t = now();
31
+ const list = perPeer.get(peerId) || [];
32
+ prune(list, t); prune(global, t);
33
+ if (list.length >= ANSWER_RATE_PER_MIN || global.length >= ANSWER_GLOBAL_PER_MIN) {
34
+ return { allowed: false, reason: 'answer-rate' };
35
+ }
36
+ list.push(t); perPeer.set(peerId, list); global.push(t);
37
+ return { allowed: true };
38
+ }
39
+ return { check };
40
+ }
41
+
42
+ function createEventFeedAsksRoutes(deps) {
43
+ const r = express.Router();
44
+ const json = express.json({ limit: '16kb' });
45
+ // deps: { nodesPath, localNodeId, originResolver, eventsEnabled, asks,
46
+ // answerService, cellForSession, log }
47
+ const gate = createFeedGate(deps);
48
+ const rate = createAskRateLimiter({ now: deps.now });
49
+ const log = typeof deps.log === 'function' ? deps.log : () => {};
50
+
51
+ // Resolve the ask for THIS peer: the cell comes from the ask store (never
52
+ // from the request), then the peer's cell scope decides. Unknown and
53
+ // out-of-scope are the same opaque 404.
54
+ function resolveAskForPeer(peer, askId) {
55
+ const ask = deps.asks.get(askId);
56
+ if (!ask) return null;
57
+ const cellId = deps.cellForSession(ask.session);
58
+ if (!cellId || !peer.allows({ scope: 'cell', cellId })) return null;
59
+ return { ask, cellId };
60
+ }
61
+
62
+ r.post('/:id/answer', json, async (req, res) => {
63
+ const g = await gate(req, res);
64
+ if (!g) return;
65
+ const verdict = rate.check(g.peer.nodeId);
66
+ if (!verdict.allowed) return res.status(429).json({ error: 'answer rate', reason: verdict.reason });
67
+ if (deps.readonly && deps.readonly()) {
68
+ return res.status(403).json({ error: 'READONLY: mutazione bloccata' });
69
+ }
70
+ // askReplyAccess on top of the feed gate: seeing the card does not
71
+ // grant the action. It is re-read with the same fresh store the gate used.
72
+ if (g.peer.grants.askReplyAccess !== true) {
73
+ return res.status(403).json({ error: 'risorsa non concessa a questo nodo', reason: 'grant-required:ask-action' });
74
+ }
75
+ const askId = String(req.params.id || '');
76
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
77
+ // CLOSED body: {text, requestId}. Anything that tries to choose a session,
78
+ // a target or an origin is a 400 before any other consideration.
79
+ for (const key of Object.keys(body)) {
80
+ if (!['text', 'requestId'].includes(key)) {
81
+ return res.status(400).json({ error: `campo non ammesso: "${key}"` });
82
+ }
83
+ }
84
+ if (typeof body.text !== 'string') return res.status(400).json({ error: 'text deve essere una stringa' });
85
+ const resolved = resolveAskForPeer(g.peer, askId);
86
+ if (!resolved) return res.status(404).json({ error: 'ask inesistente' });
87
+ const out = await deps.answerService.answerFederated({
88
+ askId, text: body.text, peerId: g.peer.nodeId, requestId: body.requestId,
89
+ });
90
+ if (out.ok) {
91
+ return res.json({ status: out.replay ? out.state : 'committed', requestId: body.requestId });
92
+ }
93
+ return res.status(out.code || 500).json({ error: out.error, reason: out.reason });
94
+ });
95
+
96
+ r.delete('/:id', async (req, res) => {
97
+ const g = await gate(req, res);
98
+ if (!g) return;
99
+ const verdict = rate.check(g.peer.nodeId);
100
+ if (!verdict.allowed) return res.status(429).json({ error: 'answer rate', reason: verdict.reason });
101
+ if (deps.readonly && deps.readonly()) {
102
+ return res.status(403).json({ error: 'READONLY: mutazione bloccata' });
103
+ }
104
+ if (g.peer.grants.askReplyAccess !== true) {
105
+ return res.status(403).json({ error: 'risorsa non concessa a questo nodo', reason: 'grant-required:ask-action' });
106
+ }
107
+ const askId = String(req.params.id || '');
108
+ const resolved = resolveAskForPeer(g.peer, askId);
109
+ if (!resolved) return res.status(404).json({ error: 'ask inesistente' });
110
+ // Local answer in flight wins: 409, and a dismissed ask is not answerable.
111
+ const out = deps.answerService.dismiss(askId);
112
+ if (!out.ok) {
113
+ if (out.reason === 'unknown') return res.status(404).json({ error: 'ask inesistente' });
114
+ if (out.reason === 'answering') return res.status(409).json({ error: 'risposta in corso: non si scarta un ask in answering' });
115
+ return res.status(500).json({ error: 'dismiss non riuscito' });
116
+ }
117
+ return res.json({ dismissed: true, id: askId, idempotent: out.idempotent === true });
118
+ });
119
+
120
+ r.get('/:id/requests/:requestId', async (req, res) => {
121
+ const g = await gate(req, res);
122
+ if (!g) return;
123
+ if (g.peer.grants.askReplyAccess !== true) {
124
+ return res.status(403).json({ error: 'risorsa non concessa a questo nodo', reason: 'grant-required:ask-action' });
125
+ }
126
+ const askId = String(req.params.id || '');
127
+ const requestId = String(req.params.requestId || '');
128
+ const resolved = resolveAskForPeer(g.peer, askId);
129
+ if (!resolved) return res.status(404).json({ error: 'ask inesistente' });
130
+ // Scoped: a peer sees ONLY its own attempts, and never the answer text of
131
+ // anyone — the receipt carries a digest, not a payload.
132
+ const entry = deps.receipts.get(g.peer.nodeId, askId, requestId);
133
+ if (!entry) return res.status(404).json({ error: 'ask inesistente' });
134
+ return res.json({
135
+ requestId, askId, state: entry.state,
136
+ ...(entry.receipt ? { receipt: entry.receipt } : {}),
137
+ ...(entry.updatedAt ? { updatedAt: entry.updatedAt } : {}),
138
+ });
139
+ });
140
+
141
+ return r;
142
+ }
143
+
144
+ module.exports = { createEventFeedAsksRoutes, createAskRateLimiter };
@@ -0,0 +1,434 @@
1
+ 'use strict';
2
+ // lib/notify/event-feed-client.js — the subscriber that runs in the CLIENT
3
+ // server, one subscription per paired owner with eventsReceive=true.
4
+ //
5
+ // Hard rules: the peer is chosen ONLY from the authorized node
6
+ // store — never a host/port/URL taken from an event; the Hydra route is opened
7
+ // with the server-side Bearer; the FIRST access (and every reset) is a
8
+ // SNAPSHOT, then the SSE stream resumes from the snapshot cursor; a frame
9
+ // attributed to another owner disconnects the stream; imported events are
10
+ // re-emitted locally ONLY — they can never re-enter a publisher, so A→B→A
11
+ // dies by construction. Transport failures back off with jitter 5→60 s; a
12
+ // stream silent for 60 s is aborted and resynced.
13
+
14
+ const BACKOFF_MIN_MS = 5000;
15
+ const BACKOFF_MAX_MS = 60000;
16
+ const IDLE_TIMEOUT_MS = 60000;
17
+ const MAX_CONSECUTIVE_RESETS = 3;
18
+
19
+ // Ingress budgets: 120 frames/min and 1 MiB/min per owner, plus a separate
20
+ // federated global window of 480 frames/min and 4 MiB/min. The counters are
21
+ // FRAMES, not transport chunks: one read() carries a whole round, and 121
22
+ // frames inside a single chunk must not pass as one.
23
+ const INGRESS_WINDOW_MS = 60000;
24
+ const INGRESS_MAX_FRAMES = 120;
25
+ const INGRESS_MAX_BYTES = 1024 * 1024;
26
+ const INGRESS_GLOBAL_MAX_FRAMES = 480;
27
+ const INGRESS_GLOBAL_MAX_BYTES = 4 * 1024 * 1024;
28
+ const INGRESS_BLOCK_BACKOFF_MS = 5000;
29
+
30
+ // Snapshot caps, enforced by the CLIENT as a floor of its own: the owner
31
+ // publishes them too, but a rogue owner is not trusted to police itself. The
32
+ // element schema is the frame schema (bounded id, bounded element size).
33
+ const SNAPSHOT_MAX_ASKS = 100;
34
+ const SNAPSHOT_MAX_NOTIFY = 50;
35
+ const SNAPSHOT_MAX_CELLS = 1000;
36
+ const SNAPSHOT_ELEMENT_MAX_BYTES = 16 * 1024;
37
+ const SNAPSHOT_ID_MAX_CHARS = 64;
38
+
39
+ function createEventFeedClient(opts = {}) {
40
+ // deps: { nodesPath, token(), eventsHub, now, fetchImpl, log }
41
+ const fetchImpl = opts.fetchImpl || fetch;
42
+ const now = opts.now || Date.now;
43
+ const log = typeof opts.log === 'function' ? opts.log : () => {};
44
+ // ownerId -> view state {cursor, generation, asks, notifications, fleetState,
45
+ // stale, lastError}; generation guards against obsolete fetches.
46
+ const views = new Map();
47
+ const running = new Map(); // nodeId -> {abort}
48
+ let stopped = false;
49
+ let generation = 0;
50
+
51
+ function backoffMs(peer) {
52
+ const fails = (peer._fails || 0) + 1;
53
+ peer._fails = fails;
54
+ const base = Math.min(BACKOFF_MAX_MS, BACKOFF_MIN_MS * Math.pow(2, fails - 1));
55
+ return Math.floor(base / 2 + Math.random() * (base / 2));
56
+ }
57
+
58
+ function viewFor(ownerId) {
59
+ if (!views.has(ownerId)) {
60
+ views.set(ownerId, { ownerId, cursor: null, asks: [], notifications: [], fleetState: null, stale: true, generation: 0 });
61
+ }
62
+ return views.get(ownerId);
63
+ }
64
+
65
+ // One resync round that ended WITHOUT a usable frame counts as a reset: a
66
+ // peer that keeps asking to resync (409 reset-required / cursor-future, or a
67
+ // frame from an unknown epoch) would otherwise be re-snapshotted forever.
68
+ // Past the cap the view goes to the error state and the poll gate stops it;
69
+ // a single healthy frame clears the streak.
70
+ function noteReset(view) {
71
+ view.consecutiveResets = (view.consecutiveResets || 0) + 1;
72
+ if (view.consecutiveResets >= MAX_CONSECUTIVE_RESETS) {
73
+ view.stale = true;
74
+ view.lastError = 'resync-exhausted';
75
+ }
76
+ }
77
+
78
+ function routeUrl(peer, resource, query = '') {
79
+ // Peer identity comes from the STORE record (name + localPort + token):
80
+ // nothing here ever reads a target from an event frame.
81
+ return `http://127.0.0.1:${peer.localPort}/federation/route/_/${resource.startsWith('/') ? resource.slice(1) : resource}${query}`;
82
+ }
83
+
84
+ function dedupKey(envelope) { return `${envelope.ownerId}:${envelope.eventId}`; }
85
+ const seen = new Map(); // dedupKey -> at, bounded to the replay horizon
86
+
87
+ function markSeen(key) {
88
+ seen.set(key, now());
89
+ if (seen.size > 2000) {
90
+ const cutoff = now() - 16 * 60 * 1000;
91
+ for (const [k, at] of seen) if (at < cutoff) seen.delete(k);
92
+ }
93
+ }
94
+
95
+ // Apply a fetched snapshot ONLY if it belongs to the current generation:
96
+ // an obsolete fetch must never overwrite a newer view (the snapshot
97
+ // WINS over the previous view of the same owner).
98
+ function applySnapshot(ownerId, snap, gen) {
99
+ const view = viewFor(ownerId);
100
+ if (gen !== generation) return false;
101
+ view.cursor = snap.cursor || null;
102
+ view.asks = Array.isArray(snap.asks) ? snap.asks.map((a) => ({ ...a, ownerId })) : [];
103
+ view.notifications = Array.isArray(snap.notifications) ? snap.notifications.map((e) => ({ ...e, ownerId })) : [];
104
+ view.fleetState = snap.fleetState || null;
105
+ view.askReplyAccess = snap.askReplyAccess === true;
106
+ view.viewEpoch = snap.viewEpoch;
107
+ view.stale = false;
108
+ view.generation = gen;
109
+ return true;
110
+ }
111
+
112
+ // Local re-emission of ONE imported envelope: attributed to the owner, into
113
+ // the local SSE hub only. The hub is the browser-facing surface — this call
114
+ // deliberately bypasses the notifier so the imported event can never be
115
+ // recorded into a feed history or re-published to other peers.
116
+ function reemit(envelope) {
117
+ const key = dedupKey(envelope);
118
+ if (seen.has(key)) return false;
119
+ markSeen(key);
120
+ if (!envelope.frame || envelope.hop !== 1) return false;
121
+ const f = envelope.frame;
122
+ if (f.type === 'notify') {
123
+ opts.eventsHub.broadcast({
124
+ type: 'notify', title: f.title, ...(f.body ? { body: f.body } : {}),
125
+ urgency: f.urgency === 'high' ? 'high' : 'normal',
126
+ ...(f.lang ? { lang: f.lang } : {}),
127
+ ...(f.askId ? { askId: String(f.askId) } : {}),
128
+ originNode: envelope.ownerId, ownerId: envelope.ownerId,
129
+ ...(envelope.scope === 'cell' && envelope.cellId ? { originCell: envelope.cellId } : {}),
130
+ eventId: envelope.eventId, ts: Date.now(),
131
+ });
132
+ } else if (f.type === 'ask') {
133
+ opts.eventsHub.broadcast({ type: 'ask', ownerId: envelope.ownerId, eventId: envelope.eventId,
134
+ ask: { id: f.askId, question: f.question, options: f.options, session: f.session, ownerId: envelope.ownerId } });
135
+ } else if (f.type === 'ask-closed') {
136
+ opts.eventsHub.broadcast({ type: f.outcome === 'dismissed' ? 'ask-dismissed' : 'ask-answered',
137
+ id: f.askId, ownerId: envelope.ownerId, eventId: envelope.eventId });
138
+ } else if (f.type === 'file-notice') {
139
+ opts.eventsHub.broadcast({ type: 'notify', title: `file: ${f.name}`, ...(f.caption ? { body: f.caption } : {}),
140
+ originNode: envelope.ownerId, ownerId: envelope.ownerId,
141
+ // Stessa attribuzione di cella del ramo notify: un file e' un avviso
142
+ // DELLA CELLA, e senza questo campo il relay lo contava come scope nodo
143
+ // (budget diverso: 7 avvisi invece di 6).
144
+ ...(envelope.scope === 'cell' && envelope.cellId ? { originCell: envelope.cellId } : {}),
145
+ eventId: envelope.eventId, ts: Date.now() });
146
+ } else if (f.type === 'fleet-state' || f.type === 'node-state') {
147
+ opts.eventsHub.broadcast({ type: 'feed-state', ownerId: envelope.ownerId, eventId: envelope.eventId, state: f });
148
+ }
149
+ return true;
150
+ }
151
+
152
+ // One streaming session against one owner. Resolves when the stream ends.
153
+ // First-hop identity headers: the visited chain starts with THIS node (the
154
+ // owner's gate re-binds it to the token-authenticated peer, so a client can
155
+ // only ever claim itself) and the hop proof is minted on the owner side.
156
+ function identityHeaders(peer, store) {
157
+ return {
158
+ authorization: `Bearer ${peer.token}`,
159
+ 'x-nexuscrew-visited': store.nodeId,
160
+ };
161
+ }
162
+
163
+ const ingress = { frames: [], bytes: [], globalFrames: [], globalBytes: [] };
164
+
165
+ // Admits ONE frame and returns the window that tripped, or null. The record is
166
+ // always written: a refused frame still happened on the wire.
167
+ function ingressTrip(t, bytes, ownerId) {
168
+ const cutoff = t - INGRESS_WINDOW_MS;
169
+ const slide = (list) => { while (list.length && list[0].t <= cutoff) list.shift(); };
170
+ slide(ingress.frames); slide(ingress.bytes); slide(ingress.globalFrames); slide(ingress.globalBytes);
171
+ ingress.frames.push({ t, ownerId }); ingress.bytes.push({ t, bytes, ownerId });
172
+ ingress.globalFrames.push({ t }); ingress.globalBytes.push({ t, bytes });
173
+ const ownBytes = ingress.bytes.filter((x) => x.ownerId === ownerId).reduce((a, x) => a + x.bytes, 0);
174
+ const globalBytes = ingress.globalBytes.reduce((a, x) => a + x.bytes, 0);
175
+ const ownFrames = ingress.frames.filter((x) => x.ownerId === ownerId).length;
176
+ if (ownFrames > INGRESS_MAX_FRAMES) return 'owner-frames';
177
+ if (ownBytes > INGRESS_MAX_BYTES) return 'owner-bytes';
178
+ if (ingress.globalFrames.length > INGRESS_GLOBAL_MAX_FRAMES) return 'global-frames';
179
+ if (globalBytes > INGRESS_GLOBAL_MAX_BYTES) return 'global-bytes';
180
+ return null;
181
+ }
182
+
183
+ // The block lasts until the window that tripped frees capacity — its oldest
184
+ // record ages out — plus a bounded backoff: the next poll must not restart
185
+ // while that counter is still full.
186
+ function ingressBlockUntil(reason, t, ownerId) {
187
+ const own = (list) => list.filter((x) => x.ownerId === ownerId);
188
+ const list = reason === 'global-frames' ? ingress.globalFrames
189
+ : reason === 'global-bytes' ? ingress.globalBytes
190
+ : reason === 'owner-bytes' ? own(ingress.bytes) : own(ingress.frames);
191
+ const oldest = list.length ? list[0].t : t;
192
+ return Math.max(oldest + INGRESS_WINDOW_MS, t) + INGRESS_BLOCK_BACKOFF_MS;
193
+ }
194
+
195
+ async function streamOnce(peer, ownerId, store) {
196
+ const view = viewFor(ownerId);
197
+ // The deadline expired: the peer is admitted again and the block is closed
198
+ // by the transition that reopens the stream, not by a silent timeout.
199
+ if (view.ingressBlockedUntil && view.ingressBlockedUntil <= Date.now()) {
200
+ view.ingressBlockedUntil = null;
201
+ view.ingressBlockReason = null;
202
+ }
203
+ const after = view.cursor ? `?after=${encodeURIComponent(view.cursor)}` : '';
204
+ const ctrl = new AbortController();
205
+ running.set(ownerId, { abort: () => ctrl.abort() });
206
+ let timer = setTimeout(() => ctrl.abort(), IDLE_TIMEOUT_MS); // 60 s without bytes
207
+ try {
208
+ const r = await fetchImpl(routeUrl(peer, '/event-feed', after), {
209
+ headers: identityHeaders(peer, store), signal: ctrl.signal,
210
+ });
211
+ if (r.status === 403 && (await r.json().catch(() => ({}))).reason === 'events-disabled') {
212
+ view.stale = true; return;
213
+ }
214
+ if (r.status === 409) { // reset-required / cursor-future: resync from a fresh snapshot
215
+ view.cursor = null;
216
+ noteReset(view);
217
+ return;
218
+ }
219
+ if (!r.ok) { view.stale = true; throw new Error(`event-feed HTTP ${r.status}`); }
220
+ peer._fails = 0;
221
+ const reader = r.body.getReader();
222
+ const dec = new TextDecoder();
223
+ let buffer = '';
224
+ for (;;) {
225
+ const { done, value } = await reader.read();
226
+ if (done) break;
227
+ // ONE idle timer, rearmed on every chunk: a stale armed timeout must
228
+ // never survive next to a new one.
229
+ clearTimeout(timer);
230
+ timer = setTimeout(() => ctrl.abort(), IDLE_TIMEOUT_MS);
231
+ const chunk = dec.decode(value, { stream: true });
232
+ buffer += chunk;
233
+ let idx;
234
+ while ((idx = buffer.indexOf('\n\n')) >= 0) {
235
+ const block = buffer.slice(0, idx); buffer = buffer.slice(idx + 2);
236
+ if (block.startsWith(':')) continue; // heartbeat
237
+ const dataLine = block.split('\n').find((l) => l.startsWith('data: '));
238
+ if (!dataLine) continue;
239
+ // Frame cap BEFORE JSON.parse.
240
+ if (dataLine.length > 16 * 1024) { log('event-feed-client: oversized frame, resync'); view.cursor = null; return; }
241
+ // Ingress budget, counted per FRAME on the wire BEFORE it is parsed or
242
+ // applied: over the cap this peer is disconnected by name and blocked
243
+ // for the rest of its window, while the other owners keep streaming.
244
+ const at = Date.now();
245
+ const trip = ingressTrip(at, Buffer.byteLength(block, 'utf8'), ownerId);
246
+ if (trip) {
247
+ view.lastError = 'ingress-budget';
248
+ view.ingressBlockReason = trip;
249
+ view.ingressBlockedUntil = ingressBlockUntil(trip, at, ownerId);
250
+ view.stale = true;
251
+ log(`event-feed-client: ${ownerId.slice(0, 8)}… ingress-budget (${trip}), blocked for ${Math.round((view.ingressBlockedUntil - at) / 1000)} s`);
252
+ // The transport is closed explicitly: an abandoned response would
253
+ // keep the socket (and the owner's writer) alive until the GC.
254
+ try { await reader.cancel(); } catch (_) { /* already closing */ }
255
+ ctrl.abort();
256
+ return;
257
+ }
258
+ let envelope;
259
+ try { envelope = JSON.parse(dataLine.slice(6)); } catch (_) { view.cursor = null; return; }
260
+ // Owner mismatch on a frame: disconnect, never apply.
261
+ if (envelope.ownerId !== ownerId) { log('event-feed-client: owner mismatch, disconnect'); return; }
262
+ // Sequence continuity within the same epoch; anything else resyncs.
263
+ const [, seqStr] = (block.split('\n').find((l) => l.startsWith('id: ')) || '').split(': ');
264
+ const [epochStr, sStr] = String(seqStr || '').split(':');
265
+ if (Number(epochStr) !== view.viewEpoch) {
266
+ view.cursor = null;
267
+ noteReset(view);
268
+ return;
269
+ }
270
+ if (view.lastSeq !== undefined && Number(sStr) !== view.lastSeq + 1) { view.cursor = null; return; }
271
+ view.lastSeq = Number(sStr);
272
+ view.consecutiveResets = 0; // a usable frame: the streak is over
273
+ view.cursor = `${epochStr}:${sStr}`;
274
+ reemit(envelope);
275
+ }
276
+ }
277
+ } finally {
278
+ clearTimeout(timer);
279
+ running.delete(ownerId);
280
+ }
281
+ }
282
+
283
+ // Capability BEFORE subscribing: an owner without the feed capability makes
284
+ // the view unsupported — no fallback, no infinite backoff.
285
+ async function checkCapability(peer, ownerId) {
286
+ const view = viewFor(ownerId);
287
+ if (view.capabilityChecked) return true;
288
+ // The health endpoint of the OWNER is probed directly (same pattern as
289
+ // the tunnel health probe): /federation/health with the peer's token.
290
+ const r = await fetchImpl(`http://127.0.0.1:${peer.localPort}/federation/health`, {
291
+ headers: { authorization: `Bearer ${peer.token}` },
292
+ });
293
+ if (!r.ok) { view.stale = true; throw new Error(`health HTTP ${r.status}`); }
294
+ const body = await r.json();
295
+ if (body.eventFeedV1 !== true || body.instanceId !== ownerId) {
296
+ view.unsupported = true;
297
+ view.lastError = 'event-feed-unsupported';
298
+ // One transition log, not one per tick: the poll gate keeps this
299
+ // owner out from here on.
300
+ log(`event-feed-client: ${ownerId.slice(0, 8)}… no feed capability, subscription refused`);
301
+ return false;
302
+ }
303
+ view.capabilityChecked = true;
304
+ return true;
305
+ }
306
+
307
+ async function snapshotOnce(peer, ownerId, store) {
308
+ const view = viewFor(ownerId);
309
+ const gen = generation;
310
+ const r = await fetchImpl(routeUrl(peer, '/event-feed/snapshot'), {
311
+ headers: identityHeaders(peer, store),
312
+ });
313
+ if (!r.ok) { view.stale = true; throw new Error(`snapshot HTTP ${r.status}`); }
314
+ // Size cap BEFORE JSON.parse: a body over the server budget is refused on
315
+ // sight, never parsed.
316
+ const text = await r.text();
317
+ if (Buffer.byteLength(text, 'utf8') > 3 * 1024 * 1024) {
318
+ view.stale = true; view.lastError = 'snapshot-oversize';
319
+ return false;
320
+ }
321
+ const snap = JSON.parse(text);
322
+ // Owner mismatch on the snapshot: disconnect, never apply.
323
+ if (snap.ownerId !== ownerId) {
324
+ view.stale = true; view.lastError = 'owner-mismatch';
325
+ return false;
326
+ }
327
+ // Per-element sanity over EVERY list, not just the notify history: bounded
328
+ // id, bounded element, and the count caps. A snapshot that breaks any of
329
+ // them is refused whole — never applied in part.
330
+ const boundedId = (id) => typeof id === 'string' && id.length > 0 && id.length <= SNAPSHOT_ID_MAX_CHARS;
331
+ const boundedElement = (e) => !!e && typeof e === 'object'
332
+ && Buffer.byteLength(JSON.stringify(e), 'utf8') <= SNAPSHOT_ELEMENT_MAX_BYTES;
333
+ if (Array.isArray(snap.asks)
334
+ && (snap.asks.length > SNAPSHOT_MAX_ASKS
335
+ || !snap.asks.every((a) => boundedElement(a) && boundedId(a && a.id)))) {
336
+ view.stale = true; view.lastError = 'snapshot-element-oversize';
337
+ return false;
338
+ }
339
+ if (Array.isArray(snap.notifications)
340
+ && (snap.notifications.length > SNAPSHOT_MAX_NOTIFY
341
+ || !snap.notifications.every((e) => boundedElement(e) && boundedId(e && e.eventId)))) {
342
+ view.stale = true; view.lastError = 'snapshot-element-oversize';
343
+ return false;
344
+ }
345
+ if (snap.fleetState && typeof snap.fleetState === 'object') {
346
+ const cells = Array.isArray(snap.fleetState.cells) ? snap.fleetState.cells : [];
347
+ if (cells.length > SNAPSHOT_MAX_CELLS || !boundedElement(snap.fleetState)
348
+ || !cells.every((c) => boundedElement(c) && boundedId(c && c.cell))) {
349
+ view.stale = true; view.lastError = 'snapshot-element-oversize';
350
+ return false;
351
+ }
352
+ }
353
+ // A snapshot over the server budget is a floor, not a whole: resync.
354
+ if (snap.resyncRequired) { view.stale = true; return false; }
355
+ applySnapshot(ownerId, snap, gen);
356
+ return true;
357
+ }
358
+
359
+ // One reconciliation round across every enabled peer. Never throws.
360
+ async function poll() {
361
+ if (stopped) return;
362
+ const store = opts.loadStore();
363
+ if (!store) return;
364
+ const peers = (store.nodes || []).filter((n) => n && n.direction === 'outbound'
365
+ && n.eventsReceive === true && n.token && n.localPort && n.nodeId);
366
+ await Promise.all(peers.map(async (peer) => {
367
+ const ownerId = peer.nodeId;
368
+ if (running.has(ownerId)) return; // one loop per pair
369
+ const view = viewFor(ownerId);
370
+ try {
371
+ if (view.unsupported || (view.consecutiveResets || 0) >= MAX_CONSECUTIVE_RESETS
372
+ || (view.ingressBlockedUntil || 0) > Date.now()) return;
373
+ if (!view.capabilityChecked) {
374
+ const ok = await checkCapability(peer, ownerId);
375
+ if (!ok) return;
376
+ }
377
+ if (!view.cursor) {
378
+ const ok = await snapshotOnce(peer, ownerId, store);
379
+ if (!ok) return;
380
+ }
381
+ await streamOnce(peer, ownerId, store);
382
+ } catch (e) {
383
+ view.stale = true;
384
+ // A named feed reason (e.g. owner-mismatch) survives a later transport
385
+ // error: it is the reason the operator needs to see.
386
+ if (view.lastError !== 'owner-mismatch') {
387
+ view.lastError = String((e && e.message) || e);
388
+ }
389
+ const delay = backoffMs(peer);
390
+ log(`event-feed-client: ${ownerId.slice(0, 8)}… retry in ${delay} ms (${view.lastError})`);
391
+ setTimeout(() => { void poll(); }, delay);
392
+ }
393
+ }));
394
+ }
395
+
396
+ let pollTimer = null;
397
+ function start() {
398
+ stopped = false; generation += 1;
399
+ void poll();
400
+ // The store is written AFTER boot in real life (pairing happens later):
401
+ // keep scanning for newly enabled peers. Streams are long-lived, so this
402
+ // tick only ever picks up peers that are not running yet.
403
+ if (!pollTimer) {
404
+ pollTimer = setInterval(() => { void poll(); }, opts.pollMs || 4000);
405
+ if (typeof pollTimer.unref === 'function') pollTimer.unref();
406
+ }
407
+ }
408
+ function stop() {
409
+ stopped = true; generation += 1;
410
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
411
+ for (const [, h] of running) { try { h.abort(); } catch (_) {} }
412
+ }
413
+
414
+ // Local aggregated read: connected views only, with the watermark the UI
415
+ // needs to initialize without racing the live stream.
416
+ function state() {
417
+ return {
418
+ views: [...views.entries()].map(([ownerId, v]) => ({
419
+ ownerId, cursor: v.cursor, viewEpoch: v.viewEpoch, stale: v.stale,
420
+ // Both sides of the merge matter to the UI: the answer/dismiss gate
421
+ // (askReplyAccess) and the ingress/error state of the view.
422
+ askReplyAccess: v.askReplyAccess === true,
423
+ lastError: v.lastError || null,
424
+ ingressBlockedUntil: v.ingressBlockedUntil || null,
425
+ ingressBlockReason: v.ingressBlockReason || null,
426
+ asks: v.asks, notifications: v.notifications, fleetState: v.fleetState,
427
+ })),
428
+ };
429
+ }
430
+
431
+ return { start, stop, poll, state, reemit, viewFor };
432
+ }
433
+
434
+ module.exports = { createEventFeedClient };