diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e23ee588d..2c9848fc6 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/e2e/onboarding-ai-offer.spec.ts b/e2e/onboarding-ai-offer.spec.ts new file mode 100644 index 000000000..0e7bb948c --- /dev/null +++ b/e2e/onboarding-ai-offer.spec.ts @@ -0,0 +1,170 @@ +/** + * v1.38.19 (wave D) — the AI offer at the end of the setup flow. + * + * The screen used to promise that insights "work for you right now" the + * moment the operator had configured a key. It never knew whether the key + * answered, and it never mentioned the consent receipt the egress actually + * needs — so the person who believed it met 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 the screen kept promising. + * + * Two runs, one account, and the only difference between them is a row in + * `provider_health`: + * + * a success inside the freshness window → the offer, and one tap grants + * a dead credential inside its cooldown → "not answering", and no button + * + * What is deliberately absent: any call to a provider. The flow's rule holds + * — nothing in it talks to one — and a probe here would bill the operator for + * every registration on the instance. + * + * Serial, and it restores the instance state it seeded: the tri-state folds + * across accounts by design, so its inputs are instance-wide. + */ +import { + E2E_SETUP_AI_OFFER, + SETUP_AI_OFFER_STORAGE_STATE_PATH, +} from "./setup/global-setup"; +import { + clearAiConsent, + clearOperatorProvider, + seedOperatorProvider, + seedSharedProviderResult, +} from "./setup/ai-offer-fixture"; +import { resetSetupFlow } from "./setup/setup-flow-fixture"; +import { expect, test } from "./setup/test"; +import { + answer, + answerEverything, + complete, + expectScreen, +} from "./setup-flow-helpers"; + +const OFFER = '[data-slot="onboarding-ai-offer"]'; +const GRANT = '[data-slot="onboarding-ai-offer-grant"]'; +const UNAVAILABLE = '[data-slot="onboarding-ai-unavailable"]'; +const SHARED_KEY = '[data-slot="onboarding-ai-shared-key"]'; + +/** Walk the account to the done screen through the API, then open it. */ +async function openDoneScreen(page: import("@playwright/test").Page) { + await page.goto("/onboarding"); + await answerEverything(page); + // Q6 applies because the answers name glucose and weight; the flow does not + // complete while a screen it owes is unanswered. + await answer(page, { + step: "units", + units: { glucoseUnit: "mmol/L", unitPreference: "metric" }, + }); + await complete(page); + await answer(page, { step: "first-result", status: "skipped" }); + await page.goto("/onboarding/done"); + await expectScreen(page, "done"); + // The panel itself is unconditional; the variant inside it is not. + await expect(page.locator('[data-slot="onboarding-ai-panel"]')).toBeVisible(); +} + +// One account, one instance-wide ledger. Two workers driving this at once +// would each read the other's seed. +test.describe.configure({ mode: "serial" }); + +test.describe("the setup flow's AI offer follows the shared provider's health", () => { + test.use({ storageState: SETUP_AI_OFFER_STORAGE_STATE_PATH }); + + test.beforeEach(async () => { + await resetSetupFlow(E2E_SETUP_AI_OFFER.username); + await clearAiConsent(E2E_SETUP_AI_OFFER.username); + await seedOperatorProvider(); + }); + + test.afterAll(async () => { + await clearOperatorProvider(); + await clearAiConsent(E2E_SETUP_AI_OFFER.username); + }); + + test("offers one tap when the shared provider worked recently, and the tap leaves a receipt", async ({ + page, + }) => { + test.setTimeout(90_000); + await seedSharedProviderResult(E2E_SETUP_AI_OFFER.username, { + result: "ok", + okMinutesAgo: 30, + }); + + // No receipt before the tap — otherwise the assertion after it proves + // nothing about the button. + const before = await page.request.get( + "/api/consent/ai/latest?kind=ai_full", + ); + expect(before.status()).toBe(200); + expect( + ((await before.json()) as { data: { receipt: unknown } }).data.receipt, + ).toBeNull(); + + await openDoneScreen(page); + + await expect(page.locator(OFFER)).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(UNAVAILABLE)).toHaveCount(0); + + // No provider was called to decide this. The flow reads a projection. + const probes: string[] = []; + page.on("request", (req) => { + const path = new URL(req.url()).pathname; + if (path === "/api/ai/test" || path.startsWith("/api/insights/")) + probes.push(path); + }); + + await page.locator(GRANT).click(); + + // The line the person reads swaps to the granted one. + await expect(page.locator(SHARED_KEY)).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(GRANT)).toHaveCount(0); + expect(probes, "the offer must not trigger a provider call").toEqual([]); + + const after = await page.request.get("/api/consent/ai/latest?kind=ai_full"); + const receipt = ( + (await after.json()) as { + data: { receipt: { kind: string; revokedAt: string | null } | null }; + } + ).data.receipt; + expect(receipt).not.toBeNull(); + expect(receipt!.kind).toBe("ai_full"); + expect(receipt!.revokedAt).toBeNull(); + }); + + test("says the provider is not answering while its credential is benched, and offers no button", async ({ + page, + }) => { + test.setTimeout(90_000); + await seedSharedProviderResult(E2E_SETUP_AI_OFFER.username, { + result: "auth_failed", + }); + + await openDoneScreen(page); + + await expect(page.locator(UNAVAILABLE)).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(GRANT)).toHaveCount(0); + await expect(page.locator(OFFER)).toHaveCount(0); + // The unproven promise is not shown to somebody who has not consented. + await expect(page.locator(SHARED_KEY)).toHaveCount(0); + + // The rest of the panel is untouched: the flow stays complete without it. + await expect( + page.locator('[data-slot="onboarding-ai-keyless"]'), + ).toBeVisible(); + await expect( + page.locator('[data-slot="onboarding-open-dashboard"]'), + ).toBeVisible(); + + // The grant route itself stays open — it is the user's own consent act + // and is reachable from Settings at any time. What this case pins is the + // decision the screen was handed, so read it back from the source. + const status = await page.request.get("/api/user/ai-provider"); + const data = ( + (await status.json()) as { + data: { serverProviderHealth: string; serverProviderOffer: boolean }; + } + ).data; + expect(data.serverProviderHealth).toBe("unhealthy"); + expect(data.serverProviderOffer).toBe(false); + }); +}); diff --git a/e2e/setup/ai-offer-fixture.ts b/e2e/setup/ai-offer-fixture.ts new file mode 100644 index 000000000..a78bf00d9 --- /dev/null +++ b/e2e/setup/ai-offer-fixture.ts @@ -0,0 +1,132 @@ +/** + * v1.38.19 (wave D) — the instance state the shared-provider offer reads. + * + * Two rows decide what the setup flow's last screen may say, and neither has + * a product endpoint that writes them the way this journey needs: + * + * `app_settings.admin_ai_key_encrypted` — presence alone makes the operator + * the origin that would serve a user with no provider of their own + * (`managedBy: "server"`). Nothing decrypts it on this path, so the value + * below is a placeholder ciphertext and not a credential of any kind. + * + * `provider_health` — the ledger the tri-state folds. It is written only as + * a side effect of a real completion, which this suite must never make, so + * the journey states the outcome it wants to reason about instead. + * + * Both are INSTANCE-wide by nature: the projection deliberately folds across + * accounts, because a fresh account's own ledger is empty by construction. + * That is why the spec runs serially and puts both back afterwards. + * + * Seeded through `pg`, the same transport `global-setup.ts` and the other + * fixtures use. + */ +import pg from "pg"; + +/** Not a key. Presence is the only thing read on this path. */ +const PLACEHOLDER_ADMIN_KEY = "e2e-admin-key-presence-only"; + +function connect(): pg.Pool { + const url = process.env.DATABASE_URL; + if (!url) { + throw new Error("[ai-offer-fixture] DATABASE_URL is not set"); + } + return new pg.Pool({ connectionString: url }); +} + +async function userId(pool: pg.Pool, username: string): Promise { + const { rows } = await pool.query<{ id: string }>( + `SELECT id FROM users WHERE username = $1`, + [username], + ); + const id = rows[0]?.id; + if (!id) throw new Error(`[ai-offer-fixture] no account named ${username}`); + return id; +} + +/** The operator has configured a shared provider on this instance. */ +export async function seedOperatorProvider(): Promise { + const pool = connect(); + try { + await pool.query( + `INSERT INTO app_settings (id, admin_ai_key_encrypted) + VALUES ('singleton', $1) + ON CONFLICT (id) DO UPDATE SET admin_ai_key_encrypted = EXCLUDED.admin_ai_key_encrypted`, + [PLACEHOLDER_ADMIN_KEY], + ); + } finally { + await pool.end(); + } +} + +/** Put the instance back: no operator key, no ledger rows for either tag. */ +export async function clearOperatorProvider(): Promise { + const pool = connect(); + try { + await pool.query( + `UPDATE app_settings SET admin_ai_key_encrypted = NULL WHERE id = 'singleton'`, + ); + await pool.query( + `DELETE FROM provider_health + WHERE provider_type IN ('admin-openai', 'admin-codex')`, + ); + } finally { + await pool.end(); + } +} + +/** + * State one outcome for the operator's OpenAI provider. + * + * `ok` — it served somebody `okMinutesAgo` minutes ago. + * `auth_failed` — its credential is dead and benched for another hour, + * which is the shape the operator's own instance was in on + * 2026-09-11 (a 500 from the OAuth proxy, then a 401). + */ +export async function seedSharedProviderResult( + username: string, + outcome: { result: "ok"; okMinutesAgo: number } | { result: "auth_failed" }, +): Promise { + const pool = connect(); + try { + const id = await userId(pool, username); + await pool.query( + `DELETE FROM provider_health + WHERE provider_type IN ('admin-openai', 'admin-codex')`, + ); + if (outcome.result === "ok") { + await pool.query( + `INSERT INTO provider_health + (id, user_id, provider_type, last_result, consecutive_failures, + last_ok_at, updated_at) + VALUES ($1, $2, 'admin-openai', 'ok', 0, + NOW() - ($3 || ' minutes')::interval, NOW())`, + [`e2e-ph-${id}`, id, String(outcome.okMinutesAgo)], + ); + } else { + await pool.query( + `INSERT INTO provider_health + (id, user_id, provider_type, last_result, last_status, + consecutive_failures, last_failure_at, next_retry_at, updated_at) + VALUES ($1, $2, 'admin-openai', 'auth_failed', 401, 1, + NOW(), NOW() + interval '1 hour', NOW())`, + [`e2e-ph-${id}`, id], + ); + } + } finally { + await pool.end(); + } +} + +/** No receipt on file — the state a fresh account reaches the screen in. */ +export async function clearAiConsent(username: string): Promise { + const pool = connect(); + try { + const id = await userId(pool, username); + await pool.query(`DELETE FROM consent_receipts WHERE user_id = $1`, [id]); + await pool.query(`DELETE FROM rate_limits WHERE key = 'consent:' || $1`, [ + id, + ]); + } finally { + await pool.end(); + } +} diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 7dc203437..d18aa0227 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -502,6 +502,21 @@ export const E2E_SETUP_LOCALE = { role: "USER", } as const; +/** + * v1.38.19 (wave D) — the shared-provider offer's own account. + * + * Its journey seeds INSTANCE-wide state (the operator key's presence and the + * provider-health ledger the tri-state folds) and mints a consent receipt on + * the account, so it cannot share one with a journey that asserts a module + * map or a tile order. + */ +export const E2E_SETUP_AI_OFFER = { + email: "e2e-setup-ai-offer@healthlog.test", + username: "e2e-setup-ai-offer", + password: "Ao6!Zd3fB9qMv2Rk", + role: "USER", +} as const; + export const SETUP_BP_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupBp.json", @@ -530,6 +545,10 @@ export const SETUP_LOCALE_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupLocale.json", ); +export const SETUP_AI_OFFER_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateSetupAiOffer.json", +); const SETUP_FLOW_ACCOUNTS = [ E2E_SETUP_BP, @@ -538,6 +557,7 @@ const SETUP_FLOW_ACCOUNTS = [ E2E_SETUP_CHILD, E2E_SETUP_A11Y, E2E_SETUP_LOCALE, + E2E_SETUP_AI_OFFER, E2E_SETUP_CONNECT, ] as const; @@ -1282,6 +1302,7 @@ export default async function globalSetup(config: FullConfig): Promise { await capture(E2E_SETUP_A11Y, SETUP_A11Y_STORAGE_STATE_PATH); await capture(E2E_SETUP_LOCALE, SETUP_LOCALE_STORAGE_STATE_PATH); await capture(E2E_SETUP_CONNECT, SETUP_CONNECT_STORAGE_STATE_PATH); + await capture(E2E_SETUP_AI_OFFER, SETUP_AI_OFFER_STORAGE_STATE_PATH); await capture(E2E_SETUP_CHILD, SETUP_CHILD_STORAGE_STATE_PATH); await pool.query( `UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`, diff --git a/messages/de.json b/messages/de.json index f59632503..0248279e8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4558,7 +4558,18 @@ "ladderOauthBody": "Verbinde ein ChatGPT-Konto — keine Kosten pro Token, aber die Bedingungen der Verbraucherstufe können Inhalte fürs Training nutzen. Deshalb eine bewusste Wahl, nie der Standard für deine Gesundheitsdaten.", "keylessLine": "HealthLog ist auch ohne KI vollständig nutzbar. Deine Kacheln, Diagramme, Trends und Exporte funktionieren alle — KI ergänzt nur den Klartext obendrauf.", "setupCta": "Einrichten (etwa eine Minute)", - "sharedKeyNote": "Diese Instanz stellt bereits einen gemeinsamen KI-Anbieter bereit, deshalb funktionieren Insights für dich sofort — keine Einrichtung nötig." + "sharedKeyNote": "Diese Instanz stellt bereits einen gemeinsamen KI-Anbieter bereit, deshalb funktionieren Insights für dich sofort — keine Einrichtung nötig.", + "offer": { + "title": "Die Zusammenfassung dieser Instanz ist bereit.", + "body": "Der Betreiber stellt einen Anbieter bereit, der zuletzt innerhalb der letzten 24 Stunden funktioniert hat. Mit einem Tipp schaltest du den Klartext ein; deine Daten gehen dann an diesen einen Anbieter, an niemanden sonst.", + "cta": "Klartext einschalten", + "granted": "Eingeschaltet. Die erste Zusammenfassung erscheint auf dem Dashboard.", + "error": "Das Einschalten hat nicht geklappt. Versuch es gleich noch einmal." + }, + "unavailable": { + "title": "Der Anbieter dieser Instanz antwortet gerade nicht.", + "body": "HealthLog funktioniert ohne Klartext vollständig. Du kannst später in den Einstellungen einen eigenen Anbieter hinterlegen." + } }, "steps": { "who": "Für wen diese Akte ist", diff --git a/messages/en.json b/messages/en.json index b8f2dd281..c2914500e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4558,7 +4558,18 @@ "ladderOauthBody": "Connect a ChatGPT account — no per-token cost, but the consumer tier's terms may use content for training, so it's a deliberate choice, never the default for your health data.", "keylessLine": "HealthLog is fully useful without AI. Your tiles, charts, trends and exports all work — AI only adds the plain-language read on top.", "setupCta": "Set it up (about a minute)", - "sharedKeyNote": "This instance already provides a shared AI provider, so insights work for you right now — no setup needed." + "sharedKeyNote": "This instance already provides a shared AI provider, so insights work for you right now — no setup needed.", + "offer": { + "title": "This instance can write your summary in plain language.", + "body": "The operator provides a provider that last worked within the past 24 hours. One tap turns the plain-language read on; your data then goes to that one provider and to nobody else.", + "cta": "Turn on plain language", + "granted": "Turned on. The first summary appears on your dashboard.", + "error": "Turning it on did not work. Give it another try in a moment." + }, + "unavailable": { + "title": "This instance’s provider is not answering right now.", + "body": "HealthLog is fully useful without the plain-language read. You can add a provider of your own later under Settings." + } }, "steps": { "who": "Who this record is for", diff --git a/messages/es.json b/messages/es.json index 0edd77a85..6333bee7e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4558,7 +4558,18 @@ "ladderOauthBody": "Conecta una cuenta de ChatGPT — sin coste por token, pero los términos del nivel de consumo pueden usar el contenido para entrenamiento, así que es una elección deliberada, nunca la opción por defecto para tus datos de salud.", "keylessLine": "HealthLog es plenamente útil sin IA. Tus paneles, gráficos, tendencias y exportaciones funcionan — la IA solo añade la lectura en lenguaje claro por encima.", "setupCta": "Configúralo (un minuto aprox.)", - "sharedKeyNote": "Esta instancia ya ofrece un proveedor de IA compartido, así que los insights funcionan para ti ahora mismo — sin configuración." + "sharedKeyNote": "Esta instancia ya ofrece un proveedor de IA compartido, así que los insights funcionan para ti ahora mismo — sin configuración.", + "offer": { + "title": "Esta instancia ya puede escribir tu resumen en lenguaje claro.", + "body": "El operador ofrece un proveedor que funcionó por última vez en las últimas 24 horas. Con un toque activas el lenguaje claro; tus datos irán entonces a ese único proveedor y a nadie más.", + "cta": "Activar el lenguaje claro", + "granted": "Activado. El primer resumen aparecerá en tu panel.", + "error": "No se pudo activar. Vuelve a intentarlo en un momento." + }, + "unavailable": { + "title": "El proveedor de esta instancia no responde en este momento.", + "body": "HealthLog es plenamente útil sin el lenguaje claro. Más adelante puedes añadir tu propio proveedor en los ajustes." + } }, "steps": { "who": "Para quién es este registro", diff --git a/messages/fr.json b/messages/fr.json index 95a5ed947..f1ac4a8f8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4558,7 +4558,18 @@ "ladderOauthBody": "Reliez un compte ChatGPT — sans coût par jeton, mais les conditions du niveau grand public peuvent utiliser le contenu pour l'entraînement : c'est donc un choix délibéré, jamais l'option par défaut pour vos données de santé.", "keylessLine": "HealthLog est pleinement utile sans IA. Vos tuiles, graphiques, tendances et exports fonctionnent tous — l'IA n'ajoute que la lecture en langage clair par-dessus.", "setupCta": "Configurez-le (environ une minute)", - "sharedKeyNote": "Cette instance fournit déjà un fournisseur d'IA partagé : les insights fonctionnent pour vous dès maintenant — aucune configuration nécessaire." + "sharedKeyNote": "Cette instance fournit déjà un fournisseur d'IA partagé : les insights fonctionnent pour vous dès maintenant — aucune configuration nécessaire.", + "offer": { + "title": "Cette instance peut rédiger votre résumé en langage clair.", + "body": "L’exploitant met à disposition un fournisseur qui a fonctionné au cours des dernières 24 heures. Une seule touche active le langage clair ; vos données partent alors vers ce seul fournisseur et vers personne d’autre.", + "cta": "Activer le langage clair", + "granted": "Activé. Le premier résumé apparaîtra sur votre tableau de bord.", + "error": "L’activation n’a pas abouti. Réessayez dans un instant." + }, + "unavailable": { + "title": "Le fournisseur de cette instance ne répond pas pour le moment.", + "body": "HealthLog reste pleinement utile sans le langage clair. Vous pourrez ajouter votre propre fournisseur plus tard dans les paramètres." + } }, "steps": { "who": "À qui appartient ce dossier", diff --git a/messages/it.json b/messages/it.json index 8713afca9..39910e728 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4558,7 +4558,18 @@ "ladderOauthBody": "Collega un account ChatGPT — nessun costo per token, ma i termini del piano consumer possono usare i contenuti per l'addestramento, quindi è una scelta deliberata, mai l'impostazione predefinita per i tuoi dati sanitari.", "keylessLine": "HealthLog è pienamente utile anche senza IA. Le tue schede, i grafici, i trend e le esportazioni funzionano tutti — l'IA aggiunge solo la lettura in linguaggio chiaro.", "setupCta": "Configuralo (circa un minuto)", - "sharedKeyNote": "Questa istanza fornisce già un provider IA condiviso, quindi gli insight funzionano per te fin da subito — nessuna configurazione necessaria." + "sharedKeyNote": "Questa istanza fornisce già un provider IA condiviso, quindi gli insight funzionano per te fin da subito — nessuna configurazione necessaria.", + "offer": { + "title": "Questa istanza può già scrivere il tuo riepilogo in parole semplici.", + "body": "Il gestore mette a disposizione un fornitore che ha funzionato nelle ultime 24 ore. Con un tocco attivi le parole semplici; i tuoi dati andranno allora a quell’unico fornitore e a nessun altro.", + "cta": "Attiva le parole semplici", + "granted": "Attivato. Il primo riepilogo comparirà sulla tua dashboard.", + "error": "L’attivazione non è riuscita. Riprova tra un momento." + }, + "unavailable": { + "title": "Il fornitore di questa istanza al momento non risponde.", + "body": "HealthLog resta pienamente utile senza le parole semplici. Potrai aggiungere un fornitore tuo più avanti nelle impostazioni." + } }, "steps": { "who": "A chi appartiene questa cartella", diff --git a/messages/ko.json b/messages/ko.json index 6626ae5a4..a6a81dc1c 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4558,7 +4558,18 @@ "ladderOauthBody": "ChatGPT 계정을 연결해요. 토큰당 비용은 없지만 소비자 등급 약관은 내용을 학습에 쓸 수 있어서, 건강 데이터에는 기본값이 아니라 신중히 고를 선택이에요.", "keylessLine": "AI 없이도 HealthLog는 충분히 쓸모 있어요. 타일, 차트, 추이, 내보내기 모두 그대로 작동하고 — AI는 그 위에 쉬운 말 해설을 더할 뿐이에요.", "setupCta": "설정하기 (1분 정도)", - "sharedKeyNote": "이 인스턴스에는 이미 공용 AI 제공자가 있어서 지금 바로 인사이트를 쓸 수 있어요 — 따로 설정할 게 없어요." + "sharedKeyNote": "이 인스턴스에는 이미 공용 AI 제공자가 있어서 지금 바로 인사이트를 쓸 수 있어요 — 따로 설정할 게 없어요.", + "offer": { + "title": "이 인스턴스가 요약을 쉬운 말로 써 줄 수 있습니다.", + "body": "운영자가 제공하는 공급자가 지난 24시간 안에 마지막으로 정상 동작했습니다. 한 번만 누르면 쉬운 말 요약이 켜지며, 그러면 데이터는 그 하나의 공급자에게만 전달되고 다른 어느 곳에도 가지 않습니다.", + "cta": "쉬운 말 요약 켜기", + "granted": "켜졌습니다. 첫 요약은 대시보드에 표시됩니다.", + "error": "켜지지 않았습니다. 잠시 후에 다시 시도해 주세요." + }, + "unavailable": { + "title": "이 인스턴스의 공급자가 지금은 응답하지 않습니다.", + "body": "쉬운 말 요약 없이도 HealthLog는 완전히 쓸모가 있습니다. 나중에 설정에서 직접 공급자를 등록할 수 있습니다." + } }, "steps": { "who": "이 기록의 대상", diff --git a/messages/pl.json b/messages/pl.json index 21e52cc8f..405ec5665 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4558,7 +4558,18 @@ "ladderOauthBody": "Podłącz konto ChatGPT — bez kosztu za token, ale warunki poziomu konsumenckiego mogą wykorzystywać treści do treningu, więc to świadomy wybór, nigdy domyślny dla danych o zdrowiu.", "keylessLine": "HealthLog jest w pełni użyteczny bez AI. Twoje kafelki, wykresy, trendy i eksporty działają — AI dokłada tylko odczyt prostym językiem.", "setupCta": "Skonfiguruj (około minuty)", - "sharedKeyNote": "Ta instancja udostępnia już wspólnego dostawcę AI, więc insighty działają dla Ciebie od razu — bez konfiguracji." + "sharedKeyNote": "Ta instancja udostępnia już wspólnego dostawcę AI, więc insighty działają dla Ciebie od razu — bez konfiguracji.", + "offer": { + "title": "Ta instancja potrafi już napisać twoje podsumowanie zwykłym językiem.", + "body": "Operator udostępnia dostawcę, który działał po raz ostatni w ciągu ostatnich 24 godzin. Jedno dotknięcie włącza zwykły język; twoje dane trafią wtedy do tego jednego dostawcy i do nikogo więcej.", + "cta": "Włącz zwykły język", + "granted": "Włączone. Pierwsze podsumowanie pojawi się na pulpicie.", + "error": "Włączenie się nie powiodło. Spróbuj ponownie za chwilę." + }, + "unavailable": { + "title": "Dostawca tej instancji w tej chwili nie odpowiada.", + "body": "HealthLog jest w pełni użyteczny bez zwykłego języka. Później możesz dodać w ustawieniach własnego dostawcę." + } }, "steps": { "who": "Dla kogo jest ta kartoteka", 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/components/onboarding/__tests__/done-screen.test.tsx b/src/components/onboarding/__tests__/done-screen.test.tsx index 628d87837..eaa9ad780 100644 --- a/src/components/onboarding/__tests__/done-screen.test.tsx +++ b/src/components/onboarding/__tests__/done-screen.test.tsx @@ -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; + }>, +); + vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: aiProviderState.data }), - useMutation: () => ({ mutate: () => {}, isPending: false }), + useMutation: (options?: { + mutationKey?: readonly unknown[]; + mutationFn?: () => Promise; + }) => { + 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>()), + 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(" 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(" 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(); }); }); diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx index 53b78ca92..799a06daf 100644 --- a/src/components/onboarding/done-screen.tsx +++ b/src/components/onboarding/done-screen.tsx @@ -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({ 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 (
)} - {sharedKeyServes ? ( + {consentOnFile && operatorProvides ? (

- {t("onboarding.ai.sharedKeyNote")} + {justGranted + ? t("onboarding.ai.offer.granted") + : t("onboarding.ai.sharedKeyNote")}

) : null} + {offerShared ? ( +
+

+ {t("onboarding.ai.offer.title")} +

+

+ {t("onboarding.ai.offer.body")} +

+ +
+ ) : null} + + {sharedUnavailable ? ( +
+

+ {t("onboarding.ai.unavailable.title")} +

+

+ {t("onboarding.ai.unavailable.body")} +

+
+ ) : null} +

{t("onboarding.ai.ladderTitle")} diff --git a/src/lib/ai/__tests__/server-provider-health.test.ts b/src/lib/ai/__tests__/server-provider-health.test.ts new file mode 100644 index 000000000..7e6c58909 --- /dev/null +++ b/src/lib/ai/__tests__/server-provider-health.test.ts @@ -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", + ]); + }); +}); diff --git a/src/lib/ai/server-provider-health.ts b/src/lib/ai/server-provider-health.ts new file mode 100644 index 000000000..aea613a71 --- /dev/null +++ b/src/lib/ai/server-provider-health.ts @@ -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 { + 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"; +} 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",