@mmmbuto/nexuscrew 0.9.64 → 0.9.66
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 +18 -0
- package/docs/NODES.md +44 -0
- package/docs/NOTIFICATIONS.md +34 -0
- package/docs/SECURITY.md +40 -0
- package/frontend/dist/assets/index-DhZOap1-.js +169 -0
- package/frontend/dist/assets/{index-BC6vO7Zd.css → index-Dwd37Z_R.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +31 -0
- package/lib/nodes/access-presets.js +27 -7
- package/lib/nodes/commands.js +63 -4
- package/lib/nodes/store.js +197 -12
- package/lib/notify/ask-cell.js +53 -0
- 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 +125 -12
- 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 +37 -12
- 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 +52 -4
- package/lib/proxy/resource-acl.js +6 -0
- package/lib/server.js +230 -7
- package/lib/settings/routes.js +6 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-oUd5O8W8.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 };
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// dies by construction. Transport failures back off with jitter 5→60 s; a
|
|
12
12
|
// stream silent for 60 s is aborted and resynced.
|
|
13
13
|
|
|
14
|
+
const askCell = require('./ask-cell.js');
|
|
15
|
+
|
|
14
16
|
const BACKOFF_MIN_MS = 5000;
|
|
15
17
|
const BACKOFF_MAX_MS = 60000;
|
|
16
18
|
const IDLE_TIMEOUT_MS = 60000;
|
|
@@ -90,6 +92,13 @@ function createEventFeedClient(opts = {}) {
|
|
|
90
92
|
// stale, lastError}; generation guards against obsolete fetches.
|
|
91
93
|
const isAskDismissed = typeof opts.isAskDismissed === 'function' ? opts.isAskDismissed : () => false;
|
|
92
94
|
const visibleAsks = (ownerId, asks) => asks.filter(ask => !isAskDismissed(ownerId, ask));
|
|
95
|
+
// Notices THIS node has already cleared locally while the owner has not taken
|
|
96
|
+
// the dismissal yet (queued, or refused for good): the local intent is what the
|
|
97
|
+
// operator acted on, so the notice must not come back to a node that reloads or
|
|
98
|
+
// restarts and rebuilds this view from the owner's snapshot. Applied at IMPORT,
|
|
99
|
+
// where every notice enters the view — a dismissal drops the one already held —
|
|
100
|
+
// so nothing else has to filter on read.
|
|
101
|
+
const isNoticeDismissed = typeof opts.isNoticeDismissed === 'function' ? opts.isNoticeDismissed : () => false;
|
|
93
102
|
const views = new Map();
|
|
94
103
|
const running = new Map(); // nodeId -> {abort}
|
|
95
104
|
let stopped = false;
|
|
@@ -169,6 +178,36 @@ function createEventFeedClient(opts = {}) {
|
|
|
169
178
|
return true;
|
|
170
179
|
}
|
|
171
180
|
|
|
181
|
+
// Notices the owner said are CLEARED. Same shape and same reasoning as the
|
|
182
|
+
// ask tombstones above, with the TTL of the store that produced them (15
|
|
183
|
+
// minutes, the life of the notice itself) and the view epoch, so an event id
|
|
184
|
+
// reused after an owner reset is not suppressed forever.
|
|
185
|
+
const NOTICE_TOMBSTONE_TTL_MS = 15 * 60 * 1000;
|
|
186
|
+
const noticeTombstones = new Map(); // `${ownerId}|${eventId}` -> {at, epoch}
|
|
187
|
+
|
|
188
|
+
function noticeTombstoneKey(ownerId, eventId) { return `${ownerId}|${eventId}`; }
|
|
189
|
+
|
|
190
|
+
function noteNoticeClosed(ownerId, eventId, epoch) {
|
|
191
|
+
if (!ownerId || !eventId) return;
|
|
192
|
+
noticeTombstones.set(noticeTombstoneKey(ownerId, eventId), { at: now(), epoch });
|
|
193
|
+
if (noticeTombstones.size > 4096) noticeTombstones.delete(noticeTombstones.keys().next().value);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function noticeIsClosed(ownerId, eventId, epoch) {
|
|
197
|
+
const key = noticeTombstoneKey(ownerId, eventId);
|
|
198
|
+
const hit = noticeTombstones.get(key);
|
|
199
|
+
if (!hit) return false;
|
|
200
|
+
if (now() - hit.at > NOTICE_TOMBSTONE_TTL_MS) { noticeTombstones.delete(key); return false; }
|
|
201
|
+
if (typeof hit.epoch === 'number' && typeof epoch === 'number' && hit.epoch !== epoch) return false;
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function dropNoticeFromView(ownerId, eventId) {
|
|
206
|
+
const view = viewFor(ownerId);
|
|
207
|
+
view.notifications = view.notifications.filter((n) => (n && n.eventId) !== eventId);
|
|
208
|
+
return view;
|
|
209
|
+
}
|
|
210
|
+
|
|
172
211
|
function dropAskFromView(ownerId, askId) {
|
|
173
212
|
const view = viewFor(ownerId);
|
|
174
213
|
view.asks = view.asks.filter((a) => (a.ownerAskId || a.id) !== askId);
|
|
@@ -222,6 +261,28 @@ function createEventFeedClient(opts = {}) {
|
|
|
222
261
|
}
|
|
223
262
|
}
|
|
224
263
|
|
|
264
|
+
// An ask of a snapshot, attributed to its owner, with the cell the OWNER
|
|
265
|
+
// resolved for it. `cellId` on the wire becomes `ownerCellId` here (validated by
|
|
266
|
+
// the schema check) and any `ownerCellId`/`originCell` the wire carried is
|
|
267
|
+
// dropped: those two names are ours, set only by authenticated ingress. When the
|
|
268
|
+
// snapshot has no cell for the ask, the cell the view already held for the SAME
|
|
269
|
+
// generation (same owner timestamp and content) is kept; a new generation never
|
|
270
|
+
// inherits it.
|
|
271
|
+
function withOwnerCell(wire, ownerId, knownCells) {
|
|
272
|
+
const { cellId, ownerCellId: _forged, originCell: _forgedOrigin, ...ask } = wire;
|
|
273
|
+
const out = { ...ask, ownerId };
|
|
274
|
+
if (askCell.isCellId(cellId)) return { ...out, ownerCellId: cellId };
|
|
275
|
+
const hit = knownCells.get(out.ownerAskId || out.id);
|
|
276
|
+
if (hit) {
|
|
277
|
+
const { askFingerprint } = require('./asks.js');
|
|
278
|
+
const priorTs = Object.hasOwn(hit.prior, 'ownerAskTs') ? hit.prior.ownerAskTs : hit.prior.ts;
|
|
279
|
+
const nextTs = Object.hasOwn(out, 'ownerAskTs') ? out.ownerAskTs : out.ts;
|
|
280
|
+
if (Number.isSafeInteger(priorTs) && priorTs > 0 && priorTs === nextTs
|
|
281
|
+
&& askFingerprint(hit.prior) === askFingerprint(out)) return { ...out, ownerCellId: hit.cellId };
|
|
282
|
+
}
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
|
|
225
286
|
// Apply a fetched snapshot ONLY if it belongs to the current generation:
|
|
226
287
|
// an obsolete fetch must never overwrite a newer view (the snapshot
|
|
227
288
|
// WINS over the previous view of the same owner).
|
|
@@ -231,13 +292,29 @@ function createEventFeedClient(opts = {}) {
|
|
|
231
292
|
view.cursor = snap.cursor || null;
|
|
232
293
|
// A snapshot taken before the owner processed a dismiss still carries the
|
|
233
294
|
// ask: the tombstone is what keeps it from coming back.
|
|
295
|
+
// What this view already knows about the cell of each ask, taken BEFORE the
|
|
296
|
+
// view is replaced: an owner that does not publish the cell in its snapshot
|
|
297
|
+
// (older release) must not make a card forget what its live frame said.
|
|
298
|
+
const knownCells = new Map();
|
|
299
|
+
for (const prior of [...view.asks, ...(view.alertAsks ? view.alertAsks.values() : [])]) {
|
|
300
|
+
const resolved = askCell.resolveAskCell({ ownerCellId: prior.ownerCellId, originCell: prior.originCell });
|
|
301
|
+
if (resolved.cellId) knownCells.set(prior.ownerAskId || prior.id, { prior, cellId: resolved.cellId });
|
|
302
|
+
}
|
|
234
303
|
view.asks = Array.isArray(snap.asks)
|
|
235
304
|
? snap.asks
|
|
236
305
|
.filter((a) => a && !askIsClosed(ownerId, a.ownerAskId || a.id, snap.viewEpoch, a) && !isAskDismissed(ownerId, a))
|
|
237
|
-
.map((a) => (
|
|
306
|
+
.map((a) => withOwnerCell(a, ownerId, knownCells))
|
|
238
307
|
: [];
|
|
239
308
|
view.alertAsks = new Map(); // fresh snapshot replaces all live-only alert sources
|
|
240
|
-
|
|
309
|
+
// A snapshot taken before the owner processed the dismissal still carries
|
|
310
|
+
// the notice: the tombstone is what keeps it from coming back, exactly like
|
|
311
|
+
// the ask above.
|
|
312
|
+
view.notifications = Array.isArray(snap.notifications)
|
|
313
|
+
? snap.notifications
|
|
314
|
+
.filter((e) => e && !noticeIsClosed(ownerId, e.eventId, snap.viewEpoch)
|
|
315
|
+
&& !isNoticeDismissed(ownerId, e.eventId))
|
|
316
|
+
.map((e) => ({ ...e, ownerId }))
|
|
317
|
+
: [];
|
|
241
318
|
view.fleetState = snap.fleetState || null;
|
|
242
319
|
view.askReplyAccess = snap.askReplyAccess === true;
|
|
243
320
|
view.viewEpoch = snap.viewEpoch;
|
|
@@ -349,14 +426,21 @@ function createEventFeedClient(opts = {}) {
|
|
|
349
426
|
...alertFields(alertFrame),
|
|
350
427
|
originNode: envelope.ownerId, ownerId: envelope.ownerId,
|
|
351
428
|
...(envelope.scope === 'cell' && envelope.cellId ? { originCell: envelope.cellId } : {}),
|
|
352
|
-
eventId: envelope.eventId,
|
|
429
|
+
eventId: envelope.eventId,
|
|
430
|
+
// Origin time, not rebroadcast time: toast and card must tell the
|
|
431
|
+
// same truth as the owner's emission (emittedAt), falling back only
|
|
432
|
+
// when an envelope somehow carries none.
|
|
433
|
+
ts: Number.isFinite(envelope.emittedAt) ? envelope.emittedAt : Date.now(),
|
|
353
434
|
});
|
|
354
435
|
// The first legacy alert may have created an unknown admission during
|
|
355
436
|
// broadcast. Bind that admission before any following known replay.
|
|
356
437
|
if (historicalAsk && typeof opts.onAuthoritativeAsks === 'function') opts.onAuthoritativeAsks(envelope.ownerId, [historicalAsk]);
|
|
357
438
|
} else if (f.type === 'ask') {
|
|
358
439
|
const ask = { id: f.askId, question: f.question, options: f.options, session: f.session, ts: f.ts,
|
|
359
|
-
ownerAskTs: Number.isSafeInteger(f.askTs) && f.askTs > 0 ? f.askTs : null, ownerId: envelope.ownerId
|
|
440
|
+
ownerAskTs: Number.isSafeInteger(f.askTs) && f.askTs > 0 ? f.askTs : null, ownerId: envelope.ownerId,
|
|
441
|
+
// The envelope of an ask frame is scoped to the cell the owner resolved;
|
|
442
|
+
// it is the owner's statement, carried on the authenticated stream.
|
|
443
|
+
...(envelope.scope === 'cell' && askCell.isCellId(envelope.cellId) ? { ownerCellId: envelope.cellId } : {}) };
|
|
360
444
|
if (!rememberLiveAsk(envelope.ownerId, ask)) return false;
|
|
361
445
|
if (typeof opts.onAuthoritativeAsks === 'function') opts.onAuthoritativeAsks(envelope.ownerId, [ask]);
|
|
362
446
|
opts.eventsHub.broadcast({ type: 'ask', ownerId: envelope.ownerId, eventId: envelope.eventId,
|
|
@@ -378,6 +462,16 @@ function createEventFeedClient(opts = {}) {
|
|
|
378
462
|
opts.eventsHub.broadcast({ type: f.outcome === 'dismissed' ? 'ask-dismissed' : 'ask-answered',
|
|
379
463
|
id: f.askId, ownerAskId: f.askId, ownerId: envelope.ownerId, eventId: envelope.eventId,
|
|
380
464
|
...(ownerAskTs ? { ownerAskTs } : {}) });
|
|
465
|
+
} else if (f.type === 'notify-closed' || f.type === 'notify-closed-node') {
|
|
466
|
+
// The owner cleared a notice. The card leaves the exported view of every
|
|
467
|
+
// device that reads this feed, live, and the tombstone keeps an anterior
|
|
468
|
+
// snapshot from handing it back. The frame carries the id of the notice it
|
|
469
|
+
// closes, not its own: the envelope id only moves the cursor.
|
|
470
|
+
const eventId = typeof f.eventId === 'string' ? f.eventId : '';
|
|
471
|
+
if (!eventId) return false;
|
|
472
|
+
dismissNoticeConfirmed(envelope.ownerId, eventId);
|
|
473
|
+
opts.eventsHub.broadcast({ type: 'notify-dismissed', ownerId: envelope.ownerId, eventId,
|
|
474
|
+
...(envelope.scope === 'cell' && envelope.cellId ? { originCell: envelope.cellId } : {}) });
|
|
381
475
|
} else if (f.type === 'file-notice') {
|
|
382
476
|
// Stessa forma della consegna locale (lib/files/routes.js): il nome del
|
|
383
477
|
// file e' SEMPRE nel corpo, con la caption quando c'e'. Prima l'importato
|
|
@@ -389,7 +483,8 @@ function createEventFeedClient(opts = {}) {
|
|
|
389
483
|
// DELLA CELLA, e senza questo campo il relay lo contava come scope nodo
|
|
390
484
|
// (budget diverso: 7 avvisi invece di 6).
|
|
391
485
|
...(envelope.scope === 'cell' && envelope.cellId ? { originCell: envelope.cellId } : {}),
|
|
392
|
-
eventId: envelope.eventId,
|
|
486
|
+
eventId: envelope.eventId,
|
|
487
|
+
ts: Number.isFinite(envelope.emittedAt) ? envelope.emittedAt : Date.now() });
|
|
393
488
|
} else if (f.type === 'fleet-state' || f.type === 'node-state') {
|
|
394
489
|
opts.eventsHub.broadcast({ type: 'feed-state', ownerId: envelope.ownerId, eventId: envelope.eventId, state: f });
|
|
395
490
|
}
|
|
@@ -580,7 +675,8 @@ function createEventFeedClient(opts = {}) {
|
|
|
580
675
|
// punto non è mai 'ok' (reason 'snapshot-schema:<campo>') — lo snapshot
|
|
581
676
|
// non si applica, niente applicazione parziale.
|
|
582
677
|
// Produttore di riferimento: lib/notify/event-feed-routes.js buildSnapshot
|
|
583
|
-
// (emette sempre i campi della view e marca resyncRequired
|
|
678
|
+
// (emette sempre i campi della view e marca resyncRequired solo quando la
|
|
679
|
+
// fonte aveva più voci del cap, non a pagina piena).
|
|
584
680
|
function validateSnapshot(ownerId, snap, bytes, contentType, profile = 'view') {
|
|
585
681
|
if (!snap || typeof snap !== 'object') return { ok: false, reason: 'shape' };
|
|
586
682
|
if (bytes > 3 * 1024 * 1024) return { ok: false, reason: 'snapshot-oversize' };
|
|
@@ -605,11 +701,12 @@ function createEventFeedClient(opts = {}) {
|
|
|
605
701
|
&& Buffer.byteLength(JSON.stringify(e), 'utf8') <= SNAPSHOT_ELEMENT_MAX_BYTES;
|
|
606
702
|
// Cap di pagina e per-elemento: identità e misura. Id non stringa o vuoto,
|
|
607
703
|
// elemento non oggetto o fuori budget → 'snapshot-element-oversize'
|
|
608
|
-
// (mapping storico invariato, il cap resta un cap).
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
|
|
704
|
+
// (mapping storico invariato, il cap resta un cap). SOPRA il cap (>): il
|
|
705
|
+
// produttore marca resyncRequired solo quando la FONTe superava la pagina,
|
|
706
|
+
// quindi una pagina ESATTAMENTE al cap senza marcatore è una forma
|
|
707
|
+
// legittima dell'owner — la pagina al cap È il suo elenco aperto — mentre
|
|
708
|
+
// OLTRE il cap la lista non prova l'assenza di nessuna domanda.
|
|
709
|
+
if (snap.asks.length > SNAPSHOT_MAX_ASKS
|
|
613
710
|
|| !snap.asks.every((a) => boundedElement(a) && boundedId(a && a.id))) {
|
|
614
711
|
return { ok: false, reason: 'snapshot-element-oversize' };
|
|
615
712
|
}
|
|
@@ -650,6 +747,9 @@ function createEventFeedClient(opts = {}) {
|
|
|
650
747
|
&& (a.question === undefined || typeof a.question === 'string')
|
|
651
748
|
&& (a.options === undefined || Array.isArray(a.options))
|
|
652
749
|
&& (a.session === undefined || typeof a.session === 'string')
|
|
750
|
+
// The cell the OWNER resolved for the ask: absent (an older owner, or a
|
|
751
|
+
// session the owner could not resolve) or a well-formed cell id, never anything else.
|
|
752
|
+
&& (a.cellId === undefined || askCell.isCellId(a.cellId))
|
|
653
753
|
&& (a.ts === undefined || typeof a.ts === 'number');
|
|
654
754
|
if (!snap.asks.every(askFieldsOk)) return schema('asks');
|
|
655
755
|
return { ok: true };
|
|
@@ -912,6 +1012,19 @@ function createEventFeedClient(opts = {}) {
|
|
|
912
1012
|
return true;
|
|
913
1013
|
}
|
|
914
1014
|
|
|
1015
|
+
// A notice dismissal this node has CONFIRMED (the owner's closure frame, or a
|
|
1016
|
+
// relay that returned 2xx/404): only then does the view forget the notice, and
|
|
1017
|
+
// only then is it tombstoned. Until then the card stays where it is.
|
|
1018
|
+
function dismissNoticeConfirmed(ownerId, eventId) {
|
|
1019
|
+
if (!ownerId || !eventId) return false;
|
|
1020
|
+
const view = dropNoticeFromView(ownerId, eventId);
|
|
1021
|
+
noteNoticeClosed(ownerId, eventId, view.viewEpoch);
|
|
1022
|
+
if (typeof opts.onViewChanged === 'function') {
|
|
1023
|
+
try { opts.onViewChanged(ownerId); } catch (_) { /* UI refresh cannot invalidate the closure. */ }
|
|
1024
|
+
}
|
|
1025
|
+
return true;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
915
1028
|
// Esito TIPIZZATO: {status:'ok', asks} | {status:'skipped'|'pending'|'error',
|
|
916
1029
|
// reason, retryAt}. SOLO 'ok' è autorevole (snapshot fresco, col contratto
|
|
917
1030
|
// della decisione soddisfatto, acquisito in questa chiamata): uno skip, un
|
|
@@ -1009,7 +1122,7 @@ function createEventFeedClient(opts = {}) {
|
|
|
1009
1122
|
}
|
|
1010
1123
|
}
|
|
1011
1124
|
|
|
1012
|
-
return { start, stop, poll, state, reemit, viewFor, dismissConfirmed, ownerSnapshotAsks, rememberLiveAsk, applyOwnerClosure };
|
|
1125
|
+
return { start, stop, poll, state, reemit, viewFor, dismissConfirmed, dismissNoticeConfirmed, ownerSnapshotAsks, rememberLiveAsk, applyOwnerClosure };
|
|
1013
1126
|
}
|
|
1014
1127
|
|
|
1015
1128
|
module.exports = { createEventFeedClient };
|