Skip to content

Commit 105730c

Browse files
committed
feat(auth): snapshot before destructive actions
1 parent 5ddee4d commit 105730c

4 files changed

Lines changed: 487 additions & 0 deletions

File tree

lib/destructive-actions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
loadFlaggedAccounts,
1111
saveAccounts,
1212
saveFlaggedAccounts,
13+
snapshotAccountStorage,
1314
} from "./storage.js";
1415

1516
export const DESTRUCTIVE_ACTION_COPY = {
@@ -111,6 +112,7 @@ export async function deleteAccountAtIndex(options: {
111112
const target = options.storage.accounts.at(options.index);
112113
if (!target) return null;
113114
const flagged = await loadFlaggedAccounts();
115+
await snapshotAccountStorage({ reason: "delete-account" });
114116
const nextStorage: AccountStorageV3 = {
115117
...options.storage,
116118
accounts: options.storage.accounts.map((account) => ({ ...account })),
@@ -172,6 +174,7 @@ export async function deleteAccountAtIndex(options: {
172174
* Removes the accounts WAL and backups via the underlying storage helper.
173175
*/
174176
export async function deleteSavedAccounts(): Promise<DestructiveActionResult> {
177+
await snapshotAccountStorage({ reason: "delete-saved-accounts" });
175178
return {
176179
accountsCleared: await clearAccounts(),
177180
flaggedCleared: false,
@@ -184,6 +187,7 @@ export async function deleteSavedAccounts(): Promise<DestructiveActionResult> {
184187
* Keeps unified settings and on-disk Codex CLI sync state; only the in-memory Codex CLI cache is cleared.
185188
*/
186189
export async function resetLocalState(): Promise<DestructiveActionResult> {
190+
await snapshotAccountStorage({ reason: "reset-local-state" });
187191
const accountsCleared = await clearAccounts();
188192
const flaggedCleared = await clearFlaggedAccounts();
189193
const quotaCacheCleared = await clearQuotaCache();

lib/storage.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,22 @@ export interface ActionableNamedBackupRecoveries {
205205
totalBackups: number;
206206
}
207207

208+
export type AccountSnapshotReason =
209+
| "delete-account"
210+
| "delete-saved-accounts"
211+
| "reset-local-state"
212+
| "import-accounts";
213+
214+
export type AccountSnapshotFailurePolicy = "warn" | "error";
215+
216+
export interface AccountSnapshotOptions {
217+
reason: AccountSnapshotReason;
218+
now?: number;
219+
force?: boolean;
220+
failurePolicy?: AccountSnapshotFailurePolicy;
221+
createBackup?: typeof createNamedBackup;
222+
}
223+
208224
interface LoadedBackupCandidate {
209225
normalized: AccountStorageV3 | null;
210226
storedVersion: unknown;
@@ -2155,6 +2171,83 @@ export async function createNamedBackup(
21552171
);
21562172
}
21572173

2174+
function formatTimestampForSnapshot(timestamp: number): string {
2175+
const date = new Date(timestamp);
2176+
const pad = (value: number): string => value.toString().padStart(2, "0");
2177+
const milliseconds = date.getUTCMilliseconds().toString().padStart(3, "0");
2178+
const year = date.getUTCFullYear();
2179+
const month = pad(date.getUTCMonth() + 1);
2180+
const day = pad(date.getUTCDate());
2181+
const hours = pad(date.getUTCHours());
2182+
const minutes = pad(date.getUTCMinutes());
2183+
const seconds = pad(date.getUTCSeconds());
2184+
return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}_${milliseconds}`;
2185+
}
2186+
2187+
function buildAccountSnapshotName(
2188+
reason: AccountSnapshotReason,
2189+
timestamp: number,
2190+
): string {
2191+
return `accounts-${reason}-snapshot-${formatTimestampForSnapshot(timestamp)}`;
2192+
}
2193+
2194+
function extractPathTail(pathValue: string): string {
2195+
const segments = pathValue.split(/[\\/]+/).filter(Boolean);
2196+
return segments.at(-1) ?? pathValue;
2197+
}
2198+
2199+
function redactFilesystemDetails(value: string): string {
2200+
return value.replace(
2201+
/(?:[A-Za-z]:)?[\\/][^"'`\r\n]+(?:[\\/][^"'`\r\n]+)+/g,
2202+
(pathValue) => extractPathTail(pathValue),
2203+
);
2204+
}
2205+
2206+
function formatSnapshotErrorForLog(error: unknown): string {
2207+
const code =
2208+
typeof (error as NodeJS.ErrnoException | undefined)?.code === "string"
2209+
? (error as NodeJS.ErrnoException).code
2210+
: undefined;
2211+
const rawMessage =
2212+
error instanceof Error ? error.message : String(error ?? "unknown error");
2213+
const redactedMessage = redactFilesystemDetails(rawMessage);
2214+
if (code && !redactedMessage.includes(code)) {
2215+
return `${code}: ${redactedMessage}`;
2216+
}
2217+
return redactedMessage;
2218+
}
2219+
2220+
export async function snapshotAccountStorage(
2221+
options: AccountSnapshotOptions,
2222+
): Promise<NamedBackupMetadata | null> {
2223+
const {
2224+
reason,
2225+
now = Date.now(),
2226+
force = true,
2227+
failurePolicy = "warn",
2228+
createBackup = createNamedBackup,
2229+
} = options;
2230+
const currentStorage = await loadAccounts();
2231+
if (!currentStorage || currentStorage.accounts.length === 0) {
2232+
return null;
2233+
}
2234+
2235+
const backupName = buildAccountSnapshotName(reason, now);
2236+
try {
2237+
return await createBackup(backupName, { force });
2238+
} catch (error) {
2239+
if (failurePolicy === "error") {
2240+
throw error;
2241+
}
2242+
log.warn("Failed to create account storage snapshot", {
2243+
reason,
2244+
backupName,
2245+
error: formatSnapshotErrorForLog(error),
2246+
});
2247+
return null;
2248+
}
2249+
}
2250+
21582251
export async function assessNamedBackupRestore(
21592252
name: string,
21602253
options: { currentStorage?: AccountStorageV3 | null } = {},
@@ -3538,6 +3631,10 @@ export async function importAccounts(
35383631
throw new Error("Invalid account storage format");
35393632
}
35403633

3634+
// Capture the current pool before merge/limit checks so failed imports still
3635+
// leave a rollback-ready checkpoint for the pre-import state.
3636+
await snapshotAccountStorage({ reason: "import-accounts" });
3637+
35413638
const {
35423639
imported: importedCount,
35433640
total,

test/destructive-actions.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const clearCodexCliStateCacheMock = vi.fn();
77
const loadFlaggedAccountsMock = vi.fn();
88
const saveAccountsMock = vi.fn();
99
const saveFlaggedAccountsMock = vi.fn();
10+
const snapshotAccountStorageMock = vi.fn();
1011

1112
vi.mock("../lib/codex-cli/state.js", () => ({
1213
clearCodexCliStateCache: clearCodexCliStateCacheMock,
@@ -26,6 +27,7 @@ vi.mock("../lib/storage.js", () => ({
2627
loadFlaggedAccounts: loadFlaggedAccountsMock,
2728
saveAccounts: saveAccountsMock,
2829
saveFlaggedAccounts: saveFlaggedAccountsMock,
30+
snapshotAccountStorage: snapshotAccountStorageMock,
2931
}));
3032

3133
describe("destructive actions", () => {
@@ -38,6 +40,7 @@ describe("destructive actions", () => {
3840
loadFlaggedAccountsMock.mockResolvedValue({ version: 1, accounts: [] });
3941
saveAccountsMock.mockResolvedValue(undefined);
4042
saveFlaggedAccountsMock.mockResolvedValue(undefined);
43+
snapshotAccountStorageMock.mockResolvedValue(null);
4144
});
4245

4346
it("returns delete-only results without pretending kept data was cleared", async () => {
@@ -50,7 +53,15 @@ describe("destructive actions", () => {
5053
flaggedCleared: false,
5154
quotaCacheCleared: false,
5255
});
56+
expect(snapshotAccountStorageMock).toHaveBeenCalledWith({
57+
reason: "delete-saved-accounts",
58+
});
5359
expect(clearAccountsMock).toHaveBeenCalledTimes(1);
60+
expect(
61+
snapshotAccountStorageMock.mock.invocationCallOrder[0],
62+
).toBeLessThan(
63+
clearAccountsMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
64+
);
5465
expect(clearFlaggedAccountsMock).not.toHaveBeenCalled();
5566
expect(clearQuotaCacheMock).not.toHaveBeenCalled();
5667
expect(clearCodexCliStateCacheMock).not.toHaveBeenCalled();
@@ -68,7 +79,15 @@ describe("destructive actions", () => {
6879
flaggedCleared: false,
6980
quotaCacheCleared: true,
7081
});
82+
expect(snapshotAccountStorageMock).toHaveBeenCalledWith({
83+
reason: "reset-local-state",
84+
});
7185
expect(clearAccountsMock).toHaveBeenCalledTimes(1);
86+
expect(
87+
snapshotAccountStorageMock.mock.invocationCallOrder[0],
88+
).toBeLessThan(
89+
clearAccountsMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
90+
);
7291
expect(clearFlaggedAccountsMock).toHaveBeenCalledTimes(1);
7392
expect(clearQuotaCacheMock).toHaveBeenCalledTimes(1);
7493
expect(clearCodexCliStateCacheMock).toHaveBeenCalledTimes(1);
@@ -120,6 +139,14 @@ describe("destructive actions", () => {
120139
const deleted = await deleteAccountAtIndex({ storage, index: 0 });
121140

122141
expect(deleted).not.toBeNull();
142+
expect(snapshotAccountStorageMock).toHaveBeenCalledWith({
143+
reason: "delete-account",
144+
});
145+
expect(
146+
snapshotAccountStorageMock.mock.invocationCallOrder[0],
147+
).toBeLessThan(
148+
saveAccountsMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
149+
);
123150
expect(deleted?.storage.accounts.map((account) => account.refreshToken)).toEqual([
124151
"refresh-active",
125152
"refresh-other",

0 commit comments

Comments
 (0)