@cortexkit/antigravity-auth-core 2.2.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -2
- package/dist/abort-forwarding.d.ts +3 -0
- package/dist/abort-forwarding.d.ts.map +1 -0
- package/dist/abort-forwarding.js +19 -0
- package/dist/abort-forwarding.js.map +1 -0
- package/dist/account-identity.d.ts +52 -0
- package/dist/account-identity.d.ts.map +1 -0
- package/dist/account-identity.js +66 -0
- package/dist/account-identity.js.map +1 -0
- package/dist/account-manager.d.ts +228 -43
- package/dist/account-manager.d.ts.map +1 -1
- package/dist/account-manager.js +942 -903
- package/dist/account-manager.js.map +1 -1
- package/dist/account-migration.d.ts +309 -0
- package/dist/account-migration.d.ts.map +1 -0
- package/dist/account-migration.js +2282 -0
- package/dist/account-migration.js.map +1 -0
- package/dist/account-repository-codecs.d.ts +82 -0
- package/dist/account-repository-codecs.d.ts.map +1 -0
- package/dist/account-repository-codecs.js +609 -0
- package/dist/account-repository-codecs.js.map +1 -0
- package/dist/account-repository-types.d.ts +831 -0
- package/dist/account-repository-types.d.ts.map +1 -0
- package/dist/account-repository-types.js +137 -0
- package/dist/account-repository-types.js.map +1 -0
- package/dist/account-repository.d.ts +442 -0
- package/dist/account-repository.d.ts.map +1 -0
- package/dist/account-repository.js +2253 -0
- package/dist/account-repository.js.map +1 -0
- package/dist/account-selector.d.ts +470 -0
- package/dist/account-selector.d.ts.map +1 -0
- package/dist/account-selector.js +1383 -0
- package/dist/account-selector.js.map +1 -0
- package/dist/account-storage.d.ts.map +1 -1
- package/dist/account-storage.js +5 -0
- package/dist/account-storage.js.map +1 -1
- package/dist/agy-transport.d.ts.map +1 -1
- package/dist/agy-transport.js +21 -35
- package/dist/agy-transport.js.map +1 -1
- package/dist/antigravity-command-menu.d.ts +171 -0
- package/dist/antigravity-command-menu.d.ts.map +1 -0
- package/dist/antigravity-command-menu.js +523 -0
- package/dist/antigravity-command-menu.js.map +1 -0
- package/dist/atomic-write.d.ts +18 -1
- package/dist/atomic-write.d.ts.map +1 -1
- package/dist/atomic-write.js +15 -3
- package/dist/atomic-write.js.map +1 -1
- package/dist/common-auth-embedded/NOTICE.txt +21 -0
- package/dist/common-auth-embedded/auth-menu/accounts.d.ts +97 -0
- package/dist/common-auth-embedded/auth-menu/accounts.js +387 -0
- package/dist/common-auth-embedded/auth-menu/ansi.d.ts +26 -0
- package/dist/common-auth-embedded/auth-menu/ansi.js +98 -0
- package/dist/common-auth-embedded/auth-menu/confirm.d.ts +7 -0
- package/dist/common-auth-embedded/auth-menu/confirm.js +21 -0
- package/dist/common-auth-embedded/auth-menu/doctor.d.ts +52 -0
- package/dist/common-auth-embedded/auth-menu/doctor.js +81 -0
- package/dist/common-auth-embedded/auth-menu/index.d.ts +17 -0
- package/dist/common-auth-embedded/auth-menu/index.js +12 -0
- package/dist/common-auth-embedded/auth-menu/login.d.ts +55 -0
- package/dist/common-auth-embedded/auth-menu/login.js +83 -0
- package/dist/common-auth-embedded/auth-menu/menu.d.ts +59 -0
- package/dist/common-auth-embedded/auth-menu/menu.js +73 -0
- package/dist/common-auth-embedded/auth-menu/opencode-v1.d.ts +49 -0
- package/dist/common-auth-embedded/auth-menu/opencode-v1.js +47 -0
- package/dist/common-auth-embedded/auth-menu/select.d.ts +21 -0
- package/dist/common-auth-embedded/auth-menu/select.js +159 -0
- package/dist/common-auth-embedded/auth-menu/terminal.d.ts +43 -0
- package/dist/common-auth-embedded/auth-menu/terminal.js +19 -0
- package/dist/common-auth-embedded/claustrum/consumer.d.ts +102 -0
- package/dist/common-auth-embedded/claustrum/consumer.js +298 -0
- package/dist/common-auth-embedded/claustrum/custody.d.ts +175 -0
- package/dist/common-auth-embedded/claustrum/custody.js +354 -0
- package/dist/common-auth-embedded/claustrum/enrollment.d.ts +158 -0
- package/dist/common-auth-embedded/claustrum/enrollment.js +759 -0
- package/dist/common-auth-embedded/claustrum/errors.d.ts +16 -0
- package/dist/common-auth-embedded/claustrum/errors.js +8 -0
- package/dist/common-auth-embedded/claustrum/host-slot.d.ts +39 -0
- package/dist/common-auth-embedded/claustrum/host-slot.js +72 -0
- package/dist/common-auth-embedded/claustrum/index.d.ts +18 -0
- package/dist/common-auth-embedded/claustrum/index.js +22 -0
- package/dist/common-auth-embedded/claustrum/interlock.d.ts +27 -0
- package/dist/common-auth-embedded/claustrum/interlock.js +29 -0
- package/dist/common-auth-embedded/claustrum/roster.d.ts +208 -0
- package/dist/common-auth-embedded/claustrum/roster.js +536 -0
- package/dist/common-auth-embedded/commands/builtins.d.ts +71 -0
- package/dist/common-auth-embedded/commands/builtins.js +534 -0
- package/dist/common-auth-embedded/commands/index.d.ts +10 -0
- package/dist/common-auth-embedded/commands/index.js +5 -0
- package/dist/common-auth-embedded/commands/menu.d.ts +61 -0
- package/dist/common-auth-embedded/commands/menu.js +300 -0
- package/dist/common-auth-embedded/commands/model.d.ts +238 -0
- package/dist/common-auth-embedded/commands/model.js +16 -0
- package/dist/common-auth-embedded/commands/pi.d.ts +23 -0
- package/dist/common-auth-embedded/commands/pi.js +206 -0
- package/dist/common-auth-embedded/commands/seam.d.ts +72 -0
- package/dist/common-auth-embedded/commands/seam.js +301 -0
- package/dist/common-auth-embedded/fs/atomic-write.d.ts +20 -0
- package/dist/common-auth-embedded/fs/atomic-write.js +80 -0
- package/dist/common-auth-embedded/fs/index.d.ts +7 -0
- package/dist/common-auth-embedded/fs/index.js +6 -0
- package/dist/common-auth-embedded/fs/lock-constants.d.ts +14 -0
- package/dist/common-auth-embedded/fs/lock-constants.js +14 -0
- package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +33 -0
- package/dist/common-auth-embedded/fs/refresh-file-lock.js +501 -0
- package/dist/common-auth-embedded/fs/with-lock.d.ts +33 -0
- package/dist/common-auth-embedded/fs/with-lock.js +48 -0
- package/dist/common-auth-embedded/logger/capture-sink.d.ts +14 -0
- package/dist/common-auth-embedded/logger/capture-sink.js +13 -0
- package/dist/common-auth-embedded/logger/engine.d.ts +100 -0
- package/dist/common-auth-embedded/logger/engine.js +246 -0
- package/dist/common-auth-embedded/logger/index.d.ts +6 -0
- package/dist/common-auth-embedded/logger/index.js +3 -0
- package/dist/common-auth-embedded/logger/redact.d.ts +11 -0
- package/dist/common-auth-embedded/logger/redact.js +83 -0
- package/dist/common-auth-embedded/pi-slot/index.d.ts +3 -0
- package/dist/common-auth-embedded/pi-slot/index.js +2 -0
- package/dist/common-auth-embedded/pi-slot/lock.js +212 -0
- package/dist/common-auth-embedded/pi-slot/slot.d.ts +49 -0
- package/dist/common-auth-embedded/pi-slot/slot.js +383 -0
- package/dist/common-auth-embedded/quota/codec.d.ts +11 -0
- package/dist/common-auth-embedded/quota/codec.js +12 -0
- package/dist/common-auth-embedded/quota/format.d.ts +46 -0
- package/dist/common-auth-embedded/quota/format.js +138 -0
- package/dist/common-auth-embedded/quota/index.d.ts +10 -0
- package/dist/common-auth-embedded/quota/index.js +5 -0
- package/dist/common-auth-embedded/quota/map.d.ts +63 -0
- package/dist/common-auth-embedded/quota/map.js +105 -0
- package/dist/common-auth-embedded/quota/merge.d.ts +48 -0
- package/dist/common-auth-embedded/quota/merge.js +162 -0
- package/dist/common-auth-embedded/quota/projection.d.ts +64 -0
- package/dist/common-auth-embedded/quota/projection.js +134 -0
- package/dist/common-auth-embedded/routing/admission.d.ts +93 -0
- package/dist/common-auth-embedded/routing/admission.js +142 -0
- package/dist/common-auth-embedded/routing/index.d.ts +8 -0
- package/dist/common-auth-embedded/routing/index.js +4 -0
- package/dist/common-auth-embedded/routing/ordered.d.ts +47 -0
- package/dist/common-auth-embedded/routing/ordered.js +58 -0
- package/dist/common-auth-embedded/routing/pins.d.ts +22 -0
- package/dist/common-auth-embedded/routing/pins.js +34 -0
- package/dist/common-auth-embedded/routing/sticky.d.ts +158 -0
- package/dist/common-auth-embedded/routing/sticky.js +339 -0
- package/dist/common-auth-embedded/source-output.json +1090 -0
- package/dist/common-auth-embedded/store/attribution.d.ts +28 -0
- package/dist/common-auth-embedded/store/attribution.js +60 -0
- package/dist/common-auth-embedded/store/errors.d.ts +55 -0
- package/dist/common-auth-embedded/store/errors.js +38 -0
- package/dist/common-auth-embedded/store/hooks.d.ts +12 -0
- package/dist/common-auth-embedded/store/hooks.js +34 -0
- package/dist/common-auth-embedded/store/identity.d.ts +39 -0
- package/dist/common-auth-embedded/store/identity.js +79 -0
- package/dist/common-auth-embedded/store/index.d.ts +21 -0
- package/dist/common-auth-embedded/store/index.js +7 -0
- package/dist/common-auth-embedded/store/mutate.d.ts +189 -0
- package/dist/common-auth-embedded/store/mutate.js +500 -0
- package/dist/common-auth-embedded/store/pool.d.ts +196 -0
- package/dist/common-auth-embedded/store/pool.js +120 -0
- package/dist/common-auth-embedded/store/provider-state.d.ts +131 -0
- package/dist/common-auth-embedded/store/provider-state.js +226 -0
- package/dist/common-auth-embedded/store/publication.d.ts +47 -0
- package/dist/common-auth-embedded/store/publication.js +304 -0
- package/dist/common-auth-embedded/store/pull.d.ts +36 -0
- package/dist/common-auth-embedded/store/pull.js +143 -0
- package/dist/common-auth-embedded/store/refresh-lock.d.ts +63 -0
- package/dist/common-auth-embedded/store/refresh-lock.js +136 -0
- package/dist/common-auth-embedded/store/refresh.d.ts +72 -0
- package/dist/common-auth-embedded/store/refresh.js +225 -0
- package/dist/common-auth-embedded/store/reserved.js +13 -0
- package/dist/common-auth-embedded/store/rows.d.ts +313 -0
- package/dist/common-auth-embedded/store/rows.js +1258 -0
- package/dist/common-auth-embedded/store/runtime.d.ts +36 -0
- package/dist/common-auth-embedded/store/runtime.js +68 -0
- package/dist/common-auth-embedded/store/schema.d.ts +443 -0
- package/dist/common-auth-embedded/store/schema.js +754 -0
- package/dist/common-auth-embedded/store/settings.d.ts +63 -0
- package/dist/common-auth-embedded/store/settings.js +120 -0
- package/dist/common-auth-embedded/store/torn.d.ts +78 -0
- package/dist/common-auth-embedded/store/torn.js +294 -0
- package/dist/common-auth-embedded/util/any-signal.js +42 -0
- package/dist/common-auth-runtime.d.ts +73 -0
- package/dist/common-auth-runtime.d.ts.map +1 -0
- package/dist/common-auth-runtime.js +65 -0
- package/dist/common-auth-runtime.js.map +1 -0
- package/dist/credential-redaction.d.ts +13 -0
- package/dist/credential-redaction.d.ts.map +1 -0
- package/dist/credential-redaction.js +230 -0
- package/dist/credential-redaction.js.map +1 -0
- package/dist/fetch-timeout.d.ts +4 -2
- package/dist/fetch-timeout.d.ts.map +1 -1
- package/dist/fetch-timeout.js +121 -14
- package/dist/fetch-timeout.js.map +1 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/quota-manager.d.ts.map +1 -1
- package/dist/quota-manager.js +35 -10
- package/dist/quota-manager.js.map +1 -1
- package/dist/quota-presentation.d.ts +10 -0
- package/dist/quota-presentation.d.ts.map +1 -0
- package/dist/quota-presentation.js +60 -0
- package/dist/quota-presentation.js.map +1 -0
- package/dist/quota-types.d.ts +2 -0
- package/dist/quota-types.d.ts.map +1 -1
- package/dist/rotation.d.ts +8 -0
- package/dist/rotation.d.ts.map +1 -1
- package/dist/rotation.js +24 -0
- package/dist/rotation.js.map +1 -1
- package/dist/transform/claude.d.ts +7 -3
- package/dist/transform/claude.d.ts.map +1 -1
- package/dist/transform/claude.js +6 -2
- package/dist/transform/claude.js.map +1 -1
- package/dist/transform/types.d.ts +1 -1
- package/dist/transform/types.d.ts.map +1 -1
- package/dist/vault-account-source.d.ts +183 -0
- package/dist/vault-account-source.d.ts.map +1 -0
- package/dist/vault-account-source.js +296 -0
- package/dist/vault-account-source.js.map +1 -0
- package/dist/vault-enrollment-authority.d.ts +16 -0
- package/dist/vault-enrollment-authority.d.ts.map +1 -0
- package/dist/vault-enrollment-authority.js +19 -0
- package/dist/vault-enrollment-authority.js.map +1 -0
- package/dist/vault-family.d.ts +11 -0
- package/dist/vault-family.d.ts.map +1 -0
- package/dist/vault-family.js +24 -0
- package/dist/vault-family.js.map +1 -0
- package/dist/vault-provider-state.d.ts +162 -0
- package/dist/vault-provider-state.d.ts.map +1 -0
- package/dist/vault-provider-state.js +422 -0
- package/dist/vault-provider-state.js.map +1 -0
- package/package.json +39 -2
|
@@ -0,0 +1,2282 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { link, lstat, mkdir, open, realpath, unlink } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
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";
|
|
8
|
+
/** Derives store paths from the caller's absolute legacy file path, not HOME or host preferences. */
|
|
9
|
+
export function resolveAccountStorePaths(legacyPath, generationId) {
|
|
10
|
+
if (generationId !== undefined)
|
|
11
|
+
return generationPaths(legacyPath, generationId);
|
|
12
|
+
if (!isAbsolute(legacyPath) || legacyPath !== resolve(legacyPath)) {
|
|
13
|
+
throw new AccountMigrationError('legacy path must be absolute and normalized');
|
|
14
|
+
}
|
|
15
|
+
const storeDir = join(dirname(legacyPath), `${basename(legacyPath)}.store`);
|
|
16
|
+
return {
|
|
17
|
+
legacyPath,
|
|
18
|
+
storeDir,
|
|
19
|
+
configPath: join(storeDir, 'config.json'),
|
|
20
|
+
statePath: join(storeDir, 'state.json'),
|
|
21
|
+
migrationPath: join(storeDir, 'migration.json'),
|
|
22
|
+
backupsDir: join(storeDir, 'backups'),
|
|
23
|
+
retiredDir: join(storeDir, 'retired'),
|
|
24
|
+
transfersDir: join(storeDir, 'transfers'),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function accountStorePointerPath(legacyPath) {
|
|
28
|
+
return `${resolveAccountStorePaths(legacyPath).storeDir}.pointer.json`;
|
|
29
|
+
}
|
|
30
|
+
function generationPaths(legacyPath, id) {
|
|
31
|
+
const logical = resolveAccountStorePaths(legacyPath);
|
|
32
|
+
text(id, UUID, 'generation id');
|
|
33
|
+
const storeDir = join(dirname(legacyPath), `${basename(legacyPath)}.store.${id}.generation`);
|
|
34
|
+
return {
|
|
35
|
+
...logical,
|
|
36
|
+
storeDir,
|
|
37
|
+
configPath: join(storeDir, 'config.json'),
|
|
38
|
+
statePath: join(storeDir, 'state.json'),
|
|
39
|
+
migrationPath: join(storeDir, 'migration.json'),
|
|
40
|
+
backupsDir: join(storeDir, 'backups'),
|
|
41
|
+
retiredDir: join(storeDir, 'retired'),
|
|
42
|
+
transfersDir: join(storeDir, 'transfers'),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function pathGenerationId(paths) {
|
|
46
|
+
const id = basename(paths.storeDir).slice(`${basename(paths.legacyPath)}.store.`.length, -'.generation'.length);
|
|
47
|
+
text(id, UUID, 'physical generation');
|
|
48
|
+
if (!isDeepStrictEqual(generationPaths(paths.legacyPath, id), paths))
|
|
49
|
+
throw new AccountMigrationError('physical paths differ from derived generation');
|
|
50
|
+
return id;
|
|
51
|
+
}
|
|
52
|
+
export function decodeAccountStorePointer(value, legacyPath) {
|
|
53
|
+
const raw = exact(value, ['schemaVersion', 'id', 'directoryBasename'], 'pointer');
|
|
54
|
+
if (raw.schemaVersion !== 1)
|
|
55
|
+
throw new AccountMigrationError('unsupported pointer schema');
|
|
56
|
+
const id = text(raw.id, UUID, 'pointer generation');
|
|
57
|
+
const paths = generationPaths(legacyPath, id);
|
|
58
|
+
if (raw.directoryBasename !== basename(paths.storeDir))
|
|
59
|
+
throw new AccountMigrationError('pointer directory basename differs from its derived generation');
|
|
60
|
+
return { schemaVersion: 1, id, directoryBasename: basename(paths.storeDir) };
|
|
61
|
+
}
|
|
62
|
+
async function readPointer(legacyPath) {
|
|
63
|
+
const logical = resolveAccountStorePaths(legacyPath);
|
|
64
|
+
if (await exists(logical.storeDir))
|
|
65
|
+
throw new AccountMigrationError('foreign logical store directory is not pointer authority');
|
|
66
|
+
const path = accountStorePointerPath(legacyPath);
|
|
67
|
+
if (!(await exists(path)))
|
|
68
|
+
return undefined;
|
|
69
|
+
return decodeAccountStorePointer(jsonBytes(await secureRead(path)), legacyPath);
|
|
70
|
+
}
|
|
71
|
+
export async function resolvePublishedAccountStorePaths(legacyPath) {
|
|
72
|
+
const pointer = await readPointer(legacyPath);
|
|
73
|
+
if (!pointer)
|
|
74
|
+
return undefined;
|
|
75
|
+
const paths = generationPaths(legacyPath, pointer.id);
|
|
76
|
+
const journal = await readJournal(paths);
|
|
77
|
+
if (!journal || journal.id !== pointer.id)
|
|
78
|
+
throw new AccountMigrationError('published generation has no matching complete journal');
|
|
79
|
+
return paths;
|
|
80
|
+
}
|
|
81
|
+
export async function assertPublishedAccountStoreGeneration(paths) {
|
|
82
|
+
const current = await resolvePublishedAccountStorePaths(paths.legacyPath);
|
|
83
|
+
if (!current || !isDeepStrictEqual(current, paths))
|
|
84
|
+
throw new AccountMigrationError('bound store generation is no longer published');
|
|
85
|
+
}
|
|
86
|
+
/** Errors contain field paths and ordinals, never account values or secrets. */
|
|
87
|
+
export class AccountMigrationError extends Error {
|
|
88
|
+
reason;
|
|
89
|
+
constructor(reason) {
|
|
90
|
+
super(`Account-store migration refused: ${reason}`);
|
|
91
|
+
this.reason = reason;
|
|
92
|
+
this.name = 'AccountMigrationError';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const QUOTA_FIELDS = [
|
|
96
|
+
'cachedQuotaAccountId',
|
|
97
|
+
'cachedQuota',
|
|
98
|
+
'cachedPerModelQuota',
|
|
99
|
+
'cachedQuotaUpdatedAt',
|
|
100
|
+
];
|
|
101
|
+
function object(value, path) {
|
|
102
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
103
|
+
throw new AccountMigrationError(`${path} must be an object`);
|
|
104
|
+
}
|
|
105
|
+
if (Object.getPrototypeOf(value) !== Object.prototype &&
|
|
106
|
+
Object.getPrototypeOf(value) !== null)
|
|
107
|
+
throw new AccountMigrationError(`${path} is not a plain JSON object`);
|
|
108
|
+
if (Object.getOwnPropertySymbols(value).length ||
|
|
109
|
+
Object.getOwnPropertyNames(value).some((key) => {
|
|
110
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
111
|
+
return (key === '__proto__' ||
|
|
112
|
+
!descriptor?.enumerable ||
|
|
113
|
+
!('value' in descriptor));
|
|
114
|
+
}))
|
|
115
|
+
throw new AccountMigrationError(`${path} has unsafe JSON properties`);
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
function finite(value) {
|
|
119
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
120
|
+
}
|
|
121
|
+
function assertSerializableNumbers(value, depth = 0) {
|
|
122
|
+
if (depth > 128)
|
|
123
|
+
throw new AccountMigrationError('source JSON exceeds safe nesting depth');
|
|
124
|
+
if (typeof value === 'number' &&
|
|
125
|
+
(!Number.isFinite(value) || Object.is(value, -0)))
|
|
126
|
+
throw new AccountMigrationError('source contains a numeric value the public JSON writer cannot retain losslessly');
|
|
127
|
+
if (value !== null && typeof value === 'object') {
|
|
128
|
+
for (const item of Object.values(value))
|
|
129
|
+
assertSerializableNumbers(item, depth + 1);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function legacyOptional(row, key, predicate, path) {
|
|
133
|
+
if (Object.hasOwn(row, key) && row[key] !== null && !predicate(row[key])) {
|
|
134
|
+
throw new AccountMigrationError(`${path}.${key} has an invalid type`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function parseAccount(value, ordinal, version, clock) {
|
|
138
|
+
const path = `accounts[${ordinal}]`;
|
|
139
|
+
const source = object(value, path);
|
|
140
|
+
if (typeof source.refreshToken !== 'string' ||
|
|
141
|
+
!source.refreshToken.trim().length) {
|
|
142
|
+
throw new AccountMigrationError(`${path}.refreshToken must be nonempty`);
|
|
143
|
+
}
|
|
144
|
+
const metadataRaw = { ...source };
|
|
145
|
+
delete metadataRaw.refreshToken;
|
|
146
|
+
const quotaRaw = { schemaVersion: 1 };
|
|
147
|
+
let hasQuota = false;
|
|
148
|
+
for (const key of QUOTA_FIELDS) {
|
|
149
|
+
if (!Object.hasOwn(source, key))
|
|
150
|
+
continue;
|
|
151
|
+
hasQuota = true;
|
|
152
|
+
quotaRaw[key] = source[key];
|
|
153
|
+
delete metadataRaw[key];
|
|
154
|
+
}
|
|
155
|
+
if (version === 1) {
|
|
156
|
+
legacyOptional(source, 'isRateLimited', (v) => typeof v === 'boolean', path);
|
|
157
|
+
legacyOptional(source, 'rateLimitResetTime', finite, path);
|
|
158
|
+
delete metadataRaw.isRateLimited;
|
|
159
|
+
delete metadataRaw.rateLimitResetTime;
|
|
160
|
+
}
|
|
161
|
+
// Validate the complete shape before normalization can discard old fields.
|
|
162
|
+
const originalMetadata = decodeProviderMetadata(metadataRaw);
|
|
163
|
+
const quota = hasQuota ? decodeQuotaState(quotaRaw) : undefined;
|
|
164
|
+
const normalized = encodeProviderMetadata(originalMetadata);
|
|
165
|
+
if (version === 1) {
|
|
166
|
+
if (source.isRateLimited &&
|
|
167
|
+
finite(source.rateLimitResetTime) &&
|
|
168
|
+
source.rateLimitResetTime > clock) {
|
|
169
|
+
if (originalMetadata.rateLimitResetTimes === null)
|
|
170
|
+
throw new AccountMigrationError(`${path} has conflicting rate-limit encodings`);
|
|
171
|
+
const resets = { ...originalMetadata.rateLimitResetTimes };
|
|
172
|
+
for (const key of ['claude', 'gemini-antigravity']) {
|
|
173
|
+
if (Object.hasOwn(resets, key) &&
|
|
174
|
+
resets[key] !== source.rateLimitResetTime)
|
|
175
|
+
throw new AccountMigrationError(`${path} has conflicting rate-limit encodings`);
|
|
176
|
+
resets[key] = source.rateLimitResetTime;
|
|
177
|
+
}
|
|
178
|
+
normalized.rateLimitResetTimes = resets;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
else if (version === 2 &&
|
|
182
|
+
originalMetadata.rateLimitResetTimes !== null &&
|
|
183
|
+
originalMetadata.rateLimitResetTimes !== undefined) {
|
|
184
|
+
const resets = { ...originalMetadata.rateLimitResetTimes };
|
|
185
|
+
const claude = resets.claude;
|
|
186
|
+
const gemini = resets.gemini;
|
|
187
|
+
delete resets.claude;
|
|
188
|
+
delete resets.gemini;
|
|
189
|
+
if (claude === null || (finite(claude) && claude > clock))
|
|
190
|
+
resets.claude = claude;
|
|
191
|
+
if (gemini === null || (finite(gemini) && gemini > clock)) {
|
|
192
|
+
if (Object.hasOwn(resets, 'gemini-antigravity') &&
|
|
193
|
+
resets['gemini-antigravity'] !== gemini)
|
|
194
|
+
throw new AccountMigrationError(`${path} has conflicting rate-limit encodings`);
|
|
195
|
+
resets['gemini-antigravity'] = gemini;
|
|
196
|
+
}
|
|
197
|
+
normalized.rateLimitResetTimes = resets;
|
|
198
|
+
}
|
|
199
|
+
if (version < 4) {
|
|
200
|
+
delete normalized.fingerprint;
|
|
201
|
+
delete normalized.fingerprintHistory;
|
|
202
|
+
}
|
|
203
|
+
const metadata = decodeProviderMetadata(normalized);
|
|
204
|
+
return {
|
|
205
|
+
ordinal,
|
|
206
|
+
refreshToken: source.refreshToken,
|
|
207
|
+
metadata,
|
|
208
|
+
...(quota === undefined ? {} : { quota }),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function legacyRouting(source) {
|
|
212
|
+
const raw = { schemaVersion: 1 };
|
|
213
|
+
const unknown = {};
|
|
214
|
+
for (const [key, value] of Object.entries(source)) {
|
|
215
|
+
if (key === 'version' || key === 'accounts')
|
|
216
|
+
continue;
|
|
217
|
+
if (key === 'activeIndex' || key === 'activeIndexByFamily')
|
|
218
|
+
raw[key] = value;
|
|
219
|
+
else {
|
|
220
|
+
if ([
|
|
221
|
+
'refreshToken',
|
|
222
|
+
'accessToken',
|
|
223
|
+
'access',
|
|
224
|
+
'refresh',
|
|
225
|
+
'expires',
|
|
226
|
+
'apiKey',
|
|
227
|
+
].includes(key))
|
|
228
|
+
throw new AccountMigrationError('source-level credential field has no legacy account owner');
|
|
229
|
+
unknown[key] = value;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// legacySourceFields preserves unknown keys in legacy account JSON without
|
|
233
|
+
// interpreting them as the migration's schemaVersion or selected-row references.
|
|
234
|
+
if (Object.keys(unknown).length)
|
|
235
|
+
raw.legacySourceFields = unknown;
|
|
236
|
+
return decodeRoutingSettings(raw);
|
|
237
|
+
}
|
|
238
|
+
/** Imports source JSON directly: a normal legacy save would normalize email/cursors and lose per-model quota, unknown fields and null/absent distinctions. */
|
|
239
|
+
export function parseLegacyAccountStorage(bytes, normalizationClock) {
|
|
240
|
+
if (!finite(normalizationClock) || Object.is(normalizationClock, -0)) {
|
|
241
|
+
throw new AccountMigrationError('normalization clock must be finite and JSON-roundtrippable');
|
|
242
|
+
}
|
|
243
|
+
let parsed;
|
|
244
|
+
try {
|
|
245
|
+
parsed = JSON.parse(bytes);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
throw new AccountMigrationError('malformed source JSON');
|
|
249
|
+
}
|
|
250
|
+
assertSerializableNumbers(parsed);
|
|
251
|
+
const source = object(parsed, 'source');
|
|
252
|
+
const version = source.version;
|
|
253
|
+
if (version !== 1 && version !== 2 && version !== 3 && version !== 4) {
|
|
254
|
+
throw new AccountMigrationError('unsupported source version');
|
|
255
|
+
}
|
|
256
|
+
if (!Array.isArray(source.accounts)) {
|
|
257
|
+
throw new AccountMigrationError('source.accounts must be an array');
|
|
258
|
+
}
|
|
259
|
+
const parsedAccounts = source.accounts.map((row, index) => {
|
|
260
|
+
try {
|
|
261
|
+
return parseAccount(row, index, version, normalizationClock);
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (error instanceof AccountCodecError) {
|
|
265
|
+
throw new AccountMigrationError(`accounts[${index}]: ${error.message}`);
|
|
266
|
+
}
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
const winners = new Map();
|
|
271
|
+
for (const row of parsedAccounts) {
|
|
272
|
+
const email = row.metadata.email;
|
|
273
|
+
if (!email)
|
|
274
|
+
continue;
|
|
275
|
+
const prior = winners.get(email);
|
|
276
|
+
if (!prior ||
|
|
277
|
+
row.metadata.lastUsed > prior.metadata.lastUsed ||
|
|
278
|
+
(row.metadata.lastUsed === prior.metadata.lastUsed &&
|
|
279
|
+
row.metadata.addedAt > prior.metadata.addedAt)) {
|
|
280
|
+
winners.set(email, row);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const accounts = parsedAccounts.filter((row) => !row.metadata.email || winners.get(row.metadata.email) === row);
|
|
284
|
+
const secrets = new Map();
|
|
285
|
+
for (const row of accounts) {
|
|
286
|
+
const prior = secrets.get(row.refreshToken);
|
|
287
|
+
if (prior !== undefined) {
|
|
288
|
+
throw new AccountMigrationError(`duplicate refresh secret at ordinals ${prior}, ${row.ordinal}`);
|
|
289
|
+
}
|
|
290
|
+
secrets.set(row.refreshToken, row.ordinal);
|
|
291
|
+
}
|
|
292
|
+
const routing = legacyRouting(source);
|
|
293
|
+
const active = routing.activeIndex ?? 0;
|
|
294
|
+
const effectiveActiveIndex = accounts.length
|
|
295
|
+
? Math.max(0, Math.min(active, accounts.length - 1))
|
|
296
|
+
: 0;
|
|
297
|
+
return {
|
|
298
|
+
sourceVersion: version,
|
|
299
|
+
normalizationClock,
|
|
300
|
+
accounts,
|
|
301
|
+
routing,
|
|
302
|
+
effectiveActiveIndex,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/** Builds v4 JSON from stored account fields, preserving unknown fields and distinguishing null from absence. */
|
|
306
|
+
export function exportLegacyAccountStorage(accounts, routing) {
|
|
307
|
+
const out = encodeRoutingSettings(routing);
|
|
308
|
+
delete out.schemaVersion;
|
|
309
|
+
delete out.activeRow;
|
|
310
|
+
delete out.activeRowByFamily;
|
|
311
|
+
const unknown = out.legacySourceFields;
|
|
312
|
+
delete out.legacySourceFields;
|
|
313
|
+
if (unknown !== undefined) {
|
|
314
|
+
if (unknown === null ||
|
|
315
|
+
typeof unknown !== 'object' ||
|
|
316
|
+
Array.isArray(unknown))
|
|
317
|
+
throw new AccountMigrationError('legacy source extension namespace is malformed');
|
|
318
|
+
for (const [key, value] of Object.entries(unknown))
|
|
319
|
+
out[key] = value;
|
|
320
|
+
}
|
|
321
|
+
out.version = 4;
|
|
322
|
+
out.accounts = accounts.map((row) => {
|
|
323
|
+
const result = encodeProviderMetadata(row.metadata);
|
|
324
|
+
result.refreshToken = row.refreshToken;
|
|
325
|
+
if (row.quota !== undefined) {
|
|
326
|
+
const quota = encodeQuotaState(row.quota);
|
|
327
|
+
delete quota.schemaVersion;
|
|
328
|
+
for (const [key, value] of Object.entries(quota))
|
|
329
|
+
result[key] = value;
|
|
330
|
+
}
|
|
331
|
+
return result;
|
|
332
|
+
});
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
export const ACCOUNT_MIGRATION_PHASES = [
|
|
336
|
+
'capture',
|
|
337
|
+
'build',
|
|
338
|
+
'verify',
|
|
339
|
+
'retire',
|
|
340
|
+
'activate',
|
|
341
|
+
];
|
|
342
|
+
/** ACCOUNT_MIGRATION_PHASES also supplies the allowed antigravityManagement.progress.step names for migration work. */
|
|
343
|
+
export const ACCOUNT_MIGRATION_MANAGEMENT_STEPS = ACCOUNT_MIGRATION_PHASES;
|
|
344
|
+
export const ACCOUNT_STORE_GENERATION_SETTINGS_KEY = 'antigravityGeneration';
|
|
345
|
+
/** Identifies the published journal UUID in pool settings; account refresh/add/remove/reorder must preserve this generation identity. */
|
|
346
|
+
export function decodeAccountStoreGeneration(value) {
|
|
347
|
+
const raw = exact(value, [
|
|
348
|
+
'schemaVersion',
|
|
349
|
+
'id',
|
|
350
|
+
'legacyPath',
|
|
351
|
+
'storeDir',
|
|
352
|
+
'sourceKind',
|
|
353
|
+
'initialManifestSha256',
|
|
354
|
+
], 'generation');
|
|
355
|
+
if (raw.schemaVersion !== 1 ||
|
|
356
|
+
typeof raw.legacyPath !== 'string' ||
|
|
357
|
+
(raw.sourceKind !== 'file' && raw.sourceKind !== 'absent'))
|
|
358
|
+
throw new AccountMigrationError('generation schema is invalid');
|
|
359
|
+
const id = text(raw.id, UUID, 'generation id');
|
|
360
|
+
const paths = generationPaths(raw.legacyPath, id);
|
|
361
|
+
if (raw.storeDir !== paths.storeDir)
|
|
362
|
+
throw new AccountMigrationError('generation successor association differs');
|
|
363
|
+
return {
|
|
364
|
+
schemaVersion: 1,
|
|
365
|
+
id,
|
|
366
|
+
legacyPath: paths.legacyPath,
|
|
367
|
+
storeDir: paths.storeDir,
|
|
368
|
+
sourceKind: raw.sourceKind,
|
|
369
|
+
initialManifestSha256: text(raw.initialManifestSha256, SHA256, 'generation manifest hash'),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
function generationJson(journal) {
|
|
373
|
+
return {
|
|
374
|
+
schemaVersion: 1,
|
|
375
|
+
id: journal.id,
|
|
376
|
+
legacyPath: journal.legacyPath,
|
|
377
|
+
storeDir: journal.storeDir,
|
|
378
|
+
sourceKind: journal.sourceKind,
|
|
379
|
+
initialManifestSha256: digest(canonical(manifestJson(journal.manifest))),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
383
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
384
|
+
function exact(value, keys, path) {
|
|
385
|
+
const record = object(value, path);
|
|
386
|
+
if (Object.keys(record).some((key) => !keys.includes(key))) {
|
|
387
|
+
throw new AccountMigrationError(`${path} has unknown fields`);
|
|
388
|
+
}
|
|
389
|
+
return record;
|
|
390
|
+
}
|
|
391
|
+
function text(value, pattern, path) {
|
|
392
|
+
if (typeof value !== 'string' || !pattern.test(value))
|
|
393
|
+
throw new AccountMigrationError(`${path} is invalid`);
|
|
394
|
+
return value;
|
|
395
|
+
}
|
|
396
|
+
function list(value, path) {
|
|
397
|
+
if (!Array.isArray(value))
|
|
398
|
+
throw new AccountMigrationError(`${path} must be an array`);
|
|
399
|
+
if (Object.getOwnPropertySymbols(value).length ||
|
|
400
|
+
Object.getOwnPropertyNames(value).length !== value.length + 1 ||
|
|
401
|
+
Array.from({ length: value.length }, (_, index) => Object.getOwnPropertyDescriptor(value, String(index))).some((descriptor) => !descriptor?.enumerable || !('value' in descriptor)))
|
|
402
|
+
throw new AccountMigrationError(`${path} must be a dense plain JSON array`);
|
|
403
|
+
return value;
|
|
404
|
+
}
|
|
405
|
+
function integer(value, path) {
|
|
406
|
+
if (!Number.isSafeInteger(value) || typeof value !== 'number' || value < 0)
|
|
407
|
+
throw new AccountMigrationError(`${path} must be a nonnegative safe integer`);
|
|
408
|
+
return value;
|
|
409
|
+
}
|
|
410
|
+
function digest(bytes) {
|
|
411
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
412
|
+
}
|
|
413
|
+
function canonical(value) {
|
|
414
|
+
if (Array.isArray(value))
|
|
415
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
416
|
+
if (value !== null && typeof value === 'object') {
|
|
417
|
+
return `{${Object.entries(value)
|
|
418
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
419
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`)
|
|
420
|
+
.join(',')}}`;
|
|
421
|
+
}
|
|
422
|
+
const encoded = JSON.stringify(value);
|
|
423
|
+
if (encoded === undefined)
|
|
424
|
+
throw new AccountMigrationError('non-JSON journal value');
|
|
425
|
+
return encoded;
|
|
426
|
+
}
|
|
427
|
+
function manifestJson(manifest) {
|
|
428
|
+
return {
|
|
429
|
+
...manifest,
|
|
430
|
+
accounts: manifest.accounts.map((row) => ({
|
|
431
|
+
ordinal: row.ordinal,
|
|
432
|
+
refreshToken: row.refreshToken,
|
|
433
|
+
metadata: encodeProviderMetadata(row.metadata),
|
|
434
|
+
...(row.quota === undefined
|
|
435
|
+
? {}
|
|
436
|
+
: { quota: encodeQuotaState(row.quota) }),
|
|
437
|
+
})),
|
|
438
|
+
routing: encodeRoutingSettings(manifest.routing),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
function journalJson(journal) {
|
|
442
|
+
return { ...journal, manifest: manifestJson(journal.manifest) };
|
|
443
|
+
}
|
|
444
|
+
/** Validates saved journal fields as-is; it never reselects email winners or expires resets using a new time. */
|
|
445
|
+
export function decodeAccountMigrationJournal(value) {
|
|
446
|
+
const raw = exact(value, [
|
|
447
|
+
'schemaVersion',
|
|
448
|
+
'id',
|
|
449
|
+
'legacyPath',
|
|
450
|
+
'storeDir',
|
|
451
|
+
'status',
|
|
452
|
+
'operation',
|
|
453
|
+
'phase',
|
|
454
|
+
'sourceKind',
|
|
455
|
+
'sourceSha256',
|
|
456
|
+
'captureSha256',
|
|
457
|
+
'sourceAbsenceVerified',
|
|
458
|
+
'manifest',
|
|
459
|
+
'mapping',
|
|
460
|
+
'completedRows',
|
|
461
|
+
'verification',
|
|
462
|
+
'retiredSha256',
|
|
463
|
+
'rollback',
|
|
464
|
+
'cancelled',
|
|
465
|
+
], 'journal');
|
|
466
|
+
if (raw.schemaVersion !== 1)
|
|
467
|
+
throw new AccountMigrationError('unsupported journal schema');
|
|
468
|
+
const id = text(raw.id, UUID, 'journal.id');
|
|
469
|
+
if (typeof raw.legacyPath !== 'string')
|
|
470
|
+
throw new AccountMigrationError('journal legacy path is invalid');
|
|
471
|
+
const paths = generationPaths(raw.legacyPath, id);
|
|
472
|
+
if (raw.storeDir !== paths.storeDir)
|
|
473
|
+
throw new AccountMigrationError('journal successor association differs');
|
|
474
|
+
const status = raw.status;
|
|
475
|
+
if (status !== 'pending' && status !== 'active' && status !== 'inactive')
|
|
476
|
+
throw new AccountMigrationError('journal status is invalid');
|
|
477
|
+
const operation = raw.operation;
|
|
478
|
+
if (operation !== 'migrate' && operation !== 'rollback')
|
|
479
|
+
throw new AccountMigrationError('journal operation is invalid');
|
|
480
|
+
const phase = ACCOUNT_MIGRATION_PHASES.find((candidate) => candidate === raw.phase);
|
|
481
|
+
if (phase === undefined)
|
|
482
|
+
throw new AccountMigrationError('journal phase is invalid');
|
|
483
|
+
const sourceKind = raw.sourceKind;
|
|
484
|
+
if (sourceKind !== 'file' && sourceKind !== 'absent')
|
|
485
|
+
throw new AccountMigrationError('journal source kind is invalid');
|
|
486
|
+
const sourceSha256 = sourceKind === 'file'
|
|
487
|
+
? text(raw.sourceSha256, SHA256, 'journal.sourceSha256')
|
|
488
|
+
: undefined;
|
|
489
|
+
const captureSha256 = sourceKind === 'file'
|
|
490
|
+
? text(raw.captureSha256, SHA256, 'journal.captureSha256')
|
|
491
|
+
: undefined;
|
|
492
|
+
if (sourceKind === 'absent' && Object.hasOwn(raw, 'captureSha256'))
|
|
493
|
+
throw new AccountMigrationError('absent source cannot claim a captured file snapshot');
|
|
494
|
+
if (sourceKind === 'absent' && Object.hasOwn(raw, 'sourceSha256'))
|
|
495
|
+
throw new AccountMigrationError('absent source cannot claim a backup hash');
|
|
496
|
+
if (Object.hasOwn(raw, 'sourceAbsenceVerified') &&
|
|
497
|
+
(sourceKind !== 'absent' || raw.sourceAbsenceVerified !== true))
|
|
498
|
+
throw new AccountMigrationError('source absence association is invalid');
|
|
499
|
+
const m = exact(raw.manifest, [
|
|
500
|
+
'sourceVersion',
|
|
501
|
+
'normalizationClock',
|
|
502
|
+
'accounts',
|
|
503
|
+
'routing',
|
|
504
|
+
'effectiveActiveIndex',
|
|
505
|
+
], 'manifest');
|
|
506
|
+
const sourceVersion = m.sourceVersion;
|
|
507
|
+
if (sourceVersion !== null &&
|
|
508
|
+
sourceVersion !== 1 &&
|
|
509
|
+
sourceVersion !== 2 &&
|
|
510
|
+
sourceVersion !== 3 &&
|
|
511
|
+
sourceVersion !== 4)
|
|
512
|
+
throw new AccountMigrationError('manifest source version is invalid');
|
|
513
|
+
if ((sourceKind === 'file' && sourceVersion === null) ||
|
|
514
|
+
(sourceKind === 'absent' && sourceVersion !== null))
|
|
515
|
+
throw new AccountMigrationError('manifest version differs from source kind');
|
|
516
|
+
if (!finite(m.normalizationClock) || !finite(m.effectiveActiveIndex))
|
|
517
|
+
throw new AccountMigrationError('manifest clock or cursor is invalid');
|
|
518
|
+
const accounts = list(m.accounts, 'manifest.accounts').map((entry) => {
|
|
519
|
+
const row = exact(entry, ['ordinal', 'refreshToken', 'metadata', 'quota'], 'manifest row');
|
|
520
|
+
if (typeof row.refreshToken !== 'string' ||
|
|
521
|
+
!row.refreshToken.trim().length)
|
|
522
|
+
throw new AccountMigrationError('manifest refresh secret is empty');
|
|
523
|
+
return {
|
|
524
|
+
ordinal: integer(row.ordinal, 'manifest ordinal'),
|
|
525
|
+
refreshToken: row.refreshToken,
|
|
526
|
+
metadata: decodeProviderMetadata(row.metadata),
|
|
527
|
+
...(Object.hasOwn(row, 'quota')
|
|
528
|
+
? { quota: decodeQuotaState(row.quota) }
|
|
529
|
+
: {}),
|
|
530
|
+
};
|
|
531
|
+
});
|
|
532
|
+
const ordinals = accounts.map((row) => row.ordinal);
|
|
533
|
+
if (new Set(ordinals).size !== accounts.length ||
|
|
534
|
+
ordinals.some((ordinal, i) => i > 0 && ordinal <= (ordinals[i - 1] ?? -1)) ||
|
|
535
|
+
new Set(accounts.map((row) => row.refreshToken)).size !== accounts.length)
|
|
536
|
+
throw new AccountMigrationError('manifest order or secrets are duplicated');
|
|
537
|
+
const manifest = {
|
|
538
|
+
sourceVersion,
|
|
539
|
+
normalizationClock: m.normalizationClock,
|
|
540
|
+
accounts,
|
|
541
|
+
routing: decodeRoutingSettings(m.routing),
|
|
542
|
+
effectiveActiveIndex: m.effectiveActiveIndex,
|
|
543
|
+
};
|
|
544
|
+
assertSerializableNumbers(manifestJson(manifest));
|
|
545
|
+
if (sourceKind === 'absent' &&
|
|
546
|
+
(accounts.length ||
|
|
547
|
+
!isDeepStrictEqual(encodeRoutingSettings(manifest.routing), {
|
|
548
|
+
schemaVersion: 1,
|
|
549
|
+
}) ||
|
|
550
|
+
manifest.effectiveActiveIndex !== 0))
|
|
551
|
+
throw new AccountMigrationError('absent-source manifest must be explicitly empty with no invented cursors');
|
|
552
|
+
const effective = accounts.length
|
|
553
|
+
? Math.max(0, Math.min(manifest.routing.activeIndex ?? 0, accounts.length - 1))
|
|
554
|
+
: 0;
|
|
555
|
+
if (effective !== manifest.effectiveActiveIndex)
|
|
556
|
+
throw new AccountMigrationError('manifest effective cursor differs');
|
|
557
|
+
const mapping = list(raw.mapping, 'mapping').map((entry) => {
|
|
558
|
+
const row = exact(entry, ['id', 'ordinal'], 'mapping row');
|
|
559
|
+
return {
|
|
560
|
+
id: text(row.id, UUID, 'mapping id'),
|
|
561
|
+
ordinal: integer(row.ordinal, 'mapping ordinal'),
|
|
562
|
+
};
|
|
563
|
+
});
|
|
564
|
+
if (mapping.length !== accounts.length ||
|
|
565
|
+
new Set(mapping.map((row) => row.id)).size !== mapping.length ||
|
|
566
|
+
mapping.some((row, i) => row.ordinal !== accounts[i]?.ordinal))
|
|
567
|
+
throw new AccountMigrationError('mapping differs from full manifest order');
|
|
568
|
+
const completedRows = list(raw.completedRows, 'completedRows').map((entry) => text(entry, UUID, 'completed row'));
|
|
569
|
+
if (new Set(completedRows).size !== completedRows.length ||
|
|
570
|
+
completedRows.some((row) => !mapping.some((mapped) => mapped.id === row)))
|
|
571
|
+
throw new AccountMigrationError('completed rows do not belong to this journal');
|
|
572
|
+
if (completedRows.some((id, index) => id !== mapping[index]?.id))
|
|
573
|
+
throw new AccountMigrationError('completed rows are not the captured prefix order');
|
|
574
|
+
let verification;
|
|
575
|
+
if (Object.hasOwn(raw, 'verification')) {
|
|
576
|
+
const v = exact(raw.verification, [
|
|
577
|
+
'manifestSha256',
|
|
578
|
+
'configSha256',
|
|
579
|
+
'stateSha256',
|
|
580
|
+
'statePresent',
|
|
581
|
+
'durability',
|
|
582
|
+
], 'verification');
|
|
583
|
+
if (typeof v.statePresent !== 'boolean')
|
|
584
|
+
throw new AccountMigrationError('state presence encoding is invalid');
|
|
585
|
+
if (!v.statePresent && Object.hasOwn(v, 'stateSha256'))
|
|
586
|
+
throw new AccountMigrationError('absent successor state cannot claim a file hash');
|
|
587
|
+
if (v.durability !== 'posix-directory-synced' &&
|
|
588
|
+
v.durability !== 'windows-reopened-no-directory-power-loss-guarantee')
|
|
589
|
+
throw new AccountMigrationError('verification durability is invalid');
|
|
590
|
+
verification = {
|
|
591
|
+
manifestSha256: text(v.manifestSha256, SHA256, 'manifest hash'),
|
|
592
|
+
configSha256: text(v.configSha256, SHA256, 'config hash'),
|
|
593
|
+
...(v.statePresent
|
|
594
|
+
? { stateSha256: text(v.stateSha256, SHA256, 'state hash') }
|
|
595
|
+
: {}),
|
|
596
|
+
statePresent: v.statePresent,
|
|
597
|
+
durability: v.durability,
|
|
598
|
+
};
|
|
599
|
+
if (verification.manifestSha256 !== digest(canonical(manifestJson(manifest))))
|
|
600
|
+
throw new AccountMigrationError('verification is not associated with the complete manifest');
|
|
601
|
+
}
|
|
602
|
+
const retiredSha256 = Object.hasOwn(raw, 'retiredSha256')
|
|
603
|
+
? text(raw.retiredSha256, SHA256, 'retired hash')
|
|
604
|
+
: undefined;
|
|
605
|
+
if (sourceKind === 'absent' && retiredSha256 !== undefined)
|
|
606
|
+
throw new AccountMigrationError('absent source cannot claim retired bytes');
|
|
607
|
+
if (retiredSha256 !== undefined && retiredSha256 !== sourceSha256)
|
|
608
|
+
throw new AccountMigrationError('retired bytes are not associated with source');
|
|
609
|
+
let rollback;
|
|
610
|
+
if (Object.hasOwn(raw, 'rollback')) {
|
|
611
|
+
const r = exact(raw.rollback, ['id', 'snapshotSha256', 'exportSha256', 'rows', 'successorInactive'], 'rollback');
|
|
612
|
+
const rows = list(r.rows, 'rollback rows').map((id) => text(id, UUID, 'rollback row id'));
|
|
613
|
+
if (new Set(rows).size !== rows.length ||
|
|
614
|
+
typeof r.successorInactive !== 'boolean')
|
|
615
|
+
throw new AccountMigrationError('rollback rows or inactive flag is invalid');
|
|
616
|
+
rollback = {
|
|
617
|
+
id: text(r.id, UUID, 'rollback id'),
|
|
618
|
+
snapshotSha256: text(r.snapshotSha256, SHA256, 'snapshot hash'),
|
|
619
|
+
exportSha256: text(r.exportSha256, SHA256, 'export hash'),
|
|
620
|
+
rows,
|
|
621
|
+
successorInactive: r.successorInactive,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
if (Object.hasOwn(raw, 'cancelled') && raw.cancelled !== true)
|
|
625
|
+
throw new AccountMigrationError('cancellation flag is invalid');
|
|
626
|
+
const cancelled = raw.cancelled === true;
|
|
627
|
+
if (cancelled &&
|
|
628
|
+
(status === 'active' ||
|
|
629
|
+
operation !== 'rollback' ||
|
|
630
|
+
(status === 'inactive' && phase !== 'activate') ||
|
|
631
|
+
rollback !== undefined ||
|
|
632
|
+
retiredSha256 !== undefined))
|
|
633
|
+
throw new AccountMigrationError('invalid pre-retirement cancellation');
|
|
634
|
+
if ((phase === 'retire' || phase === 'activate' || status === 'active') &&
|
|
635
|
+
!cancelled &&
|
|
636
|
+
verification === undefined)
|
|
637
|
+
throw new AccountMigrationError('retirement lacks durable full-row verification');
|
|
638
|
+
if (operation === 'migrate' &&
|
|
639
|
+
verification !== undefined &&
|
|
640
|
+
completedRows.length !== mapping.length)
|
|
641
|
+
throw new AccountMigrationError('verification acknowledged an incomplete import');
|
|
642
|
+
if (operation === 'migrate' &&
|
|
643
|
+
(retiredSha256 !== undefined || raw.sourceAbsenceVerified === true) &&
|
|
644
|
+
(!verification || (phase !== 'retire' && phase !== 'activate')))
|
|
645
|
+
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))
|
|
651
|
+
throw new AccountMigrationError('activation lacks its actual source retirement association');
|
|
652
|
+
if (status === 'active' &&
|
|
653
|
+
(operation !== 'migrate' ||
|
|
654
|
+
phase !== 'activate' ||
|
|
655
|
+
(sourceKind === 'file'
|
|
656
|
+
? retiredSha256 === undefined
|
|
657
|
+
: raw.sourceAbsenceVerified !== true) ||
|
|
658
|
+
rollback !== undefined ||
|
|
659
|
+
cancelled))
|
|
660
|
+
throw new AccountMigrationError('active receipt is incomplete');
|
|
661
|
+
if (operation === 'rollback' && rollback === undefined && !cancelled)
|
|
662
|
+
throw new AccountMigrationError('rollback lacks its current-credential snapshot');
|
|
663
|
+
if (operation === 'rollback' &&
|
|
664
|
+
!cancelled &&
|
|
665
|
+
(verification === undefined ||
|
|
666
|
+
(sourceKind === 'file'
|
|
667
|
+
? retiredSha256 === undefined
|
|
668
|
+
: raw.sourceAbsenceVerified !== true)))
|
|
669
|
+
throw new AccountMigrationError('rollback lacks the original verified retirement');
|
|
670
|
+
if (status === 'inactive' &&
|
|
671
|
+
!cancelled &&
|
|
672
|
+
(operation !== 'rollback' || phase !== 'activate' || rollback === undefined))
|
|
673
|
+
throw new AccountMigrationError('inactive receipt lacks a completed rollback');
|
|
674
|
+
if (operation === 'rollback' &&
|
|
675
|
+
(phase === 'activate' || status === 'inactive') &&
|
|
676
|
+
!cancelled &&
|
|
677
|
+
!rollback?.successorInactive)
|
|
678
|
+
throw new AccountMigrationError('rollback restoration preceded successor inactivity');
|
|
679
|
+
return {
|
|
680
|
+
schemaVersion: 1,
|
|
681
|
+
id,
|
|
682
|
+
legacyPath: paths.legacyPath,
|
|
683
|
+
storeDir: paths.storeDir,
|
|
684
|
+
status,
|
|
685
|
+
operation,
|
|
686
|
+
phase,
|
|
687
|
+
sourceKind,
|
|
688
|
+
...(sourceSha256 === undefined ? {} : { sourceSha256 }),
|
|
689
|
+
...(captureSha256 === undefined ? {} : { captureSha256 }),
|
|
690
|
+
...(raw.sourceAbsenceVerified === true
|
|
691
|
+
? { sourceAbsenceVerified: true }
|
|
692
|
+
: {}),
|
|
693
|
+
manifest,
|
|
694
|
+
mapping,
|
|
695
|
+
completedRows,
|
|
696
|
+
...(verification === undefined ? {} : { verification }),
|
|
697
|
+
...(retiredSha256 === undefined ? {} : { retiredSha256 }),
|
|
698
|
+
...(rollback === undefined ? {} : { rollback }),
|
|
699
|
+
...(cancelled ? { cancelled: true } : {}),
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
function pendingLockFailure(modules, error) {
|
|
703
|
+
if (error instanceof modules.fs.LockContentionError)
|
|
704
|
+
return { status: 'pending', reason: 'lock-contention' };
|
|
705
|
+
if (error instanceof modules.fs.LockOwnershipError)
|
|
706
|
+
return { status: 'pending', reason: 'ownership-lost' };
|
|
707
|
+
if (error instanceof modules.store.PoolOperationError && error.retryable) {
|
|
708
|
+
if (error.kind === 'lock-contention')
|
|
709
|
+
return { status: 'pending', reason: 'lock-contention' };
|
|
710
|
+
if (error.kind === 'lock-ownership')
|
|
711
|
+
return { status: 'pending', reason: 'ownership-lost' };
|
|
712
|
+
}
|
|
713
|
+
return undefined;
|
|
714
|
+
}
|
|
715
|
+
function errno(error) {
|
|
716
|
+
return error !== null &&
|
|
717
|
+
typeof error === 'object' &&
|
|
718
|
+
'code' in error &&
|
|
719
|
+
typeof error.code === 'string'
|
|
720
|
+
? error.code
|
|
721
|
+
: undefined;
|
|
722
|
+
}
|
|
723
|
+
function sameFilesystemPath(left, right) {
|
|
724
|
+
return process.platform === 'win32'
|
|
725
|
+
? left.toLowerCase() === right.toLowerCase()
|
|
726
|
+
: left === right;
|
|
727
|
+
}
|
|
728
|
+
async function exists(path) {
|
|
729
|
+
try {
|
|
730
|
+
await lstat(path);
|
|
731
|
+
return true;
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
if (errno(error) === 'ENOENT')
|
|
735
|
+
return false;
|
|
736
|
+
throw error;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
function privateMode(mode, expected) {
|
|
740
|
+
// Windows may ignore mode bits. They are attempted on creation but do not
|
|
741
|
+
// prove DACL privacy; operators must secure their custom directory's ACLs.
|
|
742
|
+
if (process.platform !== 'win32' && (mode & 0o777) !== expected)
|
|
743
|
+
throw new AccountMigrationError('insecure POSIX file or directory permissions');
|
|
744
|
+
}
|
|
745
|
+
async function privateDirectory(path) {
|
|
746
|
+
try {
|
|
747
|
+
await mkdir(path, { mode: 0o700 });
|
|
748
|
+
}
|
|
749
|
+
catch (error) {
|
|
750
|
+
if (errno(error) !== 'EEXIST')
|
|
751
|
+
throw error;
|
|
752
|
+
}
|
|
753
|
+
const stat = await lstat(path);
|
|
754
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
755
|
+
throw new AccountMigrationError('successor directory is not a regular directory');
|
|
756
|
+
privateMode(stat.mode, 0o700);
|
|
757
|
+
if (!sameFilesystemPath(await realpath(path), path))
|
|
758
|
+
throw new AccountMigrationError('successor directory traverses a symbolic link');
|
|
759
|
+
}
|
|
760
|
+
async function prepareDirectories(paths) {
|
|
761
|
+
if (!sameFilesystemPath(await realpath(dirname(paths.legacyPath)), dirname(paths.legacyPath)))
|
|
762
|
+
throw new AccountMigrationError('legacy parent traverses a symbolic link');
|
|
763
|
+
for (const path of [
|
|
764
|
+
paths.storeDir,
|
|
765
|
+
paths.backupsDir,
|
|
766
|
+
paths.retiredDir,
|
|
767
|
+
paths.transfersDir,
|
|
768
|
+
])
|
|
769
|
+
await privateDirectory(path);
|
|
770
|
+
}
|
|
771
|
+
async function secureRead(path) {
|
|
772
|
+
if (!sameFilesystemPath(await realpath(dirname(path)), dirname(path)))
|
|
773
|
+
throw new AccountMigrationError('file path traverses a symbolic link');
|
|
774
|
+
const before = await lstat(path);
|
|
775
|
+
if (!before.isFile() || before.isSymbolicLink())
|
|
776
|
+
throw new AccountMigrationError('credential-bearing path is not a regular file');
|
|
777
|
+
privateMode(before.mode, 0o600);
|
|
778
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
779
|
+
try {
|
|
780
|
+
const opened = await handle.stat();
|
|
781
|
+
if (!opened.isFile() ||
|
|
782
|
+
before.dev !== opened.dev ||
|
|
783
|
+
before.ino !== opened.ino)
|
|
784
|
+
throw new AccountMigrationError('file identity changed while opening');
|
|
785
|
+
const bytes = await handle.readFile();
|
|
786
|
+
const after = await lstat(path);
|
|
787
|
+
if (after.dev !== opened.dev ||
|
|
788
|
+
after.ino !== opened.ino ||
|
|
789
|
+
after.isSymbolicLink())
|
|
790
|
+
throw new AccountMigrationError('file identity changed while reading');
|
|
791
|
+
return bytes;
|
|
792
|
+
}
|
|
793
|
+
finally {
|
|
794
|
+
await handle.close();
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
function jsonBytes(bytes) {
|
|
798
|
+
try {
|
|
799
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
throw new AccountMigrationError('malformed or non-UTF8 durable JSON');
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
async function syncFile(path) {
|
|
806
|
+
await secureRead(path);
|
|
807
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
808
|
+
try {
|
|
809
|
+
await handle.sync();
|
|
810
|
+
}
|
|
811
|
+
finally {
|
|
812
|
+
await handle.close();
|
|
813
|
+
}
|
|
814
|
+
await secureRead(path);
|
|
815
|
+
}
|
|
816
|
+
export function accountMigrationDurability(platform = process.platform) {
|
|
817
|
+
return platform === 'win32'
|
|
818
|
+
? 'windows-reopened-no-directory-power-loss-guarantee'
|
|
819
|
+
: 'posix-directory-synced';
|
|
820
|
+
}
|
|
821
|
+
async function syncDirectory(path, boundary) {
|
|
822
|
+
if (process.platform === 'win32')
|
|
823
|
+
return;
|
|
824
|
+
await boundary?.('before-directory-sync');
|
|
825
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
826
|
+
try {
|
|
827
|
+
await handle.sync();
|
|
828
|
+
}
|
|
829
|
+
finally {
|
|
830
|
+
await handle.close();
|
|
831
|
+
}
|
|
832
|
+
await boundary?.('after-directory-sync');
|
|
833
|
+
}
|
|
834
|
+
async function exclusiveBytes(path, bytes, expectedHash, boundary) {
|
|
835
|
+
let handle;
|
|
836
|
+
try {
|
|
837
|
+
handle = await open(path, 'wx', 0o600);
|
|
838
|
+
}
|
|
839
|
+
catch (error) {
|
|
840
|
+
if (errno(error) !== 'EEXIST')
|
|
841
|
+
throw error;
|
|
842
|
+
}
|
|
843
|
+
if (handle !== undefined) {
|
|
844
|
+
try {
|
|
845
|
+
await handle.writeFile(bytes);
|
|
846
|
+
await handle.sync();
|
|
847
|
+
}
|
|
848
|
+
finally {
|
|
849
|
+
await handle.close();
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
// Reuse a backup, retired copy or export stage only after comparing reopened
|
|
853
|
+
// bytes, expected SHA256 and restrictive mode; a matching filename is insufficient.
|
|
854
|
+
const reopened = await secureRead(path);
|
|
855
|
+
if (digest(reopened) !== expectedHash || !reopened.equals(bytes))
|
|
856
|
+
throw new AccountMigrationError('exclusive backup or recovery copy differs');
|
|
857
|
+
await syncFile(path);
|
|
858
|
+
await syncDirectory(dirname(path), boundary);
|
|
859
|
+
}
|
|
860
|
+
async function readJournal(paths) {
|
|
861
|
+
if (!(await exists(paths.storeDir)))
|
|
862
|
+
return undefined;
|
|
863
|
+
const directory = await lstat(paths.storeDir);
|
|
864
|
+
if (!directory.isDirectory() ||
|
|
865
|
+
directory.isSymbolicLink() ||
|
|
866
|
+
!sameFilesystemPath(await realpath(paths.storeDir), paths.storeDir))
|
|
867
|
+
throw new AccountMigrationError('journal directory is an alias or non-directory');
|
|
868
|
+
privateMode(directory.mode, 0o700);
|
|
869
|
+
if (!(await exists(paths.migrationPath)))
|
|
870
|
+
return undefined;
|
|
871
|
+
const journal = decodeAccountMigrationJournal(jsonBytes(await secureRead(paths.migrationPath)));
|
|
872
|
+
if (journal.legacyPath !== paths.legacyPath ||
|
|
873
|
+
journal.storeDir !== paths.storeDir)
|
|
874
|
+
throw new AccountMigrationError('journal belongs to another source');
|
|
875
|
+
return journal;
|
|
876
|
+
}
|
|
877
|
+
async function saveJournal(modules, paths, lease, journal, initial = false, boundary) {
|
|
878
|
+
decodeAccountMigrationJournal(journalJson(journal));
|
|
879
|
+
const stage = journal.id;
|
|
880
|
+
const journalTemp = `${paths.migrationPath}.${stage}.tmp`;
|
|
881
|
+
if (await exists(journalTemp)) {
|
|
882
|
+
const current = await readJournal(paths);
|
|
883
|
+
if (current?.id !== journal.id)
|
|
884
|
+
throw new AccountMigrationError('journal staging file has no durable generation owner');
|
|
885
|
+
await secureRead(journalTemp);
|
|
886
|
+
await lease.assertOwned();
|
|
887
|
+
await unlink(journalTemp);
|
|
888
|
+
}
|
|
889
|
+
await lease.assertOwned();
|
|
890
|
+
await modules.fs.writeJsonAtomic(paths.migrationPath, journalJson(journal), {
|
|
891
|
+
stageName: () => stage,
|
|
892
|
+
beforeRename: async () => {
|
|
893
|
+
await lease.assertOwned();
|
|
894
|
+
const current = await readJournal(paths);
|
|
895
|
+
const owned = typeof initial === 'string'
|
|
896
|
+
? current?.id === initial && current.status === 'inactive'
|
|
897
|
+
: initial
|
|
898
|
+
? current === undefined
|
|
899
|
+
: current?.id === journal.id;
|
|
900
|
+
if (!owned)
|
|
901
|
+
throw new AccountMigrationError('journal ownership changed before write');
|
|
902
|
+
await syncFile(`${paths.migrationPath}.${stage}.tmp`);
|
|
903
|
+
await lease.assertOwned();
|
|
904
|
+
},
|
|
905
|
+
});
|
|
906
|
+
await syncFile(paths.migrationPath);
|
|
907
|
+
await syncDirectory(paths.storeDir, boundary);
|
|
908
|
+
const reopened = await readJournal(paths);
|
|
909
|
+
if (!reopened ||
|
|
910
|
+
!isDeepStrictEqual(journalJson(reopened), journalJson(journal)))
|
|
911
|
+
throw new AccountMigrationError('journal readback differs');
|
|
912
|
+
await lease.assertOwned();
|
|
913
|
+
}
|
|
914
|
+
function openMigrationStore(modules, paths, now, onStep) {
|
|
915
|
+
return modules.store.openPoolStore({
|
|
916
|
+
provider: ACCOUNT_STORE_PROVIDER,
|
|
917
|
+
configPath: paths.configPath,
|
|
918
|
+
statePath: paths.statePath,
|
|
919
|
+
quota: QUOTA_CODEC,
|
|
920
|
+
providerState: {
|
|
921
|
+
validate: isValidProviderState,
|
|
922
|
+
credentialBound: providerStateCredentialBound,
|
|
923
|
+
merge(previous, incoming) {
|
|
924
|
+
// A retry may finish a credential written before its roster row. Recorded
|
|
925
|
+
// and incoming provider state must be identical; differences do not authorize
|
|
926
|
+
// combining metadata or overwriting another credential context.
|
|
927
|
+
if (!isDeepStrictEqual(previous, incoming))
|
|
928
|
+
throw new AccountMigrationError('retry provider state differs from captured manifest');
|
|
929
|
+
return incoming;
|
|
930
|
+
},
|
|
931
|
+
onReplace() {
|
|
932
|
+
throw new AccountMigrationError('offline migration never replaces a credential');
|
|
933
|
+
},
|
|
934
|
+
},
|
|
935
|
+
requireCredentialStamps: true,
|
|
936
|
+
now,
|
|
937
|
+
lockOptions: { timeoutMs: 2_000 },
|
|
938
|
+
...(onStep === undefined ? {} : { onStep }),
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
async function rowsOf(store) {
|
|
942
|
+
const read = await store.read();
|
|
943
|
+
if (read.status !== 'ready')
|
|
944
|
+
throw new AccountMigrationError('successor is not a ready public store');
|
|
945
|
+
return read.rows;
|
|
946
|
+
}
|
|
947
|
+
async function settingsOf(store) {
|
|
948
|
+
const read = await store.readSettings();
|
|
949
|
+
if (read.status !== 'ready')
|
|
950
|
+
throw new AccountMigrationError('successor settings are not ready');
|
|
951
|
+
return read.settings;
|
|
952
|
+
}
|
|
953
|
+
function rowRef(row) {
|
|
954
|
+
const epoch = row.credentialEpoch ?? 1;
|
|
955
|
+
if (!Number.isSafeInteger(epoch) || epoch < 1)
|
|
956
|
+
throw new AccountMigrationError('invalid credential epoch');
|
|
957
|
+
return {
|
|
958
|
+
id: row.id,
|
|
959
|
+
credentialEpoch: epoch,
|
|
960
|
+
...(row.identity === undefined ? {} : { identity: row.identity }),
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
function healthyRow(row, allowTorn = false) {
|
|
964
|
+
if (row.type !== 'oauth' ||
|
|
965
|
+
row.credential?.type !== 'oauth' ||
|
|
966
|
+
!row.credential.refresh.length ||
|
|
967
|
+
row.hasEntry !== true ||
|
|
968
|
+
row.stamp !== 'bound' ||
|
|
969
|
+
(row.torn && !allowTorn) ||
|
|
970
|
+
row.unbound ||
|
|
971
|
+
row.invalid ||
|
|
972
|
+
row.providerStateDropped ||
|
|
973
|
+
row.providerState === undefined ||
|
|
974
|
+
(row.enabled && !row.candidate && !(allowTorn && row.torn)))
|
|
975
|
+
throw new AccountMigrationError('successor row is missing, unbound, invalid, torn or dropped');
|
|
976
|
+
rowRef(row);
|
|
977
|
+
decodeProviderState(row.providerState);
|
|
978
|
+
if (row.quota !== undefined)
|
|
979
|
+
decodeQuotaState(row.quota);
|
|
980
|
+
}
|
|
981
|
+
function expectedRouting(journal, rows) {
|
|
982
|
+
const routing = { ...journal.manifest.routing };
|
|
983
|
+
const active = rows[journal.manifest.effectiveActiveIndex];
|
|
984
|
+
if (active !== undefined)
|
|
985
|
+
routing.activeRow = rowRef(active);
|
|
986
|
+
const family = journal.manifest.routing.activeIndexByFamily;
|
|
987
|
+
const refs = {};
|
|
988
|
+
for (const name of ['claude', 'gemini']) {
|
|
989
|
+
const cursor = family?.[name] ?? journal.manifest.effectiveActiveIndex;
|
|
990
|
+
const row = rows.length ? rows[cursor % rows.length] : undefined;
|
|
991
|
+
if (row !== undefined)
|
|
992
|
+
refs[name] = rowRef(row);
|
|
993
|
+
}
|
|
994
|
+
if (Object.keys(refs).length)
|
|
995
|
+
routing.activeRowByFamily = refs;
|
|
996
|
+
return routing;
|
|
997
|
+
}
|
|
998
|
+
function assertImportedRow(row, manifest, id) {
|
|
999
|
+
healthyRow(row);
|
|
1000
|
+
if (row.id !== id ||
|
|
1001
|
+
row.identity !== undefined ||
|
|
1002
|
+
rowRef(row).credentialEpoch !== 1 ||
|
|
1003
|
+
row.credential?.type !== 'oauth' ||
|
|
1004
|
+
row.credential.refresh !== manifest.refreshToken ||
|
|
1005
|
+
row.credential.access !== undefined ||
|
|
1006
|
+
row.credential.expires !== undefined ||
|
|
1007
|
+
row.enabled !== (manifest.metadata.enabled !== false))
|
|
1008
|
+
throw new AccountMigrationError('successor credential, lineage, identity or enabled flag differs from manifest');
|
|
1009
|
+
const metadata = decodeProviderState(row.providerState);
|
|
1010
|
+
if (!isDeepStrictEqual(encodeProviderState(metadata), encodeProviderState({ schemaVersion: 1, metadata: manifest.metadata })) ||
|
|
1011
|
+
!isDeepStrictEqual(row.quota, manifest.quota === undefined
|
|
1012
|
+
? undefined
|
|
1013
|
+
: encodeQuotaState(manifest.quota)))
|
|
1014
|
+
throw new AccountMigrationError('successor full metadata, quota or presence differs from manifest');
|
|
1015
|
+
}
|
|
1016
|
+
async function verifyImported(store, journal) {
|
|
1017
|
+
await assertStateOwnership(generationPaths(journal.legacyPath, journal.id), journal, true);
|
|
1018
|
+
const rows = await rowsOf(store);
|
|
1019
|
+
if (rows.length !== journal.mapping.length)
|
|
1020
|
+
throw new AccountMigrationError('successor roster length differs from manifest');
|
|
1021
|
+
rows.forEach((row, index) => {
|
|
1022
|
+
const mapped = journal.mapping[index];
|
|
1023
|
+
const manifest = journal.manifest.accounts[index];
|
|
1024
|
+
if (!mapped || !manifest)
|
|
1025
|
+
throw new AccountMigrationError('successor manifest mapping is incomplete');
|
|
1026
|
+
assertImportedRow(row, manifest, mapped.id);
|
|
1027
|
+
});
|
|
1028
|
+
const settings = await settingsOf(store);
|
|
1029
|
+
if (!isDeepStrictEqual(decodeAccountStoreGeneration(settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY]), generationJson(journal)))
|
|
1030
|
+
throw new AccountMigrationError('successor generation differs from journal');
|
|
1031
|
+
if (!isDeepStrictEqual(settings[ROUTING_SETTINGS_KEY], encodeRoutingSettings(expectedRouting(journal, rows))))
|
|
1032
|
+
throw new AccountMigrationError('successor order or cursor presence differs from manifest');
|
|
1033
|
+
}
|
|
1034
|
+
async function verifyCopies(paths, journal, retired) {
|
|
1035
|
+
if (journal.sourceKind === 'absent')
|
|
1036
|
+
return;
|
|
1037
|
+
const backup = await secureRead(join(paths.backupsDir, `${journal.sourceSha256}.json`));
|
|
1038
|
+
if (digest(backup) !== journal.sourceSha256)
|
|
1039
|
+
throw new AccountMigrationError('backup independent digest differs');
|
|
1040
|
+
const capture = await secureRead(join(paths.transfersDir, `${journal.id}.capture.json`));
|
|
1041
|
+
if (digest(capture) !== journal.captureSha256 ||
|
|
1042
|
+
!isDeepStrictEqual(jsonBytes(capture), captureJson(journal)))
|
|
1043
|
+
throw new AccountMigrationError('journal differs from its exclusive capture snapshot');
|
|
1044
|
+
// Original backup JSON and the captured time reproduce the expected import.
|
|
1045
|
+
// Compare that value with the journal without changing its selected rows, clock,
|
|
1046
|
+
// source positions or UUID mapping.
|
|
1047
|
+
const expected = parseLegacyAccountStorage(new TextDecoder('utf-8', { fatal: true }).decode(backup), journal.manifest.normalizationClock);
|
|
1048
|
+
if (!isDeepStrictEqual(manifestJson(expected), manifestJson(journal.manifest)))
|
|
1049
|
+
throw new AccountMigrationError('captured manifest differs from its verified original source');
|
|
1050
|
+
if (retired) {
|
|
1051
|
+
const bytes = await secureRead(join(paths.retiredDir, `${journal.id}.json`));
|
|
1052
|
+
if (!bytes.equals(backup) || digest(bytes) !== journal.retiredSha256)
|
|
1053
|
+
throw new AccountMigrationError('retired copy and backup association differs');
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
function captureJson(journal) {
|
|
1057
|
+
return {
|
|
1058
|
+
schemaVersion: 1,
|
|
1059
|
+
id: journal.id,
|
|
1060
|
+
legacyPath: journal.legacyPath,
|
|
1061
|
+
storeDir: journal.storeDir,
|
|
1062
|
+
sourceKind: journal.sourceKind,
|
|
1063
|
+
sourceSha256: journal.sourceSha256,
|
|
1064
|
+
manifest: manifestJson(journal.manifest),
|
|
1065
|
+
mapping: journal.mapping,
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
async function synchronizedSnapshot(paths, boundary) {
|
|
1069
|
+
await syncFile(paths.configPath);
|
|
1070
|
+
const statePresent = await exists(paths.statePath);
|
|
1071
|
+
if (statePresent)
|
|
1072
|
+
await syncFile(paths.statePath);
|
|
1073
|
+
await syncDirectory(paths.storeDir, boundary);
|
|
1074
|
+
return {
|
|
1075
|
+
config: await secureRead(paths.configPath),
|
|
1076
|
+
...(statePresent ? { state: await secureRead(paths.statePath) } : {}),
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
async function assertStateOwnership(paths, journal, complete) {
|
|
1080
|
+
if (complete) {
|
|
1081
|
+
const config = object(jsonBytes(await secureRead(paths.configPath)), 'successor config');
|
|
1082
|
+
const pool = object(config.commonAuthPool, 'successor pool');
|
|
1083
|
+
const entries = object(pool.rows, 'successor entries');
|
|
1084
|
+
if (Object.keys(entries).length !== journal.mapping.length ||
|
|
1085
|
+
Object.keys(entries).some((id) => !journal.mapping.some((row) => row.id === id)))
|
|
1086
|
+
throw new AccountMigrationError('native entry presence differs from manifest');
|
|
1087
|
+
const retired = pool.retiredEpochs === undefined
|
|
1088
|
+
? {}
|
|
1089
|
+
: object(pool.retiredEpochs, 'retired epochs');
|
|
1090
|
+
journal.mapping.forEach((mapped, index) => {
|
|
1091
|
+
const entry = object(entries[mapped.id], 'successor entry');
|
|
1092
|
+
const quota = journal.manifest.accounts[index]?.quota;
|
|
1093
|
+
if (entry.credentialEpoch !== 1 ||
|
|
1094
|
+
Object.hasOwn(retired, mapped.id) ||
|
|
1095
|
+
Object.hasOwn(entry, 'quota') !== (quota !== undefined) ||
|
|
1096
|
+
(quota !== undefined &&
|
|
1097
|
+
!isDeepStrictEqual(entry.quota, encodeQuotaState(quota))))
|
|
1098
|
+
throw new AccountMigrationError('native epoch or full quota presence differs from manifest');
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
if (!(await exists(paths.statePath))) {
|
|
1102
|
+
if (complete && journal.mapping.length)
|
|
1103
|
+
throw new AccountMigrationError('successor state is missing');
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
const state = object(jsonBytes(await secureRead(paths.statePath)), 'successor state');
|
|
1107
|
+
const accounts = state.accounts === undefined
|
|
1108
|
+
? {}
|
|
1109
|
+
: object(state.accounts, 'successor state accounts');
|
|
1110
|
+
const ids = Object.keys(accounts);
|
|
1111
|
+
if (ids.some((id) => !journal.mapping.some((row) => row.id === id)) ||
|
|
1112
|
+
(complete && ids.length !== journal.mapping.length))
|
|
1113
|
+
throw new AccountMigrationError('successor state contains unrelated or missing credentials');
|
|
1114
|
+
}
|
|
1115
|
+
async function management(store, journal, clear = false) {
|
|
1116
|
+
const targets = journal.rollback?.rows ?? journal.mapping.map((row) => row.id);
|
|
1117
|
+
await store.updateSettings((settings) => {
|
|
1118
|
+
const prior = settings[MANAGEMENT_SETTINGS_KEY];
|
|
1119
|
+
if (prior !== undefined) {
|
|
1120
|
+
const p = object(prior, 'management');
|
|
1121
|
+
if (p.id !== journal.id ||
|
|
1122
|
+
p.kind !== 'migration' ||
|
|
1123
|
+
!isDeepStrictEqual(p.targets, targets))
|
|
1124
|
+
throw new AccountMigrationError('management settings belong to another operation');
|
|
1125
|
+
}
|
|
1126
|
+
if (clear)
|
|
1127
|
+
delete settings[MANAGEMENT_SETTINGS_KEY];
|
|
1128
|
+
else
|
|
1129
|
+
settings[MANAGEMENT_SETTINGS_KEY] = {
|
|
1130
|
+
id: journal.id,
|
|
1131
|
+
kind: 'migration',
|
|
1132
|
+
targets,
|
|
1133
|
+
progress: {
|
|
1134
|
+
step: journal.phase,
|
|
1135
|
+
completedTargets: journal.operation === 'rollback' ? [] : journal.completedRows,
|
|
1136
|
+
},
|
|
1137
|
+
};
|
|
1138
|
+
return settings;
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
function receipt(journal) {
|
|
1142
|
+
if (journal.status === 'pending')
|
|
1143
|
+
throw new AccountMigrationError('pending journal has no serving receipt');
|
|
1144
|
+
return {
|
|
1145
|
+
id: journal.id,
|
|
1146
|
+
status: journal.status,
|
|
1147
|
+
legacyPath: journal.legacyPath,
|
|
1148
|
+
storeDir: journal.storeDir,
|
|
1149
|
+
sourceKind: journal.sourceKind,
|
|
1150
|
+
durability: journal.verification?.durability ?? accountMigrationDurability(),
|
|
1151
|
+
restartRequired: journal.sourceKind === 'file' || journal.operation === 'rollback',
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
async function currentSuccessor(store, paths, journal) {
|
|
1155
|
+
const config = object(jsonBytes(await secureRead(paths.configPath)), 'active config');
|
|
1156
|
+
if (config.version !== 1 || !Array.isArray(config.accounts))
|
|
1157
|
+
throw new AccountMigrationError('active config lacks its native roster');
|
|
1158
|
+
const pool = object(config.commonAuthPool, 'active native pool');
|
|
1159
|
+
if (pool.schemaVersion !== 1)
|
|
1160
|
+
throw new AccountMigrationError('active native pool schema differs');
|
|
1161
|
+
const entries = object(pool.rows, 'active entries');
|
|
1162
|
+
if (pool.retiredEpochs !== undefined) {
|
|
1163
|
+
for (const [id, epoch] of Object.entries(object(pool.retiredEpochs, 'retained epochs'))) {
|
|
1164
|
+
if (!UUID.test(id) ||
|
|
1165
|
+
!Number.isSafeInteger(epoch) ||
|
|
1166
|
+
typeof epoch !== 'number' ||
|
|
1167
|
+
epoch < 1)
|
|
1168
|
+
throw new AccountMigrationError('retained epoch record is invalid');
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
const nativeIds = config.accounts.map((item) => text(object(item, 'active roster row').id, UUID, 'active row id'));
|
|
1172
|
+
if (new Set(nativeIds).size !== nativeIds.length ||
|
|
1173
|
+
Object.keys(entries).length !== nativeIds.length ||
|
|
1174
|
+
Object.keys(entries).some((id) => !nativeIds.includes(id)))
|
|
1175
|
+
throw new AccountMigrationError('active native entry/roster presence differs');
|
|
1176
|
+
let stateAccounts = {};
|
|
1177
|
+
if (await exists(paths.statePath)) {
|
|
1178
|
+
const state = object(jsonBytes(await secureRead(paths.statePath)), 'active state');
|
|
1179
|
+
if (state.version !== 1)
|
|
1180
|
+
throw new AccountMigrationError('active native state schema differs');
|
|
1181
|
+
stateAccounts = object(state.accounts, 'active state accounts');
|
|
1182
|
+
}
|
|
1183
|
+
if (Object.keys(stateAccounts).length !== nativeIds.length ||
|
|
1184
|
+
Object.keys(stateAccounts).some((id) => !nativeIds.includes(id)))
|
|
1185
|
+
throw new AccountMigrationError('active state contains orphan or missing credentials');
|
|
1186
|
+
const rows = await rowsOf(store);
|
|
1187
|
+
if (!isDeepStrictEqual(rows.map((row) => row.id), nativeIds))
|
|
1188
|
+
throw new AccountMigrationError('public and native active roster order differs');
|
|
1189
|
+
rows.forEach((row) => {
|
|
1190
|
+
healthyRow(row);
|
|
1191
|
+
const entry = object(entries[row.id], 'active native entry');
|
|
1192
|
+
if (entry.credentialEpoch !== rowRef(row).credentialEpoch ||
|
|
1193
|
+
Object.hasOwn(entry, 'quota') !== (row.quota !== undefined) ||
|
|
1194
|
+
(row.quota !== undefined && !isDeepStrictEqual(entry.quota, row.quota)))
|
|
1195
|
+
throw new AccountMigrationError('active native epoch or quota presence differs from public row');
|
|
1196
|
+
});
|
|
1197
|
+
const settings = await settingsOf(store);
|
|
1198
|
+
if (!isDeepStrictEqual(decodeAccountStoreGeneration(settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY]), generationJson(journal)))
|
|
1199
|
+
throw new AccountMigrationError('active generation setting differs from journal');
|
|
1200
|
+
decodeRoutingSettings(settings[ROUTING_SETTINGS_KEY]);
|
|
1201
|
+
}
|
|
1202
|
+
function openAdmissionStore(modules, paths, now) {
|
|
1203
|
+
return modules.store.openPoolStore({
|
|
1204
|
+
provider: ACCOUNT_STORE_PROVIDER,
|
|
1205
|
+
configPath: paths.configPath,
|
|
1206
|
+
statePath: paths.statePath,
|
|
1207
|
+
quota: QUOTA_CODEC,
|
|
1208
|
+
providerState: {
|
|
1209
|
+
validate: isValidProviderState,
|
|
1210
|
+
credentialBound: providerStateCredentialBound,
|
|
1211
|
+
merge() {
|
|
1212
|
+
throw new AccountMigrationError('binding/admission performs no provider-state writes');
|
|
1213
|
+
},
|
|
1214
|
+
onReplace() {
|
|
1215
|
+
throw new AccountMigrationError('binding/admission performs no credential replacements');
|
|
1216
|
+
},
|
|
1217
|
+
},
|
|
1218
|
+
requireCredentialStamps: true,
|
|
1219
|
+
now,
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
async function trustedCurrentGeneration(store, paths, journal) {
|
|
1223
|
+
const config = object(jsonBytes(await secureRead(paths.configPath)), 'bound config');
|
|
1224
|
+
if (config.version !== 1 || !Array.isArray(config.accounts))
|
|
1225
|
+
throw new AccountMigrationError('bound config lacks its native roster');
|
|
1226
|
+
const pool = object(config.commonAuthPool, 'bound native pool');
|
|
1227
|
+
if (pool.schemaVersion !== 1)
|
|
1228
|
+
throw new AccountMigrationError('bound native pool schema differs');
|
|
1229
|
+
object(pool.rows, 'bound native entries');
|
|
1230
|
+
if (await exists(paths.statePath)) {
|
|
1231
|
+
const state = object(jsonBytes(await secureRead(paths.statePath)), 'bound native state');
|
|
1232
|
+
if (state.version !== 1)
|
|
1233
|
+
throw new AccountMigrationError('bound native state schema differs');
|
|
1234
|
+
object(state.accounts, 'bound state accounts');
|
|
1235
|
+
}
|
|
1236
|
+
const settings = await settingsOf(store);
|
|
1237
|
+
if (!isDeepStrictEqual(decodeAccountStoreGeneration(settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY]), generationJson(journal)))
|
|
1238
|
+
throw new AccountMigrationError('bound generation setting differs from completed journal');
|
|
1239
|
+
return settings;
|
|
1240
|
+
}
|
|
1241
|
+
/** A bound result establishes completed-generation ownership for repository recovery, not readiness to select or route credentials. */
|
|
1242
|
+
export async function readAccountStoreBinding(legacyPath, modules, now, expectedGeneration) {
|
|
1243
|
+
try {
|
|
1244
|
+
const paths = await resolvePublishedAccountStorePaths(legacyPath);
|
|
1245
|
+
if (!paths)
|
|
1246
|
+
return (await exists(legacyPath))
|
|
1247
|
+
? { status: 'pending' }
|
|
1248
|
+
: { status: 'initialization-required' };
|
|
1249
|
+
const journal = await readJournal(paths);
|
|
1250
|
+
if (!journal)
|
|
1251
|
+
throw new AccountMigrationError('published binding journal missing');
|
|
1252
|
+
if (expectedGeneration !== undefined && journal.id !== expectedGeneration)
|
|
1253
|
+
throw new AccountMigrationError('bound repository generation differs from pointer');
|
|
1254
|
+
if (journal.status === 'pending')
|
|
1255
|
+
return {
|
|
1256
|
+
status: 'pending',
|
|
1257
|
+
phase: journal.phase,
|
|
1258
|
+
operation: journal.operation,
|
|
1259
|
+
};
|
|
1260
|
+
if (journal.status === 'inactive')
|
|
1261
|
+
return { status: 'inactive', id: journal.id };
|
|
1262
|
+
if (await exists(legacyPath))
|
|
1263
|
+
throw new AccountMigrationError('legacy source was recreated after activation');
|
|
1264
|
+
await verifyCopies(paths, journal, true);
|
|
1265
|
+
const settings = await trustedCurrentGeneration(openAdmissionStore(modules, paths, now), paths, journal);
|
|
1266
|
+
const pending = settings[MANAGEMENT_SETTINGS_KEY];
|
|
1267
|
+
if (pending !== undefined) {
|
|
1268
|
+
const record = object(pending, 'repository management');
|
|
1269
|
+
if (record.kind === 'migration')
|
|
1270
|
+
return { status: 'pending', phase: 'activate', operation: 'migrate' };
|
|
1271
|
+
if (record.kind !== 'clear' && record.kind !== 'replace-pool')
|
|
1272
|
+
throw new AccountMigrationError('unknown management owner prevents binding');
|
|
1273
|
+
// Ownership permits opening these physical paths. The repository must then
|
|
1274
|
+
// validate and resume its own strict clear/replace journal; this binding
|
|
1275
|
+
// neither validates that operation nor grants credential-serving readiness.
|
|
1276
|
+
}
|
|
1277
|
+
return { status: 'bound', receipt: receipt(journal), paths };
|
|
1278
|
+
}
|
|
1279
|
+
catch (error) {
|
|
1280
|
+
return {
|
|
1281
|
+
status: 'error',
|
|
1282
|
+
reason: error instanceof AccountMigrationError
|
|
1283
|
+
? error.reason
|
|
1284
|
+
: 'journal, backup or generation binding validation failed',
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
/** Serving requires healthy current rows and no repository management work. Binding alone is insufficient. */
|
|
1289
|
+
export async function readAccountStoreAdmission(legacyPath, modules, now, expectedGeneration) {
|
|
1290
|
+
const binding = await readAccountStoreBinding(legacyPath, modules, now, expectedGeneration);
|
|
1291
|
+
if (binding.status !== 'bound')
|
|
1292
|
+
return binding;
|
|
1293
|
+
try {
|
|
1294
|
+
const journal = await readJournal(binding.paths);
|
|
1295
|
+
if (!journal)
|
|
1296
|
+
throw new AccountMigrationError('active serving journal missing');
|
|
1297
|
+
const store = openAdmissionStore(modules, binding.paths, now);
|
|
1298
|
+
await currentSuccessor(store, binding.paths, journal);
|
|
1299
|
+
if ((await settingsOf(store))[MANAGEMENT_SETTINGS_KEY] !== undefined)
|
|
1300
|
+
return { status: 'pending' };
|
|
1301
|
+
return { status: 'active', receipt: binding.receipt, paths: binding.paths };
|
|
1302
|
+
}
|
|
1303
|
+
catch (error) {
|
|
1304
|
+
return {
|
|
1305
|
+
status: 'error',
|
|
1306
|
+
reason: error instanceof AccountMigrationError
|
|
1307
|
+
? error.reason
|
|
1308
|
+
: 'current credential serving validation failed',
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
/** Legacy-write guard: canonical pointer/journal errors and pending or active generation receipts refuse old-format writes under the accounts lease. */
|
|
1313
|
+
export async function assertLegacyAccountStorageWritable(legacyPath) {
|
|
1314
|
+
const paths = await resolvePublishedAccountStorePaths(resolve(legacyPath));
|
|
1315
|
+
if (paths === undefined)
|
|
1316
|
+
return;
|
|
1317
|
+
const journal = await readJournal(paths);
|
|
1318
|
+
if (journal === undefined &&
|
|
1319
|
+
((await exists(paths.configPath)) || (await exists(paths.statePath))))
|
|
1320
|
+
throw new AccountMigrationError('unowned successor files refuse legacy writes');
|
|
1321
|
+
if (journal !== undefined && journal.status !== 'inactive')
|
|
1322
|
+
throw new AccountMigrationError('legacy storage is owned by the account store; use offline rollback before old-build writes');
|
|
1323
|
+
}
|
|
1324
|
+
async function capture(modules, paths, lease, options, previous) {
|
|
1325
|
+
await options.onBoundary?.('before-capture');
|
|
1326
|
+
const captured = await modules.fs.withLock(paths.legacyPath, { name: 'accounts', ttlMs: 10_000, timeoutMs: 2_000, renew: true }, async (accountsLease) => {
|
|
1327
|
+
await accountsLease.assertOwned();
|
|
1328
|
+
await lease.assertOwned();
|
|
1329
|
+
const bytes = await secureRead(paths.legacyPath);
|
|
1330
|
+
const manifest = parseLegacyAccountStorage(new TextDecoder('utf-8', { fatal: true }).decode(bytes), options.now());
|
|
1331
|
+
const sourceSha256 = digest(bytes);
|
|
1332
|
+
await options.onBoundary?.('capture:before-backup');
|
|
1333
|
+
await exclusiveBytes(join(paths.backupsDir, `${sourceSha256}.json`), bytes, sourceSha256, options.onBoundary);
|
|
1334
|
+
const journal = {
|
|
1335
|
+
schemaVersion: 1,
|
|
1336
|
+
id: pathGenerationId(paths),
|
|
1337
|
+
legacyPath: paths.legacyPath,
|
|
1338
|
+
storeDir: paths.storeDir,
|
|
1339
|
+
status: 'pending',
|
|
1340
|
+
operation: 'migrate',
|
|
1341
|
+
phase: 'capture',
|
|
1342
|
+
sourceKind: 'file',
|
|
1343
|
+
sourceSha256,
|
|
1344
|
+
manifest,
|
|
1345
|
+
mapping: manifest.accounts.map((row) => ({
|
|
1346
|
+
id: randomUUID(),
|
|
1347
|
+
ordinal: row.ordinal,
|
|
1348
|
+
})),
|
|
1349
|
+
completedRows: [],
|
|
1350
|
+
};
|
|
1351
|
+
const captureBytes = Buffer.from(`${JSON.stringify(captureJson(journal), null, 2)}\n`);
|
|
1352
|
+
journal.captureSha256 = digest(captureBytes);
|
|
1353
|
+
await exclusiveBytes(join(paths.transfersDir, `${journal.id}.capture.json`), captureBytes, journal.captureSha256, options.onBoundary);
|
|
1354
|
+
await accountsLease.assertOwned();
|
|
1355
|
+
await lease.assertOwned();
|
|
1356
|
+
if (previous !== undefined) {
|
|
1357
|
+
// Retain the previous journal UUID and full durable record before a new
|
|
1358
|
+
// generation captures the restored legacy file.
|
|
1359
|
+
const archived = Buffer.from(`${JSON.stringify(journalJson(previous), null, 2)}\n`);
|
|
1360
|
+
await exclusiveBytes(join(paths.transfersDir, `${previous.id}.journal.json`), archived, digest(archived), options.onBoundary);
|
|
1361
|
+
}
|
|
1362
|
+
await saveJournal(modules, paths, lease, journal, true, options.onBoundary);
|
|
1363
|
+
return journal;
|
|
1364
|
+
});
|
|
1365
|
+
return captured;
|
|
1366
|
+
}
|
|
1367
|
+
async function build(store, modules, paths, lease, journal, options) {
|
|
1368
|
+
await options.onBoundary?.('before-build');
|
|
1369
|
+
await assertStateOwnership(paths, journal, false);
|
|
1370
|
+
if (!journal.mapping.length && !(await exists(paths.configPath))) {
|
|
1371
|
+
// Public initialize leaves a missing config alone. An empty import first
|
|
1372
|
+
// exclusively creates the native {version:1,accounts:[]} config it requires.
|
|
1373
|
+
const bootstrap = Buffer.from('{"version":1,"accounts":[]}\n');
|
|
1374
|
+
await exclusiveBytes(paths.configPath, bootstrap, digest(bootstrap), options.onBoundary);
|
|
1375
|
+
}
|
|
1376
|
+
await store.initialize();
|
|
1377
|
+
const known = await rowsOf(store);
|
|
1378
|
+
if (known.some((row) => !journal.mapping.some((mapped) => mapped.id === row.id)))
|
|
1379
|
+
throw new AccountMigrationError('build found unrelated successor rows');
|
|
1380
|
+
for (let index = 0; index < journal.mapping.length; index++) {
|
|
1381
|
+
const mapped = journal.mapping[index];
|
|
1382
|
+
const source = journal.manifest.accounts[index];
|
|
1383
|
+
if (!mapped || !source)
|
|
1384
|
+
throw new AccountMigrationError('captured row mapping is incomplete');
|
|
1385
|
+
await lease.assertOwned();
|
|
1386
|
+
const before = (await rowsOf(store)).find((row) => row.id === mapped.id);
|
|
1387
|
+
if (journal.completedRows.includes(mapped.id)) {
|
|
1388
|
+
if (!before)
|
|
1389
|
+
throw new AccountMigrationError('acknowledged successor row disappeared');
|
|
1390
|
+
assertImportedRow(before, source, mapped.id);
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
if (before === undefined) {
|
|
1394
|
+
const result = await store.add({
|
|
1395
|
+
id: mapped.id,
|
|
1396
|
+
credential: { type: 'oauth', refresh: source.refreshToken },
|
|
1397
|
+
providerState: encodeProviderState({
|
|
1398
|
+
schemaVersion: 1,
|
|
1399
|
+
metadata: source.metadata,
|
|
1400
|
+
}),
|
|
1401
|
+
}, { extraLocks: [legacyAccountsLock(paths.legacyPath)] });
|
|
1402
|
+
if (result.id !== mapped.id)
|
|
1403
|
+
throw new AccountMigrationError('public add deduplicated onto an unrelated UUID');
|
|
1404
|
+
}
|
|
1405
|
+
else {
|
|
1406
|
+
// Validate the recorded credential epoch/identity and let public enable/disable
|
|
1407
|
+
// finish an interrupted state/config transition without starting a new epoch.
|
|
1408
|
+
healthyRow(before, true);
|
|
1409
|
+
if (before.credential?.type !== 'oauth' ||
|
|
1410
|
+
before.credential.refresh !== source.refreshToken)
|
|
1411
|
+
throw new AccountMigrationError('unacknowledged row differs from captured credential');
|
|
1412
|
+
}
|
|
1413
|
+
const row = (await rowsOf(store)).find((candidate) => candidate.id === mapped.id);
|
|
1414
|
+
if (!row)
|
|
1415
|
+
throw new AccountMigrationError('public add has no successor row');
|
|
1416
|
+
const fence = rowRef(row);
|
|
1417
|
+
if (source.metadata.enabled === false)
|
|
1418
|
+
await store.disable(mapped.id, 'legacy-disabled', {
|
|
1419
|
+
attribution: fence,
|
|
1420
|
+
extraLocks: [legacyAccountsLock(paths.legacyPath)],
|
|
1421
|
+
});
|
|
1422
|
+
else if (row.torn || !row.enabled)
|
|
1423
|
+
await store.enable(mapped.id, {
|
|
1424
|
+
attribution: fence,
|
|
1425
|
+
extraLocks: [legacyAccountsLock(paths.legacyPath)],
|
|
1426
|
+
});
|
|
1427
|
+
if (source.quota !== undefined) {
|
|
1428
|
+
const fresh = (await rowsOf(store)).find((candidate) => candidate.id === mapped.id);
|
|
1429
|
+
if (!fresh)
|
|
1430
|
+
throw new AccountMigrationError('quota successor row disappeared');
|
|
1431
|
+
await store.recordQuota(mapped.id, rowRef(fresh), encodeQuotaState(source.quota));
|
|
1432
|
+
}
|
|
1433
|
+
const actual = (await rowsOf(store)).find((candidate) => candidate.id === mapped.id);
|
|
1434
|
+
if (!actual)
|
|
1435
|
+
throw new AccountMigrationError('imported row disappeared');
|
|
1436
|
+
assertImportedRow(actual, source, mapped.id);
|
|
1437
|
+
journal.completedRows.push(mapped.id);
|
|
1438
|
+
await saveJournal(modules, paths, lease, journal, false, options.onBoundary);
|
|
1439
|
+
await management(store, journal);
|
|
1440
|
+
}
|
|
1441
|
+
await store.reorder(journal.mapping.map((row) => row.id));
|
|
1442
|
+
const routing = expectedRouting(journal, await rowsOf(store));
|
|
1443
|
+
await store.updateSettings((settings) => {
|
|
1444
|
+
settings[ROUTING_SETTINGS_KEY] = encodeRoutingSettings(routing);
|
|
1445
|
+
return settings;
|
|
1446
|
+
});
|
|
1447
|
+
await store.updateSettings((settings) => {
|
|
1448
|
+
const prior = settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY];
|
|
1449
|
+
if (prior !== undefined &&
|
|
1450
|
+
!isDeepStrictEqual(decodeAccountStoreGeneration(prior), generationJson(journal)))
|
|
1451
|
+
throw new AccountMigrationError('build cannot overwrite another generation');
|
|
1452
|
+
settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY] = generationJson(journal);
|
|
1453
|
+
return settings;
|
|
1454
|
+
});
|
|
1455
|
+
await management(store, journal);
|
|
1456
|
+
await options.onBoundary?.('after-build');
|
|
1457
|
+
}
|
|
1458
|
+
async function verify(store, paths, journal, options) {
|
|
1459
|
+
await options.onBoundary?.('before-verify');
|
|
1460
|
+
await verifyCopies(paths, journal, false);
|
|
1461
|
+
await assertStateOwnership(paths, journal, true);
|
|
1462
|
+
await verifyImported(store, journal);
|
|
1463
|
+
const snapshot = await synchronizedSnapshot(paths, options.onBoundary);
|
|
1464
|
+
await verifyImported(store, journal);
|
|
1465
|
+
journal.verification = {
|
|
1466
|
+
manifestSha256: digest(canonical(manifestJson(journal.manifest))),
|
|
1467
|
+
configSha256: digest(snapshot.config),
|
|
1468
|
+
...(snapshot.state === undefined
|
|
1469
|
+
? {}
|
|
1470
|
+
: { stateSha256: digest(snapshot.state) }),
|
|
1471
|
+
statePresent: snapshot.state !== undefined,
|
|
1472
|
+
durability: accountMigrationDurability(),
|
|
1473
|
+
};
|
|
1474
|
+
await options.onBoundary?.('after-verify');
|
|
1475
|
+
}
|
|
1476
|
+
async function retire(store, modules, paths, lease, journal, options) {
|
|
1477
|
+
await options.onBoundary?.('before-retire');
|
|
1478
|
+
// Verify imported store fields before taking the legacy accounts lock: public
|
|
1479
|
+
// row operations acquire row/provider/extra locks before their save locks, so
|
|
1480
|
+
// capture/retirement must not enclose those operations in an accounts lease.
|
|
1481
|
+
await verifyImported(store, journal);
|
|
1482
|
+
await assertStateOwnership(paths, journal, true);
|
|
1483
|
+
await synchronizedSnapshot(paths, options.onBoundary);
|
|
1484
|
+
await verifyImported(store, journal);
|
|
1485
|
+
await verifyCopies(paths, journal, false);
|
|
1486
|
+
const sourceSha256 = journal.sourceSha256;
|
|
1487
|
+
if (journal.sourceKind !== 'file' || sourceSha256 === undefined)
|
|
1488
|
+
throw new AccountMigrationError('file retirement requires a captured source hash');
|
|
1489
|
+
await modules.fs.withLock(paths.legacyPath, { name: 'accounts', ttlMs: 10_000, timeoutMs: 2_000, renew: true }, async (accountsLease) => {
|
|
1490
|
+
await lease.assertOwned();
|
|
1491
|
+
await accountsLease.assertOwned();
|
|
1492
|
+
const retiredPath = join(paths.retiredDir, `${journal.id}.json`);
|
|
1493
|
+
if (await exists(paths.legacyPath)) {
|
|
1494
|
+
const source = await secureRead(paths.legacyPath);
|
|
1495
|
+
if (digest(source) !== sourceSha256)
|
|
1496
|
+
throw new AccountMigrationError('legacy source changed since capture');
|
|
1497
|
+
await exclusiveBytes(retiredPath, source, sourceSha256, options.onBoundary);
|
|
1498
|
+
await lease.assertOwned();
|
|
1499
|
+
await accountsLease.assertOwned();
|
|
1500
|
+
if (digest(await secureRead(paths.legacyPath)) !== journal.sourceSha256)
|
|
1501
|
+
throw new AccountMigrationError('legacy source changed before unlink');
|
|
1502
|
+
await syncDirectory(dirname(paths.legacyPath), options.onBoundary);
|
|
1503
|
+
await lease.assertOwned();
|
|
1504
|
+
await accountsLease.assertOwned();
|
|
1505
|
+
await unlink(paths.legacyPath);
|
|
1506
|
+
}
|
|
1507
|
+
else {
|
|
1508
|
+
// If the original legacy file is absent, require this journal UUID's retired
|
|
1509
|
+
// legacy copy to independently match the captured source SHA256 before resuming.
|
|
1510
|
+
if (!journal.verification ||
|
|
1511
|
+
digest(await secureRead(retiredPath)) !== journal.sourceSha256)
|
|
1512
|
+
throw new AccountMigrationError('missing source has no verified retirement association');
|
|
1513
|
+
}
|
|
1514
|
+
try {
|
|
1515
|
+
await syncDirectory(dirname(paths.legacyPath), options.onBoundary);
|
|
1516
|
+
}
|
|
1517
|
+
catch (error) {
|
|
1518
|
+
await lease.assertOwned();
|
|
1519
|
+
await accountsLease.assertOwned();
|
|
1520
|
+
if (!(await exists(paths.legacyPath))) {
|
|
1521
|
+
// A directory-sync failure after unlink cannot acknowledge retirement.
|
|
1522
|
+
// Restore the verified bytes only if the legacy path is still absent;
|
|
1523
|
+
// never overwrite a file another writer recreated at that path.
|
|
1524
|
+
try {
|
|
1525
|
+
const recovery = await secureRead(retiredPath);
|
|
1526
|
+
await exclusiveBytes(paths.legacyPath, recovery, sourceSha256);
|
|
1527
|
+
}
|
|
1528
|
+
catch {
|
|
1529
|
+
throw new AccountMigrationError('retirement directory sync failed; verified recovery copies retained and exclusive source restoration could not be acknowledged');
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
throw error;
|
|
1533
|
+
}
|
|
1534
|
+
journal.retiredSha256 = journal.sourceSha256;
|
|
1535
|
+
await verifyCopies(paths, journal, true);
|
|
1536
|
+
});
|
|
1537
|
+
await options.onBoundary?.('after-retire');
|
|
1538
|
+
}
|
|
1539
|
+
async function discardInactive(store, paths, journal) {
|
|
1540
|
+
await makeCancelledSeedReady(store, paths, journal);
|
|
1541
|
+
const targets = journal.rollback?.rows ?? journal.mapping.map((row) => row.id);
|
|
1542
|
+
const owned = {
|
|
1543
|
+
...journal,
|
|
1544
|
+
mapping: targets.map((id, ordinal) => ({ id, ordinal })),
|
|
1545
|
+
};
|
|
1546
|
+
await assertStateOwnership(paths, owned, false);
|
|
1547
|
+
for (const id of targets) {
|
|
1548
|
+
const rows = await rowsOf(store);
|
|
1549
|
+
const state = (await exists(paths.statePath))
|
|
1550
|
+
? object(jsonBytes(await secureRead(paths.statePath)), 'inactive state')
|
|
1551
|
+
: {};
|
|
1552
|
+
const accounts = state.accounts === undefined
|
|
1553
|
+
? {}
|
|
1554
|
+
: object(state.accounts, 'inactive state accounts');
|
|
1555
|
+
if (rows.some((row) => row.id === id) || Object.hasOwn(accounts, id))
|
|
1556
|
+
await store.remove(id);
|
|
1557
|
+
}
|
|
1558
|
+
if ((await rowsOf(store)).length)
|
|
1559
|
+
throw new AccountMigrationError('inactive successor contains unrelated rows');
|
|
1560
|
+
await management(store, journal, true);
|
|
1561
|
+
await store.updateSettings((settings) => {
|
|
1562
|
+
const prior = settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY];
|
|
1563
|
+
if (prior !== undefined &&
|
|
1564
|
+
!isDeepStrictEqual(decodeAccountStoreGeneration(prior), generationJson(journal)))
|
|
1565
|
+
throw new AccountMigrationError('inactive generation setting belongs to another journal');
|
|
1566
|
+
delete settings[ACCOUNT_STORE_GENERATION_SETTINGS_KEY];
|
|
1567
|
+
return settings;
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
async function makeCancelledSeedReady(store, paths, journal) {
|
|
1571
|
+
const read = await store.read();
|
|
1572
|
+
if (read.status === 'pending-migration') {
|
|
1573
|
+
const config = jsonBytes(await secureRead(paths.configPath));
|
|
1574
|
+
if (!journal.cancelled ||
|
|
1575
|
+
journal.mapping.length ||
|
|
1576
|
+
!isDeepStrictEqual(config, { version: 1, accounts: [] }))
|
|
1577
|
+
throw new AccountMigrationError('cancellation cannot adopt an unrelated pending config');
|
|
1578
|
+
await store.initialize();
|
|
1579
|
+
}
|
|
1580
|
+
else if (read.status === 'error')
|
|
1581
|
+
throw new AccountMigrationError('cancellation successor could not be read');
|
|
1582
|
+
}
|
|
1583
|
+
async function finishCancellation(store, modules, paths, lease, journal, options) {
|
|
1584
|
+
if (!journal.cancelled ||
|
|
1585
|
+
journal.sourceKind !== 'file' ||
|
|
1586
|
+
digest(await secureRead(paths.legacyPath)) !== journal.sourceSha256)
|
|
1587
|
+
throw new AccountMigrationError('cancellation requires its unchanged unserved source');
|
|
1588
|
+
await verifyCopies(paths, journal, false);
|
|
1589
|
+
await makeCancelledSeedReady(store, paths, journal);
|
|
1590
|
+
await management(store, journal, true);
|
|
1591
|
+
await modules.fs.withLock(paths.legacyPath, { name: 'accounts', ttlMs: 10_000, timeoutMs: 2_000, renew: true }, async (accountsLease) => {
|
|
1592
|
+
await accountsLease.assertOwned();
|
|
1593
|
+
await lease.assertOwned();
|
|
1594
|
+
if (digest(await secureRead(paths.legacyPath)) !== journal.sourceSha256)
|
|
1595
|
+
throw new AccountMigrationError('cancellation source changed before acknowledgment');
|
|
1596
|
+
journal.status = 'inactive';
|
|
1597
|
+
journal.phase = 'activate';
|
|
1598
|
+
await saveJournal(modules, paths, lease, journal, false, options.onBoundary);
|
|
1599
|
+
});
|
|
1600
|
+
return { status: 'completed', receipt: receipt(journal) };
|
|
1601
|
+
}
|
|
1602
|
+
/** Capture/build/verify/retire/activate use the genuine public store and filesystem exports from the same checksum-verified common-auth package. */
|
|
1603
|
+
export function createAccountMigrationFactory(modules) {
|
|
1604
|
+
return async (options) => {
|
|
1605
|
+
if (options.offline?.processesStopped !== true)
|
|
1606
|
+
throw new AccountMigrationError('stopped processes and timers must be explicitly confirmed');
|
|
1607
|
+
resolveAccountStorePaths(options.legacyPath);
|
|
1608
|
+
try {
|
|
1609
|
+
return await withParentTopology(modules, options.legacyPath, async (parentLease) => {
|
|
1610
|
+
const prior = await readPointer(options.legacyPath);
|
|
1611
|
+
if (prior)
|
|
1612
|
+
await finishPointerPublication(options.legacyPath, prior, parentLease);
|
|
1613
|
+
let paths = prior
|
|
1614
|
+
? generationPaths(options.legacyPath, prior.id)
|
|
1615
|
+
: undefined;
|
|
1616
|
+
let previous;
|
|
1617
|
+
if (paths) {
|
|
1618
|
+
const existing = await readJournal(paths);
|
|
1619
|
+
if (!existing)
|
|
1620
|
+
throw new AccountMigrationError('published generation journal missing');
|
|
1621
|
+
if (existing.status === 'inactive') {
|
|
1622
|
+
previous = existing;
|
|
1623
|
+
await modules.fs.withLock(paths.configPath, {
|
|
1624
|
+
name: MANAGEMENT_LOCK_NAME,
|
|
1625
|
+
ttlMs: 10_000,
|
|
1626
|
+
timeoutMs: 2_000,
|
|
1627
|
+
renew: true,
|
|
1628
|
+
}, async (oldLease) => {
|
|
1629
|
+
if (!paths)
|
|
1630
|
+
throw new AccountMigrationError('inactive physical paths missing');
|
|
1631
|
+
const oldStore = openMigrationStore(modules, paths, options.now, writeObserver(paths, oldLease, options.onBoundary));
|
|
1632
|
+
await discardInactive(oldStore, paths, existing);
|
|
1633
|
+
});
|
|
1634
|
+
paths = undefined;
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
if (paths === undefined) {
|
|
1638
|
+
if (!(await exists(options.legacyPath)))
|
|
1639
|
+
throw new AccountMigrationError('absent source requires explicit fresh initialization');
|
|
1640
|
+
paths = await allocateGeneration(options.legacyPath);
|
|
1641
|
+
}
|
|
1642
|
+
const physical = paths;
|
|
1643
|
+
return modules.fs.withLock(physical.configPath, {
|
|
1644
|
+
name: MANAGEMENT_LOCK_NAME,
|
|
1645
|
+
ttlMs: 10_000,
|
|
1646
|
+
timeoutMs: 2_000,
|
|
1647
|
+
renew: true,
|
|
1648
|
+
}, async (lease) => {
|
|
1649
|
+
let journal = await readJournal(physical);
|
|
1650
|
+
if (journal === undefined) {
|
|
1651
|
+
journal = await capture(modules, physical, lease, options, previous);
|
|
1652
|
+
await modules.fs.withLock(physical.legacyPath, {
|
|
1653
|
+
name: 'accounts',
|
|
1654
|
+
ttlMs: 10_000,
|
|
1655
|
+
timeoutMs: 2_000,
|
|
1656
|
+
renew: true,
|
|
1657
|
+
}, async (accountsLease) => {
|
|
1658
|
+
await accountsLease.assertOwned();
|
|
1659
|
+
if (digest(await secureRead(physical.legacyPath)) !==
|
|
1660
|
+
journal?.sourceSha256)
|
|
1661
|
+
throw new AccountMigrationError('source changed before pointer publication');
|
|
1662
|
+
if (!journal)
|
|
1663
|
+
throw new AccountMigrationError('capture journal missing');
|
|
1664
|
+
await publishGenerationPointer(modules, physical, journal, parentLease, prior, options.onBoundary);
|
|
1665
|
+
});
|
|
1666
|
+
await options.onBoundary?.('after-capture');
|
|
1667
|
+
}
|
|
1668
|
+
await assertPublishedAccountStoreGeneration(physical);
|
|
1669
|
+
const store = openMigrationStore(modules, physical, options.now, writeObserver(physical, lease, options.onBoundary));
|
|
1670
|
+
if (journal.status === 'active') {
|
|
1671
|
+
if (await exists(physical.legacyPath))
|
|
1672
|
+
throw new AccountMigrationError('legacy source was recreated after activation');
|
|
1673
|
+
await verifyCopies(physical, journal, true);
|
|
1674
|
+
await currentSuccessor(store, physical, journal);
|
|
1675
|
+
await management(store, journal, true);
|
|
1676
|
+
return { status: 'completed', receipt: receipt(journal) };
|
|
1677
|
+
}
|
|
1678
|
+
if (journal.operation === 'rollback')
|
|
1679
|
+
throw new AccountMigrationError('resume offline rollback before re-migration');
|
|
1680
|
+
if (journal.sourceKind === 'absent')
|
|
1681
|
+
throw new AccountMigrationError('resume explicit fresh initialization; never adopt an appearing source');
|
|
1682
|
+
await verifyCopies(physical, journal, false);
|
|
1683
|
+
if (journal.phase === 'capture' || journal.phase === 'build') {
|
|
1684
|
+
journal.phase = 'build';
|
|
1685
|
+
await saveJournal(modules, physical, lease, journal, false, options.onBoundary);
|
|
1686
|
+
await build(store, modules, physical, lease, journal, options);
|
|
1687
|
+
journal.phase = 'verify';
|
|
1688
|
+
await saveJournal(modules, physical, lease, journal, false, options.onBoundary);
|
|
1689
|
+
}
|
|
1690
|
+
if (journal.phase === 'verify') {
|
|
1691
|
+
await verify(store, physical, journal, options);
|
|
1692
|
+
journal.phase = 'retire';
|
|
1693
|
+
await saveJournal(modules, physical, lease, journal, false, options.onBoundary);
|
|
1694
|
+
await management(store, journal);
|
|
1695
|
+
}
|
|
1696
|
+
if (journal.phase === 'retire') {
|
|
1697
|
+
await retire(store, modules, physical, lease, journal, options);
|
|
1698
|
+
journal.phase = 'activate';
|
|
1699
|
+
await saveJournal(modules, physical, lease, journal, false, options.onBoundary);
|
|
1700
|
+
}
|
|
1701
|
+
await options.onBoundary?.('before-activate');
|
|
1702
|
+
await verifyImported(store, journal);
|
|
1703
|
+
await verifyCopies(physical, journal, true);
|
|
1704
|
+
if (await exists(physical.legacyPath))
|
|
1705
|
+
throw new AccountMigrationError('legacy source reappeared before activation');
|
|
1706
|
+
journal.status = 'active';
|
|
1707
|
+
await saveJournal(modules, physical, lease, journal, false, options.onBoundary);
|
|
1708
|
+
await management(store, journal, true);
|
|
1709
|
+
await options.onBoundary?.('after-activate');
|
|
1710
|
+
return { status: 'completed', receipt: receipt(journal) };
|
|
1711
|
+
});
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
catch (error) {
|
|
1715
|
+
const pending = pendingLockFailure(modules, error);
|
|
1716
|
+
if (pending)
|
|
1717
|
+
return pending;
|
|
1718
|
+
throw error;
|
|
1719
|
+
}
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
function refMatches(ref, row) {
|
|
1723
|
+
return (ref !== null && ref !== undefined && isDeepStrictEqual(ref, rowRef(row)));
|
|
1724
|
+
}
|
|
1725
|
+
function exportRouting(routing, rows) {
|
|
1726
|
+
const result = { ...routing };
|
|
1727
|
+
const global = rows.findIndex((row) => refMatches(routing.activeRow, row));
|
|
1728
|
+
const effective = rows.length
|
|
1729
|
+
? Math.max(0, Math.min(routing.activeIndex ?? 0, rows.length - 1))
|
|
1730
|
+
: 0;
|
|
1731
|
+
if (global >= 0 && global !== effective)
|
|
1732
|
+
result.activeIndex = global;
|
|
1733
|
+
const fallback = rows.length
|
|
1734
|
+
? Math.max(0, Math.min(result.activeIndex ?? 0, rows.length - 1))
|
|
1735
|
+
: 0;
|
|
1736
|
+
for (const family of ['claude', 'gemini']) {
|
|
1737
|
+
const index = rows.findIndex((row) => refMatches(routing.activeRowByFamily?.[family], row));
|
|
1738
|
+
const cursor = rows.length
|
|
1739
|
+
? (routing.activeIndexByFamily?.[family] ?? fallback) % rows.length
|
|
1740
|
+
: 0;
|
|
1741
|
+
if (index >= 0 && index !== cursor)
|
|
1742
|
+
result.activeIndexByFamily = {
|
|
1743
|
+
...result.activeIndexByFamily,
|
|
1744
|
+
[family]: index,
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
return result;
|
|
1748
|
+
}
|
|
1749
|
+
async function exportCurrent(store) {
|
|
1750
|
+
const rows = await rowsOf(store);
|
|
1751
|
+
const settings = await settingsOf(store);
|
|
1752
|
+
const routing = decodeRoutingSettings(settings[ROUTING_SETTINGS_KEY]);
|
|
1753
|
+
const accounts = rows.map((row, ordinal) => {
|
|
1754
|
+
healthyRow(row);
|
|
1755
|
+
if (row.credential?.type !== 'oauth')
|
|
1756
|
+
throw new AccountMigrationError('rollback cannot export a non-OAuth row');
|
|
1757
|
+
const metadata = decodeProviderState(row.providerState).metadata;
|
|
1758
|
+
if ((metadata.enabled !== false) !== row.enabled)
|
|
1759
|
+
throw new AccountMigrationError('current enabled encoding differs from native row');
|
|
1760
|
+
return {
|
|
1761
|
+
ordinal,
|
|
1762
|
+
refreshToken: row.credential.refresh,
|
|
1763
|
+
metadata,
|
|
1764
|
+
...(row.quota === undefined
|
|
1765
|
+
? {}
|
|
1766
|
+
: { quota: decodeQuotaState(row.quota) }),
|
|
1767
|
+
};
|
|
1768
|
+
});
|
|
1769
|
+
return {
|
|
1770
|
+
rows: rows.map((row) => row.id),
|
|
1771
|
+
exported: exportLegacyAccountStorage(accounts, exportRouting(routing, rows)),
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
async function rollbackSnapshot(paths, journal) {
|
|
1775
|
+
const rollback = journal.rollback;
|
|
1776
|
+
if (!rollback)
|
|
1777
|
+
throw new AccountMigrationError('rollback snapshot association is absent');
|
|
1778
|
+
const bytes = await secureRead(join(paths.transfersDir, `${rollback.id}.json`));
|
|
1779
|
+
if (digest(bytes) !== rollback.snapshotSha256)
|
|
1780
|
+
throw new AccountMigrationError('current-credential snapshot digest differs');
|
|
1781
|
+
const snapshot = exact(jsonBytes(bytes), ['config', 'state', 'exported'], 'rollback snapshot');
|
|
1782
|
+
if (typeof snapshot.config !== 'string' ||
|
|
1783
|
+
(snapshot.state !== undefined && typeof snapshot.state !== 'string'))
|
|
1784
|
+
throw new AccountMigrationError('rollback snapshot file encoding is invalid');
|
|
1785
|
+
const exported = object(snapshot.exported, 'rollback export');
|
|
1786
|
+
if (digest(`${JSON.stringify(exported, null, 2)}\n`) !== rollback.exportSha256)
|
|
1787
|
+
throw new AccountMigrationError('rollback export association differs');
|
|
1788
|
+
if (exported.version !== 4)
|
|
1789
|
+
throw new AccountMigrationError('rollback export is not v4');
|
|
1790
|
+
const accounts = list(exported.accounts, 'rollback export accounts').map((row, ordinal) => parseAccount(row, ordinal, 4, journal.manifest.normalizationClock));
|
|
1791
|
+
// Rollback validates current exported v4 rows without repeating import-time
|
|
1792
|
+
// email winner selection or clock-dependent reset/quota normalization. Serialization
|
|
1793
|
+
// must preserve every current credential and metadata field.
|
|
1794
|
+
const copied = exportLegacyAccountStorage(accounts, legacyRouting(exported));
|
|
1795
|
+
if (!isDeepStrictEqual(copied, exported))
|
|
1796
|
+
throw new AccountMigrationError('rollback export does not roundtrip losslessly');
|
|
1797
|
+
return {
|
|
1798
|
+
config: snapshot.config,
|
|
1799
|
+
...(snapshot.state === undefined ? {} : { state: snapshot.state }),
|
|
1800
|
+
exported: copied,
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
/** Retains current credential epochs and exports current refresh tokens to legacy JSON, never reviving tokens from the original source backup. */
|
|
1804
|
+
export function createAccountRollbackFactory(modules) {
|
|
1805
|
+
return async (options) => {
|
|
1806
|
+
if (options.offline?.processesStopped !== true)
|
|
1807
|
+
throw new AccountMigrationError('stopped processes and timers must be explicitly confirmed');
|
|
1808
|
+
try {
|
|
1809
|
+
return await withParentTopology(modules, options.legacyPath, async () => {
|
|
1810
|
+
const paths = await resolvePublishedAccountStorePaths(options.legacyPath);
|
|
1811
|
+
if (!paths)
|
|
1812
|
+
throw new AccountMigrationError('rollback requires a published generation');
|
|
1813
|
+
return modules.fs.withLock(paths.configPath, {
|
|
1814
|
+
name: MANAGEMENT_LOCK_NAME,
|
|
1815
|
+
ttlMs: 10_000,
|
|
1816
|
+
timeoutMs: 2_000,
|
|
1817
|
+
renew: true,
|
|
1818
|
+
}, async (lease) => {
|
|
1819
|
+
const initialJournal = await readJournal(paths);
|
|
1820
|
+
if (!initialJournal)
|
|
1821
|
+
throw new AccountMigrationError('rollback requires an owned migration generation');
|
|
1822
|
+
let journal = initialJournal;
|
|
1823
|
+
const store = openMigrationStore(modules, paths, options.now, writeObserver(paths, lease, options.onBoundary));
|
|
1824
|
+
if (journal.status === 'inactive') {
|
|
1825
|
+
if (journal.cancelled) {
|
|
1826
|
+
return finishCancellation(store, modules, paths, lease, journal, options);
|
|
1827
|
+
}
|
|
1828
|
+
else {
|
|
1829
|
+
await rollbackSnapshot(paths, journal);
|
|
1830
|
+
if (digest(await secureRead(paths.legacyPath)) !==
|
|
1831
|
+
journal.rollback?.exportSha256)
|
|
1832
|
+
throw new AccountMigrationError('restored legacy bytes changed after rollback');
|
|
1833
|
+
}
|
|
1834
|
+
return { status: 'completed', receipt: receipt(journal) };
|
|
1835
|
+
}
|
|
1836
|
+
if (journal.status === 'pending' && journal.cancelled)
|
|
1837
|
+
return finishCancellation(store, modules, paths, lease, journal, options);
|
|
1838
|
+
if (journal.status === 'pending' &&
|
|
1839
|
+
journal.operation === 'migrate') {
|
|
1840
|
+
if (journal.sourceKind === 'absent')
|
|
1841
|
+
throw new AccountMigrationError('unserved fresh initialization resumes explicitly; no legacy file can be restored');
|
|
1842
|
+
// Pre-retirement cancellation preserves the untouched original legacy file;
|
|
1843
|
+
// this pending generation has never been admitted for credential serving.
|
|
1844
|
+
if (!['capture', 'build', 'verify'].includes(journal.phase) ||
|
|
1845
|
+
digest(await secureRead(paths.legacyPath)) !==
|
|
1846
|
+
journal.sourceSha256)
|
|
1847
|
+
throw new AccountMigrationError('pre-retirement cancellation requires unchanged, unserved legacy source');
|
|
1848
|
+
await verifyCopies(paths, journal, false);
|
|
1849
|
+
journal = {
|
|
1850
|
+
...journal,
|
|
1851
|
+
status: 'pending',
|
|
1852
|
+
operation: 'rollback',
|
|
1853
|
+
phase: 'capture',
|
|
1854
|
+
cancelled: true,
|
|
1855
|
+
};
|
|
1856
|
+
await saveJournal(modules, paths, lease, journal, false, options.onBoundary);
|
|
1857
|
+
return finishCancellation(store, modules, paths, lease, journal, options);
|
|
1858
|
+
}
|
|
1859
|
+
if (journal.status === 'active') {
|
|
1860
|
+
const active = journal;
|
|
1861
|
+
await currentSuccessor(store, paths, journal);
|
|
1862
|
+
if (await exists(paths.legacyPath))
|
|
1863
|
+
throw new AccountMigrationError('rollback refuses an existing or recreated legacy source');
|
|
1864
|
+
await verifyCopies(paths, journal, true);
|
|
1865
|
+
await options.onBoundary?.('rollback:before-capture');
|
|
1866
|
+
// Public config.json/state.json save leases protect the complete current
|
|
1867
|
+
// credentials, metadata and epoch snapshot used for rollback export.
|
|
1868
|
+
await modules.fs.withLock(paths.configPath, { name: 'save', ttlMs: 10_000, timeoutMs: 2_000, renew: true }, async (configLease) => {
|
|
1869
|
+
await modules.fs.withLock(paths.statePath, {
|
|
1870
|
+
name: 'save',
|
|
1871
|
+
ttlMs: 10_000,
|
|
1872
|
+
timeoutMs: 2_000,
|
|
1873
|
+
renew: true,
|
|
1874
|
+
}, async (stateLease) => {
|
|
1875
|
+
const current = await exportCurrent(store);
|
|
1876
|
+
const files = await synchronizedSnapshot(paths, options.onBoundary);
|
|
1877
|
+
const snapshot = Buffer.from(`${JSON.stringify({ config: files.config.toString('base64'), ...(files.state === undefined ? {} : { state: files.state.toString('base64') }), exported: current.exported }, null, 2)}\n`);
|
|
1878
|
+
const rollback = {
|
|
1879
|
+
id: randomUUID(),
|
|
1880
|
+
snapshotSha256: digest(snapshot),
|
|
1881
|
+
exportSha256: digest(`${JSON.stringify(current.exported, null, 2)}\n`),
|
|
1882
|
+
rows: current.rows,
|
|
1883
|
+
successorInactive: false,
|
|
1884
|
+
};
|
|
1885
|
+
await exclusiveBytes(join(paths.transfersDir, `${rollback.id}.json`), snapshot, rollback.snapshotSha256, options.onBoundary);
|
|
1886
|
+
await configLease.assertOwned();
|
|
1887
|
+
await stateLease.assertOwned();
|
|
1888
|
+
journal = {
|
|
1889
|
+
...active,
|
|
1890
|
+
status: 'pending',
|
|
1891
|
+
operation: 'rollback',
|
|
1892
|
+
phase: 'capture',
|
|
1893
|
+
rollback,
|
|
1894
|
+
};
|
|
1895
|
+
await saveJournal(modules, paths, lease, journal, false, options.onBoundary);
|
|
1896
|
+
});
|
|
1897
|
+
});
|
|
1898
|
+
await options.onBoundary?.('rollback:after-capture');
|
|
1899
|
+
}
|
|
1900
|
+
const rollback = journal.rollback;
|
|
1901
|
+
if (!rollback)
|
|
1902
|
+
throw new AccountMigrationError('rollback snapshot is incomplete');
|
|
1903
|
+
const snapshot = await rollbackSnapshot(paths, journal);
|
|
1904
|
+
if (journal.phase === 'capture' || journal.phase === 'build') {
|
|
1905
|
+
journal.phase = 'build';
|
|
1906
|
+
await saveJournal(modules, paths, lease, journal, false, options.onBoundary);
|
|
1907
|
+
await options.onBoundary?.('rollback:before-build');
|
|
1908
|
+
await management(store, journal);
|
|
1909
|
+
await options.onBoundary?.('rollback:after-build');
|
|
1910
|
+
journal.phase = 'verify';
|
|
1911
|
+
await saveJournal(modules, paths, lease, journal, false, options.onBoundary);
|
|
1912
|
+
}
|
|
1913
|
+
if (journal.phase === 'verify') {
|
|
1914
|
+
await options.onBoundary?.('rollback:before-verify');
|
|
1915
|
+
await currentSuccessor(store, paths, journal);
|
|
1916
|
+
const current = await exportCurrent(store);
|
|
1917
|
+
if (!isDeepStrictEqual(current.rows, rollback.rows) ||
|
|
1918
|
+
!isDeepStrictEqual(current.exported, snapshot.exported))
|
|
1919
|
+
throw new AccountMigrationError('current credentials or full metadata changed during rollback');
|
|
1920
|
+
await synchronizedSnapshot(paths, options.onBoundary);
|
|
1921
|
+
await verifyCopies(paths, journal, true);
|
|
1922
|
+
await options.onBoundary?.('rollback:after-verify');
|
|
1923
|
+
journal.phase = 'retire';
|
|
1924
|
+
await saveJournal(modules, paths, lease, journal, false, options.onBoundary);
|
|
1925
|
+
}
|
|
1926
|
+
if (journal.phase === 'retire') {
|
|
1927
|
+
await options.onBoundary?.('rollback:before-retire');
|
|
1928
|
+
rollback.successorInactive = true;
|
|
1929
|
+
await saveJournal(modules, paths, lease, journal, false, options.onBoundary);
|
|
1930
|
+
await management(store, journal);
|
|
1931
|
+
await options.onBoundary?.('rollback:after-retire');
|
|
1932
|
+
journal.phase = 'activate';
|
|
1933
|
+
await saveJournal(modules, paths, lease, journal, false, options.onBoundary);
|
|
1934
|
+
}
|
|
1935
|
+
await options.onBoundary?.('rollback:before-activate');
|
|
1936
|
+
await currentSuccessor(store, paths, journal);
|
|
1937
|
+
const current = await exportCurrent(store);
|
|
1938
|
+
if (!isDeepStrictEqual(current.rows, rollback.rows) ||
|
|
1939
|
+
!isDeepStrictEqual(current.exported, snapshot.exported))
|
|
1940
|
+
throw new AccountMigrationError('current successor changed before exclusive legacy restoration');
|
|
1941
|
+
await synchronizedSnapshot(paths, options.onBoundary);
|
|
1942
|
+
const exportSha256 = rollback.exportSha256;
|
|
1943
|
+
await modules.fs.withLock(paths.legacyPath, {
|
|
1944
|
+
name: 'accounts',
|
|
1945
|
+
ttlMs: 10_000,
|
|
1946
|
+
timeoutMs: 2_000,
|
|
1947
|
+
renew: true,
|
|
1948
|
+
}, async (accountsLease) => {
|
|
1949
|
+
await lease.assertOwned();
|
|
1950
|
+
await accountsLease.assertOwned();
|
|
1951
|
+
// Reuse the restored legacy file only when its exact bytes match this
|
|
1952
|
+
// generation's recorded durable current-credential rollback restore intent.
|
|
1953
|
+
const bytes = Buffer.from(`${JSON.stringify(snapshot.exported, null, 2)}\n`);
|
|
1954
|
+
await exclusiveBytes(paths.legacyPath, bytes, exportSha256, options.onBoundary);
|
|
1955
|
+
await accountsLease.assertOwned();
|
|
1956
|
+
journal.status = 'inactive';
|
|
1957
|
+
await saveJournal(modules, paths, lease, journal, false, options.onBoundary);
|
|
1958
|
+
});
|
|
1959
|
+
await management(store, journal, true);
|
|
1960
|
+
await options.onBoundary?.('rollback:after-activate');
|
|
1961
|
+
return { status: 'completed', receipt: receipt(journal) };
|
|
1962
|
+
});
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
catch (error) {
|
|
1966
|
+
const pending = pendingLockFailure(modules, error);
|
|
1967
|
+
if (pending !== undefined)
|
|
1968
|
+
return pending;
|
|
1969
|
+
throw error;
|
|
1970
|
+
}
|
|
1971
|
+
};
|
|
1972
|
+
}
|
|
1973
|
+
async function assertSourceAbsent(paths) {
|
|
1974
|
+
if (await exists(paths.legacyPath))
|
|
1975
|
+
throw new AccountMigrationError('fresh initialization refuses an existing or appearing legacy source');
|
|
1976
|
+
}
|
|
1977
|
+
async function expectedFreshArtifacts(paths, journal) {
|
|
1978
|
+
if (journal.sourceKind !== 'absent' ||
|
|
1979
|
+
journal.operation !== 'migrate' ||
|
|
1980
|
+
journal.mapping.length)
|
|
1981
|
+
throw new AccountMigrationError('journal is not an owned fresh initialization');
|
|
1982
|
+
await assertSourceAbsent(paths);
|
|
1983
|
+
await verifyCopies(paths, journal, false);
|
|
1984
|
+
if (await exists(paths.statePath))
|
|
1985
|
+
throw new AccountMigrationError('fresh initialization found unrelated credential artifacts');
|
|
1986
|
+
if (!(await exists(paths.configPath)))
|
|
1987
|
+
return;
|
|
1988
|
+
if (journal.phase === 'capture')
|
|
1989
|
+
throw new AccountMigrationError('fresh capture has an unexpected config artifact');
|
|
1990
|
+
const config = exact(jsonBytes(await secureRead(paths.configPath)), [
|
|
1991
|
+
'version',
|
|
1992
|
+
'accounts',
|
|
1993
|
+
'commonAuthPool',
|
|
1994
|
+
MANAGEMENT_SETTINGS_KEY,
|
|
1995
|
+
ROUTING_SETTINGS_KEY,
|
|
1996
|
+
ACCOUNT_STORE_GENERATION_SETTINGS_KEY,
|
|
1997
|
+
], 'fresh config');
|
|
1998
|
+
if (config.version !== 1 || list(config.accounts, 'fresh roster').length)
|
|
1999
|
+
throw new AccountMigrationError('fresh successor is not the expected empty roster');
|
|
2000
|
+
if (config.commonAuthPool === undefined) {
|
|
2001
|
+
if (journal.phase !== 'build' ||
|
|
2002
|
+
!isDeepStrictEqual(config, { version: 1, accounts: [] }))
|
|
2003
|
+
throw new AccountMigrationError('fresh seed has an unrelated association');
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
const pool = exact(config.commonAuthPool, ['schemaVersion', 'rows', 'retiredEpochs'], 'fresh pool');
|
|
2007
|
+
if (pool.schemaVersion !== 1 ||
|
|
2008
|
+
Object.keys(object(pool.rows, 'fresh entries')).length ||
|
|
2009
|
+
(pool.retiredEpochs !== undefined &&
|
|
2010
|
+
Object.keys(object(pool.retiredEpochs, 'fresh retired epochs')).length))
|
|
2011
|
+
throw new AccountMigrationError('fresh pool contains credential or epoch artifacts');
|
|
2012
|
+
const managementRecord = config[MANAGEMENT_SETTINGS_KEY];
|
|
2013
|
+
if (managementRecord !== undefined) {
|
|
2014
|
+
const m = exact(managementRecord, ['id', 'kind', 'targets', 'progress'], 'fresh management');
|
|
2015
|
+
const p = exact(m.progress, ['step', 'completedTargets'], 'fresh progress');
|
|
2016
|
+
if (m.id !== journal.id ||
|
|
2017
|
+
m.kind !== 'migration' ||
|
|
2018
|
+
list(m.targets, 'fresh targets').length ||
|
|
2019
|
+
list(p.completedTargets, 'fresh completions').length ||
|
|
2020
|
+
!ACCOUNT_MIGRATION_PHASES.some((phase) => phase === p.step))
|
|
2021
|
+
throw new AccountMigrationError('fresh management ownership differs');
|
|
2022
|
+
}
|
|
2023
|
+
if (config[ROUTING_SETTINGS_KEY] !== undefined &&
|
|
2024
|
+
!isDeepStrictEqual(config[ROUTING_SETTINGS_KEY], { schemaVersion: 1 }))
|
|
2025
|
+
throw new AccountMigrationError('fresh cursor encoding has an unrelated association');
|
|
2026
|
+
if (config[ACCOUNT_STORE_GENERATION_SETTINGS_KEY] !== undefined &&
|
|
2027
|
+
!isDeepStrictEqual(decodeAccountStoreGeneration(config[ACCOUNT_STORE_GENERATION_SETTINGS_KEY]), generationJson(journal)))
|
|
2028
|
+
throw new AccountMigrationError('fresh generation setting differs');
|
|
2029
|
+
}
|
|
2030
|
+
/** Explicit fresh initialization publishes an owned pointer/journal before first login; pointer absence alone never authorizes credential service. */
|
|
2031
|
+
export async function initializeFreshAccountStore(modules, options) {
|
|
2032
|
+
resolveAccountStorePaths(options.legacyPath);
|
|
2033
|
+
try {
|
|
2034
|
+
return await withParentTopology(modules, options.legacyPath, async (parentLease) => {
|
|
2035
|
+
await assertSourceAbsent(resolveAccountStorePaths(options.legacyPath));
|
|
2036
|
+
const pointer = await readPointer(options.legacyPath);
|
|
2037
|
+
if (pointer)
|
|
2038
|
+
await finishPointerPublication(options.legacyPath, pointer, parentLease);
|
|
2039
|
+
const physical = pointer
|
|
2040
|
+
? generationPaths(options.legacyPath, pointer.id)
|
|
2041
|
+
: await allocateGeneration(options.legacyPath);
|
|
2042
|
+
return modules.fs.withLock(physical.configPath, {
|
|
2043
|
+
name: MANAGEMENT_LOCK_NAME,
|
|
2044
|
+
ttlMs: 10_000,
|
|
2045
|
+
timeoutMs: 2_000,
|
|
2046
|
+
renew: true,
|
|
2047
|
+
}, async (lease) => {
|
|
2048
|
+
let journal = await readJournal(physical);
|
|
2049
|
+
if (pointer === undefined) {
|
|
2050
|
+
await options.onBoundary?.('before-capture');
|
|
2051
|
+
await assertSourceAbsent(physical);
|
|
2052
|
+
await modules.fs.withLock(physical.legacyPath, {
|
|
2053
|
+
name: 'accounts',
|
|
2054
|
+
ttlMs: 10_000,
|
|
2055
|
+
timeoutMs: 2_000,
|
|
2056
|
+
renew: true,
|
|
2057
|
+
}, async (accountsLease) => {
|
|
2058
|
+
await assertSourceAbsent(physical);
|
|
2059
|
+
await accountsLease.assertOwned();
|
|
2060
|
+
await lease.assertOwned();
|
|
2061
|
+
if (journal !== undefined ||
|
|
2062
|
+
(await exists(physical.configPath)) ||
|
|
2063
|
+
(await exists(physical.statePath)))
|
|
2064
|
+
throw new AccountMigrationError('fresh stage has unrelated artifacts');
|
|
2065
|
+
journal = {
|
|
2066
|
+
schemaVersion: 1,
|
|
2067
|
+
id: pathGenerationId(physical),
|
|
2068
|
+
legacyPath: physical.legacyPath,
|
|
2069
|
+
storeDir: physical.storeDir,
|
|
2070
|
+
sourceKind: 'absent',
|
|
2071
|
+
status: 'pending',
|
|
2072
|
+
operation: 'migrate',
|
|
2073
|
+
phase: 'capture',
|
|
2074
|
+
manifest: {
|
|
2075
|
+
sourceVersion: null,
|
|
2076
|
+
normalizationClock: options.now(),
|
|
2077
|
+
accounts: [],
|
|
2078
|
+
routing: { schemaVersion: 1 },
|
|
2079
|
+
effectiveActiveIndex: 0,
|
|
2080
|
+
},
|
|
2081
|
+
mapping: [],
|
|
2082
|
+
completedRows: [],
|
|
2083
|
+
};
|
|
2084
|
+
await saveJournal(modules, physical, lease, journal, true, options.onBoundary);
|
|
2085
|
+
await options.onBoundary?.('pointer:before-publication');
|
|
2086
|
+
await assertSourceAbsent(physical);
|
|
2087
|
+
await publishGenerationPointer(modules, physical, journal, parentLease, undefined, options.onBoundary);
|
|
2088
|
+
await options.onBoundary?.('pointer:after-publication');
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
if (journal?.sourceKind !== 'absent' ||
|
|
2092
|
+
journal.operation !== 'migrate' ||
|
|
2093
|
+
journal.status === 'inactive')
|
|
2094
|
+
throw new AccountMigrationError('fresh resume has no compatible pointed generation');
|
|
2095
|
+
await assertPublishedAccountStoreGeneration(physical);
|
|
2096
|
+
const guardBoundary = async (name) => {
|
|
2097
|
+
await assertSourceAbsent(physical);
|
|
2098
|
+
await options.onBoundary?.(name);
|
|
2099
|
+
await assertSourceAbsent(physical);
|
|
2100
|
+
await lease.assertOwned();
|
|
2101
|
+
};
|
|
2102
|
+
const store = openMigrationStore(modules, physical, options.now, writeObserver(physical, lease, guardBoundary));
|
|
2103
|
+
if (journal.status === 'active') {
|
|
2104
|
+
await verifyCopies(physical, journal, false);
|
|
2105
|
+
await currentSuccessor(store, physical, journal);
|
|
2106
|
+
await management(store, journal, true);
|
|
2107
|
+
return { status: 'completed', receipt: receipt(journal) };
|
|
2108
|
+
}
|
|
2109
|
+
await expectedFreshArtifacts(physical, journal);
|
|
2110
|
+
if (journal.phase === 'capture') {
|
|
2111
|
+
await guardBoundary('after-capture');
|
|
2112
|
+
journal.phase = 'build';
|
|
2113
|
+
await saveJournal(modules, physical, lease, journal, false, options.onBoundary);
|
|
2114
|
+
}
|
|
2115
|
+
const phaseOptions = {
|
|
2116
|
+
now: options.now,
|
|
2117
|
+
onBoundary: guardBoundary,
|
|
2118
|
+
};
|
|
2119
|
+
if (journal.phase === 'build') {
|
|
2120
|
+
await build(store, modules, physical, lease, journal, phaseOptions);
|
|
2121
|
+
await expectedFreshArtifacts(physical, journal);
|
|
2122
|
+
journal.phase = 'verify';
|
|
2123
|
+
await saveJournal(modules, physical, lease, journal, false, options.onBoundary);
|
|
2124
|
+
}
|
|
2125
|
+
if (journal.phase === 'verify') {
|
|
2126
|
+
await verify(store, physical, journal, phaseOptions);
|
|
2127
|
+
journal.phase = 'retire';
|
|
2128
|
+
await saveJournal(modules, physical, lease, journal, false, options.onBoundary);
|
|
2129
|
+
await management(store, journal);
|
|
2130
|
+
}
|
|
2131
|
+
if (journal.phase === 'retire') {
|
|
2132
|
+
await guardBoundary('before-retire');
|
|
2133
|
+
await expectedFreshArtifacts(physical, journal);
|
|
2134
|
+
await verifyImported(store, journal);
|
|
2135
|
+
const retiring = journal;
|
|
2136
|
+
await modules.fs.withLock(physical.legacyPath, {
|
|
2137
|
+
name: 'accounts',
|
|
2138
|
+
ttlMs: 10_000,
|
|
2139
|
+
timeoutMs: 2_000,
|
|
2140
|
+
renew: true,
|
|
2141
|
+
}, async (accountsLease) => {
|
|
2142
|
+
await assertSourceAbsent(physical);
|
|
2143
|
+
await accountsLease.assertOwned();
|
|
2144
|
+
retiring.sourceAbsenceVerified = true;
|
|
2145
|
+
await saveJournal(modules, physical, lease, retiring, false, options.onBoundary);
|
|
2146
|
+
});
|
|
2147
|
+
await guardBoundary('after-retire');
|
|
2148
|
+
journal.phase = 'activate';
|
|
2149
|
+
await saveJournal(modules, physical, lease, journal, false, options.onBoundary);
|
|
2150
|
+
}
|
|
2151
|
+
await guardBoundary('before-activate');
|
|
2152
|
+
await expectedFreshArtifacts(physical, journal);
|
|
2153
|
+
await verifyImported(store, journal);
|
|
2154
|
+
journal.status = 'active';
|
|
2155
|
+
await saveJournal(modules, physical, lease, journal, false, options.onBoundary);
|
|
2156
|
+
await management(store, journal, true);
|
|
2157
|
+
await guardBoundary('after-activate');
|
|
2158
|
+
return { status: 'completed', receipt: receipt(journal) };
|
|
2159
|
+
});
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
catch (error) {
|
|
2163
|
+
const pending = pendingLockFailure(modules, error);
|
|
2164
|
+
if (pending)
|
|
2165
|
+
return pending;
|
|
2166
|
+
throw error;
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
async function allocateGeneration(legacyPath) {
|
|
2170
|
+
if (await exists(resolveAccountStorePaths(legacyPath).storeDir))
|
|
2171
|
+
throw new AccountMigrationError('foreign logical directory refuses generation allocation');
|
|
2172
|
+
const paths = generationPaths(legacyPath, randomUUID());
|
|
2173
|
+
await mkdir(paths.storeDir, { mode: 0o700 });
|
|
2174
|
+
await prepareDirectories(paths);
|
|
2175
|
+
return paths;
|
|
2176
|
+
}
|
|
2177
|
+
async function publishGenerationPointer(modules, paths, journal, parentLease, prior, boundary) {
|
|
2178
|
+
await parentLease.assertOwned();
|
|
2179
|
+
const currentJournal = await readJournal(paths);
|
|
2180
|
+
if (!currentJournal ||
|
|
2181
|
+
currentJournal.id !== journal.id ||
|
|
2182
|
+
!isDeepStrictEqual(journalJson(currentJournal), journalJson(journal)))
|
|
2183
|
+
throw new AccountMigrationError('publication requires its complete synced canonical journal');
|
|
2184
|
+
await syncFile(paths.migrationPath);
|
|
2185
|
+
await syncDirectory(paths.storeDir);
|
|
2186
|
+
const pointer = {
|
|
2187
|
+
schemaVersion: 1,
|
|
2188
|
+
id: journal.id,
|
|
2189
|
+
directoryBasename: basename(paths.storeDir),
|
|
2190
|
+
};
|
|
2191
|
+
const path = accountStorePointerPath(paths.legacyPath);
|
|
2192
|
+
if (prior === undefined) {
|
|
2193
|
+
const stage = `${path}.${journal.id}.tmp`;
|
|
2194
|
+
const bytes = Buffer.from(`${JSON.stringify(pointer, null, 2)}\n`);
|
|
2195
|
+
await exclusiveBytes(stage, bytes, digest(bytes));
|
|
2196
|
+
await parentLease.assertOwned();
|
|
2197
|
+
await boundary?.('pointer:before-link');
|
|
2198
|
+
if (await exists(resolveAccountStorePaths(paths.legacyPath).storeDir))
|
|
2199
|
+
throw new AccountMigrationError('foreign logical store directory appeared before publication');
|
|
2200
|
+
if (journal.sourceKind === 'absent')
|
|
2201
|
+
await assertSourceAbsent(paths);
|
|
2202
|
+
else if (digest(await secureRead(paths.legacyPath)) !== journal.sourceSha256)
|
|
2203
|
+
throw new AccountMigrationError('captured source changed immediately before pointer link');
|
|
2204
|
+
try {
|
|
2205
|
+
await link(stage, path);
|
|
2206
|
+
}
|
|
2207
|
+
catch (error) {
|
|
2208
|
+
if (errno(error) === 'EEXIST')
|
|
2209
|
+
throw new AccountMigrationError('foreign or competing pointer publication refuses overwrite');
|
|
2210
|
+
throw new AccountMigrationError('exclusive pointer hard-link publication failed');
|
|
2211
|
+
}
|
|
2212
|
+
await boundary?.('pointer:after-link-before-parent-sync');
|
|
2213
|
+
await syncDirectory(dirname(path));
|
|
2214
|
+
if (!isDeepStrictEqual(await readPointer(paths.legacyPath), pointer))
|
|
2215
|
+
throw new AccountMigrationError('pointer publication readback differs');
|
|
2216
|
+
await boundary?.('pointer:after-sync-before-stage-cleanup');
|
|
2217
|
+
await unlink(stage);
|
|
2218
|
+
}
|
|
2219
|
+
else {
|
|
2220
|
+
const oldPaths = generationPaths(paths.legacyPath, prior.id);
|
|
2221
|
+
const oldJournal = await readJournal(oldPaths);
|
|
2222
|
+
if (oldJournal?.status !== 'inactive')
|
|
2223
|
+
throw new AccountMigrationError('pointer transition requires an owned inactive prior generation');
|
|
2224
|
+
await verifyCopies(oldPaths, oldJournal, !oldJournal.cancelled);
|
|
2225
|
+
const stageName = journal.id;
|
|
2226
|
+
await modules.fs.writeJsonAtomic(path, pointer, {
|
|
2227
|
+
stageName: () => stageName,
|
|
2228
|
+
beforeRename: async () => {
|
|
2229
|
+
await parentLease.assertOwned();
|
|
2230
|
+
if (!isDeepStrictEqual(await readPointer(paths.legacyPath), prior) ||
|
|
2231
|
+
(await readJournal(oldPaths))?.status !== 'inactive')
|
|
2232
|
+
throw new AccountMigrationError('inactive pointer changed before transition');
|
|
2233
|
+
await syncFile(`${path}.${stageName}.tmp`);
|
|
2234
|
+
},
|
|
2235
|
+
});
|
|
2236
|
+
await syncFile(path);
|
|
2237
|
+
await syncDirectory(dirname(path));
|
|
2238
|
+
}
|
|
2239
|
+
await parentLease.assertOwned();
|
|
2240
|
+
}
|
|
2241
|
+
function withParentTopology(modules, legacyPath, body) {
|
|
2242
|
+
return modules.fs.withLock(legacyPath, {
|
|
2243
|
+
name: MANAGEMENT_LOCK_NAME,
|
|
2244
|
+
ttlMs: 10_000,
|
|
2245
|
+
timeoutMs: 2_000,
|
|
2246
|
+
renew: true,
|
|
2247
|
+
}, body);
|
|
2248
|
+
}
|
|
2249
|
+
function writeObserver(paths, lease, boundary) {
|
|
2250
|
+
return async (step, info) => {
|
|
2251
|
+
await lease.assertOwned();
|
|
2252
|
+
await assertPublishedAccountStoreGeneration(paths);
|
|
2253
|
+
if (step.startsWith('after-')) {
|
|
2254
|
+
// The public observer runs under the native write locks. Persist the renamed
|
|
2255
|
+
// target and its directory before the next write or migration acknowledgment.
|
|
2256
|
+
// Random native staging files are private residue, not recovery evidence.
|
|
2257
|
+
await syncFile(step.includes('config') ? paths.configPath : paths.statePath);
|
|
2258
|
+
await syncDirectory(paths.storeDir, boundary);
|
|
2259
|
+
}
|
|
2260
|
+
await boundary?.(`public:${info.operation}:${step}`);
|
|
2261
|
+
await lease.assertOwned();
|
|
2262
|
+
await assertPublishedAccountStoreGeneration(paths);
|
|
2263
|
+
};
|
|
2264
|
+
}
|
|
2265
|
+
async function finishPointerPublication(legacyPath, pointer, lease) {
|
|
2266
|
+
const path = accountStorePointerPath(legacyPath);
|
|
2267
|
+
await syncFile(path);
|
|
2268
|
+
await syncDirectory(dirname(path));
|
|
2269
|
+
const stage = `${path}.${pointer.id}.tmp`;
|
|
2270
|
+
if (!(await exists(stage)))
|
|
2271
|
+
return;
|
|
2272
|
+
const bytes = await secureRead(stage);
|
|
2273
|
+
if (!isDeepStrictEqual(decodeAccountStorePointer(jsonBytes(bytes), legacyPath), pointer))
|
|
2274
|
+
throw new AccountMigrationError('pointer stage differs from publication authority');
|
|
2275
|
+
const [published, temporary] = await Promise.all([lstat(path), lstat(stage)]);
|
|
2276
|
+
if (published.dev !== temporary.dev || published.ino !== temporary.ino)
|
|
2277
|
+
throw new AccountMigrationError('pointer stage is not the published hard link');
|
|
2278
|
+
await lease.assertOwned();
|
|
2279
|
+
await unlink(stage);
|
|
2280
|
+
await syncDirectory(dirname(path));
|
|
2281
|
+
}
|
|
2282
|
+
//# sourceMappingURL=account-migration.js.map
|