Merge wave D — the shared provider is offered only when it is known to work

The done screen's assistant panel stops claiming that the instance's
provider already serves the person. A tri-state read over the operator's
own provider ledger decides between a one-tap offer that mints the
consent receipt, a plain "not answering right now" note, and today's
setup link; anything unknown, any read error, a managed profile and an
operator who has switched the assistant off all fall to no offer.

# Conflicts:
#	e2e/setup/global-setup.ts
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 10:25:19 +02:00
commit 1885b7867d
18 files changed
+1204 -23

No files matched your search

@@ -43,11 +43,33 @@ vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
// v1.38.19 (wave D) — the four preconditions the honest offer rides on.
vi.mock("@/lib/ai/server-provider-health", () => ({
readServerProviderHealth: vi.fn(),
}));
vi.mock("@/lib/feature-flags", () => ({
getAssistantFlags: vi.fn(),
}));
vi.mock("@/lib/sharing/provider-work-authority", () => ({
providerWorkAuthorityForRecord: vi.fn(() => ({ origin: "owner" })),
providerCredentialPolicy: vi.fn(() => "personal"),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
hasActiveConsentForSurface: vi.fn(),
}));
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { resolveProviderAvailability } from "@/lib/ai/provider";
import { isPublicUrl } from "@/lib/validations/notifications";
import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist";
import { readServerProviderHealth } from "@/lib/ai/server-provider-health";
import { getAssistantFlags } from "@/lib/feature-flags";
import { providerCredentialPolicy } from "@/lib/sharing/provider-work-authority";
import { hasActiveConsentForSurface } from "@/lib/ai/consent-guard";
function patchRequest(body: unknown): Request {
return new Request("http://localhost/api/user/ai-provider", {
@@ -70,6 +92,18 @@ interface AiProviderResponse {
beforeEach(() => {
vi.resetAllMocks();
// The shape every GET needs before a case narrows it: an owning caller on
// an instance whose assistant surfaces are on, who has not consented yet.
vi.mocked(readServerProviderHealth).mockResolvedValue("unknown");
vi.mocked(getAssistantFlags).mockResolvedValue({
enabled: true,
coach: true,
briefing: true,
insightStatus: true,
correlations: true,
});
vi.mocked(providerCredentialPolicy).mockReturnValue("personal");
vi.mocked(hasActiveConsentForSurface).mockResolvedValue(false);
});
describe("GET /api/user/ai-provider availability", () => {
@@ -405,3 +439,128 @@ describe("GET /api/user/ai-provider — the gateway's fields (#470)", () => {
expect(JSON.stringify(body)).not.toContain("enc-gateway");
});
});
/**
* v1.38.19 (wave D) — the honest shared-provider offer.
*
* The setup flow's last screen used to promise that insights "work for you
* right away" whenever `managedBy` said `server`. That is a presence read:
* it knows a key is configured, never that it works, and it says nothing
* about the consent receipt the egress actually needs. These three fields
* are what makes the promise checkable — and every one of the five
* preconditions below can only ever take the offer AWAY.
*/
describe("GET /api/user/ai-provider — the shared-provider offer", () => {
function serverManaged(): void {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
aiProvider: null,
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: null,
managedProfileAt: null,
} as never);
vi.mocked(resolveProviderAvailability).mockResolvedValue({
aiAvailable: true,
managedBy: "server",
});
}
async function read(): Promise<{
serverProviderHealth: string;
serverProviderOffer: boolean;
serverProviderConsent: boolean;
}> {
const res = await (GET as () => Promise<Response>)();
const body = (await res.json()) as {
data: {
serverProviderHealth: string;
serverProviderOffer: boolean;
serverProviderConsent: boolean;
};
};
return body.data;
}
it("offers the shared provider when it is demonstrably healthy and nothing forbids it", async () => {
serverManaged();
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
await expect(read()).resolves.toMatchObject({
serverProviderHealth: "healthy",
serverProviderOffer: true,
serverProviderConsent: false,
});
});
it.each(["unhealthy", "unknown"] as const)(
"never offers on %s health — unknown is not a maybe",
async (health) => {
serverManaged();
vi.mocked(readServerProviderHealth).mockResolvedValue(health);
await expect(read()).resolves.toMatchObject({
serverProviderHealth: health,
serverProviderOffer: false,
});
},
);
it("never offers when the operator switched the assistant surfaces off", async () => {
serverManaged();
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
vi.mocked(getAssistantFlags).mockResolvedValue({
enabled: true,
coach: false,
briefing: true,
insightStatus: true,
correlations: true,
});
await expect(read()).resolves.toMatchObject({
serverProviderHealth: "healthy",
serverProviderOffer: false,
});
});
it("never offers to a managed profile — a child's record does not consent for itself", async () => {
serverManaged();
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
vi.mocked(providerCredentialPolicy).mockReturnValue("operator-default");
await expect(read()).resolves.toMatchObject({ serverProviderOffer: false });
});
it("stops offering once the user holds a receipt, and says the consent is on file", async () => {
serverManaged();
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
vi.mocked(hasActiveConsentForSurface).mockResolvedValue(true);
await expect(read()).resolves.toMatchObject({
serverProviderOffer: false,
serverProviderConsent: true,
});
});
it("never offers a provider the operator does not manage", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
aiProvider: "OPENAI",
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: "enc-openai",
managedProfileAt: null,
} as never);
vi.mocked(resolveProviderAvailability).mockResolvedValue({
aiAvailable: true,
managedBy: "user",
});
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
await expect(read()).resolves.toMatchObject({ serverProviderOffer: false });
});
it("reads the health without probing anything", async () => {
serverManaged();
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
await read();
expect(vi.mocked(readServerProviderHealth)).toHaveBeenCalledTimes(1);
expect(vi.mocked(readServerProviderHealth)).toHaveBeenCalledWith();
});
});
+55
View File
@@ -11,6 +11,13 @@ import { isPublicUrl } from "@/lib/validations/notifications";
import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist";
import { encrypt, decrypt } from "@/lib/crypto";
import { resolveProviderAvailability } from "@/lib/ai/provider";
import { readServerProviderHealth } from "@/lib/ai/server-provider-health";
import { hasActiveConsentForSurface } from "@/lib/ai/consent-guard";
import { getAssistantFlags } from "@/lib/feature-flags";
import {
providerCredentialPolicy,
providerWorkAuthorityForRecord,
} from "@/lib/sharing/provider-work-authority";
import { annotate } from "@/lib/logging/context";
import { aiProviderPatchSchema } from "@/lib/validations/ai-provider";
@@ -35,6 +42,9 @@ export const GET = apiHandler(async () => {
aiCompatModel: true,
// v1.22 (#89)
aiResponseTimeoutSeconds: true,
// v1.38.19 (wave D) — a managed profile is never the party that gives
// consent for itself; the offer below has to know.
managedProfileAt: true,
},
});
@@ -45,6 +55,46 @@ export const GET = apiHandler(async () => {
// `managedBy` reports the origin only; no admin keys/endpoints are leaked.
const { aiAvailable, managedBy } = await resolveProviderAvailability(user.id);
// ── v1.38.19 (wave D) — the shared provider, honestly ────────────────
// `managedBy: "server"` says the operator configured a key. It has never
// said the key WORKS, and the setup flow spent three releases promising
// that it did. It also says nothing about the consent receipt
// `consent-guard.ts` demands before any PHI reaches `admin-openai` /
// `admin-codex`, which is the decision a fresh account is actually
// missing — the provider was already chosen for them by the default chain.
//
// So three additive fields, and every one of them can only ever take the
// offer away:
// serverProviderHealth — the instance-wide tri-state, fail closed.
// serverProviderConsent — this user already holds a receipt.
// serverProviderOffer — all five preconditions hold at once.
//
// Nothing here probes a provider. The flow's rule ("nothing in the flow
// talks to an AI provider") holds: a probe would bill the OPERATOR for
// every registration on the instance.
const serverProviderHealth = await readServerProviderHealth();
const serverProviderConsent = await hasActiveConsentForSurface(
user.id,
"coach",
);
const assistantFlags = await getAssistantFlags();
// `personal`, not merely "not deny": a guardian's own record resolves to
// `personal`, while a managed profile resolves to `operator-default` —
// its provider is the operator's by definition, and a child's record does
// not consent to its own PHI egress. Both must be excluded here, and only
// the positive form excludes both.
const credentialPolicy = providerCredentialPolicy(
providerWorkAuthorityForRecord(user.id),
u?.managedProfileAt ?? null,
);
const serverProviderOffer =
managedBy === "server" &&
serverProviderHealth === "healthy" &&
assistantFlags.enabled &&
assistantFlags.coach &&
credentialPolicy === "personal" &&
!serverProviderConsent;
return apiSuccess({
provider: u?.aiProvider ?? null,
model: u?.aiModel ?? null,
@@ -67,6 +117,11 @@ export const GET = apiHandler(async () => {
hasCompatKey: Boolean(u?.aiCompatKeyEncrypted),
// v1.22 (#89) — per-user response timeout, in seconds (null = default).
responseTimeoutSeconds: u?.aiResponseTimeoutSeconds ?? null,
// v1.38.19 (wave D) — see the block above. A tri-state and two booleans;
// no count, no timestamp and no other account is inferable from them.
serverProviderHealth,
serverProviderOffer,
serverProviderConsent,
});
});
@@ -12,15 +12,52 @@ import { I18nProvider } from "@/lib/i18n/context";
*/
const aiProviderState = vi.hoisted(() => ({
data: undefined as { managedBy?: string | null } | undefined,
data: undefined as
| {
managedBy?: string | null;
serverProviderHealth?: string;
serverProviderOffer?: boolean;
serverProviderConsent?: boolean;
}
| undefined,
}));
/**
* The consent grant is a mutation, and this suite renders to static markup
* (the house convention — there is no jsdom in this repo), so the click
* itself cannot be dispatched. What IS worth proving is the request the
* button would send, so every `useMutation` registration is captured here
* and its `mutationFn` invoked directly.
*/
const mutations = vi.hoisted(
() =>
[] as Array<{
mutationKey?: readonly unknown[];
mutationFn?: () => Promise<unknown>;
}>,
);
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: aiProviderState.data }),
useMutation: () => ({ mutate: () => {}, isPending: false }),
useMutation: (options?: {
mutationKey?: readonly unknown[];
mutationFn?: () => Promise<unknown>;
}) => {
if (options) mutations.push(options);
return { mutate: () => {}, mutateAsync: async () => {}, isPending: false };
},
useQueryClient: () => ({ invalidateQueries: async () => {} }),
}));
const apiFetchRaw = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
apiFetchRaw,
}));
const toastError = vi.hoisted(() => vi.fn());
vi.mock("sonner", () => ({ toast: { error: toastError } }));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: () => {}, replace: () => {} }),
}));
@@ -110,11 +147,133 @@ describe("<DoneScreen> AI panel", () => {
expect(render("both")).toContain('href="/settings/access"');
expect(render("me")).not.toContain('href="/settings/access"');
});
});
it("shows the shared-key note only when the operator key serves the user", () => {
aiProviderState.data = { managedBy: "user" };
expect(render()).not.toContain('data-slot="onboarding-ai-shared-key"');
aiProviderState.data = { managedBy: "server" };
expect(render()).toContain('data-slot="onboarding-ai-shared-key"');
/**
* v1.38.19 (wave D) — the shared provider, offered only when it works.
*
* The card used to read `managedBy === "server"` and promise that insights
* "work for you right now". That is a presence read: it knew a key was
* configured, never that it worked, and it said nothing about the consent
* receipt the egress actually needs — so a fresh account followed the
* promise straight into a `consent.ai.required` refusal. On 2026-09-11 the
* operator's own instance was answering HTTP 500 while the screen kept
* making that promise.
*
* Four variants now, and only one of them has a button.
*/
function grantMutation() {
return mutations.find((m) =>
(m.mutationKey ?? []).some(
(part) => typeof part === "string" && part.includes("consent"),
),
);
}
describe("<DoneScreen> shared-provider offer", () => {
it("offers one tap when the server says the provider is known to work", () => {
aiProviderState.data = {
managedBy: "server",
serverProviderHealth: "healthy",
serverProviderOffer: true,
serverProviderConsent: false,
};
const html = render();
expect(html).toContain('data-slot="onboarding-ai-offer"');
expect(html).toContain('data-slot="onboarding-ai-offer-grant"');
// The unproven promise is gone from this branch.
expect(html).not.toContain('data-slot="onboarding-ai-shared-key"');
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
});
it.each(["unhealthy", "unknown"] as const)(
"says the provider is not answering on %s, and offers no button",
(health) => {
aiProviderState.data = {
managedBy: "server",
serverProviderHealth: health,
serverProviderOffer: false,
serverProviderConsent: false,
};
const html = render();
expect(html).toContain('data-slot="onboarding-ai-unavailable"');
expect(html).not.toContain('data-slot="onboarding-ai-offer-grant"');
expect(html).not.toContain('data-slot="onboarding-ai-shared-key"');
// The rest of the panel is untouched in every branch.
expect(html).toContain('data-slot="onboarding-ai-keyless"');
expect(html).toContain('href="/settings/ai"');
},
);
it("keeps the shared-key note for the one state it was ever true of", () => {
aiProviderState.data = {
managedBy: "server",
serverProviderHealth: "healthy",
serverProviderOffer: false,
serverProviderConsent: true,
};
const html = render();
expect(html).toContain('data-slot="onboarding-ai-shared-key"');
expect(html).not.toContain('data-slot="onboarding-ai-offer-grant"');
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
});
it("leaves a user with their own provider the setup link and nothing else", () => {
aiProviderState.data = {
managedBy: "user",
serverProviderHealth: "healthy",
serverProviderOffer: false,
serverProviderConsent: false,
};
const html = render();
expect(html).not.toContain('data-slot="onboarding-ai-offer"');
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
expect(html).not.toContain('data-slot="onboarding-ai-shared-key"');
expect(html).toContain('href="/settings/ai"');
});
it("says nothing at all while the status is still in flight", () => {
aiProviderState.data = undefined;
const html = render();
expect(html).not.toContain('data-slot="onboarding-ai-offer"');
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
});
it("grants through the affirmative web intent — the only path that may lift a revocation", async () => {
mutations.length = 0;
apiFetchRaw.mockReset();
apiFetchRaw.mockResolvedValue({ ok: true });
aiProviderState.data = {
managedBy: "server",
serverProviderHealth: "healthy",
serverProviderOffer: true,
serverProviderConsent: false,
};
render();
const grant = grantMutation();
expect(grant).toBeDefined();
await grant!.mutationFn!();
expect(apiFetchRaw).toHaveBeenCalledWith(
"/api/consent/ai/web",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ intent: "affirmative" }),
}),
);
});
it("a refused grant throws rather than reporting a consent nobody recorded", async () => {
mutations.length = 0;
apiFetchRaw.mockReset();
apiFetchRaw.mockResolvedValue({ ok: false, status: 429 });
aiProviderState.data = {
managedBy: "server",
serverProviderHealth: "healthy",
serverProviderOffer: true,
serverProviderConsent: false,
};
render();
const grant = grantMutation();
await expect(grant!.mutationFn!()).rejects.toThrow();
});
});
+121 -9
View File
@@ -3,7 +3,8 @@
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
CheckCircle2,
Compass,
@@ -24,7 +25,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useAccountSwitch } from "@/hooks/use-account-switch";
import { setTourReferrer } from "@/components/onboarding/tour-launcher";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage";
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
@@ -33,6 +34,15 @@ import { accountLabel } from "@/lib/sharing/account-access-view";
interface AiProviderStatus {
/** Origin of the provider that would serve this user, if any. */
managedBy?: "user" | "local" | "server" | null;
/**
* v1.38.19 (wave D) — the instance-wide tri-state. Fail closed: `unknown`
* is treated exactly like `unhealthy`, here and everywhere.
*/
serverProviderHealth?: "healthy" | "unhealthy" | "unknown";
/** Whether the shared provider may honestly be offered in one tap. */
serverProviderOffer?: boolean;
/** Whether this user already holds an active `ai_full` / `ai_coach` receipt. */
serverProviderConsent?: boolean;
}
/**
@@ -101,10 +111,21 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
router.push("/");
}
// The shared-key note is the ONLY honest divergence in the panel: on a
// deployment that ships an operator key, insights already work for the
// user, so we say so plainly instead of pushing a BYOK/local setup they
// don't need. Presence-only read; no key material is exposed.
// ── v1.38.19 (wave D) — the shared provider, offered only when it works ──
//
// This block used to be one line: `managedBy === "server"` painted a note
// reading "insights work for you right now — no setup needed". Both halves
// were wrong. `managedBy` is a PRESENCE read — it knows the operator
// configured a key, never that the key answers — and "no setup needed"
// skipped the consent receipt `consent-guard.ts` demands before any health
// data reaches `admin-openai` / `admin-codex`, so the person who believed
// the note walked into a `consent.ai.required` refusal on their first
// briefing. On 2026-09-11 the operator's own instance was answering HTTP
// 500 from its OAuth proxy while this note kept promising otherwise.
//
// The server decides now, and the four branches below only READ it. The
// rest of the panel — sample, ladder, the "fully useful without AI" line,
// the setup link — is identical in every branch; nothing here is a gate.
const { data: aiProvider } = useQuery<AiProviderStatus>({
queryKey: queryKeys.userAiProvider(),
queryFn: async () => {
@@ -112,7 +133,58 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
},
enabled: !!user,
});
const sharedKeyServes = aiProvider?.managedBy === "server";
// The tap's own outcome, held locally so the line changes the moment the
// receipt is minted rather than a refetch later.
const [justGranted, setJustGranted] = useState(false);
const queryClient = useQueryClient();
const grantConsent = useMutation({
mutationKey: queryKeys.aiConsentReceipt("ai_full"),
mutationFn: async () => {
// `intent: "affirmative"` marks this as the user's own consent act —
// the only web path allowed to supersede an earlier withdrawal. The
// silent mount heal posts no body and deliberately cannot.
const res = await apiFetchRaw("/api/consent/ai/web", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ intent: "affirmative" }),
});
if (!res.ok) throw new Error("consent grant failed");
},
onSuccess: async () => {
setJustGranted(true);
await queryClient.invalidateQueries({
queryKey: queryKeys.userAiProvider(),
});
await queryClient.invalidateQueries({
queryKey: queryKeys.aiConsentReceipt("ai_full"),
});
},
onError: () => {
// The button stays. A failed grant is a failed grant; the screen must
// not read as though consent were on file.
toast.error(t("onboarding.ai.offer.error"));
},
});
const operatorProvides = aiProvider?.managedBy === "server";
const consentOnFile =
justGranted || aiProvider?.serverProviderConsent === true;
const offerShared =
operatorProvides &&
!consentOnFile &&
aiProvider?.serverProviderOffer === true;
// Only a health verdict paints the "not answering" card. When the offer is
// refused for a reason that is not health — the operator switched the
// assistant surfaces off, or this is a managed profile whose provider is
// the operator's by definition — the panel falls back to its neutral form
// and claims nothing either way.
const sharedUnavailable =
operatorProvides &&
!consentOnFile &&
!offerShared &&
(aiProvider?.serverProviderHealth === "unhealthy" ||
aiProvider?.serverProviderHealth === "unknown");
return (
<section
@@ -185,15 +257,55 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
</Button>
)}
{sharedKeyServes ? (
{consentOnFile && operatorProvides ? (
<p
data-slot="onboarding-ai-shared-key"
className="text-foreground bg-primary/5 border-primary/20 rounded-lg border px-3 py-2 text-sm leading-relaxed"
>
{t("onboarding.ai.sharedKeyNote")}
{justGranted
? t("onboarding.ai.offer.granted")
: t("onboarding.ai.sharedKeyNote")}
</p>
) : null}
{offerShared ? (
<div
data-slot="onboarding-ai-offer"
className="border-primary/20 bg-primary/5 space-y-2.5 rounded-lg border px-3 py-3"
>
<p className="text-foreground text-sm font-medium">
{t("onboarding.ai.offer.title")}
</p>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("onboarding.ai.offer.body")}
</p>
<Button
type="button"
size="sm"
className="min-h-9 w-full"
data-slot="onboarding-ai-offer-grant"
disabled={grantConsent.isPending}
onClick={() => grantConsent.mutate()}
>
{t("onboarding.ai.offer.cta")}
</Button>
</div>
) : null}
{sharedUnavailable ? (
<div
data-slot="onboarding-ai-unavailable"
className="border-border bg-muted/40 space-y-1 rounded-lg border px-3 py-3"
>
<p className="text-foreground text-sm font-medium">
{t("onboarding.ai.unavailable.title")}
</p>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("onboarding.ai.unavailable.body")}
</p>
</div>
) : null}
<div className="space-y-2.5">
<p className="text-foreground text-sm font-medium">
{t("onboarding.ai.ladderTitle")}
@@ -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",
]);
});
});
+112
View File
@@ -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<ServerProviderHealth> {
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";
}
+20
View File
@@ -559,6 +559,26 @@ const aiProviderResponse = z
.describe(
"v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends.",
),
// v1.38.19 (wave D) — the setup flow's last screen used to promise
// that insights "work for you right away" as soon as `managedBy` read
// `server`. That is a presence read; it never knew whether the key
// worked, and it said nothing about the consent receipt the egress
// actually needs. These three fields make the promise checkable.
serverProviderHealth: z
.enum(["healthy", "unhealthy", "unknown"])
.describe(
"Instance-wide health of the operator's shared providers (`admin-openai` / `admin-codex`), folded across all accounts and reduced to a tri-state: `healthy` when one of them demonstrably served somebody within the last 24 h and no credential sits in its auth-failure cooldown, `unhealthy` when they have been tried and are not working, `unknown` when nothing has been tried yet on this instance or the read failed. Deliberately carries no count, no timestamp and no account identity — 'the provider of this instance last worked' is a statement about the instance, not about other people. Fails closed: treat `unknown` exactly like `unhealthy`. Reading this never probes a provider.",
),
serverProviderOffer: z
.boolean()
.describe(
"Whether the shared provider may honestly be offered to this caller in one tap. True only when all of: the operator's provider is the one that would serve them (`managedBy: \"server\"`), `serverProviderHealth` is `healthy`, the operator's assistant master + coach flags are on, the acting record holds its own credentials (a managed profile never does), and the caller holds no receipt yet. Anything unknown makes it false.",
),
serverProviderConsent: z
.boolean()
.describe(
"Whether this caller already holds an active `ai_full` / `ai_coach` receipt — the grant `POST /api/consent/ai/web` mints. When true there is nothing left to offer, and `serverProviderOffer` is false for that reason rather than for a bad one.",
),
})
.meta({
id: "AiProviderResponse",