@mmmbuto/nexuscrew 0.8.50 → 0.8.52-rc.0

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.
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-kj-l-RDc.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-43DFO1EH.css">
14
+ <script type="module" crossorigin src="/assets/index-qLF-zV3C.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-DNQLAGoa.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.8.50"}
1
+ {"version":"0.8.51"}
@@ -19,6 +19,15 @@ const OLLAMA_CONTEXT = Object.freeze({
19
19
  'minimax-m3': 524288, 'qwen3.5:397b': 262144, 'deepseek-v4-flash': 1048576,
20
20
  'mistral-large-3:675b': 262144, 'gemma4:31b': 262144,
21
21
  });
22
+ // Lo stesso modello si scrive con o senza tag ('deepseek-v4-flash' e
23
+ // 'deepseek-v4-flash:0731'). Si prova prima la chiave esatta, perche' nella
24
+ // mappa ci sono nomi in cui il tag fa parte dell'identita' del modello
25
+ // ('qwen3.5:397b'), e solo dopo si ricade sul nome base. Troncare sempre al
26
+ // primo ':' romperebbe proprio quelle voci.
27
+ function ollamaContextFor(model) {
28
+ const key = String(model || '');
29
+ return OLLAMA_CONTEXT[key] ?? OLLAMA_CONTEXT[key.split(':')[0]];
30
+ }
22
31
  const ALIBABA_TOKEN_PLAN_MODELS = Object.freeze([
23
32
  'qwen3.8-max-preview', 'qwen3.7-plus', 'qwen3.7-max',
24
33
  'qwen3.6-flash', 'glm-5.2', 'deepseek-v4-pro',
@@ -849,7 +858,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
849
858
  } else {
850
859
  const endpoint = spec.baseUrl || profile.endpoint;
851
860
  const token = profile.auth === 'none' ? 'ollama' : cred.value;
852
- const contextWindow = OLLAMA_CONTEXT[model] || (spec.provider === 'zai' ? 1000000 : 200000);
861
+ const contextWindow = ollamaContextFor(model) ?? (spec.provider === 'zai' ? 1000000 : 200000);
853
862
  Object.assign(env, {
854
863
  ANTHROPIC_BASE_URL: endpoint, ANTHROPIC_AUTH_TOKEN: token, ANTHROPIC_API_KEY: '',
855
864
  ANTHROPIC_MODEL: model, ANTHROPIC_DEFAULT_OPUS_MODEL: model,
@@ -866,7 +875,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
866
875
  else if (spec.provider === 'ollama-cloud') {
867
876
  env.OPENAI_API_KEY = cred.value;
868
877
  args.push(...codexProviderArgs('ollama_cloud', 'Ollama Cloud', profile.endpoint, 'OPENAI_API_KEY'));
869
- args.push('-c', 'model_providers.ollama_cloud.stream_idle_timeout_ms=600000', '-c', `model_context_window=${OLLAMA_CONTEXT[model] || 200000}`);
878
+ args.push('-c', 'model_providers.ollama_cloud.stream_idle_timeout_ms=600000', '-c', `model_context_window=${ollamaContextFor(model) ?? 200000}`);
870
879
  const localCatalog = path.join(home, '.codex', 'ollama_cloud_model_catalog.json');
871
880
  if (fs.existsSync(localCatalog)) args.push('-c', `model_catalog_json="${localCatalog}"`);
872
881
  } else if (spec.provider === 'openrouter') {
package/lib/mcp/tools.js CHANGED
@@ -147,6 +147,92 @@ async function readLocalCellDirectory(ctx, callerSession) {
147
147
  return { nodeId, cells: normalizeCellPayload(payload, owner, callerSession) };
148
148
  }
149
149
 
150
+ const VL_TARGET_RE = /^([a-f0-9]{16,64}):VL-([a-f0-9]{32})$/;
151
+
152
+ function parseVlTarget(value) {
153
+ const match = VL_TARGET_RE.exec(String(value || ''));
154
+ return match ? { instanceId: match[1], nodeId: match[2], id: match[0] } : null;
155
+ }
156
+
157
+ async function requireActiveLocalSender(ctx, tool) {
158
+ const identity = await ctx.identity();
159
+ const callerSession = requireSession(identity.session, tool, identity.code);
160
+ const directory = await readLocalCellDirectory(ctx, callerSession);
161
+ if (!directory.cells.some((cell) => cell.self && cell.active)) {
162
+ throw new Error(`${tool}: la sessione chiamante non e' una cella Fleet attiva locale`);
163
+ }
164
+ return callerSession;
165
+ }
166
+
167
+ async function readVlDirectory(ctx) {
168
+ const [config, topology] = await Promise.all([
169
+ ctx.api('GET', '/api/config'), ctx.api('GET', '/api/topology'),
170
+ ]);
171
+ const localId = String(config && config.instanceId || '');
172
+ if (!NODE_ID_RE.test(localId)) throw new Error('instanceId locale non disponibile');
173
+ const owners = [{ instanceId: localId, route: [], label: 'Local', stale: false },
174
+ ...topologyOwners(topology).filter((owner) => !owner.stale && owner.instanceId !== localId)];
175
+ const nodes = [];
176
+ const availableOwners = [];
177
+ const unavailable = [];
178
+ await Promise.all(owners.map(async (owner) => {
179
+ // Le route /vl-nodes/* non sono federabili (NOTE in lib/proxy/federation.js).
180
+ // Un owner remoto non e' irraggiungibile: e' chiuso per decisione. Chiamarlo
181
+ // per raccogliere il 404 e riportarlo come 'unreachable' descriverebbe un
182
+ // confine sano come un guasto, e manderebbe chi diagnostica a cercare un
183
+ // tunnel che sta benissimo — proprio quando quel campo serve a distinguere
184
+ // un nodo davvero muto.
185
+ if (owner.route.length) {
186
+ unavailable.push({
187
+ instanceId: owner.instanceId, owner: owner.label,
188
+ route: owner.route.join('/'), failure: 'policy-denied',
189
+ });
190
+ return;
191
+ }
192
+ const apiPath = '/api/vl-nodes';
193
+ try {
194
+ const payload = await ctx.api('GET', apiPath);
195
+ if (String(payload && payload.instanceId || '') !== owner.instanceId
196
+ || !Array.isArray(payload && payload.nodes)) throw new Error('directory VL owner mismatch');
197
+ const route = owner.route.length ? owner.route.join('/') : 'local';
198
+ availableOwners.push({ instanceId: owner.instanceId, owner: owner.label, route });
199
+ for (const node of payload.nodes) {
200
+ const expected = `${owner.instanceId}:VL-${node && node.nodeId}`;
201
+ if (!node || !/^[a-f0-9]{32}$/.test(String(node.nodeId || '')) || node.id !== expected) continue;
202
+ nodes.push({
203
+ id: expected, instanceId: owner.instanceId, nodeId: node.nodeId,
204
+ cell: typeof node.cell === 'string' ? node.cell : `VL-${node.nodeId.slice(0, 8)}`,
205
+ label: typeof node.label === 'string' ? node.label : 'VL', route,
206
+ online: node.online === true, canManage: node.canManage === true,
207
+ lastSeen: Number.isSafeInteger(node.lastSeen) ? node.lastSeen : null,
208
+ generation: Number.isSafeInteger(node.generation) ? node.generation : 0,
209
+ version: typeof node.version === 'string' ? node.version : '',
210
+ capabilities: Array.isArray(node.capabilities) ? node.capabilities : [],
211
+ health: node.health && typeof node.health === 'object' ? node.health : null,
212
+ inflight: node.inflight && typeof node.inflight === 'object' ? node.inflight : null,
213
+ lastAck: node.lastAck && typeof node.lastAck === 'object' ? node.lastAck : null,
214
+ });
215
+ }
216
+ } catch (error) {
217
+ unavailable.push({
218
+ instanceId: owner.instanceId, owner: owner.label,
219
+ route: owner.route.length ? owner.route.join('/') : 'local',
220
+ failure: /timeout/i.test(String(error && error.message || error)) ? 'timeout' : 'unreachable',
221
+ });
222
+ }
223
+ }));
224
+ availableOwners.sort((a, b) => (a.route === 'local' ? -1 : b.route === 'local' ? 1 : a.route.localeCompare(b.route)));
225
+ nodes.sort((a, b) => (a.route === 'local' ? -1 : b.route === 'local' ? 1 : a.route.localeCompare(b.route))
226
+ || a.cell.localeCompare(b.cell));
227
+ unavailable.sort((a, b) => a.route.localeCompare(b.route));
228
+ return { nodeId: localId, owners: availableOwners, nodes, unavailable };
229
+ }
230
+
231
+ function vlOwnerPath(owner, leaf = '') {
232
+ const resource = leaf ? `vl-nodes/${leaf}` : 'vl-nodes';
233
+ return owner.route === 'local' ? `/api/${resource}` : routePath(owner.route.split('/'), resource);
234
+ }
235
+
150
236
  // --- definizione tool (prefisso nc_ anti-collisione) ---------------------------
151
237
  // annotations.readOnlyHint:true sui tool che non mutano nulla (§1).
152
238
  const TOOLS = [
@@ -368,6 +454,94 @@ const TOOLS = [
368
454
  return readCellDirectory(ctx, callerSession);
369
455
  },
370
456
  },
457
+ {
458
+ name: 'nc_vl_nodes',
459
+ description: 'Directory read-only dei micro-device VL pairati sui nodi NexusCrew autorizzati. online richiede un poll end-to-end recente; usa l id owner-qualified esatto per i comandi.',
460
+ inputSchema: { type: 'object', properties: {} },
461
+ annotations: { readOnlyHint: true },
462
+ async handler(_args, ctx) {
463
+ return readVlDirectory(ctx);
464
+ },
465
+ },
466
+ {
467
+ name: 'nc_vl_invite',
468
+ description: 'Crea un invite VL one-time bounded su un owner NexusCrew autorizzato. Richiede una cella Fleet locale attiva; il valore restituito e un segreto effimero da consegnare fuori da log e argv.',
469
+ inputSchema: {
470
+ type: 'object',
471
+ properties: {
472
+ ownerId: { type: 'string', description: 'instanceId esatto restituito da nc_vl_nodes' },
473
+ label: { type: 'string', description: 'label device, max 48 caratteri' },
474
+ ttlSeconds: { type: 'integer', minimum: 30, maximum: 3600 },
475
+ },
476
+ required: ['ownerId', 'label'],
477
+ },
478
+ async handler(args, ctx) {
479
+ await requireActiveLocalSender(ctx, 'nc_vl_invite');
480
+ const ownerId = argString(args, 'ownerId', { required: true, max: 64 });
481
+ const label = argString(args, 'label', { required: true, max: 48 });
482
+ const ttlSeconds = args.ttlSeconds === undefined ? 600 : args.ttlSeconds;
483
+ if (!NODE_ID_RE.test(ownerId) || !Number.isSafeInteger(ttlSeconds)
484
+ || ttlSeconds < 30 || ttlSeconds > 3600) throw new Error('ownerId o ttlSeconds non valido');
485
+ const directory = await readVlDirectory(ctx);
486
+ const owner = directory.owners.find((item) => item.instanceId === ownerId);
487
+ if (!owner) throw new Error('owner VL non disponibile nella rete autorizzata');
488
+ const apiPath = vlOwnerPath(owner, 'invite');
489
+ if (!apiPath) throw new Error('route owner VL non valida');
490
+ const result = await ctx.api('POST', apiPath, { label, ttlSeconds });
491
+ return { ...result, note: 'invite e segreto effimero: non inserirlo in argv, log o handoff' };
492
+ },
493
+ },
494
+ {
495
+ name: 'nc_vl_command',
496
+ description: 'Invia un comando bounded a un micro-device VL online. submitted conferma solo consegna al poll live; il completamento richiede lastAck con lo stesso id.',
497
+ inputSchema: {
498
+ type: 'object',
499
+ properties: {
500
+ target: { type: 'string', description: 'id owner-qualified esatto restituito da nc_vl_nodes' },
501
+ kind: { type: 'string', enum: ['status', 'health', 'start', 'stop', 'restart', 'version', 'capabilities', 'logs', 'update_candidate', 'unpair'] },
502
+ args: { type: 'object', description: 'argomenti exact del comando bounded' },
503
+ },
504
+ required: ['target', 'kind'],
505
+ },
506
+ async handler(args, ctx) {
507
+ await requireActiveLocalSender(ctx, 'nc_vl_command');
508
+ const target = parseVlTarget(argString(args, 'target', { required: true, max: 128 }));
509
+ const kind = argString(args, 'kind', { required: true, max: 32 });
510
+ const commandArgs = args.args === undefined ? {} : args.args;
511
+ if (!target || !commandArgs || typeof commandArgs !== 'object' || Array.isArray(commandArgs)
512
+ || JSON.stringify(commandArgs).length > 4096) throw new Error('target o args VL non validi');
513
+ const directory = await readVlDirectory(ctx);
514
+ const node = directory.nodes.find((item) => item.id === target.id);
515
+ if (!node) throw new Error('micro-device VL non trovato nella rete autorizzata');
516
+ if (!node.canManage || !node.online) throw new Error('micro-device VL offline; nessun comando accodato');
517
+ const apiPath = vlOwnerPath(node, `${target.nodeId}/commands`);
518
+ if (!apiPath) throw new Error('route micro-device VL non valida');
519
+ const receipt = await ctx.api('POST', apiPath, { kind, args: commandArgs });
520
+ return { ...receipt, target: target.id,
521
+ note: receipt.note || 'submitted conferma il trasporto live; completamento solo via lastAck' };
522
+ },
523
+ },
524
+ {
525
+ name: 'nc_vl_revoke',
526
+ description: 'Revoca esplicitamente un pairing VL su un owner autorizzato. Richiede una cella Fleet locale attiva e l id owner-qualified esatto.',
527
+ inputSchema: {
528
+ type: 'object', properties: {
529
+ target: { type: 'string', description: 'id owner-qualified esatto restituito da nc_vl_nodes' },
530
+ }, required: ['target'],
531
+ },
532
+ async handler(args, ctx) {
533
+ await requireActiveLocalSender(ctx, 'nc_vl_revoke');
534
+ const target = parseVlTarget(argString(args, 'target', { required: true, max: 128 }));
535
+ if (!target) throw new Error('target VL non valido');
536
+ const directory = await readVlDirectory(ctx);
537
+ const node = directory.nodes.find((item) => item.id === target.id);
538
+ if (!node) throw new Error('micro-device VL non trovato nella rete autorizzata');
539
+ const apiPath = vlOwnerPath(node, target.nodeId);
540
+ if (!apiPath) throw new Error('route micro-device VL non valida');
541
+ await ctx.api('DELETE', apiPath);
542
+ return { target: target.id, revoked: true };
543
+ },
544
+ },
371
545
  {
372
546
  name: 'nc_cell_diagnostics',
373
547
  description: 'Diagnostica read-only target-specifica di una cella Fleet locale: command Shell redatto e ultima causa spawn/start bounded. Richiede un caller Fleet locale attivo; non attraversa la federazione.',
@@ -105,16 +105,37 @@ function knownResource(resource) {
105
105
  || resource === '/files/upload'
106
106
  || resource === '/cells'
107
107
  || resource === '/cells/send'
108
+ // NOTE: `/vl-nodes`, `/vl-nodes/invite`, `/vl-nodes/:id` and
109
+ // `/vl-nodes/:id/commands` were federated when the VL bridge was written.
110
+ // They were removed before the bridge ever shipped. `commands` accepts
111
+ // `update_candidate`, which names the URL the device downloads its own
112
+ // update from — and accepts `http:` — while the `sha256` guarantees
113
+ // nothing, because the sender supplies both the URL and the hash. Federated,
114
+ // that lets a paired peer install an arbitrary binary on someone else's
115
+ // device.
116
+ //
117
+ // Same class as `/settings/peering/invite` below: a capability that extends
118
+ // trust past the boundary the owner chose. Denied by default; it can be
119
+ // federated the day the update channel is bound to something the receiving
120
+ // owner controls, not before. Local `/vl-nodes/*` calls are unaffected —
121
+ // this is the federation allowlist only.
108
122
  || resource === '/decks'
109
123
  || /^\/decks\/[a-z0-9-]{1,32}$/.test(resource)
110
124
  || resource === '/topology'
111
125
  || resource === '/diagnostics/status'
112
126
  || resource === '/diagnostics/logs'
113
127
  || resource === '/diagnostics/verbose'
114
- // A connected client may ask its hub to mint a hub-owned, one-time
115
- // pairing invite. This is the only settings mutation exposed through
116
- // Hydra: the rest of /settings stays unreachable.
117
- || resource === '/settings/peering/invite'
128
+ // NOTE: `/settings/peering/invite` used to live here, so a connected client
129
+ // could ask its hub to mint a hub-owned invite. It was removed on
130
+ // 2026-08-04. The invite is bound to the HUB's instanceId: whoever consumes
131
+ // it joins the hub, not the peer that asked for it. So any paired node could
132
+ // obtain a live invite and hand it to a third party, and the hub's operator
133
+ // would neither act nor know — trust became transitive without a decision.
134
+ //
135
+ // A paired node is otherwise trusted as its owner (see docs/SECURITY.md),
136
+ // and that is deliberate. This one is different: it is the capability that
137
+ // ADMITS a further node, so leaving it federated would let a peer break the
138
+ // very boundary the owner chose. Minting an invite is done on the hub.
118
139
  || resource === '/ws'
119
140
  || resource === '/audio/capability'
120
141
  || resource === '/audio/speak'
@@ -134,6 +155,7 @@ function allowedResource(resource, method = 'GET') {
134
155
  if (resource === '/files/upload') return method === 'POST';
135
156
  if (resource === '/cells') return method === 'GET';
136
157
  if (resource === '/cells/send') return method === 'POST';
158
+ // `/vl-nodes/*` is deliberately absent — see the NOTE in `knownResource`.
137
159
  if (resource === '/decks') return method === 'GET' || method === 'POST';
138
160
  if (/^\/decks\/[a-z0-9-]{1,32}$/.test(resource)) {
139
161
  return method === 'PUT' || method === 'PATCH' || method === 'DELETE';
@@ -142,7 +164,6 @@ function allowedResource(resource, method = 'GET') {
142
164
  if (resource === '/diagnostics/status') return method === 'GET';
143
165
  if (resource === '/diagnostics/logs') return method === 'GET' || method === 'DELETE';
144
166
  if (resource === '/diagnostics/verbose') return method === 'PATCH';
145
- if (resource === '/settings/peering/invite') return method === 'POST';
146
167
  if (resource === '/ws') return method === 'GET';
147
168
  if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) return method === 'GET';
148
169
  if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) return method === 'POST';
package/lib/server.js CHANGED
@@ -56,6 +56,9 @@ const { notifyRoutes } = require('./notify/routes.js');
56
56
  const { createNpmUpdater } = require('./update/manager.js');
57
57
  const { createDiagnostics } = require('./diagnostics/store.js');
58
58
  const { diagnosticsRoutes } = require('./diagnostics/routes.js');
59
+ const vlNodeStore = require('./vl-nodes/store.js');
60
+ const { createBroker: createVlNodeBroker } = require('./vl-nodes/broker.js');
61
+ const { publicRoutes: vlNodePublicRoutes, apiRoutes: vlNodeApiRoutes } = require('./vl-nodes/routes.js');
59
62
 
60
63
  function sessionExists(tmuxBin, name) {
61
64
  if (typeof name !== 'string' || !/^[\w.@%:+-]{1,128}$/.test(name)) return false;
@@ -141,6 +144,9 @@ function createServer(opts = {}) {
141
144
  // token / add-remove nodi visibili senza restart). token MAI redatto qui: e'
142
145
  // il valore che il proxy inietta upstream, non esce mai verso il browser.
143
146
  const nodesPath = cfg.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
147
+ const vlNodesPath = cfg.vlNodesPath || vlNodeStore.defaultPath(cfg.home || os.homedir());
148
+ const vlNodeBroker = opts.vlNodeBroker || createVlNodeBroker();
149
+ const vlOwnerId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
144
150
  // fetch usata dai probe di salute federati (iniettabile nei test); default globale.
145
151
  const healthFetch = cfg.fetchImpl || fetch;
146
152
  const topologyCachePath = cfg.topologyCachePath || topologyCache.defaultPath(cfg.home || os.homedir());
@@ -518,6 +524,12 @@ function createServer(opts = {}) {
518
524
  // no-store on everything (HTML+assets+API): this is a local, token-adjacent tool.
519
525
  app.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
520
526
  app.use('/pair', publicPeeringRoutes({ cfg, nodesPath }));
527
+ // VL micro-device nodes use a separate, scoped credential surface. These
528
+ // routes never accept the NexusCrew UI bearer and expose only pair, poll and
529
+ // self-unpair. Operator management remains behind /api + federation ACL.
530
+ app.use('/vl-node/v1', vlNodePublicRoutes({
531
+ storePath: vlNodesPath, broker: vlNodeBroker, ownerId: vlOwnerId,
532
+ }));
521
533
  // This route is deliberately outside the bearer-authenticated federation
522
534
  // router. It emits a MAC only on a listener owned by the expected slot and
523
535
  // never accepts/sends a bearer to a suspicious loopback listener.
@@ -678,6 +690,9 @@ function createServer(opts = {}) {
678
690
  })),
679
691
  readonly: proxyReadonly,
680
692
  }));
693
+ api.use('/vl-nodes', vlNodeApiRoutes({
694
+ storePath: vlNodesPath, broker: vlNodeBroker, ownerId: vlOwnerId, readonly: proxyReadonly,
695
+ }));
681
696
  api.use('/decks', decksRoutes({ cfg, decksPath }));
682
697
  api.use('/fs', fsRoutes({ home: os.homedir() })); // folder-picker del dialog new session
683
698
  // /nodes (read-only, per la settings UI B2): stesso formato di `nodes list --json`
@@ -0,0 +1,228 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const { NODE_ID_RE } = require('./store.js');
5
+
6
+ const SESSION_RE = /^[a-f0-9]{32}$/;
7
+ const PROTOCOL = 'vl-node/1';
8
+ const MAX_WAIT_MS = 30_000;
9
+ const ONLINE_GRACE_MS = 45_000;
10
+
11
+ function boundedString(value, max = 128) {
12
+ return typeof value === 'string' ? value.normalize('NFC').replace(/[\p{Cc}\p{Cf}]/gu, '').slice(0, max) : '';
13
+ }
14
+
15
+ function boundedInteger(value, min, max, fallback = 0) {
16
+ return Number.isSafeInteger(value) && value >= min && value <= max ? value : fallback;
17
+ }
18
+
19
+ function sanitizeAck(raw) {
20
+ if (!raw || !/^[a-f0-9]{32}$/.test(String(raw.id || ''))) return null;
21
+ if (!['ok', 'error', 'rejected'].includes(raw.status)) return null;
22
+ const result = raw.result && typeof raw.result === 'object' && !Array.isArray(raw.result)
23
+ ? JSON.parse(JSON.stringify(raw.result).slice(0, 16_384)) : null;
24
+ return { id: raw.id, status: raw.status, ...(result ? { result } : {}) };
25
+ }
26
+
27
+ function sanitizeHealth(raw) {
28
+ const health = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
29
+ return {
30
+ state: ['starting', 'running', 'stopped', 'degraded', 'error'].includes(health.state)
31
+ ? health.state : 'degraded',
32
+ uptimeSec: boundedInteger(health.uptimeSec, 0, Number.MAX_SAFE_INTEGER),
33
+ rssBytes: boundedInteger(health.rssBytes, 0, 1 << 30),
34
+ processCount: boundedInteger(health.processCount, 1, 16, 1),
35
+ brokerReachable: health.brokerReachable === true,
36
+ childPid: boundedInteger(health.childPid, 1, 1 << 30, 0) || null,
37
+ batteryPercent: boundedInteger(health.batteryPercent, 0, 100, -1),
38
+ detail: boundedString(health.detail, 256),
39
+ };
40
+ }
41
+
42
+ function sanitizeHeartbeat(raw) {
43
+ if (!raw || raw.protocol !== PROTOCOL || !SESSION_RE.test(String(raw.sessionId || ''))
44
+ || !Number.isSafeInteger(raw.seq) || raw.seq < 0) return null;
45
+ const capabilities = Array.isArray(raw.capabilities)
46
+ ? [...new Set(raw.capabilities.filter((item) => /^[a-z][a-z0-9_]{0,31}$/.test(String(item))))].slice(0, 32)
47
+ : [];
48
+ return {
49
+ protocol: PROTOCOL,
50
+ sessionId: raw.sessionId,
51
+ seq: raw.seq,
52
+ version: boundedString(raw.version, 64),
53
+ capabilities,
54
+ health: sanitizeHealth(raw.health),
55
+ ack: sanitizeAck(raw.ack),
56
+ };
57
+ }
58
+
59
+ function createBroker(options = {}) {
60
+ const now = options.now || Date.now;
61
+ const setTimer = options.setTimeout || setTimeout;
62
+ const clearTimer = options.clearTimeout || clearTimeout;
63
+ const randomBytes = options.randomBytes || crypto.randomBytes;
64
+ const states = new Map();
65
+
66
+ function ensure(node) {
67
+ let state = states.get(node.nodeId);
68
+ if (!state) {
69
+ state = {
70
+ nodeId: node.nodeId,
71
+ label: node.label,
72
+ pairedAt: node.pairedAt,
73
+ lastSeen: null,
74
+ heartbeat: null,
75
+ generation: 0,
76
+ waiter: null,
77
+ inflight: null,
78
+ lastAck: null,
79
+ };
80
+ states.set(node.nodeId, state);
81
+ }
82
+ state.label = node.label;
83
+ state.pairedAt = node.pairedAt;
84
+ return state;
85
+ }
86
+
87
+ function finishWaiter(state, value) {
88
+ const waiter = state.waiter;
89
+ if (!waiter) return false;
90
+ state.waiter = null;
91
+ clearTimer(waiter.timer);
92
+ waiter.cleanup?.();
93
+ waiter.resolve(value);
94
+ return true;
95
+ }
96
+
97
+ function observe(node, raw) {
98
+ const heartbeat = sanitizeHeartbeat(raw);
99
+ if (!heartbeat) return { ok: false, code: 'invalid-heartbeat' };
100
+ // Reaching this authenticated endpoint is itself the end-to-end proof.
101
+ // Do not preserve a stale false value from the instant before send().
102
+ heartbeat.health.brokerReachable = true;
103
+ const state = ensure(node);
104
+ const sameSession = Boolean(state.heartbeat
105
+ && state.heartbeat.sessionId === heartbeat.sessionId);
106
+ if (sameSession
107
+ && heartbeat.seq <= state.heartbeat.seq) return { ok: false, code: 'stale-sequence' };
108
+ if (!sameSession) {
109
+ state.generation += 1;
110
+ finishWaiter(state, { type: 'superseded', generation: state.generation });
111
+ if (state.inflight) {
112
+ state.lastAck = {
113
+ id: state.inflight.id,
114
+ status: 'error',
115
+ result: { code: 'stale-session', detail: 'device session changed before ack' },
116
+ at: now(),
117
+ };
118
+ state.inflight = null;
119
+ }
120
+ }
121
+ state.lastSeen = now();
122
+ state.heartbeat = heartbeat;
123
+ let acknowledged = false;
124
+ if (heartbeat.ack && state.inflight && heartbeat.ack.id === state.inflight.id) {
125
+ state.lastAck = { ...heartbeat.ack, at: state.lastSeen };
126
+ state.inflight = null;
127
+ acknowledged = true;
128
+ } else if (state.inflight && sameSession) {
129
+ state.lastAck = {
130
+ id: state.inflight.id,
131
+ status: 'error',
132
+ result: {
133
+ code: 'delivery-unknown',
134
+ detail: 'device repolled without acknowledging the dispatched command',
135
+ },
136
+ at: state.lastSeen,
137
+ };
138
+ state.inflight = null;
139
+ }
140
+ return { ok: true, state, acknowledged };
141
+ }
142
+
143
+ function poll(node, rawHeartbeat, { waitMs = MAX_WAIT_MS, signal = null } = {}) {
144
+ const observed = observe(node, rawHeartbeat);
145
+ if (!observed.ok) return Promise.resolve({ type: 'error', code: observed.code });
146
+ const state = observed.state;
147
+ finishWaiter(state, { type: 'superseded', generation: state.generation });
148
+ if (observed.acknowledged) {
149
+ return Promise.resolve({ type: 'acknowledged', generation: state.generation });
150
+ }
151
+ const boundedWait = Math.max(1, Math.min(MAX_WAIT_MS, Number(waitMs) || MAX_WAIT_MS));
152
+ return new Promise((resolve) => {
153
+ const waiter = { resolve, timer: null, cleanup: null };
154
+ waiter.timer = setTimer(() => {
155
+ if (state.waiter === waiter) state.waiter = null;
156
+ waiter.cleanup?.();
157
+ resolve({ type: 'idle', generation: state.generation });
158
+ }, boundedWait);
159
+ waiter.timer.unref?.();
160
+ if (signal) {
161
+ const onAbort = () => {
162
+ if (state.waiter === waiter) state.waiter = null;
163
+ clearTimer(waiter.timer);
164
+ resolve({ type: 'aborted', generation: state.generation });
165
+ };
166
+ signal.addEventListener('abort', onAbort, { once: true });
167
+ waiter.cleanup = () => signal.removeEventListener('abort', onAbort);
168
+ }
169
+ state.waiter = waiter;
170
+ });
171
+ }
172
+
173
+ function dispatch(nodeId, command) {
174
+ if (!NODE_ID_RE.test(String(nodeId || ''))) return { ok: false, code: 'invalid-node-id' };
175
+ const state = states.get(nodeId);
176
+ if (!state || !state.waiter || !state.heartbeat || now() - state.lastSeen > ONLINE_GRACE_MS) {
177
+ return { ok: false, code: 'node-offline' };
178
+ }
179
+ if (state.inflight) return { ok: false, code: 'command-in-flight' };
180
+ const id = randomBytes(16).toString('hex');
181
+ const envelope = { id, issuedAt: now(), generation: state.generation, ...command };
182
+ state.inflight = { id, kind: command.kind, status: 'submitted', submittedAt: envelope.issuedAt };
183
+ if (!finishWaiter(state, { type: 'command', command: envelope })) {
184
+ state.inflight = null;
185
+ return { ok: false, code: 'node-offline' };
186
+ }
187
+ return { ok: true, id, status: 'submitted', generation: state.generation };
188
+ }
189
+
190
+ function forget(nodeId) {
191
+ const state = states.get(nodeId);
192
+ if (state) finishWaiter(state, { type: 'revoked' });
193
+ return states.delete(nodeId);
194
+ }
195
+
196
+ function list(nodes) {
197
+ const at = now();
198
+ return nodes.map((node) => {
199
+ const state = states.get(node.nodeId);
200
+ const online = Boolean(state && (state.waiter || state.inflight)
201
+ && state.lastSeen !== null && at - state.lastSeen <= ONLINE_GRACE_MS);
202
+ return {
203
+ ...node,
204
+ online,
205
+ lastSeen: state?.lastSeen ?? null,
206
+ generation: state?.generation ?? 0,
207
+ version: state?.heartbeat?.version || '',
208
+ capabilities: state?.heartbeat?.capabilities || [],
209
+ health: state?.heartbeat?.health || null,
210
+ inflight: state?.inflight || null,
211
+ lastAck: state?.lastAck || null,
212
+ };
213
+ });
214
+ }
215
+
216
+ return { poll, dispatch, forget, list, observe, _states: states };
217
+ }
218
+
219
+ module.exports = {
220
+ SESSION_RE,
221
+ PROTOCOL,
222
+ MAX_WAIT_MS,
223
+ ONLINE_GRACE_MS,
224
+ sanitizeHeartbeat,
225
+ sanitizeHealth,
226
+ sanitizeAck,
227
+ createBroker,
228
+ };