mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Settings now reads as nine groups: Account, Tracking, Arrangement, Notifications, Health profile, AI, Connections, Data and System. On a phone one button names the current section and opens the grouped list, replacing the strip of every section. What moved, every old address still working: - Sessions, trusted devices and security activity sit under Security; the privacy page anchors forward there. - Notifications holds every reminder kind: the Coach nudges and the checkup reminder switches joined, and a card names the reminders set where their subject lives (dose times, cycle reminders). - Arrangement holds order and view only. Mood tags, the labs biomarker catalog and on-device text recognition, and the injection sites live on module pages under /settings/modules/<id>, linked from each Modules row and from the module's own wrench. /settings/layout/mood redirects. - The timezone saves on change in the Display card, with a one-tap offer of the device zone. - Screening hints sit beside the life circumstances they come from. - System gains Preview features with the Topics switch. Duplicates: the Coach and MCP pages show their state and link to the one switch in Modules (turning MCP off keeps tokens), the Coach overlay shows the provider and model with a link to Settings > AI instead of a second set of selects, and the privacy page drops its copy of the export links. A guard walks every historical /settings address through the redirects and every forwarded anchor to a page that renders.
125 lines
4.6 KiB
TypeScript
125 lines
4.6 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);
|
|
// v1.44 — three write sites left on purpose: the Coach's second switch
|
|
// and the MCP page's second switch (the one switch is in Modules), and
|
|
// the Coach overlay's copy of the provider and model selects (chosen in
|
|
// Settings > AI only).
|
|
expect(sites.length).toBeGreaterThanOrEqual(19);
|
|
expect(total).toBeGreaterThanOrEqual(19);
|
|
});
|
|
|
|
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);
|
|
},
|
|
);
|
|
});
|