@livedesk/hub 0.1.30 → 0.1.31
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/package.json +2 -2
- package/src/agents/agent-audit-store.js +16 -6
- package/src/agents/agent-permissions.js +9 -3
- package/src/agents/agent-tool-registry.js +19 -1
- package/src/captures/capture-store.js +50 -3
- package/src/filesystem/shared-folders.js +8 -0
- package/src/filesystem/transfer-jobs.js +31 -4
- package/src/live-desk-update.js +87 -19
- package/src/remote-hub.js +658 -166
- package/src/security/device-credential-authority.js +358 -0
- package/src/security/security-audit-store.js +238 -0
- package/src/server.js +755 -191
- package/src/settings/settings-schema.js +19 -39
- package/src/transport/relay-hub-control.js +329 -3
- package/src/transport/secure-direct-acceptor.js +432 -0
- package/src/transport/udp-hub-transport.js +17 -5
- package/src/transport/udp-rendezvous.js +54 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livedesk/hub",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.31",
|
|
4
4
|
"description": "LiveDesk local Hub API and browser frame bridge",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/server.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
19
|
-
"@livedesk/runtime-core": "0.1.
|
|
19
|
+
"@livedesk/runtime-core": "0.1.3",
|
|
20
20
|
"@openai/codex-sdk": "0.145.0",
|
|
21
21
|
"cors": "^2.8.5",
|
|
22
22
|
"express": "^4.21.2",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { appendFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
@@ -84,10 +84,20 @@ export function createAgentAuditStore({ dataDir = path.join(os.homedir(), '.live
|
|
|
84
84
|
await writeQueue;
|
|
85
85
|
return record;
|
|
86
86
|
},
|
|
87
|
-
async list({ runId = '', limit = 200 } = {}) {
|
|
87
|
+
async list({ runId = '', limit = 200 } = {}) {
|
|
88
88
|
const current = await load();
|
|
89
89
|
const normalizedLimit = Math.max(1, Math.min(500, Number(limit) || 200));
|
|
90
|
-
return current.filter(item => !runId || item.runId === String(runId)).slice(-normalizedLimit).reverse();
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
return current.filter(item => !runId || item.runId === String(runId)).slice(-normalizedLimit).reverse();
|
|
91
|
+
},
|
|
92
|
+
async clear() {
|
|
93
|
+
writeQueue = writeQueue.then(async () => {
|
|
94
|
+
await rm(filePath, { force: true });
|
|
95
|
+
records = [];
|
|
96
|
+
fileRecordCount = 0;
|
|
97
|
+
rewriteNeeded = false;
|
|
98
|
+
});
|
|
99
|
+
await writeQueue;
|
|
100
|
+
return { ok: true };
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -181,9 +181,15 @@ function safeAutoConstraint(tool, args, mode) {
|
|
|
181
181
|
return '';
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
export function evaluateAgentToolPermission({ policy, toolName, arguments: args = {}, deviceIds = [] } = {}) {
|
|
185
|
-
const tool = getAgentToolDefinition(toolName);
|
|
186
|
-
if (!tool) return { decision: 'deny', category: 'unknown', risk: 'critical', reason: 'Unknown or unregistered Agent tool.' };
|
|
184
|
+
export function evaluateAgentToolPermission({ policy, toolName, arguments: args = {}, deviceIds = [] } = {}) {
|
|
185
|
+
const tool = getAgentToolDefinition(toolName);
|
|
186
|
+
if (!tool) return { decision: 'deny', category: 'unknown', risk: 'critical', reason: 'Unknown or unregistered Agent tool.' };
|
|
187
|
+
// This is deliberately redundant with the filtered MCP registry. A future
|
|
188
|
+
// registry edit must not make an AI mutation executable merely by exposing
|
|
189
|
+
// a definition again.
|
|
190
|
+
if (tool.mutating === true || tool.readOnly !== true) {
|
|
191
|
+
return { decision: 'deny', category: tool.category, risk: 'critical', reason: 'AI mutating tools are disabled in the first public product.' };
|
|
192
|
+
}
|
|
187
193
|
const allowed = new Set(normalizeIds(policy?.deviceIds));
|
|
188
194
|
const requested = targetIdsFromArgs(args, deviceIds);
|
|
189
195
|
if (requested.some(deviceId => !allowed.has(deviceId))) {
|
|
@@ -24,7 +24,7 @@ function withRequiredInput(properties, required = []) {
|
|
|
24
24
|
const taskPermission = { ask: 'ask', safeAuto: 'ask', fullAccess: 'allow' };
|
|
25
25
|
const readTaskPermission = { ask: 'allow', safeAuto: 'allow', fullAccess: 'allow' };
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
const ALL_AGENT_TOOL_DEFINITIONS = Object.freeze([
|
|
28
28
|
{
|
|
29
29
|
name: 'livedesk.list_devices',
|
|
30
30
|
description: 'List connected LiveDesk Clients. This is read-only.',
|
|
@@ -313,6 +313,20 @@ export const AGENT_TOOL_DEFINITIONS = Object.freeze([
|
|
|
313
313
|
}
|
|
314
314
|
]);
|
|
315
315
|
|
|
316
|
+
// LD-SEC-AI-001: the first public product exposes read-only AI tools only.
|
|
317
|
+
// Keep the retired names explicit so every boundary can fail closed for an old
|
|
318
|
+
// model, stale MCP schema, or rolling-update Client without advertising the
|
|
319
|
+
// capability or relying on a permission preset.
|
|
320
|
+
export const RETIRED_AGENT_MUTATING_TOOL_NAMES = Object.freeze(
|
|
321
|
+
ALL_AGENT_TOOL_DEFINITIONS.filter(tool => tool.mutating === true).map(tool => tool.name)
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const retiredMutatingToolNames = new Set(RETIRED_AGENT_MUTATING_TOOL_NAMES);
|
|
325
|
+
|
|
326
|
+
export const AGENT_TOOL_DEFINITIONS = Object.freeze(
|
|
327
|
+
ALL_AGENT_TOOL_DEFINITIONS.filter(tool => tool.readOnly === true && tool.mutating !== true)
|
|
328
|
+
);
|
|
329
|
+
|
|
316
330
|
export const AGENT_TOOL_NAMES = Object.freeze(AGENT_TOOL_DEFINITIONS.map(tool => tool.name));
|
|
317
331
|
|
|
318
332
|
const toolByName = new Map(AGENT_TOOL_DEFINITIONS.map(tool => [tool.name, tool]));
|
|
@@ -320,3 +334,7 @@ const toolByName = new Map(AGENT_TOOL_DEFINITIONS.map(tool => [tool.name, tool])
|
|
|
320
334
|
export function getAgentToolDefinition(name) {
|
|
321
335
|
return toolByName.get(String(name || '').trim()) || null;
|
|
322
336
|
}
|
|
337
|
+
|
|
338
|
+
export function isRetiredAgentMutatingToolName(name) {
|
|
339
|
+
return retiredMutatingToolNames.has(String(name || '').trim());
|
|
340
|
+
}
|
|
@@ -53,11 +53,13 @@ async function atomicJson(filePath, value) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
export class CaptureStore {
|
|
56
|
-
constructor({ dataDir } = {}) {
|
|
56
|
+
constructor({ dataDir, getRetentionPolicy = () => 'never', onRetentionDelete = () => {} } = {}) {
|
|
57
57
|
this.root = captureRoot(dataDir);
|
|
58
58
|
this.indexPath = path.join(this.root, 'index.json');
|
|
59
59
|
this.sessions = new Map();
|
|
60
60
|
this.index = null;
|
|
61
|
+
this.getRetentionPolicy = typeof getRetentionPolicy === 'function' ? getRetentionPolicy : () => 'never';
|
|
62
|
+
this.onRetentionDelete = typeof onRetentionDelete === 'function' ? onRetentionDelete : () => {};
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
async initialize() {
|
|
@@ -199,6 +201,7 @@ export class CaptureStore {
|
|
|
199
201
|
|
|
200
202
|
async list() {
|
|
201
203
|
await this.ready();
|
|
204
|
+
await this.enforceRetention();
|
|
202
205
|
return this.index.captures.map(publicRecord);
|
|
203
206
|
}
|
|
204
207
|
|
|
@@ -218,10 +221,54 @@ export class CaptureStore {
|
|
|
218
221
|
}
|
|
219
222
|
|
|
220
223
|
resolveFilePath(record) {
|
|
221
|
-
if (record?.filePath) return record.filePath;
|
|
222
224
|
const folder = record?.kind === 'screenshot' ? 'images' : record?.kind === 'timelapse' ? 'timelapses' : 'recordings';
|
|
223
225
|
const extension = record?.mimeType === 'image/png' ? '.png' : '.webm';
|
|
224
|
-
|
|
226
|
+
const fallback = path.join(this.root, folder, `${safeName(record?.id, 'missing')}${extension}`);
|
|
227
|
+
const candidate = path.resolve(record?.filePath || fallback);
|
|
228
|
+
const relative = path.relative(path.resolve(this.root), candidate);
|
|
229
|
+
return !relative || (!relative.startsWith('..') && !path.isAbsolute(relative)) ? candidate : fallback;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async enforceRetention({ now = Date.now() } = {}) {
|
|
233
|
+
await this.ready();
|
|
234
|
+
const policy = String(this.getRetentionPolicy() || 'never').trim();
|
|
235
|
+
const retentionDays = policy === '7-days' ? 7 : policy === '30-days' ? 30 : 0;
|
|
236
|
+
if (!retentionDays) return { policy: 'never', removed: 0, removedBytes: 0 };
|
|
237
|
+
const cutoff = Number(now) - retentionDays * 24 * 60 * 60_000;
|
|
238
|
+
const expired = this.index.captures.filter(record => {
|
|
239
|
+
const createdAt = Date.parse(record?.createdAt || '');
|
|
240
|
+
return Number.isFinite(createdAt) && createdAt < cutoff;
|
|
241
|
+
});
|
|
242
|
+
if (expired.length === 0) return { policy, removed: 0, removedBytes: 0 };
|
|
243
|
+
const expiredIds = new Set(expired.map(record => record.id));
|
|
244
|
+
this.index.captures = this.index.captures.filter(record => !expiredIds.has(record.id));
|
|
245
|
+
await Promise.all(expired.map(record => rm(this.resolveFilePath(record), { force: true })));
|
|
246
|
+
await atomicJson(this.indexPath, { captures: this.index.captures });
|
|
247
|
+
const result = {
|
|
248
|
+
policy,
|
|
249
|
+
removed: expired.length,
|
|
250
|
+
removedBytes: expired.reduce((sum, record) => sum + Math.max(0, Number(record.sizeBytes) || 0), 0)
|
|
251
|
+
};
|
|
252
|
+
await this.onRetentionDelete(result);
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async removeAll() {
|
|
257
|
+
await this.ready();
|
|
258
|
+
const records = [...this.index.captures];
|
|
259
|
+
const sessions = [...this.sessions.values()];
|
|
260
|
+
this.sessions.clear();
|
|
261
|
+
await Promise.all([
|
|
262
|
+
...records.map(record => rm(this.resolveFilePath(record), { force: true })),
|
|
263
|
+
...sessions.map(session => rm(session.tempPath, { force: true }))
|
|
264
|
+
]);
|
|
265
|
+
this.index.captures = [];
|
|
266
|
+
await atomicJson(this.indexPath, { captures: [] });
|
|
267
|
+
return {
|
|
268
|
+
removed: records.length,
|
|
269
|
+
removedBytes: records.reduce((sum, record) => sum + Math.max(0, Number(record.sizeBytes) || 0), 0),
|
|
270
|
+
cancelledSessions: sessions.length
|
|
271
|
+
};
|
|
225
272
|
}
|
|
226
273
|
|
|
227
274
|
async _record(record) {
|
|
@@ -104,6 +104,14 @@ export class HubSharedFolders {
|
|
|
104
104
|
return { ok: removed };
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
async clear() {
|
|
108
|
+
await this.ensureLoaded();
|
|
109
|
+
const removed = this.folders.size;
|
|
110
|
+
this.folders.clear();
|
|
111
|
+
await this.persist();
|
|
112
|
+
return { ok: true, removed };
|
|
113
|
+
}
|
|
114
|
+
|
|
107
115
|
async sync(id, deviceIds, remoteDirectory) {
|
|
108
116
|
await this.ensureLoaded();
|
|
109
117
|
const folder = this.folders.get(String(id || ''));
|
|
@@ -29,11 +29,14 @@ function markUpdated(job) {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export class HubTransferJobs {
|
|
32
|
-
constructor({ filesystem, remoteHub, maxConcurrent = 2, commandResultTimeoutMs = COMMAND_RESULT_TIMEOUT_MS } = {}) {
|
|
32
|
+
constructor({ filesystem, remoteHub, maxConcurrent = 2, commandResultTimeoutMs = COMMAND_RESULT_TIMEOUT_MS, getMaxFileSizeBytes = () => Number.MAX_SAFE_INTEGER } = {}) {
|
|
33
33
|
this.filesystem = filesystem;
|
|
34
34
|
this.remoteHub = remoteHub;
|
|
35
35
|
this.maxConcurrent = Math.max(1, Number(maxConcurrent) || 2);
|
|
36
36
|
this.commandResultTimeoutMs = Math.max(1_000, Number(commandResultTimeoutMs) || COMMAND_RESULT_TIMEOUT_MS);
|
|
37
|
+
this.getMaxFileSizeBytes = typeof getMaxFileSizeBytes === 'function'
|
|
38
|
+
? getMaxFileSizeBytes
|
|
39
|
+
: () => Number.MAX_SAFE_INTEGER;
|
|
37
40
|
this.jobs = new Map();
|
|
38
41
|
this.queue = [];
|
|
39
42
|
this.running = 0;
|
|
@@ -98,6 +101,21 @@ export class HubTransferJobs {
|
|
|
98
101
|
return snapshot(job);
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
clear() {
|
|
105
|
+
let cancelled = 0;
|
|
106
|
+
for (const job of this.jobs.values()) {
|
|
107
|
+
if (!['completed', 'partially-failed', 'failed', 'cancelled'].includes(job.state)) {
|
|
108
|
+
this.cancel(job.jobId);
|
|
109
|
+
cancelled += 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const removed = this.jobs.size;
|
|
113
|
+
this.jobs.clear();
|
|
114
|
+
this.queue = [];
|
|
115
|
+
this.pendingCommands.clear();
|
|
116
|
+
return { cancelled, removed };
|
|
117
|
+
}
|
|
118
|
+
|
|
101
119
|
pump() {
|
|
102
120
|
while (this.running < this.maxConcurrent && this.queue.length > 0) {
|
|
103
121
|
const job = this.queue.shift();
|
|
@@ -118,6 +136,10 @@ export class HubTransferJobs {
|
|
|
118
136
|
? { files: job.files, totalBytes: job.files.reduce((sum, file) => sum + Number(file.size || 0), 0) }
|
|
119
137
|
: await this.filesystem.scan(job.itemIds);
|
|
120
138
|
if (job.cancelled) return;
|
|
139
|
+
const maxFileSizeBytes = Math.max(1, Number(this.getMaxFileSizeBytes()) || 1);
|
|
140
|
+
if (scan.files.some(file => Number(file.size || 0) > maxFileSizeBytes)) {
|
|
141
|
+
throw new Error('file-transfer-file-too-large');
|
|
142
|
+
}
|
|
121
143
|
job.totalFiles = scan.files.length;
|
|
122
144
|
job.totalBytes = scan.totalBytes;
|
|
123
145
|
job.state = 'sending';
|
|
@@ -160,10 +182,12 @@ export class HubTransferJobs {
|
|
|
160
182
|
async sendFile(job, file) {
|
|
161
183
|
const activeTargets = () => job.deviceIds.filter(deviceId => !job.failedTargets.has(deviceId));
|
|
162
184
|
if (Number(file.size || 0) === 0) {
|
|
163
|
-
|
|
185
|
+
const sha256 = crypto.createHash('sha256').update(Buffer.alloc(0)).digest('hex');
|
|
186
|
+
await this.sendChunk(job, file, 0, Buffer.alloc(0), true, activeTargets(), sha256);
|
|
164
187
|
return;
|
|
165
188
|
}
|
|
166
189
|
const stream = fs.createReadStream(file.absolutePath, { highWaterMark: CHUNK_BYTES });
|
|
190
|
+
const hasher = crypto.createHash('sha256');
|
|
167
191
|
job.currentStream = stream;
|
|
168
192
|
let offset = 0;
|
|
169
193
|
try {
|
|
@@ -171,9 +195,11 @@ export class HubTransferJobs {
|
|
|
171
195
|
if (job.cancelled) return;
|
|
172
196
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
173
197
|
const final = offset + buffer.length >= Number(file.size || 0);
|
|
198
|
+
hasher.update(buffer);
|
|
199
|
+
const sha256 = final ? hasher.digest('hex') : '';
|
|
174
200
|
const targets = activeTargets();
|
|
175
201
|
if (targets.length === 0) throw new Error('all-targets-failed');
|
|
176
|
-
await this.sendChunk(job, file, offset, buffer, final, targets);
|
|
202
|
+
await this.sendChunk(job, file, offset, buffer, final, targets, sha256);
|
|
177
203
|
offset += buffer.length;
|
|
178
204
|
job.sentBytes += buffer.length;
|
|
179
205
|
markUpdated(job);
|
|
@@ -184,7 +210,7 @@ export class HubTransferJobs {
|
|
|
184
210
|
}
|
|
185
211
|
}
|
|
186
212
|
|
|
187
|
-
async sendChunk(job, file, offset, buffer, final, targets) {
|
|
213
|
+
async sendChunk(job, file, offset, buffer, final, targets, sha256 = '') {
|
|
188
214
|
const outcomes = await Promise.all(targets.map(async deviceId => {
|
|
189
215
|
if (job.cancelled) return { deviceId, ok: false, error: 'transfer-cancelled' };
|
|
190
216
|
const commandId = crypto.randomUUID();
|
|
@@ -200,6 +226,7 @@ export class HubTransferJobs {
|
|
|
200
226
|
offset,
|
|
201
227
|
totalBytes: Number(file.size || 0),
|
|
202
228
|
final,
|
|
229
|
+
sha256,
|
|
203
230
|
dataBase64: buffer.toString('base64'),
|
|
204
231
|
lastModified: Number(file.modifiedMs || 0) || 0,
|
|
205
232
|
requestedAt: new Date().toISOString()
|
package/src/live-desk-update.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { fetchSignedUpdateManifest } from '@livedesk/runtime-core/signed-update-manifest';
|
|
2
3
|
|
|
3
4
|
export const LIVE_DESK_UPDATE_COMMAND = 'livedesk.client-update';
|
|
4
5
|
// 0.1.172 delegates to the exact-version, identity-preserving package
|
|
@@ -138,27 +139,27 @@ function buildLegacyUpdateCommand(
|
|
|
138
139
|
return buildLegacyPlatformCommand(device.platform, payload);
|
|
139
140
|
}
|
|
140
141
|
|
|
141
|
-
async function
|
|
142
|
-
const encodedName = packageName.startsWith('@') ? packageName.replace('/', '%2F') : packageName;
|
|
143
|
-
const
|
|
142
|
+
async function fetchPackageMetadata(packageName, version, fetchImpl) {
|
|
143
|
+
const encodedName = packageName.startsWith('@') ? packageName.replace('/', '%2F') : packageName;
|
|
144
|
+
const encodedVersion = encodeURIComponent(String(version || 'latest'));
|
|
145
|
+
const response = await fetchImpl(`https://registry.npmjs.org/${encodedName}/${encodedVersion}`, {
|
|
144
146
|
headers: { Accept: 'application/json' },
|
|
145
147
|
signal: AbortSignal.timeout(12_000)
|
|
146
148
|
});
|
|
147
149
|
if (!response.ok) {
|
|
148
|
-
throw new Error(`npm registry returned HTTP ${response.status} for ${packageName}.`);
|
|
150
|
+
throw new Error(`npm registry returned HTTP ${response.status} for ${packageName}@${version}.`);
|
|
149
151
|
}
|
|
150
152
|
const payload = await response.json();
|
|
151
|
-
const
|
|
152
|
-
if (!
|
|
153
|
-
return { version, package: payload };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
]);
|
|
153
|
+
const resolvedVersion = cleanVersion(payload?.version);
|
|
154
|
+
if (!resolvedVersion) throw new Error(`npm registry returned no version for ${packageName}@${version}.`);
|
|
155
|
+
return { version: resolvedVersion, package: payload };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function fetchUnsignedRegistryReleaseForTests(fetchImpl) {
|
|
159
|
+
const [manager, client] = await Promise.all([
|
|
160
|
+
fetchPackageMetadata('livedesk', 'latest', fetchImpl),
|
|
161
|
+
fetchPackageMetadata('@livedesk/client', 'latest', fetchImpl)
|
|
162
|
+
]);
|
|
162
163
|
const bundledClientVersion = cleanVersion(manager.package?.livedeskClientVersion);
|
|
163
164
|
if (!bundledClientVersion) {
|
|
164
165
|
throw new Error(`livedesk@${manager.version} is missing required livedeskClientVersion release metadata.`);
|
|
@@ -172,7 +173,47 @@ export async function fetchLatestLiveDeskRelease(fetchImpl = globalThis.fetch) {
|
|
|
172
173
|
: `livedesk@${manager.version} bundles Client ${bundledClientVersion}, while @livedesk/client latest is ${client.version}.`,
|
|
173
174
|
managerPackage: manager.package,
|
|
174
175
|
clientPackage: client.package,
|
|
175
|
-
checkedAt: new Date().toISOString()
|
|
176
|
+
checkedAt: new Date().toISOString(),
|
|
177
|
+
manifestVerified: false,
|
|
178
|
+
blockedVersions: { manager: [], client: [], desktop: [] }
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function fetchLatestLiveDeskRelease(fetchImpl = globalThis.fetch, {
|
|
183
|
+
manifestUrl = '',
|
|
184
|
+
publicKey = '',
|
|
185
|
+
now = () => Date.now(),
|
|
186
|
+
allowUnsignedRegistryForTests = false
|
|
187
|
+
} = {}) {
|
|
188
|
+
if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable for LiveDesk update checks.');
|
|
189
|
+
if (allowUnsignedRegistryForTests === true) return fetchUnsignedRegistryReleaseForTests(fetchImpl);
|
|
190
|
+
const manifest = await fetchSignedUpdateManifest({ fetchImpl, url: manifestUrl, publicKey, now });
|
|
191
|
+
const managerRelease = manifest.release.manager;
|
|
192
|
+
const clientRelease = manifest.release.client;
|
|
193
|
+
const [manager, client] = await Promise.all([
|
|
194
|
+
fetchPackageMetadata('livedesk', managerRelease.version, fetchImpl),
|
|
195
|
+
fetchPackageMetadata('@livedesk/client', clientRelease.version, fetchImpl)
|
|
196
|
+
]);
|
|
197
|
+
const bundledClientVersion = cleanVersion(manager.package?.livedeskClientVersion);
|
|
198
|
+
if (manager.version !== managerRelease.version
|
|
199
|
+
|| client.version !== clientRelease.version
|
|
200
|
+
|| manager.package?.dist?.integrity !== managerRelease.integrity
|
|
201
|
+
|| client.package?.dist?.integrity !== clientRelease.integrity
|
|
202
|
+
|| bundledClientVersion !== clientRelease.version) {
|
|
203
|
+
throw new Error('signed-update-registry-binding-invalid');
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
latestManagerVersion: managerRelease.version,
|
|
207
|
+
latestClientVersion: clientRelease.version,
|
|
208
|
+
registryClientVersion: client.version,
|
|
209
|
+
releaseSkew: '',
|
|
210
|
+
managerPackage: manager.package,
|
|
211
|
+
clientPackage: client.package,
|
|
212
|
+
checkedAt: new Date(now()).toISOString(),
|
|
213
|
+
manifestVerified: true,
|
|
214
|
+
manifestGeneratedAt: manifest.generatedAt,
|
|
215
|
+
manifestExpiresAt: manifest.expiresAt,
|
|
216
|
+
blockedVersions: manifest.blockedVersions
|
|
176
217
|
};
|
|
177
218
|
}
|
|
178
219
|
|
|
@@ -239,6 +280,9 @@ export function createLiveDeskUpdateManager({
|
|
|
239
280
|
requestHubRestart,
|
|
240
281
|
fetchImpl = globalThis.fetch,
|
|
241
282
|
now = () => Date.now(),
|
|
283
|
+
updateManifestUrl = '',
|
|
284
|
+
updatePublicKey = '',
|
|
285
|
+
allowUnsignedRegistryForTests = false,
|
|
242
286
|
clientBatchSize = LIVE_DESK_UPDATE_CLIENT_BATCH_SIZE,
|
|
243
287
|
targetTimeoutMs = LIVE_DESK_UPDATE_TARGET_TIMEOUT_MS,
|
|
244
288
|
operationTimeoutMs = LIVE_DESK_UPDATE_TIMEOUT_MS,
|
|
@@ -275,9 +319,30 @@ export function createLiveDeskUpdateManager({
|
|
|
275
319
|
|
|
276
320
|
const checkLatest = async () => {
|
|
277
321
|
if (checkPromise) return checkPromise;
|
|
278
|
-
checkPromise = fetchLatestLiveDeskRelease(fetchImpl
|
|
279
|
-
|
|
280
|
-
|
|
322
|
+
checkPromise = fetchLatestLiveDeskRelease(fetchImpl, {
|
|
323
|
+
manifestUrl: updateManifestUrl,
|
|
324
|
+
publicKey: updatePublicKey,
|
|
325
|
+
now,
|
|
326
|
+
allowUnsignedRegistryForTests
|
|
327
|
+
})
|
|
328
|
+
.then(release => {
|
|
329
|
+
if (cleanVersion(currentManagerVersion)
|
|
330
|
+
&& compareVersions(release.latestManagerVersion, currentManagerVersion) < 0) {
|
|
331
|
+
throw new Error('signed-update-manager-downgrade-rejected');
|
|
332
|
+
}
|
|
333
|
+
if (cleanVersion(currentClientVersion)
|
|
334
|
+
&& compareVersions(release.latestClientVersion, currentClientVersion) < 0) {
|
|
335
|
+
throw new Error('signed-update-client-downgrade-rejected');
|
|
336
|
+
}
|
|
337
|
+
if (release.blockedVersions?.manager?.includes(cleanVersion(currentManagerVersion))
|
|
338
|
+
&& compareVersions(release.latestManagerVersion, currentManagerVersion) <= 0) {
|
|
339
|
+
throw new Error('signed-update-blocked-manager-without-safe-upgrade');
|
|
340
|
+
}
|
|
341
|
+
if (release.blockedVersions?.client?.includes(cleanVersion(currentClientVersion))
|
|
342
|
+
&& compareVersions(release.latestClientVersion, currentClientVersion) <= 0) {
|
|
343
|
+
throw new Error('signed-update-blocked-client-without-safe-upgrade');
|
|
344
|
+
}
|
|
345
|
+
latestRelease = release;
|
|
281
346
|
checkError = '';
|
|
282
347
|
return release;
|
|
283
348
|
})
|
|
@@ -321,6 +386,9 @@ export function createLiveDeskUpdateManager({
|
|
|
321
386
|
latestClientVersion: latestRelease?.latestClientVersion || '',
|
|
322
387
|
registryClientVersion: latestRelease?.registryClientVersion || '',
|
|
323
388
|
releaseSkew: latestRelease?.releaseSkew || '',
|
|
389
|
+
manifestVerified: latestRelease?.manifestVerified === true,
|
|
390
|
+
manifestGeneratedAt: latestRelease?.manifestGeneratedAt || '',
|
|
391
|
+
manifestExpiresAt: latestRelease?.manifestExpiresAt || '',
|
|
324
392
|
updateAvailable,
|
|
325
393
|
managerUpdateAvailable,
|
|
326
394
|
clientUpdateAvailable,
|