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.
@@ -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
- const archivePath = path.join(stageRoot, manifest.updateBundle.name);
179
- const fetchImpl = options.fetch || globalThis.fetch;
180
- const bundleBytes = await fetchReleaseAsset(fetchImpl, candidate.bundleUrl, manifest.updateBundle.size + 1);
181
- if (bundleBytes.length !== manifest.updateBundle.size) {
182
- throw updateError('update_bundle_size_mismatch', 'Downloaded update bundle size does not match the signed manifest.');
183
- }
184
- const hash = crypto.createHash('sha256').update(bundleBytes).digest('hex');
185
- if (hash !== manifest.updateBundle.sha256) {
186
- throw updateError('update_bundle_hash_mismatch', 'Downloaded update bundle hash does not match the signed manifest.');
187
- }
188
- fs.writeFileSync(archivePath, bundleBytes, { mode: 0o600 });
189
- const payloadRoot = path.join(stageRoot, 'payload');
190
- extractVerifiedUpdateBundle({ archivePath, destinationRoot: payloadRoot });
191
- verifyStagedPair(payloadRoot, manifest.version);
192
- const journal = {
193
- id: transactionId,
194
- state: 'staged',
195
- sourceVersion: activeVersion,
196
- sourcePreviousVersion: readVersionPointer(context.nativeRoot, 'previous-version'),
197
- targetVersion: manifest.version,
198
- stageRoot,
199
- payloadRoot,
200
- manifestSha256: crypto.createHash('sha256').update(manifestBytes).digest('hex'),
201
- createdAt: new Date().toISOString(),
202
- updatedAt: new Date().toISOString()
203
- };
204
- writeJournal(context, journal);
205
- return { transactionId, targetVersion: manifest.version, state: journal.state };
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
- rollbackUpdate
827
+ revokeUpdate,
828
+ rollbackUpdate,
829
+ stageCandidate,
830
+ syncManagedMarkerVersions
548
831
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-overleaf-link",
3
- "version": "1.9.2",
3
+ "version": "1.9.4",
4
4
  "description": "Cross-platform Chrome bridge that connects Codex to the active Overleaf project.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",