Skip to content

fix(auth): collapse fallback duplicates, stabilize dedupe remap, and harden auth tests#38

Merged
ndycode merged 7 commits into
mainfrom
fix/oauth-dedupe-menu
Feb 24, 2026
Merged

fix(auth): collapse fallback duplicates, stabilize dedupe remap, and harden auth tests#38
ndycode merged 7 commits into
mainfrom
fix/oauth-dedupe-menu

Conversation

@ndycode
Copy link
Copy Markdown
Owner

@ndycode ndycode commented Feb 24, 2026

Summary

  • fix OAuth account dedupe so org-scoped and token-fallback variants sharing a refresh token collapse into one canonical entry
  • preserve org identity and account metadata during merges while still taking freshest token/session state
  • keep active account selection stable after dedupe/pruning by remapping via multi-key identity (org, accountId, refresh)
  • improve auth menu account labels so same-email entries are distinguishable (workspace/label/id suffix)
  • harden storage normalization/import remap paths to match runtime dedupe semantics
  • isolate integration tests from real user auth storage and remove temp artifacts after tests
  • replace real-looking test emails with reserved @example.com values

Root Cause

One OAuth login can yield multiple candidate identities (org-scoped + token fallback) for the same person/refresh token. Without consistent dedupe+merge semantics across runtime persistence and storage normalization, duplicate-looking entries survived and active indices could drift after pruning.

What Changed

  • index.ts

    • added/extended refresh-token collision pruning in persistAccountPool
    • added robust active-index remap after prune (multi-key identity matching)
    • preserved org identity fields when merging fallback into org-scoped entries
    • preserved non-identity metadata (enabled, cooldown fields, switch reason, rate-limit maps)
    • updated rateLimitResetTimes merge to per-key max so latest reset windows are retained
  • lib/storage.ts

    • aligned normalization/import remap behavior with multi-key identity matching
    • ensured dedupe/remap handles refresh-token fallback -> org-survivor transitions correctly
  • lib/ui/auth-menu.ts

    • clearer account display identity for multi-account selection
  • test/index.test.ts

    • fixed org-vs-token expectation mismatches
    • added regressions for active-index remap and latest rate-limit window merge behavior
  • test/rotation-integration.test.ts

    • forced suite storage path to isolated temp file (prevents pollution of real ~/.opencode/openai-codex-accounts.json)
    • added teardown cleanup to unlink temp file (ignores ENOENT)
    • replaced non-reserved email literals with synthetic @example.com

Validation

  • npm run typecheck
  • npm test -- test/index.test.ts test/storage.test.ts
  • npm test -- test/rotation-integration.test.ts test/index.test.ts

All passed on this branch.

User-Facing Outcome

  • opencode auth login no longer keeps/creates duplicate fallback entries for the same account after dedupe paths execute
  • local test runs no longer repopulate fake account1@example.com entries into real user storage

note: greptile review for oc-chatgpt-multi-auth. cite files like lib/foo.ts:123. confirm regression tests + windows concurrency/token redaction coverage.

Greptile Summary

fixed oauth account dedupe so org-scoped and token-fallback variants sharing a refresh token collapse into one canonical entry. preserved org identity and account metadata during merges while taking freshest token/session state. kept active account selection stable after dedupe/pruning by remapping via multi-key identity (org, accountId, refresh). improved auth menu labels so same-email entries are distinguishable. hardened storage normalization/import remap paths to match runtime dedupe semantics. isolated integration tests from real user auth storage and removed temp artifacts after tests. replaced real-looking test emails with reserved @example.com values.

key changes:

  • index.ts: added refresh-token collision pruning in persistAccountPool, robust active-index remap after prune using multi-key identity matching, preserved org identity fields when merging fallback into org-scoped entries, preserved non-identity metadata (enabled, cooldown fields, switch reason, rate-limit maps), updated rateLimitResetTimes merge to per-key max so latest reset windows are retained
  • lib/storage.ts: aligned normalization/import remap behavior with multi-key identity matching, ensured dedupe/remap handles refresh-token fallback → org-survivor transitions correctly, added normalization call on every write path
  • lib/ui/auth-menu.ts: clearer account display identity for multi-account selection (workspace/label/id suffix)
  • test/rotation-integration.test.ts: forced suite storage path to isolated temp file (prevents pollution of real ~/.opencode/openai-codex-accounts.json), added teardown cleanup to unlink temp file, replaced non-reserved email literals with synthetic @example.com
  • test/index.test.ts, test/storage.test.ts: added regressions for active-index remap, latest rate-limit window merge, org/token collapse, and cooldown preservation

windows filesystem concurrency: no new file operations added. existing saveAccountsUnlocked in lib/storage.ts already uses atomic write-via-rename pattern (temp file → rename) to defend against windows antivirus locks. normalization now runs on every write (line 704 in lib/storage.ts) which adds deterministic dedupe enforcement but doesn't change concurrency semantics.

token leakage: no new token exposure paths. merge logic in mergeAccountRecords (index.ts:456-534) preserves existing token redaction boundaries - tokens stay in-memory during dedupe and are written through the existing saveAccountsUnlocked path which enforces 0o600 permissions.

regression tests:

  • test/index.test.ts covers org/token collapse (collapses org-scoped primary and no-org token variant...), active index remap after prune (remaps active indices to merged org account...), rate-limit window merge (keeps latest rate-limit reset windows...), cooldown/enabled preservation (keeps restrictive enabled/cooldown metadata...)
  • test/storage.test.ts adds normalizes duplicate org/token variants sharing a refresh token before writing

Confidence Score: 4/5

  • safe to merge with one attention area: the previous race condition fix should be verified in practice
  • comprehensive test coverage for dedupe semantics and active index remap, correct fix for the race condition identified in previous thread (identity keys captured before prune, resolved after), preserves windows filesystem concurrency safety and token permissions, no new token exposure paths. score reflects thorough implementation but the identity-key remap logic is new and complex (multi-key fallback with org/accountId/refresh) so real-world validation would add confidence.
  • pay close attention to index.ts lines 799-841 (identity key capture and remap after prune) - this is the fix for the race condition and uses multi-key fallback logic that should be validated under edge cases (e.g., pruning removes all variants of a previously-active account)

Important Files Changed

Filename Overview
index.ts adds refresh-token-based dedupe with org-scoped merge priority, multi-key active index remap after prune, and preserves restrictive cooldown/enabled state - fixes previous race condition
lib/storage.ts aligns normalization/import remap with multi-key identity matching and adds refresh-token dedupe layer with org-scoped survivor preference - now normalizes on every write path
test/rotation-integration.test.ts isolates test suite from real user storage by forcing temp file path and cleaning up after, replaces real-looking emails with reserved @example.com
test/index.test.ts adds regression tests for org/token collapse, active index remap, rate-limit window merge, and cooldown preservation during dedupe
test/storage.test.ts updates expectations to match refresh-token dedupe semantics and adds test for normalization-on-write behavior

Last reviewed commit: 178ae6e

Context used:

  • Rule from dashboard - What: Every code change must explain how it defends against Windows filesystem concurrency bugs and ... (source)

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 24, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds refresh-token collision detection and account-merge logic into persistence and normalization, expands identity indexes with a global refresh-token map, switches identity resolution to multi-key matching, updates UI account-title formatting, adjusts tests for collapsed/deduplicated behavior, and exposes a test-only storage path setter.

Changes

Cohort / File(s) Summary
Persistence & Collision-Pruning
index.ts, lib/storage.ts
Introduce refresh-token collision detection and pruning (pruneRefreshTokenCollisions, deduplicateAccountsByRefreshToken, pickNewestAccountIndex, mergeAccountRecords), integrated into persistence/normalize before writes; adjust Content-Type headers in quota/Codex requests; add byRefreshTokenGlobal identity index.
Identity & Deduplication Helpers
lib/storage.ts
Replace single-key identity resolution with multi-key flows (toAccountIdentityKeys, extractActiveKeys, findAccountIndexByIdentityKeys); dedupe pipeline updated to run refresh-token collapsing after email/key dedupe; active/family indices mapped from identity-key arrays.
Types & Public API Shapes
lib/storage.ts, lib/storage.js
AccountLike gains optional accountIdSource?: AccountMetadataV3["accountIdSource"] and accountLabel?: string. Export setStoragePathDirect added to allow tests to override storage path.
UI Title Formatting
lib/ui/auth-menu.ts
Add formatAccountIdSuffix helper and update accountTitle() to compose titles from email, workspace label, and truncated account ID suffix (first 8 + last 6 chars) with fallbacks; deletion-confirmation uses composed title.
Tests: Auth Menu
test/auth-menu.test.ts
Add unit tests verifying composed menu labels (email, workspace, shortened id) and that delete confirmations use detailed composed title.
Tests: Persistence & Storage
test/index.test.ts, test/storage.test.ts, test/rotation-integration.test.ts
Update and add tests asserting collapse/deduplication of org-scoped and no-org variants sharing a refresh token, remapping of active indices/families, normalization-before-write behavior (including EPERM/EBUSY retry scenarios), and redirect storage writes in integration tests via setStoragePathDirect.

Sequence Diagram

sequenceDiagram
    participant Client as Account Manager
    participant Auth as Authenticator
    participant Resolver as Identity Resolver
    participant Dedupe as Deduplication Engine
    participant Storage as Storage Layer

    Client->>Auth: submit accounts to persist
    Auth->>Resolver: build identity indexes (org-scoped, byRefreshTokenGlobal)
    Resolver->>Dedupe: provide candidate account indices & identity keys
    Dedupe->>Dedupe: detect refresh-token collisions
    Dedupe->>Dedupe: pickNewestAccountIndex (lastUsed/addedAt) & mergeAccountRecords
    Dedupe->>Storage: return deduplicated/merged account set
    Storage->>Storage: normalizeAccountStorage (final pruning + index cleanup)
    Storage->>Client: persist normalized accounts
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I nibbled tokens, sorted by date,

Merged twin entries, chose the latest mate,
Short IDs peeking at head and tail,
Pruned the doubles down the trail,
A tiny hop — storage neat and great.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: collapsing fallback duplicates (refresh token deduplication), stabilizing dedupe remapping (active-index adjustments), and hardening auth tests (expanded test coverage).
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/oauth-dedupe-menu

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
lib/storage.ts (1)

93-100: ⚠️ Potential issue | 🟠 Major

Preserve org-scoped metadata when merging refresh-token collisions.

When a fallback entry is newer, mergeAccountRecords can overwrite org-scoped accountLabel / accountIdSource, which are used for identity and UI. That can regress the “org-preferred” intent. Preserve these fields from the target (org) record, similar to the index.ts merge logic.

🛠️ Suggested fix
 type AccountLike = {
   organizationId?: string;
   accountId?: string;
+  accountIdSource?: AccountMetadataV3["accountIdSource"];
+  accountLabel?: string;
   email?: string;
   refreshToken: string;
   addedAt?: number;
   lastUsed?: number;
 };
 
 function mergeAccountRecords<T extends AccountLike>(target: T, source: T): T {
   const newest = selectNewestAccount(target, source);
   const older = newest === target ? source : target;
   return {
     ...older,
     ...newest,
     organizationId: target.organizationId ?? source.organizationId,
     accountId: target.accountId ?? source.accountId,
+    accountIdSource: target.accountIdSource ?? source.accountIdSource,
+    accountLabel: target.accountLabel ?? source.accountLabel,
     email: target.email ?? source.email,
   };
 }

Also applies to: 292-302

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/storage.ts` around lines 93 - 100, mergeAccountRecords currently allows a
newer fallback entry to overwrite org-scoped metadata (like accountLabel and
accountIdSource) from the target org record; update mergeAccountRecords to
preserve these org-scoped fields from the existing/target record when they are
present (i.e., only set accountLabel and accountIdSource from the
incoming/source record if the target’s value is absent), mirroring the merge
behavior used in index.ts; apply the same preservation logic to the related
merge code block around the other occurrence (the block referenced at 292-302)
and reference the AccountLike type and field names (accountLabel,
accountIdSource, accountId) when making the change.
index.ts (1)

654-742: ⚠️ Potential issue | 🟠 Major

Preserve the active selection after collision pruning.

Pruning can delete the currently active fallback entry, and the current clamping logic can then point at an unrelated account. Consider remapping by identity key (org → accountId → refreshToken) before clamping so the active account stays stable.

🛠️ Suggested fix (map active index by identity before clamping)
-					const activeIndex = replaceAll
-						? 0
-						: typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex)
-							? stored.activeIndex
-							: 0;
+					const resolveIdentityKey = (
+						account: { organizationId?: string; accountId?: string; refreshToken?: string } | undefined,
+					): string | undefined => {
+						const org = account?.organizationId?.trim();
+						if (org) return `org:${org}`;
+						const id = account?.accountId?.trim();
+						if (id) return `account:${id}`;
+						const refresh = account?.refreshToken?.trim();
+						return refresh ? `refresh:${refresh}` : undefined;
+					};
+
+					const storedActive =
+						!replaceAll && stored?.accounts?.length
+							? stored.accounts[
+									typeof stored.activeIndex === "number" && Number.isFinite(stored.activeIndex)
+										? stored.activeIndex
+										: 0
+							  ]
+							: undefined;
+					const activeKey = resolveIdentityKey(storedActive);
+					const mappedActiveIndex =
+						activeKey
+							? accounts.findIndex((account) => resolveIdentityKey(account) === activeKey)
+							: -1;
+
+					const activeIndex = replaceAll
+						? 0
+						: mappedActiveIndex >= 0
+							? mappedActiveIndex
+							: typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex)
+								? stored.activeIndex
+								: 0;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@index.ts` around lines 654 - 742, Prune can remove entries before computing
clampedActiveIndex causing stored.activeIndex (and stored.activeIndexByFamily)
to point to a different account; before calling pruneRefreshTokenCollisions
capture a stable identity map (e.g. map stored.activeIndex and each
stored.activeIndexByFamily[family] to an identity key built from
account.organizationId, account.accountId, account.refreshToken), run
pruneRefreshTokenCollisions which mutates accounts, then remap those saved
identity keys back to new indices in the updated accounts array (falling back to
0/clamped bounds) so clampedActiveIndex and activeIndexByFamily are computed
from the remapped indices instead of the pre-prune numeric indices; reference
pruneRefreshTokenCollisions, accounts, stored.activeIndex,
stored.activeIndexByFamily, MODEL_FAMILIES, clampedActiveIndex, and
activeIndexByFamily when implementing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/storage.test.ts`:
- Around line 1471-1504: In the test "normalizes duplicate org/token variants
sharing refresh token before writing" replace the real-looking email
"realuser@gmail.com" used in the storage.accounts entries with a non-real
placeholder (e.g., "user@example.com" or "user@company.test") so both account
objects in the test data passed to saveAccounts() and later asserted from
loadAccounts() use the placeholder; update all occurrences in the storage
variable to avoid real email addresses.

---

Outside diff comments:
In `@index.ts`:
- Around line 654-742: Prune can remove entries before computing
clampedActiveIndex causing stored.activeIndex (and stored.activeIndexByFamily)
to point to a different account; before calling pruneRefreshTokenCollisions
capture a stable identity map (e.g. map stored.activeIndex and each
stored.activeIndexByFamily[family] to an identity key built from
account.organizationId, account.accountId, account.refreshToken), run
pruneRefreshTokenCollisions which mutates accounts, then remap those saved
identity keys back to new indices in the updated accounts array (falling back to
0/clamped bounds) so clampedActiveIndex and activeIndexByFamily are computed
from the remapped indices instead of the pre-prune numeric indices; reference
pruneRefreshTokenCollisions, accounts, stored.activeIndex,
stored.activeIndexByFamily, MODEL_FAMILIES, clampedActiveIndex, and
activeIndexByFamily when implementing.

In `@lib/storage.ts`:
- Around line 93-100: mergeAccountRecords currently allows a newer fallback
entry to overwrite org-scoped metadata (like accountLabel and accountIdSource)
from the target org record; update mergeAccountRecords to preserve these
org-scoped fields from the existing/target record when they are present (i.e.,
only set accountLabel and accountIdSource from the incoming/source record if the
target’s value is absent), mirroring the merge behavior used in index.ts; apply
the same preservation logic to the related merge code block around the other
occurrence (the block referenced at 292-302) and reference the AccountLike type
and field names (accountLabel, accountIdSource, accountId) when making the
change.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting

📥 Commits

Reviewing files that changed from the base of the PR and between 75d3517 and e6d3b1c.

📒 Files selected for processing (6)
  • index.ts
  • lib/storage.ts
  • lib/ui/auth-menu.ts
  • test/auth-menu.test.ts
  • test/index.test.ts
  • test/storage.test.ts

Repository owner deleted a comment from coderabbitai Bot Feb 24, 2026
Copy link
Copy Markdown

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment thread index.ts Outdated
Comment on lines +736 to +758
const getAccountAtStoredIndex = (rawIndex: unknown) => {
if (typeof rawIndex !== "number" || !Number.isFinite(rawIndex)) return undefined;
const candidate = Math.floor(rawIndex);
if (candidate < 0 || candidate >= accounts.length) return undefined;
return accounts[candidate];
};

const storedActiveKey = replaceAll
? undefined
: resolveIdentityKey(getAccountAtStoredIndex(stored?.activeIndex));
const storedActiveKeyByFamily: Partial<Record<ModelFamily, string>> = {};
if (!replaceAll) {
for (const family of MODEL_FAMILIES) {
const familyKey = resolveIdentityKey(
getAccountAtStoredIndex(stored?.activeIndexByFamily?.[family]),
);
if (familyKey) {
storedActiveKeyByFamily[family] = familyKey;
}
}
}

pruneRefreshTokenCollisions();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

identity key capture happens before pruning, which creates a race: if stored.activeIndex points to an account that gets removed by pruneRefreshTokenCollisions(), the captured storedActiveKey will be the identity of the removed account. when resolveIndexByIdentityKey searches the pruned array, it won't find that key and falls back to the numeric index—which now points to a different account.

move getAccountAtStoredIndex and the identity key capture logic to after pruneRefreshTokenCollisions() so it reads from the final deduplicated array.

Prompt To Fix With AI
This is a comment left during a code review.
Path: index.ts
Line: 736-758

Comment:
identity key capture happens before pruning, which creates a race: if `stored.activeIndex` points to an account that gets removed by `pruneRefreshTokenCollisions()`, the captured `storedActiveKey` will be the identity of the removed account. when `resolveIndexByIdentityKey` searches the pruned array, it won't find that key and falls back to the numeric index—which now points to a different account.

move `getAccountAtStoredIndex` and the identity key capture logic to after `pruneRefreshTokenCollisions()` so it reads from the final deduplicated array.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/storage.ts (1)

308-412: ⚠️ Potential issue | 🟠 Major

Active index remap can drift after refresh-token dedupe removes fallback entries.
When a no‑org fallback is removed in favor of an org‑scoped account sharing the same refresh token, normalizeAccountStorage remaps active indices by identity key (org/accountId/refreshToken). A refresh‑token key will no longer match the org-scoped survivor, so the fallback clamp can point at the wrong account if indices shift.
Suggested fix: when an active key is refresh‑token–based and no exact identity match is found, fall back to matching by refreshToken across remaining accounts (including org‑scoped). Apply the same fallback for activeIndexByFamily.

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@index.ts`:
- Around line 725-798: The active-index remapping can miss a surviving account
when pruneRefreshTokenCollisions replaces a refresh-token identity with an
org/account identity; to fix, capture refresh-token values before calling
pruneRefreshTokenCollisions (e.g., save storedRefreshKey = storedActiveKey and
storedRefreshKeyByFamily = storedActiveKeyByFamily), then after pruning, if
resolveIndexByIdentityKey(storedActiveKey) returns undefined and
storedRefreshKey starts with "refresh:", attempt a fallback lookup by comparing
trimmed account.refreshToken values to the saved refresh token (apply same for
each family using storedRefreshKeyByFamily) and use that index as
remappedActiveIndex/family remapped index so the active account follows the
surviving identity.
- Around line 456-481: mergeAccountRecords currently only preserves identity
fields and token/timestamp values from the "newer" record and may drop
non-identity metadata present only on the source (e.g., enabled,
rateLimitResetTimes, coolingDownUntil, cooldownReason, lastSwitchReason). Update
mergeAccountRecords to explicitly preserve non-identity metadata by assigning
each metadata field with target[field] ?? source[field] (or merging structures
where appropriate) instead of letting them be lost; keep the existing
newer/older selection for refreshToken/accessToken/expiresAt and use Math.max
for addedAt/lastUsed as now, but add explicit merges for enabled,
rateLimitResetTimes, coolingDownUntil, cooldownReason, lastSwitchReason (and any
similar metadata on accounts) so source metadata is only applied when target
lacks it.

In `@test/index.test.ts`:
- Line 1602: Test uses autoMethod.authorize({ loginMode: "add", accountCount:
"0" }) which differs from other tests that use "1"; confirm intent and if
unintentional, change accountCount to "1" to exercise the same first-account-add
branch in the OpenAIOAuthPlugin persistAccountPool flow (or, if "0" is
intentional, add an explicit comment and/or an assertion that verifies the
alternate branch behavior). Locate the call to autoMethod.authorize in the test
and either replace "0" with "1" or document and assert the special-case branch
to keep the test representative.
- Around line 1558-1561: The test's assertion is wrong: organizationEntries is
filtered for account.organizationId === "organization-shared" but expects the
fallback "token-personal" (no org); update the assertion to expect the
org-scoped account chosen by the collapse/select logic (the value produced by
selectBestAccountCandidate, e.g., the org-variant accountId) instead of
"token-personal", and keep the existing check that mockStorage.accounts has
length 1 to reflect that the no-org token was pruned in favor of the org-scoped
identity; locate the failing assertions by looking for organizationEntries and
selectBestAccountCandidate in the test and replace the accountId expectation
accordingly.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting

📥 Commits

Reviewing files that changed from the base of the PR and between e6d3b1c and 311efbb.

📒 Files selected for processing (4)
  • index.ts
  • lib/storage.ts
  • test/index.test.ts
  • test/storage.test.ts

Comment thread index.ts
Comment thread index.ts Outdated
Comment thread test/index.test.ts
Comment thread test/index.test.ts Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@index.ts`:
- Around line 456-486: The mergeAccountRecords function currently merges
rateLimitResetTimes by spreading source then target so the target always wins
even when source/newer should win; update the rateLimitResetTimes merge to
prefer the newer record (or take per-key max): compute newer and older as you
already do (newer/older variables) and replace the current spread block with
...(older.rateLimitResetTimes ?? {}), ...(newer.rateLimitResetTimes ?? {}) or,
for per-key max, iterate Object.keys(newer.rateLimitResetTimes ||
older.rateLimitResetTimes || {}) and set each key to Math.max(older[key] ?? 0,
newer[key] ?? 0) so the most recent reset times are preserved when merging
accounts.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting

📥 Commits

Reviewing files that changed from the base of the PR and between 311efbb and ab33aff.

📒 Files selected for processing (3)
  • index.ts
  • lib/storage.ts
  • test/index.test.ts

Comment thread index.ts Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/rotation-integration.test.ts (1)

26-32: ⚠️ Potential issue | 🟠 Major

Replace potentially-real email addresses with synthetic @example.com values.

jorrizarellano123456@gmail.com and keiyoon25@gmail.com are specific, non-reserved addresses that do not follow the RFC 2606 convention (@example.com) used by the rest of this file's test data. If either address belongs to a real user, committing it to a public repository constitutes a GDPR/CCPA compliance risk. They appear structurally consistent with addresses that might have been copied from a live storage file when writing the deduplication regression test.

🛡️ Proposed fix — replace with synthetic addresses
 const DUPLICATE_EMAIL_ACCOUNTS = [
-  { email: "jorrizarellano123456@gmail.com", refresh_token: "token_old",   lastUsed: 1000 },
-  { email: "jorrizarellano123456@gmail.com", refresh_token: "token_new",   lastUsed: 2000 },
-  { email: "keiyoon25@gmail.com",            refresh_token: "token_old_2", lastUsed: 1500 },
-  { email: "keiyoon25@gmail.com",            refresh_token: "token_new_2", lastUsed: 2500 },
-  { email: "unique@gmail.com",               refresh_token: "token_unique", lastUsed: 1800 },
+  { email: "duplicate.a@example.com", refresh_token: "token_old",   lastUsed: 1000 },
+  { email: "duplicate.a@example.com", refresh_token: "token_new",   lastUsed: 2000 },
+  { email: "duplicate.b@example.com", refresh_token: "token_old_2", lastUsed: 1500 },
+  { email: "duplicate.b@example.com", refresh_token: "token_new_2", lastUsed: 2500 },
+  { email: "unique@example.com",      refresh_token: "token_unique", lastUsed: 1800 },
 ];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/rotation-integration.test.ts` around lines 26 - 32, The test data
constant DUPLICATE_EMAIL_ACCOUNTS contains real-looking emails; replace the two
specific addresses ("jorrizarellano123456@gmail.com" and "keiyoon25@gmail.com")
with synthetic, reserved example addresses (e.g.,
"jorrizarellano123456@example.com" and "keiyoon25@example.com") inside the
DUPLICATE_EMAIL_ACCOUNTS array in test/rotation-integration.test.ts, leaving
refresh_token and lastUsed values unchanged so the deduplication test behavior
remains the same.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/rotation-integration.test.ts`:
- Around line 66-68: The afterAll hook currently calls
setStoragePathDirect(null) but doesn't remove the temp JSON file written by
saveToDisk/saveToDiskDebounced; update the afterAll block to also delete/unlink
the TEST_STORAGE_PATH file (use fs.unlink or fs.promises.unlink) if it exists,
catching and ignoring ENOENT errors so test teardown never fails, and keep the
call to setStoragePathDirect(null) after the deletion; reference the
TEST_STORAGE_PATH constant and the afterAll hook that currently invokes
setStoragePathDirect.

---

Outside diff comments:
In `@test/rotation-integration.test.ts`:
- Around line 26-32: The test data constant DUPLICATE_EMAIL_ACCOUNTS contains
real-looking emails; replace the two specific addresses
("jorrizarellano123456@gmail.com" and "keiyoon25@gmail.com") with synthetic,
reserved example addresses (e.g., "jorrizarellano123456@example.com" and
"keiyoon25@example.com") inside the DUPLICATE_EMAIL_ACCOUNTS array in
test/rotation-integration.test.ts, leaving refresh_token and lastUsed values
unchanged so the deduplication test behavior remains the same.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting

📥 Commits

Reviewing files that changed from the base of the PR and between ab33aff and 86d474c.

📒 Files selected for processing (1)
  • test/rotation-integration.test.ts

Comment thread test/rotation-integration.test.ts Outdated
@ndycode ndycode changed the title fix(auth): collapse token fallback duplicates and clarify auth menu identities fix(auth): collapse fallback duplicates, stabilize dedupe remap, and harden auth tests Feb 24, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@index.ts`:
- Around line 436-503: mergeAccountRecords currently favors
target.enabled/target.coolingDownUntil which can re-enable or clear a cooling
source account; change the merge to use "most restrictive wins": compute enabled
as false if either target.enabled === false or source.enabled === false,
otherwise fall back to target.enabled ?? source.enabled; compute
coolingDownUntil as Math.max(target.coolingDownUntil ?? 0,
source.coolingDownUntil ?? 0) and if that max > 0 pick cooldownReason from
whichever record (target or source) had that timestamp (or prefer source if
equal); update the assignment in accounts[targetIndex] to use these computed
values instead of target.coolingDownUntil ?? source.coolingDownUntil and
target.cooldownReason ?? source.cooldownReason so disabled/cooling state is
preserved after dedupe.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting

📥 Commits

Reviewing files that changed from the base of the PR and between 86d474c and 0c16d80.

📒 Files selected for processing (3)
  • index.ts
  • test/index.test.ts
  • test/rotation-integration.test.ts

Comment thread index.ts
Comment on lines +436 to +503
const asUniqueIndex = (indices: number[] | undefined): number | undefined => {
if (!indices || indices.length !== 1) return undefined;
const [onlyIndex] = indices;
return typeof onlyIndex === "number" ? onlyIndex : undefined;
};

const pickNewestAccountIndex = (existingIndex: number, candidateIndex: number): number => {
const existing = accounts[existingIndex];
const candidate = accounts[candidateIndex];
if (!existing) return candidateIndex;
if (!candidate) return existingIndex;
const existingLastUsed = existing.lastUsed ?? 0;
const candidateLastUsed = candidate.lastUsed ?? 0;
if (candidateLastUsed > existingLastUsed) return candidateIndex;
if (candidateLastUsed < existingLastUsed) return existingIndex;
const existingAddedAt = existing.addedAt ?? 0;
const candidateAddedAt = candidate.addedAt ?? 0;
return candidateAddedAt >= existingAddedAt ? candidateIndex : existingIndex;
};

const mergeAccountRecords = (targetIndex: number, sourceIndex: number): void => {
const target = accounts[targetIndex];
const source = accounts[sourceIndex];
if (!target || !source) return;
const targetLastUsed = target.lastUsed ?? 0;
const sourceLastUsed = source.lastUsed ?? 0;
const targetAddedAt = target.addedAt ?? 0;
const sourceAddedAt = source.addedAt ?? 0;
const sourceIsNewer =
sourceLastUsed > targetLastUsed ||
(sourceLastUsed === targetLastUsed && sourceAddedAt > targetAddedAt);
const newer = sourceIsNewer ? source : target;
const older = sourceIsNewer ? target : source;
const mergedRateLimitResetTimes: Record<string, number> = {};
const rateLimitResetKeys = new Set([
...Object.keys(older.rateLimitResetTimes ?? {}),
...Object.keys(newer.rateLimitResetTimes ?? {}),
]);
for (const key of rateLimitResetKeys) {
const olderRaw = older.rateLimitResetTimes?.[key];
const newerRaw = newer.rateLimitResetTimes?.[key];
const olderValue =
typeof olderRaw === "number" && Number.isFinite(olderRaw) ? olderRaw : 0;
const newerValue =
typeof newerRaw === "number" && Number.isFinite(newerRaw) ? newerRaw : 0;
const resolved = Math.max(olderValue, newerValue);
if (resolved > 0) {
mergedRateLimitResetTimes[key] = resolved;
}
}
accounts[targetIndex] = {
...target,
accountId: target.accountId ?? source.accountId,
organizationId: target.organizationId ?? source.organizationId,
accountIdSource: target.accountIdSource ?? source.accountIdSource,
accountLabel: target.accountLabel ?? source.accountLabel,
email: target.email ?? source.email,
refreshToken: newer.refreshToken || older.refreshToken,
accessToken: newer.accessToken || older.accessToken,
expiresAt: newer.expiresAt ?? older.expiresAt,
enabled: target.enabled ?? source.enabled,
addedAt: Math.max(target.addedAt ?? 0, source.addedAt ?? 0),
lastUsed: Math.max(target.lastUsed ?? 0, source.lastUsed ?? 0),
lastSwitchReason: target.lastSwitchReason ?? source.lastSwitchReason,
rateLimitResetTimes: mergedRateLimitResetTimes,
coolingDownUntil: target.coolingDownUntil ?? source.coolingDownUntil,
cooldownReason: target.cooldownReason ?? source.cooldownReason,
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve disabled/cooldown state when merging duplicates.

mergeAccountRecords keeps target.enabled/target.coolingDownUntil whenever defined, so a disabled or cooling source record can be re‑enabled/cleared if the newer target lacks those flags. That defeats user intent and can prematurely use a cooling account after dedupe.

💡 Suggested merge semantics (most restrictive wins)
 			const mergeAccountRecords = (targetIndex: number, sourceIndex: number): void => {
 				const target = accounts[targetIndex];
 				const source = accounts[sourceIndex];
 				if (!target || !source) return;
@@
 				for (const key of rateLimitResetKeys) {
@@
 				}
+				const mergedEnabled =
+					target.enabled === false || source.enabled === false
+						? false
+						: target.enabled ?? source.enabled;
+				const mergedCoolingDownUntil = Math.max(
+					target.coolingDownUntil ?? 0,
+					source.coolingDownUntil ?? 0,
+				) || undefined;
+				const mergedCooldownReason =
+					mergedCoolingDownUntil === (target.coolingDownUntil ?? 0)
+						? target.cooldownReason ?? source.cooldownReason
+						: source.cooldownReason ?? target.cooldownReason;
 				accounts[targetIndex] = {
 					...target,
@@
-					enabled: target.enabled ?? source.enabled,
+					enabled: mergedEnabled,
 					addedAt: Math.max(target.addedAt ?? 0, source.addedAt ?? 0),
 					lastUsed: Math.max(target.lastUsed ?? 0, source.lastUsed ?? 0),
 					lastSwitchReason: target.lastSwitchReason ?? source.lastSwitchReason,
 					rateLimitResetTimes: mergedRateLimitResetTimes,
-					coolingDownUntil: target.coolingDownUntil ?? source.coolingDownUntil,
-					cooldownReason: target.cooldownReason ?? source.cooldownReason,
+					coolingDownUntil: mergedCoolingDownUntil,
+					cooldownReason: mergedCooldownReason,
 				};
 			};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@index.ts` around lines 436 - 503, mergeAccountRecords currently favors
target.enabled/target.coolingDownUntil which can re-enable or clear a cooling
source account; change the merge to use "most restrictive wins": compute enabled
as false if either target.enabled === false or source.enabled === false,
otherwise fall back to target.enabled ?? source.enabled; compute
coolingDownUntil as Math.max(target.coolingDownUntil ?? 0,
source.coolingDownUntil ?? 0) and if that max > 0 pick cooldownReason from
whichever record (target or source) had that timestamp (or prefer source if
equal); update the assignment in accounts[targetIndex] to use these computed
values instead of target.coolingDownUntil ?? source.coolingDownUntil and
target.cooldownReason ?? source.cooldownReason so disabled/cooling state is
preserved after dedupe.

@ndycode ndycode merged commit 358a1ac into main Feb 24, 2026
2 checks passed
@ndycode ndycode deleted the fix/oauth-dedupe-menu branch February 24, 2026 22:43
ndycode added a commit that referenced this pull request Apr 6, 2026
…harden auth tests (#38)

* fix(auth): collapse token fallback duplicates and clarify account labels

* fix(auth): stabilize org-preferred refresh dedupe and active index remap

* fix(auth): keep active/account metadata stable through refresh dedupe

* test(storage): isolate rotation integration writes from user auth store

* fix(auth,test): preserve latest reset windows and harden test fixtures

* fix(auth): preserve restrictive cooldown/enable state during dedupe

* chore(release): v5.3.3

---------
ndycode added a commit that referenced this pull request Apr 6, 2026
…harden auth tests (#38)

* fix(auth): collapse token fallback duplicates and clarify account labels

* fix(auth): stabilize org-preferred refresh dedupe and active index remap

* fix(auth): keep active/account metadata stable through refresh dedupe

* test(storage): isolate rotation integration writes from user auth store

* fix(auth,test): preserve latest reset windows and harden test fixtures

* fix(auth): preserve restrictive cooldown/enable state during dedupe

* chore(release): v5.3.3

---------
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants