From c8bcf1382791054e0cd2c791814192d9dd7db251 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 11:59:27 +0800 Subject: [PATCH 1/6] refactor: extract storage identity helpers --- lib/storage.ts | 74 ++++++----------------------------------- lib/storage/identity.ts | 62 ++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 63 deletions(-) create mode 100644 lib/storage/identity.ts diff --git a/lib/storage.ts b/lib/storage.ts index aabbe776..12c77dac 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -15,7 +15,15 @@ import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; import { formatStorageErrorHint } from "./storage/error-hints.js"; export { formatStorageErrorHint } from "./storage/error-hints.js"; +export { + getAccountIdentityKey, + normalizeEmailKey, +} from "./storage/identity.js"; +import { + type AccountIdentityRef, + toAccountIdentityRef, +} from "./storage/identity.js"; import { type AccountMetadataV1, type AccountMetadataV3, @@ -1143,57 +1151,6 @@ function selectNewestAccount( return candidateAddedAt >= currentAddedAt ? candidate : current; } -function normalizeAccountIdKey( - accountId: string | undefined, -): string | undefined { - if (!accountId) return undefined; - const trimmed = accountId.trim(); - return trimmed || undefined; -} - -/** - * Normalize email keys for case-insensitive account identity matching. - */ -export function normalizeEmailKey( - email: string | undefined, -): string | undefined { - if (!email) return undefined; - const trimmed = email.trim(); - if (!trimmed) return undefined; - return trimmed.toLowerCase(); -} - -function normalizeRefreshTokenKey( - refreshToken: string | undefined, -): string | undefined { - if (!refreshToken) return undefined; - const trimmed = refreshToken.trim(); - return trimmed || undefined; -} - -type AccountIdentityRef = { - accountId?: string; - emailKey?: string; - refreshToken?: string; -}; - -type AccountMatchOptions = { - allowUniqueAccountIdFallbackWithoutEmail?: boolean; -}; - -function toAccountIdentityRef( - account: - | Pick - | null - | undefined, -): AccountIdentityRef { - return { - accountId: normalizeAccountIdKey(account?.accountId), - emailKey: normalizeEmailKey(account?.email), - refreshToken: normalizeRefreshTokenKey(account?.refreshToken), - }; -} - function collectDistinctIdentityValues( values: Array, ): Set { @@ -1204,18 +1161,9 @@ function collectDistinctIdentityValues( return distinct; } -export function getAccountIdentityKey( - account: Pick, -): string | undefined { - const ref = toAccountIdentityRef(account); - if (ref.accountId && ref.emailKey) { - return `account:${ref.accountId}::email:${ref.emailKey}`; - } - if (ref.accountId) return `account:${ref.accountId}`; - if (ref.emailKey) return `email:${ref.emailKey}`; - if (ref.refreshToken) return `refresh:${ref.refreshToken}`; - return undefined; -} +type AccountMatchOptions = { + allowUniqueAccountIdFallbackWithoutEmail?: boolean; +}; function findNewestMatchingIndex( accounts: readonly T[], diff --git a/lib/storage/identity.ts b/lib/storage/identity.ts new file mode 100644 index 00000000..6fc21e7c --- /dev/null +++ b/lib/storage/identity.ts @@ -0,0 +1,62 @@ +type AccountLike = { + accountId?: string; + email?: string; + refreshToken?: string; +}; + +export type AccountIdentityRef = { + accountId?: string; + emailKey?: string; + refreshToken?: string; +}; + +export function normalizeAccountIdKey( + accountId: string | undefined, +): string | undefined { + if (!accountId) return undefined; + const trimmed = accountId.trim(); + return trimmed || undefined; +} + +export function normalizeEmailKey( + email: string | undefined, +): string | undefined { + if (!email) return undefined; + const trimmed = email.trim(); + if (!trimmed) return undefined; + return trimmed.toLowerCase(); +} + +export function normalizeRefreshTokenKey( + refreshToken: string | undefined, +): string | undefined { + if (!refreshToken) return undefined; + const trimmed = refreshToken.trim(); + return trimmed || undefined; +} + +export function toAccountIdentityRef( + account: + | Pick + | null + | undefined, +): AccountIdentityRef { + return { + accountId: normalizeAccountIdKey(account?.accountId), + emailKey: normalizeEmailKey(account?.email), + refreshToken: normalizeRefreshTokenKey(account?.refreshToken), + }; +} + +export function getAccountIdentityKey( + account: Pick, +): string | undefined { + const ref = toAccountIdentityRef(account); + if (ref.accountId && ref.emailKey) { + return `account:${ref.accountId}::email:${ref.emailKey}`; + } + if (ref.accountId) return `account:${ref.accountId}`; + if (ref.emailKey) return `email:${ref.emailKey}`; + if (ref.refreshToken) return `refresh:${ref.refreshToken}`; + return undefined; +} From 8636163548e4eb5ba427e64c8505ca075eac84b3 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:41:54 +0800 Subject: [PATCH 2/6] fix: break storage error helper cycle --- lib/errors.ts | 23 +++++++++++++++++++++++ lib/storage.ts | 25 ++----------------------- lib/storage/error-hints.ts | 2 +- test/storage.test.ts | 17 ++++++++++++++++- 4 files changed, 42 insertions(+), 25 deletions(-) diff --git a/lib/errors.ts b/lib/errors.ts index a44081cd..334ea591 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -164,3 +164,26 @@ export class CodexRateLimitError extends CodexError { this.accountId = options?.accountId; } } + +/** + * Storage-specific error with a filesystem code, target path, and user-facing hint. + */ +export class StorageError extends Error { + readonly code: string; + readonly path: string; + readonly hint: string; + + constructor( + message: string, + code: string, + path: string, + hint: string, + cause?: Error, + ) { + super(message, { cause }); + this.name = "StorageError"; + this.code = code; + this.path = path; + this.hint = hint; + } +} diff --git a/lib/storage.ts b/lib/storage.ts index 12c77dac..184ca34f 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { existsSync, promises as fs } from "node:fs"; import { basename, dirname, isAbsolute, join, relative } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; +import { StorageError } from "./errors.js"; import { createLogger } from "./logger.js"; import { exportNamedBackupFile, @@ -14,6 +15,7 @@ import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; import { formatStorageErrorHint } from "./storage/error-hints.js"; +export { StorageError } from "./errors.js"; export { formatStorageErrorHint } from "./storage/error-hints.js"; export { getAccountIdentityKey, @@ -204,29 +206,6 @@ async function collectNamedBackups( return candidates; } -/** - * Custom error class for storage operations with platform-aware hints. - */ -export class StorageError extends Error { - readonly code: string; - readonly path: string; - readonly hint: string; - - constructor( - message: string, - code: string, - path: string, - hint: string, - cause?: Error, - ) { - super(message, { cause }); - this.name = "StorageError"; - this.code = code; - this.path = path; - this.hint = hint; - } -} - let storageMutex: Promise = Promise.resolve(); const transactionSnapshotContext = new AsyncLocalStorage<{ snapshot: AccountStorageV3 | null; diff --git a/lib/storage/error-hints.ts b/lib/storage/error-hints.ts index dd0b78ce..03cc4c86 100644 --- a/lib/storage/error-hints.ts +++ b/lib/storage/error-hints.ts @@ -1,4 +1,4 @@ -import { StorageError } from "../storage.js"; +import { StorageError } from "../errors.js"; export function formatStorageErrorHint(error: unknown, path: string): string { const err = error as NodeJS.ErrnoException; diff --git a/test/storage.test.ts b/test/storage.test.ts index 32480c74..46030255 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -27,7 +27,7 @@ import { withAccountAndFlaggedStorageTransaction, withAccountStorageTransaction, } from "../lib/storage.js"; -import { formatStorageErrorHint } from "../lib/storage/error-hints.js"; +import { toStorageError } from "../lib/storage/error-hints.js"; // Mocking the behavior we're about to implement for TDD // Since the functions aren't in lib/storage.ts yet, we'll need to mock them or @@ -79,6 +79,21 @@ describe("storage", () => { expect(error.hint).toContain("Permission denied writing"); expect(error.path).toBe("/tmp/openai-codex-accounts.json"); }); + + it("wraps unknown failures with a StorageError", () => { + const cause = Object.assign(new Error("file locked"), { code: "EBUSY" }); + const error = toStorageError( + "failed to persist accounts", + cause, + "/tmp/openai-codex-accounts.json", + ); + + expect(error).toBeInstanceOf(StorageError); + expect(error.code).toBe("EBUSY"); + expect(error.path).toBe("/tmp/openai-codex-accounts.json"); + expect(error.hint).toContain("File is locked"); + expect(error.cause).toBe(cause); + }); }); describe("deduplication", () => { it("preserves activeIndexByFamily when shared accountId entries remain distinct without email", () => { From 39bf72a99bd5f7f17f830559ea277eba7ea02fde Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:44:52 +0800 Subject: [PATCH 3/6] fix: tighten storage identity key surface --- lib/storage.ts | 40 +++++++++++++++++++++++++++++---- lib/storage/identity.ts | 20 +++++++++++------ test/storage.test.ts | 49 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 11 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 184ca34f..b8a9ea56 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -22,10 +22,7 @@ export { normalizeEmailKey, } from "./storage/identity.js"; -import { - type AccountIdentityRef, - toAccountIdentityRef, -} from "./storage/identity.js"; +import { normalizeEmailKey } from "./storage/identity.js"; import { type AccountMetadataV1, type AccountMetadataV3, @@ -232,6 +229,41 @@ type AccountLike = { lastUsed?: number; }; +type AccountIdentityRef = { + accountId?: string; + emailKey?: string; + refreshToken?: string; +}; + +function normalizeAccountIdKey( + accountId: string | undefined, +): string | undefined { + if (!accountId) return undefined; + const trimmed = accountId.trim(); + return trimmed || undefined; +} + +function normalizeRefreshTokenKey( + refreshToken: string | undefined, +): string | undefined { + if (!refreshToken) return undefined; + const trimmed = refreshToken.trim(); + return trimmed || undefined; +} + +function toAccountIdentityRef( + account: + | Pick + | null + | undefined, +): AccountIdentityRef { + return { + accountId: normalizeAccountIdKey(account?.accountId), + emailKey: normalizeEmailKey(account?.email), + refreshToken: normalizeRefreshTokenKey(account?.refreshToken), + }; +} + function looksLikeSyntheticFixtureAccount(account: AccountMetadataV3): boolean { const email = typeof account.email === "string" ? account.email.trim().toLowerCase() : ""; diff --git a/lib/storage/identity.ts b/lib/storage/identity.ts index 6fc21e7c..d6ab4b70 100644 --- a/lib/storage/identity.ts +++ b/lib/storage/identity.ts @@ -1,18 +1,18 @@ +import { createHash } from "node:crypto"; + type AccountLike = { accountId?: string; email?: string; refreshToken?: string; }; -export type AccountIdentityRef = { +type AccountIdentityRef = { accountId?: string; emailKey?: string; refreshToken?: string; }; -export function normalizeAccountIdKey( - accountId: string | undefined, -): string | undefined { +function normalizeAccountIdKey(accountId: string | undefined): string | undefined { if (!accountId) return undefined; const trimmed = accountId.trim(); return trimmed || undefined; @@ -27,7 +27,7 @@ export function normalizeEmailKey( return trimmed.toLowerCase(); } -export function normalizeRefreshTokenKey( +function normalizeRefreshTokenKey( refreshToken: string | undefined, ): string | undefined { if (!refreshToken) return undefined; @@ -35,7 +35,11 @@ export function normalizeRefreshTokenKey( return trimmed || undefined; } -export function toAccountIdentityRef( +function hashRefreshTokenKey(refreshToken: string): string { + return createHash("sha256").update(refreshToken).digest("hex"); +} + +function toAccountIdentityRef( account: | Pick | null @@ -57,6 +61,8 @@ export function getAccountIdentityKey( } if (ref.accountId) return `account:${ref.accountId}`; if (ref.emailKey) return `email:${ref.emailKey}`; - if (ref.refreshToken) return `refresh:${ref.refreshToken}`; + if (ref.refreshToken) { + return `refresh:${hashRefreshTokenKey(ref.refreshToken)}`; + } return undefined; } diff --git a/test/storage.test.ts b/test/storage.test.ts index 46030255..93aba856 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -12,6 +13,7 @@ import { exportNamedBackup, findMatchingAccountIndex, formatStorageErrorHint, + getAccountIdentityKey, getFlaggedAccountsPath, getStoragePath, importAccounts, @@ -95,6 +97,53 @@ describe("storage", () => { expect(error.cause).toBe(cause); }); }); + + describe("account identity keys", () => { + it("prefers accountId and normalized email when both are present", () => { + expect( + getAccountIdentityKey({ + accountId: " acct-123 ", + email: " User@Example.com ", + refreshToken: "secret-token", + }), + ).toBe("account:acct-123::email:user@example.com"); + }); + + it("falls back to accountId when email is missing", () => { + expect( + getAccountIdentityKey({ + accountId: " acct-123 ", + email: " ", + refreshToken: "secret-token", + }), + ).toBe("account:acct-123"); + }); + + it("falls back to normalized email when accountId is missing", () => { + expect( + getAccountIdentityKey({ + accountId: " ", + email: " User@Example.com ", + refreshToken: "secret-token", + }), + ).toBe("email:user@example.com"); + }); + + it("hashes refresh-token-only fallbacks", () => { + const refreshToken = " secret-token "; + const expectedHash = createHash("sha256") + .update(refreshToken.trim()) + .digest("hex"); + const identityKey = getAccountIdentityKey({ + accountId: " ", + email: " ", + refreshToken, + }); + + expect(identityKey).toBe(`refresh:${expectedHash}`); + expect(identityKey).not.toContain(refreshToken.trim()); + }); + }); describe("deduplication", () => { it("preserves activeIndexByFamily when shared accountId entries remain distinct without email", () => { const now = Date.now(); From 9373f0542c3ebb745c4afdc8bb9b216551035f23 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 13:05:48 +0800 Subject: [PATCH 4/6] docs: explain hashed refresh identity fallback --- lib/storage/identity.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/storage/identity.ts b/lib/storage/identity.ts index d6ab4b70..42c301e9 100644 --- a/lib/storage/identity.ts +++ b/lib/storage/identity.ts @@ -62,6 +62,8 @@ export function getAccountIdentityKey( if (ref.accountId) return `account:${ref.accountId}`; if (ref.emailKey) return `email:${ref.emailKey}`; if (ref.refreshToken) { + // Legacy refresh-only identity keys embedded raw tokens. Hashing preserves + // deterministic fallback matching without exposing token material in logs. return `refresh:${hashRefreshTokenKey(ref.refreshToken)}`; } return undefined; From 5ee807708d1de49517a7cd82f498063491a32209 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:15:09 +0800 Subject: [PATCH 5/6] refactor: share storage identity helpers --- lib/storage.ts | 43 ++++++----------------------------------- lib/storage/identity.ts | 4 ++-- test/storage.test.ts | 17 ++++++++++------ 3 files changed, 19 insertions(+), 45 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index b8a9ea56..69d51be6 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -16,13 +16,17 @@ import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; import { formatStorageErrorHint } from "./storage/error-hints.js"; export { StorageError } from "./errors.js"; -export { formatStorageErrorHint } from "./storage/error-hints.js"; +export { formatStorageErrorHint, toStorageError } from "./storage/error-hints.js"; export { getAccountIdentityKey, normalizeEmailKey, } from "./storage/identity.js"; -import { normalizeEmailKey } from "./storage/identity.js"; +import { + type AccountIdentityRef, + normalizeEmailKey, + toAccountIdentityRef, +} from "./storage/identity.js"; import { type AccountMetadataV1, type AccountMetadataV3, @@ -229,41 +233,6 @@ type AccountLike = { lastUsed?: number; }; -type AccountIdentityRef = { - accountId?: string; - emailKey?: string; - refreshToken?: string; -}; - -function normalizeAccountIdKey( - accountId: string | undefined, -): string | undefined { - if (!accountId) return undefined; - const trimmed = accountId.trim(); - return trimmed || undefined; -} - -function normalizeRefreshTokenKey( - refreshToken: string | undefined, -): string | undefined { - if (!refreshToken) return undefined; - const trimmed = refreshToken.trim(); - return trimmed || undefined; -} - -function toAccountIdentityRef( - account: - | Pick - | null - | undefined, -): AccountIdentityRef { - return { - accountId: normalizeAccountIdKey(account?.accountId), - emailKey: normalizeEmailKey(account?.email), - refreshToken: normalizeRefreshTokenKey(account?.refreshToken), - }; -} - function looksLikeSyntheticFixtureAccount(account: AccountMetadataV3): boolean { const email = typeof account.email === "string" ? account.email.trim().toLowerCase() : ""; diff --git a/lib/storage/identity.ts b/lib/storage/identity.ts index 42c301e9..d7d424b0 100644 --- a/lib/storage/identity.ts +++ b/lib/storage/identity.ts @@ -6,7 +6,7 @@ type AccountLike = { refreshToken?: string; }; -type AccountIdentityRef = { +export type AccountIdentityRef = { accountId?: string; emailKey?: string; refreshToken?: string; @@ -39,7 +39,7 @@ function hashRefreshTokenKey(refreshToken: string): string { return createHash("sha256").update(refreshToken).digest("hex"); } -function toAccountIdentityRef( +export function toAccountIdentityRef( account: | Pick | null diff --git a/test/storage.test.ts b/test/storage.test.ts index 93aba856..96388a9f 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -19,6 +19,7 @@ import { importAccounts, loadAccounts, loadFlaggedAccounts, + normalizeEmailKey, normalizeAccountStorage, resolveAccountSelectionIndex, saveFlaggedAccounts, @@ -26,15 +27,10 @@ import { saveAccounts, setStoragePath, setStoragePathDirect, + toStorageError, withAccountAndFlaggedStorageTransaction, withAccountStorageTransaction, } from "../lib/storage.js"; -import { toStorageError } from "../lib/storage/error-hints.js"; - -// Mocking the behavior we're about to implement for TDD -// Since the functions aren't in lib/storage.ts yet, we'll need to mock them or -// accept that this test won't even compile/run until we add them. -// But Task 0 says: "Tests should fail initially (RED phase)" describe("storage", () => { const _origCODEX_HOME = process.env.CODEX_HOME; @@ -99,6 +95,15 @@ describe("storage", () => { }); describe("account identity keys", () => { + it("normalizes mixed-case emails directly", () => { + expect(normalizeEmailKey(" User@Example.com ")).toBe("user@example.com"); + }); + + it("returns undefined for missing or blank emails", () => { + expect(normalizeEmailKey(undefined)).toBeUndefined(); + expect(normalizeEmailKey(" ")).toBeUndefined(); + }); + it("prefers accountId and normalized email when both are present", () => { expect( getAccountIdentityKey({ From 273e46a8f4d8224fa14c8e6227f5468360168147 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 22:44:48 +0800 Subject: [PATCH 6/6] fix: drop unused storage identity import --- lib/storage.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/storage.ts b/lib/storage.ts index 8ecd8f69..17f1ec9f 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -24,7 +24,6 @@ export { import { type AccountIdentityRef, - normalizeEmailKey, toAccountIdentityRef, } from "./storage/identity.js"; import {