codex-multi-auth 0.1.8 → 0.1.9

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.
@@ -713,107 +713,224 @@ function selectNewestAccount(current, candidate) {
713
713
  const candidateAddedAt = candidate.addedAt || 0;
714
714
  return candidateAddedAt >= currentAddedAt ? candidate : current;
715
715
  }
716
- function deduplicateAccountsByKey(accounts) {
717
- const keyToIndex = new Map();
718
- const indicesToKeep = new Set();
716
+ function normalizeAccountIdKey(accountId) {
717
+ if (!accountId)
718
+ return undefined;
719
+ const trimmed = accountId.trim();
720
+ return trimmed || undefined;
721
+ }
722
+ /**
723
+ * Normalize email keys for case-insensitive account identity matching.
724
+ */
725
+ export function normalizeEmailKey(email) {
726
+ if (!email)
727
+ return undefined;
728
+ const trimmed = email.trim();
729
+ if (!trimmed)
730
+ return undefined;
731
+ return trimmed.toLowerCase();
732
+ }
733
+ function normalizeRefreshTokenKey(refreshToken) {
734
+ if (!refreshToken)
735
+ return undefined;
736
+ const trimmed = refreshToken.trim();
737
+ return trimmed || undefined;
738
+ }
739
+ function toAccountIdentityRef(account) {
740
+ return {
741
+ accountId: normalizeAccountIdKey(account?.accountId),
742
+ emailKey: normalizeEmailKey(account?.email),
743
+ refreshToken: normalizeRefreshTokenKey(account?.refreshToken),
744
+ };
745
+ }
746
+ function collectDistinctIdentityValues(values) {
747
+ const distinct = new Set();
748
+ for (const value of values) {
749
+ if (value)
750
+ distinct.add(value);
751
+ }
752
+ return distinct;
753
+ }
754
+ export function getAccountIdentityKey(account) {
755
+ const ref = toAccountIdentityRef(account);
756
+ if (ref.accountId && ref.emailKey) {
757
+ return `account:${ref.accountId}::email:${ref.emailKey}`;
758
+ }
759
+ if (ref.accountId)
760
+ return `account:${ref.accountId}`;
761
+ if (ref.emailKey)
762
+ return `email:${ref.emailKey}`;
763
+ if (ref.refreshToken)
764
+ return `refresh:${ref.refreshToken}`;
765
+ return undefined;
766
+ }
767
+ function findNewestMatchingIndex(accounts, predicate) {
768
+ let matchIndex;
769
+ let match;
719
770
  for (let i = 0; i < accounts.length; i += 1) {
720
771
  const account = accounts[i];
721
772
  if (!account)
722
773
  continue;
723
- const key = account.accountId || account.refreshToken;
724
- if (!key)
774
+ const ref = toAccountIdentityRef(account);
775
+ if (!predicate(ref))
725
776
  continue;
726
- const existingIndex = keyToIndex.get(key);
727
- if (existingIndex === undefined) {
728
- keyToIndex.set(key, i);
777
+ if (matchIndex === undefined) {
778
+ matchIndex = i;
779
+ match = account;
729
780
  continue;
730
781
  }
731
- const existing = accounts[existingIndex];
732
- const newest = selectNewestAccount(existing, account);
733
- keyToIndex.set(key, newest === account ? i : existingIndex);
734
- }
735
- for (const idx of keyToIndex.values()) {
736
- indicesToKeep.add(idx);
737
- }
738
- const result = [];
739
- for (let i = 0; i < accounts.length; i += 1) {
740
- if (indicesToKeep.has(i)) {
741
- const account = accounts[i];
742
- if (account)
743
- result.push(account);
782
+ const newest = selectNewestAccount(match, account);
783
+ if (newest === account) {
784
+ matchIndex = i;
785
+ match = account;
744
786
  }
745
787
  }
746
- return result;
788
+ return matchIndex;
747
789
  }
748
- /**
749
- * Removes duplicate accounts, keeping the most recently used entry for each unique key.
750
- * Deduplication is based on accountId or refreshToken.
751
- * @param accounts - Array of accounts to deduplicate
752
- * @returns New array with duplicates removed
753
- */
754
- export function deduplicateAccounts(accounts) {
755
- return deduplicateAccountsByKey(accounts);
790
+ function findCompositeAccountMatchIndex(accounts, candidateRef) {
791
+ if (!candidateRef.accountId || !candidateRef.emailKey)
792
+ return undefined;
793
+ return findNewestMatchingIndex(accounts, (ref) => ref.accountId === candidateRef.accountId &&
794
+ ref.emailKey === candidateRef.emailKey);
756
795
  }
757
- /**
758
- * Removes duplicate accounts by email, keeping the most recently used entry.
759
- * Accounts without email are always preserved.
760
- * @param accounts - Array of accounts to deduplicate
761
- * @returns New array with email duplicates removed
762
- */
763
- export function normalizeEmailKey(email) {
764
- if (!email)
796
+ function findSafeEmailMatchIndex(accounts, candidateRef) {
797
+ if (!candidateRef.emailKey)
765
798
  return undefined;
766
- const trimmed = email.trim();
767
- if (!trimmed)
799
+ const emailAccountIds = [candidateRef.accountId];
800
+ let foundAny = false;
801
+ for (let i = 0; i < accounts.length; i += 1) {
802
+ const account = accounts[i];
803
+ if (!account)
804
+ continue;
805
+ const ref = toAccountIdentityRef(account);
806
+ if (ref.emailKey !== candidateRef.emailKey)
807
+ continue;
808
+ foundAny = true;
809
+ emailAccountIds.push(ref.accountId);
810
+ }
811
+ if (!foundAny)
768
812
  return undefined;
769
- return trimmed.toLowerCase();
813
+ if (collectDistinctIdentityValues(emailAccountIds).size > 1) {
814
+ return undefined;
815
+ }
816
+ return findNewestMatchingIndex(accounts, (ref) => ref.emailKey === candidateRef.emailKey);
770
817
  }
771
- export function deduplicateAccountsByEmail(accounts) {
772
- const emailToNewestIndex = new Map();
773
- const indicesToKeep = new Set();
818
+ function findCompatibleRefreshTokenMatchIndex(accounts, candidateRef) {
819
+ if (!candidateRef.refreshToken)
820
+ return undefined;
821
+ let matchingIndex;
822
+ let matchingAccount = null;
774
823
  for (let i = 0; i < accounts.length; i += 1) {
775
824
  const account = accounts[i];
776
825
  if (!account)
777
826
  continue;
778
- const email = normalizeEmailKey(account.email);
779
- if (!email) {
780
- indicesToKeep.add(i);
781
- continue;
782
- }
783
- const existingIndex = emailToNewestIndex.get(email);
784
- if (existingIndex === undefined) {
785
- emailToNewestIndex.set(email, i);
827
+ const ref = toAccountIdentityRef(account);
828
+ if (ref.refreshToken !== candidateRef.refreshToken)
786
829
  continue;
787
- }
788
- const existing = accounts[existingIndex];
789
- // istanbul ignore next -- defensive code: existingIndex always refers to valid account
790
- if (!existing) {
791
- emailToNewestIndex.set(email, i);
830
+ if ((candidateRef.accountId &&
831
+ ref.accountId &&
832
+ ref.accountId !== candidateRef.accountId) ||
833
+ (candidateRef.emailKey &&
834
+ ref.emailKey &&
835
+ ref.emailKey !== candidateRef.emailKey)) {
836
+ return undefined;
837
+ }
838
+ if (matchingIndex !== undefined &&
839
+ !candidateRef.accountId &&
840
+ !candidateRef.emailKey) {
841
+ return undefined;
842
+ }
843
+ if (matchingIndex === undefined || matchingAccount === null) {
844
+ matchingIndex = i;
845
+ matchingAccount = account;
792
846
  continue;
793
847
  }
794
- const existingLastUsed = existing.lastUsed || 0;
795
- const candidateLastUsed = account.lastUsed || 0;
796
- const existingAddedAt = existing.addedAt || 0;
797
- const candidateAddedAt = account.addedAt || 0;
798
- const isNewer = candidateLastUsed > existingLastUsed ||
799
- (candidateLastUsed === existingLastUsed &&
800
- candidateAddedAt > existingAddedAt);
801
- if (isNewer) {
802
- emailToNewestIndex.set(email, i);
848
+ const newest = selectNewestAccount(matchingAccount, account);
849
+ if (newest === account) {
850
+ matchingIndex = i;
851
+ matchingAccount = account;
803
852
  }
804
853
  }
805
- for (const idx of emailToNewestIndex.values()) {
806
- indicesToKeep.add(idx);
854
+ return matchingIndex;
855
+ }
856
+ function findUniqueAccountIdMatchIndex(accounts, candidateRef, options) {
857
+ if (!candidateRef.accountId)
858
+ return undefined;
859
+ if (!candidateRef.emailKey &&
860
+ !options.allowUniqueAccountIdFallbackWithoutEmail) {
861
+ return undefined;
807
862
  }
808
- const result = [];
863
+ let matchingIndex;
864
+ let matchingEmailKey;
809
865
  for (let i = 0; i < accounts.length; i += 1) {
810
- if (indicesToKeep.has(i)) {
811
- const account = accounts[i];
812
- if (account)
813
- result.push(account);
866
+ const account = accounts[i];
867
+ if (!account)
868
+ continue;
869
+ const ref = toAccountIdentityRef(account);
870
+ if (ref.accountId !== candidateRef.accountId)
871
+ continue;
872
+ if (matchingIndex !== undefined) {
873
+ return undefined;
874
+ }
875
+ matchingIndex = i;
876
+ matchingEmailKey = ref.emailKey;
877
+ }
878
+ if (matchingIndex !== undefined &&
879
+ matchingEmailKey &&
880
+ candidateRef.emailKey &&
881
+ matchingEmailKey !== candidateRef.emailKey) {
882
+ return undefined;
883
+ }
884
+ return matchingIndex;
885
+ }
886
+ export function findMatchingAccountIndex(accounts, candidate, options = {}) {
887
+ const candidateRef = toAccountIdentityRef(candidate);
888
+ const byComposite = findCompositeAccountMatchIndex(accounts, candidateRef);
889
+ if (byComposite !== undefined)
890
+ return byComposite;
891
+ const byEmail = findSafeEmailMatchIndex(accounts, candidateRef);
892
+ if (byEmail !== undefined)
893
+ return byEmail;
894
+ if (candidateRef.refreshToken) {
895
+ const byRefresh = findCompatibleRefreshTokenMatchIndex(accounts, candidateRef);
896
+ if (byRefresh !== undefined)
897
+ return byRefresh;
898
+ }
899
+ return findUniqueAccountIdMatchIndex(accounts, candidateRef, options);
900
+ }
901
+ export function resolveAccountSelectionIndex(accounts, candidate, fallbackIndex = 0) {
902
+ if (accounts.length === 0)
903
+ return 0;
904
+ const matchedIndex = findMatchingAccountIndex(accounts, candidate, {
905
+ allowUniqueAccountIdFallbackWithoutEmail: true,
906
+ });
907
+ if (matchedIndex !== undefined)
908
+ return matchedIndex;
909
+ return clampIndex(fallbackIndex, accounts.length);
910
+ }
911
+ function deduplicateAccountsByIdentity(accounts) {
912
+ const deduplicated = [];
913
+ for (const account of accounts) {
914
+ if (!account)
915
+ continue;
916
+ const existingIndex = findMatchingAccountIndex(deduplicated, account);
917
+ if (existingIndex === undefined) {
918
+ deduplicated.push(account);
919
+ continue;
814
920
  }
921
+ deduplicated[existingIndex] = selectNewestAccount(deduplicated[existingIndex], account);
815
922
  }
816
- return result;
923
+ return deduplicated;
924
+ }
925
+ /**
926
+ * Removes duplicate accounts, keeping the most recently used entry for each
927
+ * safely matched identity.
928
+ */
929
+ export function deduplicateAccounts(accounts) {
930
+ return deduplicateAccountsByIdentity(accounts);
931
+ }
932
+ export function deduplicateAccountsByEmail(accounts) {
933
+ return deduplicateAccountsByIdentity(accounts);
817
934
  }
818
935
  function isRecord(value) {
819
936
  return !!value && typeof value === "object" && !Array.isArray(value);
@@ -823,20 +940,17 @@ function clampIndex(index, length) {
823
940
  return 0;
824
941
  return Math.max(0, Math.min(index, length - 1));
825
942
  }
826
- function toAccountKey(account) {
827
- return account.accountId || account.refreshToken;
828
- }
829
- function extractActiveKey(accounts, activeIndex) {
943
+ function extractActiveAccountRef(accounts, activeIndex) {
830
944
  const candidate = accounts[activeIndex];
831
945
  if (!isRecord(candidate))
832
- return undefined;
833
- const accountId = typeof candidate.accountId === "string" && candidate.accountId.trim()
834
- ? candidate.accountId
835
- : undefined;
836
- const refreshToken = typeof candidate.refreshToken === "string" && candidate.refreshToken.trim()
837
- ? candidate.refreshToken
838
- : undefined;
839
- return accountId || refreshToken;
946
+ return {};
947
+ return toAccountIdentityRef({
948
+ accountId: typeof candidate.accountId === "string" ? candidate.accountId : undefined,
949
+ email: typeof candidate.email === "string" ? candidate.email : undefined,
950
+ refreshToken: typeof candidate.refreshToken === "string"
951
+ ? candidate.refreshToken
952
+ : undefined,
953
+ });
840
954
  }
841
955
  /**
842
956
  * Normalizes and validates account storage data, migrating from v1 to v3 if needed.
@@ -864,7 +978,7 @@ export function normalizeAccountStorage(data) {
864
978
  ? data.activeIndex
865
979
  : 0;
866
980
  const rawActiveIndex = clampIndex(activeIndexValue, rawAccounts.length);
867
- const activeKey = extractActiveKey(rawAccounts, rawActiveIndex);
981
+ const activeRef = extractActiveAccountRef(rawAccounts, rawActiveIndex);
868
982
  const fromVersion = data.version;
869
983
  const baseStorage = fromVersion === 1
870
984
  ? migrateV1ToV3(data)
@@ -872,16 +986,15 @@ export function normalizeAccountStorage(data) {
872
986
  const validAccounts = rawAccounts.filter((account) => isRecord(account) &&
873
987
  typeof account.refreshToken === "string" &&
874
988
  !!account.refreshToken.trim());
875
- const deduplicatedAccounts = deduplicateAccountsByEmail(deduplicateAccountsByKey(validAccounts));
989
+ const deduplicatedAccounts = deduplicateAccounts(validAccounts);
876
990
  const activeIndex = (() => {
877
991
  if (deduplicatedAccounts.length === 0)
878
992
  return 0;
879
- if (activeKey) {
880
- const mappedIndex = deduplicatedAccounts.findIndex((account) => toAccountKey(account) === activeKey);
881
- if (mappedIndex >= 0)
882
- return mappedIndex;
883
- }
884
- return clampIndex(rawActiveIndex, deduplicatedAccounts.length);
993
+ return resolveAccountSelectionIndex(deduplicatedAccounts, {
994
+ accountId: activeRef.accountId,
995
+ email: activeRef.emailKey,
996
+ refreshToken: activeRef.refreshToken,
997
+ }, rawActiveIndex);
885
998
  })();
886
999
  const activeIndexByFamily = {};
887
1000
  const rawFamilyIndices = isRecord(baseStorage.activeIndexByFamily)
@@ -893,15 +1006,12 @@ export function normalizeAccountStorage(data) {
893
1006
  ? rawIndexValue
894
1007
  : rawActiveIndex;
895
1008
  const clampedRawIndex = clampIndex(rawIndex, rawAccounts.length);
896
- const familyKey = extractActiveKey(rawAccounts, clampedRawIndex);
897
- let mappedIndex = clampIndex(rawIndex, deduplicatedAccounts.length);
898
- if (familyKey && deduplicatedAccounts.length > 0) {
899
- const idx = deduplicatedAccounts.findIndex((account) => toAccountKey(account) === familyKey);
900
- if (idx >= 0) {
901
- mappedIndex = idx;
902
- }
903
- }
904
- activeIndexByFamily[family] = mappedIndex;
1009
+ const familyRef = extractActiveAccountRef(rawAccounts, clampedRawIndex);
1010
+ activeIndexByFamily[family] = resolveAccountSelectionIndex(deduplicatedAccounts, {
1011
+ accountId: familyRef.accountId,
1012
+ email: familyRef.emailKey,
1013
+ refreshToken: familyRef.refreshToken,
1014
+ }, rawIndex);
905
1015
  }
906
1016
  return {
907
1017
  version: 3,
@@ -1314,6 +1424,17 @@ async function saveAccountsUnlocked(storage) {
1314
1424
  throw new StorageError(`Failed to save accounts: ${err?.message || "Unknown error"}`, code, path, hint, err instanceof Error ? err : undefined);
1315
1425
  }
1316
1426
  }
1427
+ function cloneAccountStorageForPersistence(storage) {
1428
+ return {
1429
+ version: 3,
1430
+ accounts: structuredClone(storage?.accounts ?? []),
1431
+ activeIndex: typeof storage?.activeIndex === "number" &&
1432
+ Number.isFinite(storage.activeIndex)
1433
+ ? storage.activeIndex
1434
+ : 0,
1435
+ activeIndexByFamily: structuredClone(storage?.activeIndexByFamily ?? {}),
1436
+ };
1437
+ }
1317
1438
  export async function withAccountStorageTransaction(handler) {
1318
1439
  return withStorageLock(async () => {
1319
1440
  const state = {
@@ -1328,6 +1449,40 @@ export async function withAccountStorageTransaction(handler) {
1328
1449
  return transactionSnapshotContext.run(state, () => handler(current, persist));
1329
1450
  });
1330
1451
  }
1452
+ export async function withAccountAndFlaggedStorageTransaction(handler) {
1453
+ return withStorageLock(async () => {
1454
+ const state = {
1455
+ snapshot: await loadAccountsInternal(saveAccountsUnlocked),
1456
+ active: true,
1457
+ };
1458
+ const current = state.snapshot;
1459
+ const persist = async (accountStorage, flaggedStorage) => {
1460
+ const previousAccounts = cloneAccountStorageForPersistence(state.snapshot);
1461
+ const nextAccounts = cloneAccountStorageForPersistence(accountStorage);
1462
+ await saveAccountsUnlocked(nextAccounts);
1463
+ try {
1464
+ await saveFlaggedAccountsUnlocked(flaggedStorage);
1465
+ state.snapshot = nextAccounts;
1466
+ }
1467
+ catch (error) {
1468
+ try {
1469
+ await saveAccountsUnlocked(previousAccounts);
1470
+ state.snapshot = previousAccounts;
1471
+ }
1472
+ catch (rollbackError) {
1473
+ const combinedError = new AggregateError([error, rollbackError], "Flagged save failed and account storage rollback also failed");
1474
+ log.error("Failed to rollback account storage after flagged save failure", {
1475
+ error: String(error),
1476
+ rollbackError: String(rollbackError),
1477
+ });
1478
+ throw combinedError;
1479
+ }
1480
+ throw error;
1481
+ }
1482
+ };
1483
+ return transactionSnapshotContext.run(state, () => handler(current, persist));
1484
+ });
1485
+ }
1331
1486
  /**
1332
1487
  * Persists account storage to disk using atomic write (temp file + rename).
1333
1488
  * Creates the Codex multi-auth storage directory if it doesn't exist.
@@ -1516,48 +1671,53 @@ export async function loadFlaggedAccounts() {
1516
1671
  return empty;
1517
1672
  }
1518
1673
  }
1519
- export async function saveFlaggedAccounts(storage) {
1520
- return withStorageLock(async () => {
1521
- const path = getFlaggedAccountsPath();
1522
- const markerPath = getIntentionalResetMarkerPath(path);
1523
- const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
1524
- const tempPath = `${path}.${uniqueSuffix}.tmp`;
1525
- try {
1526
- await fs.mkdir(dirname(path), { recursive: true });
1527
- if (existsSync(path)) {
1528
- try {
1529
- await fs.copyFile(path, `${path}.bak`);
1530
- }
1531
- catch (backupError) {
1532
- log.warn("Failed to create flagged backup snapshot", {
1533
- path,
1534
- error: String(backupError),
1535
- });
1536
- }
1537
- }
1538
- const content = JSON.stringify(normalizeFlaggedStorage(storage), null, 2);
1539
- await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
1540
- await fs.rename(tempPath, path);
1674
+ async function saveFlaggedAccountsUnlocked(storage) {
1675
+ const path = getFlaggedAccountsPath();
1676
+ const markerPath = getIntentionalResetMarkerPath(path);
1677
+ const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
1678
+ const tempPath = `${path}.${uniqueSuffix}.tmp`;
1679
+ try {
1680
+ await fs.mkdir(dirname(path), { recursive: true });
1681
+ if (existsSync(path)) {
1541
1682
  try {
1542
- await fs.unlink(markerPath);
1683
+ await copyFileWithRetry(path, `${path}.bak`, {
1684
+ allowMissingSource: true,
1685
+ });
1543
1686
  }
1544
- catch {
1545
- // Best effort cleanup.
1687
+ catch (backupError) {
1688
+ log.warn("Failed to create flagged backup snapshot", {
1689
+ path,
1690
+ error: String(backupError),
1691
+ });
1546
1692
  }
1547
1693
  }
1548
- catch (error) {
1549
- try {
1550
- await fs.unlink(tempPath);
1551
- }
1552
- catch {
1553
- // Ignore cleanup failures.
1554
- }
1555
- log.error("Failed to save flagged account storage", {
1556
- path,
1557
- error: String(error),
1558
- });
1559
- throw error;
1694
+ const content = JSON.stringify(normalizeFlaggedStorage(storage), null, 2);
1695
+ await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
1696
+ await renameFileWithRetry(tempPath, path);
1697
+ try {
1698
+ await fs.unlink(markerPath);
1699
+ }
1700
+ catch {
1701
+ // Best effort cleanup.
1702
+ }
1703
+ }
1704
+ catch (error) {
1705
+ try {
1706
+ await fs.unlink(tempPath);
1560
1707
  }
1708
+ catch {
1709
+ // Ignore cleanup failures.
1710
+ }
1711
+ log.error("Failed to save flagged account storage", {
1712
+ path,
1713
+ error: String(error),
1714
+ });
1715
+ throw error;
1716
+ }
1717
+ }
1718
+ export async function saveFlaggedAccounts(storage) {
1719
+ return withStorageLock(async () => {
1720
+ await saveFlaggedAccountsUnlocked(storage);
1561
1721
  });
1562
1722
  }
1563
1723
  export async function clearFlaggedAccounts() {
@@ -1635,7 +1795,7 @@ export async function exportAccounts(filePath, force = true, beforeCommit) {
1635
1795
  }
1636
1796
  /**
1637
1797
  * Imports accounts from a JSON file, merging with existing accounts.
1638
- * Deduplicates by accountId/email, preserving most recently used entries.
1798
+ * Deduplicates by safe account identity, preserving most recently used entries.
1639
1799
  * @param filePath - Source file path
1640
1800
  * @throws Error if file is invalid or would exceed MAX_ACCOUNTS
1641
1801
  */
@@ -1662,12 +1822,12 @@ export async function importAccounts(filePath) {
1662
1822
  const existingActiveIndex = existing?.activeIndex ?? 0;
1663
1823
  const merged = [...existingAccounts, ...normalized.accounts];
1664
1824
  if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) {
1665
- const deduped = deduplicateAccountsByEmail(deduplicateAccounts(merged));
1825
+ const deduped = deduplicateAccounts(merged);
1666
1826
  if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) {
1667
1827
  throw new Error(`Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduped.length})`);
1668
1828
  }
1669
1829
  }
1670
- const deduplicatedAccounts = deduplicateAccountsByEmail(deduplicateAccounts(merged));
1830
+ const deduplicatedAccounts = deduplicateAccounts(merged);
1671
1831
  const newStorage = {
1672
1832
  version: 3,
1673
1833
  accounts: deduplicatedAccounts,