diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6ba15e063..37a26fc86 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -44485,6 +44485,30 @@ components: - type: "null" description: 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. + serverProviderHealth: + type: string + enum: + - healthy + - unhealthy + - unknown + description: "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: + type: boolean + description: "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: + type: boolean + description: 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. required: - provider - model @@ -44500,6 +44524,9 @@ components: - compatModel - hasCompatKey - responseTimeoutSeconds + - serverProviderHealth + - serverProviderOffer + - serverProviderConsent additionalProperties: false description: The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`). Reports key presence + a masked preview only — never a plaintext key. diff --git a/src/app/api/user/ai-provider/__tests__/route.test.ts b/src/app/api/user/ai-provider/__tests__/route.test.ts index a1421a944..fbef38c9c 100644 --- a/src/app/api/user/ai-provider/__tests__/route.test.ts +++ b/src/app/api/user/ai-provider/__tests__/route.test.ts @@ -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)(); + 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(); + }); +}); diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index 524a32d01..9fbe13e47 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -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, }); }); diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 2ce21fc07..e8b805e81 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -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",