@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,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 };
|