mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user