@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
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/notify/event-feed-notices-routes.js — the OWNER's notice dismissal surface.
|
|
3
|
+
//
|
|
4
|
+
// DELETE /event-feed/notices/:eventId clear one notice
|
|
5
|
+
// POST /event-feed/notices/dismiss-all clear the visible set (closed body)
|
|
6
|
+
//
|
|
7
|
+
// The owner is the source of truth for its own notices, so this is where a
|
|
8
|
+
// dismissal is registered: the snapshot it serves stops carrying the entry
|
|
9
|
+
// (every other device inherits that at its next read) and a closure frame is
|
|
10
|
+
// published for the peers that already hold the card live.
|
|
11
|
+
//
|
|
12
|
+
// Authorization is the ask-action pattern, reused verbatim: kill switch →
|
|
13
|
+
// proven federated origin → chain ending here (direct or two hops) → visibility
|
|
14
|
+
// ACL of the origin → `eventsAccess` + `askReplyAccess` on EVERY ring. Seeing a
|
|
15
|
+
// notice is not what grants clearing it, and no new grant name is invented.
|
|
16
|
+
//
|
|
17
|
+
// Unknown and out-of-scope ids get the SAME opaque 404: an id that does not
|
|
18
|
+
// exist and an id the peer may not see must be indistinguishable. The budget is
|
|
19
|
+
// its own instance (6/min/peer, 30/min global) and `dismiss-all` counts once:
|
|
20
|
+
// N DELETEs through two hops would die in the per-request budget.
|
|
21
|
+
|
|
22
|
+
const express = require('express');
|
|
23
|
+
const { createAskActionGate, createAskRateLimiter } = require('./event-feed-asks-routes.js');
|
|
24
|
+
|
|
25
|
+
// The event id is a crypto.randomUUID() minted by the producer registry: it is
|
|
26
|
+
// validated here BEFORE any use — a path is never built from a request value.
|
|
27
|
+
const EVENT_ID_RE = /^[0-9a-f-]{36}$/;
|
|
28
|
+
// Same cap as SNAPSHOT_MAX_NOTIFY: `dismiss-all` covers what the peer can see,
|
|
29
|
+
// which is what the snapshot it just read held.
|
|
30
|
+
const MAX_VISIBLE = 50;
|
|
31
|
+
|
|
32
|
+
function createEventFeedNoticesRoutes(deps) {
|
|
33
|
+
const r = express.Router();
|
|
34
|
+
const json = express.json({ limit: '8kb' });
|
|
35
|
+
const gate = createAskActionGate(deps);
|
|
36
|
+
const rate = createAskRateLimiter({ now: deps.now });
|
|
37
|
+
const log = typeof deps.log === 'function' ? deps.log : () => {};
|
|
38
|
+
|
|
39
|
+
// Resolve the notice for THIS peer: it must be in the history of the owner
|
|
40
|
+
// AND visible to the ASKING peer under the grants of the owner, by the same
|
|
41
|
+
// rule the snapshot applies to the envelope it would ship. Visibility is a
|
|
42
|
+
// decision of the ORIGIN (cross-chain transit is gated earlier in the hop
|
|
43
|
+
// chain): a relay never gains a visibility of its own.
|
|
44
|
+
function resolveNoticeForPeer(peer, eventId) {
|
|
45
|
+
const entry = deps.history.list().find((e) => e && e.eventId === eventId);
|
|
46
|
+
if (!entry || !entry.envelope) return null;
|
|
47
|
+
if (!peer.allowsEnvelope(entry.envelope)) return null;
|
|
48
|
+
return entry;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The closure inherits scope and cell of the original, so the publisher
|
|
52
|
+
// delivers it to exactly the peers that could see that notice. A failure to
|
|
53
|
+
// publish never turns a registered dismissal into an error: the entry is
|
|
54
|
+
// already gone from the snapshot, which is the durable half.
|
|
55
|
+
async function publishClosures(entries) {
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
try {
|
|
58
|
+
await deps.closeNotice({
|
|
59
|
+
scope: entry.envelope.scope,
|
|
60
|
+
cellId: entry.envelope.scope === 'cell' ? entry.envelope.cellId : null,
|
|
61
|
+
eventId: entry.eventId,
|
|
62
|
+
});
|
|
63
|
+
} catch (e) {
|
|
64
|
+
log(`notices: closure not published for ${String(entry.eventId).slice(0, 8)}: ${String((e && e.message) || e)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
r.delete('/:eventId', async (req, res) => {
|
|
70
|
+
const g = await gate(req, res);
|
|
71
|
+
if (!g) return;
|
|
72
|
+
const verdict = rate.check(g.peer.nodeId, g.actor);
|
|
73
|
+
if (!verdict.allowed) return res.status(429).json({ error: 'dismiss rate', reason: verdict.reason });
|
|
74
|
+
if (deps.readonly && deps.readonly()) {
|
|
75
|
+
return res.status(403).json({ error: 'READONLY: mutazione bloccata' });
|
|
76
|
+
}
|
|
77
|
+
if (g.peer.grants.askReplyAccess !== true) {
|
|
78
|
+
return res.status(403).json({ error: 'risorsa non concessa a questo nodo', reason: 'grant-required:ask-action' });
|
|
79
|
+
}
|
|
80
|
+
const eventId = String(req.params.eventId || '');
|
|
81
|
+
if (!EVENT_ID_RE.test(eventId)) {
|
|
82
|
+
return res.status(400).json({ error: 'eventId non valido' });
|
|
83
|
+
}
|
|
84
|
+
const entry = resolveNoticeForPeer(g.peer, eventId);
|
|
85
|
+
if (!entry) {
|
|
86
|
+
// Unknown and invisible are the same answer, so the surface cannot be
|
|
87
|
+
// used to probe the owner's history.
|
|
88
|
+
return res.status(404).json({ error: 'notify inesistente' });
|
|
89
|
+
}
|
|
90
|
+
const already = deps.dismissals.isDismissed(eventId);
|
|
91
|
+
if (already) return res.json({ dismissed: true, idempotent: true });
|
|
92
|
+
const written = deps.dismissals.dismiss(eventId);
|
|
93
|
+
if (!written.ok) {
|
|
94
|
+
// The store refused to represent the dismissal (declared as a
|
|
95
|
+
// degradation): answering 200 would claim a truth we do not hold.
|
|
96
|
+
return res.status(503).json({ error: 'dismissals unavailable', reason: written.reason });
|
|
97
|
+
}
|
|
98
|
+
await publishClosures([entry]);
|
|
99
|
+
return res.json({ dismissed: true, idempotent: false });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
r.post('/dismiss-all', json, async (req, res) => {
|
|
103
|
+
const g = await gate(req, res);
|
|
104
|
+
if (!g) return;
|
|
105
|
+
const verdict = rate.check(g.peer.nodeId, g.actor);
|
|
106
|
+
if (!verdict.allowed) return res.status(429).json({ error: 'dismiss rate', reason: verdict.reason });
|
|
107
|
+
if (deps.readonly && deps.readonly()) {
|
|
108
|
+
return res.status(403).json({ error: 'READONLY: mutazione bloccata' });
|
|
109
|
+
}
|
|
110
|
+
if (g.peer.grants.askReplyAccess !== true) {
|
|
111
|
+
return res.status(403).json({ error: 'risorsa non concessa a questo nodo', reason: 'grant-required:ask-action' });
|
|
112
|
+
}
|
|
113
|
+
// CLOSED body: the set to clear is what the peer can see, never a list the
|
|
114
|
+
// request brings. A body that tries to choose it is a 400 before anything.
|
|
115
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
116
|
+
for (const key of Object.keys(body)) {
|
|
117
|
+
return res.status(400).json({ error: `campo non ammesso: "${key}"` });
|
|
118
|
+
}
|
|
119
|
+
const visible = deps.history.list()
|
|
120
|
+
.filter((e) => e && e.envelope && g.peer.allowsEnvelope(e.envelope))
|
|
121
|
+
.slice(-MAX_VISIBLE);
|
|
122
|
+
const cleared = [];
|
|
123
|
+
for (const entry of visible) {
|
|
124
|
+
if (deps.dismissals.isDismissed(entry.eventId)) continue;
|
|
125
|
+
const written = deps.dismissals.dismiss(entry.eventId);
|
|
126
|
+
if (written.ok) cleared.push(entry);
|
|
127
|
+
}
|
|
128
|
+
await publishClosures(cleared);
|
|
129
|
+
return res.json({ dismissed: cleared.length });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return r;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { createEventFeedNoticesRoutes, EVENT_ID_RE, MAX_VISIBLE };
|
|
@@ -94,6 +94,27 @@ const PRODUCERS = {
|
|
|
94
94
|
ts: typeof p.ts === 'number' ? p.ts : undefined,
|
|
95
95
|
}),
|
|
96
96
|
},
|
|
97
|
+
// Closure of a notice the OWNER cleared: the frame carries the event id the
|
|
98
|
+
// receivers already hold, and inherits the scope (and the cell) of that
|
|
99
|
+
// original — that is what delivers it to exactly the peers that could see it.
|
|
100
|
+
'notify-closed': {
|
|
101
|
+
scope: 'cell',
|
|
102
|
+
requires: ['cellId'],
|
|
103
|
+
build: (p) => ({
|
|
104
|
+
type: 'notify-closed',
|
|
105
|
+
eventId: cleanStr(p.eventId, 64) || '',
|
|
106
|
+
ts: typeof p.ts === 'number' ? p.ts : undefined,
|
|
107
|
+
}),
|
|
108
|
+
},
|
|
109
|
+
'notify-closed-node': {
|
|
110
|
+
scope: 'node',
|
|
111
|
+
requires: [],
|
|
112
|
+
build: (p) => ({
|
|
113
|
+
type: 'notify-closed-node',
|
|
114
|
+
eventId: cleanStr(p.eventId, 64) || '',
|
|
115
|
+
ts: typeof p.ts === 'number' ? p.ts : undefined,
|
|
116
|
+
}),
|
|
117
|
+
},
|
|
97
118
|
// A file notice: name/caption/session only. A file event is an alert — the
|
|
98
119
|
// download is a separate, separately authorized operation.
|
|
99
120
|
'file-notice': {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
const express = require('express');
|
|
15
15
|
const nodesStore = require('../nodes/store.js');
|
|
16
16
|
const eventFeedAcl = require('./event-feed-acl.js');
|
|
17
|
+
const askCell = require('./ask-cell.js');
|
|
17
18
|
|
|
18
19
|
const SNAPSHOT_MAX_BYTES = 3 * 1024 * 1024;
|
|
19
20
|
const SNAPSHOT_MAX_ASKS = 100;
|
|
@@ -197,6 +198,7 @@ function createEventFeedRoutes(deps) {
|
|
|
197
198
|
async function buildSnapshot({ peer, self }) {
|
|
198
199
|
// Cursor captured atomically with the snapshot content: the feed engine is
|
|
199
200
|
// synchronous, so one turn later nothing has been inserted in between.
|
|
201
|
+
const dismissals = deps.noticeDismissals || null;
|
|
200
202
|
const st = deps.eventFeed.status(peer.nodeId);
|
|
201
203
|
const cursor = st ? `${st.viewEpoch}:${st.seq}` : null;
|
|
202
204
|
const viewEpoch = st ? st.viewEpoch : null;
|
|
@@ -210,6 +212,12 @@ function createEventFeedRoutes(deps) {
|
|
|
210
212
|
// alias ancora aperti. Non si spedisce Nessuno snapshot: si rilancia un
|
|
211
213
|
// errore tipizzato che l'handler traduce in 503 {error:"asks-unreadable"}.
|
|
212
214
|
let asks = [];
|
|
215
|
+
// SOURCE counts, taken BEFORE any slice: the marker means "there was more
|
|
216
|
+
// than this page holds", and only the source can tell. A page sliced to
|
|
217
|
+
// exactly the cap from a source no bigger than the cap is the WHOLE set.
|
|
218
|
+
let askSourceCount = 0;
|
|
219
|
+
let notifySourceCount = 0;
|
|
220
|
+
let cellSourceCount = 0;
|
|
213
221
|
try {
|
|
214
222
|
// Health gate PRIMA di list(): un asks.json ESISTENTE ma MALFORMATO
|
|
215
223
|
// (virgola finale, asks non array) produce un list() [] SILENZIOSO — senza
|
|
@@ -234,10 +242,15 @@ function createEventFeedRoutes(deps) {
|
|
|
234
242
|
// guardia e' esplicita sui campi di provenienza, non dedotta dalla
|
|
235
243
|
// visibilita' della cella.
|
|
236
244
|
.filter((a) => !a.originNode && !(a.ownerId && self && a.ownerId !== self))
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
245
|
+
// The cell is resolved ONCE, by this owner, through its own definitions, and
|
|
246
|
+
// it travels with the ask: a session with a custom tmux name does not decode
|
|
247
|
+
// to a cell on the receiving side, and the receiver must not guess it. Only
|
|
248
|
+
// a well-formed id is published; an unresolved one is simply absent.
|
|
249
|
+
.map((a) => ({ id: a.id, question: a.question, options: a.options, session: a.session, ts: a.ts, cell: cellForSession(a.session) }))
|
|
250
|
+
.filter((a) => peer.allows({ scope: 'cell', cellId: a.cell }))
|
|
251
|
+
.map(({ cell, ...ask }) => (askCell.isCellId(cell) ? { ...ask, cellId: cell } : ask));
|
|
252
|
+
askSourceCount = visible.length;
|
|
253
|
+
asks = visible.slice(0, SNAPSHOT_MAX_ASKS);
|
|
241
254
|
} catch (e) {
|
|
242
255
|
// Una riga, nessun dato sensibile: il messaggio dello store e' gia'
|
|
243
256
|
// limitato a nome file e causa (v. persist.js readJsonSafe).
|
|
@@ -248,7 +261,12 @@ function createEventFeedRoutes(deps) {
|
|
|
248
261
|
}
|
|
249
262
|
|
|
250
263
|
// Notify history: local-only, ACL-filtered per envelope, capped.
|
|
251
|
-
const
|
|
264
|
+
const visibleNotifications = deps.history.list()
|
|
265
|
+
// Cleared notices are gone from the owner's view BEFORE the ACL: the
|
|
266
|
+
// dismissal is the owner's own statement about its own notice, and every
|
|
267
|
+
// device that reads this snapshot inherits it (that is what makes the
|
|
268
|
+
// cleanup true everywhere, not just on the device that asked).
|
|
269
|
+
.filter((e) => !(dismissals && dismissals.isDismissed(e.eventId)))
|
|
252
270
|
.filter((e) => peer.allows(e.envelope))
|
|
253
271
|
.map((e) => {
|
|
254
272
|
// Node frames can carry cell lists (fleet state): project them with
|
|
@@ -258,8 +276,9 @@ function createEventFeedRoutes(deps) {
|
|
|
258
276
|
return { ...e, envelope: { ...e.envelope, frame: { ...e.envelope.frame, cells } } };
|
|
259
277
|
}
|
|
260
278
|
return e;
|
|
261
|
-
})
|
|
262
|
-
|
|
279
|
+
});
|
|
280
|
+
notifySourceCount = visibleNotifications.length;
|
|
281
|
+
const notifications = visibleNotifications.slice(-SNAPSHOT_MAX_NOTIFY);
|
|
263
282
|
|
|
264
283
|
// Fleet state: projected by visibility, hidden cells contribute nothing.
|
|
265
284
|
let fleetState = { available: false, cells: [] };
|
|
@@ -267,7 +286,9 @@ function createEventFeedRoutes(deps) {
|
|
|
267
286
|
const fleet = await deps.fleetP;
|
|
268
287
|
if (fleet && typeof fleet.status === 'function') {
|
|
269
288
|
const stt = await fleet.status();
|
|
270
|
-
const
|
|
289
|
+
const sourceCells = (stt && Array.isArray(stt.cells) ? stt.cells : []);
|
|
290
|
+
cellSourceCount = sourceCells.length;
|
|
291
|
+
const raw = sourceCells
|
|
271
292
|
.slice(0, SNAPSHOT_MAX_CELLS)
|
|
272
293
|
.map((c) => ({ cell: c && c.cell, active: c && c.active === true }));
|
|
273
294
|
fleetState = { available: stt && stt.available === true, cells: peer.projectCells(raw) };
|
|
@@ -289,10 +310,14 @@ function createEventFeedRoutes(deps) {
|
|
|
289
310
|
nodeState: { nodeId: self },
|
|
290
311
|
historyStatus: deps.history.status(),
|
|
291
312
|
};
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
313
|
+
// The marker means "the source had MORE than this page holds": decided on
|
|
314
|
+
// the source counts, BEFORE the slice. Marking a page that is exactly at
|
|
315
|
+
// the cap told the client "incomplete" about complete pages — and made a
|
|
316
|
+
// full-window owner unimportable forever (a notification history cannot
|
|
317
|
+
// even exceed its own cap).
|
|
318
|
+
if (askSourceCount > SNAPSHOT_MAX_ASKS
|
|
319
|
+
|| notifySourceCount > SNAPSHOT_MAX_NOTIFY
|
|
320
|
+
|| cellSourceCount > SNAPSHOT_MAX_CELLS) {
|
|
296
321
|
built.resyncRequired = true;
|
|
297
322
|
}
|
|
298
323
|
return built;
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/notify/notice-dismissals-queue.js — durable queue for notice dismissals
|
|
3
|
+
// this node could not hand to the owner yet, plus the drainer that retries them.
|
|
4
|
+
//
|
|
5
|
+
// The local intent is ALREADY honoured when a record lands here (the card is
|
|
6
|
+
// gone from this node's view): what is pending is the owner's copy, so the other
|
|
7
|
+
// devices stop seeing the notice too. Records carry no content — owner, event
|
|
8
|
+
// id, times, sync state — never a title, never a body.
|
|
9
|
+
//
|
|
10
|
+
// Bounds and failure policy mirror the dismissals store and the history: 200
|
|
11
|
+
// records, 64 KiB, retention equal to the notice's own life (15 minutes),
|
|
12
|
+
// atomic tmp+rename 0600, degradation declared in status(). An entry whose
|
|
13
|
+
// notice has expired is not worth carrying: it leaves the queue.
|
|
14
|
+
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
const MAX_ENTRIES = 200;
|
|
19
|
+
const MAX_BYTES = 64 * 1024;
|
|
20
|
+
const MAX_MS = 15 * 60 * 1000;
|
|
21
|
+
const SCHEMA = 'nexuscrew-notice-dismissal-queue-v1';
|
|
22
|
+
const OWNER_ID_RE = /^[a-f0-9]{32}$/;
|
|
23
|
+
const STATES = new Set(['pending', 'blocked']);
|
|
24
|
+
|
|
25
|
+
function createNoticeDismissalQueue({ filePath, now = Date.now, limits = {} }) {
|
|
26
|
+
const maxEntries = limits.maxEntries || MAX_ENTRIES;
|
|
27
|
+
const maxBytes = limits.maxBytes || MAX_BYTES;
|
|
28
|
+
const maxMs = limits.maxMs || MAX_MS;
|
|
29
|
+
|
|
30
|
+
let cache = null;
|
|
31
|
+
let degraded = null;
|
|
32
|
+
// The last load could not read or understand the file: the in-memory list
|
|
33
|
+
// is INCOMPLETE until a retry succeeds. While it lasts, the operations the
|
|
34
|
+
// node accepts go to an ordered journal and nothing is written: a successful
|
|
35
|
+
// read replays the journal on top of the disk before writing.
|
|
36
|
+
let loadFailed = false;
|
|
37
|
+
let journal = [];
|
|
38
|
+
|
|
39
|
+
function validateList(raw) {
|
|
40
|
+
if (!Array.isArray(raw)) return null;
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const entry of raw) {
|
|
43
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
44
|
+
if (!OWNER_ID_RE.test(String(entry.ownerId || ''))) return null;
|
|
45
|
+
if (typeof entry.eventId !== 'string' || entry.eventId.length === 0) return null;
|
|
46
|
+
if (!STATES.has(entry.syncState)) return null;
|
|
47
|
+
if (typeof entry.at !== 'number' || !Number.isFinite(entry.at)) return null;
|
|
48
|
+
out.push({
|
|
49
|
+
ownerId: entry.ownerId, eventId: entry.eventId,
|
|
50
|
+
at: entry.at, expiresAt: Number.isFinite(entry.expiresAt) ? entry.expiresAt : entry.at + maxMs,
|
|
51
|
+
syncState: entry.syncState,
|
|
52
|
+
attempts: Number.isSafeInteger(entry.attempts) && entry.attempts >= 0 ? entry.attempts : 0,
|
|
53
|
+
nextRetryAt: Number.isFinite(entry.nextRetryAt) ? entry.nextRetryAt : 0,
|
|
54
|
+
...(typeof entry.lastReason === 'string' ? { lastReason: entry.lastReason } : {}),
|
|
55
|
+
...(typeof entry.retryBinding === 'string' || entry.retryBinding === null ? { retryBinding: entry.retryBinding } : {}),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function prune(entries, t) {
|
|
62
|
+
let out = entries.filter((e) => e.expiresAt > t);
|
|
63
|
+
if (out.length > maxEntries) out = out.slice(out.length - maxEntries);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Pure disk read, no cache: {ok:true, entries} or {ok:false} with the
|
|
68
|
+
// degradation reason set. ENOENT is a legitimate absence, not a failure.
|
|
69
|
+
function readFromDisk() {
|
|
70
|
+
let raw;
|
|
71
|
+
try { raw = fs.readFileSync(filePath, 'utf8'); }
|
|
72
|
+
catch (e) {
|
|
73
|
+
if (!(e && e.code === 'ENOENT')) degraded = 'queue-read-failed';
|
|
74
|
+
return e && e.code === 'ENOENT' ? { ok: true, entries: [] } : { ok: false };
|
|
75
|
+
}
|
|
76
|
+
if (Buffer.byteLength(raw, 'utf8') > maxBytes * 4) { degraded = 'queue-oversize'; return { ok: false }; }
|
|
77
|
+
let parsed = null;
|
|
78
|
+
try { parsed = JSON.parse(raw); } catch (_) { degraded = 'queue-schema'; return { ok: false }; }
|
|
79
|
+
const entries = parsed && parsed.schema === SCHEMA ? validateList(parsed.entries) : null;
|
|
80
|
+
if (!entries) { degraded = 'queue-schema'; return { ok: false }; }
|
|
81
|
+
return { ok: true, entries: prune(entries, now()) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function load() {
|
|
85
|
+
if (cache) return cache;
|
|
86
|
+
const read = readFromDisk();
|
|
87
|
+
if (!read.ok) {
|
|
88
|
+
loadFailed = true;
|
|
89
|
+
cache = [];
|
|
90
|
+
return cache;
|
|
91
|
+
}
|
|
92
|
+
loadFailed = false;
|
|
93
|
+
cache = read.entries;
|
|
94
|
+
return cache;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function persist(entries) {
|
|
98
|
+
let body;
|
|
99
|
+
try { body = JSON.stringify({ schema: SCHEMA, savedAt: now(), entries }); }
|
|
100
|
+
catch (_) { degraded = 'queue-write-failed'; return { ok: false, reason: 'write-failed', list: entries }; }
|
|
101
|
+
if (Buffer.byteLength(body, 'utf8') > maxBytes) { degraded = 'queue-oversize'; return { ok: false, reason: 'oversize', list: entries }; }
|
|
102
|
+
try {
|
|
103
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
104
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
105
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
106
|
+
fs.renameSync(tmp, filePath);
|
|
107
|
+
} catch (_) { degraded = 'queue-write-failed'; return { ok: false, reason: 'write-failed', list: entries }; }
|
|
108
|
+
// A successful write holds everything this queue knows: the degradation is
|
|
109
|
+
// over and the answers go back to attesting full durability. A past
|
|
110
|
+
// failure must not stick to every future answer.
|
|
111
|
+
loadFailed = false;
|
|
112
|
+
degraded = null;
|
|
113
|
+
return { ok: true, list: entries };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Recovery from a failed load: the disk is the base and the journal replays
|
|
117
|
+
// on top of it IN ORDER (the node's current intent wins), then the normal
|
|
118
|
+
// prune applies — the oldest records leave, a fresh accepted intent never
|
|
119
|
+
// does. Returns false when the disk is still unreadable: nothing is written
|
|
120
|
+
// then, the intent stays in memory (journal included) and the answers keep
|
|
121
|
+
// declaring durable:false with the read degradation reason.
|
|
122
|
+
function attemptRecovery() {
|
|
123
|
+
const read = readFromDisk();
|
|
124
|
+
if (!read.ok) return false;
|
|
125
|
+
let entries = read.entries;
|
|
126
|
+
for (const op of journal) {
|
|
127
|
+
if (op.op === 'enqueue') {
|
|
128
|
+
if (!entries.some((e) => e.ownerId === op.ownerId && e.eventId === op.eventId)) entries.push(op.record);
|
|
129
|
+
} else if (op.op === 'update') {
|
|
130
|
+
const i = entries.findIndex((e) => e.ownerId === op.ownerId && e.eventId === op.eventId);
|
|
131
|
+
if (i >= 0) entries[i] = { ...entries[i], ...op.patch };
|
|
132
|
+
} else {
|
|
133
|
+
entries = entries.filter((e) => !(e.ownerId === op.ownerId && e.eventId === op.eventId));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const rebuilt = prune(entries, now());
|
|
137
|
+
cache = rebuilt;
|
|
138
|
+
const written = persist(rebuilt);
|
|
139
|
+
if (!written.ok) return false;
|
|
140
|
+
journal = [];
|
|
141
|
+
loadFailed = false;
|
|
142
|
+
degraded = null;
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function indexOf(entries, ownerId, eventId) {
|
|
147
|
+
return entries.findIndex((e) => e.ownerId === ownerId && e.eventId === eventId);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function get(ownerId, eventId) {
|
|
151
|
+
const entries = prune(load(), now());
|
|
152
|
+
const i = indexOf(entries, ownerId, eventId);
|
|
153
|
+
return i < 0 ? null : { ...entries[i] };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function list(ownerId) {
|
|
157
|
+
const entries = prune(load(), now());
|
|
158
|
+
return entries.filter((e) => (ownerId ? e.ownerId === ownerId : true)).map((e) => ({ ...e }));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function enqueue({ ownerId, eventId } = {}) {
|
|
162
|
+
if (!OWNER_ID_RE.test(String(ownerId || ''))) return { ok: false, reason: 'bad-owner' };
|
|
163
|
+
if (typeof eventId !== 'string' || !eventId) return { ok: false, reason: 'bad-event' };
|
|
164
|
+
const entries = prune(load(), now());
|
|
165
|
+
const i = indexOf(entries, ownerId, eventId);
|
|
166
|
+
if (i >= 0) return { ok: true, record: { ...entries[i] }, existed: true };
|
|
167
|
+
const t = now();
|
|
168
|
+
const record = { ownerId, eventId, at: t, expiresAt: t + maxMs, syncState: 'pending', attempts: 0, nextRetryAt: 0 };
|
|
169
|
+
entries.push(record);
|
|
170
|
+
const kept = prune(entries, t);
|
|
171
|
+
cache = kept;
|
|
172
|
+
if (loadFailed) {
|
|
173
|
+
journal.push({ op: 'enqueue', ownerId, eventId, record });
|
|
174
|
+
attemptRecovery();
|
|
175
|
+
return { ok: true, record: { ...record }, existed: false };
|
|
176
|
+
}
|
|
177
|
+
const written = persist(kept);
|
|
178
|
+
if (written.ok) degraded = null;
|
|
179
|
+
return { ok: true, record: { ...record }, existed: false };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function update(ownerId, eventId, patch = {}) {
|
|
183
|
+
const entries = prune(load(), now());
|
|
184
|
+
const i = indexOf(entries, ownerId, eventId);
|
|
185
|
+
if (i < 0) return { ok: false, reason: 'unknown' };
|
|
186
|
+
entries[i] = { ...entries[i], ...patch, ownerId, eventId };
|
|
187
|
+
cache = prune(entries, now());
|
|
188
|
+
if (loadFailed) {
|
|
189
|
+
journal.push({ op: 'update', ownerId, eventId, patch });
|
|
190
|
+
attemptRecovery();
|
|
191
|
+
return { ok: true, record: get(ownerId, eventId) || { ...entries[i] } };
|
|
192
|
+
}
|
|
193
|
+
const written = persist(entries);
|
|
194
|
+
if (written.ok) degraded = null;
|
|
195
|
+
return { ok: true, record: { ...entries[i] } };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Confirmed: the owner has it, the record leaves the queue. While the load
|
|
199
|
+
// is failing the removal is journaled even when the record is not in the
|
|
200
|
+
// incomplete in-memory list: the replay applies it to what the disk holds.
|
|
201
|
+
function confirm(ownerId, eventId) {
|
|
202
|
+
const entries = prune(load(), now());
|
|
203
|
+
const i = indexOf(entries, ownerId, eventId);
|
|
204
|
+
if (loadFailed) {
|
|
205
|
+
journal.push({ op: 'confirm', ownerId, eventId });
|
|
206
|
+
if (i >= 0) entries.splice(i, 1);
|
|
207
|
+
cache = prune(entries, now());
|
|
208
|
+
attemptRecovery();
|
|
209
|
+
return { ok: true, removed: i >= 0 };
|
|
210
|
+
}
|
|
211
|
+
if (i < 0) return { ok: true, removed: false };
|
|
212
|
+
entries.splice(i, 1);
|
|
213
|
+
cache = entries;
|
|
214
|
+
const written = persist(entries);
|
|
215
|
+
if (written.ok) degraded = null;
|
|
216
|
+
return { ok: true, removed: true };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function status() {
|
|
220
|
+
const entries = prune(load(), now());
|
|
221
|
+
return {
|
|
222
|
+
degraded: degraded || null,
|
|
223
|
+
count: entries.length,
|
|
224
|
+
pending: entries.filter((e) => e.syncState === 'pending').length,
|
|
225
|
+
blocked: entries.filter((e) => e.syncState === 'blocked').length,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { enqueue, get, list, update, confirm, status, MAX_MS };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function deferDelay(attempts, random, retryAfterMs = 0) {
|
|
233
|
+
const base = Math.min(300000, 1000 * 2 ** Math.min(attempts, 9)) * (0.5 + random() * 0.5);
|
|
234
|
+
return Math.max(retryAfterMs, base);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// The drainer: one attempt per owner at a time, a global budget and a per-owner
|
|
238
|
+
// one, exponential backoff with jitter, and `blocked` records that stay put
|
|
239
|
+
// until the binding to that owner changes (a re-pair is the only thing that can
|
|
240
|
+
// make a refusal meaningful again).
|
|
241
|
+
function createNoticeDismissalDrainer({
|
|
242
|
+
queue, relay, now = Date.now, random = Math.random,
|
|
243
|
+
setTimer = setTimeout, clearTimer = clearTimeout, intervalMs = 15000,
|
|
244
|
+
globalBudget = 4, ownerBudget = 1, budgetWindowMs = 60000,
|
|
245
|
+
bindingForOwner = () => null, readonly = () => false, log = () => {},
|
|
246
|
+
} = {}) {
|
|
247
|
+
const flights = new Set();
|
|
248
|
+
const globalHits = [];
|
|
249
|
+
const ownerHits = new Map();
|
|
250
|
+
let stopped = false;
|
|
251
|
+
let timer = null;
|
|
252
|
+
let running = null;
|
|
253
|
+
|
|
254
|
+
function defer(record, reason, blocked = false, retryAfterMs = 0) {
|
|
255
|
+
const attempts = (record.attempts || 0) + 1;
|
|
256
|
+
return queue.update(record.ownerId, record.eventId, {
|
|
257
|
+
syncState: blocked ? 'blocked' : 'pending',
|
|
258
|
+
retryBinding: bindingForOwner(record.ownerId),
|
|
259
|
+
attempts, lastReason: reason || 'owner-unreachable',
|
|
260
|
+
nextRetryAt: now() + deferDelay(attempts, random, retryAfterMs),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function rejected(record, result = {}) {
|
|
265
|
+
const reason = result.reason || 'owner-unreachable';
|
|
266
|
+
const pending = result.transient === true || (result.code === 429) || (typeof result.code === 'number' && result.code >= 500);
|
|
267
|
+
return defer(record, reason, !pending, result.retryAfterMs || 0);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function attempt(record) {
|
|
271
|
+
const key = `${record.ownerId}|${record.eventId}`;
|
|
272
|
+
if (flights.has(key) || stopped || readonly()) return;
|
|
273
|
+
flights.add(key);
|
|
274
|
+
try {
|
|
275
|
+
const current = queue.get(record.ownerId, record.eventId);
|
|
276
|
+
if (!current || current.syncState !== 'pending') return;
|
|
277
|
+
const result = await relay.relayNoticeDismiss({ ownerId: record.ownerId, eventId: record.eventId });
|
|
278
|
+
if (stopped || readonly()) return;
|
|
279
|
+
const latest = queue.get(record.ownerId, record.eventId);
|
|
280
|
+
if (!latest || latest.syncState !== 'pending' || latest.at !== current.at) return;
|
|
281
|
+
if (result.ok) { queue.confirm(latest.ownerId, latest.eventId); return; }
|
|
282
|
+
if (result.reason === 'unsupported') { defer(latest, 'unsupported', true); return; }
|
|
283
|
+
rejected(latest, result);
|
|
284
|
+
} catch (error) {
|
|
285
|
+
const current = queue.get(record.ownerId, record.eventId);
|
|
286
|
+
if (!stopped && current && current.syncState === 'pending') defer(current, 'owner-unreachable');
|
|
287
|
+
log(`notice dismissal synchronization failed: ${String(error.message || error)}`);
|
|
288
|
+
} finally { flights.delete(key); }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function run() {
|
|
292
|
+
if (stopped || readonly()) return;
|
|
293
|
+
const t = now();
|
|
294
|
+
const pruneHits = (hits) => { while (hits.length && hits[0] <= t - budgetWindowMs) hits.shift(); };
|
|
295
|
+
pruneHits(globalHits);
|
|
296
|
+
for (const [ownerId, hits] of ownerHits) { pruneHits(hits); if (!hits.length) ownerHits.delete(ownerId); }
|
|
297
|
+
for (let record of queue.list()) {
|
|
298
|
+
if (globalHits.length >= globalBudget) break;
|
|
299
|
+
if (record.syncState === 'blocked') {
|
|
300
|
+
const binding = bindingForOwner(record.ownerId);
|
|
301
|
+
if (binding === null || binding === record.retryBinding) continue;
|
|
302
|
+
const resumed = queue.update(record.ownerId, record.eventId, { syncState: 'pending', nextRetryAt: 0, retryBinding: binding });
|
|
303
|
+
if (resumed.ok) record = resumed.record; else continue;
|
|
304
|
+
}
|
|
305
|
+
if (record.syncState !== 'pending' || (record.nextRetryAt || 0) > now()) continue;
|
|
306
|
+
const hits = ownerHits.get(record.ownerId) || [];
|
|
307
|
+
if (hits.length >= ownerBudget) continue;
|
|
308
|
+
hits.push(t); ownerHits.set(record.ownerId, hits); globalHits.push(t);
|
|
309
|
+
await attempt(record);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function drain() {
|
|
314
|
+
if (running) return running;
|
|
315
|
+
const flight = run().finally(() => { if (running === flight) running = null; });
|
|
316
|
+
running = flight;
|
|
317
|
+
return flight;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function schedule() {
|
|
321
|
+
if (stopped || timer) return;
|
|
322
|
+
timer = setTimer(() => { timer = null; void drain().catch(error => log(String(error.message || error))).finally(schedule); }, intervalMs);
|
|
323
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function start() { schedule(); void drain().catch(error => log(String(error.message || error))); }
|
|
327
|
+
function stop() { stopped = true; if (timer) clearTimer(timer); timer = null; }
|
|
328
|
+
function nudge() { if (!stopped) void drain().catch(error => log(String(error.message || error))); }
|
|
329
|
+
|
|
330
|
+
return { start, stop, drain, nudge };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
module.exports = {
|
|
334
|
+
createNoticeDismissalQueue, createNoticeDismissalDrainer,
|
|
335
|
+
MAX_ENTRIES, MAX_BYTES, MAX_MS, SCHEMA,
|
|
336
|
+
};
|