@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,175 @@
1
+ 'use strict';
2
+ // lib/notify/event-feed-producers.js — the producer registry.
3
+ //
4
+ // Every local producer of events is registered here with a CLOSED schema: the
5
+ // frame keeps only the allowlisted fields, everything else is dropped BEFORE
6
+ // the envelope exists, so a runtime object can never smuggle a credential, a
7
+ // proof, a subscription, a config object or an absolute path into a frame.
8
+ // A producer type without a schema is a diagnostic error, not a pass-through:
9
+ // coverage is proved by tests, never by silent omission.
10
+
11
+ const crypto = require('node:crypto');
12
+
13
+ const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
14
+ const FRAME_MAX_BYTES = 16 * 1024;
15
+
16
+ function cleanStr(v, max) {
17
+ if (v === undefined || v === null) return undefined;
18
+ if (typeof v !== 'string') return undefined;
19
+ const s = v.slice(0, max);
20
+ return s.length > 0 ? s : undefined;
21
+ }
22
+
23
+ function cleanBool(v) { return v === true ? true : v === false ? false : undefined; }
24
+
25
+ function cleanLang(v) {
26
+ const s = cleanStr(v, 16);
27
+ return s && /^[a-z]{2}(-[A-Za-z0-9]{2,8})?$/i.test(s) ? s : undefined;
28
+ }
29
+
30
+ // One entry per producer: `scope` fixes the envelope scope, `build` projects
31
+ // the runtime payload onto the closed frame schema. Anything `build` does not
32
+ // recognize is dropped here, at the source — never filtered per peer later,
33
+ // because the ring must never have held it in the first place.
34
+ const PRODUCERS = {
35
+ // A notify bound to a Fleet cell (session resolved to a cell by the owner).
36
+ notify: {
37
+ scope: 'cell',
38
+ requires: ['cellId'],
39
+ build: (p) => ({
40
+ type: 'notify',
41
+ title: cleanStr(p.title, 200) || '',
42
+ ...(p.body !== undefined ? { body: cleanStr(p.body, 2000) || '' } : {}),
43
+ urgency: p.urgency === 'high' ? 'high' : 'normal',
44
+ ...(cleanLang(p.lang) ? { lang: cleanLang(p.lang) } : {}),
45
+ // Correlazione esplicita ask->notify: viaggia con il frame, cosi' il
46
+ // push importato puo' aprire la card giusta e non solo l'owner.
47
+ ...(cleanStr(p.askId, 32) ? { askId: cleanStr(p.askId, 32) } : {}),
48
+ ts: typeof p.ts === 'number' ? p.ts : undefined,
49
+ }),
50
+ },
51
+ // A notify from a local service without a cell: scope node, never a made-up
52
+ // cell ("not inventing a cell for it to pass").
53
+ 'notify-node': {
54
+ scope: 'node',
55
+ requires: [],
56
+ build: (p) => ({
57
+ type: 'notify',
58
+ title: cleanStr(p.title, 200) || '',
59
+ ...(p.body !== undefined ? { body: cleanStr(p.body, 2000) || '' } : {}),
60
+ urgency: p.urgency === 'high' ? 'high' : 'normal',
61
+ ...(cleanLang(p.lang) ? { lang: cleanLang(p.lang) } : {}),
62
+ ts: typeof p.ts === 'number' ? p.ts : undefined,
63
+ }),
64
+ },
65
+ // An ask: the frame carries the ask id and revision so the card and its
66
+ // later closure are the same object on every side.
67
+ ask: {
68
+ scope: 'cell',
69
+ requires: ['cellId'],
70
+ build: (p) => ({
71
+ type: 'ask',
72
+ askId: cleanStr(p.askId, 32) || '',
73
+ revision: typeof p.revision === 'number' ? p.revision : 0,
74
+ question: cleanStr(p.question, 3900) || '',
75
+ ...(Array.isArray(p.options) ? { options: p.options.slice(0, 8).map((o) => cleanStr(o, 200) || '') } : {}),
76
+ session: cleanStr(p.session, 128) || undefined,
77
+ ts: typeof p.ts === 'number' ? p.ts : undefined,
78
+ }),
79
+ },
80
+ // Closure of an ask: askId + cell survive the closure.
81
+ 'ask-closed': {
82
+ scope: 'cell',
83
+ requires: ['cellId'],
84
+ build: (p) => ({
85
+ type: 'ask-closed',
86
+ askId: cleanStr(p.askId, 32) || '',
87
+ revision: typeof p.revision === 'number' ? p.revision : 0,
88
+ outcome: p.outcome === 'answered' || p.outcome === 'dismissed' ? p.outcome : 'answered',
89
+ ts: typeof p.ts === 'number' ? p.ts : undefined,
90
+ }),
91
+ },
92
+ // A file notice: name/caption/session only. A file event is an alert — the
93
+ // download is a separate, separately authorized operation.
94
+ 'file-notice': {
95
+ scope: 'cell',
96
+ requires: ['cellId'],
97
+ build: (p) => ({
98
+ type: 'file-notice',
99
+ name: cleanStr(p.name, 200) || '',
100
+ ...(p.caption !== undefined ? { caption: cleanStr(p.caption, 300) || '' } : {}),
101
+ session: cleanStr(p.session, 128) || undefined,
102
+ ts: typeof p.ts === 'number' ? p.ts : undefined,
103
+ }),
104
+ },
105
+ // Fleet state: the OWNER projects the cell list per peer visibility BEFORE
106
+ // insertion; this frame only carries the allowlisted per-cell fields.
107
+ 'fleet-state': {
108
+ scope: 'node',
109
+ requires: [],
110
+ build: (p) => ({
111
+ type: 'fleet-state',
112
+ cells: Array.isArray(p.cells)
113
+ ? p.cells.slice(0, 128).map((c) => ({
114
+ cell: cleanStr(c && c.cell, 32) || '',
115
+ active: c && c.active === true,
116
+ })).filter((c) => c.cell !== '')
117
+ : [],
118
+ ts: typeof p.ts === 'number' ? p.ts : undefined,
119
+ }),
120
+ },
121
+ // Node-level service state, normalized (no absolute paths, no runtimes).
122
+ 'node-state': {
123
+ scope: 'node',
124
+ requires: [],
125
+ build: (p) => ({
126
+ type: 'node-state',
127
+ service: cleanStr(p.service, 64) || '',
128
+ state: p.state === 'up' || p.state === 'down' || p.state === 'degraded' ? p.state : 'unknown',
129
+ ts: typeof p.ts === 'number' ? p.ts : undefined,
130
+ }),
131
+ },
132
+ };
133
+
134
+ const PRODUCER_NAMES = Object.freeze(Object.keys(PRODUCERS));
135
+
136
+ // Build the v1 envelope. Throws a NAMED error for an unknown producer or a
137
+ // cell-scope producer without a resolved cell: silently re-scoping to node
138
+ // would let an unresolved cell masquerade as node traffic.
139
+ function buildEnvelope({ type, payload, ownerId, cellId = null, now = Date.now }) {
140
+ const producer = PRODUCERS[type];
141
+ if (!producer) {
142
+ const e = new Error(`unregistered event producer: "${type}"`);
143
+ e.reason = 'unregistered-producer';
144
+ throw e;
145
+ }
146
+ for (const req of producer.requires) {
147
+ if (req === 'cellId' && !(typeof cellId === 'string' && CELL_ID_RE.test(cellId))) {
148
+ const e = new Error(`producer "${type}" needs a resolved cell, got none`);
149
+ e.reason = 'cell-not-resolved';
150
+ throw e;
151
+ }
152
+ }
153
+ const frame = producer.build(payload || {});
154
+ if (frame.type === undefined) frame.type = frame.type; // closed schema already sets type
155
+ const envelope = {
156
+ v: 1,
157
+ ownerId: String(ownerId || ''),
158
+ eventId: crypto.randomUUID(),
159
+ scope: producer.scope,
160
+ cellId: producer.scope === 'cell' ? cellId : null,
161
+ hop: 1,
162
+ emittedAt: now(),
163
+ frame,
164
+ };
165
+ // The wire cap is checked HERE too: an envelope that cannot travel must not
166
+ // exist, not merely be dropped per peer.
167
+ if (Buffer.byteLength(JSON.stringify(envelope), 'utf8') > FRAME_MAX_BYTES) {
168
+ const e = new Error(`frame of producer "${type}" exceeds the wire cap`);
169
+ e.reason = 'frame-too-large';
170
+ throw e;
171
+ }
172
+ return envelope;
173
+ }
174
+
175
+ module.exports = { PRODUCERS, PRODUCER_NAMES, buildEnvelope, FRAME_MAX_BYTES };
@@ -0,0 +1,254 @@
1
+ 'use strict';
2
+ // lib/notify/event-feed-routes.js — the closed SSE/JSON surface of the event
3
+ // feed, mounted INSIDE the authenticated API.
4
+ //
5
+ // Gate order on every request: kill-switch → proven federated origin (hop
6
+ // proof + trust) → hop chain EXACTLY [clientId, ownerId] → peer known in the
7
+ // store → class grants. There is NO "no hop means local" fallback: a local
8
+ // Bearer alone never opens this surface, because the feed is by definition a
9
+ // per-peer view and a local caller is not a peer.
10
+ //
11
+ // Ask ACTION routes are a later stage by design: the class exists in the
12
+ // classifier, the routes do not exist here yet.
13
+
14
+ const express = require('express');
15
+ const nodesStore = require('../nodes/store.js');
16
+ const eventFeedAcl = require('./event-feed-acl.js');
17
+
18
+ const SNAPSHOT_MAX_BYTES = 3 * 1024 * 1024;
19
+ const SNAPSHOT_MAX_ASKS = 100;
20
+ const SNAPSHOT_MAX_NOTIFY = 50;
21
+ const SNAPSHOT_MAX_CELLS = 1000;
22
+ const SNAPSHOT_RATE_PER_MIN = 6;
23
+ const SNAPSHOT_TIMEOUT_MS = 30000;
24
+
25
+ function parseCursor(raw) {
26
+ if (raw === undefined || raw === null || raw === '') return { cursor: null };
27
+ const m = /^([0-9]{1,12}):([0-9]{1,12})$/.exec(String(raw));
28
+ if (!m) return { error: 'bad-cursor' };
29
+ return { cursor: { epoch: Number(m[1]), seq: Number(m[2]) } };
30
+ }
31
+
32
+ // The feed gate, shared by EVERY /event-feed/* surface (stream, snapshot and
33
+ // the federated ask routes): kill-switch -> proven federated origin -> exact
34
+ // [clientId, ownerId] hop chain -> peer known in the store -> eventsAccess.
35
+ // The ask routes add askReplyAccess + the ask's own cell scope on top, they do
36
+ // NOT replace any of these steps.
37
+ function createFeedGate(deps) {
38
+ const log = typeof deps.log === 'function' ? deps.log : () => {};
39
+ return async function gate(req, res) {
40
+ if (deps.eventsEnabled() !== true) {
41
+ res.status(403).json({ error: 'event feed disabled on this node', reason: 'events-disabled' });
42
+ return null;
43
+ }
44
+ const resolved = await deps.originResolver.resolve(req, { requireCell: false });
45
+ if (!resolved.ok) {
46
+ res.status(403).json({ error: 'forbidden', reason: resolved.reason });
47
+ return null;
48
+ }
49
+ // No hop, no feed. A local Bearer is not a peer identity.
50
+ if (resolved.trust !== 'federated') {
51
+ res.status(403).json({ error: 'forbidden', reason: 'federated-origin-required' });
52
+ return null;
53
+ }
54
+ const self = deps.localNodeId();
55
+ const visited = Array.isArray(resolved.visited) ? resolved.visited : [];
56
+ // Direct peers only, exact chain: [clientId, ownerId]. A longer chain is
57
+ // a relay and is not traversed.
58
+ if (!self || visited.length !== 2 || visited[1] !== self) {
59
+ res.status(403).json({ error: 'forbidden', reason: 'hop-chain' });
60
+ return null;
61
+ }
62
+ const store = nodesStore.loadStore(deps.nodesPath);
63
+ if (!store) { res.status(503).json({ error: 'node store unavailable' }); return null; }
64
+ const peer = eventFeedAcl.resolvePeer(store, visited[0]);
65
+ if (!peer) { res.status(403).json({ error: 'forbidden', reason: 'peer-unknown' }); return null; }
66
+ // Grant re-read BEFORE anything else in the request: the gate above is a
67
+ // snapshot of a store read made a line earlier — good enough only because
68
+ // it IS the fresh read.
69
+ if (peer.grants.eventsAccess !== true) {
70
+ res.status(403).json({ error: 'risorsa non concessa a questo nodo', reason: 'grant-required:event-feed' });
71
+ return null;
72
+ }
73
+ if (deps.eventFeed) {
74
+ try {
75
+ if (!deps.eventFeed.status(peer.nodeId)) deps.eventFeed.enablePeer(peer.nodeId);
76
+ } catch (e) {
77
+ res.status(429).json({ error: String(e.message || e), reason: e.reason || 'peer-budget' });
78
+ return null;
79
+ }
80
+ }
81
+ return { peer, self, store };
82
+ };
83
+ }
84
+
85
+ function createEventFeedRoutes(deps) {
86
+ const log = typeof deps.log === 'function' ? deps.log : () => {};
87
+ const r = express.Router();
88
+ // deps: { nodesPath, localNodeId, originResolver, eventFeed, history,
89
+ // asksStore, fleetP, eventsEnabled, log, cellForSession }
90
+ const gate = createFeedGate(deps);
91
+
92
+ // Snapshot rate, per peer, its own budget (never the notify/audio one).
93
+ const snapshotOpens = new Map(); // nodeId -> [timestamps]
94
+
95
+ r.get('/', async (req, res) => {
96
+ const g = await gate(req, res);
97
+ if (!g) return;
98
+ const parsed = parseCursor(req.query.after);
99
+ if (parsed.error) return res.status(400).json({ error: 'bad cursor', reason: parsed.error });
100
+ const result = deps.eventFeed.subscribe(g.peer.nodeId, res, parsed.cursor);
101
+ if (!result.ok) {
102
+ if (!res.headersSent) return res.status(result.status).json({ error: 'cursor refused', reason: result.reason });
103
+ return;
104
+ }
105
+ log(`event-feed: stream open for peer ${g.peer.nodeId} at ${g.peer.grants.signature ? '' : ''}${result.epoch}:${result.seq}`);
106
+ });
107
+
108
+ r.get('/snapshot', async (req, res) => {
109
+ const g = await gate(req, res);
110
+ if (!g) return;
111
+ const cut = Date.now() - 60000;
112
+ const opens = (snapshotOpens.get(g.peer.nodeId) || []).filter((t) => t > cut);
113
+ if (opens.length >= SNAPSHOT_RATE_PER_MIN) {
114
+ return res.status(429).json({ error: 'snapshot rate', reason: 'snapshot-rate' });
115
+ }
116
+ opens.push(Date.now());
117
+ snapshotOpens.set(g.peer.nodeId, opens);
118
+
119
+ const timeout = new Promise((resolve) => setTimeout(() => resolve('timeout'), SNAPSHOT_TIMEOUT_MS));
120
+ const work = buildSnapshot(g);
121
+ const built = await Promise.race([work, timeout]);
122
+ if (built === 'timeout') {
123
+ return res.status(503).json({ error: 'snapshot timeout', reason: 'snapshot-timeout' });
124
+ }
125
+ // A build can wait up to 30 s on the fleet: the kill switch and the grants
126
+ // are RE-READ after it, so a revocation during the build wins over the
127
+ // response (stale content never ships).
128
+ if (deps.eventsEnabled() !== true) {
129
+ return res.status(403).json({ error: 'event feed disabled on this node', reason: 'events-disabled' });
130
+ }
131
+ const fresh = await gate(req, res);
132
+ if (!fresh) return;
133
+ const body = Buffer.from(JSON.stringify(built), 'utf8');
134
+ if (body.length > SNAPSHOT_MAX_BYTES) {
135
+ // An over-budget snapshot is declared, never shipped as a "complete"
136
+ // partial.
137
+ return res.status(409).json({ error: 'snapshot over budget', reason: 'resync-required' });
138
+ }
139
+ res.set('Cache-Control', 'no-store');
140
+ return res.json(built);
141
+ });
142
+
143
+ // Cell-scoped stream: same grants, one cell, still per-peer view.
144
+ r.get('/:cellId', async (req, res) => {
145
+ const g = await gate(req, res);
146
+ if (!g) return;
147
+ const cellId = String(req.params.cellId || '');
148
+ if (!g.peer.allows({ scope: 'cell', cellId })) {
149
+ return res.status(403).json({ error: 'risorsa non concessa a questo nodo', reason: 'grant-required:event-feed' });
150
+ }
151
+ const parsed = parseCursor(req.query.after);
152
+ if (parsed.error) return res.status(400).json({ error: 'bad cursor', reason: parsed.error });
153
+ const result = deps.eventFeed.subscribe(g.peer.nodeId, res, parsed.cursor, {
154
+ filter: (entry) => entry.envelope.scope === 'cell' && entry.envelope.cellId === cellId,
155
+ });
156
+ if (!result.ok) {
157
+ if (!res.headersSent) return res.status(result.status).json({ error: 'cursor refused', reason: result.reason });
158
+ return;
159
+ }
160
+ });
161
+
162
+ // Node-scoped stream: needs nodeEventsAccess; the node id must be THIS node.
163
+ r.get('/node/:nodeId', async (req, res) => {
164
+ const g = await gate(req, res);
165
+ if (!g) return;
166
+ if (String(req.params.nodeId || '') !== g.self) {
167
+ return res.status(403).json({ error: 'forbidden', reason: 'wrong-node' });
168
+ }
169
+ if (g.peer.grants.nodeEventsAccess !== true) {
170
+ return res.status(403).json({ error: 'risorsa non concessa a questo nodo', reason: 'grant-required:node-event-feed' });
171
+ }
172
+ const parsed = parseCursor(req.query.after);
173
+ if (parsed.error) return res.status(400).json({ error: 'bad cursor', reason: parsed.error });
174
+ const result = deps.eventFeed.subscribe(g.peer.nodeId, res, parsed.cursor, {
175
+ filter: (entry) => entry.envelope.scope === 'node',
176
+ });
177
+ if (!result.ok) {
178
+ if (!res.headersSent) return res.status(result.status).json({ error: 'cursor refused', reason: result.reason });
179
+ return;
180
+ }
181
+ });
182
+
183
+ async function buildSnapshot({ peer, self }) {
184
+ // Cursor captured atomically with the snapshot content: the feed engine is
185
+ // synchronous, so one turn later nothing has been inserted in between.
186
+ const st = deps.eventFeed.status(peer.nodeId);
187
+ const cursor = st ? `${st.viewEpoch}:${st.seq}` : null;
188
+ const viewEpoch = st ? st.viewEpoch : null;
189
+
190
+ // Asks: open asks of VISIBLE cells only, capped.
191
+ let asks = [];
192
+ try {
193
+ const open = deps.asksStore.list({ open: true }) || [];
194
+ const visible = open
195
+ .map((a) => ({ id: a.id, question: a.question, options: a.options, session: a.session, ts: a.ts }))
196
+ .filter((a) => peer.allows({ scope: 'cell', cellId: deps.cellForSession(a.session) }))
197
+ .slice(0, SNAPSHOT_MAX_ASKS);
198
+ asks = visible;
199
+ } catch (_) { asks = []; }
200
+
201
+ // Notify history: local-only, ACL-filtered per envelope, capped.
202
+ const notifications = deps.history.list()
203
+ .filter((e) => peer.allows(e.envelope))
204
+ .map((e) => {
205
+ // Node frames can carry cell lists (fleet state): project them with
206
+ // the same visibility rule, so history never leaks hidden cells.
207
+ if (e.envelope.scope === 'node' && Array.isArray(e.envelope.frame && e.envelope.frame.cells)) {
208
+ const cells = peer.projectCells(e.envelope.frame.cells);
209
+ return { ...e, envelope: { ...e.envelope, frame: { ...e.envelope.frame, cells } } };
210
+ }
211
+ return e;
212
+ })
213
+ .slice(-SNAPSHOT_MAX_NOTIFY);
214
+
215
+ // Fleet state: projected by visibility, hidden cells contribute nothing.
216
+ let fleetState = { available: false, cells: [] };
217
+ try {
218
+ const fleet = await deps.fleetP;
219
+ if (fleet && typeof fleet.status === 'function') {
220
+ const stt = await fleet.status();
221
+ const raw = (stt && Array.isArray(stt.cells) ? stt.cells : [])
222
+ .slice(0, SNAPSHOT_MAX_CELLS)
223
+ .map((c) => ({ cell: c && c.cell, active: c && c.active === true }));
224
+ fleetState = { available: stt && stt.available === true, cells: peer.projectCells(raw) };
225
+ }
226
+ } catch (_) { /* fleet unavailable: declared, not faked */ }
227
+
228
+ const built = {
229
+ v: 1,
230
+ ownerId: self,
231
+ peerId: peer.nodeId,
232
+ viewEpoch,
233
+ cursor,
234
+ // Whether THIS peer may answer/dismiss the owner's asks: the UI gates
235
+ // the remote reply affordance on it (read-only otherwise).
236
+ askReplyAccess: peer.grants.askReplyAccess === true,
237
+ asks,
238
+ notifications: notifications.map((e) => e.envelope),
239
+ fleetState,
240
+ nodeState: { nodeId: self },
241
+ historyStatus: deps.history.status(),
242
+ };
243
+ if (asks.length >= SNAPSHOT_MAX_ASKS
244
+ || notifications.length >= SNAPSHOT_MAX_NOTIFY
245
+ || (fleetState.cells || []).length >= SNAPSHOT_MAX_CELLS) {
246
+ built.resyncRequired = true; // at a cap: the client must treat this as a floor, not a whole
247
+ }
248
+ return built;
249
+ }
250
+
251
+ return r;
252
+ }
253
+
254
+ module.exports = { createEventFeedRoutes, parseCursor, createFeedGate };