Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 12 additions & 64 deletions lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1122,57 +1130,6 @@ function selectNewestAccount<T extends AccountLike>(
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<AccountLike, "accountId" | "email" | "refreshToken">
| null
| undefined,
): AccountIdentityRef {
return {
accountId: normalizeAccountIdKey(account?.accountId),
emailKey: normalizeEmailKey(account?.email),
refreshToken: normalizeRefreshTokenKey(account?.refreshToken),
};
}

function collectDistinctIdentityValues(
values: Array<string | undefined>,
): Set<string> {
Expand All @@ -1183,18 +1140,9 @@ function collectDistinctIdentityValues(
return distinct;
}

export function getAccountIdentityKey(
account: Pick<AccountLike, "accountId" | "email" | "refreshToken">,
): 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<T extends AccountLike>(
accounts: readonly T[],
Expand Down
70 changes: 70 additions & 0 deletions lib/storage/identity.ts
Original file line number Diff line number Diff line change
@@ -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<AccountLike, "accountId" | "email" | "refreshToken">
| null
| undefined,
): AccountIdentityRef {
return {
accountId: normalizeAccountIdKey(account?.accountId),
emailKey: normalizeEmailKey(account?.email),
refreshToken: normalizeRefreshTokenKey(account?.refreshToken),
};
}

export function getAccountIdentityKey(
account: Pick<AccountLike, "accountId" | "email" | "refreshToken">,
): 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;
}
66 changes: 60 additions & 6 deletions test/storage.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -12,27 +13,24 @@ import {
exportNamedBackup,
findMatchingAccountIndex,
formatStorageErrorHint,
getAccountIdentityKey,
getFlaggedAccountsPath,
getStoragePath,
importAccounts,
loadAccounts,
loadFlaggedAccounts,
normalizeEmailKey,
normalizeAccountStorage,
resolveAccountSelectionIndex,
saveFlaggedAccounts,
StorageError,
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;
Expand Down Expand Up @@ -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();
Expand Down