diff --git a/lib/storage.ts b/lib/storage.ts index a529807f..17f1ec9f 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -16,8 +16,16 @@ import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; import { toStorageError } 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 { + type AccountIdentityRef, + toAccountIdentityRef, +} from "./storage/identity.js"; import { type AccountMetadataV1, type AccountMetadataV3, @@ -1122,57 +1130,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 { @@ -1183,18 +1140,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..d7d424b0 --- /dev/null +++ b/lib/storage/identity.ts @@ -0,0 +1,70 @@ +import { createHash } from "node:crypto"; + +type AccountLike = { + accountId?: string; + email?: string; + refreshToken?: string; +}; + +export 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; +} + +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; +} + +function hashRefreshTokenKey(refreshToken: string): string { + return createHash("sha256").update(refreshToken).digest("hex"); +} + +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) { + // 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; +} diff --git a/test/storage.test.ts b/test/storage.test.ts index 7ea802b7..a5a99cc2 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,11 +13,13 @@ import { exportNamedBackup, findMatchingAccountIndex, formatStorageErrorHint, + getAccountIdentityKey, getFlaggedAccountsPath, getStoragePath, importAccounts, loadAccounts, loadFlaggedAccounts, + normalizeEmailKey, normalizeAccountStorage, resolveAccountSelectionIndex, saveFlaggedAccounts, @@ -24,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; @@ -95,6 +93,62 @@ describe("storage", () => { expect(error.cause).toBe(cause); }); }); + + 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({ + 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();