@mmmbuto/nexuscrew 0.9.63 → 0.9.65
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 +14 -0
- package/README.md +9 -21
- package/docs/NOTIFICATIONS.md +34 -0
- package/docs/SECURITY.md +13 -12
- package/frontend/dist/assets/index-DkYRC1vV.js +169 -0
- package/frontend/dist/assets/{index-D1cTtNED.css → index-Dwd37Z_R.css} +2 -2
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cells/routes.js +37 -3
- package/lib/cli/commands.js +1 -1
- package/lib/config.js +1 -1
- package/lib/fleet/builtin.js +3 -0
- package/lib/fleet/cell-lease-server.js +1 -1
- package/lib/fleet/cell-lease.js +1 -1
- package/lib/fleet/lease-client.js +1 -1
- package/lib/fleet/lease-verifier.js +1 -1
- package/lib/live-host/attest.js +39 -0
- package/lib/live-host/bridge.js +81 -21
- package/lib/live-host/entry.js +49 -0
- package/lib/live-host/registry.js +111 -0
- package/lib/live-host/reserved.js +12 -0
- package/lib/mcp/cells.js +37 -0
- package/lib/mcp/server.js +7 -1
- package/lib/mcp/tools.js +7 -1
- package/lib/notify/ask-relay.js +36 -166
- package/lib/notify/event-feed-asks-routes.js +18 -3
- package/lib/notify/event-feed-client.js +85 -10
- package/lib/notify/event-feed-notices-routes.js +135 -0
- package/lib/notify/event-feed-producers.js +21 -0
- package/lib/notify/event-feed-routes.js +30 -11
- package/lib/notify/notice-dismissal-queue.js +336 -0
- package/lib/notify/notice-dismissals.js +178 -0
- package/lib/notify/notice-relay.js +128 -0
- package/lib/notify/owner-channel.js +172 -0
- package/lib/proxy/federation.js +9 -0
- package/lib/proxy/resource-acl.js +6 -0
- package/lib/server.js +229 -12
- package/lib/tmux/actions.js +1 -1
- package/package.json +2 -2
- package/frontend/dist/assets/index-maPH0v_e.js +0 -169
package/lib/notify/ask-relay.js
CHANGED
|
@@ -14,10 +14,9 @@
|
|
|
14
14
|
// refused locally until verification resolves the attempt.
|
|
15
15
|
|
|
16
16
|
const crypto = require('node:crypto');
|
|
17
|
-
const { NODE_NAME_RE } = require('../nodes/store.js');
|
|
18
17
|
const { probeReverseSlot } = require('../nodes/reverse-slot-proof.js');
|
|
19
|
-
const { askSlotVerifier
|
|
20
|
-
const
|
|
18
|
+
const { askSlotVerifier } = require('../nodes/ask-slot-verifier.js');
|
|
19
|
+
const { createOwnerChannel } = require('./owner-channel.js');
|
|
21
20
|
const OWNER_REASONS = new Set([
|
|
22
21
|
'reverse-slot-unverified', 'peer-unknown', 'events-disabled', 'grant-required:ask-action',
|
|
23
22
|
'peer-visibility', 'origin-visibility', 'federated-origin-required', 'hop-chain',
|
|
@@ -37,148 +36,19 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
37
36
|
const attempts = new Map();
|
|
38
37
|
const answerFlights = new Map();
|
|
39
38
|
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
let rejectAbort;
|
|
52
|
-
const aborted = new Promise((_resolve, reject) => { rejectAbort = () => reject(new Error('relay deadline')); });
|
|
53
|
-
controller.signal.addEventListener('abort', rejectAbort, { once: true });
|
|
54
|
-
op.active = () => !controller.signal.aborted;
|
|
55
|
-
op.check = () => { if (!op.active()) throw new Error('relay deadline'); };
|
|
56
|
-
op.wait = promise => Promise.race([promise, aborted]);
|
|
57
|
-
const timer = setTimeout(() => { op.expired = true; controller.abort(); }, RELAY_DEADLINE_MS);
|
|
58
|
-
try { return await Promise.race([Promise.resolve().then(() => work(op)), aborted]); }
|
|
59
|
-
catch (_) {
|
|
60
|
-
slotVerifier.invalidate(op.proofKey);
|
|
61
|
-
if (op.answer && op.delivered && args.rid) {
|
|
62
|
-
const entry = attempts.get(attemptKey(args.ownerId, args.askId, args.rid));
|
|
63
|
-
if (entry) entry.state = 'uncertain';
|
|
64
|
-
}
|
|
65
|
-
if (op.expired) return deadlineResult(op, args, capability);
|
|
66
|
-
return capability
|
|
67
|
-
? { ownerId: args.ownerId, askId: args.askId, canReply: false, status: 'unreachable' }
|
|
68
|
-
: { ok: false, code: 502, error: 'owner unreachable' };
|
|
69
|
-
} finally {
|
|
70
|
-
clearTimeout(timer); controller.signal.removeEventListener('abort', rejectAbort); controller.abort();
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
39
|
+
const attemptKey = (ownerId, askId, rid) => `${ownerId}|${askId}|${rid}`;
|
|
40
|
+
// The channel to the owner (resolution, deadline, forwarding, hop proof) is
|
|
41
|
+
// SHARED with the notice dismissals: one implementation, one chain rule.
|
|
42
|
+
const channel = createOwnerChannel({
|
|
43
|
+
loadStore, now, fetchImpl, probeReverseSlotImpl, peers, localPort, localToken, nodesPath, slotVerifier,
|
|
44
|
+
sanitizeReason: safeReason,
|
|
45
|
+
onUncertain: (args) => {
|
|
46
|
+
const entry = attempts.get(attemptKey(args.ownerId, args.askId, args.rid));
|
|
47
|
+
if (entry) entry.state = 'uncertain';
|
|
48
|
+
},
|
|
49
|
+
});
|
|
74
50
|
function requestId() { return crypto.randomUUID(); }
|
|
75
51
|
|
|
76
|
-
// Risoluzione del produttore: peer OUTBOUND (canale proprio, come sempre)
|
|
77
|
-
// oppure peer INBOUND condiviso con consenso eventi+risposta e slot reverse
|
|
78
|
-
// attivo. Per gli inbound la porta dello slot DEVE essere verificata con la
|
|
79
|
-
// stessa sonda dei terminali (secret del peer + instanceId atteso): nessun
|
|
80
|
-
// fallback sulla localPort senza proof.
|
|
81
|
-
async function candidateOwner(ownerId, op) {
|
|
82
|
-
let store = loadStore();
|
|
83
|
-
if (!store) return null;
|
|
84
|
-
let routed = null;
|
|
85
|
-
if (typeof peers === 'function') {
|
|
86
|
-
let inventory;
|
|
87
|
-
try { inventory = await op.wait(peers()); } catch (_) { op.check(); return null; }
|
|
88
|
-
op.check();
|
|
89
|
-
const matches = (Array.isArray(inventory) ? inventory : [])
|
|
90
|
-
.filter((p) => p && (p.nodeId === ownerId || p.instanceId === ownerId));
|
|
91
|
-
if (matches.length !== 1 || matches[0].stale === true) return null;
|
|
92
|
-
const route = matches[0].route;
|
|
93
|
-
if (!Array.isArray(route) || !route.length || route.length > 4
|
|
94
|
-
|| route.some((name) => typeof name !== 'string' || !NODE_NAME_RE.test(name))
|
|
95
|
-
|| new Set(route).size !== route.length) return null;
|
|
96
|
-
routed = [...route];
|
|
97
|
-
}
|
|
98
|
-
op.check();
|
|
99
|
-
store = loadStore();
|
|
100
|
-
if (!store) return null;
|
|
101
|
-
slotVerifier.sync(store, proofScope);
|
|
102
|
-
const nodes = store.nodes || [];
|
|
103
|
-
const direct = nodes.filter((n) => n && n.nodeId === ownerId);
|
|
104
|
-
if (direct.length > 1) return null;
|
|
105
|
-
const out = direct.find((n) => n.direction === 'outbound' && n.token && n.localPort);
|
|
106
|
-
if (out) return { node: out, forwardPort: out.localPort, forwardToken: out.token, kind: 'outbound', route: routed };
|
|
107
|
-
if (!direct.length && routed) return { node: null, kind: 'routed', route: routed };
|
|
108
|
-
const inbound = nodes.find((n) => n && n.direction === 'inbound'
|
|
109
|
-
&& n.shared === true && n.nodeId === ownerId
|
|
110
|
-
&& n.token
|
|
111
|
-
&& n.askReplyAccess === true && n.eventsAccess === true
|
|
112
|
-
&& n.cellVisibility !== 'none');
|
|
113
|
-
if (!inbound) return null;
|
|
114
|
-
const forwardToken = inbound.token;
|
|
115
|
-
const pool = inbound.reversePool;
|
|
116
|
-
const slot = pool && Array.isArray(pool.slots) ? pool.slots[pool.activeSlot] : null;
|
|
117
|
-
if (!slot || !Number.isInteger(slot.port)) {
|
|
118
|
-
return { node: inbound, forwardPort: null, forwardToken, kind: 'inbound-unverified', route: routed };
|
|
119
|
-
}
|
|
120
|
-
return { node: inbound, forwardPort: slot.port, forwardToken, kind: 'inbound', route: routed };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function ownerUrl(port, path) {
|
|
124
|
-
return `http://127.0.0.1:${port}/federation/route/_/${path.replace(/^\//, '')}`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
async function forward(candidate, path, { method = 'GET', body, expectedTs, op } = {}) {
|
|
128
|
-
op.check();
|
|
129
|
-
const st = loadStore();
|
|
130
|
-
slotVerifier.sync(st, proofScope);
|
|
131
|
-
if (candidate.kind === 'inbound' && !candidate.route) {
|
|
132
|
-
const current = st && (st.nodes || []).find(node => node.name === candidate.node.name);
|
|
133
|
-
const selected = binding(st, current);
|
|
134
|
-
if (!selected || selected.fingerprint !== op.proofBinding) {
|
|
135
|
-
slotVerifier.invalidate(op.proofKey);
|
|
136
|
-
const error = new Error('reverse slot changed'); error.reason = 'reverse-slot-unverified'; throw error;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
const routed = candidate.route;
|
|
140
|
-
const url = routed
|
|
141
|
-
? `http://127.0.0.1:${localPort()}/api/route/${routed.map(encodeURIComponent).join('/')}/_${path}`
|
|
142
|
-
: ownerUrl(candidate.forwardPort, path);
|
|
143
|
-
const headers = routed ? { authorization: `Bearer ${localToken()}` }
|
|
144
|
-
: { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': st && st.nodeId || '' };
|
|
145
|
-
try {
|
|
146
|
-
op.check(); op.delivered = true;
|
|
147
|
-
const r = await op.wait(fetchImpl(url, { method, headers: { ...headers, 'content-type': 'application/json',
|
|
148
|
-
...(expectedTs === undefined ? {} : { 'x-nexuscrew-ask-ts': String(expectedTs) }) },
|
|
149
|
-
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: op.controller.signal }));
|
|
150
|
-
op.check();
|
|
151
|
-
const payload = await op.wait(r.json().catch(error => {
|
|
152
|
-
if ([403, 404, 501].includes(r.status)) return {};
|
|
153
|
-
throw error;
|
|
154
|
-
}));
|
|
155
|
-
op.check();
|
|
156
|
-
if (!r.ok) slotVerifier.invalidate(op.proofKey);
|
|
157
|
-
return { r, body: { ...payload, reason: safeReason(payload && payload.reason) } };
|
|
158
|
-
} catch (error) { slotVerifier.invalidate(op.proofKey); throw error; }
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Preflight del canale reverse per un peer inbound: rifiuto NETTO se lo slot
|
|
162
|
-
// non e' verificato (non e' un esito incerto: il forward non e' mai partito).
|
|
163
|
-
async function verifyInboundChannel(candidate, op) {
|
|
164
|
-
// REST routes prove the slot at their inbound proxy hop, never twice.
|
|
165
|
-
if (candidate.route) return { ok: true };
|
|
166
|
-
op.check();
|
|
167
|
-
const st = loadStore();
|
|
168
|
-
const proof = await op.wait(slotVerifier.verify({ store: st, peer: candidate.node, loadStore,
|
|
169
|
-
scope: proofScope, signal: op.controller.signal, probeImpl: probeReverseSlotImpl }));
|
|
170
|
-
op.check();
|
|
171
|
-
if (proof && proof.owned === true) {
|
|
172
|
-
op.proofKey = proof.key;
|
|
173
|
-
const selected = binding(st, candidate.node);
|
|
174
|
-
op.proofBinding = selected && selected.fingerprint;
|
|
175
|
-
return { ok: true };
|
|
176
|
-
}
|
|
177
|
-
return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function attemptKey(ownerId, askId, rid) { return `${ownerId}|${askId}|${rid}`; }
|
|
181
|
-
|
|
182
52
|
function statusFor(ownerId, askId) {
|
|
183
53
|
const out = [];
|
|
184
54
|
for (const [key, a] of attempts) {
|
|
@@ -204,13 +74,13 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
204
74
|
if (!ownerId || !askId || typeof text !== 'string' || !text.trim()) {
|
|
205
75
|
return { ok: false, code: 400, error: 'ownerId, askId e text richiesti' };
|
|
206
76
|
}
|
|
207
|
-
const candidate = await candidateOwner(ownerId, op);
|
|
77
|
+
const candidate = await channel.candidateOwner(ownerId, op);
|
|
208
78
|
if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
|
|
209
79
|
if (candidate.kind === 'inbound-unverified') {
|
|
210
80
|
return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
|
|
211
81
|
}
|
|
212
82
|
if (candidate.kind === 'inbound') {
|
|
213
|
-
const gate = await verifyInboundChannel(candidate, op);
|
|
83
|
+
const gate = await channel.verifyInboundChannel(candidate, op);
|
|
214
84
|
if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
|
|
215
85
|
}
|
|
216
86
|
const peer = candidate.node;
|
|
@@ -226,7 +96,7 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
226
96
|
const store = loadStore();
|
|
227
97
|
attempts.set(key, { requestId: rid, state: 'sent', askId, ownerId, digest: crypto.createHash('sha256').update(text).digest('hex') });
|
|
228
98
|
try {
|
|
229
|
-
const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}/answer`, {
|
|
99
|
+
const { r, body } = await channel.forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}/answer`, {
|
|
230
100
|
method: 'POST', body: { text, requestId: rid }, op,
|
|
231
101
|
});
|
|
232
102
|
op.check();
|
|
@@ -263,7 +133,7 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
263
133
|
if (entry) entry.state = 'failed';
|
|
264
134
|
return { ok: false, code: r.status, error: `HTTP ${r.status}`, reason: body.reason };
|
|
265
135
|
} catch (error) {
|
|
266
|
-
if (!op.active()) return deadlineResult(op, { ownerId, askId, rid }, false);
|
|
136
|
+
if (!op.active()) return channel.deadlineResult(op, { ownerId, askId, rid }, false);
|
|
267
137
|
if (!op.delivered) {
|
|
268
138
|
const entry = attempts.get(key); if (entry) entry.state = 'failed';
|
|
269
139
|
return { ok: false, code: 404, reason: error.reason || 'reverse-slot-unverified', error: 'risposta non inoltrata' };
|
|
@@ -285,7 +155,7 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
285
155
|
const pending = answerFlights.get(key);
|
|
286
156
|
if (pending && pending.digest !== digest) return { ok: false, code: 409, reason: 'request-conflict', error: 'stesso requestId con testo diverso' };
|
|
287
157
|
if (pending) return pending.promise;
|
|
288
|
-
const flight = operation({ ...args, rid }, op => relayAnswerOnce({ ...args, rid }, op), false, true);
|
|
158
|
+
const flight = channel.operation({ ...args, rid }, op => relayAnswerOnce({ ...args, rid }, op), false, true);
|
|
289
159
|
const slot = { promise: flight, digest };
|
|
290
160
|
answerFlights.set(key, slot);
|
|
291
161
|
try { return await flight; } finally { if (answerFlights.get(key) === slot) answerFlights.delete(key); }
|
|
@@ -301,38 +171,38 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
301
171
|
}
|
|
302
172
|
|
|
303
173
|
async function relayDismissOnce({ ownerId, askId, expectedTs } = {}, op) {
|
|
304
|
-
const candidate = await candidateOwner(ownerId, op);
|
|
174
|
+
const candidate = await channel.candidateOwner(ownerId, op);
|
|
305
175
|
if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
|
|
306
176
|
if (candidate.kind === 'inbound-unverified') {
|
|
307
177
|
return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
|
|
308
178
|
}
|
|
309
179
|
if (candidate.kind === 'inbound') {
|
|
310
|
-
const gate = await verifyInboundChannel(candidate, op);
|
|
180
|
+
const gate = await channel.verifyInboundChannel(candidate, op);
|
|
311
181
|
if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
|
|
312
182
|
}
|
|
313
183
|
const peer = candidate.node;
|
|
314
184
|
const store = loadStore();
|
|
315
185
|
try {
|
|
316
|
-
const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}`, { method: 'DELETE', expectedTs, op });
|
|
186
|
+
const { r, body } = await channel.forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}`, { method: 'DELETE', expectedTs, op });
|
|
317
187
|
if (!r.ok) return { ok: false, code: r.status, error: 'owner refused', reason: body.reason, retryAfterMs: retryAfterMs(r) };
|
|
318
188
|
return { ok: true, ...(body.outcome ? { outcome: body.outcome } : {}) };
|
|
319
|
-
} catch (e) { if (!op.active()) return deadlineResult(op, { ownerId, askId }, false); return { ok: false, code: 502, reason: safeReason(e.reason), error: 'owner unreachable' }; }
|
|
189
|
+
} catch (e) { if (!op.active()) return channel.deadlineResult(op, { ownerId, askId }, false); return { ok: false, code: 502, reason: safeReason(e.reason), error: 'owner unreachable' }; }
|
|
320
190
|
}
|
|
321
191
|
|
|
322
192
|
async function verifyStatusOnce({ ownerId, askId, requestId: rid }, op) {
|
|
323
|
-
const candidate = await candidateOwner(ownerId, op);
|
|
193
|
+
const candidate = await channel.candidateOwner(ownerId, op);
|
|
324
194
|
if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
|
|
325
195
|
if (candidate.kind === 'inbound-unverified') {
|
|
326
196
|
return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
|
|
327
197
|
}
|
|
328
198
|
if (candidate.kind === 'inbound') {
|
|
329
|
-
const gate = await verifyInboundChannel(candidate, op);
|
|
199
|
+
const gate = await channel.verifyInboundChannel(candidate, op);
|
|
330
200
|
if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
|
|
331
201
|
}
|
|
332
202
|
const peer = candidate.node;
|
|
333
203
|
const store = loadStore();
|
|
334
204
|
try {
|
|
335
|
-
const { r, body } = await forward(candidate,
|
|
205
|
+
const { r, body } = await channel.forward(candidate,
|
|
336
206
|
`/event-feed/asks/${encodeURIComponent(askId)}/requests/${encodeURIComponent(rid)}`, { op });
|
|
337
207
|
if (!r.ok) return { ok: false, code: r.status, reason: body.reason, error: 'owner refused' };
|
|
338
208
|
op.check();
|
|
@@ -344,22 +214,22 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
344
214
|
...(body.ownerId ? { ownerId: body.ownerId } : {}), ...(body.askId ? { askId: body.askId } : {}),
|
|
345
215
|
...(body.actor ? { actor: body.actor } : {}), ...(body.receipt ? { receipt: body.receipt } : {}) };
|
|
346
216
|
} catch (e) {
|
|
347
|
-
if (!op.active()) return deadlineResult(op, { ownerId, askId }, false);
|
|
217
|
+
if (!op.active()) return channel.deadlineResult(op, { ownerId, askId }, false);
|
|
348
218
|
return { ok: false, code: 502, reason: safeReason(e.reason), error: 'owner unreachable' };
|
|
349
219
|
}
|
|
350
220
|
}
|
|
351
221
|
|
|
352
222
|
async function replyCapabilityOnce({ ownerId, askId } = {}, op) {
|
|
353
223
|
const denied = (status, reason) => ({ ownerId, askId, canReply: false, status, ...(reason ? { reason } : {}) });
|
|
354
|
-
const candidate = await candidateOwner(ownerId, op);
|
|
224
|
+
const candidate = await channel.candidateOwner(ownerId, op);
|
|
355
225
|
if (!candidate) return denied('unreachable', 'owner-unknown');
|
|
356
226
|
if (candidate.kind === 'inbound-unverified') { op.channelUnavailable = true; return denied('unreachable', 'reverse-slot-unverified'); }
|
|
357
227
|
if (candidate.kind === 'inbound') {
|
|
358
|
-
const gate = await verifyInboundChannel(candidate, op);
|
|
228
|
+
const gate = await channel.verifyInboundChannel(candidate, op);
|
|
359
229
|
if (!gate.ok) { op.channelUnavailable = true; return denied('unreachable', gate.reason); }
|
|
360
230
|
}
|
|
361
231
|
try {
|
|
362
|
-
const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}/capability`, { op });
|
|
232
|
+
const { r, body } = await channel.forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}/capability`, { op });
|
|
363
233
|
if (['reverse-slot-unverified', 'federation-peer-unreachable', 'federation-peer-timeout'].includes(body.reason)) { op.channelUnavailable = true; return denied('unreachable', body.reason); }
|
|
364
234
|
if (r.status === 404 || r.status === 501) return denied('unsupported', body.reason);
|
|
365
235
|
if (r.status === 403) return denied('denied', body.reason);
|
|
@@ -371,14 +241,14 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
371
241
|
}
|
|
372
242
|
|
|
373
243
|
async function inspectDismissalOnce({ ownerId, askId } = {}, op) {
|
|
374
|
-
const candidate = await candidateOwner(ownerId, op);
|
|
244
|
+
const candidate = await channel.candidateOwner(ownerId, op);
|
|
375
245
|
if (!candidate) return { ok: false, code: 404, reason: 'owner-unknown' };
|
|
376
246
|
if (candidate.kind === 'inbound-unverified') return { ok: false, code: 404, reason: 'reverse-slot-unverified' };
|
|
377
247
|
if (candidate.kind === 'inbound') {
|
|
378
|
-
const proof = await verifyInboundChannel(candidate, op);
|
|
248
|
+
const proof = await channel.verifyInboundChannel(candidate, op);
|
|
379
249
|
if (!proof.ok) return proof;
|
|
380
250
|
}
|
|
381
|
-
const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}/capability`, { op });
|
|
251
|
+
const { r, body } = await channel.forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}/capability`, { op });
|
|
382
252
|
if (!r.ok) return { ok: false, code: r.status, reason: body.reason || 'unknown', retryAfterMs: retryAfterMs(r) };
|
|
383
253
|
if (body.ownerId !== ownerId || body.askId !== askId || !['open', 'answered', 'dismissed'].includes(body.status)) {
|
|
384
254
|
return { ok: false, code: 403, reason: 'unknown' };
|
|
@@ -392,10 +262,10 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
392
262
|
}
|
|
393
263
|
return { ok: true, status: body.status, ask, generationPrecondition: true };
|
|
394
264
|
}
|
|
395
|
-
function inspectDismissal(args = {}) { return operation(args, op => inspectDismissalOnce(args, op)); }
|
|
265
|
+
function inspectDismissal(args = {}) { return channel.operation(args, op => inspectDismissalOnce(args, op)); }
|
|
396
266
|
|
|
397
|
-
function relayDismiss(args = {}) { return operation(args, op => relayDismissOnce(args, op)); }
|
|
398
|
-
function verifyStatus(args = {}) { return operation(args, op => verifyStatusOnce(args, op)); }
|
|
267
|
+
function relayDismiss(args = {}) { return channel.operation(args, op => relayDismissOnce(args, op)); }
|
|
268
|
+
function verifyStatus(args = {}) { return channel.operation(args, op => verifyStatusOnce(args, op)); }
|
|
399
269
|
function channelBinding(ownerId) {
|
|
400
270
|
const store = loadStore();
|
|
401
271
|
// Kept only in process memory; credentials never enter the dismissal store.
|
|
@@ -407,7 +277,7 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
|
|
|
407
277
|
}
|
|
408
278
|
function replyCapability(args = {}) {
|
|
409
279
|
const initial = channelBinding(args.ownerId);
|
|
410
|
-
return operation(args, async op => {
|
|
280
|
+
return channel.operation(args, async op => {
|
|
411
281
|
const result = await replyCapabilityOnce(args, op);
|
|
412
282
|
channelObservations.set(args.ownerId, { at: now(), binding: initial, unavailable: op.channelUnavailable === true });
|
|
413
283
|
return result;
|
|
@@ -17,7 +17,7 @@ const express = require('express');
|
|
|
17
17
|
const nodesStore = require('../nodes/store.js');
|
|
18
18
|
const accessPresets = require('../nodes/access-presets.js');
|
|
19
19
|
const { createAudioAcl } = require('../audio/acl.js');
|
|
20
|
-
const { visibilityAllows } = require('./event-feed-acl.js');
|
|
20
|
+
const { visibilityAllows, allowsEnvelope, peerGrants } = require('./event-feed-acl.js');
|
|
21
21
|
|
|
22
22
|
const ANSWER_RATE_PER_MIN = 6;
|
|
23
23
|
const ANSWER_GLOBAL_PER_MIN = 30;
|
|
@@ -69,8 +69,23 @@ function createAskActionGate(deps) {
|
|
|
69
69
|
if (grants.some((view) => !view.configured || view.grants.eventsAccess !== true || view.grants.askReplyAccess !== true)) {
|
|
70
70
|
return refuse(403, 'grant-required:ask-action');
|
|
71
71
|
}
|
|
72
|
+
// Two different questions, two different rules — on purpose.
|
|
73
|
+
//
|
|
74
|
+
// `allows` keeps the ask-action semantics it always had: the target CELL is
|
|
75
|
+
// visible only if EVERY ring of the chain can see it.
|
|
76
|
+
//
|
|
77
|
+
// `allowsEnvelope` answers for a whole envelope (scope 'node' needs
|
|
78
|
+
// nodeEventsAccess) and follows the SNAPSHOT's rule instead, because the
|
|
79
|
+
// dismissals use it to decide whether the peer may clear the entry it is
|
|
80
|
+
// pointing at: the snapshot resolves the ORIGIN peer and nothing else
|
|
81
|
+
// (`event-feed-routes.js:64` `resolvePeer(store, visited[0])`) and refuses
|
|
82
|
+
// relays outright (`:58`), so the node that asks is judged by its own view.
|
|
83
|
+
// Intersecting the rings here would be stricter than the snapshot on a
|
|
84
|
+
// two-hop request: a node could not clear a notice it does see.
|
|
85
|
+
const originGrants = peerGrants(st, actor);
|
|
72
86
|
return { self, actor, peer: { nodeId: deliveringId, grants: grants[0].grants,
|
|
73
|
-
allows: ({ cellId }) => grants.every((view) => visibilityAllows(view.grants, cellId))
|
|
87
|
+
allows: ({ cellId }) => grants.every((view) => visibilityAllows(view.grants, cellId)),
|
|
88
|
+
allowsEnvelope: (envelope) => !!originGrants && allowsEnvelope(originGrants, envelope) } };
|
|
74
89
|
};
|
|
75
90
|
}
|
|
76
91
|
|
|
@@ -205,4 +220,4 @@ function createEventFeedAsksRoutes(deps) {
|
|
|
205
220
|
return r;
|
|
206
221
|
}
|
|
207
222
|
|
|
208
|
-
module.exports = { createEventFeedAsksRoutes, createAskRateLimiter };
|
|
223
|
+
module.exports = { createEventFeedAsksRoutes, createAskActionGate, createAskRateLimiter };
|
|
@@ -90,6 +90,13 @@ function createEventFeedClient(opts = {}) {
|
|
|
90
90
|
// stale, lastError}; generation guards against obsolete fetches.
|
|
91
91
|
const isAskDismissed = typeof opts.isAskDismissed === 'function' ? opts.isAskDismissed : () => false;
|
|
92
92
|
const visibleAsks = (ownerId, asks) => asks.filter(ask => !isAskDismissed(ownerId, ask));
|
|
93
|
+
// Notices THIS node has already cleared locally while the owner has not taken
|
|
94
|
+
// the dismissal yet (queued, or refused for good): the local intent is what the
|
|
95
|
+
// operator acted on, so the notice must not come back to a node that reloads or
|
|
96
|
+
// restarts and rebuilds this view from the owner's snapshot. Applied at IMPORT,
|
|
97
|
+
// where every notice enters the view — a dismissal drops the one already held —
|
|
98
|
+
// so nothing else has to filter on read.
|
|
99
|
+
const isNoticeDismissed = typeof opts.isNoticeDismissed === 'function' ? opts.isNoticeDismissed : () => false;
|
|
93
100
|
const views = new Map();
|
|
94
101
|
const running = new Map(); // nodeId -> {abort}
|
|
95
102
|
let stopped = false;
|
|
@@ -169,6 +176,36 @@ function createEventFeedClient(opts = {}) {
|
|
|
169
176
|
return true;
|
|
170
177
|
}
|
|
171
178
|
|
|
179
|
+
// Notices the owner said are CLEARED. Same shape and same reasoning as the
|
|
180
|
+
// ask tombstones above, with the TTL of the store that produced them (15
|
|
181
|
+
// minutes, the life of the notice itself) and the view epoch, so an event id
|
|
182
|
+
// reused after an owner reset is not suppressed forever.
|
|
183
|
+
const NOTICE_TOMBSTONE_TTL_MS = 15 * 60 * 1000;
|
|
184
|
+
const noticeTombstones = new Map(); // `${ownerId}|${eventId}` -> {at, epoch}
|
|
185
|
+
|
|
186
|
+
function noticeTombstoneKey(ownerId, eventId) { return `${ownerId}|${eventId}`; }
|
|
187
|
+
|
|
188
|
+
function noteNoticeClosed(ownerId, eventId, epoch) {
|
|
189
|
+
if (!ownerId || !eventId) return;
|
|
190
|
+
noticeTombstones.set(noticeTombstoneKey(ownerId, eventId), { at: now(), epoch });
|
|
191
|
+
if (noticeTombstones.size > 4096) noticeTombstones.delete(noticeTombstones.keys().next().value);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function noticeIsClosed(ownerId, eventId, epoch) {
|
|
195
|
+
const key = noticeTombstoneKey(ownerId, eventId);
|
|
196
|
+
const hit = noticeTombstones.get(key);
|
|
197
|
+
if (!hit) return false;
|
|
198
|
+
if (now() - hit.at > NOTICE_TOMBSTONE_TTL_MS) { noticeTombstones.delete(key); return false; }
|
|
199
|
+
if (typeof hit.epoch === 'number' && typeof epoch === 'number' && hit.epoch !== epoch) return false;
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function dropNoticeFromView(ownerId, eventId) {
|
|
204
|
+
const view = viewFor(ownerId);
|
|
205
|
+
view.notifications = view.notifications.filter((n) => (n && n.eventId) !== eventId);
|
|
206
|
+
return view;
|
|
207
|
+
}
|
|
208
|
+
|
|
172
209
|
function dropAskFromView(ownerId, askId) {
|
|
173
210
|
const view = viewFor(ownerId);
|
|
174
211
|
view.asks = view.asks.filter((a) => (a.ownerAskId || a.id) !== askId);
|
|
@@ -237,7 +274,15 @@ function createEventFeedClient(opts = {}) {
|
|
|
237
274
|
.map((a) => ({ ...a, ownerId }))
|
|
238
275
|
: [];
|
|
239
276
|
view.alertAsks = new Map(); // fresh snapshot replaces all live-only alert sources
|
|
240
|
-
|
|
277
|
+
// A snapshot taken before the owner processed the dismissal still carries
|
|
278
|
+
// the notice: the tombstone is what keeps it from coming back, exactly like
|
|
279
|
+
// the ask above.
|
|
280
|
+
view.notifications = Array.isArray(snap.notifications)
|
|
281
|
+
? snap.notifications
|
|
282
|
+
.filter((e) => e && !noticeIsClosed(ownerId, e.eventId, snap.viewEpoch)
|
|
283
|
+
&& !isNoticeDismissed(ownerId, e.eventId))
|
|
284
|
+
.map((e) => ({ ...e, ownerId }))
|
|
285
|
+
: [];
|
|
241
286
|
view.fleetState = snap.fleetState || null;
|
|
242
287
|
view.askReplyAccess = snap.askReplyAccess === true;
|
|
243
288
|
view.viewEpoch = snap.viewEpoch;
|
|
@@ -349,7 +394,11 @@ function createEventFeedClient(opts = {}) {
|
|
|
349
394
|
...alertFields(alertFrame),
|
|
350
395
|
originNode: envelope.ownerId, ownerId: envelope.ownerId,
|
|
351
396
|
...(envelope.scope === 'cell' && envelope.cellId ? { originCell: envelope.cellId } : {}),
|
|
352
|
-
eventId: envelope.eventId,
|
|
397
|
+
eventId: envelope.eventId,
|
|
398
|
+
// Origin time, not rebroadcast time: toast and card must tell the
|
|
399
|
+
// same truth as the owner's emission (emittedAt), falling back only
|
|
400
|
+
// when an envelope somehow carries none.
|
|
401
|
+
ts: Number.isFinite(envelope.emittedAt) ? envelope.emittedAt : Date.now(),
|
|
353
402
|
});
|
|
354
403
|
// The first legacy alert may have created an unknown admission during
|
|
355
404
|
// broadcast. Bind that admission before any following known replay.
|
|
@@ -378,6 +427,16 @@ function createEventFeedClient(opts = {}) {
|
|
|
378
427
|
opts.eventsHub.broadcast({ type: f.outcome === 'dismissed' ? 'ask-dismissed' : 'ask-answered',
|
|
379
428
|
id: f.askId, ownerAskId: f.askId, ownerId: envelope.ownerId, eventId: envelope.eventId,
|
|
380
429
|
...(ownerAskTs ? { ownerAskTs } : {}) });
|
|
430
|
+
} else if (f.type === 'notify-closed' || f.type === 'notify-closed-node') {
|
|
431
|
+
// The owner cleared a notice. The card leaves the exported view of every
|
|
432
|
+
// device that reads this feed, live, and the tombstone keeps an anterior
|
|
433
|
+
// snapshot from handing it back. The frame carries the id of the notice it
|
|
434
|
+
// closes, not its own: the envelope id only moves the cursor.
|
|
435
|
+
const eventId = typeof f.eventId === 'string' ? f.eventId : '';
|
|
436
|
+
if (!eventId) return false;
|
|
437
|
+
dismissNoticeConfirmed(envelope.ownerId, eventId);
|
|
438
|
+
opts.eventsHub.broadcast({ type: 'notify-dismissed', ownerId: envelope.ownerId, eventId,
|
|
439
|
+
...(envelope.scope === 'cell' && envelope.cellId ? { originCell: envelope.cellId } : {}) });
|
|
381
440
|
} else if (f.type === 'file-notice') {
|
|
382
441
|
// Stessa forma della consegna locale (lib/files/routes.js): il nome del
|
|
383
442
|
// file e' SEMPRE nel corpo, con la caption quando c'e'. Prima l'importato
|
|
@@ -389,7 +448,8 @@ function createEventFeedClient(opts = {}) {
|
|
|
389
448
|
// DELLA CELLA, e senza questo campo il relay lo contava come scope nodo
|
|
390
449
|
// (budget diverso: 7 avvisi invece di 6).
|
|
391
450
|
...(envelope.scope === 'cell' && envelope.cellId ? { originCell: envelope.cellId } : {}),
|
|
392
|
-
eventId: envelope.eventId,
|
|
451
|
+
eventId: envelope.eventId,
|
|
452
|
+
ts: Number.isFinite(envelope.emittedAt) ? envelope.emittedAt : Date.now() });
|
|
393
453
|
} else if (f.type === 'fleet-state' || f.type === 'node-state') {
|
|
394
454
|
opts.eventsHub.broadcast({ type: 'feed-state', ownerId: envelope.ownerId, eventId: envelope.eventId, state: f });
|
|
395
455
|
}
|
|
@@ -580,7 +640,8 @@ function createEventFeedClient(opts = {}) {
|
|
|
580
640
|
// punto non è mai 'ok' (reason 'snapshot-schema:<campo>') — lo snapshot
|
|
581
641
|
// non si applica, niente applicazione parziale.
|
|
582
642
|
// Produttore di riferimento: lib/notify/event-feed-routes.js buildSnapshot
|
|
583
|
-
// (emette sempre i campi della view e marca resyncRequired
|
|
643
|
+
// (emette sempre i campi della view e marca resyncRequired solo quando la
|
|
644
|
+
// fonte aveva più voci del cap, non a pagina piena).
|
|
584
645
|
function validateSnapshot(ownerId, snap, bytes, contentType, profile = 'view') {
|
|
585
646
|
if (!snap || typeof snap !== 'object') return { ok: false, reason: 'shape' };
|
|
586
647
|
if (bytes > 3 * 1024 * 1024) return { ok: false, reason: 'snapshot-oversize' };
|
|
@@ -605,11 +666,12 @@ function createEventFeedClient(opts = {}) {
|
|
|
605
666
|
&& Buffer.byteLength(JSON.stringify(e), 'utf8') <= SNAPSHOT_ELEMENT_MAX_BYTES;
|
|
606
667
|
// Cap di pagina e per-elemento: identità e misura. Id non stringa o vuoto,
|
|
607
668
|
// elemento non oggetto o fuori budget → 'snapshot-element-oversize'
|
|
608
|
-
// (mapping storico invariato, il cap resta un cap).
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
|
|
669
|
+
// (mapping storico invariato, il cap resta un cap). SOPRA il cap (>): il
|
|
670
|
+
// produttore marca resyncRequired solo quando la FONTe superava la pagina,
|
|
671
|
+
// quindi una pagina ESATTAMENTE al cap senza marcatore è una forma
|
|
672
|
+
// legittima dell'owner — la pagina al cap È il suo elenco aperto — mentre
|
|
673
|
+
// OLTRE il cap la lista non prova l'assenza di nessuna domanda.
|
|
674
|
+
if (snap.asks.length > SNAPSHOT_MAX_ASKS
|
|
613
675
|
|| !snap.asks.every((a) => boundedElement(a) && boundedId(a && a.id))) {
|
|
614
676
|
return { ok: false, reason: 'snapshot-element-oversize' };
|
|
615
677
|
}
|
|
@@ -912,6 +974,19 @@ function createEventFeedClient(opts = {}) {
|
|
|
912
974
|
return true;
|
|
913
975
|
}
|
|
914
976
|
|
|
977
|
+
// A notice dismissal this node has CONFIRMED (the owner's closure frame, or a
|
|
978
|
+
// relay that returned 2xx/404): only then does the view forget the notice, and
|
|
979
|
+
// only then is it tombstoned. Until then the card stays where it is.
|
|
980
|
+
function dismissNoticeConfirmed(ownerId, eventId) {
|
|
981
|
+
if (!ownerId || !eventId) return false;
|
|
982
|
+
const view = dropNoticeFromView(ownerId, eventId);
|
|
983
|
+
noteNoticeClosed(ownerId, eventId, view.viewEpoch);
|
|
984
|
+
if (typeof opts.onViewChanged === 'function') {
|
|
985
|
+
try { opts.onViewChanged(ownerId); } catch (_) { /* UI refresh cannot invalidate the closure. */ }
|
|
986
|
+
}
|
|
987
|
+
return true;
|
|
988
|
+
}
|
|
989
|
+
|
|
915
990
|
// Esito TIPIZZATO: {status:'ok', asks} | {status:'skipped'|'pending'|'error',
|
|
916
991
|
// reason, retryAt}. SOLO 'ok' è autorevole (snapshot fresco, col contratto
|
|
917
992
|
// della decisione soddisfatto, acquisito in questa chiamata): uno skip, un
|
|
@@ -1009,7 +1084,7 @@ function createEventFeedClient(opts = {}) {
|
|
|
1009
1084
|
}
|
|
1010
1085
|
}
|
|
1011
1086
|
|
|
1012
|
-
return { start, stop, poll, state, reemit, viewFor, dismissConfirmed, ownerSnapshotAsks, rememberLiveAsk, applyOwnerClosure };
|
|
1087
|
+
return { start, stop, poll, state, reemit, viewFor, dismissConfirmed, dismissNoticeConfirmed, ownerSnapshotAsks, rememberLiveAsk, applyOwnerClosure };
|
|
1013
1088
|
}
|
|
1014
1089
|
|
|
1015
1090
|
module.exports = { createEventFeedClient };
|