Show the answers behind a screening score

Refs A3-8. The per-item PHQ-9 / GAD-7 / WHO-5 / SCI answers have been
stored encrypted since the assessments shipped, and nothing ever read
them back — the clinical detail (item 9 included) was invisible. A new
by-id read decrypts them for whoever holds the mind read grant (the same
grant that already serves the mood diary's prose and the item-9 flag),
degrades to an honest items-unavailable state on any decrypt or shape
problem, and the history rows gain an expandable breakdown rendering the
official instrument texts the check-in flow already carries — nothing
retranslated, no alarm styling on item 9 beyond what the house already
does.

Rides along: the guard inventories learn the branch's new routes (frozen
counts moved with reasons), the reminder-engine test harnesses carry the
ledger delegate, and the custom-metric restore toast keys sit in their
right namespace in all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 09:46:48 +02:00
1 parent 651d37b494
commit 90bad17cc8
23 files changed
+1036 -34

No files matched your search

@@ -107,8 +107,8 @@ describe("DELETE /api/custom-metrics/[id]/entries/[entryId]", () => {
);
expect(res.status).toBe(404);
const where = vi.mocked(prisma.customMetricEntry.findFirst).mock.calls[0][0]
.where;
const where = vi.mocked(prisma.customMetricEntry.findFirst).mock
.calls[0]![0]!.where!;
expect(where.deletedAt).toBeNull();
expect(prisma.customMetricEntry.update).not.toHaveBeenCalled();
});
@@ -0,0 +1,241 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
/**
* `GET /api/mental-health/assessments/[id]` — the per-administration detail
* read that finally consumes `responsesEncrypted` (audit finding: the column
* was write-only; its single reader was key rotation).
*
* Pins the two halves of the contract:
* (a) the owner's detail read decrypts the stored blob and returns the
* per-item answers + the unscored functional follow-up, and
* (b) a decrypt failure (key gap / corruption / shape drift) degrades to
* `items: null` + `itemsUnavailable: true` while the denormalised
* total / band / flag still answer — never a 500 for the whole row
* (the records-DTO fail-soft precedent).
*/
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
mentalHealthAssessment: { findFirst: vi.fn() },
},
}));
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/ai/coach/bytes-codec", () => ({
decryptFromBytes: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "testuser",
role: "USER" as const,
locale: "en",
},
};
const callGet = GET as unknown as (
req: NextRequest,
ctx: { params: Promise<{ id: string }> },
) => Promise<Response>;
function makeReq(
id: string,
): [NextRequest, { params: Promise<{ id: string }> }] {
return [
new NextRequest(
new URL(`http://localhost/api/mental-health/assessments/${id}`),
{ method: "GET" },
),
{ params: Promise.resolve({ id }) },
];
}
/** A stored PHQ-9 administration as the select returns it. */
function storedRow(overrides: Record<string, unknown> = {}) {
return {
id: "mha_1",
userId: "user-1",
instrument: "PHQ9",
locale: "en",
version: "standard",
totalScore: 10,
severityBand: "moderate",
item9Flagged: true,
crisisShownAt: new Date("2026-06-28T00:00:00.000Z"),
takenAt: new Date("2026-06-28T00:00:00.000Z"),
createdAt: new Date("2026-06-28T00:00:00.000Z"),
responsesEncrypted: Buffer.from("ciphertext"),
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
});
describe("GET /api/mental-health/assessments/[id]", () => {
it("returns the decrypted per-item answers + functional follow-up for the owner", async () => {
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
storedRow() as never,
);
vi.mocked(decryptFromBytes).mockReturnValue(
JSON.stringify({
items: [1, 1, 1, 1, 1, 1, 1, 1, 2],
functionalDifficulty: 1,
schema: 1,
}),
);
const res = await callGet(...makeReq("mha_1"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
assessment: {
id: string;
totalScore: number;
items: number[] | null;
functionalDifficulty: number | null;
itemsUnavailable: boolean;
};
};
};
expect(body.data.assessment.id).toBe("mha_1");
expect(body.data.assessment.items).toEqual([1, 1, 1, 1, 1, 1, 1, 1, 2]);
expect(body.data.assessment.functionalDifficulty).toBe(1);
expect(body.data.assessment.itemsUnavailable).toBe(false);
// The denormalised score fields ride along unchanged.
expect(body.data.assessment.totalScore).toBe(10);
});
it("omits functionalDifficulty as null when the stored blob has none", async () => {
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
storedRow({
instrument: "GAD7",
totalScore: 6,
item9Flagged: false,
}) as never,
);
vi.mocked(decryptFromBytes).mockReturnValue(
JSON.stringify({ items: [1, 2, 0, 1, 1, 1, 0], schema: 1 }),
);
const res = await callGet(...makeReq("mha_1"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
assessment: {
items: number[] | null;
functionalDifficulty: number | null;
};
};
};
expect(body.data.assessment.items).toEqual([1, 2, 0, 1, 1, 1, 0]);
expect(body.data.assessment.functionalDifficulty).toBeNull();
});
it("degrades to items: null + itemsUnavailable on a decrypt failure — never a 500", async () => {
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
storedRow() as never,
);
vi.mocked(decryptFromBytes).mockImplementation(() => {
throw new Error("Unknown encryption key id: v9");
});
const res = await callGet(...makeReq("mha_1"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
assessment: {
totalScore: number;
items: number[] | null;
itemsUnavailable: boolean;
};
};
};
expect(body.data.assessment.items).toBeNull();
expect(body.data.assessment.itemsUnavailable).toBe(true);
// The score half of the row still answers.
expect(body.data.assessment.totalScore).toBe(10);
});
it("degrades on a malformed or shape-drifted plaintext (wrong item count)", async () => {
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
storedRow() as never,
);
// 3 answers on a stored PHQ-9 row: pairing them to the 9 official item
// texts would mislabel every answer, so the read refuses to guess.
vi.mocked(decryptFromBytes).mockReturnValue(
JSON.stringify({ items: [1, 2, 3], schema: 1 }),
);
const res = await callGet(...makeReq("mha_1"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
assessment: { items: number[] | null; itemsUnavailable: boolean };
};
};
expect(body.data.assessment.items).toBeNull();
expect(body.data.assessment.itemsUnavailable).toBe(true);
});
it("404s another user's row without leaking its existence", async () => {
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
storedRow({ userId: "user-2" }) as never,
);
const res = await callGet(...makeReq("mha_1"));
expect(res.status).toBe(404);
// The item content never left the guard.
expect(decryptFromBytes).not.toHaveBeenCalled();
});
it("404s a missing row", async () => {
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
null as never,
);
const res = await callGet(...makeReq("mha_missing"));
expect(res.status).toBe(404);
});
it("returns the module 403 when the mental-health module is off", async () => {
const { apiError } = await import("@/lib/api-response");
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: apiError("disabled", 403, { errorCode: "module.disabled" }),
});
const res = await callGet(...makeReq("mha_1"));
expect(res.status).toBe(403);
expect(prisma.mentalHealthAssessment.findFirst).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,175 @@
/**
* `GET /api/mental-health/assessments/[id]` — one screener administration WITH
* its decrypted per-item answers.
*
* The list route beside this one denormalises total + band + item-9 flag for
* cheap history reads and deliberately never touches `responsesEncrypted`.
* Until this route existed nothing read the blob back at all (its only
* consumer was key rotation), so the stored clinical detail — including the
* PHQ-9 item-9 answer — was invisible to the person it belongs to. This is
* the read side of that write.
*
* GATING — same declaration as the sibling list read (`read`, `mind`), on
* purpose: the mind domain already serves decrypted free text at this level
* (the mood entry's encrypted note on `GET /api/mood-entries/[id]`, the Coach
* reminder notes), and the list read already publishes `item9Flagged` — the
* fact that the self-harm item was answered non-zero. A delegate the owner
* trusted with a mind-scope read grant therefore learns nothing here that is
* categorically beyond what the domain already shows; splitting the per-item
* values onto a stricter gate than the mood diary's prose would be an
* inconsistency, not a protection. The module gate resolves against the
* RECORD, so the surface stays unreachable wherever the owner keeps the
* module off.
*
* DECRYPT FAILURES degrade honestly (the records-DTO fail-soft precedent):
* `items: null` + `itemsUnavailable: true`, with the denormalised score
* fields still answering — a key gap on one row never 500s the read. A
* plaintext whose item count no longer matches the instrument degrades the
* same way, because pairing answers to the wrong official item texts would be
* worse than showing none.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate, getEvent } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import {
INSTRUMENTS,
type InstrumentDefinition,
type InstrumentId,
} from "@/lib/mental-health/instruments";
type RouteParams = { params: Promise<{ id: string }> };
interface DecodedResponses {
/** Per-item answers in presentation order, or null when unreadable. */
items: number[] | null;
/** The unscored PHQ-9 functional follow-up, when it was answered. */
functionalDifficulty: number | null;
itemsUnavailable: boolean;
}
const UNAVAILABLE: DecodedResponses = {
items: null,
functionalDifficulty: null,
itemsUnavailable: true,
};
/**
* Decrypt + decode the stored `{ items, functionalDifficulty?, schema: 1 }`
* blob, fail-soft. Every answer is re-checked against the instrument's own
* ceiling before it is served: the write path validated exactly these bounds,
* so anything outside them is corruption, not data.
*/
function decodeResponses(
buf: Uint8Array,
def: InstrumentDefinition,
): DecodedResponses {
let parsed: unknown;
try {
parsed = JSON.parse(decryptFromBytes(buf));
} catch (err) {
// Undecryptable payload (key gap / corruption): fail soft but log it
// (F-CRYPTO-2) so a systemic key gap surfaces instead of reading blank.
getEvent()?.addWarning(
`assessment responses decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return UNAVAILABLE;
}
if (typeof parsed !== "object" || parsed === null) return UNAVAILABLE;
const { items, functionalDifficulty } = parsed as {
items?: unknown;
functionalDifficulty?: unknown;
};
const valid =
Array.isArray(items) &&
items.length === def.itemCount &&
items.every(
(v) => Number.isInteger(v) && (v as number) >= 0 && v <= def.itemMax,
);
if (!valid) {
getEvent()?.addWarning(
"assessment responses shape drifted from the instrument definition",
);
return UNAVAILABLE;
}
return {
items: items as number[],
functionalDifficulty:
Number.isInteger(functionalDifficulty) &&
(functionalDifficulty as number) >= 0 &&
(functionalDifficulty as number) <= 3
? (functionalDifficulty as number)
: null,
itemsUnavailable: false,
};
}
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read", "mind");
// Opt-in module (default OFF) — the same record-resolved gate as the list.
const gate = await requireModuleEnabled(user.id, "mentalHealth");
if (!gate.enabled) return gate.response;
const { id } = await params;
// Fetch-then-guard against the resolved user (the by-id sibling pattern);
// a soft-deleted row 404s like it does on the list.
const row = await prisma.mentalHealthAssessment.findFirst({
where: { id, deletedAt: null },
select: {
id: true,
userId: true,
instrument: true,
locale: true,
version: true,
totalScore: true,
severityBand: true,
item9Flagged: true,
crisisShownAt: true,
takenAt: true,
createdAt: true,
responsesEncrypted: true,
},
});
if (!row || row.userId !== user.id) {
return apiError("Assessment not found", 404);
}
const def = INSTRUMENTS[row.instrument as InstrumentId];
const decoded = decodeResponses(row.responsesEncrypted, def);
annotate({
action: { name: "mental-health.detail" },
// The instrument + degrade flag only — item answers are NEVER logged.
meta: {
instrument: row.instrument,
items_unavailable: decoded.itemsUnavailable,
},
});
return apiSuccess({
assessment: {
id: row.id,
instrument: row.instrument,
locale: row.locale,
version: row.version,
totalScore: row.totalScore,
severityBand: row.severityBand,
item9Flagged: row.item9Flagged,
crisisShownAt: row.crisisShownAt?.toISOString() ?? null,
takenAt: row.takenAt.toISOString(),
createdAt: row.createdAt.toISOString(),
items: decoded.items,
functionalDifficulty: decoded.functionalDifficulty,
itemsUnavailable: decoded.itemsUnavailable,
},
});
},
);
@@ -13,7 +13,8 @@
* interpretation, no AI ingestion of item content, no third-party alerting.
*
* GET lists the caller's assessments (totals + bands + flags only — item-level
* answers are excluded from this surface by construction).
* answers are excluded from this surface by construction; the by-id sibling
* route is the one read that decrypts them back).
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";