diff --git a/src/lib/ai/__tests__/server-provider-health.test.ts b/src/lib/ai/__tests__/server-provider-health.test.ts new file mode 100644 index 000000000..7e6c58909 --- /dev/null +++ b/src/lib/ai/__tests__/server-provider-health.test.ts @@ -0,0 +1,137 @@ +/** + * v1.38.19 (wave D) — the instance-wide health of the operator's shared + * provider, as a tri-state a FRESH account is allowed to read. + * + * The ledger itself is per-user and fails OPEN (an optimisation, never a + * gate). This projection is the opposite on both counts: it folds rows + * across every account into one instance-level verdict, and it fails + * CLOSED — anything but a demonstrated recent success is not an offer. + * + * What it must never leak: how many accounts are tracked, when any of them + * last succeeded, or which account a row belongs to. The return type is the + * whole contract. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + providerHealth: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { + SERVER_PROVIDER_FRESH_WINDOW_MS, + readServerProviderHealth, +} from "../server-provider-health"; + +const NOW = new Date("2026-09-11T12:00:00.000Z"); + +function ago(ms: number): Date { + return new Date(NOW.getTime() - ms); +} + +function ahead(ms: number): Date { + return new Date(NOW.getTime() + ms); +} + +function rows( + ...list: Array<{ + lastResult: string; + lastOkAt?: Date | null; + nextRetryAt?: Date | null; + }> +) { + vi.mocked(prisma.providerHealth.findMany).mockResolvedValue( + list.map((row) => ({ + lastResult: row.lastResult, + lastOkAt: row.lastOkAt ?? null, + nextRetryAt: row.nextRetryAt ?? null, + })) as never, + ); +} + +beforeEach(() => { + vi.mocked(prisma.providerHealth.findMany).mockReset(); + vi.mocked(annotate).mockReset(); +}); + +describe("readServerProviderHealth", () => { + it("is healthy when some account saw the shared provider work inside the window", async () => { + rows({ lastResult: "ok", lastOkAt: ago(60 * 60 * 1000) }); + await expect(readServerProviderHealth(NOW)).resolves.toBe("healthy"); + }); + + it("is unhealthy when the last success is older than the window", async () => { + rows({ + lastResult: "ok", + lastOkAt: ago(SERVER_PROVIDER_FRESH_WINDOW_MS + 60_000), + }); + await expect(readServerProviderHealth(NOW)).resolves.toBe("unhealthy"); + }); + + it("is unhealthy when rows exist but none ever succeeded", async () => { + rows({ lastResult: "hard_failed", lastOkAt: null }); + await expect(readServerProviderHealth(NOW)).resolves.toBe("unhealthy"); + }); + + it("lets a 401 in cooldown beat a fresh ok — a dead key means the same for everyone", async () => { + rows( + { lastResult: "ok", lastOkAt: ago(60 * 1000) }, + { + lastResult: "auth_failed", + lastOkAt: ago(3 * 60 * 60 * 1000), + nextRetryAt: ahead(60 * 60 * 1000), + }, + ); + await expect(readServerProviderHealth(NOW)).resolves.toBe("unhealthy"); + }); + + it("ignores an auth failure whose cooldown has run out", async () => { + rows( + { lastResult: "ok", lastOkAt: ago(60 * 1000) }, + { + lastResult: "auth_failed", + lastOkAt: null, + nextRetryAt: ago(60 * 1000), + }, + ); + await expect(readServerProviderHealth(NOW)).resolves.toBe("healthy"); + }); + + it("is unknown when the instance has no rows for either shared provider", async () => { + rows(); + await expect(readServerProviderHealth(NOW)).resolves.toBe("unknown"); + }); + + it("is unknown when the read throws, and says so in the event", async () => { + vi.mocked(prisma.providerHealth.findMany).mockRejectedValue( + new Error("connection terminated"), + ); + await expect(readServerProviderHealth(NOW)).resolves.toBe("unknown"); + expect(vi.mocked(annotate)).toHaveBeenCalledWith( + expect.objectContaining({ + action: { name: "ai.server_health.read_failed" }, + }), + ); + }); + + it("reads only the operator-funded provider tags, and no per-account column", async () => { + rows({ lastResult: "ok", lastOkAt: ago(1000) }); + await readServerProviderHealth(NOW); + const args = vi.mocked(prisma.providerHealth.findMany).mock.calls[0]![0]!; + expect(args.where).toEqual({ + providerType: { in: ["admin-openai", "admin-codex"] }, + }); + expect(Object.keys(args.select ?? {}).sort()).toEqual([ + "lastOkAt", + "lastResult", + "nextRetryAt", + ]); + }); +}); diff --git a/src/lib/ai/server-provider-health.ts b/src/lib/ai/server-provider-health.ts new file mode 100644 index 000000000..aea613a71 --- /dev/null +++ b/src/lib/ai/server-provider-health.ts @@ -0,0 +1,112 @@ +/** + * v1.38.19 (wave D) — "does the shared provider of THIS instance work?", + * answered for a user who has never made a single AI call. + * + * The question comes from the end of the setup flow. The done screen used + * to claim "insights work for you right away" on the strength of + * `managedBy === "server"`, which is a PRESENCE read: it says the operator + * configured a key, never that the key works. On 2026-09-11 the operator's + * own instance answered HTTP 500 from its OAuth proxy while the screen kept + * making that promise. This module is the evidence the promise needs. + * + * Why it cannot come from the user's own ledger: `provider_health` is unique + * on `(user_id, provider_type)`, and an account walking the wizard has NO + * row — for any provider. Its own ledger can only answer "unknown", forever. + * So the projection folds rows ACROSS accounts. That is also the reason for + * the shape of the return value. + * + * What leaves this module is a tri-state and nothing else: no counts, no + * timestamps, no user ids, not even "how many accounts are tracked". "The + * provider of this instance last worked" is a statement about the instance; + * "seven accounts used it at 06:42Z" is a statement about other people. The + * admin fold (`GET /api/admin/provider-health`) keeps the second kind and + * stays behind `requireAdmin()`; this one is readable by any signed-in user + * precisely because it carries none of it. + * + * Fail CLOSED, deliberately against the grain of the ledger it reads. The + * ledger fails open (`provider-health-ledger.ts`) because there it protects + * a generation already under way — contributing nothing is the safe outcome. + * Here the output is a promise made to a person, so a read error, a timeout + * or an empty table is `unknown`, and `unknown` buys nothing: the caller + * treats it exactly like `unhealthy`. + * + * No probe. `POST /api/ai/test` exists and would be the direct answer, but it + * is a real completion billed to whoever the chain resolves to — usually the + * operator. A probe here would charge the operator for every registration, + * and a sign-up bot would scale that bill. The design spec's rule holds: + * nothing in the flow talks to a provider. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; + +/** + * The whole contract. Three words, no payload. + * + * healthy — the instance's shared provider demonstrably served somebody + * inside the freshness window, and no credential is benched. + * unhealthy — it has been tried and it is not working. + * unknown — nobody has tried it yet on this instance, or the read + * failed. Never an offer; see the module note on failing closed. + */ +export type ServerProviderHealth = "healthy" | "unhealthy" | "unknown"; + +/** + * How recent a success has to be to still count. + * + * The ledger's own cooldowns bracket the choice: 6 h for an auth failure, + * 5 min for a hard one. A window under those would read `unhealthy` on a + * quiet single-person instance simply because nothing ran that hour; a much + * larger one would paint a key that died on Monday as green on Thursday. + * A day is long enough for an instance with one briefing per morning to hold + * the threshold, short enough that a dead key goes grey within a day. + */ +export const SERVER_PROVIDER_FRESH_WINDOW_MS = 24 * 60 * 60 * 1000; + +/** + * The provider tags the operator pays for: the instance-wide OpenAI key and + * the shared central Codex account. These are exactly the two tags + * `consent-guard.ts` gates on, and that is not a coincidence — the offer + * this feeds is the consent for these two and for nothing else. + */ +const SHARED_PROVIDER_TYPES = ["admin-openai", "admin-codex"] as const; + +export async function readServerProviderHealth( + now: Date = new Date(), +): Promise { + let rows: Array<{ + lastResult: string; + lastOkAt: Date | null; + nextRetryAt: Date | null; + }>; + try { + rows = await prisma.providerHealth.findMany({ + where: { providerType: { in: [...SHARED_PROVIDER_TYPES] } }, + // Three columns, none of which identifies an account. The absence of + // `userId` here is load-bearing, not tidiness. + select: { lastResult: true, lastOkAt: true, nextRetryAt: true }, + }); + } catch { + annotate({ action: { name: "ai.server_health.read_failed" } }); + return "unknown"; + } + + if (rows.length === 0) return "unknown"; + + // A benched credential outranks any success. A 401/403 is not one + // account's bad luck — the operator's key is one object, and while it is + // in its cooldown it is dead for everybody, including whoever is reading + // this screen right now. + const credentialBenched = rows.some( + (row) => + row.lastResult === "auth_failed" && + row.nextRetryAt !== null && + row.nextRetryAt.getTime() > now.getTime(), + ); + if (credentialBenched) return "unhealthy"; + + const floor = now.getTime() - SERVER_PROVIDER_FRESH_WINDOW_MS; + const workedRecently = rows.some( + (row) => row.lastOkAt !== null && row.lastOkAt.getTime() >= floor, + ); + return workedRecently ? "healthy" : "unhealthy"; +}