@cortexkit/antigravity-auth-core 3.0.15 → 3.0.17

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.
@@ -1,10 +1,11 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
- import { link, lstat, mkdir, open, realpath, unlink } from 'node:fs/promises';
3
+ import { link, lstat, mkdir, open, realpath, rename, unlink, } from 'node:fs/promises';
4
4
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
5
5
  import { isDeepStrictEqual } from 'node:util';
6
6
  import { AccountCodecError, decodeProviderMetadata, decodeProviderState, decodeQuotaState, decodeRoutingSettings, encodeProviderMetadata, encodeProviderState, encodeQuotaState, encodeRoutingSettings, isValidProviderState, providerStateCredentialBound, QUOTA_CODEC, } from "./account-repository-codecs.js";
7
- import { ACCOUNT_STORE_PROVIDER, legacyAccountsLock, MANAGEMENT_LOCK_NAME, MANAGEMENT_SETTINGS_KEY, ROUTING_SETTINGS_KEY, } from "./account-repository-types.js";
7
+ import { ACCOUNT_STORE_PROVIDER, legacyAccountsLock, MANAGEMENT_LOCK_NAME, MANAGEMENT_SETTINGS_KEY, ROUTING_SETTINGS_KEY, refreshProviderLock, } from "./account-repository-types.js";
8
+ import { createPrivateBytes, createPrivateDirectoryExclusive, verifySafeDirectory, windowsPrivateFiles, } from './common-auth-embedded/fs/private-files.js';
8
9
  /** Derives store paths from the caller's absolute legacy file path, not HOME or host preferences. */
9
10
  export function resolveAccountStorePaths(legacyPath, generationId) {
10
11
  if (generationId !== undefined)
@@ -59,14 +60,96 @@ export function decodeAccountStorePointer(value, legacyPath) {
59
60
  throw new AccountMigrationError('pointer directory basename differs from its derived generation');
60
61
  return { schemaVersion: 1, id, directoryBasename: basename(paths.storeDir) };
61
62
  }
62
- async function readPointer(legacyPath) {
63
+ /** Decodes either pointer schema; schema 1 keeps `decodeAccountStorePointer`'s exact rules. */
64
+ export function decodeAccountStorePointerRecord(value, legacyPath) {
65
+ if (object(value, 'pointer').schemaVersion !== 2)
66
+ return decodeAccountStorePointer(value, legacyPath);
67
+ const raw = exact(value, ['schemaVersion', 'id', 'directoryBasename', 'relocatedTo'], 'forward');
68
+ const id = text(raw.id, UUID, 'forward source generation');
69
+ const paths = generationPaths(legacyPath, id);
70
+ if (raw.directoryBasename !== basename(paths.storeDir))
71
+ throw new AccountMigrationError('forward directory basename differs from its derived generation');
72
+ const target = exact(raw.relocatedTo, ['legacyPath', 'id'], 'forward target');
73
+ if (typeof target.legacyPath !== 'string')
74
+ throw new AccountMigrationError('forward target path is invalid');
75
+ const targetId = text(target.id, UUID, 'forward target generation');
76
+ const targetPaths = generationPaths(target.legacyPath, targetId);
77
+ if (sameFilesystemPath(targetPaths.legacyPath, legacyPath))
78
+ throw new AccountMigrationError('forward names its own anchor');
79
+ return {
80
+ schemaVersion: 2,
81
+ id,
82
+ directoryBasename: basename(paths.storeDir),
83
+ relocatedTo: {
84
+ legacyPath: targetPaths.legacyPath,
85
+ id: targetId,
86
+ },
87
+ };
88
+ }
89
+ async function readPointerRecord(legacyPath) {
63
90
  const logical = resolveAccountStorePaths(legacyPath);
64
91
  if (await exists(logical.storeDir))
65
92
  throw new AccountMigrationError('foreign logical store directory is not pointer authority');
66
93
  const path = accountStorePointerPath(legacyPath);
67
94
  if (!(await exists(path)))
68
95
  return undefined;
69
- return decodeAccountStorePointer(jsonBytes(await secureRead(path)), legacyPath);
96
+ return decodeAccountStorePointerRecord(jsonBytes(await secureRead(path)), legacyPath);
97
+ }
98
+ /**
99
+ * The schema-1 pointer for operations that work on the generation published
100
+ * at this anchor. A forward refuses them: this build only reads a moved
101
+ * store (through `readAccountStoreBinding`) and never migrates, initializes,
102
+ * publishes over or writes beside one.
103
+ */
104
+ async function readPointer(legacyPath) {
105
+ const record = await readPointerRecord(legacyPath);
106
+ if (record?.schemaVersion === 2)
107
+ throw new AccountMigrationError(`the account store moved to ${record.relocatedTo.legacyPath}; this operation does not follow a moved store`);
108
+ return record;
109
+ }
110
+ /**
111
+ * The anchor a forward leads to. The new anchor's pointer must publish
112
+ * exactly the generation the forward names; anything else (no pointer, a
113
+ * different generation, a second forward) refuses, because binding there
114
+ * would either serve the wrong store or report "no accounts" and let a login
115
+ * create a second store.
116
+ */
117
+ async function forwardTarget(legacyPath, forward) {
118
+ const target = forward.relocatedTo.legacyPath;
119
+ const published = await readPointerRecord(target);
120
+ if (published?.schemaVersion !== 1 || published.id !== forward.relocatedTo.id)
121
+ throw new AccountMigrationError(`the forwarding pointer ${accountStorePointerPath(legacyPath)} names generation ${forward.relocatedTo.id}, which ${accountStorePointerPath(target)} does not publish`);
122
+ return target;
123
+ }
124
+ /**
125
+ * Resolves the caller's anchor to the one that holds the store: follows a
126
+ * schema-2 forward one step to the anchor it names, after checking that
127
+ * anchor still publishes the forwarded generation. Reads only; it writes
128
+ * nothing at either anchor.
129
+ */
130
+ export async function resolveAccountStoreAnchor(legacyPath, options = {}) {
131
+ resolveAccountStorePaths(legacyPath);
132
+ const pointer = accountStorePointerPath(legacyPath);
133
+ for (const alternative of options.alternatives ?? []) {
134
+ if (sameFilesystemPath(alternative, legacyPath))
135
+ continue;
136
+ const other = accountStorePointerPath(alternative);
137
+ if ((await exists(pointer)) && (await exists(other)))
138
+ throw new AccountMigrationError(`account store pointers exist at both ${pointer} and ${other}; refusing to choose between them`);
139
+ }
140
+ const record = await readPointerRecord(legacyPath);
141
+ if (record?.schemaVersion !== 2)
142
+ return {
143
+ legacyPath: (await restoredRelocationAnchor(legacyPath, record)) ?? legacyPath,
144
+ };
145
+ const named = record.relocatedTo.legacyPath;
146
+ if (options.relocatedAnchor !== undefined &&
147
+ !sameFilesystemPath(options.relocatedAnchor, named))
148
+ throw new AccountMigrationError(`the forwarding pointer ${pointer} names ${named}, but this process keeps the account store at ${options.relocatedAnchor}`);
149
+ return {
150
+ legacyPath: await forwardTarget(legacyPath, record),
151
+ forwardedFrom: legacyPath,
152
+ };
70
153
  }
71
154
  export async function resolvePublishedAccountStorePaths(legacyPath) {
72
155
  const pointer = await readPointer(legacyPath);
@@ -354,7 +437,9 @@ export function decodeAccountStoreGeneration(value) {
354
437
  ], 'generation');
355
438
  if (raw.schemaVersion !== 1 ||
356
439
  typeof raw.legacyPath !== 'string' ||
357
- (raw.sourceKind !== 'file' && raw.sourceKind !== 'absent'))
440
+ (raw.sourceKind !== 'file' &&
441
+ raw.sourceKind !== 'absent' &&
442
+ raw.sourceKind !== 'generation'))
358
443
  throw new AccountMigrationError('generation schema is invalid');
359
444
  const id = text(raw.id, UUID, 'generation id');
360
445
  const paths = generationPaths(raw.legacyPath, id);
@@ -462,6 +547,7 @@ export function decodeAccountMigrationJournal(value) {
462
547
  'retiredSha256',
463
548
  'rollback',
464
549
  'cancelled',
550
+ 'relocation',
465
551
  ], 'journal');
466
552
  if (raw.schemaVersion !== 1)
467
553
  throw new AccountMigrationError('unsupported journal schema');
@@ -481,12 +567,16 @@ export function decodeAccountMigrationJournal(value) {
481
567
  if (phase === undefined)
482
568
  throw new AccountMigrationError('journal phase is invalid');
483
569
  const sourceKind = raw.sourceKind;
484
- if (sourceKind !== 'file' && sourceKind !== 'absent')
570
+ if (sourceKind !== 'file' &&
571
+ sourceKind !== 'absent' &&
572
+ sourceKind !== 'generation')
485
573
  throw new AccountMigrationError('journal source kind is invalid');
486
- const sourceSha256 = sourceKind === 'file'
574
+ // A moved store keeps the same two hashes as a pool-file source: its
575
+ // snapshot of the old generation's files and its capture file.
576
+ const sourceSha256 = sourceKind !== 'absent'
487
577
  ? text(raw.sourceSha256, SHA256, 'journal.sourceSha256')
488
578
  : undefined;
489
- const captureSha256 = sourceKind === 'file'
579
+ const captureSha256 = sourceKind !== 'absent'
490
580
  ? text(raw.captureSha256, SHA256, 'journal.captureSha256')
491
581
  : undefined;
492
582
  if (sourceKind === 'absent' && Object.hasOwn(raw, 'captureSha256'))
@@ -511,7 +601,7 @@ export function decodeAccountMigrationJournal(value) {
511
601
  sourceVersion !== 4)
512
602
  throw new AccountMigrationError('manifest source version is invalid');
513
603
  if ((sourceKind === 'file' && sourceVersion === null) ||
514
- (sourceKind === 'absent' && sourceVersion !== null))
604
+ (sourceKind !== 'file' && sourceVersion !== null))
515
605
  throw new AccountMigrationError('manifest version differs from source kind');
516
606
  if (!finite(m.normalizationClock) || !finite(m.effectiveActiveIndex))
517
607
  throw new AccountMigrationError('manifest clock or cursor is invalid');
@@ -549,6 +639,15 @@ export function decodeAccountMigrationJournal(value) {
549
639
  }) ||
550
640
  manifest.effectiveActiveIndex !== 0))
551
641
  throw new AccountMigrationError('absent-source manifest must be explicitly empty with no invented cursors');
642
+ // A move copies the store's own files; its journal carries no rows and so
643
+ // no refresh token. The moved rows are listed in `relocation.rows`.
644
+ if (sourceKind === 'generation' &&
645
+ (accounts.length ||
646
+ !isDeepStrictEqual(encodeRoutingSettings(manifest.routing), {
647
+ schemaVersion: 1,
648
+ }) ||
649
+ manifest.effectiveActiveIndex !== 0))
650
+ throw new AccountMigrationError('moved-store manifest must be explicitly empty');
552
651
  const effective = accounts.length
553
652
  ? Math.max(0, Math.min(manifest.routing.activeIndex ?? 0, accounts.length - 1))
554
653
  : 0;
@@ -606,6 +705,33 @@ export function decodeAccountMigrationJournal(value) {
606
705
  throw new AccountMigrationError('absent source cannot claim retired bytes');
607
706
  if (retiredSha256 !== undefined && retiredSha256 !== sourceSha256)
608
707
  throw new AccountMigrationError('retired bytes are not associated with source');
708
+ const relocation = decodeRelocation(raw, sourceKind, paths.legacyPath);
709
+ if (sourceKind === 'generation') {
710
+ // The moved store's retirement is the forward at the old anchor, not a
711
+ // retired pool-file copy or a checked absence.
712
+ if (retiredSha256 !== undefined)
713
+ throw new AccountMigrationError('moved source cannot claim retired pool-file bytes');
714
+ if (relocation === undefined)
715
+ throw new AccountMigrationError('moved source lacks its relocation evidence');
716
+ if (operation === 'rollback')
717
+ throw new AccountMigrationError('a moved account store is not rolled back; move it back first with `antigravity-auth store relocate --back --offline`');
718
+ if (relocation.sourceForwarded && !relocation.sourceTombstoned)
719
+ throw new AccountMigrationError('source forward precedes its management tombstone');
720
+ if ((relocation.sourceTombstoned || relocation.sourceForwarded) &&
721
+ (!verification || (phase !== 'retire' && phase !== 'activate')))
722
+ throw new AccountMigrationError('source tombstone or forward precedes durable verification');
723
+ if (status === 'active' && !relocation.sourceForwarded)
724
+ throw new AccountMigrationError('active moved store lacks its source forward');
725
+ if (phase === 'activate' && !relocation.sourceForwarded)
726
+ throw new AccountMigrationError('moved-store activation lacks its source forward');
727
+ }
728
+ // What retired the source: the retired pool-file copy, the checked
729
+ // absence of a pool file, or the forward that replaced the old pointer.
730
+ const retirementRecorded = sourceKind === 'file'
731
+ ? retiredSha256 !== undefined
732
+ : sourceKind === 'absent'
733
+ ? raw.sourceAbsenceVerified === true
734
+ : relocation?.sourceForwarded === true;
609
735
  let rollback;
610
736
  if (Object.hasOwn(raw, 'rollback')) {
611
737
  const r = exact(raw.rollback, ['id', 'snapshotSha256', 'exportSha256', 'rows', 'successorInactive'], 'rollback');
@@ -626,8 +752,12 @@ export function decodeAccountMigrationJournal(value) {
626
752
  const cancelled = raw.cancelled === true;
627
753
  if (cancelled &&
628
754
  (status === 'active' ||
629
- operation !== 'rollback' ||
630
- (status === 'inactive' && phase !== 'activate') ||
755
+ (sourceKind === 'generation'
756
+ ? operation !== 'migrate' ||
757
+ relocation?.sourceForwarded === true ||
758
+ phase === 'activate'
759
+ : operation !== 'rollback' ||
760
+ (status === 'inactive' && phase !== 'activate')) ||
631
761
  rollback !== undefined ||
632
762
  retiredSha256 !== undefined))
633
763
  throw new AccountMigrationError('invalid pre-retirement cancellation');
@@ -643,18 +773,12 @@ export function decodeAccountMigrationJournal(value) {
643
773
  (retiredSha256 !== undefined || raw.sourceAbsenceVerified === true) &&
644
774
  (!verification || (phase !== 'retire' && phase !== 'activate')))
645
775
  throw new AccountMigrationError('retirement association precedes durable verification');
646
- if (operation === 'migrate' &&
647
- phase === 'activate' &&
648
- (sourceKind === 'file'
649
- ? retiredSha256 === undefined
650
- : raw.sourceAbsenceVerified !== true))
776
+ if (operation === 'migrate' && phase === 'activate' && !retirementRecorded)
651
777
  throw new AccountMigrationError('activation lacks its actual source retirement association');
652
778
  if (status === 'active' &&
653
779
  (operation !== 'migrate' ||
654
780
  phase !== 'activate' ||
655
- (sourceKind === 'file'
656
- ? retiredSha256 === undefined
657
- : raw.sourceAbsenceVerified !== true) ||
781
+ !retirementRecorded ||
658
782
  rollback !== undefined ||
659
783
  cancelled))
660
784
  throw new AccountMigrationError('active receipt is incomplete');
@@ -662,10 +786,7 @@ export function decodeAccountMigrationJournal(value) {
662
786
  throw new AccountMigrationError('rollback lacks its current-credential snapshot');
663
787
  if (operation === 'rollback' &&
664
788
  !cancelled &&
665
- (verification === undefined ||
666
- (sourceKind === 'file'
667
- ? retiredSha256 === undefined
668
- : raw.sourceAbsenceVerified !== true)))
789
+ (verification === undefined || !retirementRecorded))
669
790
  throw new AccountMigrationError('rollback lacks the original verified retirement');
670
791
  if (status === 'inactive' &&
671
792
  !cancelled &&
@@ -697,6 +818,74 @@ export function decodeAccountMigrationJournal(value) {
697
818
  ...(retiredSha256 === undefined ? {} : { retiredSha256 }),
698
819
  ...(rollback === undefined ? {} : { rollback }),
699
820
  ...(cancelled ? { cancelled: true } : {}),
821
+ ...(relocation === undefined ? {} : { relocation }),
822
+ };
823
+ }
824
+ /** Decodes `relocation`, which only a moved store's journal may carry. */
825
+ function decodeRelocation(raw, sourceKind, legacyPath) {
826
+ if (!Object.hasOwn(raw, 'relocation'))
827
+ return undefined;
828
+ if (sourceKind !== 'generation')
829
+ throw new AccountMigrationError('only a moved store records relocation evidence');
830
+ const r = exact(raw.relocation, [
831
+ 'source',
832
+ 'rows',
833
+ 'sourceTombstoned',
834
+ 'sourceForwarded',
835
+ 'reversed',
836
+ 'reverseSha256',
837
+ ], 'relocation');
838
+ const s = exact(r.source, [
839
+ 'legacyPath',
840
+ 'storeDir',
841
+ 'journalId',
842
+ 'configSha256',
843
+ 'stateSha256',
844
+ 'statePresent',
845
+ ], 'relocation source');
846
+ if (typeof s.legacyPath !== 'string')
847
+ throw new AccountMigrationError('relocation source path is invalid');
848
+ const journalId = text(s.journalId, UUID, 'relocation source journal');
849
+ const source = generationPaths(s.legacyPath, journalId);
850
+ if (s.storeDir !== source.storeDir)
851
+ throw new AccountMigrationError('relocation source association differs');
852
+ if (sameFilesystemPath(source.legacyPath, legacyPath))
853
+ throw new AccountMigrationError('relocation source and target share one anchor');
854
+ if (typeof s.statePresent !== 'boolean')
855
+ throw new AccountMigrationError('relocation state presence is invalid');
856
+ if (!s.statePresent && Object.hasOwn(s, 'stateSha256'))
857
+ throw new AccountMigrationError('absent source state cannot claim a file hash');
858
+ const rows = list(r.rows, 'relocation rows').map((id) => text(id, UUID, 'relocation row id'));
859
+ if (new Set(rows).size !== rows.length)
860
+ throw new AccountMigrationError('relocation rows are duplicated');
861
+ if (typeof r.sourceTombstoned !== 'boolean' ||
862
+ typeof r.sourceForwarded !== 'boolean')
863
+ throw new AccountMigrationError('relocation progress flags are invalid');
864
+ if (Object.hasOwn(r, 'reversed') &&
865
+ (r.reversed !== true || raw.status !== 'active' || !r.sourceForwarded))
866
+ throw new AccountMigrationError('reverse lacks an active forwarded generation');
867
+ if (Object.hasOwn(r, 'reverseSha256') !== (r.reversed === true))
868
+ throw new AccountMigrationError('reverse snapshot association is incomplete');
869
+ return {
870
+ source: {
871
+ legacyPath: source.legacyPath,
872
+ storeDir: source.storeDir,
873
+ journalId,
874
+ configSha256: text(s.configSha256, SHA256, 'relocation config hash'),
875
+ ...(s.statePresent
876
+ ? { stateSha256: text(s.stateSha256, SHA256, 'relocation state hash') }
877
+ : {}),
878
+ statePresent: s.statePresent,
879
+ },
880
+ rows,
881
+ sourceTombstoned: r.sourceTombstoned,
882
+ sourceForwarded: r.sourceForwarded,
883
+ ...(r.reversed === true
884
+ ? {
885
+ reversed: true,
886
+ reverseSha256: text(r.reverseSha256, SHA256, 'reverse snapshot hash'),
887
+ }
888
+ : {}),
700
889
  };
701
890
  }
702
891
  function pendingLockFailure(modules, error) {
@@ -744,7 +933,10 @@ function privateMode(mode, expected) {
744
933
  }
745
934
  async function privateDirectory(path) {
746
935
  try {
747
- await mkdir(path, { mode: 0o700 });
936
+ if (windowsPrivateFiles)
937
+ await createPrivateDirectoryExclusive(path);
938
+ else
939
+ await mkdir(path, { mode: 0o700 });
748
940
  }
749
941
  catch (error) {
750
942
  if (errno(error) !== 'EEXIST')
@@ -754,6 +946,8 @@ async function privateDirectory(path) {
754
946
  if (!stat.isDirectory() || stat.isSymbolicLink())
755
947
  throw new AccountMigrationError('successor directory is not a regular directory');
756
948
  privateMode(stat.mode, 0o700);
949
+ if (windowsPrivateFiles)
950
+ await verifySafeDirectory(path);
757
951
  if (!sameFilesystemPath(await realpath(path), path))
758
952
  throw new AccountMigrationError('successor directory traverses a symbolic link');
759
953
  }
@@ -836,7 +1030,14 @@ async function syncDirectory(path, boundary) {
836
1030
  async function exclusiveBytes(path, bytes, expectedHash, boundary, pointerStage = false) {
837
1031
  let handle;
838
1032
  try {
839
- handle = await open(path, 'wx', 0o600);
1033
+ if (windowsPrivateFiles) {
1034
+ // The backend accepts Uint8Array, although the embedded declaration only
1035
+ // lists strings. Preserve native JSON bytes without a UTF-8 round trip.
1036
+ const createBytes = createPrivateBytes;
1037
+ await createBytes(path, bytes);
1038
+ }
1039
+ else
1040
+ handle = await open(path, 'wx', 0o600);
840
1041
  }
841
1042
  catch (error) {
842
1043
  if (errno(error) !== 'EEXIST')
@@ -856,8 +1057,9 @@ async function exclusiveBytes(path, bytes, expectedHash, boundary, pointerStage
856
1057
  const reopened = await secureRead(path);
857
1058
  if (digest(reopened) !== expectedHash || !reopened.equals(bytes))
858
1059
  throw new AccountMigrationError('exclusive backup or recovery copy differs');
859
- // A new pointer stage was synced through its write handle. Reused stages and
860
- // all backup, bootstrap, retired and export copies retain the reopened barrier.
1060
+ // A new POSIX pointer stage was synced through its write handle. A stage the
1061
+ // Windows private writer created has no handle here, so it is synced like
1062
+ // reused stages and every backup, bootstrap, retired and export copy.
861
1063
  if (handle === undefined || !pointerStage)
862
1064
  await syncFile(path);
863
1065
  await syncDirectory(dirname(path), boundary);
@@ -1046,6 +1248,8 @@ async function verifyCopies(paths, journal, retired) {
1046
1248
  if (digest(capture) !== journal.captureSha256 ||
1047
1249
  !isDeepStrictEqual(jsonBytes(capture), captureJson(journal)))
1048
1250
  throw new AccountMigrationError('journal differs from its exclusive capture snapshot');
1251
+ if (journal.sourceKind === 'generation')
1252
+ return verifyRelocationSnapshot(backup, journal);
1049
1253
  // Original backup JSON and the captured time reproduce the expected import.
1050
1254
  // Compare that value with the journal without changing its selected rows, clock,
1051
1255
  // source positions or UUID mapping.
@@ -1068,8 +1272,67 @@ function captureJson(journal) {
1068
1272
  sourceSha256: journal.sourceSha256,
1069
1273
  manifest: manifestJson(journal.manifest),
1070
1274
  mapping: journal.mapping,
1275
+ ...(journal.relocation === undefined
1276
+ ? {}
1277
+ : { relocation: { source: journal.relocation.source } }),
1278
+ };
1279
+ }
1280
+ function base64Bytes(value, path) {
1281
+ if (typeof value !== 'string')
1282
+ throw new AccountMigrationError(`${path} is invalid`);
1283
+ const bytes = Buffer.from(value, 'base64');
1284
+ // Only canonical base64 round-trips; anything else could hide extra bytes.
1285
+ if (bytes.toString('base64') !== value)
1286
+ throw new AccountMigrationError(`${path} is not canonical base64`);
1287
+ return bytes;
1288
+ }
1289
+ export function decodeRelocationSnapshot(value) {
1290
+ const raw = exact(value, ['config', 'state', 'journal'], 'moved snapshot');
1291
+ return {
1292
+ config: base64Bytes(raw.config, 'moved snapshot config'),
1293
+ ...(Object.hasOwn(raw, 'state')
1294
+ ? { state: base64Bytes(raw.state, 'moved snapshot state') }
1295
+ : {}),
1296
+ journal: base64Bytes(raw.journal, 'moved snapshot journal'),
1071
1297
  };
1072
1298
  }
1299
+ /**
1300
+ * Ties a moved store's source snapshot to its journal: the copied files hash
1301
+ * to `relocation.source`, the copied journal is the active source generation
1302
+ * it names, and the copied config carries that generation's own settings
1303
+ * key. The live old generation is not compared: it gains a management
1304
+ * tombstone after the copy, and it is no longer served.
1305
+ *
1306
+ * Binding a copy at another anchor is sound because the store's files hold
1307
+ * no location. The common-auth "Store portability" contract (common-auth
1308
+ * `docs/adoption-inventory.md` at 672f6265, pinned by its
1309
+ * `test/store/store-portability.test.ts`) states that `config.json` and
1310
+ * `state.json` record no path, folder or file identity, or host identity,
1311
+ * and that a byte copy of both taken under the store's save locks opens
1312
+ * elsewhere as the same pool. Lock and sidecar files are not part of that
1313
+ * contract, and neither is any in-memory state kept by the process that
1314
+ * opened the old files; nothing here relies on either.
1315
+ */
1316
+ function verifyRelocationSnapshot(backup, journal) {
1317
+ const source = journal.relocation?.source;
1318
+ if (source === undefined)
1319
+ throw new AccountMigrationError('moved source lacks its relocation evidence');
1320
+ const snapshot = decodeRelocationSnapshot(jsonBytes(backup));
1321
+ if (digest(snapshot.config) !== source.configSha256 ||
1322
+ (snapshot.state !== undefined) !== source.statePresent ||
1323
+ (snapshot.state !== undefined &&
1324
+ digest(snapshot.state) !== source.stateSha256))
1325
+ throw new AccountMigrationError('moved source snapshot differs from its recorded hashes');
1326
+ const copied = decodeAccountMigrationJournal(jsonBytes(snapshot.journal));
1327
+ if (copied.id !== source.journalId ||
1328
+ copied.legacyPath !== source.legacyPath ||
1329
+ copied.storeDir !== source.storeDir ||
1330
+ copied.status !== 'active')
1331
+ throw new AccountMigrationError('moved source snapshot is not the active source generation');
1332
+ const config = object(jsonBytes(snapshot.config), 'moved source config');
1333
+ if (!isDeepStrictEqual(decodeAccountStoreGeneration(config[ACCOUNT_STORE_GENERATION_SETTINGS_KEY]), generationJson(copied)))
1334
+ throw new AccountMigrationError('moved source snapshot belongs to another generation');
1335
+ }
1073
1336
  async function synchronizedSnapshot(paths, boundary) {
1074
1337
  await syncFile(paths.configPath);
1075
1338
  const statePresent = await exists(paths.statePath);
@@ -1118,7 +1381,9 @@ async function assertStateOwnership(paths, journal, complete) {
1118
1381
  throw new AccountMigrationError('successor state contains unrelated or missing credentials');
1119
1382
  }
1120
1383
  async function management(store, journal, clear = false) {
1121
- const targets = journal.rollback?.rows ?? journal.mapping.map((row) => row.id);
1384
+ const targets = journal.relocation?.rows ??
1385
+ journal.rollback?.rows ??
1386
+ journal.mapping.map((row) => row.id);
1122
1387
  await store.updateSettings((settings) => {
1123
1388
  const prior = settings[MANAGEMENT_SETTINGS_KEY];
1124
1389
  if (prior !== undefined) {
@@ -1153,7 +1418,7 @@ function receipt(journal) {
1153
1418
  storeDir: journal.storeDir,
1154
1419
  sourceKind: journal.sourceKind,
1155
1420
  durability: journal.verification?.durability ?? accountMigrationDurability(),
1156
- restartRequired: journal.sourceKind === 'file' || journal.operation === 'rollback',
1421
+ restartRequired: journal.sourceKind !== 'absent' || journal.operation === 'rollback',
1157
1422
  };
1158
1423
  }
1159
1424
  async function currentSuccessor(store, paths, journal) {
@@ -1246,40 +1511,18 @@ async function trustedCurrentGeneration(store, paths, journal) {
1246
1511
  /** A bound result establishes completed-generation ownership for repository recovery, not readiness to select or route credentials. */
1247
1512
  export async function readAccountStoreBinding(legacyPath, modules, now, expectedGeneration) {
1248
1513
  try {
1249
- const paths = await resolvePublishedAccountStorePaths(legacyPath);
1250
- if (!paths)
1251
- return (await exists(legacyPath))
1252
- ? { status: 'pending' }
1253
- : { status: 'initialization-required' };
1254
- const journal = await readJournal(paths);
1255
- if (!journal)
1256
- throw new AccountMigrationError('published binding journal missing');
1257
- if (expectedGeneration !== undefined && journal.id !== expectedGeneration)
1258
- throw new AccountMigrationError('bound repository generation differs from pointer');
1259
- if (journal.status === 'pending')
1260
- return {
1261
- status: 'pending',
1262
- phase: journal.phase,
1263
- operation: journal.operation,
1264
- };
1265
- if (journal.status === 'inactive')
1266
- return { status: 'inactive', id: journal.id };
1267
- if (await exists(legacyPath))
1268
- throw new AccountMigrationError('legacy source was recreated after activation');
1269
- await verifyCopies(paths, journal, true);
1270
- const settings = await trustedCurrentGeneration(openAdmissionStore(modules, paths, now), paths, journal);
1271
- const pending = settings[MANAGEMENT_SETTINGS_KEY];
1272
- if (pending !== undefined) {
1273
- const record = object(pending, 'repository management');
1274
- if (record.kind === 'migration')
1275
- return { status: 'pending', phase: 'activate', operation: 'migrate' };
1276
- if (record.kind !== 'clear' && record.kind !== 'replace-pool')
1277
- throw new AccountMigrationError('unknown management owner prevents binding');
1278
- // Ownership permits opening these physical paths. The repository must then
1279
- // validate and resume its own strict clear/replace journal; this binding
1280
- // neither validates that operation nor grants credential-serving readiness.
1514
+ // A forward at this anchor leads one step to the anchor the store moved
1515
+ // to; the bound paths are then the moved generation's own.
1516
+ const record = await readPointerRecord(legacyPath);
1517
+ const restored = await restoredRelocationAnchor(legacyPath, record);
1518
+ if (restored !== undefined)
1519
+ return await bindPublishedGeneration(restored, modules, now, expectedGeneration);
1520
+ if (record?.schemaVersion === 2) {
1521
+ if (await exists(legacyPath))
1522
+ throw new AccountMigrationError(`a pool file reappeared at ${legacyPath} after the account store moved to ${record.relocatedTo.legacyPath}`);
1523
+ return await bindPublishedGeneration(await forwardTarget(legacyPath, record), modules, now, expectedGeneration, legacyPath);
1281
1524
  }
1282
- return { status: 'bound', receipt: receipt(journal), paths };
1525
+ return await bindPublishedGeneration(legacyPath, modules, now, expectedGeneration);
1283
1526
  }
1284
1527
  catch (error) {
1285
1528
  return {
@@ -1290,6 +1533,84 @@ export async function readAccountStoreBinding(legacyPath, modules, now, expected
1290
1533
  };
1291
1534
  }
1292
1535
  }
1536
+ /**
1537
+ * A moved generation binds only while the old anchor still forwards to
1538
+ * exactly it. A pointer set back to schema 1 there, a missing forward, or a
1539
+ * forward to another generation refuses: either store could then be the
1540
+ * one a build writes to, and neither is chosen by age.
1541
+ */
1542
+ async function assertSourceForwarded(journal, forwardedFrom) {
1543
+ const source = journal.relocation?.source;
1544
+ if (source === undefined)
1545
+ throw new AccountMigrationError('moved source lacks its relocation evidence');
1546
+ const pointer = accountStorePointerPath(source.legacyPath);
1547
+ if (forwardedFrom !== undefined && forwardedFrom !== source.legacyPath)
1548
+ throw new AccountMigrationError(`the forwarding pointer ${accountStorePointerPath(forwardedFrom)} leads to a store moved from ${source.legacyPath}`);
1549
+ if (await exists(source.legacyPath))
1550
+ throw new AccountMigrationError(`a pool file reappeared at ${source.legacyPath} after the account store moved to ${journal.legacyPath}`);
1551
+ const record = await readPointerRecord(source.legacyPath);
1552
+ if (record === undefined)
1553
+ throw new AccountMigrationError(`the forwarding pointer ${pointer} to the moved account store at ${journal.storeDir} is missing; restore it`);
1554
+ if (record.schemaVersion === 1)
1555
+ throw new AccountMigrationError(`the account store in ${dirname(source.legacyPath)} was reactivated: ${pointer} publishes generation ${record.id} while the moved store at ${journal.storeDir} is active; restore the forwarding pointer`);
1556
+ if (record.id !== source.journalId ||
1557
+ record.relocatedTo.legacyPath !== journal.legacyPath ||
1558
+ record.relocatedTo.id !== journal.id)
1559
+ throw new AccountMigrationError(`the forwarding pointer ${pointer} names another store than the moved account store at ${journal.storeDir}`);
1560
+ }
1561
+ async function bindPublishedGeneration(legacyPath, modules, now, expectedGeneration, forwardedFrom) {
1562
+ const paths = await resolvePublishedAccountStorePaths(legacyPath);
1563
+ if (!paths) {
1564
+ if (forwardedFrom !== undefined)
1565
+ throw new AccountMigrationError('forwarded anchor publishes no generation');
1566
+ return (await exists(legacyPath))
1567
+ ? { status: 'pending' }
1568
+ : { status: 'initialization-required' };
1569
+ }
1570
+ const journal = await readJournal(paths);
1571
+ if (!journal)
1572
+ throw new AccountMigrationError('published binding journal missing');
1573
+ if (expectedGeneration !== undefined && journal.id !== expectedGeneration)
1574
+ throw new AccountMigrationError('bound repository generation differs from pointer');
1575
+ if (forwardedFrom !== undefined && journal.sourceKind !== 'generation')
1576
+ throw new AccountMigrationError(`the forwarding pointer ${accountStorePointerPath(forwardedFrom)} leads to a store that was not moved there`);
1577
+ // An unfinished move is finished by the build that started it; this build
1578
+ // neither resumes it nor serves either store meanwhile.
1579
+ if (journal.sourceKind === 'generation' && journal.status === 'pending')
1580
+ throw new AccountMigrationError(`a move of the account store to ${journal.storeDir} is unfinished; finish it with the version that started it`);
1581
+ if (journal.status === 'pending')
1582
+ return {
1583
+ status: 'pending',
1584
+ phase: journal.phase,
1585
+ operation: journal.operation,
1586
+ };
1587
+ if (journal.status === 'inactive')
1588
+ return { status: 'inactive', id: journal.id };
1589
+ if (await exists(legacyPath))
1590
+ throw new AccountMigrationError('legacy source was recreated after activation');
1591
+ await verifyCopies(paths, journal, true);
1592
+ if (journal.sourceKind === 'generation')
1593
+ await assertSourceForwarded(journal, forwardedFrom);
1594
+ const settings = await trustedCurrentGeneration(openAdmissionStore(modules, paths, now), paths, journal);
1595
+ const pending = settings[MANAGEMENT_SETTINGS_KEY];
1596
+ if (pending !== undefined) {
1597
+ const record = object(pending, 'repository management');
1598
+ // Only a store move writes a migration record owned by another
1599
+ // generation: it blocks the old generation once the copy is taken.
1600
+ // Reaching it here means this anchor's pointer was set back from the
1601
+ // forward to the old generation.
1602
+ if (record.kind === 'migration' && record.id !== journal.id)
1603
+ throw new AccountMigrationError(`the account store at ${paths.storeDir} was moved to generation ${String(record.id)}, but ${accountStorePointerPath(legacyPath)} publishes the old store again; restore the forwarding pointer`);
1604
+ if (record.kind === 'migration')
1605
+ return { status: 'pending', phase: 'activate', operation: 'migrate' };
1606
+ if (record.kind !== 'clear' && record.kind !== 'replace-pool')
1607
+ throw new AccountMigrationError('unknown management owner prevents binding');
1608
+ // Ownership permits opening these physical paths. The repository must then
1609
+ // validate and resume its own strict clear/replace journal; this binding
1610
+ // neither validates that operation nor grants credential-serving readiness.
1611
+ }
1612
+ return { status: 'bound', receipt: receipt(journal), paths };
1613
+ }
1293
1614
  /** Serving requires healthy current rows and no repository management work. Binding alone is insufficient. */
1294
1615
  export async function readAccountStoreAdmission(legacyPath, modules, now, expectedGeneration) {
1295
1616
  const binding = await readAccountStoreBinding(legacyPath, modules, now, expectedGeneration);
@@ -1623,6 +1944,8 @@ export function createAccountMigrationFactory(modules) {
1623
1944
  const existing = await readJournal(paths);
1624
1945
  if (!existing)
1625
1946
  throw new AccountMigrationError('published generation journal missing');
1947
+ if (existing.sourceKind === 'generation')
1948
+ throw new AccountMigrationError('a moved account store is finished by `antigravity-auth store relocate`, not by migrate');
1626
1949
  if (existing.status === 'inactive') {
1627
1950
  previous = existing;
1628
1951
  await modules.fs.withLock(paths.configPath, {
@@ -1724,6 +2047,695 @@ export function createAccountMigrationFactory(modules) {
1724
2047
  }
1725
2048
  };
1726
2049
  }
2050
+ function relocationOf(journal) {
2051
+ if (!journal.relocation)
2052
+ throw new AccountMigrationError('relocation evidence missing');
2053
+ return journal.relocation;
2054
+ }
2055
+ function withGenerationLease(modules, path, name, body) {
2056
+ return modules.fs.withLock(path, { name, ttlMs: 10_000, timeoutMs: 2_000, renew: true }, body);
2057
+ }
2058
+ function withSaveLocks(modules, paths, body) {
2059
+ return withGenerationLease(modules, paths.configPath, 'save', async (configLease) => withGenerationLease(modules, paths.statePath, 'save', async (stateLease) => {
2060
+ const result = await body([configLease, stateLease]);
2061
+ await configLease.assertOwned();
2062
+ await stateLease.assertOwned();
2063
+ return result;
2064
+ }));
2065
+ }
2066
+ async function relocationSnapshot(paths, journal) {
2067
+ await verifyCopies(paths, journal, false);
2068
+ return decodeRelocationSnapshot(jsonBytes(await secureRead(join(paths.backupsDir, `${journal.sourceSha256}.json`))));
2069
+ }
2070
+ function withoutManagement(value) {
2071
+ const config = object(jsonBytes(value), 'source config');
2072
+ delete config[MANAGEMENT_SETTINGS_KEY];
2073
+ return config;
2074
+ }
2075
+ async function sourceMatches(modules, source, snapshot, journal) {
2076
+ return withSaveLocks(modules, source, async () => {
2077
+ const current = await synchronizedSnapshot(source);
2078
+ const config = object(jsonBytes(current.config), 'source config');
2079
+ const pending = config[MANAGEMENT_SETTINGS_KEY];
2080
+ if (pending !== undefined &&
2081
+ !isDeepStrictEqual(pending, sourceTombstone(journal)))
2082
+ throw new AccountMigrationError('source management record belongs to another operation');
2083
+ return (isDeepStrictEqual(withoutManagement(current.config), withoutManagement(snapshot.config)) &&
2084
+ (current.state === undefined
2085
+ ? snapshot.state === undefined
2086
+ : snapshot.state !== undefined && current.state.equals(snapshot.state)));
2087
+ });
2088
+ }
2089
+ function sourceTombstone(journal) {
2090
+ return {
2091
+ id: journal.id,
2092
+ kind: 'migration',
2093
+ targets: relocationOf(journal).rows,
2094
+ progress: { step: 'activate', completedTargets: [] },
2095
+ };
2096
+ }
2097
+ async function captureRelocation(modules, source, paths, lease, options) {
2098
+ const captured = await withSaveLocks(modules, source, async () => {
2099
+ const files = await synchronizedSnapshot(source, options.onBoundary);
2100
+ return { ...files, journal: await secureRead(source.migrationPath) };
2101
+ });
2102
+ const sourceJournal = decodeAccountMigrationJournal(jsonBytes(captured.journal));
2103
+ if (sourceJournal.status !== 'active')
2104
+ throw new AccountMigrationError('relocation requires an active source');
2105
+ const config = object(jsonBytes(captured.config), 'source config');
2106
+ if (config[MANAGEMENT_SETTINGS_KEY] !== undefined)
2107
+ throw new AccountMigrationError('source management is pending');
2108
+ const sourceStore = openAdmissionStore(modules, source, options.now);
2109
+ const rows = await rowsOf(sourceStore);
2110
+ const bytes = Buffer.from(JSON.stringify({
2111
+ config: captured.config.toString('base64'),
2112
+ ...(captured.state === undefined
2113
+ ? {}
2114
+ : { state: captured.state.toString('base64') }),
2115
+ journal: captured.journal.toString('base64'),
2116
+ }));
2117
+ const journal = {
2118
+ schemaVersion: 1,
2119
+ id: pathGenerationId(paths),
2120
+ legacyPath: paths.legacyPath,
2121
+ storeDir: paths.storeDir,
2122
+ status: 'pending',
2123
+ operation: 'migrate',
2124
+ phase: 'capture',
2125
+ sourceKind: 'generation',
2126
+ sourceSha256: digest(bytes),
2127
+ manifest: {
2128
+ sourceVersion: null,
2129
+ normalizationClock: options.now(),
2130
+ accounts: [],
2131
+ routing: { schemaVersion: 1 },
2132
+ effectiveActiveIndex: 0,
2133
+ },
2134
+ mapping: [],
2135
+ completedRows: [],
2136
+ relocation: {
2137
+ source: {
2138
+ legacyPath: source.legacyPath,
2139
+ storeDir: source.storeDir,
2140
+ journalId: sourceJournal.id,
2141
+ configSha256: digest(captured.config),
2142
+ statePresent: captured.state !== undefined,
2143
+ ...(captured.state === undefined
2144
+ ? {}
2145
+ : { stateSha256: digest(captured.state) }),
2146
+ },
2147
+ rows: rows.map((row) => row.id),
2148
+ sourceTombstoned: false,
2149
+ sourceForwarded: false,
2150
+ },
2151
+ };
2152
+ await exclusiveBytes(join(paths.backupsDir, `${journal.sourceSha256}.json`), bytes, digest(bytes), options.onBoundary);
2153
+ await options.onBoundary?.('relocate:capture:after-backup');
2154
+ const capture = Buffer.from(`${JSON.stringify(captureJson(journal), null, 2)}\n`);
2155
+ journal.captureSha256 = digest(capture);
2156
+ await exclusiveBytes(join(paths.transfersDir, `${journal.id}.capture.json`), capture, digest(capture), options.onBoundary);
2157
+ await saveJournal(modules, paths, lease, journal, true, options.onBoundary);
2158
+ return journal;
2159
+ }
2160
+ async function buildRelocation(store, paths, snapshot, journal, options) {
2161
+ const target = generationJson(journal);
2162
+ const buildManagement = {
2163
+ id: journal.id,
2164
+ kind: 'migration',
2165
+ targets: relocationOf(journal).rows,
2166
+ progress: { step: 'build' },
2167
+ };
2168
+ const source = object(jsonBytes(snapshot.config), 'snapshot config')[ACCOUNT_STORE_GENERATION_SETTINGS_KEY];
2169
+ // A crash after updateSettings leaves a rewritten config, not the original clone.
2170
+ // Accept it only after proving its entire native config differs solely in our two keys.
2171
+ let rewritten = false;
2172
+ if (await exists(paths.configPath)) {
2173
+ const current = object(jsonBytes(await secureRead(paths.configPath)), 'build config');
2174
+ rewritten = isDeepStrictEqual(current[ACCOUNT_STORE_GENERATION_SETTINGS_KEY], target);
2175
+ if (rewritten) {
2176
+ const expected = object(jsonBytes(snapshot.config), 'snapshot config');
2177
+ expected[ACCOUNT_STORE_GENERATION_SETTINGS_KEY] = target;
2178
+ expected[MANAGEMENT_SETTINGS_KEY] = buildManagement;
2179
+ if (!isDeepStrictEqual(current, expected))
2180
+ throw new AccountMigrationError('partial clone config differs from snapshot');
2181
+ }
2182
+ }
2183
+ if (snapshot.state !== undefined)
2184
+ await exclusiveBytes(paths.statePath, snapshot.state, digest(snapshot.state), options.onBoundary);
2185
+ await options.onBoundary?.('relocate:build:after-state');
2186
+ if (!rewritten)
2187
+ await exclusiveBytes(paths.configPath, snapshot.config, digest(snapshot.config), options.onBoundary);
2188
+ await options.onBoundary?.('relocate:build:after-config');
2189
+ await store.updateSettings((settings) => {
2190
+ const prior = settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY];
2191
+ if (!isDeepStrictEqual(prior, source) && !isDeepStrictEqual(prior, target))
2192
+ throw new AccountMigrationError('clone generation differs from source and target');
2193
+ const management = settings[MANAGEMENT_SETTINGS_KEY];
2194
+ if (management !== undefined &&
2195
+ !isDeepStrictEqual(management, buildManagement))
2196
+ throw new AccountMigrationError('clone management belongs to another operation');
2197
+ settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY] = target;
2198
+ settings[MANAGEMENT_SETTINGS_KEY] = buildManagement;
2199
+ return settings;
2200
+ });
2201
+ }
2202
+ async function cancelRelocation(modules, sourceStore, paths, lease, journal, options) {
2203
+ const relocation = relocationOf(journal);
2204
+ const pointer = await readPointerRecord(relocation.source.legacyPath);
2205
+ if (relocation.sourceForwarded ||
2206
+ pointer?.schemaVersion !== 1 ||
2207
+ pointer.id !== relocation.source.journalId)
2208
+ throw new AccountMigrationError('nothing cancels after the source forward');
2209
+ await sourceStore.updateSettings((settings) => {
2210
+ const prior = settings[MANAGEMENT_SETTINGS_KEY];
2211
+ if (prior !== undefined) {
2212
+ if (!isDeepStrictEqual(prior, sourceTombstone(journal)))
2213
+ throw new AccountMigrationError("cancellation cannot remove another operation's tombstone");
2214
+ delete settings[MANAGEMENT_SETTINGS_KEY];
2215
+ }
2216
+ return settings;
2217
+ });
2218
+ relocation.sourceTombstoned = false;
2219
+ journal.status = 'inactive';
2220
+ journal.cancelled = true;
2221
+ await saveJournal(modules, paths, lease, journal, false, options.onBoundary);
2222
+ return { status: 'cancelled', id: journal.id };
2223
+ }
2224
+ async function replaceRelocationPointer(modules, anchor, prior, next, id, leases, boundary, prefix = 'relocate:forward') {
2225
+ const path = accountStorePointerPath(anchor);
2226
+ const stage = `${path}.${id}.tmp`;
2227
+ const assertOwned = async () => {
2228
+ for (const lease of leases)
2229
+ await lease.assertOwned();
2230
+ };
2231
+ if (await exists(stage)) {
2232
+ if (!isDeepStrictEqual(jsonBytes(await secureRead(stage)), next))
2233
+ throw new AccountMigrationError('foreign relocation pointer stage');
2234
+ await assertOwned();
2235
+ await unlink(stage);
2236
+ }
2237
+ const current = await readPointerRecord(anchor);
2238
+ if (!isDeepStrictEqual(current, next)) {
2239
+ if (!isDeepStrictEqual(current, prior))
2240
+ throw new AccountMigrationError('source pointer changed before relocation');
2241
+ await modules.fs.writeJsonAtomic(path, next, {
2242
+ stageName: () => id,
2243
+ beforeRename: async () => {
2244
+ await assertOwned();
2245
+ if (!isDeepStrictEqual(await readPointerRecord(anchor), prior))
2246
+ throw new AccountMigrationError('source pointer changed before rename');
2247
+ await syncFile(stage);
2248
+ await boundary?.(`${prefix}:before-rename`);
2249
+ },
2250
+ });
2251
+ await boundary?.(`${prefix}:after-rename-before-sync`);
2252
+ }
2253
+ // Also finish these barriers when recovering a rename that preceded a crash.
2254
+ await syncFile(path);
2255
+ await syncDirectory(dirname(anchor));
2256
+ if (!isDeepStrictEqual(await readPointerRecord(anchor), next))
2257
+ throw new AccountMigrationError('relocation pointer readback differs');
2258
+ await assertOwned();
2259
+ }
2260
+ /** Offline relocation only. The host must prevent forward and reverse relocations from running concurrently. */
2261
+ export function createAccountRelocationFactory(modules) {
2262
+ return async (options) => {
2263
+ if (options.offline?.processesStopped !== true)
2264
+ throw new AccountMigrationError('stopped processes and timers must be explicitly confirmed');
2265
+ resolveAccountStorePaths(options.legacyPath);
2266
+ resolveAccountStorePaths(options.targetPath);
2267
+ if (sameFilesystemPath(options.legacyPath, options.targetPath))
2268
+ throw new AccountMigrationError('relocation requires distinct anchors');
2269
+ try {
2270
+ return await withParentTopology(modules, options.legacyPath, (sourceParent) => withParentTopology(modules, options.targetPath, async (targetParent) => {
2271
+ if (options.back)
2272
+ return reverseRelocation(modules, options, sourceParent, targetParent);
2273
+ const sourceRecord = await readPointerRecord(options.legacyPath);
2274
+ if (!sourceRecord)
2275
+ throw new AccountMigrationError('relocation source pointer missing');
2276
+ if (sourceRecord.schemaVersion === 1)
2277
+ await finishPointerPublication(options.legacyPath, sourceRecord, sourceParent);
2278
+ const source = generationPaths(options.legacyPath, sourceRecord.id);
2279
+ if ((await exists(source.legacyPath)) ||
2280
+ (await exists(options.targetPath)))
2281
+ throw new AccountMigrationError('relocation refuses a reappeared pool file');
2282
+ return withGenerationLease(modules, source.configPath, MANAGEMENT_LOCK_NAME, async (sourceLease) => withGenerationLease(modules, await realpath(source.configPath), 'antigravity-topology', async (topologyLease) => {
2283
+ const sourceJournal = await readJournal(source);
2284
+ if (!sourceJournal)
2285
+ throw new AccountMigrationError('relocation source journal missing');
2286
+ const prior = await readPointer(options.targetPath);
2287
+ if (prior)
2288
+ await finishPointerPublication(options.targetPath, prior, targetParent);
2289
+ let paths = prior
2290
+ ? generationPaths(options.targetPath, prior.id)
2291
+ : undefined;
2292
+ let existing = paths
2293
+ ? await readJournal(paths)
2294
+ : undefined;
2295
+ if (prior &&
2296
+ (existing?.sourceKind !== 'generation' ||
2297
+ relocationOf(existing).source.legacyPath !==
2298
+ source.legacyPath ||
2299
+ relocationOf(existing).source.journalId !==
2300
+ sourceJournal.id))
2301
+ throw new AccountMigrationError('destination belongs to another source');
2302
+ if (sourceJournal.status !== 'active' &&
2303
+ !(existing &&
2304
+ sourceJournal.operation === 'rollback' &&
2305
+ isDeepStrictEqual((await settingsOf(openAdmissionStore(modules, source, options.now)))[MANAGEMENT_SETTINGS_KEY], sourceTombstone(existing))))
2306
+ throw new AccountMigrationError('relocation requires an active source journal or its own tombstone');
2307
+ if (existing?.relocation?.reversed &&
2308
+ (await restoredRelocationAnchor(options.targetPath, prior)) !== source.legacyPath)
2309
+ throw new AccountMigrationError('finish the reverse relocation before moving forward again');
2310
+ if (existing?.status === 'active' &&
2311
+ !existing.relocation?.reversed)
2312
+ await assertSourceForwarded(existing, undefined);
2313
+ if ((existing?.cancelled &&
2314
+ existing.status === 'inactive') ||
2315
+ existing?.relocation?.reversed) {
2316
+ paths = undefined;
2317
+ existing = undefined;
2318
+ }
2319
+ if (!paths) {
2320
+ if (sourceRecord.schemaVersion !== 1)
2321
+ throw new AccountMigrationError('forwarded source has no resumable destination');
2322
+ await currentSuccessor(openAdmissionStore(modules, source, options.now), source, sourceJournal);
2323
+ await verifyCopies(source, sourceJournal, true);
2324
+ if (sourceJournal.sourceKind === 'generation')
2325
+ await assertSourceForwarded(sourceJournal, undefined);
2326
+ await options.onBoundary?.('relocate:before-capture');
2327
+ paths = await allocateGeneration(options.targetPath);
2328
+ }
2329
+ const physical = paths;
2330
+ return withGenerationLease(modules, physical.configPath, MANAGEMENT_LOCK_NAME, async (lease) => {
2331
+ let journal = existing;
2332
+ if (!journal) {
2333
+ journal = await captureRelocation(modules, source, physical, lease, options);
2334
+ await publishGenerationPointer(modules, physical, journal, targetParent, lease, prior, options.onBoundary);
2335
+ await options.onBoundary?.('relocate:after-capture');
2336
+ }
2337
+ const relocation = relocationOf(journal);
2338
+ const sourceStore = openMigrationStore(modules, source, options.now, writeObserver(source, sourceLease, (point) => options.onBoundary?.(point.startsWith('public:')
2339
+ ? `relocate:source:${point}`
2340
+ : point)));
2341
+ const store = openMigrationStore(modules, physical, options.now, writeObserver(physical, lease, options.onBoundary));
2342
+ const save = async () => {
2343
+ await sourceParent.assertOwned();
2344
+ await targetParent.assertOwned();
2345
+ await topologyLease.assertOwned();
2346
+ await saveJournal(modules, physical, lease, journal, false, options.onBoundary);
2347
+ };
2348
+ const forward = {
2349
+ schemaVersion: 2,
2350
+ id: sourceJournal.id,
2351
+ directoryBasename: basename(source.storeDir),
2352
+ relocatedTo: {
2353
+ legacyPath: physical.legacyPath,
2354
+ id: journal.id,
2355
+ },
2356
+ };
2357
+ const current = await readPointerRecord(source.legacyPath);
2358
+ if (isDeepStrictEqual(current, forward)) {
2359
+ // The rename may have completed without its journal acknowledgment.
2360
+ // Finish its barriers before recording the point of no return.
2361
+ await replaceRelocationPointer(modules, source.legacyPath, forward, forward, journal.id, [sourceParent, sourceLease, topologyLease], options.onBoundary);
2362
+ relocation.sourceForwarded = true;
2363
+ relocation.sourceTombstoned = true;
2364
+ journal.phase = 'activate';
2365
+ await save();
2366
+ }
2367
+ else if (current?.schemaVersion !== 1 ||
2368
+ current.id !== sourceJournal.id ||
2369
+ relocation.sourceForwarded) {
2370
+ throw new AccountMigrationError('source forward differs from relocation evidence');
2371
+ }
2372
+ if (options.cancel)
2373
+ return cancelRelocation(modules, sourceStore, physical, lease, journal, options);
2374
+ if (journal.status === 'active') {
2375
+ await assertSourceForwarded(journal, undefined);
2376
+ await verifyCopies(physical, journal, true);
2377
+ await currentSuccessor(store, physical, journal);
2378
+ await management(store, journal, true);
2379
+ await cleanupPointerStage(physical.legacyPath, {
2380
+ schemaVersion: 1,
2381
+ id: journal.id,
2382
+ directoryBasename: basename(physical.storeDir),
2383
+ }, targetParent);
2384
+ return {
2385
+ status: 'completed',
2386
+ receipt: receipt(journal),
2387
+ };
2388
+ }
2389
+ const snapshot = await relocationSnapshot(physical, journal);
2390
+ if (!relocation.sourceForwarded &&
2391
+ !(await sourceMatches(modules, source, snapshot, journal)))
2392
+ return cancelRelocation(modules, sourceStore, physical, lease, journal, options);
2393
+ if (journal.phase === 'capture' ||
2394
+ journal.phase === 'build') {
2395
+ journal.phase = 'build';
2396
+ await save();
2397
+ await buildRelocation(store, physical, snapshot, journal, options);
2398
+ journal.phase = 'verify';
2399
+ await save();
2400
+ await options.onBoundary?.('relocate:after-build');
2401
+ }
2402
+ if (journal.phase === 'verify') {
2403
+ await options.onBoundary?.('relocate:before-verify');
2404
+ await currentSuccessor(store, physical, journal);
2405
+ const sourceRows = await rowsOf(openAdmissionStore(modules, source, options.now));
2406
+ if (!isDeepStrictEqual(sourceRows, await rowsOf(store)) ||
2407
+ !isDeepStrictEqual(sourceRows.map((row) => row.id), relocation.rows))
2408
+ return cancelRelocation(modules, sourceStore, physical, lease, journal, options);
2409
+ const strip = (settings) => {
2410
+ const copy = { ...settings };
2411
+ delete copy[ACCOUNT_STORE_GENERATION_SETTINGS_KEY];
2412
+ delete copy[MANAGEMENT_SETTINGS_KEY];
2413
+ return copy;
2414
+ };
2415
+ if (!isDeepStrictEqual(strip(await settingsOf(store)), strip(await settingsOf(sourceStore))))
2416
+ throw new AccountMigrationError('cloned settings differ from source');
2417
+ const verified = await synchronizedSnapshot(physical, options.onBoundary);
2418
+ journal.verification = {
2419
+ manifestSha256: digest(canonical(manifestJson(journal.manifest))),
2420
+ configSha256: digest(verified.config),
2421
+ statePresent: verified.state !== undefined,
2422
+ ...(verified.state === undefined
2423
+ ? {}
2424
+ : { stateSha256: digest(verified.state) }),
2425
+ durability: accountMigrationDurability(),
2426
+ };
2427
+ if (!(await sourceMatches(modules, source, snapshot, journal)))
2428
+ return cancelRelocation(modules, sourceStore, physical, lease, journal, options);
2429
+ journal.phase = 'retire';
2430
+ await save();
2431
+ await options.onBoundary?.('relocate:after-verify');
2432
+ }
2433
+ if (journal.phase === 'retire') {
2434
+ await sourceStore.updateSettings((settings) => {
2435
+ if (!isDeepStrictEqual(settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY], generationJson(sourceJournal)))
2436
+ throw new AccountMigrationError('source generation changed before tombstone');
2437
+ const prior = settings[MANAGEMENT_SETTINGS_KEY];
2438
+ if (prior !== undefined &&
2439
+ !isDeepStrictEqual(prior, sourceTombstone(journal)))
2440
+ throw new AccountMigrationError('source management belongs to another operation');
2441
+ settings[MANAGEMENT_SETTINGS_KEY] =
2442
+ sourceTombstone(journal);
2443
+ return settings;
2444
+ });
2445
+ relocation.sourceTombstoned = true;
2446
+ await save();
2447
+ await options.onBoundary?.('relocate:retire:after-tombstone');
2448
+ const keys = [
2449
+ ...new Set((await rowsOf(sourceStore)).map((row) => refreshProviderLock(source.statePath, row)
2450
+ .name)),
2451
+ ].sort();
2452
+ const drain = async (index) => {
2453
+ const name = keys[index];
2454
+ if (name === undefined)
2455
+ return sourceMatches(modules, source, snapshot, journal);
2456
+ return modules.fs.withLock(source.statePath, {
2457
+ name,
2458
+ ttlMs: 10_000,
2459
+ timeoutMs: 15_000,
2460
+ renew: true,
2461
+ }, async (refreshLease) => {
2462
+ const matched = await drain(index + 1);
2463
+ await refreshLease.assertOwned();
2464
+ return matched;
2465
+ });
2466
+ };
2467
+ if (!(await drain(0)))
2468
+ return cancelRelocation(modules, sourceStore, physical, lease, journal, options);
2469
+ await options.onBoundary?.('relocate:retire:after-drain');
2470
+ await withGenerationLease(modules, source.legacyPath, 'accounts', (accountsLease) => replaceRelocationPointer(modules, source.legacyPath, {
2471
+ schemaVersion: 1,
2472
+ id: sourceJournal.id,
2473
+ directoryBasename: basename(source.storeDir),
2474
+ }, forward, journal.id, [
2475
+ sourceParent,
2476
+ targetParent,
2477
+ sourceLease,
2478
+ topologyLease,
2479
+ lease,
2480
+ accountsLease,
2481
+ ], options.onBoundary));
2482
+ relocation.sourceForwarded = true;
2483
+ journal.phase = 'activate';
2484
+ await save();
2485
+ await options.onBoundary?.('relocate:after-retire');
2486
+ }
2487
+ await options.onBoundary?.('relocate:before-activate');
2488
+ await assertSourceForwarded(journal, undefined);
2489
+ await verifyCopies(physical, journal, true);
2490
+ await currentSuccessor(store, physical, journal);
2491
+ journal.status = 'active';
2492
+ await save();
2493
+ await management(store, journal, true);
2494
+ await cleanupPointerStage(physical.legacyPath, {
2495
+ schemaVersion: 1,
2496
+ id: journal.id,
2497
+ directoryBasename: basename(physical.storeDir),
2498
+ }, targetParent);
2499
+ await options.onBoundary?.('relocate:after-activate');
2500
+ return {
2501
+ status: 'completed',
2502
+ receipt: receipt(journal),
2503
+ };
2504
+ });
2505
+ }));
2506
+ }));
2507
+ }
2508
+ catch (error) {
2509
+ const pending = pendingLockFailure(modules, error);
2510
+ if (pending)
2511
+ return pending;
2512
+ throw error;
2513
+ }
2514
+ };
2515
+ }
2516
+ async function restoredRelocationAnchor(anchor, pointer) {
2517
+ if (pointer?.schemaVersion !== 1)
2518
+ return undefined;
2519
+ const paths = generationPaths(anchor, pointer.id);
2520
+ const journal = await readJournal(paths);
2521
+ if (!journal?.relocation?.reversed)
2522
+ return undefined;
2523
+ const source = journal.relocation.source;
2524
+ const restored = await readPointerRecord(source.legacyPath);
2525
+ if (restored?.schemaVersion !== 1)
2526
+ return undefined;
2527
+ if (restored.id !== source.journalId)
2528
+ throw new AccountMigrationError(`the reversed store at ${paths.storeDir} expects generation ${source.journalId} at ${source.legacyPath}, but that anchor publishes ${restored.id}`);
2529
+ await verifyCopies(paths, journal, true);
2530
+ await reverseEvidence(paths, journal);
2531
+ return source.legacyPath;
2532
+ }
2533
+ async function reverseEvidence(paths, journal) {
2534
+ const relocation = relocationOf(journal);
2535
+ const hash = relocation.reverseSha256;
2536
+ const bytes = await secureRead(join(paths.transfersDir, `${hash}.reverse-snapshot.json`));
2537
+ if (!hash || digest(bytes) !== hash)
2538
+ throw new AccountMigrationError('reverse snapshot digest differs');
2539
+ const evidence = exact(jsonBytes(bytes), ['config', 'state', 'journal', 'sourceId', 'targetId'], 'reverse snapshot');
2540
+ if (evidence.sourceId !== journal.id ||
2541
+ evidence.targetId !== relocation.source.journalId)
2542
+ throw new AccountMigrationError('reverse snapshot belongs to another generation');
2543
+ return decodeRelocationSnapshot({
2544
+ config: evidence.config,
2545
+ ...(evidence.state === undefined ? {} : { state: evidence.state }),
2546
+ journal: evidence.journal,
2547
+ });
2548
+ }
2549
+ async function reverseRelocation(modules, options, sourceParent, targetParent) {
2550
+ if (options.cancel)
2551
+ throw new AccountMigrationError('reverse relocation cannot be cancelled');
2552
+ const pointer = await readPointer(options.legacyPath);
2553
+ if (!pointer)
2554
+ throw new AccountMigrationError('reverse source pointer missing');
2555
+ const paths = generationPaths(options.legacyPath, pointer.id);
2556
+ return withGenerationLease(modules, paths.configPath, MANAGEMENT_LOCK_NAME, (lease) => withGenerationLease(modules, paths.configPath, 'antigravity-topology', async (topologyLease) => {
2557
+ const journal = await readJournal(paths);
2558
+ if (journal?.status !== 'active' ||
2559
+ journal.sourceKind !== 'generation')
2560
+ throw new AccountMigrationError('reverse requires an active relocated store');
2561
+ const relocation = relocationOf(journal);
2562
+ if (options.targetPath !== relocation.source.legacyPath)
2563
+ throw new AccountMigrationError('reverse target is not the original anchor');
2564
+ const original = generationPaths(options.targetPath, relocation.source.journalId);
2565
+ if ((await exists(paths.legacyPath)) ||
2566
+ (await exists(original.legacyPath)))
2567
+ throw new AccountMigrationError('reverse relocation refuses a reappeared pool file');
2568
+ return withGenerationLease(modules, original.configPath, MANAGEMENT_LOCK_NAME, (originalLease) => withGenerationLease(modules, original.configPath, 'antigravity-topology', async (originalTopology) => {
2569
+ const oldBytes = await secureRead(original.migrationPath);
2570
+ const oldJournal = await readJournal(original);
2571
+ if (oldJournal?.status !== 'active' ||
2572
+ oldJournal.sourceKind === 'generation')
2573
+ throw new AccountMigrationError('reverse original journal is not released-compatible');
2574
+ await verifyCopies(original, oldJournal, true);
2575
+ const forward = {
2576
+ schemaVersion: 2,
2577
+ id: oldJournal.id,
2578
+ directoryBasename: basename(original.storeDir),
2579
+ relocatedTo: {
2580
+ legacyPath: paths.legacyPath,
2581
+ id: journal.id,
2582
+ },
2583
+ };
2584
+ const restored = {
2585
+ schemaVersion: 1,
2586
+ id: oldJournal.id,
2587
+ directoryBasename: basename(original.storeDir),
2588
+ };
2589
+ const current = await readPointerRecord(original.legacyPath);
2590
+ if (!isDeepStrictEqual(current, forward) &&
2591
+ !(relocation.reversed &&
2592
+ isDeepStrictEqual(current, restored)))
2593
+ throw new AccountMigrationError('reverse target forward changed');
2594
+ const store = openMigrationStore(modules, paths, options.now);
2595
+ const progressPath = join(paths.transfersDir, `${journal.id}.reverse.json`);
2596
+ const progress = async (phase, snapshotSha256) => {
2597
+ const record = {
2598
+ schemaVersion: 1,
2599
+ id: journal.id,
2600
+ sourcePath: paths.legacyPath,
2601
+ targetPath: original.legacyPath,
2602
+ targetId: oldJournal.id,
2603
+ snapshotSha256,
2604
+ phase,
2605
+ };
2606
+ const stage = `${progressPath}.${journal.id}.tmp`;
2607
+ if (await exists(stage)) {
2608
+ await secureRead(stage);
2609
+ await lease.assertOwned();
2610
+ await unlink(stage);
2611
+ }
2612
+ await modules.fs.writeJsonAtomic(progressPath, record, {
2613
+ stageName: () => journal.id,
2614
+ beforeRename: async () => {
2615
+ await lease.assertOwned();
2616
+ await syncFile(stage);
2617
+ },
2618
+ });
2619
+ await syncFile(progressPath);
2620
+ await syncDirectory(paths.transfersDir);
2621
+ if (!isDeepStrictEqual(jsonBytes(await secureRead(progressPath)), record))
2622
+ throw new AccountMigrationError('reverse progress readback differs');
2623
+ };
2624
+ await verifyCopies(paths, journal, true);
2625
+ await currentSuccessor(store, paths, journal);
2626
+ if (!relocation.reversed) {
2627
+ const settings = await settingsOf(store);
2628
+ if (settings[MANAGEMENT_SETTINGS_KEY] !== undefined)
2629
+ throw new AccountMigrationError('reverse source management is pending');
2630
+ await options.onBoundary?.('relocate:back:before-clone');
2631
+ await withSaveLocks(modules, paths, async (sourceLeases) => {
2632
+ const snapshot = await synchronizedSnapshot(paths, options.onBoundary);
2633
+ const evidence = Buffer.from(JSON.stringify({
2634
+ sourceId: journal.id,
2635
+ targetId: oldJournal.id,
2636
+ config: snapshot.config.toString('base64'),
2637
+ ...(snapshot.state === undefined
2638
+ ? {}
2639
+ : { state: snapshot.state.toString('base64') }),
2640
+ journal: oldBytes.toString('base64'),
2641
+ }));
2642
+ const evidencePath = join(paths.transfersDir, `${digest(evidence)}.reverse-snapshot.json`);
2643
+ await exclusiveBytes(evidencePath, evidence, digest(evidence), options.onBoundary);
2644
+ await progress('clone', digest(evidence));
2645
+ await withSaveLocks(modules, original, async (targetLeases) => {
2646
+ const assertOwned = async () => {
2647
+ for (const held of [
2648
+ lease,
2649
+ originalLease,
2650
+ ...sourceLeases,
2651
+ ...targetLeases,
2652
+ ])
2653
+ await held.assertOwned();
2654
+ };
2655
+ const copy = async (path, bytes) => {
2656
+ const stage = `${path}.${journal.id}.tmp`;
2657
+ await exclusiveBytes(stage, bytes, digest(bytes), options.onBoundary);
2658
+ await assertOwned();
2659
+ await rename(stage, path);
2660
+ await syncFile(path);
2661
+ await syncDirectory(original.storeDir);
2662
+ };
2663
+ // Native files are copied, never exported through the lossy V4 representation.
2664
+ if (snapshot.state !== undefined)
2665
+ await copy(original.statePath, snapshot.state);
2666
+ else if (await exists(original.statePath)) {
2667
+ await assertOwned();
2668
+ await unlink(original.statePath);
2669
+ await syncDirectory(original.storeDir);
2670
+ }
2671
+ await options.onBoundary?.('relocate:back:after-state');
2672
+ await copy(original.configPath, snapshot.config);
2673
+ await progress('restore-generation', digest(evidence));
2674
+ await options.onBoundary?.('relocate:back:after-clone');
2675
+ });
2676
+ relocation.reverseSha256 = digest(evidence);
2677
+ });
2678
+ // The original journal is never rewritten. Only its generation key
2679
+ // is restored in the current native pool.
2680
+ const originalStore = openMigrationStore(modules, original, options.now, async (step, info) => {
2681
+ await originalLease.assertOwned();
2682
+ if (step.startsWith('after-')) {
2683
+ await syncFile(step.includes('config')
2684
+ ? original.configPath
2685
+ : original.statePath);
2686
+ await syncDirectory(original.storeDir);
2687
+ }
2688
+ await options.onBoundary?.(`back:public:${info.operation}:${step}`);
2689
+ });
2690
+ await originalStore.updateSettings((settings) => {
2691
+ if (!isDeepStrictEqual(settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY], generationJson(journal)))
2692
+ throw new AccountMigrationError('reverse clone generation changed');
2693
+ if (settings[MANAGEMENT_SETTINGS_KEY] !== undefined)
2694
+ throw new AccountMigrationError('reverse clone retained management');
2695
+ settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY] =
2696
+ generationJson(oldJournal);
2697
+ return settings;
2698
+ });
2699
+ await currentSuccessor(originalStore, original, oldJournal);
2700
+ if (!isDeepStrictEqual(await rowsOf(originalStore), await rowsOf(store)))
2701
+ throw new AccountMigrationError('reverse clone rows differ');
2702
+ await options.onBoundary?.('relocate:back:after-management-clear');
2703
+ if (!relocation.reverseSha256)
2704
+ throw new AccountMigrationError('reverse snapshot missing');
2705
+ await progress('publish', relocation.reverseSha256);
2706
+ relocation.reversed = true;
2707
+ await saveJournal(modules, paths, lease, journal, false, options.onBoundary);
2708
+ await options.onBoundary?.('relocate:back:after-record');
2709
+ }
2710
+ const evidence = await reverseEvidence(paths, journal);
2711
+ if (!evidence.journal.equals(oldBytes))
2712
+ throw new AccountMigrationError('reverse original journal differs from snapshot');
2713
+ if (current?.schemaVersion === 2) {
2714
+ const native = object(jsonBytes(evidence.config), 'reverse config');
2715
+ native[ACCOUNT_STORE_GENERATION_SETTINGS_KEY] =
2716
+ generationJson(oldJournal);
2717
+ const actual = await withSaveLocks(modules, original, () => synchronizedSnapshot(original, options.onBoundary));
2718
+ if (!isDeepStrictEqual(jsonBytes(actual.config), native) ||
2719
+ !isDeepStrictEqual(actual.state, evidence.state))
2720
+ throw new AccountMigrationError('reverse clone differs before pointer publication');
2721
+ }
2722
+ if (!(await secureRead(original.migrationPath)).equals(oldBytes))
2723
+ throw new AccountMigrationError('reverse changed original journal');
2724
+ await withGenerationLease(modules, original.legacyPath, 'accounts', (accountsLease) => replaceRelocationPointer(modules, original.legacyPath, forward, restored, journal.id, [
2725
+ sourceParent,
2726
+ targetParent,
2727
+ lease,
2728
+ topologyLease,
2729
+ originalLease,
2730
+ originalTopology,
2731
+ accountsLease,
2732
+ ], options.onBoundary, 'relocate:back:pointer'));
2733
+ await progress('complete', relocation.reverseSha256 ?? '');
2734
+ await options.onBoundary?.('relocate:back:after-pointer');
2735
+ return { status: 'completed', receipt: receipt(oldJournal) };
2736
+ }));
2737
+ }));
2738
+ }
1727
2739
  function refMatches(ref, row) {
1728
2740
  return (ref !== null && ref !== undefined && isDeepStrictEqual(ref, rowRef(row)));
1729
2741
  }
@@ -1812,6 +2824,11 @@ export function createAccountRollbackFactory(modules) {
1812
2824
  throw new AccountMigrationError('stopped processes and timers must be explicitly confirmed');
1813
2825
  try {
1814
2826
  return await withParentTopology(modules, options.legacyPath, async () => {
2827
+ // A moved store is rolled back from its pool file only after it was
2828
+ // moved back to this anchor; this build never does either.
2829
+ const record = await readPointerRecord(options.legacyPath);
2830
+ if (record?.schemaVersion === 2)
2831
+ throw new AccountMigrationError(`the account store moved to ${record.relocatedTo.legacyPath}; move it back with \`antigravity-auth store relocate --back --offline\` before rolling it back`);
1815
2832
  const paths = await resolvePublishedAccountStorePaths(options.legacyPath);
1816
2833
  if (!paths)
1817
2834
  throw new AccountMigrationError('rollback requires a published generation');
@@ -1824,6 +2841,8 @@ export function createAccountRollbackFactory(modules) {
1824
2841
  const initialJournal = await readJournal(paths);
1825
2842
  if (!initialJournal)
1826
2843
  throw new AccountMigrationError('rollback requires an owned migration generation');
2844
+ if (initialJournal.sourceKind === 'generation')
2845
+ throw new AccountMigrationError(`this account store was moved here from ${initialJournal.relocation?.source.legacyPath}; move it back with \`antigravity-auth store relocate --back --offline\` before rolling it back`);
1827
2846
  let journal = initialJournal;
1828
2847
  const store = openMigrationStore(modules, paths, options.now, writeObserver(paths, lease, options.onBoundary));
1829
2848
  if (journal.status === 'inactive') {
@@ -2334,7 +3353,10 @@ async function allocateGeneration(legacyPath) {
2334
3353
  if (await exists(resolveAccountStorePaths(legacyPath).storeDir))
2335
3354
  throw new AccountMigrationError('foreign logical directory refuses generation allocation');
2336
3355
  const paths = generationPaths(legacyPath, randomUUID());
2337
- await mkdir(paths.storeDir, { mode: 0o700 });
3356
+ if (windowsPrivateFiles)
3357
+ await createPrivateDirectoryExclusive(paths.storeDir);
3358
+ else
3359
+ await mkdir(paths.storeDir, { mode: 0o700 });
2338
3360
  await prepareDirectories(paths);
2339
3361
  return paths;
2340
3362
  }
@@ -2369,6 +3391,9 @@ async function publishGenerationPointer(modules, paths, journal, parentLease, ma
2369
3391
  throw new AccountMigrationError('foreign logical store directory appeared before publication');
2370
3392
  if (journal.sourceKind === 'absent')
2371
3393
  await assertSourceAbsent(paths);
3394
+ else if (journal.sourceKind === 'generation') {
3395
+ await assertSourceAbsent(paths);
3396
+ }
2372
3397
  else if (digest(await secureRead(paths.legacyPath)) !== journal.sourceSha256)
2373
3398
  throw new AccountMigrationError('captured source changed immediately before pointer link');
2374
3399
  try {
@@ -2389,9 +3414,18 @@ async function publishGenerationPointer(modules, paths, journal, parentLease, ma
2389
3414
  else {
2390
3415
  const oldPaths = generationPaths(paths.legacyPath, prior.id);
2391
3416
  const oldJournal = await readJournal(oldPaths);
2392
- if (oldJournal?.status !== 'inactive')
3417
+ if (!oldJournal)
3418
+ throw new AccountMigrationError('prior generation journal missing');
3419
+ const reversed = oldJournal?.relocation?.reversed === true &&
3420
+ journal.sourceKind === 'generation' &&
3421
+ isDeepStrictEqual(oldJournal.relocation.source.legacyPath, journal.relocation?.source.legacyPath) &&
3422
+ oldJournal.relocation.source.journalId ===
3423
+ journal.relocation?.source.journalId;
3424
+ if (oldJournal?.status !== 'inactive' && !reversed)
2393
3425
  throw new AccountMigrationError('pointer transition requires an owned inactive prior generation');
2394
3426
  await verifyCopies(oldPaths, oldJournal, !oldJournal.cancelled);
3427
+ if (reversed)
3428
+ await reverseEvidence(oldPaths, oldJournal);
2395
3429
  const stageName = journal.id;
2396
3430
  await modules.fs.writeJsonAtomic(path, pointer, {
2397
3431
  stageName: () => stageName,
@@ -2399,7 +3433,9 @@ async function publishGenerationPointer(modules, paths, journal, parentLease, ma
2399
3433
  await parentLease.assertOwned();
2400
3434
  await managementLease.assertOwned();
2401
3435
  if (!isDeepStrictEqual(await readPointer(paths.legacyPath), prior) ||
2402
- (await readJournal(oldPaths))?.status !== 'inactive')
3436
+ (reversed
3437
+ ? !isDeepStrictEqual(await readJournal(oldPaths), oldJournal)
3438
+ : (await readJournal(oldPaths))?.status !== 'inactive'))
2403
3439
  throw new AccountMigrationError('inactive pointer changed before transition');
2404
3440
  await syncFile(`${path}.${stageName}.tmp`);
2405
3441
  },