@mmmbuto/nexuscrew 0.9.77 → 0.9.78

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/lib/server.js CHANGED
@@ -461,7 +461,7 @@ function createServer(opts = {}) {
461
461
  // route API: nessuna cella puo dichiararla dal body.
462
462
  const nodesPath = cfg.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
463
463
  const identityOwnerInstanceId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
464
- const fleetP = selectProvider({ ...cfg, ensureTmuxProtection, identityOwnerInstanceId }).then((p) => p.fleet);
464
+ const fleetP = opts.fleetP || selectProvider({ ...cfg, ensureTmuxProtection, identityOwnerInstanceId }).then((p) => p.fleet);
465
465
 
466
466
  // Multi-node: nodes.json e' la fonte dati. Il proxy risolve <name>
467
467
  // -> {localPort, token} leggendo lo store ad ogni richiesta (fresh: rotazione
@@ -872,7 +872,14 @@ function createServer(opts = {}) {
872
872
  // routes never accept the NexusCrew UI bearer and expose only pair, poll and
873
873
  // self-unpair. Operator management remains behind /api + federation ACL.
874
874
  app.use('/vl-node/v1', vlNodePublicRoutes({
875
- storePath: vlNodesPath, broker: vlNodeBroker, ownerId: vlOwnerId,
875
+ storePath: vlNodesPath,
876
+ broker: vlNodeBroker,
877
+ ownerId: vlOwnerId,
878
+ cellSubmit: opts.cellSubmit || ((session, text, meta) => submitToSession(cfg.tmuxBin, session, text, {
879
+ engine: meta && meta.engine,
880
+ })),
881
+ fleetP,
882
+ readonly: proxyReadonly,
876
883
  }));
877
884
  // This route is deliberately outside the bearer-authenticated federation
878
885
  // router. It emits a MAC only on a listener owned by the expected slot and
@@ -2,6 +2,7 @@
2
2
 
3
3
  const crypto = require('node:crypto');
4
4
  const { NODE_ID_RE } = require('./store.js');
5
+ const { processOutbox, createRateLimiter } = require('./outbox.js');
5
6
 
6
7
  const SESSION_RE = /^[a-f0-9]{32}$/;
7
8
  const PROTOCOL = 'vl-node/1';
@@ -154,6 +155,12 @@ function createBroker(options = {}) {
154
155
  const setTimer = options.setTimeout || setTimeout;
155
156
  const clearTimer = options.clearTimeout || clearTimeout;
156
157
  const randomBytes = options.randomBytes || crypto.randomBytes;
158
+ const optionsStorePath = options.storePath;
159
+ const optionsOwnerId = options.ownerId;
160
+ const optionsCellSubmit = options.cellSubmit || options.submit;
161
+ const optionsFleetP = options.fleetStatus || options.fleetP;
162
+ const optionsReadonly = options.readonly;
163
+ const rateLimiter = options.rateLimiter || createRateLimiter();
157
164
  const states = new Map();
158
165
 
159
166
  function ensure(node) {
@@ -172,6 +179,7 @@ function createBroker(options = {}) {
172
179
  events: [],
173
180
  eventsCursor: 0,
174
181
  eventsBytes: 0,
182
+ outboxCursor: null,
175
183
  };
176
184
  states.set(node.nodeId, state);
177
185
  }
@@ -265,15 +273,61 @@ function createBroker(options = {}) {
265
273
  return { ok: true, state, acknowledged };
266
274
  }
267
275
 
268
- function poll(node, rawHeartbeat, { waitMs = MAX_WAIT_MS, signal = null } = {}) {
276
+ async function poll(node, rawHeartbeat, options = {}) {
277
+ const { waitMs = MAX_WAIT_MS, signal = null } = options;
269
278
  const observed = observe(node, rawHeartbeat);
270
- if (!observed.ok) return Promise.resolve({ type: 'error', code: observed.code });
279
+ if (!observed.ok) return { type: 'error', code: observed.code };
271
280
  const state = observed.state;
272
281
  finishWaiter(state, { type: 'superseded', generation: state.generation });
273
- if (observed.acknowledged) {
274
- return Promise.resolve({
275
- type: 'acknowledged', generation: state.generation, eventsCursor: state.eventsCursor,
282
+ // Identity of THIS poll. Processing the outbox waits for the
283
+ // directory della flotta, e nel frattempo un altro poll (seq piu'
284
+ // recente, sessione nuova, o forget + ri-pair) puo' superarlo. Dopo
285
+ // l'await l'identita' si ricontrolla: un poll superato non deve
286
+ // installare un waiter sopra quello piu' recente, e risponde subito.
287
+ const heartbeatAtEntry = state.heartbeat;
288
+ const generationAtEntry = state.generation;
289
+
290
+ // Ingest outbox if present
291
+ if (rawHeartbeat && Array.isArray(rawHeartbeat.outbox) && rawHeartbeat.outbox.length > 0) {
292
+ const outboxResult = await processOutbox({
293
+ outbox: rawHeartbeat.outbox,
294
+ node,
295
+ storePath: options.storePath || optionsStorePath,
296
+ ownerId: options.ownerId || optionsOwnerId,
297
+ fleetStatus: options.fleetStatus || options.fleetP || optionsFleetP,
298
+ submit: options.cellSubmit || options.submit || optionsCellSubmit,
299
+ now,
300
+ rateLimiter,
301
+ readonly: options.readonly || optionsReadonly,
276
302
  });
303
+ if (outboxResult.cursor) {
304
+ state.outboxCursor = outboxResult.cursor;
305
+ }
306
+ // The session, the generation or the sequence changed while
307
+ // questo poll attendeva la directory (oppure il nodo e' stato
308
+ // dimenticato): il poll e' superato. Non tocca il waiter corrente —
309
+ // che appartiene al poll piu' recente — e risponde subito. Un evento
310
+ // di forget durante l'attesa risponde revoked: il nodo non e' piu'
311
+ // in questa tabella.
312
+ if (states.get(state.nodeId) !== state
313
+ || state.heartbeat !== heartbeatAtEntry
314
+ || state.generation !== generationAtEntry) {
315
+ return {
316
+ type: states.get(state.nodeId) === state ? 'superseded' : 'revoked',
317
+ generation: state.generation,
318
+ eventsCursor: state.eventsCursor,
319
+ outboxCursor: state.outboxCursor,
320
+ };
321
+ }
322
+ }
323
+
324
+ if (observed.acknowledged) {
325
+ return {
326
+ type: 'acknowledged',
327
+ generation: state.generation,
328
+ eventsCursor: state.eventsCursor,
329
+ outboxCursor: state.outboxCursor,
330
+ };
277
331
  }
278
332
  const boundedWait = Math.max(1, Math.min(MAX_WAIT_MS, Number(waitMs) || MAX_WAIT_MS));
279
333
  return new Promise((resolve) => {
@@ -297,7 +351,11 @@ function createBroker(options = {}) {
297
351
  state.waiter = waiter;
298
352
  // Il cursore viaggia su OGNI risposta, anche su un poll scaduto a vuoto:
299
353
  // e' cosi' che il device sa quando puo' liberare il proprio buffer.
300
- }).then((value) => ({ ...value, eventsCursor: state.eventsCursor }));
354
+ }).then((value) => ({
355
+ ...value,
356
+ eventsCursor: state.eventsCursor,
357
+ outboxCursor: state.outboxCursor,
358
+ }));
301
359
  }
302
360
 
303
361
  function dispatch(nodeId, command) {
@@ -0,0 +1,333 @@
1
+ 'use strict';
2
+
3
+ const { deliverCellMessage } = require('../cells/deliver.js');
4
+ const { publicCells } = require('../cells/routes.js');
5
+ const { submitTextOk } = require('../tmux/actions.js');
6
+ const store = require('./store.js');
7
+
8
+ const MAX_OUTBOX_TEXT = 2048; // 2 KiB
9
+ const RATE_LIMIT_MINUTE_CAP = 6;
10
+ const RATE_LIMIT_HOUR_CAP = 20;
11
+ const RATE_LIMIT_MINUTE_MS = 60_000;
12
+ const RATE_LIMIT_HOUR_MS = 3_600_000;
13
+ const MSG_ID_RE = /^[a-f0-9]{32}$/;
14
+ const TARGET_RE = /^[a-f0-9]{16,64}:[A-Za-z0-9._-]{1,64}$/;
15
+ // Simple cell name (`Dev`): resolves to the LOCAL cell of the hub owner.
16
+ const SIMPLE_TARGET_RE = /^[A-Za-z0-9._-]{1,32}$/;
17
+
18
+ // In-process registry of ids currently being settled, so a concurrent
19
+ // presentation of the same id joins the first run instead of delivering again.
20
+ const inflightSettles = new Map(); // nodeId -> Map<id, Promise<outcome>>
21
+
22
+ function inflightGet(nodeId, id) {
23
+ return inflightSettles.get(nodeId)?.get(id) || null;
24
+ }
25
+
26
+ function inflightSet(nodeId, id, promise) {
27
+ if (!inflightSettles.has(nodeId)) inflightSettles.set(nodeId, new Map());
28
+ inflightSettles.get(nodeId).set(id, promise);
29
+ }
30
+
31
+ function inflightDelete(nodeId, id) {
32
+ const map = inflightSettles.get(nodeId);
33
+ if (!map) return;
34
+ map.delete(id);
35
+ if (!map.size) inflightSettles.delete(nodeId);
36
+ }
37
+
38
+ function hasIllegalControlChars(text) {
39
+ // Disallow control characters except TAB (\x09), LF (\x0a), CR (\x0d)
40
+ return /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(text);
41
+ }
42
+
43
+ function sanitizeOutboxItem(raw) {
44
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
45
+ return { item: null, error: 'invalid-item' };
46
+ }
47
+ // Deny-extra keys: only id, to, text (and from is ignored if present)
48
+ for (const key of Object.keys(raw)) {
49
+ if (key !== 'id' && key !== 'to' && key !== 'text' && key !== 'from') {
50
+ return { item: null, error: 'unknown-field' };
51
+ }
52
+ }
53
+ if (!MSG_ID_RE.test(String(raw.id || ''))) {
54
+ return { item: null, error: 'invalid-id' };
55
+ }
56
+ if (typeof raw.to !== 'string'
57
+ || !(TARGET_RE.test(raw.to) || SIMPLE_TARGET_RE.test(raw.to))) {
58
+ return { id: raw.id, item: null, error: 'invalid-target' };
59
+ }
60
+ if (typeof raw.text !== 'string') {
61
+ return { id: raw.id, item: null, error: 'invalid-text' };
62
+ }
63
+ if (Buffer.byteLength(raw.text, 'utf8') > MAX_OUTBOX_TEXT) {
64
+ return { id: raw.id, item: null, error: 'oversized' };
65
+ }
66
+ if (hasIllegalControlChars(raw.text)) {
67
+ return { id: raw.id, item: null, error: 'control-chars' };
68
+ }
69
+ // The text of a cell message is validated with the SAME rule of the send
70
+ // route: what a cell message must satisfy to be delivered at all.
71
+ if (!submitTextOk(raw.text)) {
72
+ return { id: raw.id, item: null, error: 'invalid-text' };
73
+ }
74
+ return {
75
+ item: {
76
+ id: raw.id,
77
+ to: raw.to,
78
+ text: raw.text,
79
+ },
80
+ error: null,
81
+ };
82
+ }
83
+
84
+ function createRateLimiter() {
85
+ const records = new Map(); // nodeId -> [timestamps]
86
+
87
+ function windowCounts(nodeId, now) {
88
+ const hourCutoff = now - RATE_LIMIT_HOUR_MS;
89
+ const hour = (records.get(nodeId) || []).filter((ts) => ts > hourCutoff);
90
+ records.set(nodeId, hour);
91
+ const minuteCutoff = now - RATE_LIMIT_MINUTE_MS;
92
+ return {
93
+ minute: hour.filter((ts) => ts > minuteCutoff).length,
94
+ hour: hour.length,
95
+ };
96
+ }
97
+
98
+ // Non-consuming probe: would a message from this node be admitted right now?
99
+ function check(nodeId, now) {
100
+ const { minute, hour } = windowCounts(nodeId, now);
101
+ return minute < RATE_LIMIT_MINUTE_CAP && hour < RATE_LIMIT_HOUR_CAP;
102
+ }
103
+
104
+ function checkAndConsume(nodeId, now) {
105
+ if (!check(nodeId, now)) return false;
106
+ records.get(nodeId).push(now);
107
+ return true;
108
+ }
109
+
110
+ function reset(nodeId) {
111
+ if (nodeId) records.delete(nodeId);
112
+ else records.clear();
113
+ }
114
+
115
+ return { check, checkAndConsume, reset };
116
+ }
117
+
118
+ async function resolveFleet(fleetSource) {
119
+ if (!fleetSource) return null;
120
+ let fleet = fleetSource;
121
+ if (typeof fleet === 'function') fleet = fleet();
122
+ if (fleet && typeof fleet.then === 'function') fleet = await fleet;
123
+ if (!fleet) return null;
124
+ if (typeof fleet.cellStatus === 'function') return await fleet.cellStatus();
125
+ if (typeof fleet.status === 'function') return await fleet.status();
126
+ return fleet;
127
+ }
128
+
129
+ // Resolve and deliver a single outbox item. Returns the FINAL outcome string
130
+ // (`submitted` or `rejected:<reason>`). Authorization is re-evaluated
131
+ // immediately before the submit: an ACL revoked while the fleet
132
+ // directory was resolving ends in `rejected:acl-revoked` with no delivery.
133
+ // The read-only gate is read the same way: a server that became read-only
134
+ // while the directory was resolving ends in `rejected:readonly`.
135
+ async function settleItem({ raw, node, storePath, localOwner, fleetStatus, submit, rateLimiter, currentTime, readonly }) {
136
+ // The gate of the send route, read from its source every time it matters:
137
+ // a read-only server delivers nothing. The outcome is `rejected:readonly`,
138
+ // exactly like the other refusals of the outbox.
139
+ const readOnly = () => typeof readonly === 'function' && readonly();
140
+ // At the entry, before every other check.
141
+ if (readOnly()) {
142
+ return 'rejected:readonly';
143
+ }
144
+ const { item, error } = sanitizeOutboxItem(raw);
145
+ if (error || !item) {
146
+ return `rejected:${error || 'invalid-message'}`;
147
+ }
148
+
149
+ // A simple cell name addresses the LOCAL cell of the hub owner.
150
+ let toOwner;
151
+ let toCell;
152
+ if (item.to.includes(':')) {
153
+ [toOwner, toCell] = item.to.split(':');
154
+ } else {
155
+ toOwner = localOwner;
156
+ toCell = item.to;
157
+ }
158
+
159
+ // 1. Federation: only local targets allowed (closed by default)
160
+ if (!localOwner || toOwner !== localOwner) {
161
+ return 'rejected:remote-unsupported';
162
+ }
163
+
164
+ const aclAllows = (cell) => {
165
+ const acl = store.getNodeAcl(storePath, node.nodeId, currentTime);
166
+ return Boolean(acl && Array.isArray(acl.allowCells) && acl.allowCells.includes(cell));
167
+ };
168
+
169
+ // 2. ACL pre-check (default deny) — fail fast before any async work
170
+ if (!aclAllows(toCell)) {
171
+ return 'rejected:acl-denied';
172
+ }
173
+
174
+ // 3. Rate limit pre-check (non-consuming probe)
175
+ if (rateLimiter && !rateLimiter.check(node.nodeId, currentTime)) {
176
+ return 'rejected:rate-limited';
177
+ }
178
+
179
+ // 4. Resolve target in local directory (async: the revocation window)
180
+ const resolvedFleet = await resolveFleet(fleetStatus);
181
+ // The gate is read again after the wait: a server that became read-only
182
+ // while the directory was resolving admits nothing, and the rate limit
183
+ // is not consumed for a delivery that will not happen.
184
+ if (readOnly()) {
185
+ return 'rejected:readonly';
186
+ }
187
+ const cells = publicCells(resolvedFleet, localOwner, currentTime);
188
+ const target = cells.find((c) => c.cell === toCell);
189
+ if (!target) {
190
+ return 'rejected:unknown-target';
191
+ }
192
+ if (!target.canReceive) {
193
+ return 'rejected:target-not-active';
194
+ }
195
+
196
+ // 5. Authorization re-evaluated immediately before the submit.
197
+ if (!aclAllows(toCell)) {
198
+ return 'rejected:acl-revoked';
199
+ }
200
+ // The last gate before the delivery: nothing is delivered by a server that
201
+ // is read-only at this point.
202
+ if (readOnly()) {
203
+ return 'rejected:readonly';
204
+ }
205
+ // The authoritative rate-limit consumption happens here, at delivery time.
206
+ if (rateLimiter && !rateLimiter.checkAndConsume(node.nodeId, currentTime)) {
207
+ return 'rejected:rate-limited';
208
+ }
209
+
210
+ // 6. Deliver through submit seam
211
+ const fromLabel = `VL-${node.nodeId.slice(0, 8)}@${localOwner.slice(0, 8)}`;
212
+ try {
213
+ if (typeof submit !== 'function') {
214
+ throw new Error('cell submit function unavailable');
215
+ }
216
+ const delivery = await deliverCellMessage(submit, target, {
217
+ id: item.id,
218
+ label: fromLabel,
219
+ text: item.text,
220
+ });
221
+ if (delivery && delivery.submitted === true) {
222
+ return 'submitted';
223
+ }
224
+ return `rejected:${delivery?.reason || 'delivery-failed'}`;
225
+ } catch (e) {
226
+ return `rejected:${String(e.message || e).slice(0, 48)}`;
227
+ }
228
+ }
229
+
230
+ async function processOutbox({
231
+ outbox,
232
+ node,
233
+ storePath,
234
+ ownerId,
235
+ fleetStatus,
236
+ submit,
237
+ now = Date.now,
238
+ rateLimiter,
239
+ readonly,
240
+ }) {
241
+ if (!Array.isArray(outbox) || outbox.length === 0) {
242
+ return { outcomes: [], cursor: null };
243
+ }
244
+
245
+ const currentTime = typeof now === 'function' ? now() : now;
246
+ const localOwner = typeof ownerId === 'function' ? ownerId() : ownerId;
247
+ const outcomes = [];
248
+ let lastCursor = null;
249
+
250
+ // Process up to 1 message per poll (queue of 1 on wire)
251
+ for (const raw of outbox.slice(0, 1)) {
252
+ const rawId = raw && typeof raw === 'object' && typeof raw.id === 'string' ? raw.id : null;
253
+ const trackableId = rawId && MSG_ID_RE.test(rawId) ? rawId : null;
254
+ const msgId = trackableId || rawId;
255
+ const emit = (outcome) => {
256
+ outcomes.push({ id: msgId, outcome });
257
+ if (msgId) lastCursor = `${msgId}:${outcome}`;
258
+ };
259
+
260
+ let run = null;
261
+ if (trackableId) {
262
+ // An id currently being settled joins the first run instead of
263
+ // triggering a second delivery.
264
+ run = inflightGet(node.nodeId, trackableId);
265
+ if (!run && storePath) {
266
+ // An id already settled returns its previous outcome, without
267
+ // delivering again (TTL and caps are enforced by the store).
268
+ const seen = store.getSeenOutcome(storePath, node.nodeId, trackableId, currentTime);
269
+ if (seen !== null) {
270
+ emit(seen);
271
+ continue;
272
+ }
273
+ }
274
+ if (!run) {
275
+ const settle = settleItem({
276
+ raw,
277
+ node,
278
+ storePath,
279
+ localOwner,
280
+ fleetStatus,
281
+ submit,
282
+ rateLimiter,
283
+ currentTime,
284
+ readonly,
285
+ }).then((outcome) => {
286
+ if (storePath) {
287
+ try {
288
+ store.recordSeenOutcome(storePath, node.nodeId, { id: trackableId, outcome, at: currentTime }, currentTime);
289
+ } catch (_) { /* store issues must not corrupt the outcome */ }
290
+ }
291
+ return outcome;
292
+ });
293
+ inflightSet(node.nodeId, trackableId, settle);
294
+ run = settle;
295
+ }
296
+ }
297
+
298
+ if (run) {
299
+ let outcome;
300
+ try {
301
+ outcome = await run;
302
+ } finally {
303
+ if (trackableId) inflightDelete(node.nodeId, trackableId);
304
+ }
305
+ emit(outcome);
306
+ continue;
307
+ }
308
+
309
+ // No trackable id (invalid or missing): settle without dedup bookkeeping.
310
+ emit(await settleItem({
311
+ raw,
312
+ node,
313
+ storePath,
314
+ localOwner,
315
+ fleetStatus,
316
+ submit,
317
+ rateLimiter,
318
+ currentTime,
319
+ readonly,
320
+ }));
321
+ }
322
+
323
+ return { outcomes, cursor: lastCursor };
324
+ }
325
+
326
+ module.exports = {
327
+ MAX_OUTBOX_TEXT,
328
+ RATE_LIMIT_MINUTE_CAP,
329
+ RATE_LIMIT_HOUR_CAP,
330
+ sanitizeOutboxItem,
331
+ createRateLimiter,
332
+ processOutbox,
333
+ };
@@ -72,9 +72,9 @@ function jsonBodyError(error, _req, res, _next) {
72
72
  return res.status(400).json({ error: String(error?.message || error) });
73
73
  }
74
74
 
75
- function publicRoutes({ storePath, broker, ownerId }) {
75
+ function publicRoutes({ storePath, broker, ownerId, cellSubmit, fleetP, readonly = () => false }) {
76
76
  const r = express.Router();
77
- const body = express.json({ limit: '20kb' });
77
+ const body = express.json({ limit: '24kb' });
78
78
 
79
79
  r.post('/pair', body, (req, res) => {
80
80
  const localOwner = ownerId();
@@ -110,6 +110,11 @@ function publicRoutes({ storePath, broker, ownerId }) {
110
110
  const result = await broker.poll(node, req.body, {
111
111
  waitMs: Number(req.get('x-vl-wait-ms')) || MAX_WAIT_MS,
112
112
  signal: abort.signal,
113
+ storePath,
114
+ ownerId,
115
+ cellSubmit,
116
+ fleetP,
117
+ readonly,
113
118
  });
114
119
  if (res.headersSent || res.destroyed) return undefined;
115
120
  // Il cursore degli eventi viaggia in un HEADER, non nel corpo: la risposta
@@ -119,6 +124,9 @@ function publicRoutes({ storePath, broker, ownerId }) {
119
124
  if (Number.isSafeInteger(result.eventsCursor)) {
120
125
  res.set('x-vl-events-cursor', String(result.eventsCursor));
121
126
  }
127
+ if (result.outboxCursor) {
128
+ res.set('x-vl-outbox-cursor', String(result.outboxCursor));
129
+ }
122
130
  if (result.type === 'command') return res.json({ protocol: PROTOCOL, command: result.command });
123
131
  if (result.type === 'idle' || result.type === 'aborted' || result.type === 'acknowledged') {
124
132
  return res.status(204).end();
@@ -211,6 +219,31 @@ function apiRoutes({ storePath, broker, ownerId, readonly = () => false }) {
211
219
  });
212
220
  });
213
221
 
222
+ r.get('/:nodeId/acl', (req, res) => {
223
+ if (!store.NODE_ID_RE.test(String(req.params.nodeId || ''))
224
+ || !store.listNodes(storePath).some((node) => node.nodeId === req.params.nodeId)) {
225
+ return res.status(404).json({ error: 'node not paired' });
226
+ }
227
+ const acl = store.getNodeAcl(storePath, req.params.nodeId);
228
+ return res.json({ protocol: PROTOCOL, nodeId: req.params.nodeId, acl });
229
+ });
230
+
231
+ r.put('/:nodeId/acl', express.json({ limit: '8kb' }), (req, res) => {
232
+ if (readonly()) return res.status(403).json({ error: 'READONLY: acl blocked' });
233
+ if (!store.NODE_ID_RE.test(String(req.params.nodeId || ''))
234
+ || !store.listNodes(storePath).some((node) => node.nodeId === req.params.nodeId)) {
235
+ return res.status(404).json({ error: 'node not paired' });
236
+ }
237
+ const body = req.body;
238
+ if (!body || typeof body !== 'object' || !Array.isArray(body.allowCells)) {
239
+ return res.status(400).json({ error: 'invalid ACL payload: allowCells array required' });
240
+ }
241
+ const ok = store.setNodeAcl(storePath, req.params.nodeId, { allowCells: body.allowCells });
242
+ if (!ok) return res.status(400).json({ error: 'failed to set ACL' });
243
+ const acl = store.getNodeAcl(storePath, req.params.nodeId);
244
+ return res.json({ protocol: PROTOCOL, nodeId: req.params.nodeId, acl });
245
+ });
246
+
214
247
  r.delete('/:nodeId', (req, res) => {
215
248
  if (readonly()) return res.status(403).json({ error: 'READONLY: revoke blocked' });
216
249
  if (!store.removeNode(storePath, req.params.nodeId)) return res.status(404).json({ error: 'node not paired' });
@@ -11,8 +11,17 @@ const DEFAULT_INVITE_TTL_MS = 10 * 60 * 1000;
11
11
  const MAX_INVITE_TTL_MS = 60 * 60 * 1000;
12
12
  const MAX_STORE_BYTES = 256 * 1024;
13
13
 
14
+ // Outbox outcome dedup: the broker remembers, per node, the ids it has
15
+ // already settled (submitted or rejected) so a re-presented id gets the same
16
+ // outcome back without a second delivery. Bounded: TTL + per-node cap + a
17
+ // global cap that keeps the store file well under MAX_STORE_BYTES.
18
+ const OUTBOX_SEEN_TTL_MS = 24 * 60 * 60 * 1000;
19
+ const OUTBOX_SEEN_MAX_PER_NODE = 256;
20
+ const OUTBOX_SEEN_MAX_TOTAL = 1024;
21
+ const SEEN_OUTCOME_MAX = 64;
22
+
14
23
  function emptyStore() {
15
- return { version: STORE_VERSION, invites: [], nodes: [] };
24
+ return { version: STORE_VERSION, invites: [], nodes: [], acl: {}, outboxSeen: {} };
16
25
  }
17
26
 
18
27
  function defaultPath(home) {
@@ -51,7 +60,63 @@ function normalizeStore(raw, now = Date.now()) {
51
60
  pairedAt: record.pairedAt,
52
61
  });
53
62
  }
54
- return { version: STORE_VERSION, invites, nodes };
63
+ const acl = {};
64
+ if (raw.acl && typeof raw.acl === 'object' && !Array.isArray(raw.acl)) {
65
+ for (const [nodeId, nodeAcl] of Object.entries(raw.acl)) {
66
+ if (!seen.has(nodeId)) continue;
67
+ if (!nodeAcl || typeof nodeAcl !== 'object' || !Array.isArray(nodeAcl.allowCells)) continue;
68
+ const allowCells = nodeAcl.allowCells
69
+ .filter((c) => typeof c === 'string' && c.trim().length > 0 && c.trim().length <= 48)
70
+ .map((c) => c.trim());
71
+ acl[nodeId] = { allowCells };
72
+ }
73
+ }
74
+ const outboxSeen = normalizeSeen(raw.outboxSeen, seen, now);
75
+ return { version: STORE_VERSION, invites, nodes, acl, outboxSeen };
76
+ }
77
+
78
+ function normalizeSeen(rawSeen, knownNodeIds, now) {
79
+ const cutoff = now - OUTBOX_SEEN_TTL_MS;
80
+ const seen = {};
81
+ let total = 0;
82
+ if (!rawSeen || typeof rawSeen !== 'object' || Array.isArray(rawSeen)) return seen;
83
+ for (const [nodeId, list] of Object.entries(rawSeen)) {
84
+ if (!knownNodeIds.has(nodeId) || !Array.isArray(list)) continue;
85
+ const byId = new Map();
86
+ for (const entry of list) {
87
+ if (!entry || typeof entry !== 'object') continue;
88
+ if (!NODE_ID_RE.test(String(entry.id || ''))) continue;
89
+ if (typeof entry.outcome !== 'string' || !entry.outcome
90
+ || entry.outcome.length > SEEN_OUTCOME_MAX) continue;
91
+ if (!Number.isSafeInteger(entry.at) || entry.at <= cutoff) continue;
92
+ byId.set(entry.id, { id: entry.id, outcome: entry.outcome, at: entry.at });
93
+ }
94
+ const newestFirst = [...byId.values()].sort((x, y) => y.at - x.at || (x.id < y.id ? 1 : -1));
95
+ const capped = newestFirst.slice(0, OUTBOX_SEEN_MAX_PER_NODE);
96
+ if (capped.length) {
97
+ seen[nodeId] = capped;
98
+ total += capped.length;
99
+ }
100
+ }
101
+ // Global cap: drop the oldest entries across nodes until within budget.
102
+ while (total > OUTBOX_SEEN_MAX_TOTAL) {
103
+ let oldestNode = null;
104
+ let oldest = null;
105
+ for (const [nodeId, list] of Object.entries(seen)) {
106
+ for (const entry of list) {
107
+ if (!oldest || entry.at < oldest.at || (entry.at === oldest.at && entry.id < oldest.id)) {
108
+ oldest = entry;
109
+ oldestNode = nodeId;
110
+ }
111
+ }
112
+ }
113
+ if (!oldestNode) break;
114
+ const list = seen[oldestNode];
115
+ list.splice(list.indexOf(oldest), 1);
116
+ if (!list.length) delete seen[oldestNode];
117
+ total -= 1;
118
+ }
119
+ return seen;
55
120
  }
56
121
 
57
122
  function ownedByCurrentUser(stat) {
@@ -173,6 +238,59 @@ function removeNode(file, nodeId, now = Date.now()) {
173
238
  const kept = store.nodes.filter((node) => node.nodeId !== nodeId);
174
239
  if (kept.length === store.nodes.length) return false;
175
240
  store.nodes = kept;
241
+ if (store.acl && store.acl[nodeId]) {
242
+ delete store.acl[nodeId];
243
+ }
244
+ if (store.outboxSeen && store.outboxSeen[nodeId]) {
245
+ delete store.outboxSeen[nodeId];
246
+ }
247
+ atomicWriteStore(file, store, now);
248
+ return true;
249
+ }
250
+
251
+ function getSeenOutcome(file, nodeId, id, now = Date.now()) {
252
+ if (!NODE_ID_RE.test(String(nodeId || '')) || !NODE_ID_RE.test(String(id || ''))) return null;
253
+ const store = loadStore(file, now);
254
+ const list = store.outboxSeen && store.outboxSeen[nodeId];
255
+ if (!Array.isArray(list)) return null;
256
+ const entry = list.find((row) => row.id === id);
257
+ return entry ? entry.outcome : null;
258
+ }
259
+
260
+ function recordSeenOutcome(file, nodeId, { id, outcome, at }, now = Date.now()) {
261
+ if (!NODE_ID_RE.test(String(nodeId || '')) || !NODE_ID_RE.test(String(id || ''))) return false;
262
+ if (typeof outcome !== 'string' || !outcome || outcome.length > SEEN_OUTCOME_MAX) return false;
263
+ const stamp = Number.isSafeInteger(at) && at > 0 ? at : now;
264
+ const store = loadStore(file, now);
265
+ if (!store.nodes.some((node) => node.nodeId === nodeId)) return false;
266
+ store.outboxSeen = store.outboxSeen || {};
267
+ const list = Array.isArray(store.outboxSeen[nodeId])
268
+ ? store.outboxSeen[nodeId].filter((row) => row.id !== id)
269
+ : [];
270
+ list.push({ id, outcome, at: stamp });
271
+ store.outboxSeen[nodeId] = list;
272
+ // normalizeStore inside the writer enforces TTL + per-node + total caps.
273
+ atomicWriteStore(file, store, now);
274
+ return true;
275
+ }
276
+
277
+ function getNodeAcl(file, nodeId, now = Date.now()) {
278
+ if (!NODE_ID_RE.test(String(nodeId || ''))) return null;
279
+ const store = loadStore(file, now);
280
+ if (!store.nodes.some((node) => node.nodeId === nodeId)) return null;
281
+ return store.acl && store.acl[nodeId] ? store.acl[nodeId] : { allowCells: [] };
282
+ }
283
+
284
+ function setNodeAcl(file, nodeId, { allowCells }, now = Date.now()) {
285
+ if (!NODE_ID_RE.test(String(nodeId || ''))) return false;
286
+ if (!Array.isArray(allowCells)) return false;
287
+ const store = loadStore(file, now);
288
+ if (!store.nodes.some((node) => node.nodeId === nodeId)) return false;
289
+ const cleanedCells = allowCells
290
+ .filter((c) => typeof c === 'string' && c.trim().length > 0 && c.trim().length <= 48)
291
+ .map((c) => c.trim());
292
+ store.acl = store.acl || {};
293
+ store.acl[nodeId] = { allowCells: cleanedCells };
176
294
  atomicWriteStore(file, store, now);
177
295
  return true;
178
296
  }
@@ -187,6 +305,9 @@ module.exports = {
187
305
  STORE_VERSION,
188
306
  DEFAULT_INVITE_TTL_MS,
189
307
  MAX_INVITE_TTL_MS,
308
+ OUTBOX_SEEN_TTL_MS,
309
+ OUTBOX_SEEN_MAX_PER_NODE,
310
+ OUTBOX_SEEN_MAX_TOTAL,
190
311
  emptyStore,
191
312
  defaultPath,
192
313
  cleanLabel,
@@ -199,5 +320,9 @@ module.exports = {
199
320
  pairNode,
200
321
  authenticate,
201
322
  removeNode,
323
+ getNodeAcl,
324
+ setNodeAcl,
325
+ getSeenOutcome,
326
+ recordSeenOutcome,
202
327
  listNodes,
203
328
  };