From 676bb2eff775cf795a0f4a1b098fe059d3a946d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 12:11:14 +0200 Subject: [PATCH] fix(ai): refresh the account's AI answer after every change to its inputs Every AI surface reads its capability from the ai block on /api/auth/me, but setting up or removing a provider, signing in to or out of Codex, saving the fallback chain, the central-Codex opt-in, the local-OCR switch, a minted consent receipt, the admin's server key and central Codex, and a guardian's save inside a managed record evicted only their own reads. The surfaces stayed on the old answer until a reload. All of these writes, and the ones that already refreshed the payload (consent, modules, Hide Coach, the operator's switches), now evict one bundle, aiInputDependentKeys. A structural test holds every client write to those endpoints to it. --- .../ai-input-invalidation-guard.test.ts | 120 ++++++++++++++++++ .../admin/ai-server-key-section.tsx | 8 +- src/components/admin/assistant-section.tsx | 8 +- .../admin/central-codex-section.tsx | 8 +- .../admin/module-availability-section.tsx | 8 +- src/components/labs/use-ocr-extract.ts | 8 +- src/components/onboarding/done-screen.tsx | 10 +- .../settings/ai/ai-consent-card.tsx | 18 ++- .../settings/ai/ai-insights-card.tsx | 24 +++- .../settings/ai/anthropic-provider-form.tsx | 5 +- .../settings/ai/central-codex-switch.tsx | 10 +- .../settings/ai/codex-provider-form.tsx | 8 +- .../settings/ai/compat-provider-form.tsx | 5 +- .../settings/ai/disable-coach-card.tsx | 6 +- .../settings/ai/fallback-chain-card.tsx | 4 +- .../settings/ai/local-provider-form.tsx | 5 +- .../settings/ai/openai-provider-form.tsx | 5 +- .../settings/ai/response-timeout-card.tsx | 4 +- .../managed-record-settings-section.tsx | 10 +- src/components/settings/mcp-section.tsx | 8 +- src/components/settings/modules-section.tsx | 9 +- .../use-grant-document-reading-consent.ts | 8 +- src/lib/query-keys/index.ts | 23 ++++ 23 files changed, 260 insertions(+), 62 deletions(-) create mode 100644 src/__tests__/ai-input-invalidation-guard.test.ts diff --git a/src/__tests__/ai-input-invalidation-guard.test.ts b/src/__tests__/ai-input-invalidation-guard.test.ts new file mode 100644 index 000000000..6b890ceed --- /dev/null +++ b/src/__tests__/ai-input-invalidation-guard.test.ts @@ -0,0 +1,120 @@ +/** + * Structural guard: every client write that changes an input of the `ai` + * block on `GET /api/auth/me` evicts it through `aiInputDependentKeys`. + * + * Every AI surface on the web reads its capability from that block. Before + * v1.39 shipped, the provider forms, the Codex sign-in, the fallback chain and + * the central-Codex switch evicted only the provider and insights reads, so a + * freshly configured provider left every AI surface on "not set up" until a + * reload. The fix routes all of them through one bundle; this guard keeps a + * new write site from re-deciding the keys by hand. + * + * The matcher finds write calls (`apiPost/apiPut/apiPatch/apiDelete`, and + * `apiFetch`/`apiFetchRaw` with a write method) whose literal path names one + * of the endpoints below, and asserts the file calls + * `invalidateKeys(, aiInputDependentKeys)`. A tripwire, not a proof: + * it holds the file, not each mutation in it (one mutation can branch over + * two endpoints and evict once), and a path built at runtime would slip the + * matcher, which is why the matched total carries a floor and an empty sweep + * fails. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { aiInputDependentKeys, queryKeys } from "@/lib/query-keys"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** + * The endpoints whose writes change an input of the capability answer. + * Starting a device sign-in changes nothing until the poll reports success, + * so the `device-start` legs are not listed. + */ +const AI_INPUT_ENDPOINTS = [ + "/api/user/ai-provider", + "/api/insights/provider-chain", + "/api/auth/codex/device-poll", + "/api/auth/codex/disconnect", + "/api/auth/me/use-central-codex", + "/api/auth/me/labs-local-ocr", + "/api/consent/ai", + "/api/auth/me/modules", + "/api/auth/me/disable-coach", + "/api/admin/ai-settings", + "/api/admin/settings/assistant-flags", + "/api/admin/settings/module-availability", + "/api/admin/central-codex/device-poll", + "/api/admin/central-codex", + // A managed record's modules / coach / profile families, written by the + // guardian from inside that record. + "/api/record-settings", +] as const; + +const HELPER_WRITE = + /\bapi(?:Post|Put|Patch|Delete)\s*(?:<[^()]*?>)?\s*\(\s*["'`](\/api\/[^"'`?]+)/g; +const RAW_WRITE = + /\bapiFetch(?:Raw)?\s*(?:<[^()]*?>)?\s*\(\s*["'`](\/api\/[^"'`?]+)[^"'`]*["'`]\s*,\s*\{[^}]*?\bmethod:\s*["'](?:POST|PUT|PATCH|DELETE)["']/g; +const BUNDLE_CALL = + /\binvalidateKeys\(\s*[A-Za-z_]\w*\s*,\s*aiInputDependentKeys\b/g; + +function isAiInputEndpoint(path: string): boolean { + if (path.endsWith("/device-start")) return false; + return AI_INPUT_ENDPOINTS.some((e) => path === e || path.startsWith(`${e}/`)); +} + +function inScope(rel: string): boolean { + if (rel.includes("__tests__") || /\.test\.tsx?$/.test(rel)) return false; + if (rel.startsWith("app/api/") || rel.startsWith("generated/")) return false; + return ( + rel.startsWith("components/") || + rel.startsWith("hooks/") || + rel.startsWith("app/") + ); +} + +function writeSites(source: string): string[] { + const out: string[] = []; + for (const re of [HELPER_WRITE, RAW_WRITE]) { + for (const m of source.matchAll(re)) { + if (isAiInputEndpoint(m[1]!)) out.push(m[1]!); + } + } + return out; +} + +const files = walkSourceFiles(SRC, { floor: 3000 }).filter(inScope); +const sites = files + .map((rel) => { + const source = readFileSync(join(SRC, rel), "utf8"); + return { + rel, + writes: writeSites(source), + bundleCalls: [...source.matchAll(BUNDLE_CALL)].length, + }; + }) + .filter((s) => s.writes.length > 0); + +describe("AI-input writes evict the capability answer", () => { + it("the bundle carries the account payload every AI surface reads", () => { + expect(aiInputDependentKeys).toContainEqual(queryKeys.authMe()); + }); + + it("finds the write sites it is meant to hold (an empty sweep is a broken matcher)", () => { + const total = sites.reduce((n, s) => n + s.writes.length, 0); + expect(sites.length).toBeGreaterThanOrEqual(21); + expect(total).toBeGreaterThanOrEqual(22); + }); + + it.each(sites.map((s) => [s.rel, s] as const))( + "%s invalidates aiInputDependentKeys", + (_rel, site) => { + expect( + site.bundleCalls, + `writes to ${site.writes.join(", ")}`, + ).toBeGreaterThanOrEqual(1); + }, + ); +}); diff --git a/src/components/admin/ai-server-key-section.tsx b/src/components/admin/ai-server-key-section.tsx index d9f0e6f1f..71799e308 100644 --- a/src/components/admin/ai-server-key-section.tsx +++ b/src/components/admin/ai-server-key-section.tsx @@ -14,7 +14,11 @@ import { Label } from "@/components/ui/label"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; import { PasswordInput } from "./_shared"; @@ -74,6 +78,8 @@ export function AiServerKeySection() { queryClient.invalidateQueries({ queryKey: queryKeys.adminAiServerKey(), }); + // The server key is a provider input of the admin's own AI answer. + void invalidateKeys(queryClient, aiInputDependentKeys); toast.success(t("admin.aiServerKey.saved")); }, onError: (error: unknown) => { diff --git a/src/components/admin/assistant-section.tsx b/src/components/admin/assistant-section.tsx index 6181a04ed..11872bbb9 100644 --- a/src/components/admin/assistant-section.tsx +++ b/src/components/admin/assistant-section.tsx @@ -7,7 +7,11 @@ import { toast } from "sonner"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { SettingsToggle } from "./_shared"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; @@ -74,7 +78,7 @@ function useUpdateAssistantFlags() { // Every record's resolved AI capabilities ride the account payload, // which is the only thing the web reads them from; bust it so the // operator sees the change within the session. - client.invalidateQueries({ queryKey: queryKeys.authMe() }); + void invalidateKeys(client, aiInputDependentKeys); toast.success(t("common.saved")); }, onError: (err) => { diff --git a/src/components/admin/central-codex-section.tsx b/src/components/admin/central-codex-section.tsx index 68b582074..f79daedf4 100644 --- a/src/components/admin/central-codex-section.tsx +++ b/src/components/admin/central-codex-section.tsx @@ -30,7 +30,11 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; interface CentralCodexStatus { status: string; @@ -100,6 +104,7 @@ export function CentralCodexSection() { queryClient.invalidateQueries({ queryKey: queryKeys.adminCentralCodex(), }); + void invalidateKeys(queryClient, aiInputDependentKeys); return; } if (!cancelled) setTimeout(tick, intervalMs); @@ -135,6 +140,7 @@ export function CentralCodexSection() { queryClient.invalidateQueries({ queryKey: queryKeys.adminCentralCodex(), }); + void invalidateKeys(queryClient, aiInputDependentKeys); } catch (err) { toast.error( err instanceof Error ? err.message : t("admin.centralCodex.error"), diff --git a/src/components/admin/module-availability-section.tsx b/src/components/admin/module-availability-section.tsx index 7e1f8513a..91648eb22 100644 --- a/src/components/admin/module-availability-section.tsx +++ b/src/components/admin/module-availability-section.tsx @@ -8,7 +8,11 @@ import { toast } from "sonner"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { MODULE_KEYS, MODULE_REGISTRY, @@ -66,7 +70,7 @@ function useUpdateModuleAvailability() { client.setQueryData(queryKeys.adminModuleAvailability(), data); // The resolved `/api/auth/me` module map depends on this operator // layer; bust it so the operator sees the change within the session. - client.invalidateQueries({ queryKey: queryKeys.authMe() }); + void invalidateKeys(client, aiInputDependentKeys); toast.success(t("common.saved")); }, onError: (err) => { diff --git a/src/components/labs/use-ocr-extract.ts b/src/components/labs/use-ocr-extract.ts index 5748cdac6..999e00f9a 100644 --- a/src/components/labs/use-ocr-extract.ts +++ b/src/components/labs/use-ocr-extract.ts @@ -16,7 +16,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiFetch, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; import { ocrImageToText } from "@/lib/labs/local-ocr"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import type { OcrCapabilityDto, OcrCommitResponseDto, @@ -149,6 +153,8 @@ export function useUpdateLabsLocalOcr() { queryClient.setQueryData(queryKeys.labsLocalOcr(), data); // The toggle changes whether text-mode scanning is available. queryClient.invalidateQueries({ queryKey: queryKeys.ocrCapability() }); + // It is also a provider input of the `labsOcr` capability on `/me`. + void invalidateKeys(queryClient, aiInputDependentKeys); }, }); } diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx index 488ce1165..02462c2ba 100644 --- a/src/components/onboarding/done-screen.tsx +++ b/src/components/onboarding/done-screen.tsx @@ -25,7 +25,11 @@ import { useOnboardingAnswer } from "@/components/onboarding/use-onboarding-flow 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 { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage"; @@ -162,9 +166,7 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) { }, onSuccess: async () => { setJustGranted(true); - await queryClient.invalidateQueries({ - queryKey: queryKeys.userAiProvider(), - }); + await invalidateKeys(queryClient, aiInputDependentKeys); await queryClient.invalidateQueries({ queryKey: queryKeys.aiConsentReceipt("ai_full"), }); diff --git a/src/components/settings/ai/ai-consent-card.tsx b/src/components/settings/ai/ai-consent-card.tsx index 7f3050ce1..0741df1cc 100644 --- a/src/components/settings/ai/ai-consent-card.tsx +++ b/src/components/settings/ai/ai-consent-card.tsx @@ -39,7 +39,11 @@ import { QueryErrorRow } from "@/components/ui/query-error-row"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; type ConsentReceiptWire = { id: string; @@ -86,10 +90,7 @@ export function AiConsentCard({ await queryClient.invalidateQueries({ queryKey: queryKeys.aiConsentReceipt("ai_full"), }); - await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); - await queryClient.invalidateQueries({ - queryKey: queryKeys.insightsProviderChain(), - }); + await invalidateKeys(queryClient, aiInputDependentKeys); }, }); @@ -110,10 +111,7 @@ export function AiConsentCard({ await queryClient.invalidateQueries({ queryKey: queryKeys.aiConsentReceipt("ai_full"), }); - await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); - await queryClient.invalidateQueries({ - queryKey: queryKeys.insightsProviderChain(), - }); + await invalidateKeys(queryClient, aiInputDependentKeys); }, }); @@ -292,7 +290,7 @@ function DocumentReadingConsentRow({ await queryClient.invalidateQueries({ queryKey: queryKeys.aiConsentReceipt("ai_extraction"), }); - await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + await invalidateKeys(queryClient, aiInputDependentKeys); }, }); diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx index 7fdc3ccf8..9daea5ea4 100644 --- a/src/components/settings/ai/ai-insights-card.tsx +++ b/src/components/settings/ai/ai-insights-card.tsx @@ -28,7 +28,11 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { AdminOpenAIProviderForm } from "./admin-openai-provider-form"; import { AiConsentCard } from "./ai-consent-card"; @@ -127,10 +131,20 @@ export function AiInsightsCard({ // settings UI. Runs once per mount of the AI-settings surface. useEffect(() => { if (!isAuthenticated) return; - void apiFetchRaw("/api/consent/ai/web", { method: "POST" }).catch(() => { - /* best-effort heal — the explicit grant path stays available */ - }); - }, [isAuthenticated]); + // A heal that minted a receipt changed an input of the account's AI + // capability answer, so `/me` re-resolves; a no-op heal leaves it alone. + void apiFetchRaw("/api/consent/ai/web", { method: "POST" }) + .then(async (res) => { + if (!res.ok) return; + const body = (await res.json()) as { data?: { minted?: boolean } }; + if (body.data?.minted) { + void invalidateKeys(queryClient, aiInputDependentKeys); + } + }) + .catch(() => { + /* best-effort heal — the explicit grant path stays available */ + }); + }, [isAuthenticated, queryClient]); // The Select is URL-driven so the SSR test can pick the branch and // a deep link works. Default = `?provider=…` query param when the diff --git a/src/components/settings/ai/anthropic-provider-form.tsx b/src/components/settings/ai/anthropic-provider-form.tsx index acd79cab2..122635e67 100644 --- a/src/components/settings/ai/anthropic-provider-form.tsx +++ b/src/components/settings/ai/anthropic-provider-form.tsx @@ -15,7 +15,7 @@ import { NativeSelect } from "@/components/ui/native-select"; import { PasswordInput } from "@/components/ui/password-input"; import { apiPatch } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; import { ANTHROPIC_MODEL_PRESETS, @@ -79,8 +79,7 @@ export function AnthropicProviderForm({ setOk(true); setMsg(t("settings.ai.saved")); setApiKey(""); - queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() }); - queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + void invalidateKeys(queryClient, aiInputDependentKeys); }, onError: (e) => { setOk(false); diff --git a/src/components/settings/ai/central-codex-switch.tsx b/src/components/settings/ai/central-codex-switch.tsx index 9e2806825..010366b03 100644 --- a/src/components/settings/ai/central-codex-switch.tsx +++ b/src/components/settings/ai/central-codex-switch.tsx @@ -23,7 +23,7 @@ import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { apiPatch } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; import type { InsightsSettings } from "./shared"; @@ -51,11 +51,9 @@ export function CentralCodexSwitch({ }), onSuccess: () => { // The opt-in changes which providers can serve this user — refresh the - // settings summary (which carries the flag) and every insight read. - queryClient.invalidateQueries({ - queryKey: queryKeys.insightsSettings(), - }); - queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + // settings summary (which carries the flag), the account's AI + // capability answer, and every insight read. + void invalidateKeys(queryClient, aiInputDependentKeys); setPendingEnable(false); }, }); diff --git a/src/components/settings/ai/codex-provider-form.tsx b/src/components/settings/ai/codex-provider-form.tsx index b53ed9525..2aafecff3 100644 --- a/src/components/settings/ai/codex-provider-form.tsx +++ b/src/components/settings/ai/codex-provider-form.tsx @@ -14,7 +14,7 @@ import { ConfirmButton } from "@/components/ui/confirm-button"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; import type { InsightsSettings } from "./shared"; @@ -55,7 +55,7 @@ export function CodexProviderForm({ url.searchParams.delete("codex_error"); window.history.replaceState({}, "", url.toString()); if (oauthOutcome.kind === "connected") { - queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + void invalidateKeys(queryClient, aiInputDependentKeys); } }, [oauthOutcome, queryClient]); @@ -112,7 +112,7 @@ export function CodexProviderForm({ setDevicePolling(false); setMsg(t("settings.codexConnected")); setMsgType("success"); - queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + void invalidateKeys(queryClient, aiInputDependentKeys); return; } if (!cancelled) setTimeout(tick, intervalMs); @@ -151,7 +151,7 @@ export function CodexProviderForm({ } setMsg(t("settings.codexDisconnected")); setMsgType("success"); - queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + void invalidateKeys(queryClient, aiInputDependentKeys); } catch (err) { setMsg(err instanceof Error ? err.message : t("settings.savingError")); setMsgType("error"); diff --git a/src/components/settings/ai/compat-provider-form.tsx b/src/components/settings/ai/compat-provider-form.tsx index a91e444a7..d584c10df 100644 --- a/src/components/settings/ai/compat-provider-form.tsx +++ b/src/components/settings/ai/compat-provider-form.tsx @@ -17,7 +17,7 @@ import { Label } from "@/components/ui/label"; import { PasswordInput } from "@/components/ui/password-input"; import { apiPatch } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; import { uiToLegacyProviderEnum, type UserAIProvider } from "./shared"; @@ -63,8 +63,7 @@ export function CompatProviderForm({ setOk(true); setMsg(t("settings.ai.saved")); setApiKey(""); - queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() }); - queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + void invalidateKeys(queryClient, aiInputDependentKeys); }, onError: (e) => { setOk(false); diff --git a/src/components/settings/ai/disable-coach-card.tsx b/src/components/settings/ai/disable-coach-card.tsx index b75aa198d..4adad60a7 100644 --- a/src/components/settings/ai/disable-coach-card.tsx +++ b/src/components/settings/ai/disable-coach-card.tsx @@ -15,7 +15,7 @@ * a pure UI inversion over the same column. * * Persists via `PATCH /api/auth/me/disable-coach`; the response invalidates - * `queryKeys.authMe()` so every Coach mount point on the client + * `aiInputDependentKeys` (the account payload first) so every Coach mount point on the client * (``, ``, the inline `` * pill, the `/targets` page CTAs) re-renders with the new `user.disableCoach` * value on the next React Query refetch tick — no full reload required. @@ -39,7 +39,7 @@ import { apiPatch } from "@/lib/api/api-fetch"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; export function DisableCoachCard({ isAuthenticated, @@ -115,7 +115,7 @@ export function DisableCoachCard({ // Surface the new value to every Coach gate via the shared // `useAuth()` query — the gates re-render once React Query // re-fetches /api/auth/me with the updated `disableCoach` field. - queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + void invalidateKeys(queryClient, aiInputDependentKeys); setOptimistic(null); scheduleClear(); }, diff --git a/src/components/settings/ai/fallback-chain-card.tsx b/src/components/settings/ai/fallback-chain-card.tsx index 0631a8f1f..388a04b1a 100644 --- a/src/components/settings/ai/fallback-chain-card.tsx +++ b/src/components/settings/ai/fallback-chain-card.tsx @@ -33,7 +33,7 @@ import { NativeSelect } from "@/components/ui/native-select"; import { Switch } from "@/components/ui/switch"; import { apiPut } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; import { DEFAULT_CHAIN, @@ -99,7 +99,7 @@ export function FallbackChainCard({ onSuccess: () => { setOk(true); setMsg(t("settings.ai.providerChain.saved")); - queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + void invalidateKeys(queryClient, aiInputDependentKeys); }, onError: (e) => { setOk(false); diff --git a/src/components/settings/ai/local-provider-form.tsx b/src/components/settings/ai/local-provider-form.tsx index 725c02dd0..63c1c5c68 100644 --- a/src/components/settings/ai/local-provider-form.tsx +++ b/src/components/settings/ai/local-provider-form.tsx @@ -15,7 +15,7 @@ import { NativeSelect } from "@/components/ui/native-select"; import { PasswordInput } from "@/components/ui/password-input"; import { apiPatch } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; import { CUSTOM_MODEL_SENTINEL, @@ -79,8 +79,7 @@ export function LocalProviderForm({ setOk(true); setMsg(t("settings.ai.saved")); setApiKey(""); - queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() }); - queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + void invalidateKeys(queryClient, aiInputDependentKeys); }, onError: (e) => { setOk(false); diff --git a/src/components/settings/ai/openai-provider-form.tsx b/src/components/settings/ai/openai-provider-form.tsx index 92bf3ea8c..84c5f63de 100644 --- a/src/components/settings/ai/openai-provider-form.tsx +++ b/src/components/settings/ai/openai-provider-form.tsx @@ -18,7 +18,7 @@ import { NativeSelect } from "@/components/ui/native-select"; import { PasswordInput } from "@/components/ui/password-input"; import { apiPatch } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; import { CUSTOM_MODEL_SENTINEL, @@ -86,8 +86,7 @@ export function OpenAIProviderForm({ setOk(true); setMsg(t("settings.ai.saved")); setApiKey(""); - queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() }); - queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + void invalidateKeys(queryClient, aiInputDependentKeys); }, onError: (e) => { setOk(false); diff --git a/src/components/settings/ai/response-timeout-card.tsx b/src/components/settings/ai/response-timeout-card.tsx index 874479ecc..c471694e3 100644 --- a/src/components/settings/ai/response-timeout-card.tsx +++ b/src/components/settings/ai/response-timeout-card.tsx @@ -20,7 +20,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { apiPatch } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; import type { UserAIProvider } from "./shared"; @@ -69,7 +69,7 @@ export function ResponseTimeoutCard({ onSuccess: () => { setOk(true); setMsg(t("settings.ai.saved")); - queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() }); + void invalidateKeys(queryClient, aiInputDependentKeys); }, onError: (e) => { setOk(false); diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx index e612daae2..72639967c 100644 --- a/src/components/settings/managed-record-settings-section.tsx +++ b/src/components/settings/managed-record-settings-section.tsx @@ -12,7 +12,11 @@ import { type InsightsSectionId, } from "@/lib/insights-layout"; import { MODULE_REGISTRY } from "@/lib/modules/registry"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { assertRecordSettingsResponseForRecord, MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS, @@ -1074,6 +1078,10 @@ export function ManagedRecordSettingsSection({ queryKeys.recordSettingsFamily(recordId, family), response, ); + // The record's module map, Hide Coach and profile (gender gates the + // cycle module) are inputs of the `/me` answer for this record, its AI + // capabilities included. + void invalidateKeys(queryClient, aiInputDependentKeys); }, }); diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index bbe832620..bbe1c063d 100644 --- a/src/components/settings/mcp-section.tsx +++ b/src/components/settings/mcp-section.tsx @@ -44,7 +44,11 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useAuth } from "@/hooks/use-auth"; import { formatDate, formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { apiFetchRaw, apiGet, apiPatch } from "@/lib/api/api-fetch"; interface McpTokenInfo { @@ -211,7 +215,7 @@ function McpEnableCard() { mutationFn: async (next: boolean) => apiPatch("/api/auth/me/modules", { mcp: next }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + void invalidateKeys(queryClient, aiInputDependentKeys); toast.success(t("settings.sections.modules.saved")); }, onError: () => toast.error(t("settings.sections.modules.error")), diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index f69ec9b98..f21a8efa2 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -71,7 +71,11 @@ import { withBaseToken, isConflict, } from "@/lib/api/optimistic-token"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { MODULE_REGISTRY, isCodeDisabledModule, @@ -170,7 +174,8 @@ export function ModulesSection() { // Insights pills, dashboard tiles, quick-add, search. The two delegated // keys also feed dedicated settings surfaces, so evict their reads too // (mirrors the canonical Coach / cycle cards' own invalidation). - void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + // The module map is also an input of the AI capability answer. + void invalidateKeys(queryClient, aiInputDependentKeys); if (moduleDelegatesTo(vars.key) === "cycle") { void queryClient.invalidateQueries({ queryKey: queryKeys.cyclePrefs(), diff --git a/src/hooks/use-grant-document-reading-consent.ts b/src/hooks/use-grant-document-reading-consent.ts index b43f673b9..988b4312f 100644 --- a/src/hooks/use-grant-document-reading-consent.ts +++ b/src/hooks/use-grant-document-reading-consent.ts @@ -3,7 +3,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiPost } from "@/lib/api/api-fetch"; -import { queryKeys } from "@/lib/query-keys"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; /** * Grant the consent for reading documents (`ai_extraction`) from the place @@ -38,7 +42,7 @@ export function useGrantDocumentReadingConsent() { }); }, onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + void invalidateKeys(queryClient, aiInputDependentKeys); void queryClient.invalidateQueries({ queryKey: queryKeys.aiConsentReceipt("ai_extraction"), }); diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 3fc184256..ed83c7676 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -302,6 +302,29 @@ export const vaccinationDependentKeys = [ queryKeys.measurementReminders(), ]; +/** + * Keys invalidated when an input of the `ai` block on `GET /api/auth/me` + * changes: the account's own provider (key, local model, compatible endpoint, + * Codex sign-in), the fallback chain, the central-Codex opt-in, a consent + * receipt granted or withdrawn, a module switch, Hide Coach, and, for the + * admin's own session, the operator's assistant switches, server key, central + * Codex and module availability. + * + * Every AI surface reads its capability from that block, so a write that + * changes an input without evicting `authMe()` leaves the surface on the old + * answer: a freshly configured provider stays "not set up" until a reload, a + * removed one keeps offering AI work the server then refuses. The provider and + * insights roots ride along because they render the same state in detail. + * + * `ai-input-invalidation-guard.test.ts` holds every client write to one of + * those endpoints to this bundle. + */ +export const aiInputDependentKeys = [ + queryKeys.authMe(), + queryKeys.userAiProvider(), + queryKeys.insightsRoot(), +]; + /** * Invalidate every key in the bundle in parallel. Use this from mutation * `onSuccess` handlers so the call site stays a one-liner instead of repeating