mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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.
121 lines
4.3 KiB
TypeScript
121 lines
4.3 KiB
TypeScript
/**
|
|
* 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);
|
|
},
|
|
);
|
|
});
|