codex-overleaf-link 1.9.2 → 1.9.4
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/README.md +23 -23
- package/extension/bootstrap/background.js +23 -8
- package/extension/bootstrap/manifest.template.json +1 -1
- package/extension/bootstrap/popup.html +1 -0
- package/extension/bootstrap/popup.js +5 -1
- package/extension/bootstrap/updateStatus.js +78 -0
- package/extension/runtime-manifest.json +1 -0
- package/extension/src/background.js +20 -0
- package/extension/src/backgroundUpdateCoordinator.js +414 -0
- package/extension/src/content/contentRuntime.js +3 -0
- package/extension/src/content/updateNotice.js +248 -0
- package/extension/src/popup.js +268 -3
- package/extension/src/shared/compatibility.js +1 -1
- package/extension/src/shared/updateConsent.js +201 -0
- package/extension/styles/panel.css +146 -0
- package/native-host/src/updateManager.js +320 -37
- package/package.json +1 -1
|
@@ -19,20 +19,26 @@ const NATIVE_MARKER = '.codex-overleaf-managed-native.json';
|
|
|
19
19
|
const JOURNAL_FILE = 'transaction.json';
|
|
20
20
|
const CANDIDATE_FILE = 'candidate.json';
|
|
21
21
|
const COOLDOWN_FILE = 'cooldowns.json';
|
|
22
|
+
const AUTHORIZATION_FILE = 'authorization.json';
|
|
23
|
+
const AUTHORIZATION_TTL_MS = 2 * 60 * 60 * 1000;
|
|
24
|
+
const CONSENT_MIGRATION_TARGET_VERSION = '1.9.4';
|
|
22
25
|
const UPDATE_METHODS = new Set([
|
|
23
26
|
'update.status',
|
|
24
27
|
'update.check',
|
|
28
|
+
'update.authorize',
|
|
25
29
|
'update.stage',
|
|
26
30
|
'update.canApply',
|
|
27
31
|
'update.apply',
|
|
28
32
|
'update.confirm',
|
|
29
|
-
'update.rollback'
|
|
33
|
+
'update.rollback',
|
|
34
|
+
'update.revoke'
|
|
30
35
|
]);
|
|
31
36
|
const RELEASE_ASSET_HOSTS = new Set([
|
|
32
37
|
'github.com',
|
|
33
38
|
'objects.githubusercontent.com',
|
|
34
39
|
'release-assets.githubusercontent.com'
|
|
35
40
|
]);
|
|
41
|
+
let updateMutationTail = Promise.resolve();
|
|
36
42
|
|
|
37
43
|
function isUpdateMethod(method) {
|
|
38
44
|
return UPDATE_METHODS.has(method);
|
|
@@ -49,19 +55,23 @@ async function handleUpdateRequest(request, options = {}) {
|
|
|
49
55
|
}
|
|
50
56
|
switch (request.method) {
|
|
51
57
|
case 'update.status':
|
|
52
|
-
return okResponse(request.id, recoverAndReadStatus(context));
|
|
58
|
+
return okResponse(request.id, await withUpdateMutex(() => recoverAndReadStatus(context)));
|
|
53
59
|
case 'update.check':
|
|
54
|
-
return okResponse(request.id, await checkForUpdate(context, request.params || {}, options));
|
|
60
|
+
return okResponse(request.id, await withUpdateMutex(() => checkForUpdate(context, request.params || {}, options)));
|
|
61
|
+
case 'update.authorize':
|
|
62
|
+
return okResponse(request.id, await withUpdateMutex(() => authorizeUpdate(context, request.params || {})));
|
|
55
63
|
case 'update.stage':
|
|
56
|
-
return okResponse(request.id, await stageCandidate(context, options));
|
|
64
|
+
return okResponse(request.id, await withUpdateMutex(() => stageCandidate(context, options)));
|
|
57
65
|
case 'update.canApply':
|
|
58
66
|
return okResponse(request.id, getApplyGate(options));
|
|
59
67
|
case 'update.apply':
|
|
60
|
-
return okResponse(request.id, applyStagedUpdate(context, request.params || {}, options));
|
|
68
|
+
return okResponse(request.id, await withUpdateMutex(() => applyStagedUpdate(context, request.params || {}, options)));
|
|
61
69
|
case 'update.confirm':
|
|
62
|
-
return okResponse(request.id, confirmUpdate(context, request.params || {}));
|
|
70
|
+
return okResponse(request.id, await withUpdateMutex(() => confirmUpdate(context, request.params || {})));
|
|
63
71
|
case 'update.rollback':
|
|
64
|
-
return okResponse(request.id, rollbackUpdate(context, request.params || {}));
|
|
72
|
+
return okResponse(request.id, await withUpdateMutex(() => rollbackUpdate(context, request.params || {})));
|
|
73
|
+
case 'update.revoke':
|
|
74
|
+
return okResponse(request.id, await withUpdateMutex(() => revokeUpdate(context, request.params || {})));
|
|
65
75
|
default:
|
|
66
76
|
throw updateError('unknown_update_method', 'Unknown managed update method.');
|
|
67
77
|
}
|
|
@@ -70,6 +80,12 @@ async function handleUpdateRequest(request, options = {}) {
|
|
|
70
80
|
}
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
function withUpdateMutex(action) {
|
|
84
|
+
const result = updateMutationTail.then(action, action);
|
|
85
|
+
updateMutationTail = result.catch(() => {});
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
73
89
|
async function checkForUpdate(context, params = {}, options = {}) {
|
|
74
90
|
const fetchImpl = options.fetch || globalThis.fetch;
|
|
75
91
|
if (typeof fetchImpl !== 'function') {
|
|
@@ -160,6 +176,53 @@ function buildReleaseAssetUrl(tag, assetName) {
|
|
|
160
176
|
return GITHUB_RELEASE_DOWNLOAD_ROOT + '/' + tag + '/' + encodeURIComponent(assetName);
|
|
161
177
|
}
|
|
162
178
|
|
|
179
|
+
function authorizeUpdate(context, params = {}) {
|
|
180
|
+
const authorizationId = String(params.authorizationId || '');
|
|
181
|
+
const targetVersion = String(params.targetVersion || '');
|
|
182
|
+
const requestedCurrentVersion = String(params.currentVersion || '');
|
|
183
|
+
if (!isUuid(authorizationId)) {
|
|
184
|
+
throw updateError('update_authorization_invalid', 'Update authorization id is invalid.');
|
|
185
|
+
}
|
|
186
|
+
const candidate = readJsonSafe(path.join(context.updatesRoot, CANDIDATE_FILE), null);
|
|
187
|
+
if (!candidate) {
|
|
188
|
+
throw updateError('update_candidate_missing', 'Check for an update before authorizing it.');
|
|
189
|
+
}
|
|
190
|
+
const manifest = verifySignedReleaseManifest(
|
|
191
|
+
Buffer.from(candidate.manifestBase64 || '', 'base64'),
|
|
192
|
+
Buffer.from(candidate.signatureBase64 || '', 'base64')
|
|
193
|
+
);
|
|
194
|
+
const activeVersion = readVersionPointer(context.nativeRoot, 'active-version');
|
|
195
|
+
if (requestedCurrentVersion !== activeVersion ||
|
|
196
|
+
targetVersion !== candidate.latestVersion ||
|
|
197
|
+
targetVersion !== manifest.version ||
|
|
198
|
+
!isNewerStableVersion(targetVersion, activeVersion)) {
|
|
199
|
+
throw updateError('update_consent_mismatch', 'Update authorization does not match the active signed candidate.');
|
|
200
|
+
}
|
|
201
|
+
const existing = readAuthorization(context);
|
|
202
|
+
if (existing?.id === authorizationId &&
|
|
203
|
+
existing.targetVersion === targetVersion &&
|
|
204
|
+
['authorized', 'bound'].includes(existing.state) &&
|
|
205
|
+
(!existing.expiresAt || Date.parse(existing.expiresAt) > Date.now())) {
|
|
206
|
+
return publicAuthorization(existing);
|
|
207
|
+
}
|
|
208
|
+
if (existing && ['authorized', 'bound'].includes(existing.state)) {
|
|
209
|
+
throw updateError('update_authorization_in_progress', 'Another update authorization is already active.');
|
|
210
|
+
}
|
|
211
|
+
const grantedAt = new Date();
|
|
212
|
+
const authorization = {
|
|
213
|
+
schemaVersion: 1,
|
|
214
|
+
id: authorizationId,
|
|
215
|
+
targetVersion,
|
|
216
|
+
sourceVersion: activeVersion,
|
|
217
|
+
state: 'authorized',
|
|
218
|
+
grantedAt: grantedAt.toISOString(),
|
|
219
|
+
expiresAt: new Date(grantedAt.getTime() + AUTHORIZATION_TTL_MS).toISOString(),
|
|
220
|
+
transactionId: ''
|
|
221
|
+
};
|
|
222
|
+
writeAuthorization(context, authorization);
|
|
223
|
+
return publicAuthorization(authorization);
|
|
224
|
+
}
|
|
225
|
+
|
|
163
226
|
async function stageCandidate(context, options = {}) {
|
|
164
227
|
const candidate = readJsonSafe(path.join(context.updatesRoot, CANDIDATE_FILE), null);
|
|
165
228
|
if (!candidate) {
|
|
@@ -172,37 +235,74 @@ async function stageCandidate(context, options = {}) {
|
|
|
172
235
|
if (!isNewerStableVersion(manifest.version, activeVersion)) {
|
|
173
236
|
throw updateError('update_candidate_stale', 'The staged update is no longer newer than the active version.');
|
|
174
237
|
}
|
|
238
|
+
const authorization = requireAuthorization(context, manifest.version, ['authorized', 'bound']);
|
|
239
|
+
const existingJournal = readJournal(context);
|
|
240
|
+
if (existingJournal?.state === 'staged' && existingJournal.targetVersion === manifest.version) {
|
|
241
|
+
if (existingJournal.authorizationId !== authorization.id ||
|
|
242
|
+
(authorization.transactionId && authorization.transactionId !== existingJournal.id)) {
|
|
243
|
+
throw updateError('update_consent_mismatch', 'Staged transaction does not match the active authorization.');
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
verifyStagedPair(existingJournal.payloadRoot, manifest.version);
|
|
247
|
+
bindAuthorization(context, authorization, existingJournal.id);
|
|
248
|
+
return {
|
|
249
|
+
transactionId: existingJournal.id,
|
|
250
|
+
targetVersion: existingJournal.targetVersion,
|
|
251
|
+
state: existingJournal.state,
|
|
252
|
+
reused: true
|
|
253
|
+
};
|
|
254
|
+
} catch (_error) {
|
|
255
|
+
cleanupStage(existingJournal.stageRoot);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (existingJournal && ['applying', 'awaiting_health'].includes(existingJournal.state)) {
|
|
259
|
+
throw updateError('update_transaction_in_progress', 'A managed update transaction is already being applied.');
|
|
260
|
+
}
|
|
261
|
+
if (authorization.state === 'bound') {
|
|
262
|
+
throw updateError('update_consent_mismatch', 'Bound authorization has no reusable staged transaction.');
|
|
263
|
+
}
|
|
175
264
|
const transactionId = crypto.randomUUID();
|
|
176
265
|
const stageRoot = path.join(context.updatesRoot, 'staging-' + transactionId);
|
|
177
266
|
fs.mkdirSync(stageRoot, { recursive: true, mode: 0o700 });
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
267
|
+
try {
|
|
268
|
+
const archivePath = path.join(stageRoot, manifest.updateBundle.name);
|
|
269
|
+
const fetchImpl = options.fetch || globalThis.fetch;
|
|
270
|
+
const bundleBytes = await fetchReleaseAsset(fetchImpl, candidate.bundleUrl, manifest.updateBundle.size + 1);
|
|
271
|
+
if (bundleBytes.length !== manifest.updateBundle.size) {
|
|
272
|
+
throw updateError('update_bundle_size_mismatch', 'Downloaded update bundle size does not match the signed manifest.');
|
|
273
|
+
}
|
|
274
|
+
const hash = crypto.createHash('sha256').update(bundleBytes).digest('hex');
|
|
275
|
+
if (hash !== manifest.updateBundle.sha256) {
|
|
276
|
+
throw updateError('update_bundle_hash_mismatch', 'Downloaded update bundle hash does not match the signed manifest.');
|
|
277
|
+
}
|
|
278
|
+
fs.writeFileSync(archivePath, bundleBytes, { mode: 0o600 });
|
|
279
|
+
const payloadRoot = path.join(stageRoot, 'payload');
|
|
280
|
+
extractVerifiedUpdateBundle({ archivePath, destinationRoot: payloadRoot });
|
|
281
|
+
verifyStagedPair(payloadRoot, manifest.version);
|
|
282
|
+
const journal = {
|
|
283
|
+
id: transactionId,
|
|
284
|
+
authorizationId: authorization.id,
|
|
285
|
+
state: 'staged',
|
|
286
|
+
sourceVersion: activeVersion,
|
|
287
|
+
sourcePreviousVersion: readVersionPointer(context.nativeRoot, 'previous-version'),
|
|
288
|
+
targetVersion: manifest.version,
|
|
289
|
+
stageRoot,
|
|
290
|
+
payloadRoot,
|
|
291
|
+
manifestSha256: crypto.createHash('sha256').update(manifestBytes).digest('hex'),
|
|
292
|
+
createdAt: new Date().toISOString(),
|
|
293
|
+
updatedAt: new Date().toISOString()
|
|
294
|
+
};
|
|
295
|
+
writeJournal(context, journal);
|
|
296
|
+
bindAuthorization(context, authorization, transactionId);
|
|
297
|
+
cleanupOrphanStageRoots(context.updatesRoot, [stageRoot]);
|
|
298
|
+
return { transactionId, targetVersion: manifest.version, state: journal.state };
|
|
299
|
+
} catch (error) {
|
|
300
|
+
cleanupStage(stageRoot);
|
|
301
|
+
const journal = readJournal(context);
|
|
302
|
+
if (journal?.id === transactionId) removeJournal(context);
|
|
303
|
+
settleAuthorization(context, authorization.id, 'revoked');
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
206
306
|
}
|
|
207
307
|
|
|
208
308
|
function getApplyGate(options = {}) {
|
|
@@ -227,6 +327,10 @@ function applyStagedUpdate(context, params = {}, options = {}) {
|
|
|
227
327
|
if (params.transactionId && params.transactionId !== journal.id) {
|
|
228
328
|
throw updateError('update_transaction_mismatch', 'Update transaction id does not match the staged update.');
|
|
229
329
|
}
|
|
330
|
+
const authorization = requireAuthorization(context, journal.targetVersion, ['bound']);
|
|
331
|
+
if (journal.authorizationId !== authorization.id || authorization.transactionId !== journal.id) {
|
|
332
|
+
throw updateError('update_consent_mismatch', 'Apply transaction does not match the bound authorization.');
|
|
333
|
+
}
|
|
230
334
|
assertManagedMarkers(context);
|
|
231
335
|
const extensionRuntime = path.join(context.extensionRoot, 'runtime');
|
|
232
336
|
const previousRuntime = path.join(context.extensionRoot, 'slots', 'previous', 'runtime');
|
|
@@ -247,8 +351,10 @@ function applyStagedUpdate(context, params = {}, options = {}) {
|
|
|
247
351
|
atomicWriteText(path.join(context.nativeRoot, 'previous-version'), journal.sourceVersion + '\n');
|
|
248
352
|
atomicWriteText(path.join(context.nativeRoot, 'active-version'), journal.targetVersion + '\n');
|
|
249
353
|
rewriteManagedManifestVersion(manifestPath, journal.targetVersion);
|
|
354
|
+
syncManagedMarkerVersions(context, journal.targetVersion);
|
|
250
355
|
} catch (error) {
|
|
251
356
|
rollbackFiles(context, { ...journal, previousManifest });
|
|
357
|
+
settleAuthorization(context, journal.authorizationId, 'revoked');
|
|
252
358
|
throw updateError('update_apply_failed', 'Managed update could not be applied atomically.', { cause: error });
|
|
253
359
|
}
|
|
254
360
|
const awaiting = {
|
|
@@ -270,6 +376,14 @@ function confirmUpdate(context, params = {}) {
|
|
|
270
376
|
if (params.transactionId !== journal.id || params.extensionVersion !== journal.targetVersion || params.nativeVersion !== journal.targetVersion) {
|
|
271
377
|
throw updateError('update_health_version_mismatch', 'Extension and native host did not confirm the same target version.');
|
|
272
378
|
}
|
|
379
|
+
if (!isLegacyInboundConsentMigration(journal)) {
|
|
380
|
+
const authorization = requireAuthorization(context, journal.targetVersion, ['bound'], {
|
|
381
|
+
activeVersion: journal.sourceVersion
|
|
382
|
+
});
|
|
383
|
+
if (journal.authorizationId !== authorization.id || authorization.transactionId !== journal.id) {
|
|
384
|
+
throw updateError('update_consent_mismatch', 'Health confirmation does not match the bound authorization.');
|
|
385
|
+
}
|
|
386
|
+
}
|
|
273
387
|
const manifest = readJsonSafe(path.join(context.extensionRoot, 'manifest.json'), null);
|
|
274
388
|
if (readVersionPointer(context.nativeRoot, 'active-version') !== journal.targetVersion || manifest?.version !== journal.targetVersion) {
|
|
275
389
|
throw updateError('update_health_version_mismatch', 'Managed files do not match the confirmed target version.');
|
|
@@ -280,8 +394,10 @@ function confirmUpdate(context, params = {}) {
|
|
|
280
394
|
confirmedAt: new Date().toISOString(),
|
|
281
395
|
updatedAt: new Date().toISOString()
|
|
282
396
|
});
|
|
397
|
+
settleAuthorization(context, journal.authorizationId, 'consumed');
|
|
283
398
|
pruneNativeVersions(context.nativeRoot, new Set([journal.targetVersion, journal.sourceVersion]));
|
|
284
399
|
cleanupStage(journal.stageRoot);
|
|
400
|
+
cleanupOrphanStageRoots(context.updatesRoot);
|
|
285
401
|
return { version: journal.targetVersion, state: 'committed' };
|
|
286
402
|
}
|
|
287
403
|
|
|
@@ -305,10 +421,50 @@ function rollbackUpdate(context, params = {}) {
|
|
|
305
421
|
updatedAt: new Date().toISOString(),
|
|
306
422
|
previousManifest: undefined
|
|
307
423
|
});
|
|
424
|
+
settleAuthorization(context, journal.authorizationId, 'revoked');
|
|
308
425
|
cleanupStage(journal.stageRoot);
|
|
426
|
+
cleanupOrphanStageRoots(context.updatesRoot);
|
|
309
427
|
return { version: journal.sourceVersion, state: 'rolled_back' };
|
|
310
428
|
}
|
|
311
429
|
|
|
430
|
+
function revokeUpdate(context, params = {}) {
|
|
431
|
+
const authorizationId = String(params.authorizationId || '');
|
|
432
|
+
const targetVersion = String(params.targetVersion || '');
|
|
433
|
+
const transactionId = String(params.transactionId || '');
|
|
434
|
+
if (!isUuid(authorizationId) || !parseSemver(targetVersion)) {
|
|
435
|
+
throw updateError('update_authorization_invalid', 'Update revocation parameters are invalid.');
|
|
436
|
+
}
|
|
437
|
+
const journal = readJournal(context);
|
|
438
|
+
if (journal?.targetVersion === targetVersion &&
|
|
439
|
+
['applying', 'awaiting_health', 'committed'].includes(journal.state)) {
|
|
440
|
+
throw updateError('update_revoke_too_late', 'The update is already being applied.');
|
|
441
|
+
}
|
|
442
|
+
const authorization = readAuthorization(context);
|
|
443
|
+
if (!authorization) {
|
|
444
|
+
if (!journal || journal.targetVersion !== targetVersion) {
|
|
445
|
+
return { state: 'already_revoked', targetVersion };
|
|
446
|
+
}
|
|
447
|
+
if (journal.authorizationId !== authorizationId) {
|
|
448
|
+
throw updateError('update_consent_mismatch', 'Revocation does not match the staged transaction.');
|
|
449
|
+
}
|
|
450
|
+
} else if (authorization.id !== authorizationId || authorization.targetVersion !== targetVersion) {
|
|
451
|
+
throw updateError('update_consent_mismatch', 'Revocation does not match the active authorization.');
|
|
452
|
+
}
|
|
453
|
+
if (transactionId && journal?.id !== transactionId) {
|
|
454
|
+
throw updateError('update_transaction_mismatch', 'Revocation transaction id does not match the staged update.');
|
|
455
|
+
}
|
|
456
|
+
if (authorization) {
|
|
457
|
+
writeAuthorization(context, { ...authorization, state: 'revoked', revokedAt: new Date().toISOString() });
|
|
458
|
+
}
|
|
459
|
+
if (journal?.state === 'staged') {
|
|
460
|
+
cleanupStage(journal.stageRoot);
|
|
461
|
+
removeJournal(context);
|
|
462
|
+
}
|
|
463
|
+
cleanupOrphanStageRoots(context.updatesRoot);
|
|
464
|
+
fs.rmSync(path.join(context.updatesRoot, AUTHORIZATION_FILE), { force: true });
|
|
465
|
+
return { state: 'revoked', targetVersion };
|
|
466
|
+
}
|
|
467
|
+
|
|
312
468
|
function rollbackFiles(context, journal) {
|
|
313
469
|
const runtime = path.join(context.extensionRoot, 'runtime');
|
|
314
470
|
const previousRuntime = path.join(context.extensionRoot, 'slots', 'previous', 'runtime');
|
|
@@ -329,6 +485,45 @@ function rollbackFiles(context, journal) {
|
|
|
329
485
|
} else {
|
|
330
486
|
fs.rmSync(path.join(context.nativeRoot, 'previous-version'), { force: true });
|
|
331
487
|
}
|
|
488
|
+
if (parseSemver(journal.sourceVersion)) {
|
|
489
|
+
syncManagedMarkerVersions(context, journal.sourceVersion);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function syncManagedMarkerVersions(context, version) {
|
|
494
|
+
rewriteManagedMarkerVersion(context.extensionRoot, EXTENSION_MARKER, 'extension', version);
|
|
495
|
+
rewriteManagedMarkerVersion(context.nativeRoot, NATIVE_MARKER, 'native', version);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function rewriteManagedMarkerVersion(root, filename, kind, version) {
|
|
499
|
+
const markerPath = path.join(root, filename);
|
|
500
|
+
const marker = readJsonSafe(markerPath, null);
|
|
501
|
+
if (!isManagedMarker(marker, kind)) {
|
|
502
|
+
throw updateError('update_marker_invalid', `The managed ${kind} marker is missing or invalid.`);
|
|
503
|
+
}
|
|
504
|
+
atomicWriteJson(markerPath, {
|
|
505
|
+
...marker,
|
|
506
|
+
version,
|
|
507
|
+
updatedAt: new Date().toISOString()
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function cleanupOrphanStageRoots(updatesRoot, retainedRoots = []) {
|
|
512
|
+
const resolvedRoot = path.resolve(String(updatesRoot || ''));
|
|
513
|
+
if (!resolvedRoot || resolvedRoot === path.parse(resolvedRoot).root) return;
|
|
514
|
+
const retained = new Set((retainedRoots || []).map(value => path.resolve(String(value || ''))));
|
|
515
|
+
let entries;
|
|
516
|
+
try {
|
|
517
|
+
entries = fs.readdirSync(resolvedRoot, { withFileTypes: true });
|
|
518
|
+
} catch (_error) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
for (const entry of entries) {
|
|
522
|
+
if (!entry.isDirectory() || !/^staging-[0-9a-f-]{36}$/i.test(entry.name)) continue;
|
|
523
|
+
const candidate = path.join(resolvedRoot, entry.name);
|
|
524
|
+
if (retained.has(path.resolve(candidate))) continue;
|
|
525
|
+
fs.rmSync(candidate, { recursive: true, force: true });
|
|
526
|
+
}
|
|
332
527
|
}
|
|
333
528
|
|
|
334
529
|
function recoverAndReadStatus(context) {
|
|
@@ -337,6 +532,7 @@ function recoverAndReadStatus(context) {
|
|
|
337
532
|
rollbackFiles(context, journal);
|
|
338
533
|
journal = { ...journal, state: 'rolled_back', reasonCode: 'update_interrupted', updatedAt: new Date().toISOString() };
|
|
339
534
|
writeJournal(context, journal);
|
|
535
|
+
settleAuthorization(context, journal.authorizationId, 'revoked');
|
|
340
536
|
}
|
|
341
537
|
if (journal?.state === 'rolled_back') {
|
|
342
538
|
pruneNativeVersions(context.nativeRoot, new Set([
|
|
@@ -348,7 +544,8 @@ function recoverAndReadStatus(context) {
|
|
|
348
544
|
managed: true,
|
|
349
545
|
activeVersion: readVersionPointer(context.nativeRoot, 'active-version'),
|
|
350
546
|
previousVersion: readVersionPointer(context.nativeRoot, 'previous-version'),
|
|
351
|
-
transaction: journal ? publicTransaction(journal) : null
|
|
547
|
+
transaction: journal ? publicTransaction(journal) : null,
|
|
548
|
+
authorization: publicAuthorization(readAuthorization(context))
|
|
352
549
|
};
|
|
353
550
|
}
|
|
354
551
|
|
|
@@ -482,6 +679,18 @@ function publicTransaction(journal) {
|
|
|
482
679
|
};
|
|
483
680
|
}
|
|
484
681
|
|
|
682
|
+
function publicAuthorization(authorization) {
|
|
683
|
+
if (!authorization) return null;
|
|
684
|
+
return {
|
|
685
|
+
state: authorization.state,
|
|
686
|
+
targetVersion: authorization.targetVersion,
|
|
687
|
+
sourceVersion: authorization.sourceVersion,
|
|
688
|
+
transactionId: authorization.transactionId || '',
|
|
689
|
+
grantedAt: authorization.grantedAt,
|
|
690
|
+
expiresAt: authorization.expiresAt
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
485
694
|
function readJournal(context) {
|
|
486
695
|
return readJsonSafe(path.join(context.updatesRoot, JOURNAL_FILE), null);
|
|
487
696
|
}
|
|
@@ -490,6 +699,60 @@ function writeJournal(context, journal) {
|
|
|
490
699
|
atomicWriteJson(path.join(context.updatesRoot, JOURNAL_FILE), journal);
|
|
491
700
|
}
|
|
492
701
|
|
|
702
|
+
function removeJournal(context) {
|
|
703
|
+
fs.rmSync(path.join(context.updatesRoot, JOURNAL_FILE), { force: true });
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function readAuthorization(context) {
|
|
707
|
+
return readJsonSafe(path.join(context.updatesRoot, AUTHORIZATION_FILE), null);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function writeAuthorization(context, authorization) {
|
|
711
|
+
atomicWriteJson(path.join(context.updatesRoot, AUTHORIZATION_FILE), authorization);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function requireAuthorization(context, targetVersion, allowedStates, options = {}) {
|
|
715
|
+
const authorization = readAuthorization(context);
|
|
716
|
+
if (!authorization) {
|
|
717
|
+
throw updateError('update_consent_required', 'Choose Update now before downloading or installing this update.');
|
|
718
|
+
}
|
|
719
|
+
const expectedActiveVersion = options.activeVersion || readVersionPointer(context.nativeRoot, 'active-version');
|
|
720
|
+
if (authorization.targetVersion !== targetVersion || authorization.sourceVersion !== expectedActiveVersion) {
|
|
721
|
+
throw updateError('update_consent_mismatch', 'Update authorization does not match this source and target version.');
|
|
722
|
+
}
|
|
723
|
+
if (!allowedStates.includes(authorization.state)) {
|
|
724
|
+
throw updateError('update_consent_required', 'Update authorization is not active.');
|
|
725
|
+
}
|
|
726
|
+
if (authorization.state === 'authorized' &&
|
|
727
|
+
(!authorization.expiresAt || Date.parse(authorization.expiresAt) <= Date.now())) {
|
|
728
|
+
settleAuthorization(context, authorization.id, 'revoked');
|
|
729
|
+
throw updateError('update_consent_required', 'Update authorization expired. Choose Update now again.');
|
|
730
|
+
}
|
|
731
|
+
return authorization;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function bindAuthorization(context, authorization, transactionId) {
|
|
735
|
+
const bound = {
|
|
736
|
+
...authorization,
|
|
737
|
+
state: 'bound',
|
|
738
|
+
transactionId,
|
|
739
|
+
boundAt: authorization.boundAt || new Date().toISOString()
|
|
740
|
+
};
|
|
741
|
+
writeAuthorization(context, bound);
|
|
742
|
+
return bound;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function settleAuthorization(context, authorizationId, state) {
|
|
746
|
+
const authorization = readAuthorization(context);
|
|
747
|
+
if (!authorization || (authorizationId && authorization.id !== authorizationId)) return;
|
|
748
|
+
writeAuthorization(context, {
|
|
749
|
+
...authorization,
|
|
750
|
+
state,
|
|
751
|
+
settledAt: new Date().toISOString()
|
|
752
|
+
});
|
|
753
|
+
fs.rmSync(path.join(context.updatesRoot, AUTHORIZATION_FILE), { force: true });
|
|
754
|
+
}
|
|
755
|
+
|
|
493
756
|
function atomicWriteJson(target, value) {
|
|
494
757
|
atomicWriteText(target, JSON.stringify(value, null, 2) + '\n');
|
|
495
758
|
}
|
|
@@ -519,6 +782,21 @@ function normalizeReasonCode(value) {
|
|
|
519
782
|
return /^[a-z0-9_]{1,80}$/.test(String(value || '')) ? String(value) : 'update_health_failed';
|
|
520
783
|
}
|
|
521
784
|
|
|
785
|
+
function isUuid(value) {
|
|
786
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(value || ''));
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function isLegacyInboundConsentMigration(journal) {
|
|
790
|
+
return Boolean(
|
|
791
|
+
journal &&
|
|
792
|
+
!journal.authorizationId &&
|
|
793
|
+
journal.state === 'awaiting_health' &&
|
|
794
|
+
journal.targetVersion === CONSENT_MIGRATION_TARGET_VERSION &&
|
|
795
|
+
parseSemver(journal.sourceVersion) &&
|
|
796
|
+
compareSemver(journal.sourceVersion, CONSENT_MIGRATION_TARGET_VERSION) < 0
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
|
|
522
800
|
function safeErrorCode(error) {
|
|
523
801
|
return /^[a-z0-9_]{1,80}$/.test(String(error?.code || '')) ? error.code : 'update_internal_error';
|
|
524
802
|
}
|
|
@@ -538,11 +816,16 @@ function errorResponse(id, code, message) {
|
|
|
538
816
|
module.exports = {
|
|
539
817
|
GITHUB_LATEST_URL,
|
|
540
818
|
applyStagedUpdate,
|
|
819
|
+
authorizeUpdate,
|
|
541
820
|
checkForUpdate,
|
|
821
|
+
cleanupOrphanStageRoots,
|
|
542
822
|
confirmUpdate,
|
|
543
823
|
getApplyGate,
|
|
544
824
|
getManagedContext,
|
|
545
825
|
handleUpdateRequest,
|
|
546
826
|
isUpdateMethod,
|
|
547
|
-
|
|
827
|
+
revokeUpdate,
|
|
828
|
+
rollbackUpdate,
|
|
829
|
+
stageCandidate,
|
|
830
|
+
syncManagedMarkerVersions
|
|
548
831
|
};
|