Merge branch 'feat/ai-routes-insights-coach' into release/v1.39.0

# Conflicts:
#	src/app/api/insights/correlations/__tests__/route.test.ts
#	src/app/api/insights/correlations/route.ts
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 10:26:56 +02:00
commit 9c7ddc13b3
163 files changed
+5450 -2064

No files matched your search

+579 -392
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -12,11 +12,13 @@ import {
} from "./utils/mock-dashboard-snapshot";
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
import { settleBeforeMeasure, waitForFonts } from "./utils/settle";
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
const EVIDENCE_DIR = join(process.cwd(), "test-results", "v1341");
const NARRATIVE_DIGEST: DailyDigest = {
generatedAt: "2026-07-29T08:30:00.000Z",
ai: DIGEST_AI_AVAILABLE,
phase: "final",
sleepPending: false,
score: {
@@ -141,8 +141,6 @@ const ALLOWLIST: Record<string, string> = {
// --- Capability / config probes: resolve a chain, never call it. ---
"app/api/insights/provider-chain/route.ts":
"Lists the user's configured chain and which entry is active, for the settings UI. Constructs clients but never calls generateCompletion. No health data in the request or the response.",
"app/api/insights/comprehensive/route.ts":
"Uses the resolver only as a boolean probe ((await resolveProvider(userId)).type !== 'none') to set hasProvider on the response. The generation path itself lives in lib/insights/comprehensive-generate.ts, which is gated.",
// --- Connection test: a real provider call, but with no user data. ---
"app/api/ai/test/route.ts":
@@ -18,6 +18,9 @@ import { describe, expect, it } from "vitest";
const ROOT = process.cwd();
const SCHEMA_FILE = join(ROOT, "src/lib/openapi/routes/insights/schemas.ts");
// Shared component schemas the insights schemas import by name (the AI
// capability state). Resolved from their own file, never guessed.
const IMPORTED_SCHEMA_FILES = [join(ROOT, "src/lib/openapi/routes/profile.ts")];
const PATH_FILE = join(ROOT, "src/lib/openapi/routes/insights/paths.ts");
const ROUTE_FILE = join(ROOT, "src/app/api/insights/derived/route.ts");
const BATCH_ROUTE_FILE = join(
@@ -132,11 +135,19 @@ function zodObjectShape(path: string, schemaName: string): ZodObjectShape {
const file = parse(path);
const initializers = new Map<string, ts.Expression>();
const arrays = new Set<string>();
for (const statement of file.statements) {
if (!ts.isVariableStatement(statement)) continue;
for (const declaration of statement.declarationList.declarations) {
if (ts.isIdentifier(declaration.name) && declaration.initializer) {
initializers.set(declaration.name.text, declaration.initializer);
// The schema file's own declarations win; an imported component fills in
// only a name the file does not declare itself.
for (const source of [file, ...IMPORTED_SCHEMA_FILES.map(parse)]) {
for (const statement of source.statements) {
if (!ts.isVariableStatement(statement)) continue;
for (const declaration of statement.declarationList.declarations) {
if (
ts.isIdentifier(declaration.name) &&
declaration.initializer &&
!initializers.has(declaration.name.text)
) {
initializers.set(declaration.name.text, declaration.initializer);
}
}
}
}
@@ -168,7 +179,8 @@ function zodObjectShape(path: string, schemaName: string): ZodObjectShape {
`${prefix || schemaName} contains unsupported Zod object syntax: ${property.getText(file)}`,
).toBe(true);
if (!ts.isPropertyAssignment(property)) return [];
const name = propertyName(property.name, file);
// The node may come from an imported schema file.
const name = propertyName(property.name, property.getSourceFile());
expect(
name,
`${prefix || schemaName} contains a computed property name`,
@@ -416,7 +428,8 @@ function apiSuccessLineageBranches(
`${path} contains unsupported apiSuccess syntax: ${property.getText(file)}`,
).toBe(true);
if (ts.isPropertyAssignment(property)) {
const name = propertyName(property.name, file);
// The node may come from an imported schema file.
const name = propertyName(property.name, property.getSourceFile());
expect(
name,
`${path} has a computed response field`,
@@ -892,7 +905,13 @@ const WIRE_ONLY_PROVENANCE =
const WIRE_ONLY_ASSESSMENT_SOURCE =
"The public response intentionally identifies deterministic versus cached assessment prose for programmatic and native callers. The web presents the prose and timestamp without exposing its implementation source.";
const WIRE_ONLY_AI =
"The web reads AI availability from the account payload (`ai` on `/api/auth/me`) for every surface at once; the per-response state is for native and programmatic callers that read one route.";
const EXCEPTIONS: Readonly<Record<string, string>> = {
"ai.available": WIRE_ONLY_AI,
"ai.reason": WIRE_ONLY_AI,
"ai.onDeviceAllowed": WIRE_ONLY_AI,
metric: WIRE_ONLY_METRIC,
"provenance.inputs": WIRE_ONLY_PROVENANCE,
"provenance.source": WIRE_ONLY_PROVENANCE,
@@ -943,6 +962,7 @@ describe("derived response consumer guard", () => {
provenance: ["derived.provenance"],
reason: ["derived.reason", "derived.status"],
assessment: ["assessment"],
ai: ["ai"],
};
for (const [index, lineage] of apiSuccessLineageBranches(
ROUTE_FILE,
@@ -987,7 +1007,14 @@ describe("derived response consumer guard", () => {
},
);
assertBatchSchemaReferencesSingleShape(SCHEMA_FILE);
assertBatchProducerLineage(BATCH_ROUTE_FILE, responseFields);
// `ai` rides the single-metric route only: the batch serves the
// deterministic assessment and resolves no capability. The schema marks
// the field optional for exactly this reason.
const singleRouteOnly = ["ai"];
assertBatchProducerLineage(
BATCH_ROUTE_FILE,
responseFields.filter((field) => !singleRouteOnly.includes(field)),
);
assertBatchClientHandoff(DERIVED_BATCH_CLIENT, VITALS_DASHBOARD);
});
@@ -0,0 +1,31 @@
/**
* Capability states for test fixtures, so a fixture names the state it means
* rather than spelling the object out.
*/
import {
ON_DEVICE_ALLOWED_REASONS,
type AiCapabilityState,
type AiUnavailableReason,
} from "@/lib/ai/capabilities/types";
import type { DailyDigestAi } from "@/lib/daily/digest";
export const AI_AVAILABLE: AiCapabilityState = Object.freeze({
available: true,
reason: null,
onDeviceAllowed: true,
});
export function aiUnavailable(reason: AiUnavailableReason): AiCapabilityState {
return {
available: false,
reason,
onDeviceAllowed: ON_DEVICE_ALLOWED_REASONS.has(reason),
};
}
/** Every AI part of the daily digest available. */
export const DIGEST_AI_AVAILABLE: DailyDigestAi = Object.freeze({
briefing: AI_AVAILABLE,
coach: AI_AVAILABLE,
reactionLines: AI_AVAILABLE,
});
+12 -5
View File
@@ -2943,13 +2943,20 @@ describe("(g) the MANAGE route set is frozen", () => {
expect(narrativeDispatch).toContain("mayDispatchProviderWork");
expect(narrativeDispatch).toContain("withProviderWorkAuthority");
// A shared comprehensive read calls the resolver even before it decides
// whether there is enough context to generate. Credential policy belongs
// inside every resolver helper so a delegate or managed system job cannot
// select a personal BYOK key or custom base URL first.
// A shared comprehensive read asks for provider presence on every read.
// It asks the presence probe, never the resolver: a read builds no client
// and refreshes no token. Credential policy belongs inside every resolver
// and presence helper so a delegate or managed system job cannot select a
// personal BYOK key or custom base URL first.
expect(
callsSymbol(
"app/api/insights/comprehensive/route.ts",
"probeProviderPresence",
),
).toBe(true);
expect(
callsSymbol("app/api/insights/comprehensive/route.ts", "resolveProvider"),
).toBe(true);
).toBe(false);
for (const name of [
"resolveProvider",
"resolveProviderChain",
@@ -0,0 +1,156 @@
/**
* AI capability route inventory, across the whole API tree.
*
* Proves, from source text, the route half of "data never depends on AI":
*
* 1. Every `route.ts` under `src/app/api` that asks an AI capability
* (`requireAiCapability` / `getAiCapability`) is on `AI_ROUTES`, which
* names the capability and whether it refuses (`action`) or answers 200
* with the model text nulled (`mixed`).
* 2. Each `AI_ROUTES` entry asks exactly the capabilities it declares, by
* literal key; an `action` route calls `requireAiCapability`, a `mixed`
* read never does (a mixed read never refuses for an AI reason).
* 3. Every `DATA_ROUTES` entry asks neither gate, nor the retired
* `requireAssistantSurface`.
* 4. The matchers find what they judge: non-zero counts, and a call split
* across lines still matches.
*
* What it does NOT prove: that a gate sits before the work it guards, or that
* a mixed read really nulls the text. The route tests and the integration
* files (`tests/integration/ai-optional-*.test.ts`) prove behaviour; this
* proves no route escapes classification and no data route grows a gate.
*
* Mutation checks (each turned this file red by name): adding
* `await requireAiCapability("statusText")` to `insights/ecg/route.ts`;
* removing the capability call from `insights/chat/route.ts`; swapping
* `getAiCapability` for `requireAiCapability` in `insights/narrative`.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { AI_CAPABILITY_KEYS } from "@/lib/ai/capabilities/types";
import { AI_ROUTES, DATA_ROUTES } from "./ai-route-inventory";
const repoRoot = resolve(__dirname, "..", "..", "..", "..");
/** Comment lines out, so prose naming a gate is not read as a call. */
function code(path: string): string {
return readFileSync(resolve(repoRoot, path), "utf8")
.split("\n")
.filter((line) => !/^\s*(\*|\/\/|\/\*)/.test(line))
.join("\n");
}
/** Whitespace-tolerant: a call split across lines still matches. */
const GATE_CALL =
/\b(?:(requireAiCapability|getAiCapability)\s*\(\s*"(\w+)"|(aiCapabilityToServe)\s*\([^,()]*,\s*"(\w+)")/g;
const ANY_GATE =
/\b(?:requireAiCapability|getAiCapability|aiCapabilityToServe)\s*\(/;
const REQUIRE_GATE = /\brequireAiCapability\s*\(/;
const RETIRED_GATE = /\brequireAssistantSurface\s*\(/;
function gateCalls(text: string): Array<{ fn: string; key: string }> {
return [...text.matchAll(GATE_CALL)].map((m) => ({
fn: m[1] ?? m[3],
key: m[2] ?? m[4],
}));
}
function routeFiles(): string[] {
const root = resolve(repoRoot, "src/app/api");
const hits: string[] = [];
function walk(dir: string): void {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (entry === "__tests__" || entry === "node_modules") continue;
walk(full);
} else if (entry === "route.ts") {
hits.push(relative(repoRoot, full).split(/[\\/]/).join("/"));
}
}
}
walk(root);
return hits.sort();
}
const ROUTES = routeFiles();
describe("AI capability route inventory", () => {
it("the matcher reads a call split across lines", () => {
const split = 'await requireAiCapability(\n "coach",\n );';
expect(gateCalls(split)).toEqual([
{ fn: "requireAiCapability", key: "coach" },
]);
expect(ANY_GATE.test("getAiCapability (\n")).toBe(true);
expect(
gateCalls('aiCapabilityToServe(\n user.id,\n "statusText",\n )'),
).toEqual([{ fn: "aiCapabilityToServe", key: "statusText" }]);
});
it("finds the routes it is meant to judge", () => {
expect(ROUTES.length).toBeGreaterThan(100);
const asking = ROUTES.filter((path) => ANY_GATE.test(code(path)));
// A matcher that stopped matching would agree with an empty world.
expect(asking.length).toBeGreaterThan(0);
expect(asking.length).toBe(Object.keys(AI_ROUTES).length);
expect(Object.keys(DATA_ROUTES).length).toBeGreaterThan(0);
});
it("every route that asks an AI capability is on AI_ROUTES", () => {
const unlisted = ROUTES.filter(
(path) => ANY_GATE.test(code(path)) && !(path in AI_ROUTES),
);
expect(
unlisted,
"these routes ask an AI capability but are not on AI_ROUTES — add them with their capability, or remove the gate from a data route",
).toEqual([]);
});
describe.each(Object.entries(AI_ROUTES))("%s", (path, entry) => {
it("exists and asks exactly the capabilities it declares", () => {
expect(ROUTES, `${path} no longer exists`).toContain(path);
const calls = gateCalls(code(path));
expect(calls.length, `${path} asks no capability`).toBeGreaterThan(0);
for (const { key } of calls) {
expect(AI_CAPABILITY_KEYS as readonly string[]).toContain(key);
}
expect([...new Set(calls.map((c) => c.key))].sort()).toEqual(
[...entry.capabilities].sort(),
);
});
it(`is ${entry.kind === "action" ? "an action that refuses" : "a mixed read that never refuses"}`, () => {
const refuses = REQUIRE_GATE.test(code(path));
expect(
refuses,
entry.kind === "action"
? `${path} is an action but never calls requireAiCapability`
: `${path} is a mixed read but calls requireAiCapability — a mixed read answers 200 and nulls the model text`,
).toBe(entry.kind === "action");
});
});
it("no data route asks an AI capability or the retired gate", () => {
const offenders = Object.keys(DATA_ROUTES).filter((path) => {
const text = code(path);
return ANY_GATE.test(text) || RETIRED_GATE.test(text);
});
expect(
offenders,
"data routes never depend on AI: remove the gate, or move the route to AI_ROUTES if it now serves model text",
).toEqual([]);
});
it("no data route is stale or listed twice", () => {
const missing = Object.keys(DATA_ROUTES).filter(
(path) => !ROUTES.includes(path),
);
expect(missing, "DATA_ROUTES names files that do not exist").toEqual([]);
const both = Object.keys(DATA_ROUTES).filter((path) => path in AI_ROUTES);
expect(both).toEqual([]);
});
});
+274
View File
@@ -0,0 +1,274 @@
/**
* The AI route inventory: which API routes ask an AI capability, and which
* are data and must never ask.
*
* The rule it pins (the AI-optional design): data never depends on AI. A
* route that serves measurements, device records, computed scores,
* statistics or template sentences imports no AI gate and is never refused
* because a switch is off, a provider is missing, consent is absent or the
* person opted out of AI analysis. A route that calls a model, or serves text
* a model wrote, names its capability:
*
* - `action` routes call `requireAiCapability(key)` and refuse with the
* capability envelope;
* - `mixed` reads call `getAiCapability(key)` (the viewer's view, for work
* they would start) or `aiCapabilityToServe(recordId, key)` (the record's
* own view, for stored model text they would show), answer 200, null the
* model text and say why in `ai`. They never refuse for an AI reason, so
* they never call `requireAiCapability`.
*
* Read by two guards: `ai-capability-route-inventory.test.ts` (the whole API
* tree) and `insights/__tests__/coach-route-gate-inventory.test.ts` (every
* route in the trees where AI routes live is classified). Not a test file, so
* importing it registers nothing.
*/
import type { AiCapabilityKey } from "@/lib/ai/capabilities/types";
export interface AiRouteEntry {
kind: "action" | "mixed";
capabilities: readonly AiCapabilityKey[];
why: string;
}
export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
// Reading documents, lab scans and medication text.
"src/app/api/documents/inbound/[id]/chat/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "POST asks a model about one document; the history GET asks nothing.",
},
"src/app/api/documents/inbound/[id]/extract/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Reads a document's text with a model.",
},
"src/app/api/documents/inbound/[id]/index/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Indexes a document's content through a provider.",
},
"src/app/api/documents/inbound/[id]/suggest/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Suggests a title and category from the document with a model.",
},
"src/app/api/documents/inbound/[id]/summary/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Summarises a document with a model.",
},
"src/app/api/documents/inbound/reindex/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Re-indexes the vault through a provider.",
},
"src/app/api/documents/inbound/route.ts": {
kind: "mixed",
capabilities: ["documentAi"],
why: "The upload is data and always accepted; only the AI work queued after it follows the capability.",
},
"src/app/api/auth/me/documents-auto-ai-read/route.ts": {
kind: "mixed",
capabilities: ["documentAi"],
why: "The preference is stored whatever AI says; the catch-up it queues follows the capability.",
},
"src/app/api/insights/chat/fenced/route.ts": {
kind: "action",
capabilities: ["coach", "documentAi"],
why: "Document chat inside a Coach conversation.",
},
"src/app/api/insights/chat/[id]/attachments/route.ts": {
kind: "action",
capabilities: ["coach", "documentAi"],
why: "Attaching a document to a Coach conversation for the model to read.",
},
"src/app/api/labs/ocr/extract/route.ts": {
kind: "action",
capabilities: ["labsOcr"],
why: "Reads a lab report image with a model.",
},
"src/app/api/medications/extract/route.ts": {
kind: "action",
capabilities: ["medicationExtract"],
why: "Turns typed medication text into a schedule with a model.",
},
"src/app/api/insights/chat/route.ts": {
kind: "action",
capabilities: ["coach"],
why: "POST streams a Coach reply from a model. The history GET in the same file asks nothing.",
},
"src/app/api/insights/generate/route.ts": {
kind: "action",
capabilities: ["briefing"],
why: "POST generates the briefing; GET is a pure AI read (the payload is the model text and nothing else), so both refuse.",
},
"src/app/api/insights/comprehensive/route.ts": {
kind: "mixed",
capabilities: ["briefing", "statusText"],
why: "Computed overview data; the `ai` block says whether the briefing and status notes around it can be shown.",
},
"src/app/api/insights/derived/route.ts": {
kind: "mixed",
capabilities: ["statusText"],
why: "Computed score; only the model-written assessment override follows the capability.",
},
"src/app/api/insights/narrative/route.ts": {
kind: "mixed",
capabilities: ["periodNarrative"],
why: "A deterministic narrative is data; a model-written one is served only while the capability is available.",
},
"src/app/api/insights/pregenerate/route.ts": {
kind: "mixed",
capabilities: ["briefing", "statusText"],
why: "Enqueue-only warm that returns no model text: `queued: false` when neither half can run.",
},
"src/app/api/insights/coach/nudge-status/route.ts": {
kind: "mixed",
capabilities: ["coach"],
why: "The unread signal of a Coach that is unavailable is the quiet empty shape, never a refusal.",
},
"src/app/api/insights/coach/seeded-question/route.ts": {
kind: "mixed",
capabilities: ["coach"],
why: "The opener exists only to open the Coach: `signal: null` while it is unavailable.",
},
"src/app/api/coach/about-me/route.ts": {
kind: "mixed",
capabilities: ["aboutMeQuestions"],
why: "The profile is data; only model-written follow-up questions follow the capability.",
},
"src/app/api/insights/biomarker-assessment/route.ts": {
kind: "mixed",
capabilities: ["statusText"],
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
},
"src/app/api/insights/blood-pressure-status/route.ts": {
kind: "mixed",
capabilities: ["statusText"],
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
},
"src/app/api/insights/bmi-status/route.ts": {
kind: "mixed",
capabilities: ["statusText"],
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
},
"src/app/api/insights/medication-compliance-status/route.ts": {
kind: "mixed",
capabilities: ["statusText"],
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
},
"src/app/api/insights/metric-status/route.ts": {
kind: "mixed",
capabilities: ["statusText"],
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
},
"src/app/api/insights/mood-status/route.ts": {
kind: "mixed",
capabilities: ["statusText"],
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
},
"src/app/api/insights/pulse-status/route.ts": {
kind: "mixed",
capabilities: ["statusText"],
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
},
"src/app/api/insights/weight-status/route.ts": {
kind: "mixed",
capabilities: ["statusText"],
why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).",
},
"src/app/api/workouts/[id]/route.ts": {
kind: "mixed",
capabilities: ["workoutInsights"],
why: "The workout is data; only the stored Activity Insight paragraph follows the capability.",
},
};
/**
* Routes that are data and must never import an AI gate. Every entry is a
* claim the guard checks against the file.
*/
export const DATA_ROUTES: Readonly<Record<string, string>> = {
// The design's list of formerly AI-gated data reads and writes.
"src/app/api/insights/derived/batch/route.ts":
"Deterministic scores and assessments for the whole grid.",
"src/app/api/insights/ecg/route.ts":
"Device recordings, list and live ingest: an upload is never refused because AI is off.",
"src/app/api/insights/ecg/[id]/route.ts": "One device recording's waveform.",
"src/app/api/insights/rhythm-events/route.ts":
"The device's own flagged events, verbatim.",
"src/app/api/insights/cards/route.ts":
"Rule alerts from the threshold engine; `provider` is `rules`.",
"src/app/api/insights/correlations/route.ts":
"Statistics; the retired Correlations switch gated no model output.",
"src/app/api/insights/coach-read/route.ts":
"Baseline placement and the strongest association, both computed.",
"src/app/api/insights/coach/seen/route.ts":
"A timestamp on the caller's own row.",
"src/app/api/insights/coach/facts/route.ts":
"Stored Coach facts: readable and erasable while the Coach is unavailable.",
"src/app/api/insights/coach/facts/[id]/route.ts": "Erasing one stored fact.",
"src/app/api/insights/chat/[id]/route.ts":
"One stored conversation: read, rename, delete.",
"src/app/api/insights/chat/messages/[id]/feedback/route.ts":
"A rating on a message that already exists.",
"src/app/api/insights/breathing-screening/route.ts":
"Device breathing data, owned by the sleep module.",
"src/app/api/insights/labs-changes/route.ts":
"Lab deltas, owned by the labs module.",
"src/app/api/insights/health-status/route.ts":
"Baseline drift, computed over core vitals.",
"src/app/api/insights/pulse/intraday/route.ts":
"The day's pulse shape, a core vital.",
"src/app/api/insights/patterns/route.ts": "Stored correlation statistics.",
"src/app/api/insights/patterns/[id]/route.ts":
"Dismissing a stored correlation.",
"src/app/api/daily/digest/route.ts":
"The Today digest is data; its AI parts are masked by the loader through the capability resolver, never refused.",
"src/app/api/daily/digest/dismiss/route.ts": "Dismissing a Today rail item.",
"src/app/api/dashboard/snapshot/route.ts":
"The dashboard is data; the briefing inside it is masked per read by the snapshot loader, never refused.",
// Configuration and other non-AI routes in the insights tree.
"src/app/api/insights/feedback/route.ts":
"Feedback on a recommendation that already exists.",
"src/app/api/insights/glp1-plateau/route.ts":
"A deterministic plateau detector over weight and dose history.",
"src/app/api/insights/glp1-timeline/route.ts":
"A merge of medication events.",
"src/app/api/insights/layout/route.ts": "The person's tile layout.",
"src/app/api/insights/provider-chain/route.ts":
"AI configuration: editable while AI is unavailable, so it can be set up.",
"src/app/api/insights/settings/route.ts":
"AI configuration: editable while AI is unavailable, so it can be set up.",
"src/app/api/insights/targets/route.ts": "Threshold reference values.",
// The Coach tree's own data: gated on the `coach` module, never on AI.
"src/app/api/coach/about-me/adopt/route.ts":
"Folds a typed answer into the stored profile; no model call.",
"src/app/api/coach/about-me/questions/route.ts":
"Reads or dismisses stored questions; no model call.",
"src/app/api/coach/plans/route.ts": "Stored Coach plans.",
"src/app/api/coach/plans/[id]/route.ts": "One stored Coach plan.",
"src/app/api/coach/reminders/route.ts": "Stored Coach reminders.",
"src/app/api/coach/reminders/[id]/route.ts": "One stored Coach reminder.",
"src/app/api/coach/reminder-suggestions/route.ts":
"Deterministic reminder suggestions.",
"src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts":
"Detaching a document: removal of one's own data.",
"src/app/api/coach/suggested-actions/route.ts":
"Confirms a proposed action from a closed allowlist; no model call.",
};
/**
* Routes in the AI trees whose AI gating another change still owns. Each has
* to be classified; the entry names who moves it. An entry that starts
* calling a capability gate fails the guard until it moves to `AI_ROUTES`.
* Empty: every route in these trees is classified.
*/
export const PENDING_ROUTES: Readonly<Record<string, string>> = {};
/** The trees in which every route has to be classified above. */
export const AI_ROUTE_TREES = [
"src/app/api/insights",
"src/app/api/coach",
"src/app/api/daily",
] as const;
@@ -32,11 +32,16 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
* `delegatesTo`):
* - cycle → `requireCycleEnabled(...)` (the cycle gate the
* `cycle` ModuleKey delegates to).
* - coach → `requireAssistantSurface("coach")` (the assistant
* master flag + per-user opt-out the `coach` ModuleKey
* delegates to). Covered in depth by the sibling
* `coach-route-gate-inventory.test.ts`; listed here so
* coach-bearing routes are not flagged as ungated.
* - an AI capability → `requireAiCapability(key)` /
* `getAiCapability(key)`. A capability resolves its owning
* modules and the AI opt-out (the `coach` module and
* `disableCoach` for the Coach, the `insights` module for
* every other capability), so an AI route that names one is
* gated on those modules. Covered in depth by
* `ai-capability-route-inventory.test.ts`. The `insights`
* module is the AI analysis opt-out and gates only AI parts;
* the data routes that happen to live under
* `/api/insights` are EXEMPT below.
*
* 3. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a
* toggleable domain but are deliberately NOT gated, each with the
@@ -147,9 +152,10 @@ const MODULE_ROUTE_TREES_BY_KEY: Readonly<
// counted as directly gated here, and the sibling
// `coach-route-gate-inventory.test.ts` covers the surface in depth.
coach: ["src/app/api/coach"],
// v1.18.0 (B2) — the AI-narrative insights tree, plus v1.28's unified
// daily-digest read (`GET /api/daily/digest`), which is the AI-narrative
// daily layer and gates on `insights` directly.
// The `insights` module is the AI analysis opt-out. It owns no data route:
// the AI routes under these trees name a capability that folds it in, and
// the data routes that live here (ECG, scores, statistics, the Today
// digest) are EXEMPT below because a computation never depends on AI.
insights: ["src/app/api/insights", "src/app/api/daily"],
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module.
// SURFACE-gated (nav entry, dashboard widget, the dedicated Medikamente
@@ -244,10 +250,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// affordance), not the row store. The extract route is read-only vision
// assistance, the commit route writes the user's own confirmed lab rows,
// and the capability probe is an infra availability check carrying no
// module data. All three are owner-scoped and AI-gated (consent / budget /
// rate); the module toggle hiding the surface does not need to wedge them.
// module data. The extract route is not listed: it names the `labsOcr` AI
// capability, which carries the labs module, so it counts as gated.
"src/app/api/labs/ocr/capability/route.ts",
"src/app/api/labs/ocr/extract/route.ts",
"src/app/api/labs/ocr/commit/route.ts",
// ── DATA LAYER (medications) ──────────────────────────────────────
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module,
@@ -268,12 +273,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// per-medication importer below: it writes intake rows and reads its own job.
"src/app/api/medications/intake/dose-history-import/route.ts",
"src/app/api/medications/intake/dose-history-import/[jobId]/status/route.ts",
// `medications/extract` is model work, not data: it answers under the
// `medicationExtract` AI capability (`requireAiCapability`), which no
// module owns, because medications is a core domain for extraction. The
// capability carries the operator switch, the record's authority and the
// consent rule; there is no module gate for it to carry.
"src/app/api/medications/extract/route.ts",
// `medications/extract` is not listed: it is model work under the
// `medicationExtract` AI capability (no owning module; medications is a
// core domain for extraction), and a capability call counts as the gate.
"src/app/api/medications/[id]/route.ts",
"src/app/api/medications/[id]/api-endpoint/route.ts",
"src/app/api/medications/[id]/cadence/route.ts",
@@ -302,12 +304,39 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// ── INFRA / UI-ONLY ───────────────────────────────────────────────
// Static FHIR CapabilityStatement — server metadata, no user data.
"src/app/api/fhir/metadata/route.ts",
// ── DATA (insights tree) ──────────────────────────────────────────
// The `insights` module is the AI analysis opt-out, so it gates AI parts
// only, through the capability. These routes serve or accept data that no
// model wrote: device recordings and events, computed scores, statistics,
// rule alerts, the person's stored Coach data and the Today digest. Turning
// AI analysis off must leave every one of them working.
"src/app/api/insights/ecg/route.ts",
"src/app/api/insights/ecg/[id]/route.ts",
"src/app/api/insights/rhythm-events/route.ts",
"src/app/api/insights/derived/batch/route.ts",
"src/app/api/insights/cards/route.ts",
"src/app/api/insights/correlations/route.ts",
"src/app/api/insights/coach-read/route.ts",
"src/app/api/insights/health-status/route.ts",
"src/app/api/insights/pulse/intraday/route.ts",
"src/app/api/insights/patterns/route.ts",
"src/app/api/insights/patterns/[id]/route.ts",
// Stored Coach data: viewing and erasing it never depends on the Coach
// being available (the GDPR reason), and a timestamp on one's own row is
// data too.
"src/app/api/insights/coach/seen/route.ts",
"src/app/api/insights/coach/facts/route.ts",
"src/app/api/insights/coach/facts/[id]/route.ts",
"src/app/api/insights/chat/[id]/route.ts",
"src/app/api/insights/chat/messages/[id]/feedback/route.ts",
// The Today digest and its dismiss: data, with the AI parts masked by the
// loader through the capability resolver.
"src/app/api/daily/digest/route.ts",
"src/app/api/daily/digest/dismiss/route.ts",
// ── INFRA / CONFIG (insights) ─────────────────────────────────────
// v1.18.0 (B2) — the insights tree's non-narrative routes. The
// `insights` module gates the AI-narrative SURFACES (status cards,
// correlations, derived scores, period narrative, the rhythm-event
// timeline); these six carry no narrative payload, so gating them would
// only break configuration / settings reads while the module is off.
// The insights tree's configuration routes carry no narrative payload,
// so gating them would only break configuration / settings reads while
// the module is off.
//
// AI provider + privacy settings and the read-only chain summary — pure
// configuration of the assistant, surfaced under Settings → AI, not an
@@ -388,7 +417,10 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
const MODULE_GATE_NEEDLE = "requireModuleEnabled(";
const CYCLE_GATE_NEEDLE = "requireCycleEnabled(";
const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
// An AI capability resolves its owning modules and the AI opt-out, so a route
// that names one is gated on them.
const AI_CAPABILITY_GATE =
/\b(?:requireAiCapability|getAiCapability|aiCapabilityToServe)\s*\(/;
// v1.18.1 — the illness journal's thin gate wrapper. `requireIllnessEnabled`
// delegates to `requireModuleEnabled(userId, "illness")` and re-stamps the
// illness-specific errorCode, exactly mirroring how `cycle` delegates to
@@ -470,6 +502,15 @@ function fileHasCall(text: string, needle: string): boolean {
});
}
/** True when a non-comment line calls an AI capability gate. */
function fileHasCapabilityGate(text: string): boolean {
return text.split("\n").some((line) => {
const trimmed = line.trim();
if (trimmed.startsWith("//") || trimmed.startsWith("*")) return false;
return AI_CAPABILITY_GATE.test(line);
});
}
/**
* Extract the module key from a `requireModuleEnabled(user.id, "<key>")`
* call. Returns the literal key, or null when the key is computed (the
@@ -634,8 +675,8 @@ describe("module API route gate inventory", () => {
if (fileHasCall(text, MODULE_GATE_NEEDLE)) continue;
if (fileHasCall(text, CYCLE_GATE_NEEDLE)) continue;
if (fileHasCall(text, COACH_GATE_NEEDLE)) continue;
if (fileHasCall(text, ILLNESS_GATE_NEEDLE)) continue;
if (fileHasCapabilityGate(text)) continue;
if (exempt.has(path)) continue;
@@ -755,8 +796,8 @@ describe("module API route gate inventory", () => {
if (
fileHasCall(text, MODULE_GATE_NEEDLE) ||
fileHasCall(text, CYCLE_GATE_NEEDLE) ||
fileHasCall(text, COACH_GATE_NEEDLE) ||
fileHasCall(text, ILLNESS_GATE_NEEDLE)
fileHasCall(text, ILLNESS_GATE_NEEDLE) ||
fileHasCapabilityGate(text)
) {
stillGated.push(path);
}
@@ -43,7 +43,7 @@ vi.mock("@/lib/rate-limit", () => ({
}));
// Operator master flag = on by default; coach delegation keys off it.
// Partial mock — api-handler imports `AssistantDisabledError` from here.
// Partial mock: the rest of the module stays real.
vi.mock("@/lib/feature-flags", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/feature-flags")>();
return {
@@ -108,7 +108,10 @@ vi.mock("@/lib/ai/coach/self-context-questions", () => ({
questions: [],
source: "none",
})),
buildFallbackQuestions: vi.fn(() => ["deterministic hint"]),
}));
// The follow-up questions are the route's one AI part: `aboutMeQuestions`.
vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() }));
import { GET, PUT } from "../route";
import {
@@ -118,6 +121,12 @@ import {
} from "@/lib/ai/coach/about-me";
import { deriveClarifyingQuestions } from "@/lib/ai/coach/self-context-questions";
import { auditLog } from "@/lib/auth/audit";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { buildFallbackQuestions } from "@/lib/ai/coach/self-context-questions";
import {
AI_AVAILABLE,
aiUnavailable,
} from "@/__tests__/helpers/ai-capability-fixtures";
type Envelope = {
data: unknown;
@@ -151,6 +160,7 @@ function putReq(
describe("about-me module gate", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getAiCapability).mockResolvedValue(AI_AVAILABLE);
profileFindUnique.mockResolvedValue({
updatedAt: new Date(0),
aiIncludedSections: ["ABOUT_ME"],
@@ -284,6 +294,7 @@ describe("about-me module gate", () => {
},
"en",
["ABOUT_ME"],
{ aiAvailable: true },
);
expect(setPendingQuestionsForUser).toHaveBeenCalledWith(
"u1",
@@ -292,6 +303,71 @@ describe("about-me module gate", () => {
);
});
it("derives without the model while aboutMeQuestions is unavailable", async () => {
setGates(true, false);
vi.mocked(getAiCapability).mockResolvedValue(
aiUnavailable("operator_disabled"),
);
vi.mocked(getSelfContextForUser).mockResolvedValueOnce({
aboutMe: "I run daily",
conditions: null,
allergies: null,
coachFocus: null,
});
const res = await put(putReq({ aiIncludedSections: ["ABOUT_ME"] }));
expect(res.status).toBe(200);
expect(getAiCapability).toHaveBeenCalledWith("aboutMeQuestions");
expect(vi.mocked(deriveClarifyingQuestions).mock.calls[0]?.[4]).toEqual({
aiAvailable: false,
});
expect((res.data as { ai: unknown }).ai).toEqual(
aiUnavailable("operator_disabled"),
);
});
it("GET swaps an outstanding (possibly model-written) set for the deterministic hints while unavailable", async () => {
setGates(true, false);
vi.mocked(getAiCapability).mockResolvedValue(aiUnavailable("no_provider"));
vi.mocked(getPendingQuestionsForUser).mockResolvedValueOnce([
"a question a model may have written",
]);
const res = await get();
expect(res.status).toBe(200);
const data = res.data as { pendingQuestions: string[]; ai: unknown };
expect(data.pendingQuestions).toEqual(["deterministic hint"]);
expect(data.ai).toEqual(aiUnavailable("no_provider"));
});
it("GET keeps an answered (empty) set empty while unavailable", async () => {
setGates(true, false);
vi.mocked(getAiCapability).mockResolvedValue(aiUnavailable("no_provider"));
vi.mocked(getPendingQuestionsForUser).mockResolvedValueOnce([]);
const res = await get();
expect(
(res.data as { pendingQuestions: string[] }).pendingQuestions,
).toEqual([]);
expect(buildFallbackQuestions).not.toHaveBeenCalled();
});
it("GET serves the stored set while available", async () => {
setGates(true, false);
vi.mocked(getPendingQuestionsForUser).mockResolvedValueOnce([
"stored question",
]);
const res = await get();
expect(
(res.data as { pendingQuestions: string[] }).pendingQuestions,
).toEqual(["stored question"]);
});
it("clears pending questions when inclusion removes every text field", async () => {
setGates(true, false);
profileFindUnique.mockResolvedValue({
+33 -5
View File
@@ -19,6 +19,13 @@
* text child only — no markdown library exists in the tree and none
* may be added (XSS posture, see the contributor notes).
*
* The follow-up questions are the one AI part. Model-written questions are
* derived and served only while the `aboutMeQuestions` capability is
* available; otherwise the deterministic completion hints stand in, no
* provider is called, and the `ai` field says why. Stored questions cannot be
* told apart by origin, so while the capability is unavailable an outstanding
* set is replaced on read by the deterministic hints for the same profile.
*
* Ownership: the user id always comes from `requireAuth()`; the body
* carries only the text. Audit rows never contain the text itself —
* only per-field lengths — because it is free-form health prose.
@@ -44,7 +51,11 @@ import {
filterSelfContextForAi,
setPendingQuestionsForUser,
} from "@/lib/ai/coach/about-me";
import { deriveClarifyingQuestions } from "@/lib/ai/coach/self-context-questions";
import {
buildFallbackQuestions,
deriveClarifyingQuestions,
} from "@/lib/ai/coach/self-context-questions";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { requireModuleEnabled } from "@/lib/modules/gate";
import {
ABOUT_ME_FIELD_MAX_CHARS,
@@ -79,7 +90,7 @@ export const GET = apiHandler(async () => {
const gate = await requireSelfContextConsumer(user.id);
if (!gate.enabled) return gate.response;
const [ctx, pendingQuestions, row] = await Promise.all([
const [ctx, storedQuestions, row, ai] = await Promise.all([
getSelfContextForUser(user.id),
gate.coachEnabled
? getPendingQuestionsForUser(user.id)
@@ -88,7 +99,22 @@ export const GET = apiHandler(async () => {
where: { userId: user.id },
select: { updatedAt: true, aiIncludedSections: true },
}),
getAiCapability("aboutMeQuestions"),
]);
const aiIncludedSections =
(row?.aiIncludedSections as HealthProfileAiSection[] | undefined) ??
DEFAULT_HEALTH_PROFILE_AI_SECTIONS;
// A stored set may be model-written. While the capability is unavailable
// an outstanding set is swapped for the deterministic hints; an answered
// (empty) set stays empty.
const pendingQuestions =
ai.available || storedQuestions.length === 0
? storedQuestions
: buildFallbackQuestions(
filterSelfContextForAi(ctx, aiIncludedSections),
user.locale,
aiIncludedSections,
);
annotate({
action: { name: "coach.about_me.get" },
@@ -107,12 +133,11 @@ export const GET = apiHandler(async () => {
allergies: ctx.allergies,
coachFocus: ctx.coachFocus,
pendingQuestions,
aiIncludedSections:
(row?.aiIncludedSections as HealthProfileAiSection[] | undefined) ??
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
aiIncludedSections,
updatedAt: row?.updatedAt?.toISOString() ?? null,
maxChars: ABOUT_ME_MAX_CHARS,
fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS,
ai,
});
});
@@ -325,6 +350,7 @@ export const PUT = apiHandler(async (req: Request) => {
ctx.allergies === null &&
ctx.coachFocus === null;
const ai = await getAiCapability("aboutMeQuestions");
let pendingQuestions: string[] = [];
let questionsToPersist: string[] | null | undefined;
let questionsSource: "ai" | "fallback" | "none" = "none";
@@ -342,6 +368,7 @@ export const PUT = apiHandler(async (req: Request) => {
aiContext,
user.locale,
aiIncludedSections,
{ aiAvailable: ai.available },
);
pendingQuestions = derived.questions;
questionsSource = derived.source;
@@ -404,6 +431,7 @@ export const PUT = apiHandler(async (req: Request) => {
updatedAt: updatedAtIso,
maxChars: ABOUT_ME_MAX_CHARS,
fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS,
ai,
});
});
@@ -8,9 +8,9 @@ import { SCORE_VERSION } from "@/lib/analytics/score/types";
/**
* `GET /api/daily/digest` — the P3 read seam.
*
* Under test: cookie/Bearer auth narrows the user, the `insights` module gate
* returns a 403 `module.disabled` envelope when off (even for a valid
* session), and the happy path returns the `DailyDigest` DTO shape. The digest
* Under test: cookie/Bearer auth narrows the user, the `insights` module (the
* AI analysis opt-out) no longer refuses the digest, and the happy path
* returns the `DailyDigest` DTO shape. The digest
* itself is composed by `loadDailyDigest`, mocked here — the route must reach
* no provider (there is nothing AI-shaped in this module graph).
*/
@@ -44,6 +44,7 @@ import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { loadDailyDigest } from "@/lib/daily/load-digest";
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -57,6 +58,7 @@ const SESSION_OK = {
const DIGEST: DailyDigest = {
generatedAt: "2026-07-16T09:00:00.000Z",
ai: DIGEST_AI_AVAILABLE,
phase: "final",
sleepPending: false,
score: {
@@ -95,7 +97,7 @@ describe("GET /api/daily/digest", () => {
expect(vi.mocked(loadDailyDigest)).not.toHaveBeenCalled();
});
it("returns the 403 module.disabled envelope when insights is off", async () => {
it("serves the digest with the insights module (the AI analysis opt-out) off", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: apiError('Module "insights" is not enabled', 403, {
@@ -104,18 +106,9 @@ describe("GET /api/daily/digest", () => {
}),
});
const res = await callGet(makeReq());
expect(res.status).toBe(403);
const body = await res.json();
expect(body.meta?.errorCode).toBe("module.disabled");
expect(vi.mocked(loadDailyDigest)).not.toHaveBeenCalled();
});
it("gates on the insights module key", async () => {
await callGet(makeReq());
expect(vi.mocked(requireModuleEnabled)).toHaveBeenCalledWith(
"user-1",
"insights",
);
expect(res.status).toBe(200);
expect(vi.mocked(requireModuleEnabled)).not.toHaveBeenCalled();
expect(vi.mocked(loadDailyDigest)).toHaveBeenCalled();
});
it("returns 200 with the DailyDigest DTO on the happy path", async () => {
@@ -1,8 +1,8 @@
/**
* `POST /api/daily/digest/dismiss` — the Today rail dismiss surface.
*
* Under test: cookie/Bearer auth narrows the user, the `insights` module gate
* returns a 403 `module.disabled` envelope when off, an `itemKey` that isn't
* Under test: cookie/Bearer auth narrows the user, the `insights` module (the
* AI analysis opt-out) no longer refuses a dismissal, an `itemKey` that isn't
* namespaced under a dismissible kind 422s BEFORE any DB write, and the happy
* path upserts the `DismissedPriorityItem` row scoped to the caller.
*/
@@ -98,7 +98,7 @@ describe("POST /api/daily/digest/dismiss", () => {
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
});
it("returns the 403 module.disabled envelope when insights is off", async () => {
it("accepts a dismissal with the insights module (the AI analysis opt-out) off", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: apiError('Module "insights" is not enabled', 403, {
@@ -109,8 +109,9 @@ describe("POST /api/daily/digest/dismiss", () => {
const res = await callPost(
makeReq({ itemKey: "milestone:record_first:WEIGHT:2026-07-16" }),
);
expect(res.status).toBe(403);
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
expect(res.status).toBe(200);
expect(requireModuleEnabled).not.toHaveBeenCalled();
expect(prisma.dismissedPriorityItem.upsert).toHaveBeenCalled();
});
it("422s an itemKey that isn't namespaced under a dismissible kind — an actionable item can never be dismissed", async () => {
+2 -6
View File
@@ -12,8 +12,8 @@
* an upsert, so a repeat dismiss of the same instance is a no-op rather than
* a conflict. `userId` is narrowed from `requireAuth()` only; the body never
* carries one. Cookie OR Bearer, mirroring the digest GET route's own auth
* policy, and gated on the same `insights` module (the daily rail's host
* surface).
* policy. Like the digest GET it carries no module gate: the rail is data,
* and the `insights` module is the AI analysis opt-out.
*/
import type { NextRequest } from "next/server";
@@ -28,7 +28,6 @@ import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { isScoreNoticeItemKey } from "@/lib/daily/priority-item";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { dismissPriorityItemSchema } from "@/lib/validations/daily";
@@ -40,9 +39,6 @@ const DISMISS_WINDOW_MS = 60_000;
export const POST = apiHandler(async (req: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "insights");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`daily-digest-dismiss:${user.id}`,
DISMISS_RATE_LIMIT,
+8 -10
View File
@@ -10,14 +10,15 @@
* provider call is reachable from this path, and nothing warms on mount.
*
* Cookie OR Bearer auth via `requireAuth()`; `userId` is narrowed from the
* resolved session — never a body field. Gated on the `insights` module (the
* daily digest is the AI-narrative daily layer); a disabled account gets the
* standard 403 `module.disabled` envelope even over a Bearer token. The rail's
* data-tile inputs inherit their own module gates via the snapshot builder.
* resolved session — never a body field. No module gate and no AI gate: the
* digest is data (score, doses, syncs, checkups, the day's markers). Its AI
* parts, the briefing lead, the reaction line and the Coach check-in, are
* served only while their capabilities are available, and the `ai` block says
* which. The rail's data-tile inputs inherit their own module gates via the
* snapshot builder.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import { loadDailyDigest } from "@/lib/daily/load-digest";
@@ -27,13 +28,10 @@ export const GET = apiHandler(async () => {
// The record's Today hero. Same family as the dashboard snapshot and
// admitted on the same argument: an aggregate over the record's own data,
// assembled from already-cached values with no provider on the path. The
// module gate below now runs against the RECORD, which is the behaviour that
// matters — a delegate gets the hero only where the owner switched insights
// on, never where the delegate did. Re-examine with the snapshot when
// AI parts resolve against the RECORD, so a delegate sees the owner's
// capabilities, never their own. Re-examine with the snapshot when
// per-module scope lands; a digest is a summary of several modules at once.
const { user } = await requireRecordAuth("read", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
const digest = await loadDailyDigest(user);
@@ -38,9 +38,19 @@ vi.mock("next/headers", () => ({
}));
const buildDashboardSnapshot = vi.fn();
vi.mock("@/lib/dashboard/snapshot", () => ({
vi.mock("@/lib/dashboard/snapshot", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/dashboard/snapshot")>()),
buildDashboardSnapshot: (...a: unknown[]) => buildDashboardSnapshot(...a),
}));
// The per-read briefing capability; its own behaviour is pinned in
// `snapshot-read-briefing-capability.test.ts`.
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
@@ -1,254 +1,120 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { describe, expect, it } from "vitest";
/**
* v1.4.38 W-C M6 — Coach API route gate inventory.
*
* The web surfaces are protected by the `flags.coach` short-circuit in
* every Coach-bearing component (see
* `src/lib/feature-flags/__tests__/coach-cascade.test.tsx`). The
* server-side mirror is `requireAssistantSurface("coach")` in every
* Coach-only route handler. Without a discovery test, a future
* contributor who lands a new `/api/insights/<foo>/route.ts` whose body
* touches the Coach stack would have to remember the gate by hand — a
* silent miss leaks the surface even when the operator turned the
* Coach matrix off.
*
* The walk below scans every `route.ts` under `src/app/api/insights/`
* and groups each handler into one of three buckets:
*
* 1. Coach-gated — file imports + invokes
* `requireAssistantSurface("coach")`.
* 2. Other-gated — file invokes `requireAssistantSurface()` with a
* different surface (`insightStatus`,
* `correlations`, `briefing`).
* 3. Allowlisted — non-Coach Insights routes that don't gate on
* the matrix at all (provider chain, settings,
* targets, glp1-timeline, feedback). Allowlist
* lives alongside the test for easy review.
*
* Anything that doesn't fit one of the three buckets is an orphan and
* fails the test by name so the fix is one search-and-add.
*/
const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
// Non-Coach assistant surfaces — gated on a sibling sub-flag.
// Per-biomarker assessment. Gated on the same `insightStatus` sub-flag as
// the metric-status + specialised status routes (no Coach prose).
"src/app/api/insights/biomarker-assessment/route.ts",
"src/app/api/insights/blood-pressure-status/route.ts",
"src/app/api/insights/bmi-status/route.ts",
"src/app/api/insights/cards/route.ts",
"src/app/api/insights/medication-compliance-status/route.ts",
// v1.8.7.1 — generic per-HealthKit-metric assessment. Gated on the
// same `insightStatus` sub-flag as the seven specialised status routes.
"src/app/api/insights/metric-status/route.ts",
"src/app/api/insights/mood-status/route.ts",
// v1.11.0 — period-narrative read route. Gates on the same `insightStatus`
// sub-flag as the assessment routes (no Coach prose).
"src/app/api/insights/narrative/route.ts",
// v1.9.0 — on-demand full assessment warm. Warms the same assessment
// cards the status routes serve, so it gates on `insightStatus`, not
// `coach`: a user with assessments enabled but Coach disabled can warm.
"src/app/api/insights/pregenerate/route.ts",
"src/app/api/insights/pulse-status/route.ts",
"src/app/api/insights/weight-status/route.ts",
];
const NOT_COACH_OWNED_ROUTES: ReadonlyArray<string> = [
// Settings + chain inventory — surface-agnostic infra reads. The
// settings UI itself is server-rendered and the chain page hides
// when no provider is configured; neither one carries assistant
// prose. Disabling Coach must not break these reads — the user can
// still inspect and reconfigure the chain that gets re-enabled.
"src/app/api/insights/provider-chain/route.ts",
// Correlation statistics: computed, never written by a model. The switch
// that used to gate them was retired because it stopped no AI work.
"src/app/api/insights/correlations/route.ts",
"src/app/api/insights/settings/route.ts",
// Read-only data feeds — `/targets` is a non-assistant Zielwerte
// page driven by classifications, not LLM prose; the GLP-1 timeline
// is the same data the medication page paints whether the assistant
// is on or off. Recommendation feedback (`/feedback`) records a
// thumbs-up/-down on a NON-Coach recommendation row that was
// produced before the operator flipped the flag; gating it would
// mean the operator's mid-flight toggle erases the user's
// ability to dispose of stale recommendations.
"src/app/api/insights/feedback/route.ts",
"src/app/api/insights/glp1-timeline/route.ts",
// Deterministic GLP-1 plateau detector read (no assistant prose) — same
// medications-domain posture as glp1-timeline directly above.
"src/app/api/insights/glp1-plateau/route.ts",
// v1.5.5 — per-user tile layout for the `/insights` surface. The
// endpoint persists tile visibility + ordering only; it carries no
// assistant prose and is the mirror of `/api/dashboard/widgets`,
// which also sits outside the Coach gate. Disabling Coach must not
// wedge the user's ability to reorder the insights tile strip.
"src/app/api/insights/layout/route.ts",
"src/app/api/insights/targets/route.ts",
// v1.25 — read-only awareness cards for the overview. Each is pure compute
// over the rollup / lab tier (baseline-drift, sleep-breathing screening,
// last-panel lab deltas) and carries no assistant prose; they gate on the
// `insights` module, not on the Coach surface. Disabling Coach must not
// wedge these factual reads.
"src/app/api/insights/health-status/route.ts",
"src/app/api/insights/breathing-screening/route.ts",
"src/app/api/insights/labs-changes/route.ts",
// S11 — the intraday pulse / tension read. Deterministic compute over the
// day's raw PULSE / steps / workouts (10-minute mean shape + the cautious
// elevated-at-rest window); it carries no assistant prose and gates on the
// `insights` module, not the Coach surface. Same posture as health-status /
// labs-changes directly above — disabling Coach must not wedge this read.
"src/app/api/insights/pulse/intraday/route.ts",
// Persisted correlation decisions are deterministic Insights data. They
// gate on the Insights module and remain usable when Coach is disabled.
"src/app/api/insights/patterns/route.ts",
"src/app/api/insights/patterns/[id]/route.ts",
// v1.29.x (S7) — the FENCED multi-document coach chat + its attach/detach
// mutations. These live under /insights/chat for URL grouping but are
// DOCUMENT-surface routes: they gate on the `inboundDocuments` module
// (`requireModuleEnabled`), NOT on the Coach assistant surface — parity with
// the document-sheet chat (`/api/documents/inbound/[id]/chat`, outside this
// scan). The security boundary here is the fence (no tools, no snapshot),
// not the Coach gate; disabling Coach must not wedge a fenced document chat
// any more than it wedges the document sheet. See the S7 design §3.2 step 1.
"src/app/api/insights/chat/fenced/route.ts",
"src/app/api/insights/chat/[id]/attachments/route.ts",
"src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts",
// Deterministic reads behind the Insights overview: the comprehensive
// overview query, the derived-metric tiles, the ECG list, strip and live
// ingest, and the device-flagged rhythm events. None of them carries
// assistant prose or calls a provider; they gate on the `insights` module
// only. Switching the assistant off, the master or a sub-flag, must not
// refuse them, or the overview fails to load.
"src/app/api/insights/comprehensive/route.ts",
"src/app/api/insights/derived/route.ts",
"src/app/api/insights/derived/batch/route.ts",
"src/app/api/insights/ecg/route.ts",
"src/app/api/insights/ecg/[id]/route.ts",
"src/app/api/insights/rhythm-events/route.ts",
];
const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
const ANY_GATE_NEEDLE = "requireAssistantSurface(";
import {
AI_ROUTES,
AI_ROUTE_TREES,
DATA_ROUTES,
PENDING_ROUTES,
} from "../../__tests__/ai-route-inventory";
/**
* Return true when the file contains the needle on a line that is NOT
* a pure comment. A documentation comment that mentions the gate
* (`// requireAssistantSurface("coach") protects this surface`) must
* not satisfy the gate-presence check — otherwise a contributor who
* deletes the actual call but leaves the docstring would slip through.
* Every route in the trees where AI routes live is classified.
*
* This file used to sort the `/api/insights` routes into "Coach-gated",
* "other-gated" and "not Coach-owned" by the operator switch each one read.
* That sorting encoded the defect the AI-optional design removes: ECG uploads,
* rhythm events, scores and correlation statistics sat behind AI switches, so
* turning AI off took data down with it. The rule now is the capability rule:
*
* - a data route asks no AI capability and is never refused for an AI
* reason;
* - an AI route names its capability (`requireAiCapability` for an action,
* `getAiCapability` for a mixed read).
*
* The lists live in `src/app/api/__tests__/ai-route-inventory.ts`; the whole
* API tree is checked against them by `ai-capability-route-inventory.test.ts`.
* This file walks the insights, Coach and daily trees and fails by name on any
* route that none of the lists classifies, so a new route there has to decide
* which kind it is. `PENDING_ROUTES` names the few whose AI gating another
* change owns; an entry there that starts asking a capability fails until it
* moves to `AI_ROUTES`.
*
* Mutation checks: deleting `insights/ecg/route.ts` from `DATA_ROUTES` fails
* the orphan test by name; adding `getAiCapability("coach")` to a pending
* route fails the pending test; pointing the walk at an empty tree fails the
* count.
*/
function fileHasGateCall(text: string, needle: string): boolean {
return text.split("\n").some((line) => {
if (!line.includes(needle)) return false;
const trimmed = line.trim();
if (trimmed.startsWith("//")) return false;
if (trimmed.startsWith("*")) return false;
return true;
});
}
/**
* Walk every `route.ts` under `src/app/api/insights/` (including
* dynamic segments under `[id]`). Returns POSIX-style paths relative
* to the repo root.
*/
function findInsightsRouteFiles(): string[] {
// __dirname here is `src/app/api/insights/__tests__/`, so the
// insights root is one directory up.
const insightsRoot = resolve(__dirname, "..");
const repoRoot = resolve(insightsRoot, "..", "..", "..", "..");
const repoRoot = resolve(__dirname, "..", "..", "..", "..", "..");
function routeFiles(tree: string): string[] {
const hits: string[] = [];
function walk(dir: string): void {
const entries = readdirSync(dir);
for (const entry of entries) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
if (entry === "__tests__") continue;
if (entry === "node_modules") continue;
if (statSync(full).isDirectory()) {
if (entry === "__tests__" || entry === "node_modules") continue;
walk(full);
continue;
} else if (entry === "route.ts") {
hits.push(relative(repoRoot, full).split(/[\\/]/).join("/"));
}
if (entry !== "route.ts") continue;
hits.push(relative(repoRoot, full).split(/[\\/]/).join("/"));
}
}
walk(insightsRoot);
return hits.sort();
walk(resolve(repoRoot, tree));
return hits;
}
describe("Coach API route gate inventory", () => {
it("every Coach-bearing insights route imports requireAssistantSurface('coach')", () => {
const repoRoot = resolve(__dirname, "..", "..", "..", "..", "..");
const routes = findInsightsRouteFiles();
expect(routes.length).toBeGreaterThan(0);
const ROUTES = AI_ROUTE_TREES.flatMap(routeFiles).sort();
const allowlistOther = new Set(NON_COACH_GATED_ROUTES);
const allowlistNone = new Set(NOT_COACH_OWNED_ROUTES);
const ANY_GATE =
/\b(?:requireAiCapability|getAiCapability|aiCapabilityToServe)\s*\(/;
const orphans: Array<{ path: string; reason: string }> = [];
function code(path: string): string {
return readFileSync(resolve(repoRoot, path), "utf8")
.split("\n")
.filter((line) => !/^\s*(\*|\/\/|\/\*)/.test(line))
.join("\n");
}
for (const path of routes) {
const full = resolve(repoRoot, path);
const text = readFileSync(full, "utf8");
const hasCoachGate = fileHasGateCall(text, COACH_GATE_NEEDLE);
const hasAnyGate = fileHasGateCall(text, ANY_GATE_NEEDLE);
if (hasCoachGate) continue;
if (allowlistOther.has(path)) {
// Sanity — the file should still gate on SOME assistant
// surface. A stale entry on this allowlist is misleading
// documentation; flip the failure mode so the orphan is
// reported with the right diagnosis.
if (!hasAnyGate) {
orphans.push({
path,
reason:
"listed in NON_COACH_GATED_ROUTES but has no requireAssistantSurface() call at all",
});
}
continue;
}
if (allowlistNone.has(path)) continue;
orphans.push({
path,
reason: `missing requireAssistantSurface("coach"); add the gate or move the route onto an allowlist (NON_COACH_GATED_ROUTES / NOT_COACH_OWNED_ROUTES)`,
});
describe("insights, Coach and daily route classification", () => {
it("walks trees that exist and finds routes in them", () => {
for (const tree of AI_ROUTE_TREES) {
expect(existsSync(resolve(repoRoot, tree)), tree).toBe(true);
}
expect(ROUTES.length).toBeGreaterThan(40);
});
it("classifies every route as AI, data or pending", () => {
const orphans = ROUTES.filter(
(path) =>
!(path in AI_ROUTES) &&
!(path in DATA_ROUTES) &&
!(path in PENDING_ROUTES),
);
expect(
orphans,
[
"Coach API route gate inventory found unaccounted-for handler(s):",
...orphans.map((o) => ` - ${o.path}: ${o.reason}`),
"Unclassified routes. Decide what each one is and add it to",
"src/app/api/__tests__/ai-route-inventory.ts:",
" AI_ROUTES — it calls a model or serves model text (name the capability);",
" DATA_ROUTES — it serves or accepts data and must never ask AI.",
].join("\n"),
).toEqual([]);
});
it("allowlists do not reference deleted route files", () => {
const known = new Set(findInsightsRouteFiles());
const stale = [...NON_COACH_GATED_ROUTES, ...NOT_COACH_OWNED_ROUTES].filter(
(path) => !known.has(path),
it("classifies no route twice", () => {
const lists = [AI_ROUTES, DATA_ROUTES, PENDING_ROUTES].map((list) =>
Object.keys(list),
);
const seen = new Map<string, number>();
for (const list of lists) {
for (const path of list) seen.set(path, (seen.get(path) ?? 0) + 1);
}
expect([...seen].filter(([, n]) => n > 1).map(([path]) => path)).toEqual(
[],
);
});
expect(
stale,
[
"Allowlist entries point to files that no longer exist —",
"delete the stale entries from",
"`src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts`:",
...stale.map((p) => ` - ${p}`),
].join("\n"),
).toEqual([]);
it("keeps PENDING_ROUTES honest: existing files, none of them asking yet", () => {
for (const path of Object.keys(PENDING_ROUTES)) {
expect(ROUTES, `${path} no longer exists`).toContain(path);
expect(
ANY_GATE.test(code(path)),
`${path} now asks an AI capability — move it to AI_ROUTES`,
).toBe(false);
}
});
});
@@ -6,6 +6,12 @@ vi.mock("@/lib/db", () => ({
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
// The `statusText` capability decides whether the note is served; the
// unavailable body's provider-presence probe is stubbed.
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() }));
vi.mock("@/lib/ai/provider", () => ({
probeProviderPresence: vi.fn(async () => true),
}));
// The route resolves the `insights` module gate after `requireAuth()`. Mock
// it default-enabled so the assertions ride through; the off → 403 coverage
@@ -57,6 +63,7 @@ import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -80,6 +87,11 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(aiCapabilityToServe).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
vi.mocked(generateBiomarkerStatus).mockResolvedValue({
hasProvider: true,
text: "ok",
@@ -121,19 +133,22 @@ describe("GET /api/insights/biomarker-assessment", () => {
expect(res.status).toBe(401);
});
it("403s + errorCode when insightStatus is disabled", async () => {
it("answers 200 with no note while statusText is unavailable, calling no generator", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantDocumentAiEnabled: true,
} as never);
vi.mocked(aiCapabilityToServe).mockResolvedValue({
available: false,
reason: "user_disabled",
onDeviceAllowed: false,
});
const res = await callGet(makeReq("bm-1"));
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data).toMatchObject({
text: null,
preparing: false,
ai: { available: false, reason: "user_disabled" },
});
expect(generateBiomarkerStatus).not.toHaveBeenCalled();
});
it("preserves the safe terminal fallback produced when a causal claim is screened", async () => {
@@ -161,6 +176,7 @@ describe("GET /api/insights/biomarker-assessment", () => {
text: "The assessment could not be completed safely. Your recorded measurements remain available.",
cached: true,
updatedAt: null,
ai: { available: true, reason: null, onDeviceAllowed: true },
});
expect(JSON.stringify(body)).not.toContain("medication caused");
expect(JSON.stringify(body)).not.toContain("causal_claim");
@@ -12,6 +12,12 @@
* provider. The generator regenerates ONLY when the latest reading
* fingerprint changes, so an idle marker re-stamps cached text without an
* LLM round-trip.
*
* A mixed read: the note is served, and warmed, only while the `statusText`
* AI capability is available. Otherwise the route answers 200 with no note
* and an `ai` state saying why, without reading the cache or queueing
* anything. The `insights` module (the AI analysis opt-out) folds into the
* capability.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -19,8 +25,8 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status";
import { resolveMetricStatusLocale } from "@/lib/insights/metric-status";
@@ -35,10 +41,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const parsed = querySchema.safeParse({
biomarkerId: request.nextUrl.searchParams.get("biomarkerId"),
});
@@ -50,6 +52,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
const ai = await aiCapabilityToServe(user.id, "statusText");
if (!ai.available) {
annotate({
action: { name: "insights.biomarker-status.unavailable" },
meta: { reason: ai.reason },
});
return apiSuccess(await unavailableStatusBody(user.id, ai));
}
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
request,
@@ -71,5 +82,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
meta: { biomarkerId: parsed.data.biomarkerId },
});
return apiSuccess(result);
return apiSuccess({ ...result, ai });
});
@@ -1,19 +1,20 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
/**
* The status family is a mixed read. The note is model text: served only
* while the `statusText` capability is available. Otherwise the route
* answers 200 with `text: null`, `preparing: false`, `hasProvider` as provider
* presence and an `ai` state, and the generator (the cache read and the warm
* enqueue behind it) is never called. The `insights` module is folded into
* the capability and no longer refuses the route.
*/
vi.mock("@/lib/db", () => ({ prisma: {} }));
// v1.18.0 — the route now resolves the `insights` module gate after
// `requireAuth()`. Mock it default-enabled so the existing assertions
// ride through; the off → 403 coverage lives in the route-gate inventory
// test.
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
requireModuleEnabled: vi.fn(),
resolveModuleMap: vi.fn().mockResolvedValue({}),
}));
@@ -38,22 +39,28 @@ vi.mock("next/headers", () => ({
})),
}));
// Stub the heavy generator so the gate test stays isolated from the
// real status pipeline.
vi.mock("@/lib/insights/blood-pressure-status", () => ({
generateBloodPressureStatusForUser: vi.fn(async () => ({
available: true,
locale: "en",
text: "ok",
})),
generateBloodPressureStatusForUser: vi.fn(),
resolveBloodPressureStatusLocale: () => "en",
}));
// The `statusText` capability decides whether the note is served; the
// unavailable body's provider-presence probe is stubbed.
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() }));
vi.mock("@/lib/ai/provider", () => ({
probeProviderPresence: vi.fn(async () => true),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { apiError } from "@/lib/api-response";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { probeProviderPresence } from "@/lib/ai/provider";
import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status";
import {
AI_AVAILABLE,
aiUnavailable,
} from "@/__tests__/helpers/ai-capability-fixtures";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -72,30 +79,63 @@ function makeReq(): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
vi.mocked(probeProviderPresence).mockResolvedValue(true);
vi.mocked(generateBloodPressureStatusForUser).mockResolvedValue({
hasProvider: true,
text: "ok",
cached: true,
updatedAt: "2026-09-20T06:00:00.000Z",
});
});
describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () => {
it("returns 200 when insightStatus is enabled (default)", async () => {
describe("GET /api/insights/blood-pressure-status", () => {
it("serves the note and the state while statusText is available", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data.text).toBe("ok");
expect(body.data.ai).toEqual(AI_AVAILABLE);
expect(aiCapabilityToServe).toHaveBeenCalledWith("user-1", "statusText");
expect(requireModuleEnabled).not.toHaveBeenCalled();
});
it("returns 403 + errorCode when insightStatus is off", async () => {
it.each([
"operator_disabled",
"user_disabled",
"consent_required",
"check_failed",
] as const)(
"answers 200 with no note for %s, reading and warming nothing",
async (reason) => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(aiCapabilityToServe).mockResolvedValue(aiUnavailable(reason));
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data).toEqual({
hasProvider: true,
text: null,
cached: false,
updatedAt: null,
preparing: false,
ai: aiUnavailable(reason),
});
expect(generateBloodPressureStatusForUser).not.toHaveBeenCalled();
},
);
it("reports hasProvider as presence only: false for a missing provider", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantDocumentAiEnabled: true,
} as never);
vi.mocked(aiCapabilityToServe).mockResolvedValue(
aiUnavailable("no_provider"),
);
vi.mocked(probeProviderPresence).mockResolvedValue(false);
const res = await callGet(makeReq());
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data.hasProvider).toBe(false);
expect(body.data.text).toBeNull();
});
it("returns 401 when unauthenticated", async () => {
@@ -104,33 +144,3 @@ describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () =
expect(res.status).toBe(401);
});
});
// v1.18.0 (B2) — the route now also requires the `insights` module.
// Representative coverage that a disabled module short-circuits with the
// 403 module.disabled envelope before any provider / cache work; the
// inventory test guards that EVERY insights AI route carries this gate.
describe("GET /api/insights/blood-pressure-status — insights module gate", () => {
it("returns 200 when insights is enabled (default)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
});
it("returns 403 + module.disabled when insights is off", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
enabled: false,
response: apiError('Module "insights" is not enabled', 403, {
errorCode: "module.disabled",
module: "insights",
}),
});
const res = await callGet(makeReq());
expect(res.status).toBe(403);
const body = (await res.json()) as {
meta?: { errorCode?: string; module?: string };
};
expect(body.meta?.errorCode).toBe("module.disabled");
expect(body.meta?.module).toBe("insights");
});
});
@@ -7,19 +7,34 @@ import {
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
export const dynamic = "force-dynamic";
/**
* A mixed read: the card's frame is data, the note inside it is model text.
* The note is served, and warmed on a miss, only while the `statusText` AI
* capability is available. Otherwise the route answers 200 with no note
* (`text: null`, `preparing: false`, `hasProvider` as provider presence) and
* an `ai` state saying why, without reading the cache or queueing anything.
* The `insights` module is the AI analysis opt-out and is folded into the
* capability, so it no longer refuses the route.
*/
export const GET = apiHandler(async (request: NextRequest) => {
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const ai = await aiCapabilityToServe(user.id, "statusText");
if (!ai.available) {
annotate({
action: { name: "insights.blood-pressure-status.unavailable" },
meta: { reason: ai.reason },
});
return apiSuccess(await unavailableStatusBody(user.id, ai));
}
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
@@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "insights.blood-pressure-status" } });
return apiSuccess(result);
return apiSuccess({ ...result, ai });
});
+21 -6
View File
@@ -7,19 +7,34 @@ import {
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
export const dynamic = "force-dynamic";
/**
* A mixed read: the card's frame is data, the note inside it is model text.
* The note is served, and warmed on a miss, only while the `statusText` AI
* capability is available. Otherwise the route answers 200 with no note
* (`text: null`, `preparing: false`, `hasProvider` as provider presence) and
* an `ai` state saying why, without reading the cache or queueing anything.
* The `insights` module is the AI analysis opt-out and is folded into the
* capability, so it no longer refuses the route.
*/
export const GET = apiHandler(async (request: NextRequest) => {
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const ai = await aiCapabilityToServe(user.id, "statusText");
if (!ai.available) {
annotate({
action: { name: "insights.bmi-status.unavailable" },
meta: { reason: ai.reason },
});
return apiSuccess(await unavailableStatusBody(user.id, ai));
}
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
@@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "insights.bmi-status" } });
return apiSuccess(result);
return apiSuccess({ ...result, ai });
});
@@ -10,7 +10,7 @@
* classification). SCREENING SIGNAL ONLY — never a HealthLog diagnosis; the
* card states this explicitly.
*
* Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `insights`
* Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `sleep`
* module gate, the shared analytics-read budget. `userId` is narrowed from the
* session.
*/
@@ -31,7 +31,10 @@ export const GET = apiHandler(async () => {
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
// The screening belongs to sleep: with the sleep module off it hides like
// every other sleep surface. Not an AI read, so the `insights` module (the
// AI analysis opt-out) does not gate it.
const m = await requireModuleEnabled(user.id, "sleep");
if (!m.enabled) return m.response;
const rl = await checkAnalyticsReadRateLimit(user.id);
@@ -15,9 +15,8 @@ vi.mock("@/lib/db", () => ({
measurement: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
// v1.4.31 — gated on `assistant.insightStatus`; null row falls
// back to the all-on default so existing assertions ride
// through unchanged.
// The operator switches are read by nothing on this route any more; the
// mock stays so a test can prove a switch-off changes nothing.
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
// v1.28 perf — PULSE now reads through the DAY-rollup read-swap
// (`probeRollupCoverage` + `readDayMeanSeries`). An empty coverage
@@ -28,10 +27,8 @@ vi.mock("@/lib/db", () => ({
},
}));
// v1.18.0 — the route now resolves the `insights` module gate after
// `requireAuth()`. Mock it default-enabled so the existing assertions
// (data shape + assistant-flag gating) ride through; the dedicated
// off → 403 coverage lives in the route-gate inventory test.
// The route no longer asks the `insights` module (the AI analysis opt-out);
// the mock stays so a test can prove it is never consulted.
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
@@ -80,7 +77,6 @@ beforeEach(() => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: null,
dateOfBirth: null,
aiProvider: null,
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
// v1.28 perf — empty coverage probe routes PULSE through the
@@ -94,33 +90,29 @@ function makeReq(): NextRequest {
return new NextRequest("http://localhost/api/insights/cards");
}
describe("GET /api/insights/cards — assistant-flag gate", () => {
it("returns 403 + errorCode when insightStatus is disabled", async () => {
describe("GET /api/insights/cards — rule alerts are data", () => {
it("answers 200 with every AI switch off", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
assistantEnabled: false,
assistantCoachEnabled: false,
assistantBriefingEnabled: false,
assistantInsightStatusEnabled: false,
assistantDocumentAiEnabled: true,
assistantDocumentAiEnabled: false,
} as never);
const res = await callGet(makeReq());
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
expect(res.status).toBe(200);
});
it("returns 403 when the master flag is off", async () => {
it("answers 200 with the AI analysis opt-out on, without asking the module", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
assistantEnabled: false,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantDocumentAiEnabled: true,
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: new Response(null, { status: 403 }),
} as never);
const res = await callGet(makeReq());
expect(res.status).toBe(403);
expect(res.status).toBe(200);
expect(requireModuleEnabled).not.toHaveBeenCalled();
});
});
@@ -165,19 +157,23 @@ describe("GET /api/insights/cards", () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue(
measurements as never,
);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: null,
dateOfBirth: null,
aiProvider: "ANTHROPIC",
} as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: Array<{ severity: string; provider: string; title: string }>;
data: Array<{
severity: string;
provider: string;
title: string;
summary: string | null;
}>;
};
expect(body.data.length).toBeGreaterThan(0);
expect(body.data[0].provider).toBe("anthropic");
// The threshold engine wrote these, never a model: the provider says so
// and no card ever carries null text (a native decoder needs both).
for (const card of body.data) {
expect(card.provider).toBe("rules");
expect(typeof card.summary).toBe("string");
}
expect(body.data.some((c) => c.severity === "alert")).toBe(true);
});
+9 -10
View File
@@ -5,10 +5,14 @@
* shared `generateAlerts()` rule engine, then re-shapes each `HealthAlert`
* to the iOS Insight model (id, title, summary, severity, recommendations,
* provider).
*
* Every card is a rule alert: deterministic text from the threshold engine,
* never model output. So the route carries no AI gate and no `insights`
* module gate (the AI analysis opt-out), `summary` is never null, and
* `provider` is `"rules"`. It used to echo the account's chosen AI provider
* (or `"claude"`), which claimed a model wrote text that none did.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
@@ -68,12 +72,6 @@ export const GET = apiHandler(async () => {
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// v1.4.31 — the iOS cards adapter feeds the same per-metric
// insight surfaces the web `<InsightStatusCard>` mounts on each
// /insights/<metric> sub-page. Both share the operator gate.
await requireAssistantSurface("insightStatus");
annotate({ action: { name: "insights.cards" } });
const NINETY_DAY_WINDOW = 90;
@@ -83,7 +81,7 @@ export const GET = apiHandler(async () => {
await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: { heightCm: true, dateOfBirth: true, aiProvider: true },
select: { heightCm: true, dateOfBirth: true },
}),
// WEIGHT + BP are manual entries (low volume, and BP needs exact-
// timestamp pairing below for `bpPctInTarget`), so a bounded raw read
@@ -257,7 +255,8 @@ export const GET = apiHandler(async () => {
medications: medicationCompliance,
});
const provider = dbUser?.aiProvider?.toLowerCase() ?? "claude";
// The rule engine wrote these, not a model.
const provider = "rules";
const generatedAt = new Date().toISOString();
const cards: InsightCard[] = alerts.map((alert, idx) => ({
@@ -2,14 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import type * as ApiHandlerModule from "@/lib/api-handler";
const { requireAuth, requireAssistantSurface, annotate, auditLog } = vi.hoisted(
() => ({
requireAuth: vi.fn(),
requireAssistantSurface: vi.fn(),
annotate: vi.fn(),
auditLog: vi.fn(),
}),
);
const { requireAuth, annotate, auditLog } = vi.hoisted(() => ({
requireAuth: vi.fn(),
annotate: vi.fn(),
auditLog: vi.fn(),
}));
vi.mock("@/lib/api-handler", async (importOriginal) => {
const actual = await importOriginal<typeof ApiHandlerModule>();
@@ -20,7 +17,6 @@ vi.mock("@/lib/api-handler", async (importOriginal) => {
requireAuth,
};
});
vi.mock("@/lib/feature-flags", () => ({ requireAssistantSurface }));
vi.mock("@/lib/logging/context", () => ({ annotate }));
vi.mock("@/lib/auth/audit", () => ({ auditLog }));
vi.mock("@/lib/ai/coach/persistence", () => ({
@@ -65,7 +61,6 @@ async function body(response: Response) {
beforeEach(() => {
vi.clearAllMocks();
requireAuth.mockResolvedValue({ user: { id: USER_ID, locale: "en" } });
requireAssistantSurface.mockResolvedValue(undefined);
auditLog.mockResolvedValue(undefined);
});
@@ -77,7 +72,6 @@ describe("PATCH /api/insights/chat/[id]", () => {
await expect(PATCH(request({ title: "Renamed" }), context())).rejects.toBe(
unauthenticated,
);
expect(requireAssistantSurface).not.toHaveBeenCalled();
expect(renameConversation).not.toHaveBeenCalled();
});
+5 -10
View File
@@ -21,7 +21,6 @@ import {
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { auditLog } from "@/lib/auth/audit";
import { COACH_CONVERSATION_TITLE_MAX } from "@/lib/ai/coach/types";
@@ -43,10 +42,9 @@ const renameConversationSchema = z
export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
const auth = await requireAuth();
// v1.4.38 W-C M6 — operator can hide the Coach surface app-wide;
// the conversation reader is part of the Coach stack (encrypted
// assistant prose), so a disabled surface must 403 here too.
await requireAssistantSurface("coach");
// Never AI-gated: a stored conversation is the person's own data. Reading,
// renaming and deleting it keep working while the Coach is unavailable for
// any reason, so a thread can always be reviewed and erased.
const { id } = await ctx.params;
if (!id) return apiError("coach.conversation.notFound", 404);
@@ -74,7 +72,6 @@ export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
export const PATCH = apiHandler(async (request: NextRequest, ctx: RouteCtx) => {
const auth = await requireAuth();
await requireAssistantSurface("coach");
const { id } = await ctx.params;
if (!id) return apiError("coach.conversation.notFound", 404);
@@ -114,10 +111,8 @@ export const PATCH = apiHandler(async (request: NextRequest, ctx: RouteCtx) => {
export const DELETE = apiHandler(
async (_request: NextRequest, ctx: RouteCtx) => {
const auth = await requireAuth();
// v1.4.38 W-C M6 — same gate as GET; a disabled Coach surface
// means the user can't reach the conversation list anyway, so the
// delete affordance must stay behind the same kill-switch.
await requireAssistantSurface("coach");
// Not AI-gated, like GET: deleting one's own thread never waits on the
// Coach being available.
const { id } = await ctx.params;
if (!id) return apiError("coach.conversation.notFound", 404);
@@ -26,11 +26,14 @@ vi.mock("@/lib/api-handler", () => ({
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
vi.mock("@/lib/ai/capabilities/gate", () => ({
requireAiCapability: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number) => ({ data: null, error, status }),
@@ -0,0 +1,224 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
/**
* The Coach chat POST (SSE) asks for the `coach` AI capability right after
* auth, before the body is parsed or anything else runs. The capability folds
* in what the Coach module gate, the operator switch and the `disableCoach`
* opt-out used to answer separately, plus provider presence and consent.
*
* - Unavailable for any reason but a missing provider: the capability
* envelope is thrown (apiHandler renders it) and nothing else runs.
* - `no_provider`: the existing `coach.provider.none` SSE frame, now with
* `reason`, because the streaming clients already read that frame.
*
* The conversation-list GET is the person's stored data: it never asks.
*/
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
}));
const { requireAiCapability } = vi.hoisted(() => ({
requireAiCapability: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({ requireAiCapability }));
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn(async () => true),
MODULE_DISABLED_ERROR_CODE: "module.disabled",
}));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number, meta?: unknown) => ({
data: null,
error,
status,
meta,
}),
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
apiValidationError: (error: string, _issues: unknown, status: number) =>
new Response(JSON.stringify({ data: null, error }), { status }),
sanitiseZodIssues: () => [],
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
AllProvidersFailedError: class extends Error {},
runRawCompletionWithFallback: vi.fn(),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: vi.fn(),
resolveProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain: vi.fn() }));
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
vi.mock("@/lib/ai/coach/types", () => ({
coachChatRequestSchema: {
safeParse: () => ({ success: false, error: { issues: [] } }),
},
}));
// v1.20.0 (F1) — the route statically imports the tool barrel; this suite
// never reaches tool code (it asserts the 403 module-gate short-circuit), so a
// thin stub keeps the import graph satisfied without pulling the real schemas.
vi.mock("@/lib/ai/coach/tools", () => ({
COACH_TOOL_DEFS: [],
MAX_ROUNDS: 3,
buildCoachDataInventory: vi.fn(),
renderDataInventory: vi.fn(),
renderFocusHint: vi.fn(() => ""),
buildToolModeAddendum: vi.fn(),
runCoachToolLoop: vi.fn(),
}));
vi.mock("@/lib/ai/coach/persistence", () => ({
appendMessage: vi.fn(),
createConversation: vi.fn(),
fetchConversationWithMessages: vi.fn(),
listConversations: vi.fn(async () => ({
conversations: [],
nextCursor: null,
})),
}));
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
enqueueCoachMemoryRefresh: vi.fn(),
}));
vi.mock("@/lib/ai/coach/facts", () => ({ storeDeterministicFacts: vi.fn() }));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(),
reserveBudget: vi.fn(async () => ({
allowed: true,
reserved: 0,
totalAfter: 0,
})),
reconcileSpend: vi.fn(),
resolveDailyCap: vi.fn(() => 200_000),
resolveDailyCapFor: vi.fn(() => 200_000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(),
}));
vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot: vi.fn() }));
vi.mock("@/lib/ai/coach/keyvalues", () => ({
parseKeyValuesSentinel: vi.fn(),
}));
vi.mock("@/lib/validations/coach-prefs", () => ({ parseCoachPrefs: vi.fn() }));
// Collect the frames a stream would carry, so a refusal frame is observable.
vi.mock("@/lib/sse/create-stream", () => ({
createSseStream: vi.fn(
(start: (c: { enqueue: (chunk: Uint8Array) => void }) => void) => {
const chunks: string[] = [];
start({
enqueue: (chunk) => chunks.push(new TextDecoder().decode(chunk)),
});
return chunks.join("");
},
),
}));
import { POST, GET } from "../route";
type Envelope = { data: unknown; error: string | null; status: number };
const post = POST as unknown as (req: Request) => Promise<Response>;
const get = GET as unknown as (req: Request) => Promise<Envelope>;
function chatPostReq(): Request {
return new Request("http://localhost/api/insights/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "hi" }),
});
}
function listReq(): Request {
return new Request("http://localhost/api/insights/chat", { method: "GET" });
}
describe("coach chat capability gate", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.each([
["operator_disabled", 403, "assistant.disabled.coach"],
["module_disabled", 403, "module.disabled"],
["user_disabled", 403, "module.disabled"],
["consent_required", 403, "consent.ai.required"],
["not_permitted_for_record", 403, "ai.record.notPermitted"],
["check_failed", 503, "ai.unavailable"],
] as const)(
"POST refuses with the capability envelope for %s",
async (reason, status, errorCode) => {
requireAiCapability.mockRejectedValue(
new AiUnavailableError("coach", reason),
);
const refused = await post(chatPostReq()).catch((err: unknown) => err);
expect(refused).toBeInstanceOf(AiUnavailableError);
const error = refused as AiUnavailableError;
expect(error.status).toBe(status);
expect(error.meta).toMatchObject({
errorCode,
capability: "coach",
reason,
});
expect(requireAiCapability).toHaveBeenCalledWith("coach");
},
);
it("POST keeps the coach.provider.none SSE frame for a missing provider, with the reason", async () => {
requireAiCapability.mockRejectedValue(
new AiUnavailableError("coach", "no_provider"),
);
const res = await post(chatPostReq());
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain('"code":"coach.provider.none"');
expect(body).toContain('"reason":"no_provider"');
});
it("POST continues past the gate when the Coach is available", async () => {
requireAiCapability.mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
const res = await post(chatPostReq());
// The stubbed schema refuses the body, so reaching validation proves the
// gate let the request through.
expect(res.status).toBe(422);
});
it("GET never asks for the capability: stored conversations stay readable", async () => {
requireAiCapability.mockRejectedValue(
new AiUnavailableError("coach", "operator_disabled"),
);
const res = await get(listReq());
expect(res.status).toBe(200);
expect(requireAiCapability).not.toHaveBeenCalled();
});
});
@@ -24,10 +24,14 @@ vi.mock("@/lib/api-handler", () => ({
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
vi.mock("@/lib/ai/capabilities/gate", () => ({
requireAiCapability: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number) => ({ data: null, error, status }),
@@ -37,11 +37,14 @@ vi.mock("@/lib/api-handler", () => ({
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
vi.mock("@/lib/ai/capabilities/gate", () => ({
requireAiCapability: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number) => ({ data: null, error, status }),
@@ -30,11 +30,14 @@ vi.mock("@/lib/api-handler", () => ({
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
vi.mock("@/lib/ai/capabilities/gate", () => ({
requireAiCapability: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number) => ({ data: null, error, status }),
@@ -27,11 +27,14 @@ vi.mock("@/lib/api-handler", () => ({
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
vi.mock("@/lib/ai/capabilities/gate", () => ({
requireAiCapability: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number) => ({ data: null, error, status }),
@@ -22,12 +22,9 @@ vi.mock("@/lib/api-handler", () => ({
},
}));
const { requireModuleEnabled, requireAssistantSurface } = vi.hoisted(() => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
requireAssistantSurface: vi.fn(async () => undefined),
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled }));
vi.mock("@/lib/feature-flags", () => ({ requireAssistantSurface }));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number, meta?: unknown) => ({
@@ -132,8 +129,6 @@ function listReq(qs = ""): Request {
describe("GET /api/insights/chat — q search param", () => {
beforeEach(() => {
vi.clearAllMocks();
requireModuleEnabled.mockResolvedValue({ enabled: true });
requireAssistantSurface.mockResolvedValue(undefined);
listConversations.mockResolvedValue({
conversations: [],
nextCursor: null,
@@ -1,204 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* v1.18.0 — the Coach chat POST (SSE) and the conversation-list GET both
* enforce the two-layer module gate (operator availability AND the
* per-user `disableCoach` opt-out) right after auth. A disabled module
* returns the 403 `module.disabled` envelope verbatim before any other
* Coach dependency runs; an enabled module falls through to the existing
* assistant-flag check. The suite pins both branches so a refactor can't
* quietly drop the gate.
*/
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
}));
// The legacy assistant-flag gate the handler reaches AFTER the module
// gate. We throw a sentinel so an enabled module is observable by the
// control reaching this call.
class AssistantSentinel extends Error {}
const { requireModuleEnabled, requireAssistantSurface } = vi.hoisted(() => ({
requireModuleEnabled: vi.fn(),
requireAssistantSurface: vi.fn(),
}));
vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled }));
vi.mock("@/lib/feature-flags", () => ({ requireAssistantSurface }));
// Everything else the module imports — stubbed harmlessly; none of it is
// reached on the disabled branch, and the enabled branch stops at the
// assistant sentinel above.
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number, meta?: unknown) => ({
data: null,
error,
status,
meta,
}),
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
AllProvidersFailedError: class extends Error {},
runRawCompletionWithFallback: vi.fn(),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: vi.fn(),
resolveProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain: vi.fn() }));
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
vi.mock("@/lib/ai/coach/types", () => ({
coachChatRequestSchema: { safeParse: () => ({ success: false }) },
}));
// v1.20.0 (F1) — the route statically imports the tool barrel; this suite
// never reaches tool code (it asserts the 403 module-gate short-circuit), so a
// thin stub keeps the import graph satisfied without pulling the real schemas.
vi.mock("@/lib/ai/coach/tools", () => ({
COACH_TOOL_DEFS: [],
MAX_ROUNDS: 3,
buildCoachDataInventory: vi.fn(),
renderDataInventory: vi.fn(),
renderFocusHint: vi.fn(() => ""),
buildToolModeAddendum: vi.fn(),
runCoachToolLoop: vi.fn(),
}));
vi.mock("@/lib/ai/coach/persistence", () => ({
appendMessage: vi.fn(),
createConversation: vi.fn(),
fetchConversationWithMessages: vi.fn(),
listConversations: vi.fn(async () => ({
conversations: [],
nextCursor: null,
})),
}));
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
enqueueCoachMemoryRefresh: vi.fn(),
}));
vi.mock("@/lib/ai/coach/facts", () => ({ storeDeterministicFacts: vi.fn() }));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(),
reserveBudget: vi.fn(async () => ({
allowed: true,
reserved: 0,
totalAfter: 0,
})),
reconcileSpend: vi.fn(),
resolveDailyCap: vi.fn(() => 200_000),
resolveDailyCapFor: vi.fn(() => 200_000),
resolveCostOwner: vi.fn(() => "operator" as const),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(),
}));
vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot: vi.fn() }));
vi.mock("@/lib/ai/coach/keyvalues", () => ({
parseKeyValuesSentinel: vi.fn(),
}));
vi.mock("@/lib/validations/coach-prefs", () => ({ parseCoachPrefs: vi.fn() }));
vi.mock("@/lib/sse/create-stream", () => ({ createSseStream: vi.fn() }));
import { POST, GET } from "../route";
type Envelope = {
data: unknown;
error: string | null;
status: number;
meta?: { errorCode?: string; module?: string };
};
const post = POST as unknown as (req: Request) => Promise<Envelope>;
const get = GET as unknown as (req: Request) => Promise<Envelope>;
const disabledResponse: Envelope = {
data: null,
error: 'Module "coach" is not enabled',
status: 403,
meta: { errorCode: "module.disabled", module: "coach" },
};
function chatPostReq(): Request {
return new Request("http://localhost/api/insights/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "hi" }),
});
}
function listReq(): Request {
return new Request("http://localhost/api/insights/chat", { method: "GET" });
}
describe("coach chat module gate", () => {
beforeEach(() => {
vi.clearAllMocks();
requireAssistantSurface.mockImplementation(async () => {
throw new AssistantSentinel("reached assistant surface");
});
});
it("POST returns 403 module.disabled when Coach is disabled", async () => {
requireModuleEnabled.mockResolvedValue({
enabled: false,
response: disabledResponse,
});
const res = await post(chatPostReq());
expect(res.status).toBe(403);
expect(res.meta?.errorCode).toBe("module.disabled");
expect(res.meta?.module).toBe("coach");
expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach");
// The gate short-circuits before the legacy assistant flag.
expect(requireAssistantSurface).not.toHaveBeenCalled();
});
it("POST falls through to the assistant flag when Coach is enabled", async () => {
requireModuleEnabled.mockResolvedValue({ enabled: true });
await expect(post(chatPostReq())).rejects.toBeInstanceOf(AssistantSentinel);
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
});
it("GET returns 403 module.disabled when Coach is disabled", async () => {
requireModuleEnabled.mockResolvedValue({
enabled: false,
response: disabledResponse,
});
const res = await get(listReq());
expect(res.status).toBe(403);
expect(res.meta?.errorCode).toBe("module.disabled");
expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach");
expect(requireAssistantSurface).not.toHaveBeenCalled();
});
it("GET reaches 200 when Coach is enabled", async () => {
requireModuleEnabled.mockResolvedValue({ enabled: true });
requireAssistantSurface.mockResolvedValue(undefined);
const res = await get(listReq());
expect(res.status).toBe(200);
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
});
});
@@ -28,10 +28,14 @@ vi.mock("@/lib/api-handler", () => ({
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
vi.mock("@/lib/ai/capabilities/gate", () => ({
requireAiCapability: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number) => ({ data: null, error, status }),
@@ -26,11 +26,14 @@ vi.mock("@/lib/api-handler", () => ({
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
vi.mock("@/lib/ai/capabilities/gate", () => ({
requireAiCapability: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number) => ({ data: null, error, status }),
@@ -41,11 +41,14 @@ const { isModuleEnabled } = vi.hoisted(() => ({
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
isModuleEnabled,
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
vi.mock("@/lib/ai/capabilities/gate", () => ({
requireAiCapability: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number) => ({ data: null, error, status }),
@@ -43,7 +43,6 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { resolveCoachFeedbackAttribution } from "@/lib/ai/feedback-attribution";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -60,11 +59,8 @@ interface RouteContext {
async function handlePost(request: NextRequest, ctx: RouteContext) {
const { user } = await requireAuth();
// v1.4.38 W-C M6 — Coach message feedback is a Coach surface
// affordance; gate it on the same operator kill-switch so a
// disabled Coach matrix can't be skirted through the feedback
// endpoint.
await requireAssistantSurface("coach");
// Not AI-gated: a rating on a message that already exists is data about
// the person's own thread and calls no model.
const { id: messageId } = await ctx.params;
// Per-user rate limit — same shape as the recommendation feedback
+61 -20
View File
@@ -7,7 +7,11 @@
* and a closing `done` frame carrying the persisted message ids.
*
* Behaviour:
* 1. requireAuth() — cookie session OR bearer token (iOS app).
* 1. requireAuth() — cookie session OR bearer token (iOS app), then the
* `coach` AI capability (operator switch, the Coach module and the
* `disableCoach` opt-out, provider presence, consent). Refused with the
* capability envelope, except `no_provider`, which keeps its
* `coach.provider.none` SSE frame (now with `reason`).
* 2. Validate body with `coachChatRequestSchema`.
* 3. reserveBudget(resolveDailyCap(chain)) — 429 with
* `coach.budget.exceeded` when the day's token cap for the
@@ -41,8 +45,10 @@ import { redactOptional, redactSecrets } from "@/lib/logging/redact";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate";
import { isModuleEnabled } from "@/lib/modules/gate";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import type { AiUnavailableReason } from "@/lib/ai/capabilities/types";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getServerTranslator } from "@/lib/i18n/server-translator";
@@ -187,16 +193,38 @@ function flushTick(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
/**
* The `coach` capability gate for a turn. Every unavailable reason throws the
* capability envelope (`assistant.disabled.coach`, `module.disabled`,
* `consent.ai.required`, `ai.record.notPermitted`, `ai.unavailable`), except
* `no_provider`: the streaming clients already read that one as the
* `coach.provider.none` SSE frame, so it keeps that wire and gains `reason`.
* Returns the frame to send, or null when the Coach is available.
*/
async function coachCapabilityRefusal(): Promise<Response | null> {
try {
await requireAiCapability("coach");
return null;
} catch (err) {
if (err instanceof AiUnavailableError && err.reason === "no_provider") {
annotate({ action: { name: "insights.coach.noProvider" } });
return streamProviderError({
code: "coach.provider.none",
reason: "no_provider",
});
}
throw err;
}
}
async function handleChatRequest(request: NextRequest): Promise<Response> {
const auth = await requireAuth();
// v1.18.0 — two-layer module gate (operator availability + per-user
// disableCoach) on top of the legacy assistant flag. 403 module.disabled.
const gate = await requireModuleEnabled(auth.user.id, "coach");
if (!gate.enabled) return gate.response;
// v1.4.31 — operator can disable the Coach surface app-wide.
// Throws AssistantDisabledError → apiHandler returns 403 +
// `errorCode: "assistant.disabled.coach"` per the iOS contract.
await requireAssistantSurface("coach");
// The `coach` capability, right after auth and before the rate limit and
// the body parse. It folds in what the Coach module gate, the operator
// switch and the `disableCoach` opt-out used to answer separately, plus
// provider presence and consent.
const refused = await coachCapabilityRefusal();
if (refused) return refused;
const userId = auth.user.id;
let body: unknown;
@@ -560,7 +588,10 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
annotate({
action: { name: "insights.coach.noProvider" },
});
return streamProviderError({ code: "coach.provider.none" });
return streamProviderError({
code: "coach.provider.none",
reason: "no_provider",
});
}
chain.push({ providerType: "admin-openai", instance: legacy });
}
@@ -579,6 +610,15 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// as before — the snapshot builder stays alive as the no-tools floor.
const toolMode = chain.every((c) => c.instance.supportsTools !== false);
// The last check before the budget reservation and the provider call: the
// `coach` capability again, at the egress site, so the call site names its
// capability whatever happens to the gate at the top. Inside one request
// the capability inputs are memoised, so this answers from the same
// resolution as the top gate; a fresh read at the wire belongs to the
// provider chokepoints, not to this route.
const refusedAtEgress = await coachCapabilityRefusal();
if (refusedAtEgress) return refusedAtEgress;
// v1.18.7 (SENIOR-DEV HIGH) — atomically RESERVE the day's budget before
// the provider call. The reservation increments the day's total by the
// per-call ceiling (`maxTokens`) in one upsert and returns the new total;
@@ -1613,13 +1653,18 @@ async function reportCoachStreamDefect(err: unknown): Promise<void> {
}
}
function streamProviderError(args: { code: string }): Response {
function streamProviderError(args: {
code: string;
/** The capability reason, when the refusal came from one. */
reason?: AiUnavailableReason;
}): Response {
const stream = createSseStream((controller) => {
controller.enqueue(
encodeFrame({
type: "error",
code: args.code,
message: args.code,
...(args.reason ? { reason: args.reason } : {}),
}),
);
});
@@ -1662,13 +1707,9 @@ const LIST_QUERY_MAX_LEN = 200;
*/
export const GET = apiHandler(async (request: NextRequest) => {
const auth = await requireAuth();
// v1.18.0 — same two-layer module gate as the SSE POST.
const gate = await requireModuleEnabled(auth.user.id, "coach");
if (!gate.enabled) return gate.response;
// v1.4.31 — same gate as the SSE POST. Hiding the rail when
// the operator has disabled Coach matches the FAB suppression
// on the client.
await requireAssistantSurface("coach");
// Never AI-gated and never module-gated: the conversation list is the
// person's stored data. It stays readable while the Coach is unavailable
// for any reason, so every thread can be found and deleted.
const url = new URL(request.url);
const cursor = url.searchParams.get("cursor");
const limitRaw = url.searchParams.get("limit");
@@ -33,9 +33,6 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => ({
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
resolveModuleMap: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAnalyticsReadRateLimit: vi.fn(),
}));
+6 -10
View File
@@ -11,7 +11,9 @@
* Mirrors the `/api/insights/derived` precedent: `apiHandler` wrapper, Zod
* `safeParse` on the query (unknown `metric` → 422 via `returnAllZodIssues`),
* cookie OR Bearer auth, `userId` narrowed from the session (never a query
* field), insights-module gate, and the shared analytics-read budget.
* field), and the shared analytics-read budget. No AI gate and no module
* gate: both lines are computed, and the `insights` module is the AI analysis
* opt-out.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -19,8 +21,6 @@ import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
@@ -37,13 +37,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// The strip is the ambient Coach presence on the metric page, so it
// gates on the Coach assistant matrix too: an operator who turns the
// Coach off must not see a "Coach read" line linger.
await requireAssistantSurface("coach");
// Pure compute over the baseline and correlation engines. The name is
// historical: no model writes either line, so neither the Coach switch nor
// the AI analysis opt-out refuses it.
// Shared analytics-read budget — generous; caps a runaway navigation loop.
const rl = await checkAnalyticsReadRateLimit(user.id);
@@ -12,8 +12,8 @@
* convention is the idempotent-delete one used elsewhere in the tree
* (a not-found delete is a successful no-op, not a 404).
*
* Coach-gated: same `requireAssistantSurface("coach")` kill-switch as the
* collection route.
* Never AI-gated, like the collection route: deleting a stored fact keeps
* working while the Coach is unavailable.
*/
import type { NextRequest } from "next/server";
@@ -21,7 +21,6 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { requireAssistantSurface } from "@/lib/feature-flags";
interface RouteCtx {
params: Promise<{ id: string }>;
@@ -30,7 +29,6 @@ interface RouteCtx {
export const DELETE = apiHandler(
async (_request: NextRequest, ctx: RouteCtx) => {
const { user } = await requireAuth();
await requireAssistantSurface("coach");
const { id } = await ctx.params;
@@ -10,7 +10,8 @@
* returns the count.
* - [id] DELETE soft-deletes the caller's own fact, and a cross-user /
* unknown id is a 0-count no-op that returns `{ deleted: false }`.
* - the `requireAssistantSurface("coach")` gate is present on every verb.
* - no verb asks for the `coach` capability: the facts are the person's
* data, readable and erasable while the Coach is unavailable.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
@@ -28,12 +29,17 @@ vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
}));
// v1.4.31 — `requireAssistantSurface()` gates near the top of each
// handler. Mock the module boundary so flag reads are deterministic; the
// gate-presence assertions below verify the call is actually made.
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
AssistantDisabledError: class extends Error {},
// The capability gate answers "unavailable" throughout: every verb must
// still work, and must not even ask.
vi.mock("@/lib/ai/capabilities/gate", () => ({
requireAiCapability: vi.fn(async () => {
throw new Error("the coach capability was consulted");
}),
getAiCapability: vi.fn(async () => ({
available: false,
reason: "operator_disabled",
onDeviceAllowed: false,
})),
}));
// Mock the codec so the test never needs an encryption key. The "row"
@@ -78,7 +84,10 @@ import { GET, DELETE } from "../route";
import { DELETE as DELETE_ONE } from "../[id]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireAssistantSurface } from "@/lib/feature-flags";
import {
getAiCapability,
requireAiCapability,
} from "@/lib/ai/capabilities/gate";
import { annotate } from "@/lib/logging/context";
const SESSION_OK = {
@@ -111,7 +120,6 @@ function deleteReq(): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireAssistantSurface).mockResolvedValue(undefined as never);
});
describe("GET /api/insights/coach/facts", () => {
@@ -223,10 +231,12 @@ describe("GET /api/insights/coach/facts", () => {
});
});
it("invokes the coach assistant-surface gate", async () => {
it("lists facts while the Coach is unavailable, without asking", async () => {
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([] as never);
await callGet();
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
const res = await callGet();
expect(res.status).toBe(200);
expect(requireAiCapability).not.toHaveBeenCalled();
expect(getAiCapability).not.toHaveBeenCalled();
});
});
@@ -261,12 +271,14 @@ describe("DELETE /api/insights/coach/facts — forget all", () => {
expect(body.data.cleared).toBe(0);
});
it("invokes the coach assistant-surface gate", async () => {
it("forgets everything while the Coach is unavailable, without asking", async () => {
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
count: 0,
} as never);
await callDeleteAll();
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
const res = await callDeleteAll();
expect(res.status).toBe(200);
expect(requireAiCapability).not.toHaveBeenCalled();
expect(getAiCapability).not.toHaveBeenCalled();
});
});
@@ -316,13 +328,15 @@ describe("DELETE /api/insights/coach/facts/[id] — forget one", () => {
expect(arg.where.userId).toBe("user-1");
});
it("invokes the coach assistant-surface gate", async () => {
it("forgets one fact while the Coach is unavailable, without asking", async () => {
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
count: 0,
} as never);
await callDeleteOne(deleteReq(), {
const res = await callDeleteOne(deleteReq(), {
params: Promise.resolve({ id: "f1" }),
});
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
expect(res.status).toBe(200);
expect(requireAiCapability).not.toHaveBeenCalled();
expect(getAiCapability).not.toHaveBeenCalled();
});
});
+4 -6
View File
@@ -14,20 +14,19 @@
* skipped rather than 500ing the whole list — the surface stays available
* for the rows that DO decrypt.
*
* Coach-gated: a fact only exists because the Coach extracted it, so the
* management surface sits behind the same `requireAssistantSurface("coach")`
* kill-switch as the rest of the Coach stack.
* Never AI-gated. The facts are the person's stored data: viewing and
* deleting them keeps working while the Coach is off for any reason (the
* operator's switch, the person's own opt-out, no provider, no consent).
* Erasure that depends on the feature being on is no erasure.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
await requireAssistantSurface("coach");
const rows = await prisma.coachFact.findMany({
where: { userId: user.id, deletedAt: null },
@@ -80,7 +79,6 @@ export const GET = apiHandler(async () => {
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
await requireAssistantSurface("coach");
// Soft-delete every active fact — keeps the rows for audit while
// hiding them from injection. `updateMany` scoped to the caller can
@@ -11,19 +11,26 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const { requireRecordAuth, evaluateContext, readStatus } = vi.hoisted(() => ({
requireRecordAuth: vi.fn(),
evaluateContext: vi.fn(async () => ({ surfaced: 0, errored: 0 })),
readStatus: vi.fn(async () => ({ unread: false, nudgedAt: null })),
}));
const { requireRecordAuth, evaluateContext, readStatus, aiCapabilityToServe } =
vi.hoisted(() => ({
requireRecordAuth: vi.fn(),
evaluateContext: vi.fn(async () => ({ surfaced: 0, errored: 0 })),
readStatus: vi.fn(
async (): Promise<{ unread: boolean; nudgedAt: string | null }> => ({
unread: false,
nudgedAt: null,
}),
),
aiCapabilityToServe: vi.fn(),
}));
const AVAILABLE = { available: true, reason: null, onDeviceAllowed: true };
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireRecordAuth,
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe }));
vi.mock("@/lib/ai/coach/nudge-status", () => ({
readCoachNudgeStatus: readStatus,
}));
@@ -40,6 +47,7 @@ import { GET } from "../route";
beforeEach(() => {
vi.clearAllMocks();
readStatus.mockResolvedValue({ unread: false, nudgedAt: null });
aiCapabilityToServe.mockResolvedValue(AVAILABLE);
});
describe("nudge-status — NEXT_APP_OPEN hook", () => {
@@ -93,3 +101,51 @@ describe("nudge-status — NEXT_APP_OPEN hook", () => {
}
});
});
describe("nudge-status — the coach capability", () => {
it("adds the resolved state beside the unread signal while available", async () => {
requireRecordAuth.mockResolvedValue({
user: { id: "u1" },
actor: { id: "u1" },
});
readStatus.mockResolvedValue({ unread: true, nudgedAt: "2026-07-18" });
const res = (await (GET as unknown as () => Promise<unknown>)()) as {
data: Record<string, unknown>;
};
expect(aiCapabilityToServe).toHaveBeenCalledWith("u1", "coach");
expect(res.data).toEqual({
unread: true,
nudgedAt: "2026-07-18",
ai: AVAILABLE,
});
});
it.each(["operator_disabled", "user_disabled", "no_provider"] as const)(
"answers the quiet 200 shape for %s, reading and evaluating nothing",
async (reason) => {
requireRecordAuth.mockResolvedValue({
user: { id: "u1" },
actor: { id: "u1" },
});
const ai = { available: false, reason, onDeviceAllowed: false };
aiCapabilityToServe.mockResolvedValue(ai);
const res = (await (GET as unknown as () => Promise<unknown>)()) as {
data: Record<string, unknown>;
error: null;
};
expect(res.error).toBeNull();
expect(res.data).toEqual({
nudgedAt: null,
unread: false,
conversationId: null,
ai,
});
expect(readStatus).not.toHaveBeenCalled();
expect(evaluateContext).not.toHaveBeenCalled();
},
);
});
@@ -21,10 +21,17 @@
* `nudgedAt` carries the newest assistant-message timestamp so the FAB's
* local seen-stamp keys on a stable value (kept for the existing client
* contract).
*
* A mixed read: while the record's `coach` capability is unavailable
* (operator switch, opt-out, no provider, no consent) the route still answers
* 200, with the
* quiet shape `{ unread: false, nudgedAt: null, conversationId: null }` and
* an `ai` state saying why. There is no Coach to open, so there is nothing
* unread, and no app-open reminder is evaluated.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status";
import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders";
import { prisma } from "@/lib/db";
@@ -46,9 +53,15 @@ export const GET = apiHandler(async () => {
// budget and writes into their conversation, and reading whether the thread
// has something unopened does neither.
const { user, actor } = await requireRecordAuth("read", "record");
// Operator-level flag — an `AppSettings` singleton, unaffected by whose
// record is open.
await requireAssistantSurface("coach");
const ai = await aiCapabilityToServe(user.id, "coach");
if (!ai.available) {
return apiSuccess({
nudgedAt: null,
unread: false,
conversationId: null,
ai,
});
}
// NEXT_APP_OPEN Coach reminders resolve here: this poll mounts with the
// app chrome, which makes it the app-open signal itself. Owner-only — a
@@ -67,7 +80,7 @@ export const GET = apiHandler(async () => {
// Shared with the `/coach` RSC prefetch (`src/app/coach/page.tsx`) so both
// readers compute the unread signal identically.
return apiSuccess(await readCoachNudgeStatus(user.id));
return apiSuccess({ ...(await readCoachNudgeStatus(user.id)), ai });
});
export const dynamic = "force-dynamic";
@@ -12,16 +12,15 @@ const findUniqueMock = vi.fn().mockResolvedValue({ notificationPrefs: null });
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: (...args: unknown[]) => findUniqueMock(...args) },
// No settings row: every assistant switch at its default (on). A switch
// read that fails now fails closed, so the row has to be readable.
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
resolveModuleMap: vi.fn().mockResolvedValue({}),
// The `coach` capability decides whether an opener is served at all; pinned
// here so the route test does not depend on the capability loader's reads.
const AVAILABLE = { available: true, reason: null, onDeviceAllowed: true };
const getAiCapability = vi.fn();
vi.mock("@/lib/ai/capabilities/gate", () => ({
getAiCapability: (...args: unknown[]) => getAiCapability(...args),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -84,6 +83,7 @@ beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
findUniqueMock.mockResolvedValue({ notificationPrefs: null });
getAiCapability.mockResolvedValue(AVAILABLE);
});
describe("GET /api/insights/coach/seeded-question", () => {
@@ -120,8 +120,25 @@ describe("GET /api/insights/coach/seeded-question", () => {
score: 58,
band: "yellow",
});
expect(getAiCapability).toHaveBeenCalledWith("coach");
expect((body.data as { ai?: unknown }).ai).toEqual(AVAILABLE);
});
it.each(["operator_disabled", "user_disabled", "no_provider"] as const)(
"answers { signal: null, ai } for %s without running the detector",
async (reason) => {
const ai = { available: false, reason, onDeviceAllowed: false };
getAiCapability.mockResolvedValue(ai);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { signal: unknown; ai: unknown };
};
expect(body.data).toEqual({ signal: null, ai });
expect(detectDerivedBriefingSignals).not.toHaveBeenCalled();
},
);
it("returns { signal: null } when nothing crosses the gate (neutral fallback)", async () => {
vi.mocked(detectDerivedBriefingSignals).mockResolvedValue(null);
const res = await callGet(makeReq());
@@ -17,14 +17,19 @@
*
* Mirrors the sibling derived routes: `apiHandler` wrapper, `requireAuth`
* (userId narrowed from the session/Bearer, never a body/query field),
* analytics-read rate limit, `insights` module gate.
* analytics-read rate limit.
*
* A mixed read: the opener only exists to open the Coach, so it is served
* only while the `coach` capability is available. Otherwise the route answers
* 200 `{ signal: null, ai }`, the same neutral shape as "nothing notable",
* with the reason beside it.
*/
import { apiError, apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
import { prisma } from "@/lib/db";
import { loadBaselineProfile } from "@/lib/insights/derived";
import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing";
@@ -46,17 +51,19 @@ export interface CoachSeededQuestionDTO {
/** Band — `yellow` / `red` (green never surfaces as notable). */
band: string;
} | null;
/** Whether the Coach this opener feeds is available, and why not. */
ai: AiCapabilityState;
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// The opener feeds the Coach hero, so it gates on the Coach assistant
// matrix: with the Coach off there is no hero to seed.
await requireAssistantSurface("coach");
// The opener feeds the Coach hero: with the Coach unavailable there is no
// hero to seed, so the neutral shape answers and says why.
const ai = await getAiCapability("coach");
if (!ai.available) {
return apiSuccess({ signal: null, ai } satisfies CoachSeededQuestionDTO);
}
const rl = await checkAnalyticsReadRateLimit(user.id);
if (!rl.allowed) {
@@ -77,7 +84,7 @@ export const GET = apiHandler(async () => {
action: { name: "coach.seeded-question.resolve" },
meta: { has_signal: false, source_metric: "none", band: "none" },
});
return apiSuccess({ signal: null } satisfies CoachSeededQuestionDTO);
return apiSuccess({ signal: null, ai } satisfies CoachSeededQuestionDTO);
}
// Profile read once via the shared loader; passed into the detector,
@@ -102,5 +109,5 @@ export const GET = apiHandler(async () => {
},
});
return apiSuccess({ signal } satisfies CoachSeededQuestionDTO);
return apiSuccess({ signal, ai } satisfies CoachSeededQuestionDTO);
});
+3 -2
View File
@@ -10,15 +10,16 @@
*
* No request body — the timestamp is server-minted from `now()` so a
* client can never backdate the stamp to suppress a future nudge.
*
* No AI gate: a timestamp on the caller's own row is data, whatever the
* Coach's availability.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { requireAssistantSurface } from "@/lib/feature-flags";
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
await requireAssistantSurface("coach");
const seenAt = new Date();
await prisma.user.update({
@@ -81,16 +81,21 @@ vi.mock("@/lib/insights/comprehensive-aggregator", () => ({
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(async () => ({ type: "none" })),
probeProviderPresence: vi.fn(async () => false),
}));
// The per-request `ai` block beside the body. Pinned here so the envelope
// assertions do not depend on the capability loader's own reads.
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityToServe: vi.fn(),
}));
vi.mock("@/lib/medication-category", () => ({
getMedicationCategories: vi.fn(async () => ({})),
}));
// The route gates on the `insights` module. Mock it default-enabled so the
// envelope assertions ride through; the module-off coverage lives in the
// module route-gate inventory test.
// The route no longer gates on the `insights` module (the AI analysis
// opt-out); the mock stays so a test can prove the route never asks it.
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
@@ -100,6 +105,8 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => ({
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { probeProviderPresence } from "@/lib/ai/provider";
import { prisma } from "@/lib/db";
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
@@ -125,6 +132,12 @@ beforeEach(() => {
vi.resetAllMocks();
// resetAllMocks drops the module gate's default; restore it.
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
vi.mocked(aiCapabilityToServe).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
vi.mocked(probeProviderPresence).mockResolvedValue(false);
__resetAllCachesForTests();
// v1.15.20 — default to an allowing analytics-read budget.
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
@@ -250,6 +263,64 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
expect(res.status).toBe(200);
});
it("answers with the AI analysis opt-out on, and publishes why the AI parts are absent", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: new Response(null, { status: 403 }),
} as never);
vi.mocked(aiCapabilityToServe).mockResolvedValue({
available: false,
reason: "user_disabled",
onDeviceAllowed: false,
});
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {},
bpRawRows: { sys: [], dia: [] },
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: null,
totalMeasurements: 0,
},
);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
expect(requireModuleEnabled).not.toHaveBeenCalled();
const body = (await res.json()) as {
data: { ai: Record<string, { reason: string }> };
};
expect(body.data.ai.briefing.reason).toBe("user_disabled");
expect(body.data.ai.statusText.reason).toBe("user_disabled");
expect(
vi
.mocked(aiCapabilityToServe)
.mock.calls.map((c) => c[1])
.sort(),
).toEqual(["briefing", "statusText"]);
});
it("reports hasProvider from the presence probe, never from building a client", async () => {
vi.mocked(probeProviderPresence).mockResolvedValue(true);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {},
bpRawRows: { sys: [], dia: [] },
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: null,
totalMeasurements: 0,
},
);
const res = await callGet(makeReq());
const body = (await res.json()) as { data: { hasProvider: boolean } };
expect(body.data.hasProvider).toBe(true);
expect(probeProviderPresence).toHaveBeenCalledWith("user-comp-1");
});
it("computes BMI from aggregate WEIGHT.latest and user heightCm", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
+21 -11
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { probeProviderPresence } from "@/lib/ai/provider";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { apiError, apiSuccess } from "@/lib/api-response";
import type { DataPoint, DataSummary } from "@/lib/analytics/trends";
import { summarize } from "@/lib/analytics/trends";
@@ -28,7 +29,6 @@ import {
requireRecordAuth,
type AuthContext,
} from "@/lib/api-handler";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import {
@@ -49,19 +49,19 @@ export const GET = apiHandler(async () => {
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
const rl = await checkAnalyticsReadRateLimit(user.id);
if (!rl.allowed) {
return apiError("Too many analytics requests. Please retry later.", 429);
}
// No assistant-surface gate. Every field is computed from the record
// (the provider chain is only probed for `hasProvider`), and this is the
// main read of the Insights overview: an operator who switches the
// assistant off still gets the overview.
// No AI gate, and no `insights` module gate (that module is the AI
// analysis opt-out). Every field is computed from the record (the provider
// chain is only probed for presence, for `hasProvider`), and this is the
// main read of the Insights overview: switching AI off, or never setting a
// provider up, still gets the overview. The `ai` block beside the body says
// whether the briefing and status notes that decorate the overview can be
// shown, resolved per request and never cached with the body.
// v1.4.35 — read-through the analytics cache keyed on
// (userId, "comprehensive"). The /insights page mount routinely
@@ -94,7 +94,16 @@ export const GET = apiHandler(async () => {
annotate,
);
return apiSuccess({ ...body, revalidating: outcome === "stale" });
const [briefing, statusText] = await Promise.all([
aiCapabilityToServe(user.id, "briefing"),
aiCapabilityToServe(user.id, "statusText"),
]);
return apiSuccess({
...body,
revalidating: outcome === "stale",
ai: { briefing, statusText },
});
});
type AuthedUser = AuthContext["user"];
@@ -599,7 +608,8 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
moodPulseScatterData,
medications: medCompliance,
alerts,
hasProvider: (await resolveProvider(userId)).type !== "none",
// Presence only: no client is built and no token is refreshed on a read.
hasProvider: await probeProviderPresence(userId),
dataSpanDays,
totalMeasurements: aggregate.totalMeasurements,
};
@@ -276,10 +276,11 @@ describe("GET /api/insights/correlations", () => {
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(2);
});
// v1.18.0 (B2) — the route now also requires the `insights` module.
it("returns 403 + module.disabled when the insights module is off", async () => {
// The `insights` module is the AI analysis opt-out. Correlations are
// statistics, so turning AI analysis off must not take them away.
it("serves the statistics with the insights module (the AI analysis opt-out) off", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: apiError('Module "insights" is not enabled', 403, {
errorCode: "module.disabled",
@@ -287,13 +288,7 @@ describe("GET /api/insights/correlations", () => {
}),
});
const res = await callGet(makeReq());
expect(res.status).toBe(403);
const body = (await res.json()) as {
meta?: { errorCode?: string; module?: string };
};
expect(body.meta?.errorCode).toBe("module.disabled");
expect(body.meta?.module).toBe("insights");
// The disabled-module request never reaches the series reads.
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
expect(res.status).toBe(200);
expect(requireModuleEnabled).not.toHaveBeenCalled();
});
});
+5 -11
View File
@@ -23,11 +23,7 @@ import { apiError, apiSuccess } from "@/lib/api-response";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import {
MODULE_KEYS,
requireModuleEnabled,
resolveModuleMap,
} from "@/lib/modules/gate";
import { MODULE_KEYS, resolveModuleMap } from "@/lib/modules/gate";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { prisma } from "@/lib/db";
@@ -71,12 +67,10 @@ export const GET = apiHandler(async () => {
return apiError("Too many analytics requests. Please retry later.", 429);
}
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// No assistant switch: this is statistics, not model output. The retired
// Correlations switch gated it and nothing model-written; an operator's AI
// switches never take a computation down.
// No AI gate and no `insights` module gate: this is statistics, not model
// output. The retired Correlations switch gated it and nothing model-written;
// an operator's AI switches, and the person's AI analysis opt-out, never
// take a computation down.
// Reader's locale for the narrated `interpretation` — the correlation cards
// render this string verbatim, so it MUST be localised (cookie / User.locale /
+4 -5
View File
@@ -39,7 +39,7 @@ import {
import { resolveDeterministicAssessment } from "@/lib/insights/derived/derived-assessment";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { instructionLocale } from "@/lib/ai/prompts/output-language";
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
import { resolveModuleMap } from "@/lib/modules/gate";
import { DERIVED_MODULE } from "../route";
export const dynamic = "force-dynamic";
@@ -117,10 +117,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
// v1.37.0 — MANAGE-level read: the deterministic batch form, no provider on
// the path.
const { user, actor } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// Same deterministic compute as the single route, so no
// assistant-surface gate.
// Same deterministic compute as the single route: no AI gate, and no
// `insights` module gate (that module is the AI analysis opt-out). Per-metric
// module ownership still applies below through `DERIVED_MODULE`.
// Per-caller limiter, same posture as the compliance routes: the cold
// build fans out up to 24 rollup walks, so an unthrottled caller could
+10 -5
View File
@@ -35,6 +35,7 @@ import {
import { DERIVED_MAX_WINDOW_DAYS } from "@/lib/insights/derived/types";
import { resolveDerivedAssessment } from "@/lib/insights/derived/derived-assessment-ai";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
export const dynamic = "force-dynamic";
@@ -88,17 +89,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
const rl = await checkAnalyticsReadRateLimit(user.id);
if (!rl.allowed) {
return apiError("Too many analytics requests. Please retry later.", 429);
}
// Pure compute over the rollup tier, so no assistant-surface gate:
// switching the assistant off does not blank the derived tiles.
// Pure compute over the rollup tier, so no AI gate and no `insights`
// module gate (the AI analysis opt-out): switching AI off does not blank the
// derived tiles. Only the model-written assessment below depends on AI.
const parsed = derivedQuerySchema.safeParse({
metric: request.nextUrl.searchParams.get("metric"),
@@ -159,11 +158,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
userLocale: user.locale ?? null,
override: localeParam,
});
// The model-written override is served, and warmed, only while
// `statusText` is available. Otherwise the deterministic text stands and
// `ai` says why there is no warmer prose.
const ai = await aiCapabilityToServe(user.id, "statusText");
const assessment = await resolveDerivedAssessment({
metric,
userId: user.id,
derived,
locale: resolvedLocale,
aiAvailable: ai.available,
});
// Flatten the discriminated union for the wire: `metric` tags it,
@@ -177,5 +181,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
provenance: derived.provenance,
reason: derived.status === "insufficient" ? derived.reason : null,
assessment,
ai,
});
});
+4 -6
View File
@@ -20,15 +20,14 @@
* a verdict of its own.
*
* Mirrors the `rhythm-events` gating: `apiHandler`, cookie OR Bearer auth,
* `userId` from the session, the `insights` module gate, and the
* `insightStatus` assistant-surface gate. No AI provider call.
* `userId` from the session. No AI gate and no module gate: the strip is
* device data. No AI provider call.
*/
import { NextRequest } from "next/server";
import { apiError, apiSuccess } from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { decryptWaveformFromBytes } from "@/lib/withings/ecg-waveform-codec";
import {
@@ -45,9 +44,8 @@ export const GET = apiHandler(
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// The waveform is never interpreted, so no assistant-surface gate.
// The waveform is never interpreted, so neither an AI gate nor the AI
// analysis opt-out applies.
const { id } = await params;
const full = request.nextUrl.searchParams.get("full") === "1";
+5 -11
View File
@@ -28,8 +28,9 @@
*
* Both verbs mirror the `rhythm-events` route gating exactly: `apiHandler`
* wrapper, cookie OR Bearer auth, `userId` narrowed from the session (never a
* body or query field), the `insights` module gate, and the `insightStatus`
* assistant-surface gate. No AI provider call on either path.
* body or query field). No AI gate and no module gate: a recording is device
* data, and the `insights` module is the AI analysis opt-out. An ingest is
* never refused because AI is off; the POST keeps its own rate limit.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -42,7 +43,6 @@ import {
} from "@/lib/api-response";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { persistEcgRecording } from "@/lib/ecg/persist-recording";
@@ -110,10 +110,6 @@ export const GET = apiHandler(async () => {
// keeps `requireAuth()`: a delegate's phone must never move somebody else's
// sync.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// A pure read of the device's own recordings, so no assistant-surface
// gate.
const rows = await prisma.ecgRecording.findMany({
where: { userId: user.id },
@@ -179,10 +175,8 @@ export const GET = apiHandler(async () => {
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// A device ingest carries no assistant prose, so switching the assistant
// off must not refuse a recording.
// A device ingest carries no model text: switching AI off, or the AI
// analysis opt-out, must never refuse a recording.
const rl = await checkRateLimit(
`insights:ecg:ingest:${user.id}`,
@@ -23,13 +23,12 @@ vi.mock("@/lib/api-handler", () => ({
})),
}));
// v1.4.31 — `requireAssistantSurface()` is gated near the top of
// the handler. The test mocks the apiHandler wrapper itself out, so
// flag reads need a deterministic mock at the module boundary.
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
AssistantDisabledError: class extends Error {},
}));
// The `briefing` capability is required near the top of both handlers.
// The test mocks the apiHandler wrapper itself out, so the gate needs a
// deterministic mock at the module boundary: available by default, and a
// refusal where a test says so.
const requireAiCapability = vi.hoisted(() => vi.fn());
vi.mock("@/lib/ai/capabilities/gate", () => ({ requireAiCapability }));
const selfContextText = vi.hoisted(() => vi.fn<() => Promise<string | null>>());
@@ -172,9 +171,15 @@ import {
FeaturesPayloadTooLargeError,
} from "@/lib/insights/features";
import { annotate } from "@/lib/logging/context";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
beforeEach(() => {
vi.clearAllMocks();
requireAiCapability.mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
// The B5b fallback runner caches "last working provider" per user
// across calls; tests that assert single-provider error mapping
// need a fresh cache so the previous test's success doesn't reorder
@@ -982,3 +987,48 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
expect(body.data.hasProvider).toBe(true);
});
});
describe("the briefing capability", () => {
it.each([
["operator_disabled", 403, "assistant.disabled.briefing"],
["user_disabled", 403, "module.disabled"],
["consent_required", 403, "consent.ai.required"],
["no_provider", 422, "ai.provider.none"],
] as const)(
"GET refuses with the envelope for %s before reading the cache or warming",
async (reason, status, errorCode) => {
requireAiCapability.mockRejectedValue(
new AiUnavailableError("briefing", reason),
);
const refused = await (
GET as unknown as (req: Request) => Promise<Response>
)(new Request("http://localhost/api/insights/generate")).catch(
(err: unknown) => err,
);
expect(refused).toBeInstanceOf(AiUnavailableError);
expect((refused as AiUnavailableError).status).toBe(status);
expect((refused as AiUnavailableError).meta.errorCode).toBe(errorCode);
expect(requireAiCapability).toHaveBeenCalledWith("briefing");
// Stored briefing text is never read, and nothing is warmed.
expect(prisma.user.findUnique).not.toHaveBeenCalled();
expect(enqueueForceWarm).not.toHaveBeenCalled();
},
);
it("POST refuses before any read or provider work", async () => {
requireAiCapability.mockRejectedValue(
new AiUnavailableError("briefing", "operator_disabled"),
);
const refused = await POST(jsonRequest({ force: true }) as never).catch(
(err: unknown) => err,
);
expect(refused).toBeInstanceOf(AiUnavailableError);
expect(requireAiCapability).toHaveBeenCalledWith("briefing");
expect(prisma.user.findUnique).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
});
+19 -10
View File
@@ -78,7 +78,8 @@ import { isLegacyInsightPayload } from "@/lib/ai/legacy-payload";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AI_PROVIDER_NONE_ERROR_CODE } from "@/lib/ai/capabilities/types";
import { invalidateUserInsights } from "@/lib/cache/invalidate";
import { annotate } from "@/lib/logging/context";
import { toProfileSex } from "@/lib/profile/sex";
@@ -139,14 +140,22 @@ export function resolveInsightsRateLimit(): number {
* cron and the "prepare assessments" button use). The next read reflects
* the fresh briefing. User-initiated regeneration stays on the POST path.
*
* A pure AI read: the payload is the model-written briefing and nothing
* else, so it keeps a typed refusal rather than answering 200 with nothing.
* The `briefing` capability is required (operator switch, the AI analysis
* opt-out, provider presence, consent); an unavailable one refuses with the
* capability envelope (`assistant.disabled.briefing`, `module.disabled`,
* `consent.ai.required`, `ai.provider.none` 422, …) before the cache is read,
* so stored briefing text is never served and no warm is enqueued.
*
* `userId` is narrowed from the session / Bearer — never a body field.
*/
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// Same surface gate as the POST + the read-only status routes: a user
// with assessments enabled but Coach disabled still reads the cached
// briefing.
await requireAssistantSurface("coach");
// The briefing is its own capability now, not the Coach's: a user with the
// Coach hidden still reads their briefing, and one who opted out of AI
// analysis does not.
await requireAiCapability("briefing");
const userId = user.id;
const dbUser = await prisma.user.findUnique({
@@ -304,11 +313,10 @@ export const POST = apiHandler((request: NextRequest) =>
// sibling consumer) for this request.
withFeatureCacheScope(async () => {
const { user } = await requireAuth();
// v1.4.31 — the advisor + daily briefing surfaces ride this
// endpoint. Operator disables Coach, every advisor consumer
// (briefing card, recommendations grid, hero strip narration)
// empties out.
await requireAssistantSurface("coach");
// The `briefing` capability, before any read or provider work. Refused
// with the capability envelope; no provider is `ai.provider.none` on the
// 422 this route has always used for it.
await requireAiCapability("briefing");
const userId = user.id;
const dbUser = await prisma.user.findUnique({
@@ -490,6 +498,7 @@ export const POST = apiHandler((request: NextRequest) =>
return apiError(
"No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.",
422,
{ errorCode: AI_PROVIDER_NONE_ERROR_CODE },
);
}
chain.push({ providerType: "admin-openai", instance: legacy });
+3 -5
View File
@@ -10,7 +10,9 @@
* the rollup tier: no provider call, no fabricated value, never a diagnosis.
*
* Mirrors `/api/insights/derived`: `apiHandler` wrapper, `requireAuth`, the
* `insights` module gate, the shared analytics-read budget. `userId` is always
* shared analytics-read budget. No module gate: the drift read spans every
* vital, and the `insights` module is the AI analysis opt-out, which a
* computation does not depend on. `userId` is always
* narrowed from the session. The labels are mapped client-side from the
* MeasurementType, so the wire carries the type tokens only.
*/
@@ -18,7 +20,6 @@ import { apiError, apiSuccess } from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { loadBaselineProfile } from "@/lib/insights/derived";
import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation";
@@ -32,9 +33,6 @@ export const GET = apiHandler(async () => {
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
const rl = await checkAnalyticsReadRateLimit(user.id);
if (!rl.allowed) {
return apiError("Too many analytics requests. Please retry later.", 429);
+5 -2
View File
@@ -11,7 +11,7 @@
* about recent news, and it stops being made rather than being made about a
* years-old draw. Neutral framing only — a delta is not a diagnosis.
*
* Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `insights`
* Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `labs`
* module gate, the shared analytics-read budget. `userId` is narrowed from the
* session; soft-deleted rows (`deletedAt`) are filtered out.
*/
@@ -30,7 +30,10 @@ export const GET = apiHandler(async () => {
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
// Lab deltas belong to labs: with the labs module off the card hides like
// every other labs surface. Not an AI read, so the `insights` module (the
// AI analysis opt-out) does not gate it.
const m = await requireModuleEnabled(user.id, "labs");
if (!m.enabled) return m.response;
const rl = await checkAnalyticsReadRateLimit(user.id);
@@ -7,19 +7,34 @@ import {
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { unavailableComplianceStatusBody } from "@/lib/insights/status-unavailable";
export const dynamic = "force-dynamic";
/**
* A mixed read: the card's frame is data, the note inside it is model text.
* The note is served, and warmed on a miss, only while the `statusText` AI
* capability is available. Otherwise the route answers 200 with no note
* (`summary: null`, no per-medication notes, `preparing: false`, `hasProvider` as provider presence) and
* an `ai` state saying why, without reading the cache or queueing anything.
* The `insights` module is the AI analysis opt-out and is folded into the
* capability, so it no longer refuses the route.
*/
export const GET = apiHandler(async (request: NextRequest) => {
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const ai = await aiCapabilityToServe(user.id, "statusText");
if (!ai.available) {
annotate({
action: { name: "insights.medication-compliance-status.unavailable" },
meta: { reason: ai.reason },
});
return apiSuccess(await unavailableComplianceStatusBody(user.id, ai));
}
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
@@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "insights.medication-compliance-status" } });
return apiSuccess(result);
return apiSuccess({ ...result, ai });
});
@@ -4,8 +4,15 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
measurement: { count: vi.fn() },
},
}));
// The `statusText` capability decides whether the note is served; the
// unavailable body's provider-presence probe is stubbed.
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() }));
vi.mock("@/lib/ai/provider", () => ({
probeProviderPresence: vi.fn(async () => true),
}));
// v1.18.0 — the route now resolves the `insights` module gate after
// `requireAuth()` (in addition to the existing per-metric sleep/glucose/
@@ -55,6 +62,7 @@ import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { generateMetricStatus } from "@/lib/insights/metric-status";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -77,6 +85,11 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(aiCapabilityToServe).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
});
describe("GET /api/insights/metric-status", () => {
@@ -126,18 +139,29 @@ describe("GET /api/insights/metric-status", () => {
expect(res.status).toBe(401);
});
it("403s + errorCode when insightStatus is disabled", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantDocumentAiEnabled: true,
} as never);
const res = await callGet(makeReq("SLEEP_DURATION"));
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
});
it.each([
[0, true],
[3, false],
] as const)(
"answers 200 with no note while statusText is unavailable (%i readings → insufficient %s)",
async (readings, insufficient) => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(aiCapabilityToServe).mockResolvedValue({
available: false,
reason: "operator_disabled",
onDeviceAllowed: false,
});
vi.mocked(prisma.measurement.count).mockResolvedValue(readings as never);
const res = await callGet(makeReq("RESTING_HEART_RATE"));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data).toMatchObject({
text: null,
preparing: false,
insufficient,
ai: { available: false, reason: "operator_disabled" },
});
expect(generateMetricStatus).not.toHaveBeenCalled();
},
);
});
+35 -6
View File
@@ -13,6 +13,12 @@
* text, never blocking on the provider. An unknown `metric` 422s via the
* closed registry enum.
*
* A mixed read: the note is model text, served (and warmed on a miss) only
* while the `statusText` AI capability is available. Otherwise the route
* answers 200 with no note, `insufficient` still computed from the data, and
* an `ai` state saying why; the cache is not read and nothing is queued. The
* `insights` module (the AI analysis opt-out) folds into the capability.
*
* Unlike the seven legacy `*-status` routes, this one carries OpenAPI
* coverage (the route schema lives in `src/lib/openapi/routes.ts`),
* establishing the pattern the legacy routes can be backfilled onto later.
@@ -23,14 +29,17 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled, type ModuleKey } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
import { prisma } from "@/lib/db";
import {
generateMetricStatus,
resolveMetricStatusLocale,
} from "@/lib/insights/metric-status";
import {
METRIC_STATUS_IDS,
getMetricStatusMeta,
type MetricStatusMetricId,
} from "@/lib/insights/metric-status-registry";
@@ -72,10 +81,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const parsed = metricQuerySchema.safeParse({
metric: request.nextUrl.searchParams.get("metric"),
});
@@ -99,6 +104,30 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!gate.enabled) return gate.response;
}
const ai = await aiCapabilityToServe(user.id, "statusText");
if (!ai.available) {
// The note is not served, but whether there is anything to assess is
// data: the card keeps its insufficient-data state.
const meta = getMetricStatusMeta(metric);
const readings = meta
? await prisma.measurement.count({
where: {
userId: user.id,
type: meta.measurementType,
deletedAt: null,
},
})
: 0;
annotate({
action: { name: "insights.metric-status.unavailable" },
meta: { metric, reason: ai.reason },
});
return apiSuccess({
...(await unavailableStatusBody(user.id, ai)),
insufficient: readings === 0,
});
}
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
request,
@@ -120,5 +149,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
meta: { metric },
});
return apiSuccess(result);
return apiSuccess({ ...result, ai });
});
@@ -10,6 +10,12 @@ vi.mock("@/lib/db", () => ({
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
// The `statusText` capability decides whether the note is served; the
// unavailable body's provider-presence probe is stubbed.
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() }));
vi.mock("@/lib/ai/provider", () => ({
probeProviderPresence: vi.fn(async () => true),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -45,6 +51,7 @@ import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { generateMoodStatusForUser } from "@/lib/insights/mood-status";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -74,6 +81,11 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
vi.mocked(aiCapabilityToServe).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
});
describe("GET /api/insights/mood-status — module gate", () => {
@@ -102,6 +114,30 @@ describe("GET /api/insights/mood-status — module gate", () => {
expect(generateMoodStatusForUser).not.toHaveBeenCalled();
});
it("keeps the mood module gate ahead of the capability", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(
userRow({ mood: false }) as never,
);
await callGet(makeReq());
expect(aiCapabilityToServe).not.toHaveBeenCalled();
});
it("answers 200 with no note while statusText is unavailable", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(userRow(null) as never);
vi.mocked(aiCapabilityToServe).mockResolvedValue({
available: false,
reason: "no_provider",
onDeviceAllowed: true,
});
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data.text).toBeNull();
expect(generateMoodStatusForUser).not.toHaveBeenCalled();
});
it("401s when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await callGet(makeReq());
+21 -6
View File
@@ -7,26 +7,41 @@ import {
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
export const dynamic = "force-dynamic";
/**
* A mixed read: the card's frame is data, the note inside it is model text.
* The note is served, and warmed on a miss, only while the `statusText` AI
* capability is available. Otherwise the route answers 200 with no note
* (`text: null`, `preparing: false`, `hasProvider` as provider presence) and
* an `ai` state saying why, without reading the cache or queueing anything.
* The `insights` module is the AI analysis opt-out and is folded into the
* capability, so it no longer refuses the route.
*/
export const GET = apiHandler(async (request: NextRequest) => {
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// Per-domain gate: the mood module must be enabled for this account
// before the mood AI-status surface is served (mirrors the cycle gate).
const gate = await requireModuleEnabled(user.id, "mood");
if (!gate.enabled) return gate.response;
await requireAssistantSurface("insightStatus");
const ai = await aiCapabilityToServe(user.id, "statusText");
if (!ai.available) {
annotate({
action: { name: "insights.mood-status.unavailable" },
meta: { reason: ai.reason },
});
return apiSuccess(await unavailableStatusBody(user.id, ai));
}
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
@@ -47,5 +62,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "insights.mood-status" } });
return apiSuccess(result);
return apiSuccess({ ...result, ai });
});
+32 -6
View File
@@ -10,6 +10,17 @@
*
* `userId` is narrowed from the session/Bearer (never a query field); an
* unknown `period` 422s via the closed enum.
*
* A mixed read. The narrative is data when the deterministic composer wrote
* it and model output when a provider did. Model-written text is served only
* while the record's `periodNarrative` capability is available (answered from
* the record's own state, so a delegate reads the owner's narrative exactly
* when the owner would); otherwise `narrative` is null and `ai` says why. The
* warm is enqueued only when the capability is available, or when no row
* exists at all (the worker then writes the deterministic narrative); never
* for a delegate. The `insights` module is the AI analysis
* opt-out and is folded into the capability, so it no longer refuses the
* route.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -18,8 +29,8 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { prisma } from "@/lib/db";
import { readPeriodNarrative } from "@/lib/insights/narrative/period-narrative-generate";
import {
PERIOD_DAYS,
@@ -57,9 +68,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const parsed = narrativeQuerySchema.safeParse({
period: request.nextUrl.searchParams.get("period"),
@@ -80,10 +88,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
const locale = narrowLocale(resolved);
// Whether model-written text may be shown is the record's own answer,
// whoever is reading; the reader already applies it (a model-written row
// reads as absent while it may not be shown). Starting a warm for a
// delegate stays suppressed below.
const ai = await aiCapabilityToServe(user.id, "periodNarrative");
const existing = await readPeriodNarrative(user.id, period, locale);
const isFresh =
existing !== null &&
Date.now() - new Date(existing.updatedAt).getTime() < NARRATIVE_FRESH_MS;
// With AI unavailable a warm only helps when no row exists at all: the
// worker then writes the deterministic narrative. A hidden model-written
// row must not be overwritten by one (turning AI back on brings it back),
// and a stale deterministic row is served as it is.
const mayWarm =
ai.available ||
(existing === null &&
(await prisma.insightNarrative.findUnique({
where: { userId_period_locale: { userId: user.id, period, locale } },
select: { id: true },
})) === null);
// Read-only: never block on the provider. Warm out of band whenever the row
// is stale / missing. The generator produces AI prose when a provider is
@@ -98,7 +122,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// The stale row is still served; only the warm is withheld.
let revalidating = false;
const generationSuppressed = delegatedGenerationSuppressed();
if (!isFresh && !generationSuppressed) {
if (!isFresh && mayWarm && !generationSuppressed) {
void enqueueNarrativeWarm({ userId: user.id, period, locale });
revalidating = true;
}
@@ -110,6 +134,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
has_narrative: existing !== null,
revalidating,
generation_suppressed: generationSuppressed,
ai_reason: ai.reason,
},
});
@@ -124,5 +149,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
: null,
revalidating,
ai,
});
});
@@ -69,16 +69,19 @@ describe("PATCH /api/insights/patterns/[id]", () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
});
it("refuses a dismissal when Insights is disabled", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
it("accepts a dismissal whatever the AI analysis opt-out says (statistics are data)", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: apiError('Module "insights" is not enabled', 403),
});
const response = await PATCH(request({ dismissed: true }), params);
expect(response.status).toBe(403);
expect(prisma.correlationPattern.findFirst).not.toHaveBeenCalled();
// No such pattern in this fixture: reaching the lookup proves no module
// gate stood in front of it.
expect(response.status).toBe(404);
expect(requireModuleEnabled).not.toHaveBeenCalled();
expect(prisma.correlationPattern.findFirst).toHaveBeenCalled();
});
it("stores the current evidence as the account-scoped dismissal baseline", async () => {
@@ -13,7 +13,6 @@ import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { updateCorrelationPatternSchema } from "@/lib/validations/correlation-patterns";
type RouteParams = { params: Promise<{ id: string }> };
@@ -26,8 +25,6 @@ export const PATCH = apiHandler(
// at no level below. The dismissal stores its own evidence hash and effect
// size, so it reads back and reverses.
const { user } = await requireRecordAuth("manage", "record");
const gate = await requireModuleEnabled(user.id, "insights");
if (!gate.enabled) return gate.response;
const parsedId = patternIdSchema.safeParse((await params).id);
if (!parsedId.success) return returnAllZodIssues(parsedId.error, 422);
const id = parsedId.data;
@@ -54,15 +54,16 @@ describe("GET /api/insights/patterns", () => {
);
});
it("refuses the list when Insights is disabled", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
it("serves the list whatever the AI analysis opt-out says (statistics are data)", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: apiError('Module "insights" is not enabled', 403),
});
const response = await GET();
expect(response.status).toBe(403);
expect(prisma.correlationPattern.findMany).not.toHaveBeenCalled();
expect(response.status).toBe(200);
expect(requireModuleEnabled).not.toHaveBeenCalled();
expect(prisma.correlationPattern.findMany).toHaveBeenCalled();
});
});
+3 -3
View File
@@ -2,7 +2,6 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
@@ -10,8 +9,9 @@ export const GET = apiHandler(async () => {
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const gate = await requireModuleEnabled(user.id, "insights");
if (!gate.enabled) return gate.response;
// Correlation patterns are statistics over the record's own rows. No model
// writes them, so the `insights` module (the AI analysis opt-out) does not
// gate them.
const patterns = await prisma.correlationPattern.findMany({
where: { userId: user.id, isCurrent: true },
orderBy: [{ lastComputedAt: "desc" }, { canonicalKey: "asc" }],
@@ -1,21 +1,15 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
/**
* `POST /api/insights/pregenerate` warms two AI capabilities, `briefing` and
* `statusText`. It returns no model output, so it never refuses: with
* neither available it answers 200 `{ queued: false }` and enqueues nothing;
* with either available it enqueues (the worker checks each half). The `ai`
* block always says which half can run.
*/
// v1.18.0 — the route now resolves the `insights` module gate after
// `requireAuth()`. Mock it default-enabled so the existing assertions
// ride through; the off → 403 coverage lives in the route-gate inventory
// test.
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
resolveModuleMap: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -38,8 +32,6 @@ vi.mock("next/headers", () => ({
})),
}));
// Always allow the anti-spam bucket so the surface gate is what the test
// exercises.
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
@@ -49,11 +41,18 @@ vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({
enqueueForceWarm: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() }));
import { POST } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import type { AiCapabilityKey } from "@/lib/ai/capabilities/types";
import {
AI_AVAILABLE,
aiUnavailable,
} from "@/__tests__/helpers/ai-capability-fixtures";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -72,57 +71,73 @@ function makeReq(): NextRequest {
});
}
function capabilities(
states: Partial<Record<AiCapabilityKey, ReturnType<typeof aiUnavailable>>>,
) {
vi.mocked(getAiCapability).mockImplementation(
async (key) => states[key] ?? AI_AVAILABLE,
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
capabilities({});
});
describe("POST /api/insights/pregenerate", () => {
it("enqueues a warm when insightStatus is enabled", async () => {
it("enqueues a warm when both halves are available", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callPost(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { queued: boolean } | null;
data: { queued: boolean; ai: Record<string, unknown> } | null;
error: string | null;
};
expect(body.error).toBeNull();
expect(body.data?.queued).toBe(true);
expect(body.data?.ai).toEqual({
briefing: AI_AVAILABLE,
statusText: AI_AVAILABLE,
});
expect(enqueueForceWarm).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user-1" }),
);
});
it("gates on insightStatus, not coach — a coach-disabled user can still warm", async () => {
it("enqueues when only one half is available (the worker skips the other)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
assistantEnabled: true,
assistantCoachEnabled: false,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantDocumentAiEnabled: true,
} as never);
capabilities({ briefing: aiUnavailable("operator_disabled") });
const res = await callPost(makeReq());
expect(res.status).toBe(200);
expect(enqueueForceWarm).toHaveBeenCalledTimes(1);
});
it("403s + errorCode when insightStatus is disabled", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantDocumentAiEnabled: true,
} as never);
const res = await callPost(makeReq());
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
expect(enqueueForceWarm).not.toHaveBeenCalled();
});
it.each([
"operator_disabled",
"user_disabled",
"no_provider",
"consent_required",
] as const)(
"answers 200 { queued: false } and enqueues nothing when neither half is available (%s)",
async (reason) => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
capabilities({
briefing: aiUnavailable(reason),
statusText: aiUnavailable(reason),
});
const res = await callPost(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { queued: boolean; ai: { briefing: { reason: string } } };
};
expect(body.data.queued).toBe(false);
expect(body.data.ai.briefing.reason).toBe(reason);
expect(enqueueForceWarm).not.toHaveBeenCalled();
// Nothing to warm, so no anti-spam bucket is spent either.
expect(checkRateLimit).not.toHaveBeenCalled();
},
);
it("401s when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
+35 -17
View File
@@ -21,12 +21,18 @@
* `userId` is always narrowed from the session / Bearer — never a body
* field. Both web and the iOS client can call it; they read the same
* cached routes afterward.
*
* The warm covers two AI capabilities: `briefing` (the comprehensive
* insight) and `statusText` (the assessment cards). It returns no model
* output, so it never refuses: with neither capability available it answers
* 200 `{ queued: false }` and enqueues nothing; with one available it
* enqueues, and the worker checks each half before it builds anything. The
* `ai` block says which half can run.
*/
import { NextRequest } from "next/server";
import { apiSuccess, apiError } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
@@ -40,21 +46,12 @@ const WARM_WINDOW_MS = 3 * 60 * 1000;
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// Gate on the same surface as the read-only status routes: this warms
// the assessment cards (the `insightStatus` surface), not the Coach.
// A user with assessments enabled but Coach disabled can still warm.
await requireAssistantSurface("insightStatus");
const userId = user.id;
// Short per-user bucket so the bypassed nightly budget can't be abused
// into a provider-cost amplifier by a tight POST loop. A blocked call
// is harmless — the caches are already being warmed by the prior call.
const rl = await checkRateLimit(`insights-warm:${userId}`, 1, WARM_WINDOW_MS);
if (!rl.allowed) {
return apiError("A warm is already in progress. Try again shortly.", 429);
}
const [briefing, statusText] = await Promise.all([
getAiCapability("briefing"),
getAiCapability("statusText"),
]);
const ai = { briefing, statusText };
const resolved = await resolveServerLocale({
request,
@@ -65,6 +62,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
// German warm, so the whole warmed cache family was in the wrong language.
const locale = normalizeLocale(resolved);
// Nothing this warm covers can run: enqueue nothing, spend no bucket.
if (!briefing.available && !statusText.available) {
annotate({
action: { name: "insights.pregenerate.skipped" },
meta: {
locale,
briefing_reason: briefing.reason,
status_reason: statusText.reason,
},
});
return apiSuccess({ queued: false, locale, ai });
}
// Short per-user bucket so the bypassed nightly budget can't be abused
// into a provider-cost amplifier by a tight POST loop. A blocked call
// is harmless — the caches are already being warmed by the prior call.
const rl = await checkRateLimit(`insights-warm:${userId}`, 1, WARM_WINDOW_MS);
if (!rl.allowed) {
return apiError("A warm is already in progress. Try again shortly.", 429);
}
await enqueueForceWarm({ userId, locale });
annotate({
@@ -74,5 +92,5 @@ export const POST = apiHandler(async (request: NextRequest) => {
// The work runs on the worker; report that it was accepted, not that it
// is done. The client polls the read-only status GETs for the text.
return apiSuccess({ queued: true, locale });
return apiSuccess({ queued: true, locale, ai });
});
+21 -6
View File
@@ -7,19 +7,34 @@ import {
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
export const dynamic = "force-dynamic";
/**
* A mixed read: the card's frame is data, the note inside it is model text.
* The note is served, and warmed on a miss, only while the `statusText` AI
* capability is available. Otherwise the route answers 200 with no note
* (`text: null`, `preparing: false`, `hasProvider` as provider presence) and
* an `ai` state saying why, without reading the cache or queueing anything.
* The `insights` module is the AI analysis opt-out and is folded into the
* capability, so it no longer refuses the route.
*/
export const GET = apiHandler(async (request: NextRequest) => {
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const ai = await aiCapabilityToServe(user.id, "statusText");
if (!ai.available) {
annotate({
action: { name: "insights.pulse-status.unavailable" },
meta: { reason: ai.reason },
});
return apiSuccess(await unavailableStatusBody(user.id, ai));
}
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
@@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "insights.pulse-status" } });
return apiSuccess(result);
return apiSuccess({ ...result, ai });
});
+3 -4
View File
@@ -8,7 +8,9 @@
* all history.
*
* Cookie OR Bearer auth via `requireAuth()`; `userId` is narrowed from the
* resolved session — never a body field. Gated on the `insights` module. The
* resolved session — never a body field. Pulse is a core vital, so no module
* gates it; the `insights` module is the AI analysis opt-out, and this read
* calls no model. The
* `date` defaults to the user's local today; anything but a `YYYY-MM-DD`
* literal is a 422 via `returnAllZodIssues`. The frontend day navigator
* pages this same route backward through prior days — a day outside
@@ -20,7 +22,6 @@ import { z } from "zod";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/format";
@@ -40,8 +41,6 @@ export const GET = apiHandler(async (req: Request) => {
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
const url = new URL(req.url);
const parsed = querySchema.safeParse({
+5 -6
View File
@@ -21,13 +21,12 @@
*
* Follows the `metric-status` / `derived` route precedent: `apiHandler`
* wrapper, cookie OR Bearer auth, `userId` narrowed from the session
* (never a query field), the `insightStatus` assistant-surface gate (no AI
* provider call — this is a pure DB read).
* (never a query field). No AI gate and no module gate: this is a pure DB
* read of device data.
*/
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
import type { MeasurementType } from "@/generated/prisma/client";
@@ -46,9 +45,9 @@ export const GET = apiHandler(async () => {
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// A pure read of the device's own verdicts, so no assistant-surface gate.
// A pure read of the device's own verdicts: no AI gate and no module gate.
// The `insights` module is the AI analysis opt-out, and nothing here was
// written by a model.
const rows = await prisma.measurement.findMany({
where: {
+21 -6
View File
@@ -7,19 +7,34 @@ import {
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
export const dynamic = "force-dynamic";
/**
* A mixed read: the card's frame is data, the note inside it is model text.
* The note is served, and warmed on a miss, only while the `statusText` AI
* capability is available. Otherwise the route answers 200 with no note
* (`text: null`, `preparing: false`, `hasProvider` as provider presence) and
* an `ai` state saying why, without reading the cache or queueing anything.
* The `insights` module is the AI analysis opt-out and is folded into the
* capability, so it no longer refuses the route.
*/
export const GET = apiHandler(async (request: NextRequest) => {
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const ai = await aiCapabilityToServe(user.id, "statusText");
if (!ai.available) {
annotate({
action: { name: "insights.weight-status.unavailable" },
meta: { reason: ai.reason },
});
return apiSuccess(await unavailableStatusBody(user.id, ai));
}
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
@@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "insights.weight-status" } });
return apiSuccess(result);
return apiSuccess({ ...result, ai });
});
@@ -505,15 +505,15 @@ describe("GET /api/user/ai-provider — the shared-provider offer", () => {
},
);
it("never offers when the operator switched the assistant surfaces off", async () => {
it("never offers when the operator switched off everything the consent would unlock", async () => {
serverManaged();
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
vi.mocked(getAssistantFlags).mockResolvedValue({
enabled: true,
coach: false,
briefing: true,
briefing: false,
insightStatus: true,
documentAi: true,
documentAi: false,
});
await expect(read()).resolves.toMatchObject({
serverProviderHealth: "healthy",
@@ -521,6 +521,41 @@ describe("GET /api/user/ai-provider — the shared-provider offer", () => {
});
});
it.each(["coach", "briefing", "documentAi"] as const)(
"still offers while the operator keeps %s on",
async (open) => {
serverManaged();
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
vi.mocked(getAssistantFlags).mockResolvedValue({
enabled: true,
coach: false,
briefing: false,
insightStatus: false,
documentAi: false,
[open]: true,
});
await expect(read()).resolves.toMatchObject({
serverProviderOffer: true,
});
},
);
it("reports no AI available while the operator's master switch is off, whatever is configured", async () => {
serverManaged();
vi.mocked(getAssistantFlags).mockResolvedValue({
enabled: false,
coach: false,
briefing: false,
insightStatus: false,
documentAi: false,
});
await expect(read()).resolves.toMatchObject({
aiAvailable: false,
managedBy: "server",
serverProviderOffer: false,
});
});
it("never offers to a managed profile — a child's record does not consent for itself", async () => {
serverManaged();
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
+20 -4
View File
@@ -53,7 +53,17 @@ export const GET = apiHandler(async () => {
// no personal provider. iOS keys its Coach visibility off `aiAvailable` so
// a server-managed provider is no longer invisible to the client.
// `managedBy` reports the origin only; no admin keys/endpoints are leaked.
const { aiAvailable, managedBy } = await resolveProviderAvailability(user.id);
//
// The operator's master switch is part of the answer: with every AI
// capability switched off there is no AI available, whatever is
// configured, so a native client that keys on this field hides its AI
// surfaces. The switches fail closed (a read error reads as all off).
const [presence, assistantFlags] = await Promise.all([
resolveProviderAvailability(user.id),
getAssistantFlags(),
]);
const aiAvailable = presence.aiAvailable && assistantFlags.enabled;
const { managedBy } = presence;
// ── v1.38.19 — the shared provider, honestly ────────────────
// `managedBy: "server"` says the operator configured a key. It has never
@@ -77,7 +87,6 @@ export const GET = apiHandler(async () => {
user.id,
"coach",
);
const assistantFlags = await getAssistantFlags();
// `personal`, not merely "not deny": a guardian's own record resolves to
// `personal`, while a managed profile resolves to `operator-default` —
// its provider is the operator's by definition, and a child's record does
@@ -93,13 +102,20 @@ export const GET = apiHandler(async () => {
// one visitor minted would turn the operator's provider on for every later
// one. A button that can only 403 into a generic toast is precisely the
// misleading failure this surface exists to remove, so there is no button.
//
// The offer is worth making while any capability it would unlock is
// switched on by the operator: the Coach, the briefing, or reading
// documents. With all three off there is nothing for the consent to open.
const demoInstance = process.env.DEMO_MODE === "true";
const offerUnlocksSomething =
assistantFlags.coach ||
assistantFlags.briefing ||
assistantFlags.documentAi;
const serverProviderOffer =
!demoInstance &&
managedBy === "server" &&
serverProviderHealth === "healthy" &&
assistantFlags.enabled &&
assistantFlags.coach &&
offerUnlocksSomething &&
credentialPolicy === "personal" &&
!serverProviderConsent;
+11 -4
View File
@@ -29,7 +29,8 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series";
@@ -60,7 +61,11 @@ export const GET = apiHandler(
// v1.18.0 B1 — gate the detail surface behind the workouts module.
const gate = await requireModuleEnabled(user.id, "workouts");
if (!gate.enabled) return gate.response;
const insightsEnabled = await isModuleEnabled(user.id, "insights");
// The stored Activity Insight paragraph is model text: it is read and
// served only while `workoutInsights` is available (operator switch, the
// AI analysis opt-out, provider presence, consent). Otherwise the row is
// not even loaded, `aiInsight` is null, and `ai` says why.
const ai = await aiCapabilityToServe(user.id, "workoutInsights");
const row = await prisma.workout.findUnique({
where: { id },
@@ -79,7 +84,7 @@ export const GET = apiHandler(
sampleCount: true,
},
},
insight: insightsEnabled
insight: ai.available
? {
select: { paragraphEncrypted: true, generatedAt: true },
}
@@ -282,7 +287,7 @@ export const GET = apiHandler(
// undecryptable paragraph degrades to no card, exactly like a workout that
// never had one.
let aiInsight: { paragraph: string; generatedAt: string } | null = null;
if (insightsEnabled && row.insight) {
if (ai.available && row.insight) {
try {
aiInsight = {
paragraph: decryptFromBytes(row.insight.paragraphEncrypted),
@@ -331,6 +336,8 @@ export const GET = apiHandler(
// re-synced one, every one on a provider-less install) serves null and
// the page's `{aiInsight ? <card/> : null}` renders nothing.
aiInsight,
// Whether the Activity Insight can be shown, and why not.
ai,
// v1.4.32 — when the requested id is a non-canonical twin the
// caller can redirect to `canonicalId` to land on the cluster
// winner. `canonicalId === id` when the requested row already
+44 -21
View File
@@ -60,14 +60,21 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => {
...actual,
resolveModuleMap: vi.fn(async () => ({})),
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
isModuleEnabled: vi.fn(async () => true),
};
});
// The Activity Insight paragraph follows the `workoutInsights` capability.
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() }));
import { GET } from "../[id]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import {
AI_AVAILABLE,
aiUnavailable,
} from "@/__tests__/helpers/ai-capability-fixtures";
import { invalidateUserTimezone } from "@/lib/tz/resolver";
const SESSION_OK = {
@@ -139,7 +146,7 @@ describe("GET /api/workouts/{id}", () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: true,
} as never);
vi.mocked(isModuleEnabled).mockResolvedValue(true);
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
// The timezone resolver caches per user for 60 s in process; drop the
// entry so a test that pins a zone is not served the previous one.
invalidateUserTimezone(SESSION_OK.user.id);
@@ -565,6 +572,7 @@ describe("GET /api/workouts/{id} — aiInsight", () => {
vi.clearAllMocks();
vi.stubEnv("ENCRYPTION_KEY", TEST_KEY);
_resetCryptoCacheForTests();
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
@@ -616,27 +624,42 @@ describe("GET /api/workouts/{id} — aiInsight", () => {
paragraph: "A steady, aerobic-leaning run.",
generatedAt: generatedAt.toISOString(),
});
});
it("does not read or expose the stored paragraph when Insights is disabled", async () => {
vi.mocked(isModuleEnabled).mockResolvedValueOnce(false);
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...BASE_ROW,
insight: {
paragraphEncrypted: new Uint8Array([1, 2, 3]),
generatedAt: new Date(`${SESSION_DAY}T07:35:00Z`),
},
} as never);
const body = await (await get()).json();
expect(body.data.aiInsight).toBeNull();
expect(prisma.workout.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
include: expect.objectContaining({ insight: false }),
}),
expect(aiCapabilityToServe).toHaveBeenCalledWith(
"user-1",
"workoutInsights",
);
expect(body.data.ai).toEqual(AI_AVAILABLE);
});
it.each([
"operator_disabled",
"user_disabled",
"module_disabled",
"consent_required",
"no_provider",
] as const)(
"does not read or expose the stored paragraph while workoutInsights is %s",
async (reason) => {
vi.mocked(aiCapabilityToServe).mockResolvedValue(aiUnavailable(reason));
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...BASE_ROW,
insight: {
paragraphEncrypted: new Uint8Array([1, 2, 3]),
generatedAt: new Date(`${SESSION_DAY}T07:35:00Z`),
},
} as never);
const body = await (await get()).json();
expect(body.data.aiInsight).toBeNull();
expect(body.data.ai).toEqual(aiUnavailable(reason));
expect(prisma.workout.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
include: expect.objectContaining({ insight: false }),
}),
);
},
);
it("degrades to no card rather than failing the page on an undecryptable row", async () => {
// `decrypt` is fail-closed by design. A key rotated away must not turn the
// whole workout-detail page into a 500 over a garnish field.
+62 -11
View File
@@ -10,20 +10,25 @@ import { queryKeys } from "@/lib/query-keys";
* The RSC wrapper (`src/app/coach/page.tsx`) dehydrates the coach nudge status
* under `queryKeys.coachNudgeStatus()` so the auto-open-most-recent decision is
* available at hydrate (collapsing the nudge → auto-open waterfall). These
* tests pin: the exact client key; the read is only run when the Coach surface
* is reachable (operator flag ON + not user-disabled); and every unreachable /
* tests pin: the exact client key; the read is only run when the `coach`
* capability is available for the caller's own record; and every unavailable /
* error path fails soft. The streaming conversation is never prefetched here.
*/
const getUnswitchedSession = vi.fn();
const requireAssistantSurface = vi.fn();
const loadAiCapabilityInputs = vi.fn();
const resolveAiCapability = vi.fn();
const readCoachNudgeStatus = vi.fn();
vi.mock("@/lib/auth/acting-carrier", () => ({
getUnswitchedSession: () => getUnswitchedSession(),
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: (s: string) => requireAssistantSurface(s),
vi.mock("@/lib/ai/capabilities/load", () => ({
loadAiCapabilityInputs: (scope: unknown) => loadAiCapabilityInputs(scope),
}));
vi.mock("@/lib/ai/capabilities/resolve", () => ({
resolveAiCapability: (key: string, inputs: unknown) =>
resolveAiCapability(key, inputs),
}));
vi.mock("@/lib/ai/coach/nudge-status", () => ({
readCoachNudgeStatus: (id: string) => readCoachNudgeStatus(id),
@@ -38,6 +43,18 @@ const NUDGE = {
conversationId: "c1",
};
const AVAILABLE = { available: true, reason: null, onDeviceAllowed: true };
const USER_DISABLED = {
available: false,
reason: "user_disabled",
onDeviceAllowed: false,
};
const OPERATOR_DISABLED = {
available: false,
reason: "operator_disabled",
onDeviceAllowed: false,
};
afterEach(() => {
vi.clearAllMocks();
delete process.env.DASHBOARD_SSR_PREFETCH;
@@ -59,31 +76,65 @@ describe("/coach RSC prefetch", () => {
getUnswitchedSession.mockResolvedValue({
user: { id: "u1", disableCoach: false },
});
requireAssistantSurface.mockResolvedValue({});
resolveAiCapability.mockReturnValue(AVAILABLE);
readCoachNudgeStatus.mockResolvedValue(NUDGE);
const el = (await CoachPage()) as ReactElement;
const q = dehydratedQuery(el);
expect(q).not.toBeNull();
expect(q!.queryHash).toBe(hashKey(queryKeys.coachNudgeStatus()));
expect(q!.state.data).toEqual(NUDGE);
// The seeded value equals the route's wire shape, `ai` included.
expect(q!.state.data).toEqual({ ...NUDGE, ai: AVAILABLE });
});
it("resolves the Coach for the caller's own record with the owner's authority", async () => {
getUnswitchedSession.mockResolvedValue({
user: { id: "u1", disableCoach: false },
});
resolveAiCapability.mockReturnValue(AVAILABLE);
readCoachNudgeStatus.mockResolvedValue(NUDGE);
await CoachPage();
expect(loadAiCapabilityInputs).toHaveBeenCalledWith({
recordId: "u1",
authority: {
origin: "owner",
recordUserId: "u1",
actorUserId: "u1",
grantId: null,
},
sections: null,
recordKind: "self",
});
expect(resolveAiCapability.mock.calls[0]?.[0]).toBe("coach");
});
it("skips the prefetch when the user opted out of the Coach", async () => {
getUnswitchedSession.mockResolvedValue({
user: { id: "u1", disableCoach: true },
});
resolveAiCapability.mockReturnValue(USER_DISABLED);
const el = (await CoachPage()) as ReactElement;
expect(el.type).not.toBe(HydrationBoundary);
expect(requireAssistantSurface).not.toHaveBeenCalled();
expect(readCoachNudgeStatus).not.toHaveBeenCalled();
});
it("fails soft when the operator Coach flag is off (surface throws)", async () => {
it("skips the prefetch when the operator turned the Coach off", async () => {
getUnswitchedSession.mockResolvedValue({
user: { id: "u1", disableCoach: false },
});
requireAssistantSurface.mockRejectedValue(new Error("assistant disabled"));
resolveAiCapability.mockReturnValue(OPERATOR_DISABLED);
const el = (await CoachPage()) as ReactElement;
expect(el.type).not.toBe(HydrationBoundary);
expect(readCoachNudgeStatus).not.toHaveBeenCalled();
});
it("fails soft when the capability load throws", async () => {
getUnswitchedSession.mockResolvedValue({
user: { id: "u1", disableCoach: false },
});
loadAiCapabilityInputs.mockRejectedValueOnce(new Error("db blip"));
const el = (await CoachPage()) as ReactElement;
expect(el.type).not.toBe(HydrationBoundary);
@@ -94,7 +145,7 @@ describe("/coach RSC prefetch", () => {
getUnswitchedSession.mockResolvedValue({
user: { id: "u1", disableCoach: false },
});
requireAssistantSurface.mockResolvedValue({});
resolveAiCapability.mockReturnValue(AVAILABLE);
readCoachNudgeStatus.mockRejectedValue(new Error("db blip"));
const el = (await CoachPage()) as ReactElement;
+38 -11
View File
@@ -5,8 +5,10 @@ import {
} from "@tanstack/react-query";
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status";
import { loadAiCapabilityInputs } from "@/lib/ai/capabilities/load";
import { resolveAiCapability } from "@/lib/ai/capabilities/resolve";
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
import { queryKeys } from "@/lib/query-keys";
import CoachPageClient from "./page-client";
@@ -35,11 +37,12 @@ import CoachPageClient from "./page-client";
* immediate refetch. When the client query is disabled (a `?c=`/`?doc=`/
* `?scope=` deep-link makes it `enabled: false`) the seeded value simply
* sits unused — harmless.
* - Availability parity: prefetch only when the Coach surface is actually
* reachable — the operator `coach` assistant flag is ON
* (`requireAssistantSurface` throws otherwise) AND the user has not opted
* out (`disableCoach`). Mirrors the client's `!flags.coach || disableCoach`
* redirect gate, so a disabled account does no wasted work.
* - Availability parity: prefetch only when the `coach` capability is
* available for the caller's own record, resolved from every layer (the
* operator switch, the `disableCoach` opt-out, provider presence and
* consent) by the same resolver the API routes use. An unavailable Coach
* does no wasted work. The seeded value carries the same `ai` state the
* nudge-status route returns, so the hydrated shape equals the wire.
* - Left CLIENT-LAZY on purpose: the streaming conversation itself (the SSE
* thread, snapshot, any provider-touching path) is NOT prefetched or warmed
* — it depends on URL params + the nudge outcome and must never be triggered
@@ -52,6 +55,28 @@ import CoachPageClient from "./page-client";
* - Fail-soft: no session, the Coach being off, or a DB blip renders the page
* exactly as before this wrapper existed — the client cell owns the fetch.
*/
/**
* The `coach` capability for the caller's own record. An RSC has no request
* event, so the route gate cannot resolve the record from it; the unswitched
* session is always the caller's own record, read with the owner's authority.
*/
async function coachCapabilityForOwnRecord(
userId: string,
): Promise<AiCapabilityState> {
const inputs = await loadAiCapabilityInputs({
recordId: userId,
authority: {
origin: "owner",
recordUserId: userId,
actorUserId: userId,
grantId: null,
},
sections: null,
recordKind: "self",
});
return resolveAiCapability("coach", inputs);
}
export default async function CoachPage() {
// Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e
// server sets `DASHBOARD_SSR_PREFETCH=false` so Playwright route mocks — which
@@ -63,15 +88,17 @@ export default async function CoachPage() {
let dehydratedState = null;
try {
const session = await getUnswitchedSession();
if (session && !session.user.disableCoach) {
// Throws `AssistantDisabledError` when the operator flag is off → caught
// below, prefetch skipped, client path stands (and redirects to /insights).
await requireAssistantSurface("coach");
const ai = session
? await coachCapabilityForOwnRecord(session.user.id)
: null;
// An unavailable Coach skips the prefetch; the client path stands (and
// redirects to /insights).
if (session && ai?.available) {
const nudge = await readCoachNudgeStatus(session.user.id);
const queryClient = new QueryClient();
queryClient.setQueryData(
queryKeys.coachNudgeStatus(),
JSON.parse(JSON.stringify(nudge)),
JSON.parse(JSON.stringify({ ...nudge, ai })),
);
dehydratedState = dehydrate(queryClient);
}
@@ -188,6 +188,7 @@ import type { EncounterDTO } from "@/lib/encounters/dto";
import { queryKeys } from "@/lib/query-keys";
import type { DailyDigest } from "@/lib/daily/digest";
import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
function render(
access: AccountAccess,
@@ -344,6 +345,7 @@ describe("the medication card menu", () => {
const DIGEST: DailyDigest = {
generatedAt: "2026-08-03T06:00:00.000Z",
ai: DIGEST_AI_AVAILABLE,
phase: "final",
sleepPending: false,
score: null,
@@ -20,6 +20,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { TodayHero } from "../today-hero";
import type { DailyDigest } from "@/lib/daily/digest";
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
const client = new QueryClient();
@@ -35,6 +36,7 @@ const ARRIVED_AT = "2026-07-16T05:41:00.000Z";
function digest(over: Partial<DailyDigest> = {}): DailyDigest {
return {
generatedAt: "2026-07-16T06:00:00.000Z",
ai: DIGEST_AI_AVAILABLE,
phase: "final",
sleepPending: false,
score: { value: 82, band: "green", delta: 3 },
@@ -7,6 +7,7 @@ import { TodayHero } from "../today-hero";
import type { DailyDigest } from "@/lib/daily/digest";
import type { PriorityItem } from "@/lib/daily/priority-item";
import { SCORE_VERSION } from "@/lib/analytics/score/types";
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
// The hero now wires the coach check-in card's keep / let-go taps through
// `useCoachCheckinAction`, so it needs a QueryClient in the tree.
@@ -51,6 +52,7 @@ const syncItem: PriorityItem = {
function digest(over: Partial<DailyDigest> = {}): DailyDigest {
return {
generatedAt: "2026-07-16T06:00:00.000Z",
ai: DIGEST_AI_AVAILABLE,
phase: "final",
sleepPending: false,
score: {
+39
View File
@@ -185,3 +185,42 @@ export async function aiCapabilityForRecord(
? getAiCapability(key, { recordId })
: aiCapabilityForJob(recordId, key);
}
/**
* Whether text a model already wrote for a record may be SHOWN, answered from
* the record's own state, whoever is reading.
*
* Serving stored text and starting model work are different questions. A
* delegate inside somebody else's record may not start model work there
* (`not_permitted_for_record`: no generation, no warm, no chat on the owner's
* key), but reads the owner's stored briefing, narrative, status notes and
* workout notes exactly when the owner would. So this resolves the record's
* operator switches, its modules and AI opt-outs, its provider and its
* consent, under the record's own authority (`system`, as a job would, which
* also answers a managed profile correctly), unmasked by the viewer's grant.
*
* Use it only to decide whether stored model text is served. Anything that
* generates, warms or enqueues asks `getAiCapability` / `requireAiCapability`
* (the viewer's view) instead. Never throws.
*/
export async function aiCapabilityToServe(
recordId: string,
key: AiCapabilityKey,
): Promise<AiCapabilityState> {
try {
const inputs = await loadAiCapabilityInputs({
recordId,
authority: {
origin: "system",
recordUserId: recordId,
actorUserId: null,
grantId: null,
},
sections: null,
recordKind: "self",
});
return resolveAiCapability(key, inputs);
} catch {
return resolveAiCapability(key, null);
}
}
@@ -58,6 +58,7 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { deriveClarifyingQuestions } from "../self-context-questions";
import type { SelfContext } from "../about-me";
import { DEFAULT_HEALTH_PROFILE_AI_SECTIONS } from "@/lib/validations/health-profile-facts";
const ctx: SelfContext = {
aboutMe: "Shift work, half-marathon training.",
@@ -104,6 +105,7 @@ describe("deriveClarifyingQuestions — included sections", () => {
{ ...ctx, aboutMe: null },
"en",
["ABOUT_ME"],
{ aiAvailable: true },
);
expect(out.questions).toEqual([
@@ -114,9 +116,13 @@ describe("deriveClarifyingQuestions — included sections", () => {
});
it("does no question work when no question-bearing field is included", async () => {
const out = await deriveClarifyingQuestions("user-1", ctx, "en", [
"FAMILY_HISTORY",
]);
const out = await deriveClarifyingQuestions(
"user-1",
ctx,
"en",
["FAMILY_HISTORY"],
{ aiAvailable: true },
);
expect(out.questions).toEqual([]);
expect(providerMocks.hasAnyConfiguredProvider).not.toHaveBeenCalled();
@@ -124,13 +130,43 @@ describe("deriveClarifyingQuestions — included sections", () => {
});
});
describe("deriveClarifyingQuestions — capability unavailable", () => {
it("serves the deterministic hints and never touches a provider, budget or snapshot", async () => {
providerMocks.resolveProviderChain.mockResolvedValue([
{ providerType: "openai", instance: provider },
]);
const out = await deriveClarifyingQuestions(
"user-1",
{ ...ctx, aboutMe: null },
"en",
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
{ aiAvailable: false },
);
expect(out.source).toBe("fallback");
expect(out.questions.length).toBeGreaterThan(0);
expect(providerMocks.hasAnyConfiguredProvider).not.toHaveBeenCalled();
expect(providerMocks.resolveProviderChain).not.toHaveBeenCalled();
expect(budgetMocks.reserveBudget).not.toHaveBeenCalled();
expect(buildCoachSnapshot).not.toHaveBeenCalled();
expect(provider.generateCompletion).not.toHaveBeenCalled();
});
});
describe("deriveClarifyingQuestions — server-managed consent gate", () => {
it("refuses the AI path on an operator-managed chain with no receipt", async () => {
providerMocks.resolveProviderChain.mockResolvedValue([
{ providerType: "admin-openai", instance: provider },
]);
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
const out = await deriveClarifyingQuestions(
"user-1",
ctx,
"en",
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
{ aiAvailable: true },
);
expect(out.source).toBe("fallback");
// The snapshot is the PHI: it must never even be built, let alone sent.
@@ -145,7 +181,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
{ providerType: "admin-codex", instance: provider },
]);
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
const out = await deriveClarifyingQuestions(
"user-1",
ctx,
"en",
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
{ aiAvailable: true },
);
expect(out.source).toBe("fallback");
expect(provider.generateCompletion).not.toHaveBeenCalled();
@@ -157,7 +199,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
providerMocks.resolveProviderChain.mockResolvedValue([]);
providerMocks.resolveProvider.mockResolvedValue(provider);
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
const out = await deriveClarifyingQuestions(
"user-1",
ctx,
"en",
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
{ aiAvailable: true },
);
expect(out.source).toBe("fallback");
expect(provider.generateCompletion).not.toHaveBeenCalled();
@@ -171,7 +219,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
kind === "ai_coach" ? { id: "receipt-1" } : null,
);
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
const out = await deriveClarifyingQuestions(
"user-1",
ctx,
"en",
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
{ aiAvailable: true },
);
expect(out.source).toBe("ai");
expect(out.questions).toEqual(["Question one?", "Question two?"]);
@@ -186,7 +240,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
kind === "ai_full" ? { id: "receipt-2" } : null,
);
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
const out = await deriveClarifyingQuestions(
"user-1",
ctx,
"en",
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
{ aiAvailable: true },
);
expect(out.source).toBe("ai");
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
@@ -197,7 +257,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
{ providerType: "openai", instance: provider },
]);
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
const out = await deriveClarifyingQuestions(
"user-1",
ctx,
"en",
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
{ aiAvailable: true },
);
expect(out.source).toBe("ai");
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
@@ -209,7 +275,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
{ providerType: "local", instance: provider },
]);
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
const out = await deriveClarifyingQuestions(
"user-1",
ctx,
"en",
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
{ aiAvailable: true },
);
expect(out.source).toBe("ai");
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
@@ -223,7 +295,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
{ providerType: "admin-openai", instance: provider },
]);
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
const out = await deriveClarifyingQuestions(
"user-1",
ctx,
"en",
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
{ aiAvailable: true },
);
expect(out.source).toBe("fallback");
expect(provider.generateCompletion).not.toHaveBeenCalled();
@@ -238,7 +316,13 @@ describe("deriveClarifyingQuestions — atomic budget", () => {
});
it("reserves before the call and reconciles the actual spend after", async () => {
await deriveClarifyingQuestions("user-1", ctx, "en");
await deriveClarifyingQuestions(
"user-1",
ctx,
"en",
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
{ aiAvailable: true },
);
expect(budgetMocks.reserveBudget).toHaveBeenCalledWith(
"user-1",
@@ -267,7 +351,13 @@ describe("deriveClarifyingQuestions — atomic budget", () => {
operatorAfter: 200_000,
});
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
const out = await deriveClarifyingQuestions(
"user-1",
ctx,
"en",
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
{ aiAvailable: true },
);
expect(out.source).toBe("fallback");
expect(provider.generateCompletion).not.toHaveBeenCalled();
@@ -276,7 +366,13 @@ describe("deriveClarifyingQuestions — atomic budget", () => {
it("reconciles the reservation to zero when the provider throws", async () => {
provider.generateCompletion.mockRejectedValue(new Error("upstream down"));
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
const out = await deriveClarifyingQuestions(
"user-1",
ctx,
"en",
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
{ aiAvailable: true },
);
expect(out.source).toBe("fallback");
expect(budgetMocks.reconcileSpend).toHaveBeenCalledWith(
+7 -1
View File
@@ -181,17 +181,23 @@ async function resolveQuestionsChain(
* Derive the pending questions for a freshly saved self-context.
* Never throws — every failure path lands on the deterministic
* fallback so the PUT route stays robust.
*
* `aiAvailable` is the caller's resolved `aboutMeQuestions` capability. When
* it is false the deterministic fallback is the whole answer: no provider is
* resolved, no budget reserved, nothing leaves the server.
*/
export async function deriveClarifyingQuestions(
userId: string,
ctx: SelfContext,
locale: string | null | undefined,
includedSections: readonly HealthProfileAiSection[] = DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
includedSections: readonly HealthProfileAiSection[],
options: { aiAvailable: boolean },
): Promise<{ questions: string[]; source: "ai" | "fallback" }> {
const fallback = () => ({
questions: buildFallbackQuestions(ctx, locale, includedSections),
source: "fallback" as const,
});
if (!options.aiAvailable) return fallback();
const included = new Set(includedSections);
// A free-form model reply cannot be proven not to cross into an omitted
// field. Partial inclusion therefore stays on the closed deterministic
+12 -1
View File
@@ -8,6 +8,7 @@
*/
import { z } from "zod/v4";
import type { AiUnavailableReason } from "@/lib/ai/capabilities/types";
import type { CoachSuggestedAction } from "./suggest-action";
/**
@@ -227,7 +228,17 @@ export type CoachStreamEvent =
messageId: string;
usage?: CoachUsage;
}
| { type: "error"; code: string; message: string };
| {
type: "error";
code: string;
message: string;
/**
* The AI capability reason, when the refusal came from one (today only
* `no_provider` on `coach.provider.none`). Additive: older clients
* ignore it.
*/
reason?: AiUnavailableReason;
};
/**
* v1.4.22 — Zod schema for one entry inside the Coach's evidence
+1 -1
View File
@@ -52,7 +52,7 @@ const SERVER_MANAGED_PROVIDER_TYPES: ReadonlySet<string> = new Set([
/**
* Error thrown when an external-LLM egress on a server-managed key is
* attempted without an active consent receipt. Mirrors `AssistantDisabledError`
* attempted without an active consent receipt. Mirrors the AI capability refusal
* so the api-handler renders the same 403 envelope shape the iOS client
* already branches on:
*
+2 -20
View File
@@ -26,7 +26,6 @@ import {
selectorNamesAnAccount,
type ActingCarrier,
} from "./auth/acting-carrier";
import { AssistantDisabledError } from "./feature-flags";
import { AiUnavailableError } from "./ai/capabilities/refusal";
import { ConsentRequiredError } from "./ai/consent-guard";
import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config";
@@ -246,27 +245,10 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
{ data: null, error: error.message, meta: error.meta },
{ status: error.status },
);
} else if (error instanceof AssistantDisabledError) {
// v1.4.31 — operator has disabled the assistant surface.
// The 403 + `errorCode: "assistant.disabled.<surface>"`
// envelope is locked per
// `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
// Older iOS clients that don't know the errorCode surface
// this as a generic 403; v1.4.31+ clients can branch on the
// errorCode to render an inline operator-disabled notice.
evt.setError(error);
response = NextResponse.json(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
},
{ status: 403 },
);
} else if (error instanceof ConsentRequiredError) {
// v1.12.1 — server-side consent gate before external-LLM PHI
// egress on the operator's server-managed key. Mirrors the
// AssistantDisabledError envelope (403 + meta.errorCode) so the
// egress on the operator's server-managed key. 403 +
// meta.errorCode, like the capability refusal above, so the
// iOS client renders an inline "grant consent" notice instead of
// a generic failure.
evt.setError(error);
@@ -21,6 +21,7 @@ import { isUrgentPayload } from "@/lib/notifications/types";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { loadDailyDigest } from "@/lib/daily/load-digest";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
// 06:00Z → 08:00 in Europe/Berlin (summer) → inside the morning window AND the
// fixed fallback hour. The tz-window cases move only this instant.
@@ -31,6 +32,7 @@ const AFTER_WINDOW = new Date("2026-07-16T12:30:00Z"); // 14:30 Berlin
function makeDigest(over: Partial<DailyDigest> = {}): DailyDigest {
return {
generatedAt: "2026-07-16T06:00:00.000Z",
ai: DIGEST_AI_AVAILABLE,
phase: "final",
sleepPending: false,
score: { value: 82, band: "good", delta: 1 },
@@ -80,7 +82,6 @@ function makeDeps(
return {
dispatch,
loadDigest: async () => makeDigest(),
isModuleEnabled: async () => true,
...over,
} as DailyBriefingDispatchDeps & { dispatch: ReturnType<typeof vi.fn> };
}
@@ -251,17 +252,27 @@ describe("maybeDispatchDailyBriefing", () => {
expect(deps.dispatch).not.toHaveBeenCalled();
});
it("insights module off → no dispatch", async () => {
it("AI analysis off: the push still goes out with the deterministic line", async () => {
// The digest the loader returns when the briefing capability is
// unavailable: no lead, no top signal, the score floor as the line.
const prisma = makePrisma({});
const deps = makeDeps({ isModuleEnabled: async () => false });
const deps = makeDeps({
loadDigest: async () =>
makeDigest({
briefingLead: null,
topSignal: null,
line: "Your health score today is 82.",
}),
});
const result = await maybeDispatchDailyBriefing(
prisma,
"u1",
IN_WINDOW,
deps,
);
expect(result).toBe("module-off");
expect(deps.dispatch).not.toHaveBeenCalled();
expect(result).toBe("sent");
const payload = JSON.stringify(deps.dispatch.mock.calls[0]);
expect(payload).toContain("Your health score today is 82.");
});
it("frequency cap: an ok ledger row earlier the SAME local day suppresses the second push", async () => {
@@ -18,6 +18,7 @@ import {
type DailyDigestInput,
} from "@/lib/daily/digest";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
const t = getServerTranslator("en").t;
const NOW = new Date("2026-07-16T09:00:00.000Z");
@@ -38,6 +39,7 @@ function meds(): MedsTodayBlock {
function input(over: Partial<DailyDigestInput> = {}): DailyDigestInput {
return {
now: NOW,
ai: DIGEST_AI_AVAILABLE,
todayEndExclusive: new Date("2026-07-17T00:00:00.000Z"),
modules: {},
enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS],
+59 -16
View File
@@ -28,6 +28,10 @@ import {
sameTimeBaselineItemKey,
tensionWindowItemKey,
} from "@/lib/daily/priority-item-key";
import {
aiUnavailable,
DIGEST_AI_AVAILABLE,
} from "@/__tests__/helpers/ai-capability-fixtures";
const t = getServerTranslator("en").t;
const NOW = new Date("2026-07-16T09:00:00.000Z");
@@ -64,6 +68,7 @@ const briefing: DailyBriefing = {
function input(over: Partial<DailyDigestInput> = {}): DailyDigestInput {
return {
now: NOW,
ai: DIGEST_AI_AVAILABLE,
// NOW is 09:00Z on 2026-07-16; UTC profile day ends at next midnight.
todayEndExclusive: new Date("2026-07-17T00:00:00.000Z"),
modules: {},
@@ -766,13 +771,25 @@ describe("buildDailyDigest — coach check-in (S3)", () => {
expect(checkin(d)).toBeUndefined();
});
it("does NOT emit a check-in when the coach module is off", () => {
const d = buildDailyDigest(
input({ modules: { coach: false }, coachPlans: [plan()] }),
t,
);
expect(checkin(d)).toBeUndefined();
});
it.each([
"operator_disabled",
"module_disabled",
"user_disabled",
"no_provider",
"consent_required",
] as const)(
"does NOT emit a check-in while the coach capability is %s",
(reason) => {
const d = buildDailyDigest(
input({
ai: { ...DIGEST_AI_AVAILABLE, coach: aiUnavailable(reason) },
coachPlans: [plan()],
}),
t,
);
expect(checkin(d)).toBeUndefined();
},
);
it("emits none when there are no standing plans", () => {
const d = buildDailyDigest(input({ coachPlans: [] }), t);
@@ -862,12 +879,13 @@ describe("S12 — the milestone reward card", () => {
expect(milestone(buildDailyDigest(input(), t))).toBeUndefined();
});
it("is suppressed when the insights module is off (module-gated)", () => {
it("is data: the AI analysis opt-out (insights module off) does not hide it", () => {
const d = buildDailyDigest(
input({ milestone: RECORD_MILESTONE, modules: { insights: false } }),
t,
);
expect(milestone(d)).toBeUndefined();
expect(milestone(d)).toBeDefined();
expect(milestone(d)?.moduleKey).toBeUndefined();
});
it("sits just below an overdue dose and above ambient items", () => {
@@ -936,7 +954,7 @@ describe("buildDailyDigest — S11 tension_window item", () => {
expect(tension(d)).toBeUndefined();
});
it("stays silent when the insights module is off", () => {
it("is data: the AI analysis opt-out (insights module off) does not hide it", () => {
const d = buildDailyDigest(
input({
modules: { insights: false },
@@ -944,7 +962,7 @@ describe("buildDailyDigest — S11 tension_window item", () => {
}),
t,
);
expect(tension(d)).toBeUndefined();
expect(tension(d)).toBeDefined();
});
it("yields the bounded rail to time-sensitive actions first", () => {
@@ -1042,12 +1060,12 @@ describe("buildDailyDigest — same_time_baseline item", () => {
expect(sameTimeItem(buildDailyDigest(input({}), t))).toBeUndefined();
});
it("stays silent when the insights module is off", () => {
it("is data: the AI analysis opt-out (insights module off) does not hide it", () => {
const d = buildDailyDigest(
input({ modules: { insights: false }, sameTime: sameTime() }),
t,
);
expect(sameTimeItem(d)).toBeUndefined();
expect(sameTimeItem(d)).toBeDefined();
});
it("uses a different sentence when today is ahead", () => {
@@ -1131,7 +1149,8 @@ describe("buildDailyDigest — ecg_new_recording (S10)", () => {
const item = ecgItem(d);
expect(item).toBeDefined();
expect(item?.status).toBe("info");
expect(item?.moduleKey).toBe("insights");
// Device data: no module owns it.
expect(item?.moduleKey).toBeUndefined();
// Single action, deep-linking the ECG viewer.
expect(item?.actions).toHaveLength(1);
expect(item?.actions[0].intent).toBe("ecg.view");
@@ -1182,7 +1201,7 @@ describe("buildDailyDigest — ecg_new_recording (S10)", () => {
expect(ecgItem(d)).toBeUndefined();
});
it("does not emit when the insights module is off", () => {
it("is device data: the AI analysis opt-out (insights module off) does not hide it", () => {
const d = buildDailyDigest(
input({
modules: { insights: false },
@@ -1193,7 +1212,7 @@ describe("buildDailyDigest — ecg_new_recording (S10)", () => {
}),
t,
);
expect(ecgItem(d)).toBeUndefined();
expect(ecgItem(d)).toBeDefined();
});
it("emits nothing when there is no recent recording", () => {
@@ -1274,3 +1293,27 @@ describe("buildDailyDigest — dismiss filtering never touches actionable kinds"
).toBeUndefined();
});
});
describe("buildDailyDigest — AI text follows its capability", () => {
it.each([
"operator_disabled",
"user_disabled",
"no_provider",
"consent_required",
] as const)(
"drops the briefing lead and top signal while briefing is %s",
(reason) => {
const d = buildDailyDigest(
input({
ai: { ...DIGEST_AI_AVAILABLE, briefing: aiUnavailable(reason) },
}),
t,
);
expect(d.briefingLead).toBeNull();
expect(d.topSignal).toBeNull();
// The push line falls to the deterministic floor, never to model text.
expect(d.line).toBe("Your health score today is 82.");
expect(d.ai.briefing.reason).toBe(reason);
},
);
});
+113 -3
View File
@@ -44,6 +44,15 @@ vi.mock("@/lib/analytics/intraday-pulse-io", () => ({
loadIntradayPulse: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: vi.fn(),
aiCapabilityToServe: vi.fn(),
}));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
decryptFromBytes: vi.fn(() => "a reaction line"),
}));
vi.mock("@/lib/i18n/server-translator", () => ({
getServerTranslator: vi.fn().mockReturnValue({ t: (key: string) => key }),
}));
@@ -57,6 +66,16 @@ import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
import { prisma } from "@/lib/db";
import {
aiCapabilityForRecord,
aiCapabilityToServe,
} from "@/lib/ai/capabilities/gate";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import {
AI_AVAILABLE,
aiUnavailable,
} from "@/__tests__/helpers/ai-capability-fixtures";
const SNAPSHOT = {
body: {
@@ -75,6 +94,7 @@ const SNAPSHOT = {
briefingState: "ready",
briefingUpdatedAt: null,
briefingStale: false,
briefingAi: AI_AVAILABLE,
},
locale: "en",
};
@@ -94,6 +114,8 @@ beforeEach(() => {
vi.mocked(resolveModuleMap).mockResolvedValue({} as never);
vi.mocked(probeRollupCoverage).mockResolvedValue(new Map());
vi.mocked(loadIntradayPulse).mockResolvedValue({ tension: null } as never);
vi.mocked(aiCapabilityForRecord).mockResolvedValue(AI_AVAILABLE);
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
});
describe("loadDailyDigest — S11/S12 extras cache", () => {
@@ -145,13 +167,13 @@ describe("loadDailyDigest — S11/S12 extras cache", () => {
expect(loadIntradayPulse).toHaveBeenCalledTimes(2);
});
it("never gathers the extras when the insights module is disabled", async () => {
it("gathers the extras even with the insights module (the AI analysis opt-out) off", async () => {
vi.mocked(resolveModuleMap).mockResolvedValue({ insights: false } as never);
await loadDailyDigest(USER, NOW);
expect(probeRollupCoverage).not.toHaveBeenCalled();
expect(loadIntradayPulse).not.toHaveBeenCalled();
expect(probeRollupCoverage).toHaveBeenCalled();
expect(loadIntradayPulse).toHaveBeenCalled();
});
it("threads the resolved hero-item visibility into composition", async () => {
vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({
@@ -191,3 +213,91 @@ describe("loadDailyDigest — S11/S12 extras cache", () => {
expect(digest.worthALook.map((item) => item.kind)).toContain("dose_window");
});
});
describe("loadDailyDigest — AI parts", () => {
const ARRIVAL = {
kind: "sleep_night",
occurredAt: new Date("2026-07-17T06:00:00.000Z"),
arrivedAt: new Date("2026-07-17T08:55:00.000Z"),
lineEncrypted: new Uint8Array([1, 2, 3]),
generatedAt: new Date("2026-07-17T08:56:00.000Z"),
};
it("resolves the coach and reaction-line capabilities for the record and publishes them", async () => {
const digest = await loadDailyDigest(USER, NOW);
expect(aiCapabilityForRecord).toHaveBeenCalledWith("user-1", "coach");
expect(aiCapabilityToServe).toHaveBeenCalledWith("user-1", "reactionLines");
expect(digest.ai).toEqual({
briefing: AI_AVAILABLE,
coach: AI_AVAILABLE,
reactionLines: AI_AVAILABLE,
});
});
it("serves the stored reaction line while the capability is available", async () => {
vi.mocked(prisma.arrivalReaction.findMany).mockResolvedValueOnce([
ARRIVAL,
] as never);
const digest = await loadDailyDigest(USER, NOW);
expect(digest.reactionLine).toBe("a reaction line");
});
it("neither decrypts nor serves a stored reaction line while the capability is unavailable", async () => {
vi.mocked(aiCapabilityToServe).mockResolvedValue(
aiUnavailable("user_disabled"),
);
vi.mocked(prisma.arrivalReaction.findMany).mockResolvedValueOnce([
ARRIVAL,
] as never);
const digest = await loadDailyDigest(USER, NOW);
expect(digest.reactionLine).toBeNull();
expect(decryptFromBytes).not.toHaveBeenCalled();
// The marker itself is data and still drives the "just in" chip.
expect(digest.justIn?.kind).toBe("sleep_night");
});
it("does not decrypt plan prose while the coach capability is unavailable", async () => {
vi.mocked(aiCapabilityForRecord).mockResolvedValue(
aiUnavailable("operator_disabled"),
);
vi.mocked(prisma.coachPlan.findMany).mockResolvedValueOnce([
{
id: "p1",
status: "active",
reviewDate: null,
createdAt: new Date("2026-07-01T00:00:00.000Z"),
updatedAt: new Date("2026-07-01T00:00:00.000Z"),
ifCueEncrypted: new Uint8Array([1]),
thenActionEncrypted: new Uint8Array([2]),
},
] as never);
const digest = await loadDailyDigest(USER, NOW);
expect(decryptFromBytes).not.toHaveBeenCalled();
expect(digest.worthALook.some((i) => i.kind === "coach_checkin")).toBe(
false,
);
});
it("fails closed on the briefing when a cached body predates the published state", async () => {
const { briefingAi: _dropped, ...older } = SNAPSHOT.body;
vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({
...SNAPSHOT,
body: older,
} as never);
const digest = await loadDailyDigest(USER, NOW);
expect(digest.ai.briefing).toEqual({
available: false,
reason: "check_failed",
onDeviceAllowed: false,
});
});
});
+9 -15
View File
@@ -32,7 +32,6 @@ import type { Locale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { ServerTranslator } from "@/lib/i18n/server-translator";
import { isModuleEnabled } from "@/lib/modules/gate";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { userDayKey } from "@/lib/tz/format";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
@@ -72,7 +71,6 @@ export type DailyBriefingDispatchResult =
| "suppressed-frequency"
| "no-digest"
| "opted-out"
| "module-off"
| "outside-window"
| "no-channel"
| "missing-user"
@@ -81,7 +79,6 @@ export type DailyBriefingDispatchResult =
export interface DailyBriefingDispatchDeps {
dispatch?: typeof dispatchNotification;
loadDigest?: (user: User, now: Date, locale: Locale) => Promise<DailyDigest>;
isModuleEnabled?: typeof isModuleEnabled;
}
function loadDailyBriefingDigest(
@@ -137,10 +134,15 @@ export function buildDailyBriefingPush(
* 1. user missing → `missing-user`
* 2. no opt-in (default OFF) → `opted-out` (no enabled DAILY_BRIEFING pref)
* 3. outside morning window → `outside-window`
* 4. insights module off → `module-off`
* 5. already pushed today → `suppressed-frequency` (push_attempts ledger)
* 6. digest not substantive → `no-digest`
* 7. dispatch; no channel → `no-channel`; else `sent`.
* 4. already pushed today → `suppressed-frequency` (push_attempts ledger)
* 5. digest not substantive → `no-digest`
* 6. dispatch; no channel → `no-channel`; else `sent`.
*
* No module gate. The `insights` module is the AI analysis opt-out: with it
* off (or AI unavailable for any other reason) the digest carries no briefing
* lead or top signal, and the push body falls to the digest's deterministic
* line (the score, or the all-clear). A digest with nothing deterministic to
* say is skipped as `no-digest`, like any other.
*/
export async function maybeDispatchDailyBriefing(
prisma: PrismaClient,
@@ -150,7 +152,6 @@ export async function maybeDispatchDailyBriefing(
): Promise<DailyBriefingDispatchResult> {
const dispatch = deps.dispatch ?? dispatchNotification;
const loadDigest = deps.loadDigest ?? loadDailyBriefingDigest;
const moduleGate = deps.isModuleEnabled ?? isModuleEnabled;
try {
const user = await prisma.user.findUnique({ where: { id: userId } });
@@ -187,13 +188,6 @@ export async function maybeDispatchDailyBriefing(
return "outside-window";
}
// Insights is the digest's home module; a user who turned it off gets no
// digest surfaces at all, so no morning push either.
if (!(await moduleGate(userId, "insights"))) {
annotate({ action: { name: "daily.briefing_push.module_off" } });
return "module-off";
}
// One per user per LOCAL day. Anchored on the `push_attempts` ledger the
// senders already write (`ok` on a successful delivery), exactly like the
// coach-nudge cap: whichever trigger fired first this morning left an `ok`
+41 -21
View File
@@ -21,6 +21,7 @@
*/
import type { EncounterKind } from "@/generated/prisma/client";
import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema";
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
import type { ArrivalKind } from "@/lib/arrivals/types";
import { encounterKindLabelKey } from "@/lib/encounters/kind-label";
import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
@@ -254,8 +255,22 @@ export interface DailyDigestArrival {
export const JUST_IN_WINDOW_MS = 3 * 60 * 60 * 1000;
/** The fully-resolved, IO-free input the composer folds into a digest. */
/**
* The three AI capabilities the digest carries text or a card for, resolved
* for this record by the IO seam. The builder serves the briefing lead and
* top signal only while `briefing` is available, the reaction line only while
* `reactionLines` is, and the Coach check-in only while `coach` is; the rest
* of the digest is data and never depends on them.
*/
export interface DailyDigestAi {
briefing: AiCapabilityState;
coach: AiCapabilityState;
reactionLines: AiCapabilityState;
}
export interface DailyDigestInput {
now: Date;
ai: DailyDigestAi;
/**
* First instant of the user's NEXT local day (profile tz), resolved by the
* IO seam via `getUserTodayBounds`. Bounds the dose-window rail to TODAY'S
@@ -374,6 +389,8 @@ export interface DailyDigest {
* sentence is garnish; its absence degrades nothing.
*/
reactionLine: string | null;
/** Why the briefing lead, the reaction line or the check-in are absent. */
ai: DailyDigestAi;
}
/** First sentence of a paragraph, trimmed; null when empty. */
@@ -598,8 +615,8 @@ function buildUpcomingVisitItem(
/**
* S12 — the calm reward card. Emits ONE `milestone` PriorityItem when a durable
* state was reached TODAY (the IO seam already applied the reached-once gate).
* Gated on the `insights` module — the daily narrative layer that hosts it.
* Celebratory-but-quiet: `success` status, a single "view" action into the
* Data, not AI: no module gate beyond the metric's own (applied by the IO
* seam). Celebratory-but-quiet: `success` status, a single "view" action into the
* metric's insight, and copy that marks arrival at a state, never a maintained
* count. Shown the day reached and never again — never a "you broke it" note.
*/
@@ -609,7 +626,6 @@ function buildMilestoneItem(
t: Translate,
): PriorityItem | null {
if (!milestone) return null;
if (!moduleEnabled(modules, "insights")) return null;
const { title, body } = milestoneCopy(milestone, t);
return {
kind: "milestone",
@@ -624,15 +640,14 @@ function buildMilestoneItem(
href: milestoneHref(milestone),
},
],
moduleKey: "insights",
};
}
/**
* S11 — the elevated-at-rest ("tension") card. Emitted at most once per day
* (the window is already the day's single most confident stretch), gated on
* the `insights` module and on a non-null window (the analytics layer stays
* silent unless every confidence gate holds). Cautious, non-diagnostic copy:
* a non-null window (the analytics layer stays silent unless every confidence
* gate holds). Pulse is a core vital, so no module gates it. Cautious, non-diagnostic copy:
* "possible tension", never a clinical stress verdict. The one action deep-
* links into the pulse insight where the intraday shape is charted.
*/
@@ -642,7 +657,6 @@ function buildTensionWindowItem(
todayLocalDate: string,
t: Translate,
): PriorityItem | null {
if (!moduleEnabled(modules, "insights")) return null;
if (!window) return null;
return {
kind: "tension_window",
@@ -657,7 +671,6 @@ function buildTensionWindowItem(
href: "/insights/pulse",
},
],
moduleKey: "insights",
};
}
@@ -681,7 +694,6 @@ function buildSameTimeBaselineItem(
todayLocalDate: string,
t: Translate,
): PriorityItem | null {
if (!moduleEnabled(modules, "insights")) return null;
if (!sameTime) return null;
if (sameTime.band === "within") return null;
// The comparison is against everything accumulated through the END of
@@ -704,7 +716,6 @@ function buildSameTimeBaselineItem(
href: "/insights/steps",
},
],
moduleKey: "insights",
};
}
@@ -735,7 +746,10 @@ function isCheckinDue(plan: DailyDigestCoachPlan, now: number): boolean {
}
/**
* The one coach check-in card (§2.3). Gated on the `coach` module. Capped at
* The one coach check-in card (§2.3). Served only while the `coach` AI
* capability is available: the card's "Adjust" action opens the Coach, and a
* Coach that is off, opted out or without a provider has nothing to open.
* The capability folds in the `coach` module. Capped at
* ONE per day across every plan: the earliest-due check-in wins, the rest wait
* for a following day. Reads existing plan state only — never a fresh AI call.
* Three one-tap actions map to the plan lifecycle: keep (re-arm), adjust
@@ -743,11 +757,11 @@ function isCheckinDue(plan: DailyDigestCoachPlan, now: number): boolean {
*/
function buildCoachCheckinItem(
plans: DailyDigestCoachPlan[],
modules: DigestModuleMap,
coach: AiCapabilityState,
now: Date,
t: Translate,
): PriorityItem | null {
if (!moduleEnabled(modules, "coach")) return null;
if (!coach.available) return null;
const nowMs = now.getTime();
const due = plans
.filter((p) => isCheckinDue(p, nowMs))
@@ -820,8 +834,8 @@ const ECG_VERDICT_KEYS: Record<
};
/**
* The one ECG "new recording" item (§3.5.3). Gated on the `insights` module
* (the ECG viewer's own gate). Fires ONLY when the freshest recording landed
* The one ECG "new recording" item (§3.5.3). Device data: no module gates
* it, as none gates the ECG viewer. Fires ONLY when the freshest recording landed
* within the last day — a calm "a new ECG recording is ready to view" pointer
* into the viewer. NON-DIAGNOSTIC: the body echoes ONLY the RECORDING DEVICE's
* verdict, attributed to the device; HealthLog never interprets the trace (the
@@ -834,7 +848,6 @@ function buildEcgNewRecordingItem(
now: Date,
t: Translate,
): PriorityItem | null {
if (!moduleEnabled(modules, "insights")) return null;
if (!ecg) return null;
const age = now.getTime() - ecg.recordedAt.getTime();
// Skip a future-dated row (clock skew) and anything older than the window.
@@ -860,7 +873,6 @@ function buildEcgNewRecordingItem(
href: "/insights#ecg",
},
],
moduleKey: "insights",
};
}
@@ -932,8 +944,12 @@ export function buildDailyDigest(
const sleepPending = sleepEnabled && sleepExpected && !isFinal;
const phase: DailyDigest["phase"] = isFinal ? "final" : "provisional";
const topSignal = input.briefing?.signalsOfDay?.[0] ?? null;
const briefingLead = firstSentence(input.briefing?.paragraph);
// The briefing is model text: its lead and top signal are served only while
// the `briefing` capability is available. The IO seam already hides it; this
// is the builder's own backstop, so the DTO cannot carry it by accident.
const briefing = input.ai.briefing.available ? input.briefing : null;
const topSignal = briefing?.signalsOfDay?.[0] ?? null;
const briefingLead = firstSentence(briefing?.paragraph);
// The day's most recently LANDED arrival drives both reaction fields. The
// sample timestamp may be hours old (sleep synced after waking, an offline
@@ -969,7 +985,10 @@ export function buildDailyDigest(
: null;
// An empty or whitespace-only line is treated as absent rather than shipped
// as a blank lead — a degraded generation must fall through to the floor.
const reactionLine = newestArrival?.line?.trim() || null;
// Model text too: served only while `reactionLines` is available.
const reactionLine = input.ai.reactionLines.available
? newestArrival?.line?.trim() || null
: null;
// Priority order: a due or overdue dose is the most time-sensitive daily
// action, a broken sync next, then the calm coach check-in, a preventive
@@ -993,7 +1012,7 @@ export function buildDailyDigest(
worthALook.push(...buildSyncIssueItems(input.syncIssues, t));
const checkin = buildCoachCheckinItem(
input.coachPlans,
input.modules,
input.ai.coach,
input.now,
t,
);
@@ -1070,5 +1089,6 @@ export function buildDailyDigest(
worthALook: visible.slice(0, MAX_WORTH_A_LOOK),
justIn,
reactionLine,
ai: input.ai,
};
}
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more