ultravisor 1.0.25 → 1.0.26
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/docs/_sidebar.md +1 -0
- package/docs/features/persistence-via-databeacon.md +1211 -0
- package/package.json +6 -6
- package/source/cli/Ultravisor-CLIProgram.cjs +62 -0
- package/source/config/Ultravisor-Default-Command-Configuration.cjs +9 -1
- package/source/persistence/UltravisorPersistenceSchema.json +240 -0
- package/source/services/Ultravisor-AuthBeaconBridge.cjs +271 -0
- package/source/services/Ultravisor-Beacon-Coordinator.cjs +242 -149
- package/source/services/Ultravisor-Beacon-Scheduler.cjs +65 -29
- package/source/services/Ultravisor-ExecutionManifest.cjs +99 -4
- package/source/services/Ultravisor-FleetManager.cjs +19 -1
- package/source/services/Ultravisor-ManifestStoreBridge.cjs +1134 -0
- package/source/services/Ultravisor-QueuePersistenceBridge.cjs +1336 -0
- package/source/web_server/Ultravisor-API-Server.cjs +951 -90
- package/webinterface/package.json +1 -0
- package/webinterface/source/Pict-Application-Ultravisor.js +57 -2
- package/webinterface/source/providers/PictRouter-Ultravisor-Configuration.json +8 -0
- package/webinterface/source/views/PictView-Ultravisor-Login.js +74 -0
- package/webinterface/source/views/PictView-Ultravisor-TopBar.js +25 -0
- package/webinterface/source/views/PictView-Ultravisor-UserManagement.js +159 -0
|
@@ -2,11 +2,81 @@ const libPictService = require(`pict-serviceproviderbase`);
|
|
|
2
2
|
|
|
3
3
|
const libFS = require('fs');
|
|
4
4
|
const libPath = require('path');
|
|
5
|
+
const libCrypto = require('crypto');
|
|
5
6
|
const libOrator = require('orator');
|
|
6
7
|
const libOratorServiceServerRestify = require(`orator-serviceserver-restify`);
|
|
7
8
|
const libOratorAuthentication = require('orator-authentication');
|
|
8
9
|
const libWebSocket = require('ws');
|
|
9
10
|
|
|
11
|
+
// Strip a manifest down to the JSON-serializable shape the wire
|
|
12
|
+
// expects. The in-memory ExecutionContext can carry closures in
|
|
13
|
+
// PendingEvents and circular refs in WaitingTasks; this projection
|
|
14
|
+
// is what /Manifest/:RunHash has always returned. Used by both the
|
|
15
|
+
// in-memory and the bridge-backed read paths so callers see the
|
|
16
|
+
// same schema regardless of where the manifest came from.
|
|
17
|
+
function _cleanManifestForWire(pManifest)
|
|
18
|
+
{
|
|
19
|
+
if (!pManifest) return null;
|
|
20
|
+
return {
|
|
21
|
+
Hash: pManifest.Hash,
|
|
22
|
+
OperationHash: pManifest.OperationHash,
|
|
23
|
+
OperationName: pManifest.OperationName,
|
|
24
|
+
Status: pManifest.Status,
|
|
25
|
+
RunMode: pManifest.RunMode,
|
|
26
|
+
Live: pManifest.Live || false,
|
|
27
|
+
StartTime: pManifest.StartTime,
|
|
28
|
+
StopTime: pManifest.StopTime,
|
|
29
|
+
ElapsedMs: pManifest.ElapsedMs,
|
|
30
|
+
Output: pManifest.Output || {},
|
|
31
|
+
GlobalState: pManifest.GlobalState || {},
|
|
32
|
+
OperationState: pManifest.OperationState || {},
|
|
33
|
+
TaskOutputs: pManifest.TaskOutputs || {},
|
|
34
|
+
TaskManifests: pManifest.TaskManifests || {},
|
|
35
|
+
WaitingTasks: pManifest.WaitingTasks || {},
|
|
36
|
+
TimingSummary: pManifest.TimingSummary || null,
|
|
37
|
+
EventLog: pManifest.EventLog || [],
|
|
38
|
+
Errors: pManifest.Errors || [],
|
|
39
|
+
Log: pManifest.Log || []
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Lightweight query-string parser. Restify's queryParser plugin is
|
|
44
|
+
// not enabled on this server (mounting it would change the request
|
|
45
|
+
// shape for every existing handler), so the few routes that need
|
|
46
|
+
// query params parse the URL directly. Returns an empty object when
|
|
47
|
+
// no query string is present.
|
|
48
|
+
function _parseQueryString(pURL)
|
|
49
|
+
{
|
|
50
|
+
if (!pURL) return {};
|
|
51
|
+
let tmpQ = pURL.indexOf('?');
|
|
52
|
+
if (tmpQ < 0) return {};
|
|
53
|
+
let tmpStr = pURL.slice(tmpQ + 1);
|
|
54
|
+
let tmpOut = {};
|
|
55
|
+
let tmpPairs = tmpStr.split('&');
|
|
56
|
+
for (let i = 0; i < tmpPairs.length; i++)
|
|
57
|
+
{
|
|
58
|
+
if (!tmpPairs[i]) continue;
|
|
59
|
+
let tmpEq = tmpPairs[i].indexOf('=');
|
|
60
|
+
let tmpKey, tmpVal;
|
|
61
|
+
if (tmpEq < 0)
|
|
62
|
+
{
|
|
63
|
+
tmpKey = tmpPairs[i];
|
|
64
|
+
tmpVal = '';
|
|
65
|
+
}
|
|
66
|
+
else
|
|
67
|
+
{
|
|
68
|
+
tmpKey = tmpPairs[i].slice(0, tmpEq);
|
|
69
|
+
tmpVal = tmpPairs[i].slice(tmpEq + 1);
|
|
70
|
+
}
|
|
71
|
+
try { tmpKey = decodeURIComponent(tmpKey.replace(/\+/g, ' ')); }
|
|
72
|
+
catch (e) { /* leave raw */ }
|
|
73
|
+
try { tmpVal = decodeURIComponent(tmpVal.replace(/\+/g, ' ')); }
|
|
74
|
+
catch (e) { /* leave raw */ }
|
|
75
|
+
tmpOut[tmpKey] = tmpVal;
|
|
76
|
+
}
|
|
77
|
+
return tmpOut;
|
|
78
|
+
}
|
|
79
|
+
|
|
10
80
|
class UltravisorAPIServer extends libPictService
|
|
11
81
|
{
|
|
12
82
|
constructor(pPict, pOptions, pServiceHash)
|
|
@@ -31,6 +101,110 @@ class UltravisorAPIServer extends libPictService
|
|
|
31
101
|
// Set of WebSocket clients subscribed to the queue.* topic
|
|
32
102
|
// (broadcast by UltravisorBeaconScheduler).
|
|
33
103
|
this._QueueSubscribers = new Set();
|
|
104
|
+
|
|
105
|
+
// Queue-event ring buffer for GUID-anchored catch-up replay.
|
|
106
|
+
//
|
|
107
|
+
// Every queue.* delta the scheduler emits gets stamped with an
|
|
108
|
+
// EventGUID (immutable identity) + Seq (monotonic-per-process
|
|
109
|
+
// ordering hint, NOT identity) and stored here. When a client
|
|
110
|
+
// reconnects with {Action:"QueueSubscribe", LastEventGUID:X},
|
|
111
|
+
// we replay everything after X.
|
|
112
|
+
//
|
|
113
|
+
// Seq alone can't be used for identity: it resets on process
|
|
114
|
+
// restart, and a persistence beacon catching up from durable
|
|
115
|
+
// history would have to reconcile with whatever Seq counter
|
|
116
|
+
// happens to be running. EventGUID is the only stable handle.
|
|
117
|
+
//
|
|
118
|
+
// Snapshot/control frames (queue.summary, queue.replay_*,
|
|
119
|
+
// queue.reset) get an EventGUID for wire consistency but are
|
|
120
|
+
// NOT buffered — replaying a stale summary would briefly clobber
|
|
121
|
+
// correct counts, and control frames are session-scoped.
|
|
122
|
+
this._QueueEventBuffer = [];
|
|
123
|
+
this._QueueEventBufferCap = 2000;
|
|
124
|
+
this._QueueEventSeq = 0;
|
|
125
|
+
|
|
126
|
+
// Manifest (per-RunHash execution event) catch-up buffers.
|
|
127
|
+
//
|
|
128
|
+
// Same EventGUID-anchored resync protocol as the queue side, but
|
|
129
|
+
// with PER-RUN buffers instead of one global ring. A single ring
|
|
130
|
+
// would either evict mid-run events (when many runs are active)
|
|
131
|
+
// or be huge to compensate; per-run is cleaner because runs have
|
|
132
|
+
// a clear lifecycle (created → events → ExecutionComplete →
|
|
133
|
+
// dropped after a grace period). Per-run Seq counters also
|
|
134
|
+
// surface gaps locally to clients ("I had Seq=7 then jumped to
|
|
135
|
+
// Seq=10 — what happened to 8 and 9?") in a way a global Seq
|
|
136
|
+
// shared across runs can't.
|
|
137
|
+
//
|
|
138
|
+
// Maps keyed by RunHash:
|
|
139
|
+
// _ManifestEventBuffers — Array<envelope> per run
|
|
140
|
+
// _ManifestEventSeqs — int counter per run
|
|
141
|
+
// _ManifestEventCleanupTimers — setTimeout handle per finalized run
|
|
142
|
+
//
|
|
143
|
+
// Buffers persist until grace period elapses after the
|
|
144
|
+
// ExecutionComplete event, so a subscriber that reconnects
|
|
145
|
+
// shortly after a run finishes can still get the full replay.
|
|
146
|
+
this._ManifestEventBuffers = new Map();
|
|
147
|
+
this._ManifestEventSeqs = new Map();
|
|
148
|
+
this._ManifestEventCleanupTimers = new Map();
|
|
149
|
+
this._ManifestEventBufferCapPerRun = 5000;
|
|
150
|
+
this._ManifestEventGracePeriodMs = 5 * 60 * 1000;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Topics that flow through the wire envelope but should NOT be
|
|
154
|
+
// retained in the replay buffer. queue.summary is a snapshot
|
|
155
|
+
// (replaying old ones would briefly show stale counts);
|
|
156
|
+
// queue.replay_* and queue.reset are control frames meant for a
|
|
157
|
+
// specific subscriber's resume cycle and don't belong in the
|
|
158
|
+
// shared history.
|
|
159
|
+
_isReplayableQueueTopic(pTopic)
|
|
160
|
+
{
|
|
161
|
+
if (pTopic === 'queue.summary') return false;
|
|
162
|
+
if (pTopic === 'queue.replay_begin') return false;
|
|
163
|
+
if (pTopic === 'queue.replay_complete') return false;
|
|
164
|
+
if (pTopic === 'queue.reset') return false;
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Find the buffer index of pGUID. Scans newest-first because the
|
|
169
|
+
// common case is "client reconnected after a brief gap" — the
|
|
170
|
+
// requested GUID is near the tail. Returns -1 when not found.
|
|
171
|
+
_findQueueEventIndex(pGUID)
|
|
172
|
+
{
|
|
173
|
+
if (!pGUID) return -1;
|
|
174
|
+
let tmpBuf = this._QueueEventBuffer;
|
|
175
|
+
for (let i = tmpBuf.length - 1; i >= 0; i--)
|
|
176
|
+
{
|
|
177
|
+
if (tmpBuf[i].EventGUID === pGUID) return i;
|
|
178
|
+
}
|
|
179
|
+
return -1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Manifest analogs — the protocol is the same shape but the buffer
|
|
183
|
+
// is partitioned by RunHash (see constructor).
|
|
184
|
+
|
|
185
|
+
// Control event types are session-scoped and never buffered. They
|
|
186
|
+
// also don't advance the per-run cursor (the cursor on the
|
|
187
|
+
// browser side intentionally pins to the last "real" execution
|
|
188
|
+
// event so a subsequent reconnect anchors against something the
|
|
189
|
+
// server actually has in its buffer).
|
|
190
|
+
_isReplayableExecutionEventType(pType)
|
|
191
|
+
{
|
|
192
|
+
if (pType === 'execution.replay_begin') return false;
|
|
193
|
+
if (pType === 'execution.replay_complete') return false;
|
|
194
|
+
if (pType === 'execution.reset') return false;
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
_findManifestEventIndex(pRunHash, pGUID)
|
|
199
|
+
{
|
|
200
|
+
if (!pRunHash || !pGUID) return -1;
|
|
201
|
+
let tmpBuf = this._ManifestEventBuffers.get(pRunHash);
|
|
202
|
+
if (!tmpBuf) return -1;
|
|
203
|
+
for (let i = tmpBuf.length - 1; i >= 0; i--)
|
|
204
|
+
{
|
|
205
|
+
if (tmpBuf[i].EventGUID === pGUID) return i;
|
|
206
|
+
}
|
|
207
|
+
return -1;
|
|
34
208
|
}
|
|
35
209
|
|
|
36
210
|
/**
|
|
@@ -751,14 +925,51 @@ class UltravisorAPIServer extends libPictService
|
|
|
751
925
|
);
|
|
752
926
|
|
|
753
927
|
// --- Manifests ---
|
|
928
|
+
// Reads merge two sources:
|
|
929
|
+
// - Live runs from the in-process UltravisorExecutionManifest
|
|
930
|
+
// service (in-memory, includes still-running operations).
|
|
931
|
+
// - Historical runs from the ManifestStore bridge (beacon when
|
|
932
|
+
// connected, on-disk fallback otherwise).
|
|
933
|
+
// Dedup by Hash; live wins so an in-flight run doesn't get
|
|
934
|
+
// replaced by a stale persisted snapshot.
|
|
754
935
|
this._OratorServer.get
|
|
755
936
|
(
|
|
756
937
|
'/Manifest',
|
|
757
938
|
function (pRequest, pResponse, fNext)
|
|
758
939
|
{
|
|
759
940
|
let tmpManifest = this._getService('UltravisorExecutionManifest');
|
|
760
|
-
|
|
761
|
-
|
|
941
|
+
let tmpBridge = this._getService('UltravisorManifestStoreBridge');
|
|
942
|
+
let tmpLiveRuns = tmpManifest ? (tmpManifest.listRuns() || []) : [];
|
|
943
|
+
if (!tmpBridge)
|
|
944
|
+
{
|
|
945
|
+
pResponse.send(tmpLiveRuns);
|
|
946
|
+
return fNext();
|
|
947
|
+
}
|
|
948
|
+
tmpBridge.listManifests({}).then((pHist) =>
|
|
949
|
+
{
|
|
950
|
+
let tmpHist = (pHist && pHist.Manifests) || [];
|
|
951
|
+
let tmpSeen = new Set();
|
|
952
|
+
let tmpOut = [];
|
|
953
|
+
for (let i = 0; i < tmpLiveRuns.length; i++)
|
|
954
|
+
{
|
|
955
|
+
tmpSeen.add(tmpLiveRuns[i].Hash);
|
|
956
|
+
tmpOut.push(tmpLiveRuns[i]);
|
|
957
|
+
}
|
|
958
|
+
for (let i = 0; i < tmpHist.length; i++)
|
|
959
|
+
{
|
|
960
|
+
if (!tmpSeen.has(tmpHist[i].Hash))
|
|
961
|
+
{
|
|
962
|
+
tmpOut.push(tmpHist[i]);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
pResponse.send(tmpOut);
|
|
966
|
+
return fNext();
|
|
967
|
+
}).catch(() =>
|
|
968
|
+
{
|
|
969
|
+
// Bridge failed — return whatever live data we have.
|
|
970
|
+
pResponse.send(tmpLiveRuns);
|
|
971
|
+
return fNext();
|
|
972
|
+
});
|
|
762
973
|
}.bind(this)
|
|
763
974
|
);
|
|
764
975
|
|
|
@@ -767,40 +978,37 @@ class UltravisorAPIServer extends libPictService
|
|
|
767
978
|
'/Manifest/:RunHash',
|
|
768
979
|
function (pRequest, pResponse, fNext)
|
|
769
980
|
{
|
|
981
|
+
let tmpHash = pRequest.params.RunHash;
|
|
770
982
|
let tmpManifest = this._getService('UltravisorExecutionManifest');
|
|
771
|
-
let tmpRun = tmpManifest ? tmpManifest.getRun(
|
|
983
|
+
let tmpRun = tmpManifest ? tmpManifest.getRun(tmpHash) : null;
|
|
772
984
|
if (tmpRun)
|
|
773
985
|
{
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
let tmpClean = {
|
|
777
|
-
Hash: tmpRun.Hash,
|
|
778
|
-
OperationHash: tmpRun.OperationHash,
|
|
779
|
-
OperationName: tmpRun.OperationName,
|
|
780
|
-
Status: tmpRun.Status,
|
|
781
|
-
RunMode: tmpRun.RunMode,
|
|
782
|
-
Live: tmpRun.Live || false,
|
|
783
|
-
StartTime: tmpRun.StartTime,
|
|
784
|
-
StopTime: tmpRun.StopTime,
|
|
785
|
-
ElapsedMs: tmpRun.ElapsedMs,
|
|
786
|
-
Output: tmpRun.Output || {},
|
|
787
|
-
GlobalState: tmpRun.GlobalState || {},
|
|
788
|
-
OperationState: tmpRun.OperationState || {},
|
|
789
|
-
TaskOutputs: tmpRun.TaskOutputs || {},
|
|
790
|
-
TaskManifests: tmpRun.TaskManifests || {},
|
|
791
|
-
WaitingTasks: tmpRun.WaitingTasks || {},
|
|
792
|
-
TimingSummary: tmpRun.TimingSummary || null,
|
|
793
|
-
EventLog: tmpRun.EventLog || [],
|
|
794
|
-
Errors: tmpRun.Errors || [],
|
|
795
|
-
Log: tmpRun.Log || []
|
|
796
|
-
};
|
|
797
|
-
pResponse.send(tmpClean);
|
|
986
|
+
pResponse.send(_cleanManifestForWire(tmpRun));
|
|
987
|
+
return fNext();
|
|
798
988
|
}
|
|
799
|
-
|
|
989
|
+
// Not in memory — try the bridge (beacon-backed history).
|
|
990
|
+
let tmpBridge = this._getService('UltravisorManifestStoreBridge');
|
|
991
|
+
if (!tmpBridge)
|
|
800
992
|
{
|
|
801
|
-
pResponse.send(404, { Error: `Manifest ${
|
|
993
|
+
pResponse.send(404, { Error: `Manifest ${tmpHash} not found.` });
|
|
994
|
+
return fNext();
|
|
802
995
|
}
|
|
803
|
-
|
|
996
|
+
tmpBridge.getManifest(tmpHash).then((pResult) =>
|
|
997
|
+
{
|
|
998
|
+
if (pResult && pResult.Success && pResult.Manifest)
|
|
999
|
+
{
|
|
1000
|
+
pResponse.send(_cleanManifestForWire(pResult.Manifest));
|
|
1001
|
+
}
|
|
1002
|
+
else
|
|
1003
|
+
{
|
|
1004
|
+
pResponse.send(404, { Error: `Manifest ${tmpHash} not found.` });
|
|
1005
|
+
}
|
|
1006
|
+
return fNext();
|
|
1007
|
+
}).catch((pErr) =>
|
|
1008
|
+
{
|
|
1009
|
+
pResponse.send(500, { Error: 'Bridge read failed: ' + (pErr && pErr.message) });
|
|
1010
|
+
return fNext();
|
|
1011
|
+
});
|
|
804
1012
|
}.bind(this)
|
|
805
1013
|
);
|
|
806
1014
|
|
|
@@ -1719,36 +1927,56 @@ class UltravisorAPIServer extends libPictService
|
|
|
1719
1927
|
);
|
|
1720
1928
|
|
|
1721
1929
|
// --- Per-Work-Item Event Log ---
|
|
1930
|
+
// Read-only — matches the no-auth precedent of /Beacon/Queue
|
|
1931
|
+
// and /Manifest/:hash. Retold-Labs proxies this for the /queue
|
|
1932
|
+
// drawer so the lab user can see what happened to a work item
|
|
1933
|
+
// without needing a separate ultravisor session. (Cancel and
|
|
1934
|
+
// Reorder, the state-changing siblings, still require a session.)
|
|
1722
1935
|
this._OratorServer.get
|
|
1723
1936
|
(
|
|
1724
1937
|
'/Beacon/Work/:WorkItemHash/Events',
|
|
1725
1938
|
function (pRequest, pResponse, fNext)
|
|
1726
1939
|
{
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1940
|
+
// Read via the persistence bridge — beacon-backed when
|
|
1941
|
+
// connected, local QueueStore otherwise. The bridge
|
|
1942
|
+
// returns {Available, Success, WorkItem|Events, Reason}
|
|
1943
|
+
// so we can distinguish "no backend at all" (503),
|
|
1944
|
+
// "unknown work item" (404), and success (200).
|
|
1945
|
+
let tmpBridge = this._getService('UltravisorQueuePersistenceBridge');
|
|
1946
|
+
if (!tmpBridge)
|
|
1732
1947
|
{
|
|
1733
|
-
pResponse.send(503, { Error: '
|
|
1948
|
+
pResponse.send(503, { Error: 'QueuePersistenceBridge service not available.' });
|
|
1734
1949
|
return fNext();
|
|
1735
1950
|
}
|
|
1736
1951
|
|
|
1737
1952
|
let tmpHash = pRequest.params.WorkItemHash;
|
|
1738
1953
|
let tmpLimit = pRequest.query ? parseInt(pRequest.query.limit, 10) || 500 : 500;
|
|
1739
|
-
|
|
1740
|
-
|
|
1954
|
+
|
|
1955
|
+
tmpBridge.getWorkItemByHash(tmpHash).then((pItemResult) =>
|
|
1956
|
+
{
|
|
1957
|
+
if (!pItemResult || !pItemResult.Available)
|
|
1958
|
+
{
|
|
1959
|
+
pResponse.send(503, { Error: 'No persistence backend available.' });
|
|
1960
|
+
return fNext();
|
|
1961
|
+
}
|
|
1962
|
+
if (!pItemResult.Success || !pItemResult.WorkItem)
|
|
1963
|
+
{
|
|
1964
|
+
pResponse.send(404, { Error: `Work item [${tmpHash}] not found.` });
|
|
1965
|
+
return fNext();
|
|
1966
|
+
}
|
|
1967
|
+
return tmpBridge.getEvents(tmpHash, tmpLimit).then((pEventsResult) =>
|
|
1968
|
+
{
|
|
1969
|
+
pResponse.send({
|
|
1970
|
+
WorkItemHash: tmpHash,
|
|
1971
|
+
Events: (pEventsResult && pEventsResult.Events) || []
|
|
1972
|
+
});
|
|
1973
|
+
return fNext();
|
|
1974
|
+
});
|
|
1975
|
+
}).catch((pErr) =>
|
|
1741
1976
|
{
|
|
1742
|
-
pResponse.send(
|
|
1977
|
+
pResponse.send(500, { Error: 'Bridge dispatch failed: ' + (pErr && pErr.message) });
|
|
1743
1978
|
return fNext();
|
|
1744
|
-
}
|
|
1745
|
-
|
|
1746
|
-
let tmpEvents = tmpStore.listEventsForWorkItem(tmpHash, tmpLimit);
|
|
1747
|
-
pResponse.send({
|
|
1748
|
-
WorkItemHash: tmpHash,
|
|
1749
|
-
Events: tmpEvents
|
|
1750
1979
|
});
|
|
1751
|
-
return fNext();
|
|
1752
1980
|
}.bind(this)
|
|
1753
1981
|
);
|
|
1754
1982
|
|
|
@@ -1759,24 +1987,99 @@ class UltravisorAPIServer extends libPictService
|
|
|
1759
1987
|
function (pRequest, pResponse, fNext)
|
|
1760
1988
|
{
|
|
1761
1989
|
let tmpScheduler = this._getService('UltravisorBeaconScheduler');
|
|
1762
|
-
let tmpStore = this._getService('UltravisorBeaconQueueStore');
|
|
1763
1990
|
let tmpSummary = tmpScheduler ? tmpScheduler.summarize() : null;
|
|
1764
|
-
|
|
1765
|
-
|
|
1991
|
+
// Restify's queryParser plugin isn't installed on this
|
|
1992
|
+
// server, so pRequest.query is undefined. Parse the URL
|
|
1993
|
+
// directly. Cheap, no dependency.
|
|
1994
|
+
let tmpQuery = _parseQueryString(pRequest.url || '');
|
|
1995
|
+
let tmpBucket = tmpQuery.bucket || null;
|
|
1996
|
+
let tmpLimit = parseInt(tmpQuery.limit, 10) || 200;
|
|
1766
1997
|
|
|
1767
1998
|
let tmpItems = tmpScheduler ? tmpScheduler.listBuckets(tmpBucket, tmpLimit) : [];
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1999
|
+
|
|
2000
|
+
// History is opt-in via ?include=history. When opted in,
|
|
2001
|
+
// pull through the persistence bridge so a connected
|
|
2002
|
+
// QueuePersistence beacon owns the long-tail history view.
|
|
2003
|
+
let tmpWantsHistory = tmpQuery.include === 'history';
|
|
2004
|
+
if (!tmpWantsHistory)
|
|
1771
2005
|
{
|
|
1772
|
-
|
|
2006
|
+
pResponse.send({ Summary: tmpSummary, Items: tmpItems, History: null });
|
|
2007
|
+
return fNext();
|
|
1773
2008
|
}
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
History:
|
|
1778
|
-
|
|
1779
|
-
|
|
2009
|
+
let tmpBridge = this._getService('UltravisorQueuePersistenceBridge');
|
|
2010
|
+
if (!tmpBridge)
|
|
2011
|
+
{
|
|
2012
|
+
pResponse.send({ Summary: tmpSummary, Items: tmpItems, History: null });
|
|
2013
|
+
return fNext();
|
|
2014
|
+
}
|
|
2015
|
+
tmpBridge.listWorkItems({ Limit: tmpLimit, OrderBy: '-EnqueuedAt' })
|
|
2016
|
+
.then((pHistResult) =>
|
|
2017
|
+
{
|
|
2018
|
+
let tmpHistorical = (pHistResult && pHistResult.WorkItems) || null;
|
|
2019
|
+
pResponse.send({
|
|
2020
|
+
Summary: tmpSummary,
|
|
2021
|
+
Items: tmpItems,
|
|
2022
|
+
History: tmpHistorical
|
|
2023
|
+
});
|
|
2024
|
+
return fNext();
|
|
2025
|
+
})
|
|
2026
|
+
.catch(() =>
|
|
2027
|
+
{
|
|
2028
|
+
// Don't fail the whole snapshot if history
|
|
2029
|
+
// fetch errors — return what we have.
|
|
2030
|
+
pResponse.send({ Summary: tmpSummary, Items: tmpItems, History: null });
|
|
2031
|
+
return fNext();
|
|
2032
|
+
});
|
|
2033
|
+
}.bind(this)
|
|
2034
|
+
);
|
|
2035
|
+
|
|
2036
|
+
// --- One-time admin bootstrap (no session required) ---
|
|
2037
|
+
// Body: {Token, UserSpec:{Username, Password, Roles?}}
|
|
2038
|
+
// Dispatches AUTH_BootstrapAdmin via the bridge. Intentionally
|
|
2039
|
+
// unauthenticated — the bootstrap is the chicken-and-egg path
|
|
2040
|
+
// for creating the very first admin in a fresh non-promiscuous
|
|
2041
|
+
// mesh, before any session exists. Defense in depth: the auth
|
|
2042
|
+
// beacon validates the token via constant-time compare AND
|
|
2043
|
+
// consumes it on first success, so brute-force or replay
|
|
2044
|
+
// attempts past the first hit fail.
|
|
2045
|
+
this._OratorServer.post
|
|
2046
|
+
(
|
|
2047
|
+
'/Beacon/BootstrapAdmin',
|
|
2048
|
+
function (pRequest, pResponse, fNext)
|
|
2049
|
+
{
|
|
2050
|
+
let tmpBridge = this._getService('UltravisorAuthBeaconBridge');
|
|
2051
|
+
if (!tmpBridge)
|
|
2052
|
+
{
|
|
2053
|
+
pResponse.send(503, { Success: false, Reason: 'AuthBeaconBridge not available' });
|
|
2054
|
+
return fNext();
|
|
2055
|
+
}
|
|
2056
|
+
if (!tmpBridge.isAvailable())
|
|
2057
|
+
{
|
|
2058
|
+
pResponse.send(503, { Success: false, Reason: 'No auth beacon connected' });
|
|
2059
|
+
return fNext();
|
|
2060
|
+
}
|
|
2061
|
+
let tmpBody = pRequest.body || {};
|
|
2062
|
+
tmpBridge.bootstrapAdmin(tmpBody.Token, tmpBody.UserSpec || {})
|
|
2063
|
+
.then((pResult) =>
|
|
2064
|
+
{
|
|
2065
|
+
let tmpStatus = (pResult && pResult.Success) ? 200 : 400;
|
|
2066
|
+
if (pResult && /not (supported|reachable)/i.test(pResult.Reason || ''))
|
|
2067
|
+
{
|
|
2068
|
+
tmpStatus = 503;
|
|
2069
|
+
}
|
|
2070
|
+
pResponse.send(tmpStatus, pResult || { Success: false });
|
|
2071
|
+
return fNext();
|
|
2072
|
+
})
|
|
2073
|
+
.catch((pErr) =>
|
|
2074
|
+
{
|
|
2075
|
+
pResponse.send(502,
|
|
2076
|
+
{
|
|
2077
|
+
Success: false,
|
|
2078
|
+
Error: 'Bootstrap dispatch failed',
|
|
2079
|
+
Reason: (pErr && pErr.message) || String(pErr)
|
|
2080
|
+
});
|
|
2081
|
+
return fNext();
|
|
2082
|
+
});
|
|
1780
2083
|
}.bind(this)
|
|
1781
2084
|
);
|
|
1782
2085
|
|
|
@@ -2279,6 +2582,86 @@ class UltravisorAPIServer extends libPictService
|
|
|
2279
2582
|
}.bind(this)
|
|
2280
2583
|
);
|
|
2281
2584
|
|
|
2585
|
+
// --- Persistence assignment (Session 3) ---
|
|
2586
|
+
// The lab POSTs an explicit persistence assignment to a running
|
|
2587
|
+
// UV; both bridges' setPersistenceAssignment fires their bootstrap
|
|
2588
|
+
// state machines if the chosen beacon is already Online. The GET
|
|
2589
|
+
// returns the merged Queue + Manifest status object the lab status
|
|
2590
|
+
// pill polls. See docs/features/persistence-via-databeacon.md.
|
|
2591
|
+
this._OratorServer.post
|
|
2592
|
+
(
|
|
2593
|
+
'/Ultravisor/Persistence/Assign',
|
|
2594
|
+
function (pRequest, pResponse, fNext)
|
|
2595
|
+
{
|
|
2596
|
+
let tmpSession = this._requireSession(pRequest, pResponse, fNext);
|
|
2597
|
+
if (tmpSession === null) return;
|
|
2598
|
+
|
|
2599
|
+
let tmpQueueBridge = this._getService('UltravisorQueuePersistenceBridge');
|
|
2600
|
+
let tmpManifestBridge = this._getService('UltravisorManifestStoreBridge');
|
|
2601
|
+
if (!tmpQueueBridge || !tmpManifestBridge)
|
|
2602
|
+
{
|
|
2603
|
+
pResponse.send(502, { Error: 'Persistence bridges not available.' });
|
|
2604
|
+
return fNext();
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
let tmpBody = pRequest.body || {};
|
|
2608
|
+
let tmpBeaconID = (tmpBody.BeaconID === null || tmpBody.BeaconID === undefined) ? null : String(tmpBody.BeaconID);
|
|
2609
|
+
let tmpIDConn = parseInt(tmpBody.IDBeaconConnection, 10) || 0;
|
|
2610
|
+
|
|
2611
|
+
try
|
|
2612
|
+
{
|
|
2613
|
+
if (!tmpBeaconID)
|
|
2614
|
+
{
|
|
2615
|
+
tmpQueueBridge.clearPersistenceAssignment();
|
|
2616
|
+
tmpManifestBridge.clearPersistenceAssignment();
|
|
2617
|
+
}
|
|
2618
|
+
else
|
|
2619
|
+
{
|
|
2620
|
+
tmpQueueBridge.setPersistenceAssignment(tmpBeaconID, tmpIDConn);
|
|
2621
|
+
tmpManifestBridge.setPersistenceAssignment(tmpBeaconID, tmpIDConn);
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
catch (pErr)
|
|
2625
|
+
{
|
|
2626
|
+
pResponse.send(500, { Error: pErr.message || String(pErr) });
|
|
2627
|
+
return fNext();
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
pResponse.send(
|
|
2631
|
+
{
|
|
2632
|
+
Success: true,
|
|
2633
|
+
Queue: tmpQueueBridge.getPersistenceStatus(),
|
|
2634
|
+
Manifest: tmpManifestBridge.getPersistenceStatus()
|
|
2635
|
+
});
|
|
2636
|
+
return fNext();
|
|
2637
|
+
}.bind(this)
|
|
2638
|
+
);
|
|
2639
|
+
|
|
2640
|
+
this._OratorServer.get
|
|
2641
|
+
(
|
|
2642
|
+
'/Ultravisor/Persistence/Status',
|
|
2643
|
+
function (pRequest, pResponse, fNext)
|
|
2644
|
+
{
|
|
2645
|
+
let tmpSession = this._requireSession(pRequest, pResponse, fNext);
|
|
2646
|
+
if (tmpSession === null) return;
|
|
2647
|
+
|
|
2648
|
+
let tmpQueueBridge = this._getService('UltravisorQueuePersistenceBridge');
|
|
2649
|
+
let tmpManifestBridge = this._getService('UltravisorManifestStoreBridge');
|
|
2650
|
+
if (!tmpQueueBridge || !tmpManifestBridge)
|
|
2651
|
+
{
|
|
2652
|
+
pResponse.send(502, { Error: 'Persistence bridges not available.' });
|
|
2653
|
+
return fNext();
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
pResponse.send(
|
|
2657
|
+
{
|
|
2658
|
+
Queue: tmpQueueBridge.getPersistenceStatus(),
|
|
2659
|
+
Manifest: tmpManifestBridge.getPersistenceStatus()
|
|
2660
|
+
});
|
|
2661
|
+
return fNext();
|
|
2662
|
+
}.bind(this)
|
|
2663
|
+
);
|
|
2664
|
+
|
|
2282
2665
|
return fCallback();
|
|
2283
2666
|
}
|
|
2284
2667
|
|
|
@@ -2335,15 +2718,80 @@ class UltravisorAPIServer extends libPictService
|
|
|
2335
2718
|
function (fNext)
|
|
2336
2719
|
{
|
|
2337
2720
|
this.fable.addServiceTypeIfNotExists('OratorAuthentication', libOratorAuthentication);
|
|
2721
|
+
|
|
2722
|
+
// Wire orator-authentication's BeaconAuthenticator hook
|
|
2723
|
+
// at construction time — orator-auth installs the provider
|
|
2724
|
+
// in its constructor when this option is present, so we
|
|
2725
|
+
// don't need any setAuthenticator() call here. The
|
|
2726
|
+
// dispatcher is a thin wrapper around the bridge so that
|
|
2727
|
+
// orator-authentication itself stays free of any
|
|
2728
|
+
// ultravisor-specific imports.
|
|
2729
|
+
let tmpBeaconAuth =
|
|
2730
|
+
{
|
|
2731
|
+
Dispatcher: (pCapability, pAction, pSettings) =>
|
|
2732
|
+
{
|
|
2733
|
+
let tmpBridge = this._getService('UltravisorAuthBeaconBridge');
|
|
2734
|
+
if (!tmpBridge)
|
|
2735
|
+
{
|
|
2736
|
+
return Promise.resolve({ Outputs: { Success: false, Reason: 'No bridge' } });
|
|
2737
|
+
}
|
|
2738
|
+
// dispatchAction returns {Available, ...Outputs} —
|
|
2739
|
+
// wrap it in {Outputs:...} so the orator-auth
|
|
2740
|
+
// provider's response-shape parser unwraps cleanly
|
|
2741
|
+
// (it accepts either bare outputs or an Outputs
|
|
2742
|
+
// envelope, but the envelope is more honest about
|
|
2743
|
+
// where the data came from).
|
|
2744
|
+
return tmpBridge.dispatchAction(pAction, pSettings)
|
|
2745
|
+
.then((pResult) => ({ Outputs: pResult }));
|
|
2746
|
+
}
|
|
2747
|
+
};
|
|
2748
|
+
|
|
2338
2749
|
this._OratorAuth = this.fable.instantiateServiceProvider('OratorAuthentication',
|
|
2339
2750
|
{
|
|
2340
2751
|
RoutePrefix: '/1.0/',
|
|
2341
2752
|
SessionTTL: this.fable.settings.UltravisorBeaconSessionTTLMs || 86400000,
|
|
2342
2753
|
CookieHttpOnly: true,
|
|
2343
|
-
CookieSecure: false
|
|
2754
|
+
CookieSecure: false,
|
|
2755
|
+
BeaconAuthenticator: tmpBeaconAuth
|
|
2344
2756
|
});
|
|
2345
2757
|
this._OratorAuth.connectRoutes();
|
|
2346
2758
|
this.log.info('Ultravisor: OratorAuthentication routes registered.');
|
|
2759
|
+
|
|
2760
|
+
// User management routes — REST surface for the auth-beacon
|
|
2761
|
+
// AUTH_*User actions, gated by orator-auth sessions + a
|
|
2762
|
+
// configurable IsAdmin check. Mounted under the same /1.0/
|
|
2763
|
+
// prefix so login cookies cover both paths. The helper
|
|
2764
|
+
// itself lives in the auth-beacon module so any other
|
|
2765
|
+
// orator-auth consumer can use it (lab, content-system, ...).
|
|
2766
|
+
try
|
|
2767
|
+
{
|
|
2768
|
+
let libUserMgmtRoutes = require('ultravisor-auth-beacon/source/server-routes.cjs');
|
|
2769
|
+
libUserMgmtRoutes.mountUserManagementRoutes(this._OratorServer.server,
|
|
2770
|
+
{
|
|
2771
|
+
OratorAuth: this._OratorAuth,
|
|
2772
|
+
RoutePrefix: '/1.0/',
|
|
2773
|
+
Log: this.log,
|
|
2774
|
+
Dispatcher: (pAction, pSettings) =>
|
|
2775
|
+
{
|
|
2776
|
+
let tmpBridge = this._getService('UltravisorAuthBeaconBridge');
|
|
2777
|
+
if (!tmpBridge)
|
|
2778
|
+
{
|
|
2779
|
+
return Promise.resolve({ Success: false, Reason: 'No bridge' });
|
|
2780
|
+
}
|
|
2781
|
+
return tmpBridge.dispatchAction(pAction, pSettings);
|
|
2782
|
+
}
|
|
2783
|
+
});
|
|
2784
|
+
}
|
|
2785
|
+
catch (pUMError)
|
|
2786
|
+
{
|
|
2787
|
+
// Non-fatal: ultravisor still works without user-mgmt
|
|
2788
|
+
// routes. The auth flow still functions through
|
|
2789
|
+
// orator-auth + the bridge. Log loud so an operator
|
|
2790
|
+
// noticing missing /Users endpoints can find the cause.
|
|
2791
|
+
this.log.warn('Ultravisor: user-management route mount failed: '
|
|
2792
|
+
+ (pUMError && pUMError.message)
|
|
2793
|
+
+ ' — Login still works, user CRUD endpoints disabled.');
|
|
2794
|
+
}
|
|
2347
2795
|
return fNext();
|
|
2348
2796
|
}.bind(this));
|
|
2349
2797
|
|
|
@@ -2488,6 +2936,18 @@ class UltravisorAPIServer extends libPictService
|
|
|
2488
2936
|
if (tmpData.Action === 'Subscribe' && tmpData.RunHash)
|
|
2489
2937
|
{
|
|
2490
2938
|
// --- Execution event subscription (web UI) ---
|
|
2939
|
+
//
|
|
2940
|
+
// Mirrors the QueueSubscribe resync protocol:
|
|
2941
|
+
// callers that pass LastEventGUID get a
|
|
2942
|
+
// replay block (replay_begin / events after
|
|
2943
|
+
// that GUID, in order / replay_complete).
|
|
2944
|
+
// Callers that pass null/omit get today's
|
|
2945
|
+
// behavior — subscribe and resume the live
|
|
2946
|
+
// feed. If LastEventGUID isn't in our
|
|
2947
|
+
// per-run buffer (gap older than the buffer
|
|
2948
|
+
// or run finalized + dropped after grace),
|
|
2949
|
+
// emit execution.reset so the client falls
|
|
2950
|
+
// back to /Manifest/:RunHash.
|
|
2491
2951
|
this._unsubscribeClient(pWebSocket);
|
|
2492
2952
|
pWebSocket._SubscribedRunHash = tmpData.RunHash;
|
|
2493
2953
|
|
|
@@ -2496,6 +2956,37 @@ class UltravisorAPIServer extends libPictService
|
|
|
2496
2956
|
this._WebSocketSubscriptions[tmpData.RunHash] = new Set();
|
|
2497
2957
|
}
|
|
2498
2958
|
this._WebSocketSubscriptions[tmpData.RunHash].add(pWebSocket);
|
|
2959
|
+
|
|
2960
|
+
let tmpLastGUID = tmpData.LastEventGUID || null;
|
|
2961
|
+
if (tmpLastGUID)
|
|
2962
|
+
{
|
|
2963
|
+
let tmpIdx = this._findManifestEventIndex(
|
|
2964
|
+
tmpData.RunHash, tmpLastGUID);
|
|
2965
|
+
if (tmpIdx < 0)
|
|
2966
|
+
{
|
|
2967
|
+
this._sendManifestControlFrame(pWebSocket,
|
|
2968
|
+
'execution.reset', tmpData.RunHash,
|
|
2969
|
+
{ Reason: 'history-too-old', LastEventGUID: tmpLastGUID });
|
|
2970
|
+
}
|
|
2971
|
+
else
|
|
2972
|
+
{
|
|
2973
|
+
let tmpBuf = this._ManifestEventBuffers.get(tmpData.RunHash) || [];
|
|
2974
|
+
let tmpReplay = tmpBuf.slice(tmpIdx + 1);
|
|
2975
|
+
this._sendManifestControlFrame(pWebSocket,
|
|
2976
|
+
'execution.replay_begin', tmpData.RunHash,
|
|
2977
|
+
{ FromGUID: tmpLastGUID, Count: tmpReplay.length });
|
|
2978
|
+
for (let i = 0; i < tmpReplay.length; i++)
|
|
2979
|
+
{
|
|
2980
|
+
this._sendManifestEnvelope(pWebSocket, tmpReplay[i]);
|
|
2981
|
+
}
|
|
2982
|
+
let tmpThrough = tmpReplay.length
|
|
2983
|
+
? tmpReplay[tmpReplay.length - 1].EventGUID
|
|
2984
|
+
: tmpLastGUID;
|
|
2985
|
+
this._sendManifestControlFrame(pWebSocket,
|
|
2986
|
+
'execution.replay_complete', tmpData.RunHash,
|
|
2987
|
+
{ ThroughGUID: tmpThrough, Count: tmpReplay.length });
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2499
2990
|
}
|
|
2500
2991
|
else if (tmpData.Action === 'Unsubscribe')
|
|
2501
2992
|
{
|
|
@@ -2538,19 +3029,69 @@ class UltravisorAPIServer extends libPictService
|
|
|
2538
3029
|
}
|
|
2539
3030
|
else if (tmpData.Action === 'QueueSubscribe')
|
|
2540
3031
|
{
|
|
3032
|
+
// QueueSubscribe doubles as a resync request:
|
|
3033
|
+
// callers that pass LastEventGUID get a
|
|
3034
|
+
// replay block (replay_begin / events after
|
|
3035
|
+
// that GUID, in order / replay_complete) BEFORE
|
|
3036
|
+
// the live summary push. Callers that pass
|
|
3037
|
+
// null (or omit it) get today's behavior — a
|
|
3038
|
+
// fresh summary with no replay.
|
|
3039
|
+
//
|
|
3040
|
+
// If the requested LastEventGUID isn't in our
|
|
3041
|
+
// ring buffer (gap older than the buffer,
|
|
3042
|
+
// process restarted, etc.), we emit
|
|
3043
|
+
// queue.reset so the client falls back to a
|
|
3044
|
+
// REST snapshot. The persistence-beacon
|
|
3045
|
+
// fallback for deeper history is the
|
|
3046
|
+
// bootstrap-flush task's territory.
|
|
2541
3047
|
this._QueueSubscribers.add(pWebSocket);
|
|
2542
3048
|
pWebSocket._QueueSubscribed = true;
|
|
3049
|
+
let tmpLastGUID = tmpData.LastEventGUID || null;
|
|
3050
|
+
if (tmpLastGUID)
|
|
3051
|
+
{
|
|
3052
|
+
let tmpIdx = this._findQueueEventIndex(tmpLastGUID);
|
|
3053
|
+
if (tmpIdx < 0)
|
|
3054
|
+
{
|
|
3055
|
+
// TODO(bootstrap-flush): before giving up,
|
|
3056
|
+
// query the queue persistence beacon for
|
|
3057
|
+
// events after pGUID and emit them here.
|
|
3058
|
+
// Today the in-process ring is the only
|
|
3059
|
+
// tier, so a miss = reset.
|
|
3060
|
+
this._sendQueueControlFrame(pWebSocket,
|
|
3061
|
+
'queue.reset',
|
|
3062
|
+
{ Reason: 'history-too-old', LastEventGUID: tmpLastGUID });
|
|
3063
|
+
}
|
|
3064
|
+
else
|
|
3065
|
+
{
|
|
3066
|
+
let tmpReplay = this._QueueEventBuffer.slice(tmpIdx + 1);
|
|
3067
|
+
this._sendQueueControlFrame(pWebSocket,
|
|
3068
|
+
'queue.replay_begin',
|
|
3069
|
+
{ FromGUID: tmpLastGUID, Count: tmpReplay.length });
|
|
3070
|
+
for (let i = 0; i < tmpReplay.length; i++)
|
|
3071
|
+
{
|
|
3072
|
+
this._sendQueueEnvelope(pWebSocket, tmpReplay[i]);
|
|
3073
|
+
}
|
|
3074
|
+
let tmpThrough = tmpReplay.length
|
|
3075
|
+
? tmpReplay[tmpReplay.length - 1].EventGUID
|
|
3076
|
+
: tmpLastGUID;
|
|
3077
|
+
this._sendQueueControlFrame(pWebSocket,
|
|
3078
|
+
'queue.replay_complete',
|
|
3079
|
+
{ ThroughGUID: tmpThrough, Count: tmpReplay.length });
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
2543
3082
|
// Send current summary immediately so the UI
|
|
2544
|
-
// doesn't wait a full tick to populate.
|
|
3083
|
+
// doesn't wait a full tick to populate. Stamp
|
|
3084
|
+
// it through the same envelope path so
|
|
3085
|
+
// LastEventGUID tracking on the client stays
|
|
3086
|
+
// monotone.
|
|
2545
3087
|
let tmpSched = this._getService('UltravisorBeaconScheduler');
|
|
2546
3088
|
if (tmpSched && typeof tmpSched.summarize === 'function')
|
|
2547
3089
|
{
|
|
2548
3090
|
try
|
|
2549
3091
|
{
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
}));
|
|
3092
|
+
let tmpSummaryEnv = this._stampQueueEvent(
|
|
3093
|
+
'queue.summary', tmpSched.summarize());
|
|
3094
|
+
this._sendQueueEnvelope(pWebSocket, tmpSummaryEnv);
|
|
2554
3095
|
}
|
|
2555
3096
|
catch (pErr) { /* ignore */ }
|
|
2556
3097
|
}
|
|
@@ -2600,15 +3141,55 @@ class UltravisorAPIServer extends libPictService
|
|
|
2600
3141
|
}
|
|
2601
3142
|
|
|
2602
3143
|
/**
|
|
2603
|
-
*
|
|
3144
|
+
* Stamp a queue topic payload with an envelope and (when replayable)
|
|
3145
|
+
* append it to the ring buffer. Returns the wire envelope so callers
|
|
3146
|
+
* can serialize once and reuse the same frame for live + replay.
|
|
3147
|
+
*
|
|
3148
|
+
* The envelope shape is:
|
|
3149
|
+
* { Topic, Payload, EventGUID, Seq, EmittedAt }
|
|
3150
|
+
*
|
|
3151
|
+
* EventGUID is the durable identity (UUID v4); Seq is a per-process
|
|
3152
|
+
* monotonic ordering hint that resets on restart and so cannot be
|
|
3153
|
+
* trusted for dedup.
|
|
3154
|
+
*/
|
|
3155
|
+
_stampQueueEvent(pTopic, pPayload)
|
|
3156
|
+
{
|
|
3157
|
+
this._QueueEventSeq += 1;
|
|
3158
|
+
let tmpEnvelope =
|
|
3159
|
+
{
|
|
3160
|
+
Topic: pTopic,
|
|
3161
|
+
Payload: pPayload,
|
|
3162
|
+
EventGUID: libCrypto.randomUUID(),
|
|
3163
|
+
Seq: this._QueueEventSeq,
|
|
3164
|
+
EmittedAt: new Date().toISOString()
|
|
3165
|
+
};
|
|
3166
|
+
if (this._isReplayableQueueTopic(pTopic))
|
|
3167
|
+
{
|
|
3168
|
+
this._QueueEventBuffer.push(tmpEnvelope);
|
|
3169
|
+
if (this._QueueEventBuffer.length > this._QueueEventBufferCap)
|
|
3170
|
+
{
|
|
3171
|
+
this._QueueEventBuffer.splice(
|
|
3172
|
+
0, this._QueueEventBuffer.length - this._QueueEventBufferCap);
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
return tmpEnvelope;
|
|
3176
|
+
}
|
|
3177
|
+
|
|
3178
|
+
/**
|
|
3179
|
+
* Fan out a queue.* topic payload to all subscribed WebSocket
|
|
3180
|
+
* clients. Stamps the envelope first so the live feed and the
|
|
3181
|
+
* replay buffer carry the same frame shape.
|
|
2604
3182
|
*
|
|
2605
3183
|
* @param {string} pTopic - e.g. "queue.enqueued" / "queue.dispatched" / ...
|
|
2606
3184
|
* @param {object} pPayload - topic-specific JSON body
|
|
2607
3185
|
*/
|
|
2608
3186
|
_broadcastQueueTopic(pTopic, pPayload)
|
|
2609
3187
|
{
|
|
3188
|
+
// Always stamp — even if no current subscribers — so the buffer
|
|
3189
|
+
// captures history that a future reconnect can replay against.
|
|
3190
|
+
let tmpEnvelope = this._stampQueueEvent(pTopic, pPayload);
|
|
2610
3191
|
if (!this._QueueSubscribers || this._QueueSubscribers.size === 0) return;
|
|
2611
|
-
let tmpMessage = JSON.stringify(
|
|
3192
|
+
let tmpMessage = JSON.stringify(tmpEnvelope);
|
|
2612
3193
|
this._QueueSubscribers.forEach(
|
|
2613
3194
|
function (pClient)
|
|
2614
3195
|
{
|
|
@@ -2621,40 +3202,169 @@ class UltravisorAPIServer extends libPictService
|
|
|
2621
3202
|
}
|
|
2622
3203
|
|
|
2623
3204
|
/**
|
|
2624
|
-
*
|
|
2625
|
-
*
|
|
3205
|
+
* Send a stamped envelope to a single subscriber. Used by the
|
|
3206
|
+
* replay path so the catch-up frames carry the same shape (and
|
|
3207
|
+
* the same EventGUID) as the originals on the live feed.
|
|
3208
|
+
*/
|
|
3209
|
+
_sendQueueEnvelope(pClient, pEnvelope)
|
|
3210
|
+
{
|
|
3211
|
+
if (!pClient || pClient.readyState !== libWebSocket.OPEN) return;
|
|
3212
|
+
try { pClient.send(JSON.stringify(pEnvelope)); }
|
|
3213
|
+
catch (pErr) { /* best effort */ }
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
/**
|
|
3217
|
+
* Emit a control frame (replay_begin / replay_complete / reset) to
|
|
3218
|
+
* a single subscriber. Stamps the envelope so the wire shape is
|
|
3219
|
+
* uniform, but skips the ring buffer per `_isReplayableQueueTopic`.
|
|
3220
|
+
*/
|
|
3221
|
+
_sendQueueControlFrame(pClient, pTopic, pPayload)
|
|
3222
|
+
{
|
|
3223
|
+
let tmpEnvelope = this._stampQueueEvent(pTopic, pPayload || {});
|
|
3224
|
+
this._sendQueueEnvelope(pClient, tmpEnvelope);
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
/**
|
|
3228
|
+
* Stamp an execution event with the resync envelope (EventGUID,
|
|
3229
|
+
* Seq, EmittedAt) and append it to the per-run buffer when the
|
|
3230
|
+
* type is replayable. Returns the wire envelope so callers can
|
|
3231
|
+
* broadcast the same frame to live subscribers.
|
|
2626
3232
|
*
|
|
2627
|
-
*
|
|
2628
|
-
*
|
|
2629
|
-
*
|
|
3233
|
+
* Per-run Seq is monotonic within a single run only; different
|
|
3234
|
+
* runs have independent counters. EventGUID is globally unique
|
|
3235
|
+
* (UUID v4) and is what survives across process restarts as the
|
|
3236
|
+
* dedup key.
|
|
2630
3237
|
*/
|
|
2631
|
-
|
|
3238
|
+
_stampManifestEvent(pEventType, pRunHash, pData)
|
|
2632
3239
|
{
|
|
2633
|
-
let
|
|
3240
|
+
let tmpSeq = (this._ManifestEventSeqs.get(pRunHash) || 0) + 1;
|
|
3241
|
+
this._ManifestEventSeqs.set(pRunHash, tmpSeq);
|
|
3242
|
+
let tmpEnvelope =
|
|
3243
|
+
{
|
|
3244
|
+
EventType: pEventType,
|
|
3245
|
+
RunHash: pRunHash,
|
|
3246
|
+
Data: pData,
|
|
3247
|
+
EventGUID: libCrypto.randomUUID(),
|
|
3248
|
+
Seq: tmpSeq,
|
|
3249
|
+
EmittedAt: new Date().toISOString()
|
|
3250
|
+
};
|
|
3251
|
+
if (this._isReplayableExecutionEventType(pEventType))
|
|
3252
|
+
{
|
|
3253
|
+
let tmpBuf = this._ManifestEventBuffers.get(pRunHash);
|
|
3254
|
+
if (!tmpBuf)
|
|
3255
|
+
{
|
|
3256
|
+
tmpBuf = [];
|
|
3257
|
+
this._ManifestEventBuffers.set(pRunHash, tmpBuf);
|
|
3258
|
+
}
|
|
3259
|
+
tmpBuf.push(tmpEnvelope);
|
|
3260
|
+
if (tmpBuf.length > this._ManifestEventBufferCapPerRun)
|
|
3261
|
+
{
|
|
3262
|
+
tmpBuf.splice(0, tmpBuf.length - this._ManifestEventBufferCapPerRun);
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
return tmpEnvelope;
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
/**
|
|
3269
|
+
* Send a stamped envelope to a single subscriber. Used by both the
|
|
3270
|
+
* live broadcast and the replay path.
|
|
3271
|
+
*/
|
|
3272
|
+
_sendManifestEnvelope(pClient, pEnvelope)
|
|
3273
|
+
{
|
|
3274
|
+
if (!pClient || pClient.readyState !== libWebSocket.OPEN) return;
|
|
3275
|
+
try { pClient.send(JSON.stringify(pEnvelope)); }
|
|
3276
|
+
catch (pErr) { /* best effort */ }
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
/**
|
|
3280
|
+
* Emit a control frame (execution.replay_begin / replay_complete /
|
|
3281
|
+
* reset) to a single subscriber. Stamped through the same envelope
|
|
3282
|
+
* path so wire shape is uniform; gated out of the buffer by
|
|
3283
|
+
* _isReplayableExecutionEventType.
|
|
3284
|
+
*/
|
|
3285
|
+
_sendManifestControlFrame(pClient, pEventType, pRunHash, pData)
|
|
3286
|
+
{
|
|
3287
|
+
let tmpEnvelope = this._stampManifestEvent(pEventType, pRunHash, pData || {});
|
|
3288
|
+
this._sendManifestEnvelope(pClient, tmpEnvelope);
|
|
3289
|
+
}
|
|
2634
3290
|
|
|
2635
|
-
|
|
3291
|
+
/**
|
|
3292
|
+
* Schedule a per-run buffer cleanup. Called once we see the
|
|
3293
|
+
* terminal ExecutionComplete event for a run; the buffer survives
|
|
3294
|
+
* the grace period so a subscriber that reconnects shortly after
|
|
3295
|
+
* the run finishes can still pull the full event log.
|
|
3296
|
+
*
|
|
3297
|
+
* Re-arming is idempotent: if a cleanup is already scheduled (e.g.
|
|
3298
|
+
* because of an earlier near-terminal event we treated as
|
|
3299
|
+
* "probably the end"), the existing timer is replaced with a fresh
|
|
3300
|
+
* one. Active runs that haven't finished never have a timer, so
|
|
3301
|
+
* their buffers grow until ExecutionComplete arrives.
|
|
3302
|
+
*
|
|
3303
|
+
* Assumption: every run that emits any execution event also emits
|
|
3304
|
+
* exactly one terminal ExecutionComplete. UltravisorExecutionManifest
|
|
3305
|
+
* funnels success/error/abandon through finalizeExecution which
|
|
3306
|
+
* unconditionally emits ExecutionComplete, so this holds. If a
|
|
3307
|
+
* future code path bypasses finalizeExecution, that run's buffer
|
|
3308
|
+
* will not be cleaned up — at which point either route the new
|
|
3309
|
+
* code path through finalizeExecution, or add a periodic GC pass
|
|
3310
|
+
* that drops buffers idle for >> _ManifestEventGracePeriodMs.
|
|
3311
|
+
*/
|
|
3312
|
+
_scheduleManifestBufferCleanup(pRunHash)
|
|
3313
|
+
{
|
|
3314
|
+
if (!pRunHash) return;
|
|
3315
|
+
let tmpExisting = this._ManifestEventCleanupTimers.get(pRunHash);
|
|
3316
|
+
if (tmpExisting) clearTimeout(tmpExisting);
|
|
3317
|
+
let tmpHandle = setTimeout(() =>
|
|
2636
3318
|
{
|
|
2637
|
-
|
|
3319
|
+
this._ManifestEventBuffers.delete(pRunHash);
|
|
3320
|
+
this._ManifestEventSeqs.delete(pRunHash);
|
|
3321
|
+
this._ManifestEventCleanupTimers.delete(pRunHash);
|
|
3322
|
+
}, this._ManifestEventGracePeriodMs);
|
|
3323
|
+
if (tmpHandle && typeof tmpHandle.unref === 'function')
|
|
3324
|
+
{
|
|
3325
|
+
tmpHandle.unref();
|
|
2638
3326
|
}
|
|
3327
|
+
this._ManifestEventCleanupTimers.set(pRunHash, tmpHandle);
|
|
3328
|
+
}
|
|
2639
3329
|
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
3330
|
+
/**
|
|
3331
|
+
* Handle an execution event from the manifest service: stamp the
|
|
3332
|
+
* envelope (always — buffer captures history even when no live
|
|
3333
|
+
* subscribers), then broadcast to subscribers of that RunHash.
|
|
3334
|
+
*
|
|
3335
|
+
* @param {string} pEventType - The event type (TaskStart, TaskComplete, ...).
|
|
3336
|
+
* @param {string} pRunHash - The execution run hash.
|
|
3337
|
+
* @param {object} pEventData - The event data.
|
|
3338
|
+
*/
|
|
3339
|
+
_onExecutionEvent(pEventType, pRunHash, pEventData)
|
|
3340
|
+
{
|
|
3341
|
+
// Always stamp so the buffer captures history a future
|
|
3342
|
+
// reconnect can replay against. Subscribers may be zero
|
|
3343
|
+
// today and present tomorrow.
|
|
3344
|
+
let tmpEnvelope = this._stampManifestEvent(pEventType, pRunHash, pEventData);
|
|
2645
3345
|
|
|
2646
|
-
tmpSubscribers.
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
3346
|
+
let tmpSubscribers = this._WebSocketSubscriptions[pRunHash];
|
|
3347
|
+
if (tmpSubscribers && tmpSubscribers.size > 0)
|
|
3348
|
+
{
|
|
3349
|
+
let tmpMessage = JSON.stringify(tmpEnvelope);
|
|
3350
|
+
tmpSubscribers.forEach(
|
|
3351
|
+
function (pClient)
|
|
2650
3352
|
{
|
|
2651
|
-
pClient.
|
|
2652
|
-
|
|
2653
|
-
|
|
3353
|
+
if (pClient.readyState === libWebSocket.OPEN)
|
|
3354
|
+
{
|
|
3355
|
+
pClient.send(tmpMessage);
|
|
3356
|
+
}
|
|
3357
|
+
});
|
|
3358
|
+
}
|
|
2654
3359
|
|
|
2655
|
-
//
|
|
3360
|
+
// Terminal event: arm the per-run buffer cleanup grace timer
|
|
3361
|
+
// and drop the live-subscriber set (subscribers reconnecting
|
|
3362
|
+
// during the grace window will still get a fresh subscription
|
|
3363
|
+
// + replay of the recorded buffer). The buffer itself is
|
|
3364
|
+
// preserved until the timer fires.
|
|
2656
3365
|
if (pEventType === 'ExecutionComplete')
|
|
2657
3366
|
{
|
|
3367
|
+
this._scheduleManifestBufferCleanup(pRunHash);
|
|
2658
3368
|
delete this._WebSocketSubscriptions[pRunHash];
|
|
2659
3369
|
}
|
|
2660
3370
|
}
|
|
@@ -2697,6 +3407,37 @@ class UltravisorAPIServer extends libPictService
|
|
|
2697
3407
|
return;
|
|
2698
3408
|
}
|
|
2699
3409
|
|
|
3410
|
+
// Non-promiscuous-mode admission gate. In default (promiscuous)
|
|
3411
|
+
// mode the lambda below resolves true synchronously; in non-
|
|
3412
|
+
// promiscuous mode it dispatches to the auth beacon and resolves
|
|
3413
|
+
// asynchronously. Either way, registration only proceeds when
|
|
3414
|
+
// allowed.
|
|
3415
|
+
this._admitBeaconForRegistration(pData, (pError, pAdmitted, pRejectReason) =>
|
|
3416
|
+
{
|
|
3417
|
+
if (pError)
|
|
3418
|
+
{
|
|
3419
|
+
this.log.warn(`Ultravisor WebSocket: beacon "${pData.Name}" admission errored: ${pError.message || pError}`);
|
|
3420
|
+
this._rejectBeaconJoin(pWebSocket, 'Admission check failed: ' + (pError.message || pError));
|
|
3421
|
+
return;
|
|
3422
|
+
}
|
|
3423
|
+
if (!pAdmitted)
|
|
3424
|
+
{
|
|
3425
|
+
this.log.warn(`Ultravisor WebSocket: beacon "${pData.Name}" rejected by admission gate: ${pRejectReason || 'denied'}`);
|
|
3426
|
+
this._rejectBeaconJoin(pWebSocket, pRejectReason || 'Beacon admission denied');
|
|
3427
|
+
return;
|
|
3428
|
+
}
|
|
3429
|
+
this._completeBeaconWSRegister(pWebSocket, pData, tmpCoordinator);
|
|
3430
|
+
});
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
/**
|
|
3434
|
+
* Finish a WS beacon registration after the admission gate has
|
|
3435
|
+
* passed. Split out from _handleBeaconWSRegister so the gate can
|
|
3436
|
+
* short-circuit cleanly without nesting the success path inside
|
|
3437
|
+
* a callback that's also responsible for failure handling.
|
|
3438
|
+
*/
|
|
3439
|
+
_completeBeaconWSRegister(pWebSocket, pData, pCoordinator)
|
|
3440
|
+
{
|
|
2700
3441
|
// Diagnostic: log what we RECEIVED from the client before forwarding
|
|
2701
3442
|
// to registerBeacon. If the client sent HostID but we're storing null,
|
|
2702
3443
|
// this log line pins down exactly where the drop happens (client vs
|
|
@@ -2712,7 +3453,7 @@ class UltravisorAPIServer extends libPictService
|
|
|
2712
3453
|
// reachability auto-detect). Forgetting to forward a field here means
|
|
2713
3454
|
// the WebSocket-registered beacon record will have that field set to
|
|
2714
3455
|
// null/empty in the coordinator, even though the client sent the value.
|
|
2715
|
-
let tmpBeacon =
|
|
3456
|
+
let tmpBeacon = pCoordinator.registerBeacon({
|
|
2716
3457
|
Name: pData.Name,
|
|
2717
3458
|
Capabilities: pData.Capabilities,
|
|
2718
3459
|
ActionSchemas: pData.ActionSchemas,
|
|
@@ -2746,6 +3487,126 @@ class UltravisorAPIServer extends libPictService
|
|
|
2746
3487
|
}
|
|
2747
3488
|
}
|
|
2748
3489
|
|
|
3490
|
+
/**
|
|
3491
|
+
* Decide whether a beacon may join the mesh. Three modes, in order:
|
|
3492
|
+
*
|
|
3493
|
+
* 1. Promiscuous (default) — always admit. Same behavior the hub
|
|
3494
|
+
* had before the auth beacon work; nothing on the wire changes
|
|
3495
|
+
* and existing deployments need no config update.
|
|
3496
|
+
*
|
|
3497
|
+
* 2. Non-promiscuous + this is the auth beacon's own join — accept
|
|
3498
|
+
* iff the JoinSecret matches `UltravisorBootstrapAuthSecret`
|
|
3499
|
+
* from config. We cannot consult the auth beacon to validate
|
|
3500
|
+
* itself (chicken-and-egg), so the bootstrap secret is the one
|
|
3501
|
+
* "trust me, I'm authorized" credential the hub keeps locally.
|
|
3502
|
+
* A beacon counts as "the auth beacon" when it advertises the
|
|
3503
|
+
* Authentication capability AND no other auth beacon is
|
|
3504
|
+
* currently registered.
|
|
3505
|
+
*
|
|
3506
|
+
* 3. Non-promiscuous + any other beacon — dispatch
|
|
3507
|
+
* AUTH_ValidateBeaconJoin to the live auth beacon via the
|
|
3508
|
+
* bridge. If the bridge isn't available (no auth beacon
|
|
3509
|
+
* connected yet) we fail closed: better to reject a real beacon
|
|
3510
|
+
* than to admit an attacker during a bootstrap window.
|
|
3511
|
+
*
|
|
3512
|
+
* The callback is invoked exactly once: (pError, pAdmitted, pReason).
|
|
3513
|
+
*/
|
|
3514
|
+
_admitBeaconForRegistration(pData, fCallback)
|
|
3515
|
+
{
|
|
3516
|
+
// Config keys live in fable.ProgramConfiguration (gathered from
|
|
3517
|
+
// .ultravisor.json + DefaultProgramConfiguration). fable.settings
|
|
3518
|
+
// is a separate, mostly-empty bag that some legacy code reads —
|
|
3519
|
+
// don't be fooled by either pattern in nearby files.
|
|
3520
|
+
let tmpConfig = (this.fable && this.fable.ProgramConfiguration) || {};
|
|
3521
|
+
let tmpNonPromiscuous = !!tmpConfig.UltravisorNonPromiscuous;
|
|
3522
|
+
if (this.fable && this.fable.LogNoisiness >= 1)
|
|
3523
|
+
{
|
|
3524
|
+
this.log.info(`[Admission] beacon "${pData.Name}" caps=[${(pData.Capabilities||[]).join(',')}] joinSecret=${pData.JoinSecret ? '(present)' : '(none)'} nonPromiscuous=${tmpNonPromiscuous}`);
|
|
3525
|
+
}
|
|
3526
|
+
if (!tmpNonPromiscuous)
|
|
3527
|
+
{
|
|
3528
|
+
return fCallback(null, true);
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
let tmpCaps = Array.isArray(pData.Capabilities) ? pData.Capabilities : [];
|
|
3532
|
+
let tmpClaimsAuth = tmpCaps.indexOf('Authentication') >= 0;
|
|
3533
|
+
let tmpJoinSecret = pData.JoinSecret || '';
|
|
3534
|
+
|
|
3535
|
+
// Bootstrap path: this beacon claims Authentication AND no auth
|
|
3536
|
+
// beacon is registered yet. Compare against the local
|
|
3537
|
+
// UltravisorBootstrapAuthSecret using a constant-time compare
|
|
3538
|
+
// so timing differences can't leak the secret.
|
|
3539
|
+
let tmpBridge = this._getService('UltravisorAuthBeaconBridge');
|
|
3540
|
+
let tmpAuthAlreadyConnected = tmpBridge && tmpBridge.isAvailable();
|
|
3541
|
+
if (tmpClaimsAuth && !tmpAuthAlreadyConnected)
|
|
3542
|
+
{
|
|
3543
|
+
let tmpExpected = tmpConfig.UltravisorBootstrapAuthSecret || '';
|
|
3544
|
+
if (!tmpExpected)
|
|
3545
|
+
{
|
|
3546
|
+
return fCallback(null, false,
|
|
3547
|
+
'Non-promiscuous mode requires UltravisorBootstrapAuthSecret in config');
|
|
3548
|
+
}
|
|
3549
|
+
if (!this._constantTimeEqual(tmpJoinSecret, tmpExpected))
|
|
3550
|
+
{
|
|
3551
|
+
return fCallback(null, false, 'Bootstrap auth secret mismatch');
|
|
3552
|
+
}
|
|
3553
|
+
return fCallback(null, true);
|
|
3554
|
+
}
|
|
3555
|
+
|
|
3556
|
+
// Standard path: ask the auth beacon to validate this join.
|
|
3557
|
+
if (!tmpBridge || !tmpAuthAlreadyConnected)
|
|
3558
|
+
{
|
|
3559
|
+
return fCallback(null, false,
|
|
3560
|
+
'No auth beacon connected; cannot validate beacon join in non-promiscuous mode');
|
|
3561
|
+
}
|
|
3562
|
+
tmpBridge.validateBeaconJoin(pData.Name || '', tmpJoinSecret, tmpCaps).then((pResult) =>
|
|
3563
|
+
{
|
|
3564
|
+
if (pResult && pResult.Available && pResult.Allowed)
|
|
3565
|
+
{
|
|
3566
|
+
return fCallback(null, true);
|
|
3567
|
+
}
|
|
3568
|
+
let tmpReason = (pResult && (pResult.Reason || pResult.Error))
|
|
3569
|
+
|| 'Auth beacon denied beacon join';
|
|
3570
|
+
return fCallback(null, false, tmpReason);
|
|
3571
|
+
}).catch((pErr) => fCallback(pErr));
|
|
3572
|
+
}
|
|
3573
|
+
|
|
3574
|
+
/**
|
|
3575
|
+
* Send an explicit rejection frame and close the socket so the
|
|
3576
|
+
* client knows it was denied (vs. a generic disconnect that looks
|
|
3577
|
+
* like a transient network blip and triggers exponential reconnect).
|
|
3578
|
+
*/
|
|
3579
|
+
_rejectBeaconJoin(pWebSocket, pReason)
|
|
3580
|
+
{
|
|
3581
|
+
if (pWebSocket && pWebSocket.readyState === libWebSocket.OPEN)
|
|
3582
|
+
{
|
|
3583
|
+
try
|
|
3584
|
+
{
|
|
3585
|
+
pWebSocket.send(JSON.stringify({
|
|
3586
|
+
EventType: 'BeaconRejected',
|
|
3587
|
+
Reason: pReason || 'Beacon admission denied'
|
|
3588
|
+
}));
|
|
3589
|
+
}
|
|
3590
|
+
catch (pErr) { /* socket dying — close handler will clean up */ }
|
|
3591
|
+
try { pWebSocket.close(4403, 'Beacon admission denied'); }
|
|
3592
|
+
catch (pErr) { /* ignore */ }
|
|
3593
|
+
}
|
|
3594
|
+
}
|
|
3595
|
+
|
|
3596
|
+
/**
|
|
3597
|
+
* Constant-time string compare via crypto.timingSafeEqual on
|
|
3598
|
+
* Buffers of equal length. Pads/diffs unequal-length inputs as
|
|
3599
|
+
* "not equal" without revealing length difference timing.
|
|
3600
|
+
*/
|
|
3601
|
+
_constantTimeEqual(pA, pB)
|
|
3602
|
+
{
|
|
3603
|
+
let tmpA = Buffer.from(String(pA || ''), 'utf8');
|
|
3604
|
+
let tmpB = Buffer.from(String(pB || ''), 'utf8');
|
|
3605
|
+
if (tmpA.length !== tmpB.length) return false;
|
|
3606
|
+
try { return libCrypto.timingSafeEqual(tmpA, tmpB); }
|
|
3607
|
+
catch (pErr) { return false; }
|
|
3608
|
+
}
|
|
3609
|
+
|
|
2749
3610
|
/**
|
|
2750
3611
|
* Handle a beacon heartbeat over WebSocket.
|
|
2751
3612
|
*/
|