@mmmbuto/nexuscrew 0.9.28 → 0.9.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/docs/LIVE_PROMPT.md +1 -1
  3. package/docs/NOTIFICATIONS.md +37 -0
  4. package/docs/OPERATIONS.md +20 -0
  5. package/frontend/dist/assets/index-BbcjoZS0.js +167 -0
  6. package/frontend/dist/assets/index-DqCfZFGi.css +32 -0
  7. package/frontend/dist/index.html +2 -2
  8. package/frontend/dist/sw.js +91 -10
  9. package/frontend/dist/version.json +1 -1
  10. package/lib/audio/groups.js +1 -1
  11. package/lib/auth/token.js +4 -4
  12. package/lib/cli/commands.js +70 -15
  13. package/lib/cli/doctor.js +5 -5
  14. package/lib/cli/fleet-service.js +7 -7
  15. package/lib/cli/init.js +55 -31
  16. package/lib/cli/pidfile.js +1 -1
  17. package/lib/cli/service.js +124 -8
  18. package/lib/cli/stable-alias.js +4 -4
  19. package/lib/cli/url.js +1 -1
  20. package/lib/config.js +36 -6
  21. package/lib/files/routes.js +1 -1
  22. package/lib/fleet/boot.js +2 -2
  23. package/lib/fleet/builtin.js +15 -15
  24. package/lib/fleet/causes.js +1 -1
  25. package/lib/fleet/cell-exec.js +23 -23
  26. package/lib/fleet/cell-hold.js +6 -6
  27. package/lib/fleet/cell-lease-server.js +106 -106
  28. package/lib/fleet/cell-lease.js +21 -21
  29. package/lib/fleet/definitions.js +10 -10
  30. package/lib/fleet/identity-authority.js +1 -1
  31. package/lib/fleet/launch-broker.js +13 -13
  32. package/lib/fleet/launch.js +15 -15
  33. package/lib/fleet/lease-client.js +23 -23
  34. package/lib/fleet/lease-routes.js +3 -3
  35. package/lib/fleet/lease-verifier.js +22 -22
  36. package/lib/fleet/managed.js +35 -35
  37. package/lib/fleet/prompt-delivery.js +13 -13
  38. package/lib/fleet/routes.js +15 -10
  39. package/lib/fleet/runtime.js +24 -24
  40. package/lib/live-host/bridge.js +53 -43
  41. package/lib/live-host/federation.js +97 -0
  42. package/lib/live-host/routes.js +166 -8
  43. package/lib/live-host/store.js +44 -6
  44. package/lib/mcp/server.js +14 -14
  45. package/lib/mcp/tools.js +5 -5
  46. package/lib/nodes/access-presets.js +258 -0
  47. package/lib/nodes/commands.js +198 -4
  48. package/lib/nodes/store.js +152 -9
  49. package/lib/nodes/tunnel.js +7 -7
  50. package/lib/notify/ask-answer-service.js +184 -0
  51. package/lib/notify/ask-receipts.js +197 -0
  52. package/lib/notify/ask-relay.js +152 -0
  53. package/lib/notify/asks.js +28 -3
  54. package/lib/notify/event-feed-acl.js +95 -0
  55. package/lib/notify/event-feed-asks-routes.js +144 -0
  56. package/lib/notify/event-feed-client.js +434 -0
  57. package/lib/notify/event-feed-history.js +115 -0
  58. package/lib/notify/event-feed-producers.js +175 -0
  59. package/lib/notify/event-feed-routes.js +254 -0
  60. package/lib/notify/event-feed.js +328 -0
  61. package/lib/notify/events.js +1 -1
  62. package/lib/notify/notifier.js +3 -3
  63. package/lib/notify/push-relay.js +240 -0
  64. package/lib/notify/push.js +8 -8
  65. package/lib/notify/routes.js +62 -23
  66. package/lib/proxy/federation.js +132 -12
  67. package/lib/proxy/node-proxy.js +13 -13
  68. package/lib/proxy/panel-auth.js +4 -4
  69. package/lib/proxy/panel-proxy.js +2 -2
  70. package/lib/proxy/resource-acl.js +145 -0
  71. package/lib/server.js +396 -40
  72. package/lib/settings/pairing-coordinator.js +1 -1
  73. package/lib/settings/routes.js +45 -32
  74. package/lib/update/manager.js +1 -1
  75. package/lib/update/runner.js +1 -1
  76. package/lib/voice/transcribe.js +1 -1
  77. package/lib/ws/bridge.js +3 -3
  78. package/package.json +1 -1
  79. package/skills/live/SKILL.md +12 -0
  80. package/frontend/dist/assets/index-CWra6B79.js +0 -167
  81. package/frontend/dist/assets/index-D6CIfCjv.css +0 -32
@@ -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 };
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+ // lib/notify/event-feed-history.js — durable store of LOCAL notifies only.
3
+ //
4
+ // It exists so an owner restart can still answer a snapshot with the recent
5
+ // notify past: the ring is memory and dies with the process, the asks store is
6
+ // durable, and this file is the third leg. Imported events NEVER enter it —
7
+ // the writer refuses anything that did not originate here — and the file is
8
+ // 0600 atomic-write, loaded back only after schema/size/retention validation.
9
+ // Persistence failure never blocks a local notify: it degrades the history and
10
+ // the degradation is visible in the snapshot status.
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+
15
+ const MAX_ENTRIES = 50;
16
+ const MAX_BYTES = 1024 * 1024;
17
+ const MAX_MS = 15 * 60 * 1000;
18
+ const SCHEMA = 'nexuscrew-event-feed-history-v1';
19
+
20
+ function createEventFeedHistory({ filePath, now = Date.now, limits = {} }) {
21
+ const maxEntries = limits.maxEntries || MAX_ENTRIES;
22
+ const maxBytes = limits.maxBytes || MAX_BYTES;
23
+ const maxMs = limits.maxMs || MAX_MS;
24
+
25
+ let cache = null; // validated entries, oldest first
26
+ let degraded = null; // null | reason string
27
+ let writeChain = Promise.resolve();
28
+
29
+ function validateList(raw) {
30
+ if (!Array.isArray(raw)) return null;
31
+ const out = [];
32
+ for (const entry of raw) {
33
+ if (!entry || typeof entry !== 'object') return null;
34
+ if (entry.origin !== 'local') return null; // imported events are never durable here
35
+ if (typeof entry.eventId !== 'string' || entry.eventId.length === 0) return null;
36
+ if (!entry.envelope || typeof entry.envelope !== 'object') return null;
37
+ if (typeof entry.at !== 'number') return null;
38
+ out.push(entry);
39
+ }
40
+ return out;
41
+ }
42
+
43
+ function load() {
44
+ if (cache) return cache;
45
+ try {
46
+ const raw = fs.readFileSync(filePath, 'utf8');
47
+ if (Buffer.byteLength(raw, 'utf8') > maxBytes * 4) {
48
+ degraded = 'history-oversize';
49
+ cache = [];
50
+ return cache;
51
+ }
52
+ const parsed = JSON.parse(raw);
53
+ const entries = validateList(parsed && parsed.entries);
54
+ if (!entries) { degraded = 'history-schema'; cache = []; return cache; }
55
+ const cutoff = now() - maxMs;
56
+ cache = entries.filter((e) => e.at >= cutoff);
57
+ } catch (_) {
58
+ // Missing file is a fresh install, not a degradation.
59
+ cache = [];
60
+ }
61
+ return cache;
62
+ }
63
+
64
+ function serialize(entries) {
65
+ return JSON.stringify({ schema: SCHEMA, savedAt: now(), entries });
66
+ }
67
+
68
+ function persist(entries) {
69
+ // Serialized writer: generations never overwrite newer data with older
70
+ // flushes. Atomic tmp+rename, 0600.
71
+ writeChain = writeChain.then(() => new Promise((resolve) => {
72
+ try {
73
+ const body = serialize(entries);
74
+ if (Buffer.byteLength(body, 'utf8') > maxBytes) {
75
+ degraded = 'history-oversize';
76
+ resolve();
77
+ return;
78
+ }
79
+ const dir = path.dirname(filePath);
80
+ fs.mkdirSync(dir, { recursive: true });
81
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
82
+ fs.writeFileSync(tmp, body, { mode: 0o600 });
83
+ fs.renameSync(tmp, filePath);
84
+ } catch (_) {
85
+ degraded = 'history-write-failed';
86
+ }
87
+ resolve();
88
+ }));
89
+ return writeChain;
90
+ }
91
+
92
+ // Record ONE local envelope. Refuses imported entries structurally: the
93
+ // origin flag is part of the entry, not a per-call courtesy.
94
+ function record(envelope) {
95
+ const entries = load();
96
+ const entry = { origin: 'local', eventId: envelope.eventId, at: now(), envelope };
97
+ entries.push(entry);
98
+ while (entries.length > maxEntries) entries.shift();
99
+ const cutoff = now() - maxMs;
100
+ while (entries.length > 0 && entries[0].at < cutoff) entries.shift();
101
+ cache = entries;
102
+ return persist(entries);
103
+ }
104
+
105
+ function list() { return load().slice(); }
106
+
107
+ function status() {
108
+ load();
109
+ return { degraded: degraded || null, count: cache.length, origin: 'local-only' };
110
+ }
111
+
112
+ return { record, list, status };
113
+ }
114
+
115
+ module.exports = { createEventFeedHistory, MAX_ENTRIES, MAX_BYTES, MAX_MS };