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,
});
});