@mmmbuto/nexuscrew 0.9.76 → 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.
@@ -20,6 +20,15 @@ const CLASSES = Object.freeze({
20
20
  // authorized inventory is projected, and a peer with no visible cell sees none
21
21
  // of it.
22
22
  inventory: Object.freeze({ grants: [], needsVisibility: true }),
23
+ // The vl legacy inventory: a photo of the NODE state (the shared
24
+ // pre-contract directories), not a list of cells. Those directories have no
25
+ // reliable cell->session mapping, so the inventory cannot be projected onto
26
+ // a partial scope without inventing one: it is whole or nothing. The peer
27
+ // needs the operator grant AND a full scope. The authoritative check is the
28
+ // scope RESOLVED on the whole chain (lib/cells/scope-guard.js): this gate
29
+ // sees only the last hop, and a restricted origin must not inherit the
30
+ // promise of a wide deliverer.
31
+ 'node-inventory': Object.freeze({ grants: ['peerOperatorAccess'], needsFullScope: true }),
23
32
  // Reading shared files: list and download, filtered by the owning cell.
24
33
  'files-read': Object.freeze({ grants: ['filesReadAccess'] }),
25
34
  // Choosing which cell of this node hosts the Live session.
@@ -94,10 +103,13 @@ function classifyResource(resource, method = 'GET') {
94
103
  // Fleet: the allowlist splits the family in two — the read alternatives are
95
104
  // GET, the rest are POST. A peer that is not an operator is refused by the
96
105
  // class gate; an operator no longer gets "resource-not-classified".
97
- if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) {
106
+ if (/^\/fleet\/(status|schema|definitions|credentials\/status|vl-new-session-state)$/.test(resource)) {
98
107
  return m === 'GET' ? 'operator' : null;
99
108
  }
100
- if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|edit-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) {
109
+ // L'inventario legacy vl e' lo stato del NODO, non un elenco di celle: e'
110
+ // intero o niente (vedi la classe 'node-inventory' qui sopra).
111
+ if (resource === '/fleet/vl-legacy-inventory') return m === 'GET' ? 'node-inventory' : null;
112
+ if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|edit-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines|vl-new-session)$/.test(resource)) {
101
113
  return m === 'POST' ? 'operator' : null;
102
114
  }
103
115
  if (resource === '/decks') return m === 'GET' || m === 'POST' ? 'operator' : null;
@@ -140,6 +152,9 @@ function allowedByClass(className, ingress) {
140
152
  if (ingress[grant] !== true) return false;
141
153
  }
142
154
  if (cls.needsVisibility === true && ingress.cellVisibility === 'none') return false;
155
+ // Una classe che si legge per intero non si presta a uno scope parziale:
156
+ // il peer deve vedere tutto quello che la risposta contiene.
157
+ if (cls.needsFullScope === true && ingress.cellVisibility !== 'all') return false;
143
158
  return true;
144
159
  }
145
160
 
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' });