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:
Marc-André Bombeck committed 2026-09-24 11:05:59 +02:00
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, "&quot;");
}
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("&quot;coach&quot;:false");
expect(html).toContain("&quot;insightStatus&quot;:false");
});
});
+45
View File
@@ -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;
}
-104
View File
@@ -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",