@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,328 @@
1
+ 'use strict';
2
+ // lib/notify/event-feed.js — owner-side event feed: a per-peer ring with the
3
+ // scope applied BEFORE insertion, a per-peer view epoch + sequence, an atomic
4
+ // replay-to-live barrier, backpressure via write(false), heartbeats, bounded
5
+ // live queues and bounded counters.
6
+ //
7
+ // Design points that are security decisions, not preferences:
8
+ // - The ring holds ONLY what the peer may see. Filtering happens before
9
+ // insertion, never at read time: a later grant change invalidates the
10
+ // whole view (epoch bump + ring wipe), it does not "re-filter" history.
11
+ // - The cursor is per peer view: `viewEpoch:seq`. The epoch changes on owner
12
+ // restart and on every grant/scope change, so an old cursor can never be
13
+ // mistaken for a live one, and the sequence never leaks a global event
14
+ // count (hidden cells stay uncountable).
15
+ // - write(false) stops the socket until drain: production to THAT stream
16
+ // pauses, the ring keeps recording, the peer's other paths are untouched.
17
+ // - Budgets are first-threshold: whichever of events/bytes/time trips first
18
+ // prunes the ring, so no single dimension can grow unbounded.
19
+
20
+ const HEARTBEAT_MS = 20000;
21
+ const FRAME_MAX_BYTES = 16 * 1024; // cap BEFORE any JSON.parse downstream
22
+ const RING_MAX_EVENTS = 512;
23
+ const RING_MAX_BYTES = 1024 * 1024;
24
+ const RING_MAX_MS = 15 * 60 * 1000;
25
+ const LIVE_QUEUE_MAX_FRAMES = 128;
26
+ const LIVE_QUEUE_MAX_BYTES = 256 * 1024;
27
+ const MAX_ENABLED_PEERS = 8;
28
+ const OPENS_PER_MIN = 6;
29
+
30
+ function defaultNow() { return Date.now(); }
31
+
32
+ function createEventFeed(opts = {}) {
33
+ const now = opts.now || defaultNow;
34
+ const heartbeatMs = opts.heartbeatMs || HEARTBEAT_MS;
35
+ const frameMaxBytes = opts.frameMaxBytes || FRAME_MAX_BYTES;
36
+ const maxPeers = opts.maxPeers || MAX_ENABLED_PEERS;
37
+ const opensPerMin = opts.opensPerMin || OPENS_PER_MIN;
38
+ const log = typeof opts.log === 'function' ? opts.log : () => {};
39
+
40
+ // peerId -> state. Enabled = has state (owner granted the feed explicitly);
41
+ // enabling beyond the budget is refused with a named error.
42
+ const peers = new Map();
43
+
44
+ function freshState() {
45
+ return {
46
+ epoch: 1,
47
+ seq: 0,
48
+ ring: [], // [{seq, bytes, at, envelope}]
49
+ ringBytes: 0,
50
+ streams: [], // at most one live stream per pair (newest wins)
51
+ opens: [], // open timestamps for the per-peer rate
52
+ counters: { delivered: 0, dropped: 0, replayed: 0, rejected: 0, since: now() },
53
+ lastCursor: null,
54
+ lastDropReason: null,
55
+ lastRejectReason: null,
56
+ };
57
+ }
58
+
59
+ function state(peerId) { return peers.get(peerId) || null; }
60
+
61
+ function enablePeer(peerId) {
62
+ if (peers.has(peerId)) return peers.get(peerId);
63
+ if (peers.size >= maxPeers) {
64
+ const e = new Error('too many peers enabled for the event feed');
65
+ e.reason = 'peer-budget';
66
+ throw e;
67
+ }
68
+ const st = freshState();
69
+ peers.set(peerId, st);
70
+ return st;
71
+ }
72
+
73
+ function disablePeer(peerId) {
74
+ const st = peers.get(peerId);
75
+ if (st) closeStreams(st, 'peer-disabled');
76
+ peers.delete(peerId);
77
+ }
78
+
79
+ function closeStreams(st, reason) {
80
+ for (const s of st.streams.splice(0)) {
81
+ try { s.close(reason); } catch (_) { /* best-effort */ }
82
+ }
83
+ }
84
+
85
+ // Grant/scope change or revocation: the view is dead, not re-filtered.
86
+ function invalidatePeer(peerId, reason) {
87
+ const st = peers.get(peerId);
88
+ if (!st) return;
89
+ closeStreams(st, reason);
90
+ st.epoch += 1;
91
+ st.seq = 0;
92
+ st.ring = [];
93
+ st.ringBytes = 0;
94
+ st.lastCursor = null;
95
+ log(`event-feed: view reset for peer ${peerId} (${reason})`);
96
+ }
97
+
98
+ // Kill-switch: close everything served, wipe every ring, refuse new opens.
99
+ function invalidateAll(reason) {
100
+ for (const [peerId, st] of peers) {
101
+ closeStreams(st, reason);
102
+ st.epoch += 1;
103
+ st.seq = 0;
104
+ st.ring = [];
105
+ st.ringBytes = 0;
106
+ st.lastCursor = null;
107
+ log(`event-feed: view reset for peer ${peerId} (${reason})`);
108
+ }
109
+ }
110
+
111
+ function pruneRing(st) {
112
+ const cutoff = now() - RING_MAX_MS;
113
+ while (st.ring.length > 0 && (
114
+ st.ring.length > RING_MAX_EVENTS
115
+ || st.ringBytes > RING_MAX_BYTES
116
+ || (st.ring.length > 0 && st.ring[0].at < cutoff && st.ring.length > 1)
117
+ )) {
118
+ const dropped = st.ring.shift();
119
+ st.ringBytes -= dropped.bytes;
120
+ }
121
+ }
122
+
123
+ // Publish an ALREADY-SCOPED envelope to one peer. The caller applied the ACL
124
+ // before calling: this function trusts the envelope's peer binding and
125
+ // refuses frames over the byte cap before they ever enter the ring.
126
+ function publish(peerId, envelope) {
127
+ const st = peers.get(peerId);
128
+ if (!st) return { ok: false, reason: 'peer-not-enabled' };
129
+ const bytes = Buffer.byteLength(JSON.stringify(envelope), 'utf8');
130
+ if (bytes > frameMaxBytes) {
131
+ st.counters.dropped += 1;
132
+ st.lastDropReason = 'frame-too-large';
133
+ return { ok: false, reason: 'frame-too-large' };
134
+ }
135
+ st.seq += 1;
136
+ const entry = { seq: st.seq, bytes, at: now(), envelope };
137
+ st.ring.push(entry);
138
+ st.ringBytes += bytes;
139
+ pruneRing(st);
140
+ st.lastCursor = `${st.epoch}:${st.seq}`;
141
+
142
+ if (st.streams.length > 0) {
143
+ const s = st.streams[st.streams.length - 1]; // newest stream wins the pair
144
+ if (s.paused) {
145
+ s.queue.push(entry);
146
+ s.queueBytes += bytes;
147
+ if (s.queue.length > LIVE_QUEUE_MAX_FRAMES || s.queueBytes > LIVE_QUEUE_MAX_BYTES) {
148
+ // Overflow: close and demand a resync, never a silently lossy stream.
149
+ st.counters.dropped += s.queue.length;
150
+ st.lastDropReason = 'live-queue-overflow';
151
+ closeStreams(st, 'resync-required');
152
+ }
153
+ } else {
154
+ try {
155
+ if (typeof s.filter === 'function' && !s.filter(entry)) {
156
+ // Narrower stream (cell-only, node-only): not a drop, a scope.
157
+ } else {
158
+ const out = s.write(entry);
159
+ st.counters.delivered += 1;
160
+ if (out === false) { s.paused = true; }
161
+ }
162
+ } catch (_) {
163
+ closeStreams(st, 'write-error');
164
+ }
165
+ }
166
+ }
167
+ return { ok: true, seq: st.seq };
168
+ }
169
+
170
+ // Subscribe an SSE response. `after` is the caller's parsed cursor or null.
171
+ // Returns {ok, epoch} or {ok:false, status, reason}: the ROUTE turns a
172
+ // refusal into the right HTTP answer before any byte of content flows.
173
+ function subscribe(peerId, res, after, opts2 = {}) {
174
+ const st = peers.get(peerId);
175
+ if (!st) return { ok: false, status: 403, reason: 'feed-not-enabled' };
176
+
177
+ // One stream per pair: a reconnection replaces the previous one.
178
+ const openCut = now() - 60000;
179
+ st.opens = st.opens.filter((t) => t > openCut);
180
+ if (st.opens.length >= opensPerMin) {
181
+ st.counters.rejected += 1;
182
+ st.lastRejectReason = 'open-rate';
183
+ return { ok: false, status: 429, reason: 'open-rate' };
184
+ }
185
+ st.opens.push(now());
186
+
187
+ let replay = [];
188
+ if (after) {
189
+ if (after.epoch !== st.epoch) {
190
+ st.counters.rejected += 1;
191
+ st.lastRejectReason = 'reset-required';
192
+ return { ok: false, status: 409, reason: 'reset-required' };
193
+ }
194
+ if (after.seq > st.seq) {
195
+ st.counters.rejected += 1;
196
+ st.lastRejectReason = 'cursor-future';
197
+ return { ok: false, status: 409, reason: 'cursor-future' };
198
+ }
199
+ // A cursor older than the ring horizon cannot be replayed honestly.
200
+ const oldest = st.ring.length > 0 ? st.ring[0].seq : st.seq + 1;
201
+ if (after.seq + 1 < oldest && !(st.ring.length === 0 && after.seq === st.seq)) {
202
+ st.counters.rejected += 1;
203
+ st.lastRejectReason = 'reset-required';
204
+ return { ok: false, status: 409, reason: 'reset-required' };
205
+ }
206
+ replay = st.ring.filter((e) => e.seq > after.seq);
207
+ }
208
+
209
+ // Replace any previous stream for this peer BEFORE writing anything: two
210
+ // live streams for one pair would double-deliver the same events.
211
+ closeStreams(st, 'replaced');
212
+
213
+ const stream = {
214
+ res,
215
+ paused: false,
216
+ queue: [],
217
+ queueBytes: 0,
218
+ closed: false,
219
+ write(entry) {
220
+ if (stream.closed) return false;
221
+ st.counters.delivered += 1;
222
+ return res.write(`id: ${st.epoch}:${entry.seq}\ndata: ${JSON.stringify(entry.envelope)}\n\n`);
223
+ },
224
+ queueEntry(entry) {
225
+ stream.queue.push(entry);
226
+ stream.queueBytes += entry.bytes;
227
+ },
228
+ drain() {
229
+ stream.paused = false;
230
+ while (!stream.paused && stream.queue.length > 0) {
231
+ const entry = stream.queue.shift();
232
+ stream.queueBytes -= entry.bytes;
233
+ try {
234
+ if (stream.write(entry) === false) stream.paused = true;
235
+ } catch (_) { closeStreams(st, 'write-error'); return; }
236
+ }
237
+ if (stream.queue.length > LIVE_QUEUE_MAX_FRAMES || stream.queueBytes > LIVE_QUEUE_MAX_BYTES) {
238
+ st.counters.dropped += stream.queue.length;
239
+ st.lastDropReason = 'live-queue-overflow';
240
+ closeStreams(st, 'resync-required');
241
+ }
242
+ },
243
+ close(reason) {
244
+ if (stream.closed) return;
245
+ stream.closed = true;
246
+ clearInterval(stream.hb);
247
+ const idx = st.streams.indexOf(stream);
248
+ if (idx >= 0) st.streams.splice(idx, 1);
249
+ try { res.end(); } catch (_) { /* the socket may be gone */ }
250
+ log(`event-feed: stream closed for peer ${peerId} (${reason})`);
251
+ },
252
+ };
253
+ const hb = setInterval(() => {
254
+ if (stream.closed) { clearInterval(hb); return; }
255
+ // A heartbeat is also a revoke checkpoint: grants are re-read without
256
+ // waiting for the next event.
257
+ if (typeof opts.validatePeer === 'function') {
258
+ let alive = true;
259
+ try { alive = opts.validatePeer(peerId) !== false; } catch (_) { alive = false; }
260
+ if (!alive) { closeStreams(st, 'revoked'); return; }
261
+ }
262
+ try {
263
+ const out = res.write(':hb\n\n');
264
+ if (out === false) stream.paused = true;
265
+ } catch (_) { closeStreams(st, 'heartbeat-error'); }
266
+ }, heartbeatMs);
267
+ if (typeof hb.unref === 'function') hb.unref();
268
+ stream.hb = hb;
269
+ st.streams.push(stream);
270
+
271
+ // Atomic replay-to-live: both happen inside this synchronous turn, so no
272
+ // publish can fall between the last replayed frame and the live hookup.
273
+ res.writeHead(200, {
274
+ 'Content-Type': 'text/event-stream',
275
+ 'Cache-Control': 'no-store',
276
+ Connection: 'keep-alive',
277
+ 'X-Accel-Buffering': 'no',
278
+ });
279
+ res.write('retry: 5000\n\n');
280
+ if (typeof opts2.filter === 'function') stream.filter = opts2.filter;
281
+ for (const entry of replay) {
282
+ if (stream.filter && !stream.filter(entry)) continue;
283
+ st.counters.replayed += 1;
284
+ if (stream.write(entry) === false) { stream.paused = true; stream.queueEntry(entry); }
285
+ }
286
+ res.on('close', () => { try { stream.close('client-disconnect'); } catch (_) {} });
287
+ res.on('drain', () => { if (stream.paused) stream.drain(); });
288
+ // write() returning false on the replay frames left them queued: drain now.
289
+ if (stream.paused && !stream.closed) stream.drain();
290
+ return { ok: true, epoch: st.epoch, seq: st.seq };
291
+ }
292
+
293
+ function status(peerId) {
294
+ const st = peers.get(peerId);
295
+ if (!st) return null;
296
+ return {
297
+ enabled: true,
298
+ viewEpoch: st.epoch,
299
+ seq: st.seq,
300
+ lastCursor: st.lastCursor,
301
+ ring: { events: st.ring.length, bytes: st.ringBytes },
302
+ streams: st.streams.length,
303
+ counters: { ...st.counters },
304
+ lastDropReason: st.lastDropReason,
305
+ lastRejectReason: st.lastRejectReason,
306
+ };
307
+ }
308
+
309
+ function closeAll() {
310
+ for (const [, st] of peers) closeStreams(st, 'shutdown');
311
+ if (opts.keepTimers !== true) { /* heartbeats die with their streams */ }
312
+ }
313
+
314
+ return {
315
+ enablePeer, disablePeer, invalidatePeer, invalidateAll,
316
+ publish, subscribe, status, closeAll,
317
+ peerIds: () => [...peers.keys()],
318
+ enabledCount: () => peers.size,
319
+ };
320
+ }
321
+
322
+ module.exports = {
323
+ createEventFeed,
324
+ LIMITS: Object.freeze({
325
+ HEARTBEAT_MS, FRAME_MAX_BYTES, RING_MAX_EVENTS, RING_MAX_BYTES, RING_MAX_MS,
326
+ LIVE_QUEUE_MAX_FRAMES, LIVE_QUEUE_MAX_BYTES, MAX_ENABLED_PEERS, OPENS_PER_MIN,
327
+ }),
328
+ };
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- // Hub SSE per gli eventi UI (notify/ask) — MCP bridge, design §2a.
2
+ // SSE hub for UI events (notify/ask) — MCP bridge.
3
3
  // Non esisteva un canale UI-broadcast (i WS sono per-attach PTY, il frame
4
4
  // 'files' viaggia solo verso i client attached alla sessione): GET /api/events
5
5
  // e' il canale EventSource su cui OGNI vista della UI ascolta notify/ask.
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
- // Facciata unica di emissione notifiche (MCP bridge §2): UI (SSE broadcast) +
3
- // web-push. Riusata dalla route /api/notify, dagli ask (urgency high) e dalla
4
- // consegna file in outbox — un solo punto che conosce entrambi i canali.
2
+ // Single facade for emitting notifications (MCP bridge): UI (SSE broadcast) +
3
+ // web-push. Reused by the /api/notify route, by the asks (high urgency) and
4
+ // by the outbox file delivery — one point that knows both channels.
5
5
 
6
6
  function createNotifier({ hub, push }) {
7
7
  // frame: {title, body?, urgency?, session?, lang?, url?}. Ritorna {ui, push} (conteggi).
@@ -0,0 +1,240 @@
1
+ 'use strict';
2
+ // lib/notify/push-relay.js — OS alert relay for IMPORTED notifications.
3
+ //
4
+ // Only NEW, authorized imported notify envelopes reach the push sender: ask
5
+ // closures and state frames update the UI silently, an ask's first alert
6
+ // travels with its correlated notify, and a snapshot or a replay of an
7
+ // already-known event never rings twice. The (owner, eventId) pair is
8
+ // persisted BEFORE the send — a crash in between may lose an alert, it may
9
+ // never duplicate one.
10
+ //
11
+ // The queue is its own budget, separate from local notifications: 32 alerts
12
+ // or 128 KiB, at most 2 concurrent sends, 10 s per send. Errors are counted,
13
+ // best-effort: a failed push NEVER causes a retry of the SSE stream that
14
+ // already accepted the event. The alert budget (6 per cell per minute,
15
+ // 12 per client per minute) is a dedicated window and urgency never
16
+ // bypasses it.
17
+
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+
21
+ const QUEUE_MAX_ALERTS = 32;
22
+ const QUEUE_MAX_BYTES = 128 * 1024;
23
+ const MAX_CONCURRENT = 2;
24
+ const SEND_TIMEOUT_MS = 10000;
25
+ const PAYLOAD_MAX_BYTES = 3 * 1024;
26
+ const DEDUP_CAP = 4096;
27
+ const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
28
+ const CELL_ALERTS_PER_MIN = 6;
29
+ const CLIENT_ALERTS_PER_MIN = 12;
30
+
31
+ function truncateUtf8(str, maxBytes) {
32
+ const out = [];
33
+ let bytes = 0;
34
+ for (const ch of String(str)) {
35
+ const b = Buffer.byteLength(ch, 'utf8');
36
+ if (bytes + b > maxBytes) return { text: out.join('') + '…', truncated: true };
37
+ bytes += b;
38
+ out.push(ch);
39
+ }
40
+ return { text: out.join(''), truncated: false };
41
+ }
42
+
43
+ // Truncate at a UNICODE boundary (never splitting a code point) and say so.
44
+ function buildPayload(envelope) {
45
+ const f = envelope.frame || {};
46
+ const full = typeof f.body === 'string' ? f.body : '';
47
+ const { text: preview, truncated } = truncateUtf8(full, 200);
48
+ const where = envelope.scope === 'cell' && envelope.cellId ? envelope.cellId : '';
49
+ const askId = typeof f.askId === 'string' && f.askId ? f.askId : null;
50
+ // Local route only: the app decides what to open, the payload never carries a
51
+ // peer URL. ask= is present only when the source names an ask.
52
+ const url = `/#owner=${envelope.ownerId}`
53
+ + (askId ? `&ask=${encodeURIComponent(askId)}` : '')
54
+ + (where ? `&cell=${encodeURIComponent(where)}` : '');
55
+ // The FULL body lives in the card (the app holds the imported envelope);
56
+ // the OS payload carries the preview only.
57
+ void full;
58
+ return {
59
+ title: truncateUtf8(f.title || 'NexusCrew', 120).text,
60
+ ...(preview ? { body: truncated ? `${preview}…` : preview } : {}),
61
+ ...(f.lang ? { lang: f.lang } : {}),
62
+ tag: `nc:${envelope.ownerId}:${envelope.eventId}`,
63
+ url,
64
+ ownerId: envelope.ownerId,
65
+ eventId: envelope.eventId,
66
+ ...(askId ? { askId } : {}),
67
+ };
68
+ }
69
+
70
+ function createPushDedup({ filePath, now = Date.now } = {}) {
71
+ function load() {
72
+ try {
73
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
74
+ if (parsed && parsed.seen && typeof parsed.seen === 'object') return parsed;
75
+ } catch (_) { /* fresh */ }
76
+ return { seen: {} };
77
+ }
78
+ function seenBefore(owner, eventId) {
79
+ return load().seen[`${owner}:${eventId}`] !== undefined;
80
+ }
81
+ // Persist BEFORE the send: the crash window may lose an alert, never
82
+ // duplicate one.
83
+ function mark(owner, eventId) {
84
+ const state = load();
85
+ state.seen[`${owner}:${eventId}`] = now();
86
+ const cutoff = now() - DEDUP_TTL_MS;
87
+ for (const [k, t] of Object.entries(state.seen)) {
88
+ if (t < cutoff) delete state.seen[k];
89
+ }
90
+ const keys = Object.keys(state.seen);
91
+ if (keys.length > DEDUP_CAP) {
92
+ for (const k of keys.slice(0, keys.length - DEDUP_CAP)) delete state.seen[k];
93
+ }
94
+ try {
95
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
96
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
97
+ fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
98
+ fs.renameSync(tmp, filePath);
99
+ } catch (_) { return false; }
100
+ return true;
101
+ }
102
+ return { seenBefore, mark };
103
+ }
104
+
105
+ // `send` is the push sender call (pushService.sendToAll wrapped with the
106
+ // timeout). Everything else is queue + budget + counters.
107
+ function createPushRelay({ pushDedup, send, now = Date.now, sendTimeoutMs = SEND_TIMEOUT_MS, log = () => {} } = {}) {
108
+ const queue = [];
109
+ let queueBytes = 0;
110
+ let active = 0;
111
+ const cellWindow = new Map();
112
+ const clientWindow = [];
113
+ const counters = { pushed: 0, dropped: 0, sendFailed: 0, since: now() };
114
+ // Per-owner view of the same numbers, for the local observability surfaces:
115
+ // an operator asking about ONE peer should not read the fleet total.
116
+ const ownerCounters = new Map();
117
+ function forOwner(ownerId) {
118
+ const key = ownerId || 'unknown';
119
+ if (!ownerCounters.has(key)) {
120
+ ownerCounters.set(key, { pushed: 0, dropped: 0, sendFailed: 0, lastReason: null, since: counters.since });
121
+ }
122
+ return ownerCounters.get(key);
123
+ }
124
+
125
+ function budgetAllows(cellId) {
126
+ const t = now();
127
+ const cutoff = t - 60000;
128
+ const clean = (m) => { for (const [k, v] of m) { const kept = v.filter((x) => x > cutoff); if (kept.length) m.set(k, kept); else m.delete(k); } };
129
+ clean(cellWindow);
130
+ while (clientWindow.length && clientWindow[0] <= cutoff) clientWindow.shift();
131
+ const cellKey = cellId || 'node';
132
+ const perCell = (cellWindow.get(cellKey) || []);
133
+ if (perCell.length >= CELL_ALERTS_PER_MIN) return false;
134
+ if (clientWindow.length >= CLIENT_ALERTS_PER_MIN) return false;
135
+ perCell.push(t); cellWindow.set(cellKey, perCell);
136
+ clientWindow.push(t);
137
+ return true;
138
+ }
139
+
140
+ async function flush() {
141
+ while (active < MAX_CONCURRENT && queue.length > 0) {
142
+ const item = queue.shift();
143
+ queueBytes -= item.bytes;
144
+ active += 1;
145
+ try {
146
+ const out = await Promise.race([
147
+ send(item.payload),
148
+ new Promise((resolve) => setTimeout(() => resolve({ timeout: true }), sendTimeoutMs)),
149
+ ]);
150
+ const own = forOwner(item.payload && item.payload.ownerId);
151
+ if (out && out.timeout) {
152
+ counters.sendFailed += 1; own.sendFailed += 1; own.lastReason = 'send-timeout';
153
+ log('push-relay: send timeout');
154
+ } else if (out && out.sent > 0) { counters.pushed += out.sent; own.pushed += out.sent; }
155
+ else { counters.sendFailed += 1; own.sendFailed += 1; own.lastReason = 'send-failed'; }
156
+ } catch (_) {
157
+ counters.sendFailed += 1;
158
+ const own = forOwner(item.payload && item.payload.ownerId);
159
+ own.sendFailed += 1; own.lastReason = 'send-failed';
160
+ }
161
+ active -= 1;
162
+ }
163
+ }
164
+
165
+ // Consider ONE imported notify envelope. Returns false when nothing was
166
+ // sent (already known, filtered, out of budget or over the queue caps).
167
+ function consider(envelope) {
168
+ if (!envelope || envelope.frame && envelope.frame.type && envelope.frame.type !== 'notify') return false;
169
+ if (!envelope.eventId || !envelope.ownerId) return false;
170
+ if (pushDedup.seenBefore(envelope.ownerId, envelope.eventId)) return false;
171
+ const payload = buildPayload(envelope);
172
+ const bytes = Buffer.byteLength(JSON.stringify(payload), 'utf8');
173
+ if (bytes > PAYLOAD_MAX_BYTES) {
174
+ counters.dropped += 1; const own = forOwner(envelope.ownerId);
175
+ own.dropped += 1; own.lastReason = 'payload-oversize';
176
+ return false;
177
+ }
178
+ if (!budgetAllows(envelope.cellId)) {
179
+ counters.dropped += 1; const own = forOwner(envelope.ownerId);
180
+ own.dropped += 1; own.lastReason = 'alert-budget';
181
+ return false;
182
+ }
183
+ if (queue.length + 1 > QUEUE_MAX_ALERTS || queueBytes + bytes > QUEUE_MAX_BYTES) {
184
+ counters.dropped += queue.length;
185
+ const own = forOwner(envelope.ownerId);
186
+ own.dropped += queue.length; own.lastReason = 'queue-overflow';
187
+ queue.length = 0; queueBytes = 0;
188
+ return false; // overflow: the UI resyncs, never a partial alert flood
189
+ }
190
+ // Dedup is durable and BEFORE the send.
191
+ if (!pushDedup.mark(envelope.ownerId, envelope.eventId)) {
192
+ counters.dropped += 1; counters.lastReason = 'dedup-persist-failed';
193
+ const own = forOwner(envelope.ownerId);
194
+ own.dropped += 1; own.lastReason = 'dedup-persist-failed';
195
+ return false;
196
+ }
197
+ queue.push({ payload, bytes });
198
+ queueBytes += bytes;
199
+ void flush();
200
+ return true;
201
+ }
202
+
203
+ // The client server re-emits one flattened event per imported envelope into
204
+ // the local hub. That is the only place a NEW imported event exists on this
205
+ // side, so the relay is fed from there: an ask or a closure updates the UI
206
+ // silently (an ask's first alert travels with its correlated notify), and a
207
+ // state frame never rings.
208
+ function considerImported(event) {
209
+ if (!event || event.type !== 'notify') return false;
210
+ if (!event.ownerId || !event.eventId) return false;
211
+ return consider({
212
+ ownerId: event.ownerId,
213
+ eventId: event.eventId,
214
+ scope: event.originCell ? 'cell' : 'node',
215
+ cellId: event.originCell || null,
216
+ frame: {
217
+ type: 'notify', title: event.title, body: event.body, lang: event.lang,
218
+ ...(event.askId ? { askId: String(event.askId) } : {}),
219
+ },
220
+ });
221
+ }
222
+
223
+ // Local, read-only view: counters and reasons only — never an endpoint, a
224
+ // subscription or a key.
225
+ function status() {
226
+ return {
227
+ queued: queue.length, queueBytes, active,
228
+ counters: { ...counters },
229
+ byOwner: Object.fromEntries([...ownerCounters.entries()].map(([k, v]) => [k, {
230
+ queued: queue.filter((q) => (q.payload && q.payload.ownerId) === k).length,
231
+ pushed: v.pushed, dropped: v.dropped, sendFailed: v.sendFailed,
232
+ lastReason: v.lastReason, since: v.since,
233
+ }])),
234
+ };
235
+ }
236
+
237
+ return { consider, considerImported, status, buildPayload };
238
+ }
239
+
240
+ module.exports = { createPushRelay, createPushDedup, buildPayload, truncateUtf8 };
@@ -1,11 +1,11 @@
1
1
  'use strict';
2
- // Web Push per il MCP bridge (design §2b). Chiavi VAPID generate LAZY al primo
3
- // uso (mai allo startup: i test che creano il server con path isolati non devono
4
- // scrivere in ~/.nexuscrew) e persistite in <dir>/vapid.json 0600; subscription
5
- // in <dir>/push.json 0600, dedup per endpoint. Su push fallito 404/410 la
6
- // subscription viene rimossa (MA non in READONLY: il cleanup e' una scrittura).
7
- // Il sender e' iniettabile (webpushImpl) cosi' i test non toccano MAI la rete;
8
- // la chiave PRIVATA non esce da questo modulo.
2
+ // Web Push for the MCP bridge. VAPID keys generated LAZY on first use
3
+ // (never at startup: tests creating the server with isolated paths must not
4
+ // write to ~/.nexuscrew) and persisted in <dir>/vapid.json 0600; subscriptions
5
+ // in <dir>/push.json 0600, dedup by endpoint. On a failed push 404/410 the
6
+ // subscription is removed (NOT in READONLY: the cleanup is a write).
7
+ // The sender is injectable (webpushImpl) so tests NEVER touch the network;
8
+ // the PRIVATE key never leaves this module.
9
9
  //
10
10
  // da revisione (threat model SSRF): sendNotification fa una richiesta server-side.
11
11
  // Ogni endpoint e' https-only, viene risolto sia al subscribe sia immediatamente
@@ -185,7 +185,7 @@ function createPushService(opts = {}) {
185
185
  let vapid = null;
186
186
  function loadVapid() {
187
187
  if (vapid && vapid.publicKey && vapid.privateKey) return vapid;
188
- const cur = readJsonSafe(vapidPath); // fail-closed su mode/owner/symlink (F4)
188
+ const cur = readJsonSafe(vapidPath); // Fail-closed su mode/owner/symlink
189
189
  if (typeof cur.publicKey === 'string' && cur.publicKey
190
190
  && typeof cur.privateKey === 'string' && cur.privateKey) {
191
191
  vapid = { publicKey: cur.publicKey, privateKey: cur.privateKey };