@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.
- package/CHANGELOG.md +21 -0
- package/docs/FLEET.md +40 -0
- package/docs/MCP.md +4 -1
- package/docs/VL_MICRO_NODES.md +10 -3
- package/frontend/dist/assets/index-Be8hcPP6.js +169 -0
- package/frontend/dist/assets/index-BwOOF72d.css +32 -0
- package/frontend/dist/index.html +2 -3
- package/frontend/dist/version.json +1 -1
- package/lib/cells/deliver.js +93 -0
- package/lib/cells/routes.js +2 -1
- package/lib/cells/scope-guard.js +46 -1
- package/lib/diagnostics/store.js +5 -0
- package/lib/fleet/builtin.js +169 -30
- package/lib/fleet/causes.js +4 -0
- package/lib/fleet/cell-exec.js +15 -2
- package/lib/fleet/definitions.js +15 -1
- package/lib/fleet/managed.js +690 -42
- package/lib/fleet/model-discovery.js +21 -6
- package/lib/fleet/routes.js +39 -0
- package/lib/fleet/runtime.js +144 -0
- package/lib/fleet/vl-session.js +230 -0
- package/lib/mcp/tools.js +20 -4
- package/lib/proxy/federation.js +6 -3
- package/lib/proxy/resource-acl.js +17 -2
- package/lib/server.js +9 -2
- package/lib/vl-nodes/broker.js +64 -6
- package/lib/vl-nodes/outbox.js +333 -0
- package/lib/vl-nodes/routes.js +35 -2
- package/lib/vl-nodes/store.js +127 -2
- package/package.json +1 -1
- package/frontend/dist/assets/index-B9GW2LHm.css +0 -32
- package/frontend/dist/assets/index-BURtPwUi.js +0 -169
|
@@ -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
|
-
|
|
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,
|
|
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
|
package/lib/vl-nodes/broker.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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) => ({
|
|
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
|
+
};
|
package/lib/vl-nodes/routes.js
CHANGED
|
@@ -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: '
|
|
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' });
|