diff --git a/src/app/api/admin/central-codex/device-poll/route.ts b/src/app/api/admin/central-codex/device-poll/route.ts index 86aab4eed..62d9df175 100644 --- a/src/app/api/admin/central-codex/device-poll/route.ts +++ b/src/app/api/admin/central-codex/device-poll/route.ts @@ -9,6 +9,7 @@ import { auditLog } from "@/lib/auth/audit"; import { getClientIp, apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { forgetProviderHealth } from "@/lib/ai/provider-health-ledger"; +import { clearStatusNegativeWindows } from "@/lib/insights/status-negative-window"; /** * Operator-shared central Codex — device-code poll (admin only). @@ -94,7 +95,12 @@ export const POST = apiHandler(async (request: NextRequest) => { cookieStore.delete("central_codex_device"); // A new central sign-in: every account's ledger rows describe the refused // one, and the central connection is one credential shared by all of them. - await forgetProviderHealth({ providerType: "admin-codex" }); + // #1279 — and every record's note windows close, as on the admin AI + // settings save, so no note sits out a window the broken one earned. + await Promise.all([ + forgetProviderHealth({ providerType: "admin-codex" }), + clearStatusNegativeWindows(), + ]); await auditLog("admin.central-codex.connected", { userId: user.id, diff --git a/src/app/api/auth/codex/device-poll/route.ts b/src/app/api/auth/codex/device-poll/route.ts index 47f9987b0..e4fb52758 100644 --- a/src/app/api/auth/codex/device-poll/route.ts +++ b/src/app/api/auth/codex/device-poll/route.ts @@ -10,6 +10,7 @@ import { getClientIp, apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { INSIGHTS_CACHE_CLEARED } from "@/lib/insights/cached-text-cipher"; import { forgetProviderHealth } from "@/lib/ai/provider-health-ledger"; +import { clearStatusNegativeWindows } from "@/lib/insights/status-negative-window"; /** * v1.4.7.1: Device-code flow polling endpoint. @@ -90,8 +91,12 @@ export const POST = apiHandler(async (request: NextRequest) => { cookieStore.delete("codex_device"); // A new sign-in: whatever the ledger held against the old one no longer - // describes this credential. - await forgetProviderHealth({ userId: user.id, providerType: "codex" }); + // describes this credential, and (#1279) the person's notes stop waiting + // out windows it earned, as on a save of their provider settings. + await Promise.all([ + forgetProviderHealth({ userId: user.id, providerType: "codex" }), + clearStatusNegativeWindows(user.id), + ]); await auditLog("codex.oauth.connected", { userId: user.id, diff --git a/tests/integration/codex-credential-park.test.ts b/tests/integration/codex-credential-park.test.ts index e4d283eb4..1eb6c4c8c 100644 --- a/tests/integration/codex-credential-park.test.ts +++ b/tests/integration/codex-credential-park.test.ts @@ -262,6 +262,113 @@ describe("a refused Codex sign-in", () => { }); }); +/** + * #1279 — a fresh sign-in also closes the status notes' negative windows, + * as a save of the person's provider settings and of the operator's admin AI + * settings do. A window grows with the failures in a row (up to two hours), + * so without it a fixed connection still had every note wait out the window + * the refused one earned. + */ +describe("a fresh Codex sign-in and the notes' windows", () => { + async function openWindow(userId: string) { + await prisma.insightStatusCache.create({ + data: { + userId, + metric: "weight", + locale: "en", + dateKey: "2026-06-15", + retryAt: new Date(Date.now() + HOUR_MS), + negativeReason: "error", + failureStreak: 4, + }, + }); + } + + async function windowOf(userId: string) { + return prisma.insightStatusCache.findFirstOrThrow({ + where: { userId }, + select: { retryAt: true, failureStreak: true }, + }); + } + + function signedIn() { + vi.mocked(pollDeviceCode).mockResolvedValue({ + status: "connected", + creds: creds("new", 10 * HOUR_MS), + } as Awaited>); + } + + it("closes the person's windows on their own sign-in", async () => { + const user = await seedCodexUser(); + await openWindow(user.id); + signedIn(); + cookieJar.set( + "codex_device", + encrypt(JSON.stringify({ deviceAuthId: "device", userCode: "CODE" })), + ); + const { POST } = await import("@/app/api/auth/codex/device-poll/route"); + const res = await POST( + new NextRequest("http://localhost/api/auth/codex/device-poll", { + method: "POST", + }), + ); + expect(res.status).toBe(200); + expect(await windowOf(user.id)).toEqual({ + retryAt: null, + failureStreak: 0, + }); + }); + + it("closes every account's windows on the operator's central sign-in", async () => { + const admin = await prisma.user.create({ + data: { + username: "central-admin", + email: "central-admin@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + const member = await prisma.user.create({ + data: { + username: "central-member", + email: "central-member@example.test", + useCentralCodex: true, + }, + }); + await openWindow(member.id); + await postgresProviderHealthLedger.recordFailure( + member.id, + "admin-codex", + 401, + ); + signedIn(); + cookieJar.set( + "central_codex_device", + encrypt(JSON.stringify({ deviceAuthId: "device", userCode: "CODE" })), + ); + const { POST } = + await import("@/app/api/admin/central-codex/device-poll/route"); + const res = await POST( + new NextRequest("http://localhost/api/admin/central-codex/device-poll", { + method: "POST", + }), + ); + expect(res.status).toBe(200); + expect(await windowOf(member.id)).toEqual({ + retryAt: null, + failureStreak: 0, + }); + expect( + (await postgresProviderHealthLedger.getSkipHints(member.id)).has( + "admin-codex", + ), + ).toBe(false); + }); +}); + describe("two refreshes of one Codex token", () => { async function codexStatus(userId: string) { return (