@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.
- package/CHANGELOG.md +16 -0
- package/docs/LIVE_PROMPT.md +1 -1
- package/docs/NOTIFICATIONS.md +37 -0
- package/docs/OPERATIONS.md +20 -0
- package/frontend/dist/assets/index-BbcjoZS0.js +167 -0
- package/frontend/dist/assets/index-DqCfZFGi.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +91 -10
- package/frontend/dist/version.json +1 -1
- package/lib/audio/groups.js +1 -1
- package/lib/auth/token.js +4 -4
- package/lib/cli/commands.js +70 -15
- package/lib/cli/doctor.js +5 -5
- package/lib/cli/fleet-service.js +7 -7
- package/lib/cli/init.js +55 -31
- package/lib/cli/pidfile.js +1 -1
- package/lib/cli/service.js +124 -8
- package/lib/cli/stable-alias.js +4 -4
- package/lib/cli/url.js +1 -1
- package/lib/config.js +36 -6
- package/lib/files/routes.js +1 -1
- package/lib/fleet/boot.js +2 -2
- package/lib/fleet/builtin.js +15 -15
- package/lib/fleet/causes.js +1 -1
- package/lib/fleet/cell-exec.js +23 -23
- package/lib/fleet/cell-hold.js +6 -6
- package/lib/fleet/cell-lease-server.js +106 -106
- package/lib/fleet/cell-lease.js +21 -21
- package/lib/fleet/definitions.js +10 -10
- package/lib/fleet/identity-authority.js +1 -1
- package/lib/fleet/launch-broker.js +13 -13
- package/lib/fleet/launch.js +15 -15
- package/lib/fleet/lease-client.js +23 -23
- package/lib/fleet/lease-routes.js +3 -3
- package/lib/fleet/lease-verifier.js +22 -22
- package/lib/fleet/managed.js +35 -35
- package/lib/fleet/prompt-delivery.js +13 -13
- package/lib/fleet/routes.js +15 -10
- package/lib/fleet/runtime.js +24 -24
- package/lib/live-host/bridge.js +53 -43
- package/lib/live-host/federation.js +97 -0
- package/lib/live-host/routes.js +166 -8
- package/lib/live-host/store.js +44 -6
- package/lib/mcp/server.js +14 -14
- package/lib/mcp/tools.js +5 -5
- package/lib/nodes/access-presets.js +258 -0
- package/lib/nodes/commands.js +198 -4
- package/lib/nodes/store.js +152 -9
- package/lib/nodes/tunnel.js +7 -7
- package/lib/notify/ask-answer-service.js +184 -0
- package/lib/notify/ask-receipts.js +197 -0
- package/lib/notify/ask-relay.js +152 -0
- package/lib/notify/asks.js +28 -3
- package/lib/notify/event-feed-acl.js +95 -0
- package/lib/notify/event-feed-asks-routes.js +144 -0
- package/lib/notify/event-feed-client.js +434 -0
- package/lib/notify/event-feed-history.js +115 -0
- package/lib/notify/event-feed-producers.js +175 -0
- package/lib/notify/event-feed-routes.js +254 -0
- package/lib/notify/event-feed.js +328 -0
- package/lib/notify/events.js +1 -1
- package/lib/notify/notifier.js +3 -3
- package/lib/notify/push-relay.js +240 -0
- package/lib/notify/push.js +8 -8
- package/lib/notify/routes.js +62 -23
- package/lib/proxy/federation.js +132 -12
- package/lib/proxy/node-proxy.js +13 -13
- package/lib/proxy/panel-auth.js +4 -4
- package/lib/proxy/panel-proxy.js +2 -2
- package/lib/proxy/resource-acl.js +145 -0
- package/lib/server.js +396 -40
- package/lib/settings/pairing-coordinator.js +1 -1
- package/lib/settings/routes.js +45 -32
- package/lib/update/manager.js +1 -1
- package/lib/update/runner.js +1 -1
- package/lib/voice/transcribe.js +1 -1
- package/lib/ws/bridge.js +3 -3
- package/package.json +1 -1
- package/skills/live/SKILL.md +12 -0
- package/frontend/dist/assets/index-CWra6B79.js +0 -167
- package/frontend/dist/assets/index-D6CIfCjv.css +0 -32
|
@@ -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
|
+
};
|
package/lib/notify/events.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
//
|
|
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.
|
package/lib/notify/notifier.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
//
|
|
3
|
-
// web-push.
|
|
4
|
-
//
|
|
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 };
|
package/lib/notify/push.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// Web Push
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// in <dir>/push.json 0600, dedup
|
|
6
|
-
// subscription
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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); //
|
|
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 };
|