fix(auth): purged pre-org oauth tombstone on org-scoped re-login
#purgeSupersededDisabledRows matched disabled rows against active ones by exact identity-key string equality, but the active-replacement path it mirrors (matchesReplacementCredential) claims pre-org legacy rows (`<b>` vs `<b>|org:<o>`). So a later org-scoped login of the same account never purged the pre-org tombstone, which then rendered forever as a red row in `omp usage` with no CLI/TUI escape. This is the OAuth half of the class of bug #2943 fixed for api_key rows in the same function. Reuse matchesReplacementCredential in the purge so an org-scoped login claims and hard-deletes its pre-org tombstone, inheriting the one-way upgrade and shared-workspace guards unchanged. Fixes #7876
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a pre-org OAuth tombstone (`email:<e>`) surviving a later org-scoped login of the same account (`email:<e>|org:<ws>`) for `openai-codex` and `anthropic`, leaving a permanent red row in `omp usage` that re-login could not clear. `#purgeSupersededDisabledRows` now reuses the `matchesReplacementCredential` semantics of the active-replacement path instead of exact identity-key equality, so an org-scoped login claims and hard-deletes its pre-org legacy tombstone ([#7876](https://github.com/can1357/oh-my-pi/issues/7876)).
|
||||
|
||||
## [17.2.10] - 2026-08-06
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -1374,11 +1374,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
||||
try {
|
||||
let hasActiveApiKey = false;
|
||||
const activeIdentityKeys = new Set<string>();
|
||||
const activeOAuthCredentials: AuthCredential[] = [];
|
||||
for (const row of activeRows) {
|
||||
if (row.credential.type === "api_key") {
|
||||
hasActiveApiKey = true;
|
||||
continue;
|
||||
}
|
||||
activeOAuthCredentials.push(row.credential);
|
||||
const identityKey = resolveCredentialIdentityKey(provider, row.credential);
|
||||
if (identityKey) activeIdentityKeys.add(identityKey);
|
||||
}
|
||||
@@ -1393,7 +1395,22 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
||||
const identityKey = resolveRowCredentialIdentityKey(provider, row);
|
||||
if (identityKey && activeIdentityKeys.has(identityKey)) {
|
||||
this.#hardDeleteStmt.run(row.id);
|
||||
continue;
|
||||
}
|
||||
// Exact key equality misses a tombstone whose key predates a format
|
||||
// the active row now uses (pre-org `<b>` vs `<b>|org:<o>`). An active
|
||||
// credential that WOULD have replaced this row had it still been
|
||||
// active supersedes its tombstone too, so mirror the replacement
|
||||
// matcher rather than restating a weaker rule. The one-way upgrade
|
||||
// and shared-workspace guards in matchesReplacementCredential carry
|
||||
// over, so this never over-deletes another member's or subscription's
|
||||
// row.
|
||||
const disabledCredential = deserializeCredential(row);
|
||||
if (disabledCredential === null) continue;
|
||||
const superseded = activeOAuthCredentials.some(active =>
|
||||
matchesReplacementCredential(provider, disabledCredential, identityKey, active),
|
||||
);
|
||||
if (superseded) this.#hardDeleteStmt.run(row.id);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort cleanup; don't let it break the main operation
|
||||
|
||||
@@ -151,6 +151,50 @@ describe("openai-codex workspace-scoped credential identity", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("purges a disabled legacy email-keyed row on the first workspace-scoped login with the same email", async () => {
|
||||
if (!store) throw new Error("test setup failed");
|
||||
|
||||
// Pre-org login → bare email key, then upstream invalidates the refresh
|
||||
// token and the row is auto-disabled (a tombstone).
|
||||
store.upsertAuthCredentialForProvider(
|
||||
"openai-codex",
|
||||
codexCredential({ suffix: "legacy", accountId: PERSONAL_WS }),
|
||||
);
|
||||
const legacyId = store.listAuthCredentials("openai-codex")[0].id;
|
||||
store.deleteAuthCredential(legacyId, "oauth refresh failed: OAuthError: 401 refresh_token_invalidated");
|
||||
|
||||
// Same human logs in again, now workspace-scoped: the org-scoped login
|
||||
// claims and hard-deletes the pre-org tombstone instead of stranding it.
|
||||
store.upsertAuthCredentialForProvider(
|
||||
"openai-codex",
|
||||
codexCredential({ suffix: "team", accountId: TEAM_WS, orgId: TEAM_WS, orgName: "team" }),
|
||||
);
|
||||
expect(readIdentityRows(dbPath)).toEqual([
|
||||
{ identity_key: `email:${EMAIL}|org:${TEAM_WS}`, disabled_cause: null },
|
||||
]);
|
||||
expect(await store.listDisabledCredentials("openai-codex")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps a disabled row of a different member of the same workspace after a workspace-scoped login", async () => {
|
||||
if (!store) throw new Error("test setup failed");
|
||||
|
||||
// Alice's org-scoped row is disabled (tombstone). Bob, a different member
|
||||
// of the SAME workspace, logs in. The shared-workspace guard must keep
|
||||
// Alice's tombstone — it is not Bob's subscription.
|
||||
store.upsertAuthCredentialForProvider(
|
||||
"openai-codex",
|
||||
codexCredential({ suffix: "alice", accountId: TEAM_WS, orgId: TEAM_WS, email: "alice@example.com" }),
|
||||
);
|
||||
const aliceId = store.listAuthCredentials("openai-codex")[0].id;
|
||||
store.deleteAuthCredential(aliceId, "oauth refresh failed: OAuthError: 401 refresh_token_invalidated");
|
||||
|
||||
store.upsertAuthCredentialForProvider(
|
||||
"openai-codex",
|
||||
codexCredential({ suffix: "bob", accountId: TEAM_WS, orgId: TEAM_WS, email: "bob@example.com" }),
|
||||
);
|
||||
expect(await store.listDisabledCredentials("openai-codex")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("never clobbers workspace-scoped rows with a workspace-less credential", () => {
|
||||
if (!store) throw new Error("test setup failed");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user