@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.
@@ -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 };
@@ -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) => ({ ...a, ownerId }))
306
+ .map((a) => withOwnerCell(a, ownerId, knownCells))
238
307
  : [];
239
308
  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 })) : [];
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, ts: Date.now(),
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, ts: Date.now() });
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 a ogni cap).
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). 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
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 };