@mmmbuto/nexuscrew 0.9.64 → 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.
@@ -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, binding } = require('../nodes/ask-slot-verifier.js');
20
- const RELAY_DEADLINE_MS = 8000;
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 proofScope = nodesPath || loadStore;
41
- function deadlineResult(op, args, capability) {
42
- if (capability) return { ownerId: args.ownerId, askId: args.askId, canReply: false, status: 'unreachable', reason: 'relay-deadline' };
43
- if (op.answer && op.delivered && args.rid) {
44
- return { ok: false, code: 200, uncertain: true, requestId: args.rid, reason: 'delivery-unknown', error: 'esito incerto: verifica lo stato prima di rispondere' };
45
- }
46
- return { ok: false, code: 504, reason: 'relay-deadline', error: 'relay deadline' };
47
- }
48
- async function operation(args, work, capability = false, answer = false) {
49
- const controller = new AbortController();
50
- const op = { controller, answer, delivered: false, expired: false, proofKey: null, proofBinding: null };
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
- view.notifications = Array.isArray(snap.notifications) ? snap.notifications.map((e) => ({ ...e, ownerId })) : [];
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, ts: Date.now(),
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, ts: Date.now() });
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 a ogni cap).
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). Il cap di pagina vale
609
- // a quota PIENA (>= SNAPSHOT_MAX_ASKS): il produttore marca resyncRequired
610
- // proprio a quella quota, quindi un elenco al cap SENZA marcatore non è
611
- // una forma dell'owner e non prova l'assenza di nessuna domanda.
612
- if (snap.asks.length >= SNAPSHOT_MAX_ASKS
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 };