mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user