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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 12:11:14 +02:00
1 parent 03ecb5bd52
commit 676bb2eff7
23 files changed
+260 -62

No files matched your search

@@ -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(<client>, 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);
},
);
});
@@ -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) => {
+6 -2
View File
@@ -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) => {
@@ -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"),
@@ -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) => {
+7 -1
View File
@@ -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);
},
});
}
+6 -4
View File
@@ -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"),
});
+8 -10
View File
@@ -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);
},
});
@@ -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
@@ -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);
@@ -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);
},
});
@@ -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");
@@ -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);
@@ -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
* (`<LayoutCoachFab>`, `<LayoutCoachMount>`, the inline `<CoachLaunchButton>`
* 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();
},
@@ -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);
@@ -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);
@@ -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);
@@ -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);
@@ -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);
},
});
+6 -2
View File
@@ -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")),
+7 -2
View File
@@ -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(),
@@ -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"),
});
+23
View File
@@ -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