livedesk 0.1.479 → 0.1.481
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/bin/livedesk.js +15 -0
- package/client/package.json +6 -6
- package/hub/package.json +2 -2
- package/hub/src/filesystem/transfer-jobs.js +5 -1
- package/hub/src/remote-hub.js +64 -7
- package/hub/src/security/device-credential-authority.js +75 -27
- package/hub/src/security/security-audit-store.js +24 -2
- package/hub/src/server.js +298 -127
- package/hub/src/transport/relay-hub-control.js +2 -1
- package/hub/src/transport/secure-direct-acceptor.js +2 -1
- package/hub/src/transport/udp-hub-transport.js +19 -8
- package/hub/src/transport/udp-rendezvous.js +143 -29
- package/package.json +7 -6
- package/runtime-core/package.json +1 -1
- package/runtime-core/src/os-secret-store.js +2 -0
- package/web/dist/assets/{index-WS5p6ygO.css → index-Cp814ic9.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/livedesk-build-evidence.json +12 -12
- /package/web/dist/assets/{index-CNcg85GR.js → index-N8fgE2ML.js} +0 -0
package/bin/livedesk.js
CHANGED
|
@@ -65,6 +65,20 @@ const MANAGER_STATE_DIR = resolve(
|
|
|
65
65
|
const MANAGER_STATE_PATH = join(MANAGER_STATE_DIR, 'manager.json');
|
|
66
66
|
const TERMINAL_HUB_UPDATE_OUTCOMES = new Set(['updated', 'restored', 'failed']);
|
|
67
67
|
|
|
68
|
+
async function preflightPackagedRuntimeDependencies() {
|
|
69
|
+
try {
|
|
70
|
+
await Promise.all([
|
|
71
|
+
import('@livedesk/runtime-core'),
|
|
72
|
+
import('@livedesk/runtime-core/os-secret-store')
|
|
73
|
+
]);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
'LiveDesk runtime dependency preflight failed before replacing the existing runtime: '
|
|
77
|
+
+ `${error instanceof Error ? error.message : String(error)}`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
function safeUpdatePathSegment(value, fallback = 'unknown') {
|
|
69
83
|
return String(value || '')
|
|
70
84
|
.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
@@ -3193,6 +3207,7 @@ async function main() {
|
|
|
3193
3207
|
}
|
|
3194
3208
|
}
|
|
3195
3209
|
await assertNoActiveMacPhysicalDiagnosticLease();
|
|
3210
|
+
await preflightPackagedRuntimeDependencies();
|
|
3196
3211
|
const roleStateOptions = {
|
|
3197
3212
|
stateDir: MANAGER_STATE_DIR,
|
|
3198
3213
|
legacyClientStateDir: process.env.LIVEDESK_LEGACY_CLIENT_STATE_DIR
|
package/client/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livedesk/client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.225",
|
|
4
4
|
"description": "LiveDesk local remote client",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -36,16 +36,16 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
38
38
|
"ffmpeg-static": "^5.3.0",
|
|
39
|
-
"@livedesk/runtime-core": "0.1.
|
|
39
|
+
"@livedesk/runtime-core": "0.1.4",
|
|
40
40
|
"@supabase/supabase-js": "^2.110.0",
|
|
41
41
|
"node-screenshots": "^0.2.8",
|
|
42
42
|
"ws": "^8.18.3"
|
|
43
43
|
},
|
|
44
44
|
"optionalDependencies": {
|
|
45
|
-
"@livedesk/fast-linux-x64": "0.1.
|
|
46
|
-
"@livedesk/fast-osx-arm64": "0.1.
|
|
47
|
-
"@livedesk/fast-osx-x64": "0.1.
|
|
48
|
-
"@livedesk/fast-win-x64": "0.1.
|
|
45
|
+
"@livedesk/fast-linux-x64": "0.1.430",
|
|
46
|
+
"@livedesk/fast-osx-arm64": "0.1.430",
|
|
47
|
+
"@livedesk/fast-osx-x64": "0.1.430",
|
|
48
|
+
"@livedesk/fast-win-x64": "0.1.430"
|
|
49
49
|
},
|
|
50
50
|
"publishConfig": {
|
|
51
51
|
"access": "public"
|
package/hub/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livedesk/hub",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.32",
|
|
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.4",
|
|
20
20
|
"@openai/codex-sdk": "0.145.0",
|
|
21
21
|
"cors": "^2.8.5",
|
|
22
22
|
"express": "^4.21.2",
|
|
@@ -322,7 +322,11 @@ export class HubTransferJobs {
|
|
|
322
322
|
try {
|
|
323
323
|
await job.onComplete({ completed, job: snapshot(job) });
|
|
324
324
|
} catch (error) {
|
|
325
|
-
|
|
325
|
+
// A completion callback can be the durable security-audit gate. Never
|
|
326
|
+
// leave a job looking successful when that final record was not stored.
|
|
327
|
+
job.state = 'failed';
|
|
328
|
+
job.error = `completion-callback-failed:${error instanceof Error ? error.message : String(error)}`;
|
|
329
|
+
job.completedAt ||= new Date().toISOString();
|
|
326
330
|
markUpdated(job);
|
|
327
331
|
}
|
|
328
332
|
}
|
package/hub/src/remote-hub.js
CHANGED
|
@@ -2331,11 +2331,12 @@ export function createRemoteHub(options = {}) {
|
|
|
2331
2331
|
const getSecurityIdentity = typeof options.getSecurityIdentity === 'function'
|
|
2332
2332
|
? options.getSecurityIdentity
|
|
2333
2333
|
: () => ({ accountId: safeString(options.accountId || env.LIVEDESK_ACCOUNT_ID, 128) });
|
|
2334
|
-
udpTransport?.setRendezvousProofIssuer?.(({ roomId, deviceId, ttlMs }) => {
|
|
2334
|
+
udpTransport?.setRendezvousProofIssuer?.(({ roomId, deviceId, role, ttlMs }) => {
|
|
2335
2335
|
const identity = getSecurityIdentity() || {};
|
|
2336
2336
|
return deviceCredentialAuthority.issueRendezvousProof({
|
|
2337
2337
|
roomId,
|
|
2338
2338
|
deviceId,
|
|
2339
|
+
role,
|
|
2339
2340
|
ttlMs,
|
|
2340
2341
|
accountId: safeString(identity.accountId, 128)
|
|
2341
2342
|
});
|
|
@@ -8321,7 +8322,7 @@ export function createRemoteHub(options = {}) {
|
|
|
8321
8322
|
};
|
|
8322
8323
|
}
|
|
8323
8324
|
|
|
8324
|
-
function sendCommand(deviceId, command) {
|
|
8325
|
+
function sendCommand(deviceId, command) {
|
|
8325
8326
|
const device = devices.get(String(deviceId || ''));
|
|
8326
8327
|
const commandName = safeString(command?.command || 'ping', 80);
|
|
8327
8328
|
const requiredPermission = commandName === 'input.control'
|
|
@@ -8387,8 +8388,62 @@ export function createRemoteHub(options = {}) {
|
|
|
8387
8388
|
command: payload.command,
|
|
8388
8389
|
channel: dedicatedFileSocket ? 'file' : 'control'
|
|
8389
8390
|
});
|
|
8390
|
-
return { ok: true, commandId };
|
|
8391
|
-
}
|
|
8391
|
+
return { ok: true, commandId };
|
|
8392
|
+
}
|
|
8393
|
+
|
|
8394
|
+
async function sendCommandAwaitResult(deviceId, command, options = {}) {
|
|
8395
|
+
const normalizedDeviceId = safeString(deviceId, 160);
|
|
8396
|
+
const device = devices.get(normalizedDeviceId);
|
|
8397
|
+
const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
|
|
8398
|
+
const commandWithId = { ...(command || {}), commandId };
|
|
8399
|
+
|
|
8400
|
+
if (device?.synthetic === true && device.connected) {
|
|
8401
|
+
const sent = sendCommand(normalizedDeviceId, commandWithId);
|
|
8402
|
+
return {
|
|
8403
|
+
...sent,
|
|
8404
|
+
queued: sent.ok === true,
|
|
8405
|
+
acknowledged: sent.ok === true,
|
|
8406
|
+
acknowledgement: sent.ok === true
|
|
8407
|
+
? { ok: true, commandId, result: { ok: true, synthetic: true }, error: '' }
|
|
8408
|
+
: { ok: false, commandId, result: null, error: sent.error || 'command-not-sent' }
|
|
8409
|
+
};
|
|
8410
|
+
}
|
|
8411
|
+
|
|
8412
|
+
if (!device?.socket || device.socket.destroyed || !device.connected) {
|
|
8413
|
+
const sent = sendCommand(normalizedDeviceId, commandWithId);
|
|
8414
|
+
return {
|
|
8415
|
+
...sent,
|
|
8416
|
+
queued: false,
|
|
8417
|
+
acknowledged: false,
|
|
8418
|
+
acknowledgement: {
|
|
8419
|
+
ok: false,
|
|
8420
|
+
commandId,
|
|
8421
|
+
result: null,
|
|
8422
|
+
error: sent.error || 'device-not-connected'
|
|
8423
|
+
}
|
|
8424
|
+
};
|
|
8425
|
+
}
|
|
8426
|
+
|
|
8427
|
+
const timeoutMs = clampNumber(options.timeoutMs, 1000, 120_000, 30_000);
|
|
8428
|
+
// Register before writing: a loopback Agent can return command.result in
|
|
8429
|
+
// the same event-loop turn as the command write.
|
|
8430
|
+
const acknowledgementPromise = waitForCommandResult(device, commandId, timeoutMs);
|
|
8431
|
+
const sent = sendCommand(normalizedDeviceId, commandWithId);
|
|
8432
|
+
if (!sent.ok) {
|
|
8433
|
+
failPendingCommandResultWaiter(device, commandId, sent.error || 'command-not-sent');
|
|
8434
|
+
}
|
|
8435
|
+
const acknowledgement = await acknowledgementPromise;
|
|
8436
|
+
return {
|
|
8437
|
+
...sent,
|
|
8438
|
+
ok: sent.ok === true && acknowledgement.ok === true,
|
|
8439
|
+
queued: sent.ok === true,
|
|
8440
|
+
acknowledged: acknowledgement.ok === true,
|
|
8441
|
+
acknowledgement,
|
|
8442
|
+
error: acknowledgement.ok === true
|
|
8443
|
+
? undefined
|
|
8444
|
+
: acknowledgement.error || sent.error || 'command-result-failed'
|
|
8445
|
+
};
|
|
8446
|
+
}
|
|
8392
8447
|
|
|
8393
8448
|
function refreshDevicePolicies(deviceIds = undefined) {
|
|
8394
8449
|
const requestedIds = Array.isArray(deviceIds) && deviceIds.length > 0
|
|
@@ -11277,9 +11332,10 @@ export function createRemoteHub(options = {}) {
|
|
|
11277
11332
|
retryTaskBatch,
|
|
11278
11333
|
listDeviceFrames,
|
|
11279
11334
|
disconnectDevice,
|
|
11280
|
-
assignDeviceSlot,
|
|
11281
|
-
sendCommand,
|
|
11282
|
-
|
|
11335
|
+
assignDeviceSlot,
|
|
11336
|
+
sendCommand,
|
|
11337
|
+
sendCommandAwaitResult,
|
|
11338
|
+
refreshDevicePolicies,
|
|
11283
11339
|
sendLegacyClientUpdate,
|
|
11284
11340
|
sendInputControl,
|
|
11285
11341
|
releaseInputOwner,
|
|
@@ -11311,6 +11367,7 @@ export function createRemoteHub(options = {}) {
|
|
|
11311
11367
|
getSecurityStatus: () => ({
|
|
11312
11368
|
hubId: deviceCredentialAuthority.hubId,
|
|
11313
11369
|
hubPublicKey: deviceCredentialAuthority.hubPublicKey,
|
|
11370
|
+
hubIssuerKeyId: deviceCredentialAuthority.hubIssuerKeyId,
|
|
11314
11371
|
direct: secureDirectAcceptor.getStatus(),
|
|
11315
11372
|
devices: deviceCredentialAuthority.listDevices()
|
|
11316
11373
|
}),
|
|
@@ -174,6 +174,9 @@ export function createDeviceCredentialAuthority({
|
|
|
174
174
|
if (!privateKeyText) throw securityError('hub-private-key-secure-store-unavailable');
|
|
175
175
|
const hubPrivateKey = requireP256PrivateKey(privateKeyText);
|
|
176
176
|
const hubPublic = requireP256PublicKey(authority.publicKey);
|
|
177
|
+
const hubIssuerKeyId = crypto.createHash('sha256')
|
|
178
|
+
.update(Buffer.from(hubPublic.text, 'base64url'))
|
|
179
|
+
.digest('base64url');
|
|
177
180
|
const derivedPublic = crypto.createPublicKey(hubPrivateKey).export({ format: 'der', type: 'spki' }).toString('base64url');
|
|
178
181
|
if (derivedPublic !== hubPublic.text) throw securityError('hub-device-authority-key-mismatch');
|
|
179
182
|
const hubId = clean(authority.hubId, 128);
|
|
@@ -183,18 +186,33 @@ export function createDeviceCredentialAuthority({
|
|
|
183
186
|
registry = { version: AUTHORITY_VERSION, devices: {} };
|
|
184
187
|
}
|
|
185
188
|
|
|
186
|
-
function persistRegistry() {
|
|
187
|
-
atomicPrivateJson(registryPath,
|
|
189
|
+
function persistRegistry(nextRegistry = registry) {
|
|
190
|
+
atomicPrivateJson(registryPath, nextRegistry);
|
|
188
191
|
}
|
|
189
192
|
|
|
190
|
-
function
|
|
193
|
+
function normalizeCredentialRequest({ accountId, deviceId, devicePublicKey } = {}) {
|
|
191
194
|
const owner = clean(accountId, 128);
|
|
192
195
|
const id = clean(deviceId, 128);
|
|
193
196
|
if (!owner) throw securityError('device-account-required');
|
|
194
197
|
if (!id) throw securityError('device-id-required');
|
|
195
198
|
const deviceKey = requireP256PublicKey(devicePublicKey);
|
|
199
|
+
return { owner, id, deviceKey };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function assertEnrollmentAllowed(request) {
|
|
203
|
+
const normalized = normalizeCredentialRequest(request);
|
|
204
|
+
const existing = registry.devices[normalized.id];
|
|
205
|
+
if (existing && !existing.revokedAt) throw securityError('device-already-enrolled');
|
|
206
|
+
if (!existing && Object.keys(registry.devices).length >= MAX_DEVICES) {
|
|
207
|
+
throw securityError('device-registry-capacity');
|
|
208
|
+
}
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function issueCredential({ accountId, deviceId, devicePublicKey }) {
|
|
213
|
+
const { owner, id, deviceKey } = normalizeCredentialRequest({ accountId, deviceId, devicePublicKey });
|
|
196
214
|
const existing = registry.devices[id];
|
|
197
|
-
if (existing && !existing.revokedAt
|
|
215
|
+
if (existing && !existing.revokedAt) throw securityError('device-already-enrolled');
|
|
198
216
|
if (!existing && Object.keys(registry.devices).length >= MAX_DEVICES) throw securityError('device-registry-capacity');
|
|
199
217
|
const issuedAt = Math.floor(now());
|
|
200
218
|
const payload = {
|
|
@@ -213,18 +231,25 @@ export function createDeviceCredentialAuthority({
|
|
|
213
231
|
dsaEncoding: 'ieee-p1363'
|
|
214
232
|
}).toString('base64url');
|
|
215
233
|
const credential = `${payloadText}.${signature}`;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
234
|
+
const nextRegistry = {
|
|
235
|
+
...registry,
|
|
236
|
+
devices: {
|
|
237
|
+
...registry.devices,
|
|
238
|
+
[id]: {
|
|
239
|
+
serial: payload.serial,
|
|
240
|
+
accountId: owner,
|
|
241
|
+
hubId,
|
|
242
|
+
devicePublicKey: deviceKey.text,
|
|
243
|
+
credential,
|
|
244
|
+
issuedAt,
|
|
245
|
+
expiresAt: payload.expiresAt,
|
|
246
|
+
revokedAt: '',
|
|
247
|
+
lastConnectedAt: ''
|
|
248
|
+
}
|
|
249
|
+
}
|
|
226
250
|
};
|
|
227
|
-
persistRegistry();
|
|
251
|
+
persistRegistry(nextRegistry);
|
|
252
|
+
registry = nextRegistry;
|
|
228
253
|
return { credential, payload: { ...payload }, hubPublicKey: hubPublic.text };
|
|
229
254
|
}
|
|
230
255
|
|
|
@@ -279,23 +304,26 @@ export function createDeviceCredentialAuthority({
|
|
|
279
304
|
}).toString('base64url');
|
|
280
305
|
}
|
|
281
306
|
|
|
282
|
-
function issueRendezvousProof({ roomId, accountId, deviceId, ttlMs = 60_000 } = {}) {
|
|
307
|
+
function issueRendezvousProof({ roomId, accountId, deviceId, role, ttlMs = 60_000 } = {}) {
|
|
283
308
|
const room = clean(roomId, 160);
|
|
284
309
|
const owner = clean(accountId, 128);
|
|
285
310
|
const device = clean(deviceId, 128);
|
|
311
|
+
const normalizedRole = clean(role, 16).toLowerCase();
|
|
286
312
|
if (!room || !owner || !device) throw securityError('rendezvous-proof-binding-required');
|
|
313
|
+
if (!['hub', 'client'].includes(normalizedRole)) throw securityError('rendezvous-proof-role-required');
|
|
287
314
|
const issuedAt = Math.floor(now());
|
|
288
315
|
const payload = {
|
|
289
|
-
version:
|
|
290
|
-
protocol: 'livedesk.udp.rendezvous-proof.
|
|
316
|
+
version: 2,
|
|
317
|
+
protocol: 'livedesk.udp.rendezvous-proof.v2',
|
|
318
|
+
issuerKeyId: hubIssuerKeyId,
|
|
291
319
|
roomId: room,
|
|
320
|
+
role: normalizedRole,
|
|
292
321
|
accountId: owner,
|
|
293
322
|
hubId,
|
|
294
323
|
deviceId: device,
|
|
295
324
|
issuedAt,
|
|
296
325
|
expiresAt: issuedAt + Math.max(10_000, Math.min(120_000, Number(ttlMs) || 60_000)),
|
|
297
|
-
nonce: crypto.randomBytes(16).toString('base64url')
|
|
298
|
-
hubPublicKey: hubPublic.text
|
|
326
|
+
nonce: crypto.randomBytes(16).toString('base64url')
|
|
299
327
|
};
|
|
300
328
|
const payloadText = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
301
329
|
return { proof: `${payloadText}.${signHubMessage(payloadText)}`, payload };
|
|
@@ -305,9 +333,19 @@ export function createDeviceCredentialAuthority({
|
|
|
305
333
|
const id = clean(deviceId, 128);
|
|
306
334
|
const record = registry.devices[id];
|
|
307
335
|
if (!record || record.revokedAt) return false;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
336
|
+
const nextRegistry = {
|
|
337
|
+
...registry,
|
|
338
|
+
devices: {
|
|
339
|
+
...registry.devices,
|
|
340
|
+
[id]: {
|
|
341
|
+
...record,
|
|
342
|
+
revokedAt: new Date(now()).toISOString(),
|
|
343
|
+
revokeReason: clean(reason, 160)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
persistRegistry(nextRegistry);
|
|
348
|
+
registry = nextRegistry;
|
|
311
349
|
return true;
|
|
312
350
|
}
|
|
313
351
|
|
|
@@ -315,8 +353,15 @@ export function createDeviceCredentialAuthority({
|
|
|
315
353
|
const id = clean(deviceId, 128);
|
|
316
354
|
const record = registry.devices[id];
|
|
317
355
|
if (!record || record.revokedAt) return false;
|
|
318
|
-
|
|
319
|
-
|
|
356
|
+
const nextRegistry = {
|
|
357
|
+
...registry,
|
|
358
|
+
devices: {
|
|
359
|
+
...registry.devices,
|
|
360
|
+
[id]: { ...record, lastConnectedAt: new Date(now()).toISOString() }
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
persistRegistry(nextRegistry);
|
|
364
|
+
registry = nextRegistry;
|
|
320
365
|
return true;
|
|
321
366
|
}
|
|
322
367
|
|
|
@@ -335,14 +380,17 @@ export function createDeviceCredentialAuthority({
|
|
|
335
380
|
|
|
336
381
|
function clearDevices() {
|
|
337
382
|
const removed = Object.keys(registry.devices).length;
|
|
338
|
-
|
|
339
|
-
persistRegistry();
|
|
383
|
+
const nextRegistry = { version: AUTHORITY_VERSION, devices: {} };
|
|
384
|
+
persistRegistry(nextRegistry);
|
|
385
|
+
registry = nextRegistry;
|
|
340
386
|
return removed;
|
|
341
387
|
}
|
|
342
388
|
|
|
343
389
|
return Object.freeze({
|
|
344
390
|
hubId,
|
|
345
391
|
hubPublicKey: hubPublic.text,
|
|
392
|
+
hubIssuerKeyId,
|
|
393
|
+
assertEnrollmentAllowed,
|
|
346
394
|
issueCredential,
|
|
347
395
|
verifyCredential,
|
|
348
396
|
verifyDeviceSignature,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
@@ -85,6 +85,25 @@ function parseSeal(raw) {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
async function syncFile(filePath, flags = 'r') {
|
|
89
|
+
const handle = await open(filePath, flags, 0o600);
|
|
90
|
+
try {
|
|
91
|
+
await handle.sync();
|
|
92
|
+
} finally {
|
|
93
|
+
await handle.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function appendDurably(filePath, value) {
|
|
98
|
+
const handle = await open(filePath, 'a', 0o600);
|
|
99
|
+
try {
|
|
100
|
+
await handle.writeFile(value, { encoding: 'utf8' });
|
|
101
|
+
await handle.sync();
|
|
102
|
+
} finally {
|
|
103
|
+
await handle.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
88
107
|
export function createSecurityAuditStore({ dataDir = path.join(os.homedir(), '.livedesk') } = {}) {
|
|
89
108
|
const filePath = path.join(dataDir, 'security', 'security-audit-v1.jsonl');
|
|
90
109
|
const sealStore = createOsSecretStore({
|
|
@@ -175,6 +194,7 @@ export function createSecurityAuditStore({ dataDir = path.join(os.homedir(), '.l
|
|
|
175
194
|
}
|
|
176
195
|
const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
177
196
|
await writeFile(temporary, `${rebuilt.map(record => JSON.stringify(record)).join('\n')}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
197
|
+
await syncFile(temporary);
|
|
178
198
|
await rename(temporary, filePath);
|
|
179
199
|
records = rebuilt;
|
|
180
200
|
seal.headHash = previousHash;
|
|
@@ -195,7 +215,9 @@ export function createSecurityAuditStore({ dataDir = path.join(os.homedir(), '.l
|
|
|
195
215
|
const recordHash = hashRecord(previousHash, payloadHash, mac);
|
|
196
216
|
written = { version: AUDIT_VERSION, previousHash, payloadHash, mac, recordHash, payload };
|
|
197
217
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
198
|
-
|
|
218
|
+
// A successful record() is the mutation gate for security-sensitive
|
|
219
|
+
// operations. Resolve only after the JSONL record is on stable storage.
|
|
220
|
+
await appendDurably(filePath, `${JSON.stringify(written)}\n`);
|
|
199
221
|
current.push(written);
|
|
200
222
|
seal.headHash = recordHash;
|
|
201
223
|
seal.recordCount = current.length;
|