mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
refactor(web): retire the feature-flag reader for the capability map
The web no longer reads GET /api/feature-flags: useFeatureFlags and its query key are gone, and use-ai-capability gains the two readers the Coach pages and the operator notice need. A structural guard fails when web code reads the retired projection, and when a module requests an AI route without being registered with the capability that gates it. Adds the copy for every new state in all seven locales and drops the per-card no-provider strings.
This commit is contained in:
1 parent
3d85445e50
commit
5f6a0e42b4
14 files changed
+621
-286
No files matched your search
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Every web request to an AI route is behind the `ai` capability map, and no
|
||||
* web code reads the retired switch projection.
|
||||
*
|
||||
* Since v1.39 the web renders every AI surface from one answer, the `ai`
|
||||
* block on `GET /api/auth/me`, read through `useAiCapability`. Two ways back
|
||||
* to the old behaviour are frozen here:
|
||||
*
|
||||
* 1. Reading `GET /api/feature-flags` (or the deleted `useFeatureFlags`
|
||||
* hook). The projection knew only the operator's switches, not the
|
||||
* person's opt-out, the provider or the consent, and it failed open, so
|
||||
* AI chrome flashed and fired refusals before the switch set arrived.
|
||||
* 2. A request to an AI route from a module nobody gates. Each such module
|
||||
* is on `AI_REQUEST_SITES` with the capability that decides it and the
|
||||
* file that reads that capability; the guard proves the file does read
|
||||
* it. A new module that requests an AI route without an entry fails by
|
||||
* name.
|
||||
*
|
||||
* Matchers are whitespace-tolerant (`useAiCapability ( "coach" )` counts) and
|
||||
* run on comment-stripped source, so prose naming a route or the hook is not
|
||||
* a hit. Each discovery asserts a non-zero count: a sweep that finds nothing
|
||||
* would agree with every allowlist, so an empty match set fails instead.
|
||||
*
|
||||
* Its honest limit: it recognises a request by the route literal. A route
|
||||
* built from pieces (`"/api/insights/" + name`) or read from a constant in
|
||||
* another module would slip it; `AI_ROUTE_PATTERNS` lists the literal shapes
|
||||
* the tree uses today.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AiCapabilityKey } from "@/lib/ai/capabilities/types";
|
||||
|
||||
import { stripComments, walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = path.resolve(__dirname, "..");
|
||||
|
||||
const NON_TEST_FILES = walkSourceFiles(SRC, { floor: 1500 }).filter(
|
||||
(rel) =>
|
||||
!rel.startsWith("app/api/") &&
|
||||
!rel.startsWith("generated/") &&
|
||||
!rel.includes("__tests__/") &&
|
||||
!/\.test\.tsx?$/.test(rel),
|
||||
);
|
||||
|
||||
/**
|
||||
* Web code for the request sweep: pages, components, hooks. `lib/` is left
|
||||
* out of that sweep because it mixes server modules (the jobs and route
|
||||
* helpers name the same routes) with client ones; the retired-projection
|
||||
* check below walks it too, minus the switch loader and the API contract,
|
||||
* which describe the route rather than read it.
|
||||
*/
|
||||
const CLIENT_FILES = NON_TEST_FILES.filter((rel) => !rel.startsWith("lib/"));
|
||||
|
||||
const FEATURE_FLAG_SWEEP = NON_TEST_FILES.filter(
|
||||
(rel) =>
|
||||
!rel.startsWith("lib/feature-flags/") && !rel.startsWith("lib/openapi/"),
|
||||
);
|
||||
|
||||
const read = (rel: string) =>
|
||||
stripComments(readFileSync(path.join(SRC, rel), "utf8"));
|
||||
|
||||
/** Request literals of routes that call a model or serve model text. */
|
||||
const AI_ROUTE_PATTERNS: readonly RegExp[] = [
|
||||
/["'`]\/api\/insights\/generate["'`?]/,
|
||||
/["'`]\/api\/insights\/\$\{\s*metric\s*\}-status/,
|
||||
/["'`]\/api\/insights\/metric-status\?/,
|
||||
/["'`]\/api\/insights\/biomarker-assessment\?/,
|
||||
/["'`]\/api\/insights\/medication-compliance-status\?/,
|
||||
/["'`]\/api\/insights\/coach\/nudge-status["'`]/,
|
||||
/["'`]\/api\/insights\/chat["'`]/,
|
||||
/["'`]\/api\/insights\/chat\/fenced["'`]/,
|
||||
/["'`]\/api\/medications\/extract["'`]/,
|
||||
/["'`]\/api\/labs\/ocr\/capability["'`]/,
|
||||
/["'`]\/api\/documents\/inbound\/capability["'`]/,
|
||||
/["'`]\/api\/ai\/test["'`]/,
|
||||
];
|
||||
|
||||
interface RequestSite {
|
||||
capability: AiCapabilityKey | null;
|
||||
/** The file that reads the capability and keeps the request from firing. */
|
||||
gate: string;
|
||||
/** Why, when `capability` is null. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every client module that requests an AI route, keyed by path under src/.
|
||||
* Adding a request site means adding it here with the capability that decides
|
||||
* it; the guard then checks the gate file really reads that capability.
|
||||
*/
|
||||
const AI_REQUEST_SITES: Record<string, RequestSite> = {
|
||||
"components/insights/use-insights-advisor.ts": {
|
||||
capability: "briefing",
|
||||
gate: "components/insights/use-insights-advisor.ts",
|
||||
},
|
||||
"components/settings/ai/runtime-actions-row.tsx": {
|
||||
capability: "briefing",
|
||||
gate: "components/settings/ai/runtime-actions-row.tsx",
|
||||
},
|
||||
"hooks/use-insight-status.ts": {
|
||||
capability: "statusText",
|
||||
gate: "hooks/use-insight-status.ts",
|
||||
},
|
||||
"app/insights/medications/page.tsx": {
|
||||
capability: "statusText",
|
||||
gate: "app/insights/medications/page.tsx",
|
||||
},
|
||||
"components/insights/layout-coach-fab.tsx": {
|
||||
capability: "coach",
|
||||
gate: "components/insights/layout-coach-fab.tsx",
|
||||
},
|
||||
"app/coach/page-client.tsx": {
|
||||
capability: "coach",
|
||||
gate: "app/coach/page-client.tsx",
|
||||
},
|
||||
// The chat hooks run only inside the drawer, which mounts only while the
|
||||
// Coach is available, and on /coach, which leaves while it is not.
|
||||
"components/insights/coach-panel/use-coach.ts": {
|
||||
capability: "coach",
|
||||
gate: "components/insights/layout-coach-mount.tsx",
|
||||
},
|
||||
"components/medications/scheduling/natural-language-extractor.tsx": {
|
||||
capability: "medicationExtract",
|
||||
gate: "components/medications/wizard/medication-wizard-dialog.tsx",
|
||||
},
|
||||
"components/labs/use-ocr-extract.ts": {
|
||||
capability: "labsOcr",
|
||||
gate: "app/labs/page.tsx",
|
||||
},
|
||||
"components/documents/use-document-assist.ts": {
|
||||
capability: "documentAi",
|
||||
gate: "components/documents/document-detail-sheet.tsx",
|
||||
},
|
||||
};
|
||||
|
||||
/** Requests to AI routes that no capability decides, with the reason. */
|
||||
const UNGATED_BY_DESIGN: Record<string, string> = {
|
||||
// `POST /api/ai/test` checks a provider before anything can be on; the
|
||||
// route answers the operator's master switch itself with a typed refusal.
|
||||
"components/settings/ai/runtime-actions-row.tsx:/api/ai/test":
|
||||
"the connection test is how a provider gets checked",
|
||||
};
|
||||
|
||||
function readsCapability(source: string, key: AiCapabilityKey): boolean {
|
||||
return new RegExp(
|
||||
`useAiCapability(?:Answer)?\\s*\\(\\s*["']${key}["']\\s*\\)`,
|
||||
).test(source);
|
||||
}
|
||||
|
||||
describe("AI surfaces read the capability map", () => {
|
||||
it("walks the web tree", () => {
|
||||
expect(CLIENT_FILES.length).toBeGreaterThan(800);
|
||||
});
|
||||
|
||||
it("no web code reads GET /api/feature-flags or the retired hook", () => {
|
||||
expect(FEATURE_FLAG_SWEEP.length).toBeGreaterThan(1500);
|
||||
const offenders = FEATURE_FLAG_SWEEP.filter((rel) => {
|
||||
const src = read(rel);
|
||||
return (
|
||||
/["'`]\/api\/feature-flags/.test(src) ||
|
||||
/["']@\/hooks\/use-feature-flags["']/.test(src) ||
|
||||
/\buseFeatureFlags\s*\(/.test(src)
|
||||
);
|
||||
});
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("every request to an AI route comes from a registered, gated module", () => {
|
||||
const discovered = CLIENT_FILES.filter((rel) => {
|
||||
const src = read(rel);
|
||||
return AI_ROUTE_PATTERNS.some((pattern) => pattern.test(src));
|
||||
});
|
||||
// A sweep that finds nothing agrees with every registry.
|
||||
expect(discovered.length).toBeGreaterThanOrEqual(8);
|
||||
|
||||
const unregistered = discovered.filter((rel) => !(rel in AI_REQUEST_SITES));
|
||||
expect(
|
||||
unregistered,
|
||||
"Module(s) request an AI route but are not on AI_REQUEST_SITES",
|
||||
).toEqual([]);
|
||||
|
||||
const stale = Object.keys(AI_REQUEST_SITES).filter(
|
||||
(rel) => !discovered.includes(rel),
|
||||
);
|
||||
expect(stale, "AI_REQUEST_SITES entries that no longer request").toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("each registered gate reads the capability it names", () => {
|
||||
const missing = Object.entries(AI_REQUEST_SITES)
|
||||
.filter(([, site]) => site.capability !== null)
|
||||
.filter(([, site]) => !readsCapability(read(site.gate), site.capability!))
|
||||
.map(([rel, site]) => `${rel} -> ${site.gate} (${site.capability})`);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("the ungated exceptions are real", () => {
|
||||
for (const key of Object.keys(UNGATED_BY_DESIGN)) {
|
||||
const [rel, route] = key.split(":");
|
||||
expect(read(rel)).toContain(route);
|
||||
}
|
||||
});
|
||||
|
||||
it("the account payload's ai block has web readers beyond the hook", () => {
|
||||
const readers = CLIENT_FILES.filter((rel) =>
|
||||
/useAiCapability(?:Answer|Map)?\s*\(/.test(read(rel)),
|
||||
).filter((rel) => rel !== "hooks/use-ai-capability.ts");
|
||||
expect(readers.length).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
});
|
||||
@@ -307,6 +307,11 @@ const PINNED_AFFORDANCES: Record<
|
||||
"text-success": 5,
|
||||
"toast.success": 1,
|
||||
},
|
||||
// The delete-with-undo toast, the same one the conversation list and the
|
||||
// history rail raise for the same delete.
|
||||
"src/components/settings/coach-conversations-memory-card.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/settings/coach-memory-section.tsx": { "toast.success": 2 },
|
||||
"src/components/settings/coach-prefs-section.tsx": { "toast.success": 1 },
|
||||
"src/components/settings/dashboard-layout-section.tsx": {
|
||||
|
||||
@@ -26,8 +26,6 @@ const REQUIRED_KEYS = [
|
||||
// Existing terminal contracts reused by the final v1.34.1 call sites.
|
||||
"insights.assessmentPreparing",
|
||||
"insights.noAnalysisYet",
|
||||
"insights.noProviderConfigured",
|
||||
"insights.noProviderAction",
|
||||
"common.loadFailed",
|
||||
"common.networkError",
|
||||
"settings.googleHealthSyncResult",
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { DEFAULT_ASSISTANT_FLAGS, useFeatureFlags } from "../use-feature-flags";
|
||||
|
||||
function Probe() {
|
||||
const flags = useFeatureFlags();
|
||||
return <pre data-testid="flags">{JSON.stringify(flags)}</pre>;
|
||||
}
|
||||
|
||||
function htmlEscape(s: string): string {
|
||||
return s.replace(/"/g, """);
|
||||
}
|
||||
|
||||
describe("useFeatureFlags", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns the all-on default when no QueryClient is mounted", () => {
|
||||
const html = renderToStaticMarkup(<Probe />);
|
||||
expect(html).toContain(htmlEscape(JSON.stringify(DEFAULT_ASSISTANT_FLAGS)));
|
||||
});
|
||||
|
||||
it("returns the all-on default while the query is still loading", () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0 } },
|
||||
});
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<Probe />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(html).toContain(htmlEscape(JSON.stringify(DEFAULT_ASSISTANT_FLAGS)));
|
||||
});
|
||||
|
||||
it("returns the resolved matrix when the endpoint responds", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
assistant: {
|
||||
enabled: true,
|
||||
coach: false,
|
||||
briefing: true,
|
||||
insightStatus: false,
|
||||
documentAi: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0 } },
|
||||
});
|
||||
// Pre-populate so the SSR pass surfaces the cached payload
|
||||
// synchronously without a fetch round-trip.
|
||||
client.setQueryData(["feature-flags"], {
|
||||
assistant: {
|
||||
enabled: true,
|
||||
coach: false,
|
||||
briefing: true,
|
||||
insightStatus: false,
|
||||
documentAi: true,
|
||||
},
|
||||
});
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<Probe />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(html).toContain(""coach":false");
|
||||
expect(html).toContain(""insightStatus":false");
|
||||
});
|
||||
});
|
||||
@@ -63,3 +63,48 @@ function useAiProviderStateInner(): AiProviderState {
|
||||
const { user } = useAuth();
|
||||
return user?.ai?.provider ?? NO_PROVIDER_STATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's answer for one capability, or `null` while there is none yet
|
||||
* (`/me` loading, no signed-in account, no query client). For the few places
|
||||
* that act on an unavailable capability rather than just hiding: a Coach page
|
||||
* that sends its visitor elsewhere must not do so on the loading frame, when
|
||||
* `useAiCapability` already reads unavailable.
|
||||
*/
|
||||
export function useAiCapabilityAnswer(
|
||||
key: AiCapabilityKey,
|
||||
): AiCapabilityState | null {
|
||||
const hasClient = useQueryClientMounted();
|
||||
if (!hasClient) return null;
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
return useAiCapabilityAnswerInner(key);
|
||||
}
|
||||
|
||||
function useAiCapabilityAnswerInner(
|
||||
key: AiCapabilityKey,
|
||||
): AiCapabilityState | null {
|
||||
const { user, isLoading } = useAuth();
|
||||
if (isLoading || !user) return null;
|
||||
return user.ai?.capabilities?.[key] ?? UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every capability at once, or `null` until `/me` has answered. For the one
|
||||
* surface that explains the whole set (the operator notice in Settings → AI);
|
||||
* a surface that shows or hides one feature reads `useAiCapability`.
|
||||
*/
|
||||
export function useAiCapabilityMap(): Partial<
|
||||
Record<AiCapabilityKey, AiCapabilityState>
|
||||
> | null {
|
||||
const hasClient = useQueryClientMounted();
|
||||
if (!hasClient) return null;
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
return useAiCapabilityMapInner();
|
||||
}
|
||||
|
||||
function useAiCapabilityMapInner(): Partial<
|
||||
Record<AiCapabilityKey, AiCapabilityState>
|
||||
> | null {
|
||||
const { user } = useAuth();
|
||||
return user?.ai?.capabilities ?? null;
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
|
||||
|
||||
/**
|
||||
* v1.4.31 — Client-side accessor for the operator's assistant
|
||||
* feature-flag matrix. Mirrors `GET /api/feature-flags`'s response
|
||||
* shape directly so callers can rely on a typed structure.
|
||||
*
|
||||
* The matrix gates every assistant-driven surface on the web —
|
||||
* Coach + chat, Daily Briefing, per-metric status notes, reading
|
||||
* documents. Deprecated: `ai` on `GET /api/auth/me` (read through
|
||||
* `useAiCapability`) is the resolved answer per capability. iOS reads the same
|
||||
* endpoint and uses the same matrix; the contract is locked per
|
||||
* `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
|
||||
*/
|
||||
|
||||
export interface AssistantFlagSet {
|
||||
/** Master kill-switch — when false, every sub-flag is forced false. */
|
||||
enabled: boolean;
|
||||
/** Coach drawer, chat SSE, history rail, FAB. */
|
||||
coach: boolean;
|
||||
/** Daily Briefing card + advisor recommendations + regen icon. */
|
||||
briefing: boolean;
|
||||
/** Per-metric status cards on every `/insights/<metric>` sub-page. */
|
||||
insightStatus: boolean;
|
||||
/** Document vault reads, lab report scans, medication text extraction. */
|
||||
documentAi: boolean;
|
||||
}
|
||||
|
||||
interface FeatureFlagsPayload {
|
||||
assistant: AssistantFlagSet;
|
||||
}
|
||||
|
||||
/** All-on default — matches the v1.4.30 behaviour for fresh installs. */
|
||||
export const DEFAULT_ASSISTANT_FLAGS: AssistantFlagSet = Object.freeze({
|
||||
enabled: true,
|
||||
coach: true,
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
documentAi: true,
|
||||
});
|
||||
|
||||
async function fetchFeatureFlags(): Promise<FeatureFlagsPayload> {
|
||||
// `apiFetchRaw` (no .ok throw) — this read soft-fails to the all-on
|
||||
// default below instead of surfacing an error state.
|
||||
const res = await apiFetchRaw("/api/feature-flags");
|
||||
if (!res.ok) {
|
||||
// Soft-fail: any HTTP error returns the all-on default so the
|
||||
// surface continues to render. The endpoint is a non-critical
|
||||
// metadata read; a 5xx must not turn into a "Coach hidden"
|
||||
// false negative for the user.
|
||||
return { assistant: { ...DEFAULT_ASSISTANT_FLAGS } };
|
||||
}
|
||||
const json = (await res.json()) as { data: FeatureFlagsPayload };
|
||||
return json.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the operator's assistant feature-flag matrix.
|
||||
*
|
||||
* `staleTime: 60_000` matches the server-side cache header so the
|
||||
* hot /insights mount path doesn't re-fetch the matrix on every
|
||||
* remount within a minute. The hook fails open — any network error
|
||||
* returns the all-on default so the user keeps seeing the surface.
|
||||
*
|
||||
* The hook is referenced from presentational components such as
|
||||
* `<InsightStatusCard>` whose unit tests render without a
|
||||
* `<QueryClientProvider>`. To keep those tests valid we delegate
|
||||
* the React-Query call to a child hook that's only invoked when a
|
||||
* client is mounted. The branch is stable across the component's
|
||||
* lifetime — `QueryClientProvider` placement never flips between
|
||||
* renders — so the conditional hook call is safe per the Rules of
|
||||
* Hooks (same posture the React docs document for "different
|
||||
* environments where the same component renders").
|
||||
*
|
||||
* v1.4.48 M4 — the SSR-mount probe is shared with `useDisableCoach`
|
||||
* via `_internal/use-query-client-safe` so the eslint-disable line
|
||||
* stays in exactly one place per consumer.
|
||||
*/
|
||||
export function useFeatureFlags(): AssistantFlagSet {
|
||||
const hasClient = useQueryClientMounted();
|
||||
if (!hasClient) return DEFAULT_ASSISTANT_FLAGS;
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
return useFeatureFlagsQuery();
|
||||
}
|
||||
|
||||
function useFeatureFlagsQuery(): AssistantFlagSet {
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.featureFlags(),
|
||||
queryFn: fetchFeatureFlags,
|
||||
staleTime: 60_000,
|
||||
gcTime: 300_000,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
// While loading or on error, default to all-on so the assistant
|
||||
// surfaces don't flicker out and back in.
|
||||
return query.data?.assistant ?? DEFAULT_ASSISTANT_FLAGS;
|
||||
}
|
||||
@@ -88,7 +88,6 @@ const RETIRED_SWITCHES: RetiredSwitch[] = [
|
||||
*/
|
||||
const PLUMBING = [
|
||||
"src/lib/feature-flags/index.ts",
|
||||
"src/hooks/use-feature-flags.ts",
|
||||
"src/components/admin/assistant-section.tsx",
|
||||
"src/app/api/feature-flags/route.ts",
|
||||
"src/app/api/admin/settings/assistant-flags/route.ts",
|
||||
|
||||
Reference in new issue
Block a user