mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
fix(consent): mint AI consent receipts only on an explicit agreement
The AI settings page posted to /api/consent/ai/web on every mount and the server minted an ai_full receipt for it, so an account that had agreed to nothing sent its health data to the operator's provider from background jobs. Native builds synced a receipt the same way on every launch. - Both grant routes now require `confirm: true`; anything else writes nothing and answers 400 consent.ai.confirmRequired. - Remove the mount-time heal. A consent card names the operator's service and the data that would go there, with one Agree button, in Settings > AI and on the Insights overview while AI is closed by the missing consent alone. - Publish the standing consent as `ai.consent` on /api/auth/me (status, since, recipients); the settings card renders active-since and the withdraw control from it. - Existing receipts stay valid. - Guard that no client grants outside a click handler, route tests for the confirmation, and an integration test that a fresh operator-key account gets no background AI text until it agrees.
This commit is contained in:
1 parent
bc5ea5a67b
commit
e2b7e7350e
45 files changed
+1707
-678
No files matched your search
@@ -188,6 +188,7 @@ describe("POST /api/consent/ai", () => {
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt: signedAt.toISOString(),
|
||||
confirm: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
@@ -224,6 +225,31 @@ describe("POST /api/consent/ai", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// v1.43.2 — native builds before it posted this body once per launch to
|
||||
// sync a receipt the account did not hold, which re-granted a consent the
|
||||
// person had withdrawn elsewhere. Only an explicit confirmation mints.
|
||||
it.each([
|
||||
["without confirm (a launch-time sync)", {}],
|
||||
["with confirm: false", { confirm: false }],
|
||||
])("refuses a well-formed grant %s and writes nothing", async (_l, extra) => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await POST(
|
||||
req({
|
||||
kind: "ai_full",
|
||||
artefact: '{"source":"ios.AIConsentSheet","scope":"ai_full"}',
|
||||
signedAt: "2026-05-18T10:00:00.000Z",
|
||||
...extra,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("consent.ai.confirmRequired");
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
expect(prisma.consentReceipt.updateMany).not.toHaveBeenCalled();
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("v1.4.43 W6 — multi-issue 400 envelope (consent uses 400)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
@@ -456,6 +456,7 @@ describe("append-only invariant", () => {
|
||||
kind: "ai_full",
|
||||
artefact: "PDF-v1",
|
||||
signedAt: signedAt1.toISOString(),
|
||||
confirm: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -510,6 +511,7 @@ describe("append-only invariant", () => {
|
||||
kind: "ai_full",
|
||||
artefact: "PDF-v2",
|
||||
signedAt: signedAt2.toISOString(),
|
||||
confirm: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
* v1.4.40 SB-10 — AI consent receipts.
|
||||
*
|
||||
* POST /api/consent/ai
|
||||
* Body: { kind, artefact, signedAt }
|
||||
* Body: { kind, artefact, signedAt, confirm: true }
|
||||
* Persists a fresh consent receipt. Returns the inserted row.
|
||||
*
|
||||
* v1.43.2 — `confirm: true` is required. A receipt records a person's
|
||||
* own consent act, and native builds before it also posted here once per
|
||||
* launch to sync a receipt the account no longer held (re-granting, for
|
||||
* example, a consent the person had withdrawn on the web). Without the
|
||||
* flag nothing is written and the route answers 400
|
||||
* `consent.ai.confirmRequired`; the client reads the standing state from
|
||||
* `ai.consent` on `GET /api/auth/me` instead.
|
||||
*
|
||||
* Reader + revoke handlers live in `./latest/route.ts`.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -19,7 +27,10 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { consentPostBody } from "@/lib/validations/consent";
|
||||
import {
|
||||
CONSENT_CONFIRM_REQUIRED_ERROR_CODE,
|
||||
consentPostBody,
|
||||
} from "@/lib/validations/consent";
|
||||
import { createReceipt } from "@/lib/consent/receipts";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
@@ -50,7 +61,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 400);
|
||||
}
|
||||
|
||||
const { kind, artefact, signedAt } = parsed.data;
|
||||
const { kind, artefact, signedAt, confirm } = parsed.data;
|
||||
if (confirm !== true) {
|
||||
// A launch-time sync from an older app build lands here. It is not the
|
||||
// person agreeing to anything, so it writes nothing.
|
||||
annotate({
|
||||
action: { name: "consent.ai.grant.unconfirmed" },
|
||||
meta: { kind },
|
||||
});
|
||||
return apiError(
|
||||
"Consent is recorded only from an explicit confirmation",
|
||||
400,
|
||||
{ errorCode: CONSENT_CONFIRM_REQUIRED_ERROR_CODE },
|
||||
);
|
||||
}
|
||||
const receipt = await createReceipt(user.id, kind, artefact, signedAt);
|
||||
|
||||
// Audit trail — separate from the row itself so the legal team can
|
||||
|
||||
@@ -22,9 +22,10 @@ vi.mock("next/headers", () => ({
|
||||
}));
|
||||
|
||||
// The mint logic + its TOCTOU close are covered by web-grant.test.ts; the
|
||||
// route test stubs it to focus on auth + rate-limit + intent + serialisation.
|
||||
// route test stubs it to focus on auth + rate-limit + the explicit
|
||||
// confirmation + serialisation.
|
||||
vi.mock("@/lib/consent/web-grant", () => ({
|
||||
ensureWebAiConsentReceipt: vi.fn(),
|
||||
grantWebAiConsent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
@@ -35,7 +36,7 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
import { POST } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
|
||||
import { grantWebAiConsent } from "@/lib/consent/web-grant";
|
||||
import { checkConsentRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -50,15 +51,15 @@ const RL_OK = {
|
||||
resetAt: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
/** The mount heal posts no body and no content-type. */
|
||||
/** What the pre-v1.43.2 mount heal sent: no body, no content-type. */
|
||||
function mkHeal(): Request {
|
||||
return new Request("http://localhost/api/consent/ai/web", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** The explicit grant control posts a JSON intent. */
|
||||
function mkIntent(body: unknown): Request {
|
||||
/** A JSON body, as the agree control (or anything else) sends one. */
|
||||
function mkJson(body: unknown): Request {
|
||||
return new Request("http://localhost/api/consent/ai/web", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
@@ -77,7 +78,7 @@ describe("POST /api/consent/ai/web", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await POST(mkHeal());
|
||||
expect(res.status).toBe(401);
|
||||
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
|
||||
expect(grantWebAiConsent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user consent bucket is exhausted", async () => {
|
||||
@@ -91,33 +92,41 @@ describe("POST /api/consent/ai/web", () => {
|
||||
const res = await POST(mkHeal());
|
||||
expect(res.status).toBe(429);
|
||||
// The throttle fires before the mint runs.
|
||||
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
|
||||
expect(grantWebAiConsent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mints a receipt and reports minted:true", async () => {
|
||||
it("mints a receipt on the explicit confirmation and reports minted:true", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({
|
||||
vi.mocked(grantWebAiConsent).mockResolvedValue({
|
||||
minted: true,
|
||||
receipt: { id: "rcpt-web-1" } as never,
|
||||
});
|
||||
|
||||
const res = await POST(mkHeal());
|
||||
const res = await POST(mkJson({ confirm: true }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { minted: boolean; kind: string };
|
||||
};
|
||||
expect(body.data.minted).toBe(true);
|
||||
expect(body.data.kind).toBe("ai_full");
|
||||
expect(grantWebAiConsent).toHaveBeenCalledWith("user-1");
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"consent.ai.grant",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({ source: "web", confirmed: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op when an active receipt already exists (minted:false)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({
|
||||
vi.mocked(grantWebAiConsent).mockResolvedValue({
|
||||
minted: false,
|
||||
reason: "already_active",
|
||||
});
|
||||
|
||||
const res = await POST(mkHeal());
|
||||
const res = await POST(mkJson({ confirm: true }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { minted: boolean } };
|
||||
expect(body.data.minted).toBe(false);
|
||||
@@ -125,49 +134,34 @@ describe("POST /api/consent/ai/web", () => {
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes a bodyless call to the mint as a heal", async () => {
|
||||
// Every shape that is not the person's own confirmation: the bodyless
|
||||
// mount heal the settings page sent until v1.43.2, an empty object, the
|
||||
// retired `{ intent }` bodies, and an explicit `false`.
|
||||
it.each([
|
||||
["no body (the old mount heal)", () => mkHeal()],
|
||||
["an empty object", () => mkJson({})],
|
||||
['the retired intent "heal"', () => mkJson({ intent: "heal" })],
|
||||
[
|
||||
'the retired intent "affirmative"',
|
||||
() => mkJson({ intent: "affirmative" }),
|
||||
],
|
||||
["confirm: false", () => mkJson({ confirm: false })],
|
||||
])("refuses %s with 400 and never reaches the mint", async (_label, mk) => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({
|
||||
minted: false,
|
||||
reason: "previously_revoked",
|
||||
});
|
||||
|
||||
const res = await POST(mkHeal());
|
||||
expect(res.status).toBe(200);
|
||||
expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith("user-1", "heal");
|
||||
const res = await POST(mk());
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("consent.ai.confirmRequired");
|
||||
expect(grantWebAiConsent).not.toHaveBeenCalled();
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes an explicit affirmative body to the mint as the consent act", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({
|
||||
minted: true,
|
||||
receipt: { id: "rcpt-web-2" } as never,
|
||||
});
|
||||
|
||||
const res = await POST(mkIntent({ intent: "affirmative" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"affirmative",
|
||||
);
|
||||
// The audit row records that this was the user's own grant, not a heal.
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"consent.ai.grant",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
source: "web",
|
||||
intent: "affirmative",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unknown intent with 422 and never reaches the mint", async () => {
|
||||
it("rejects a malformed confirmation with 422 and never reaches the mint", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await POST(mkIntent({ intent: "resurrect" }));
|
||||
const res = await POST(mkJson({ confirm: "yes" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
|
||||
expect(grantWebAiConsent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,20 @@
|
||||
/**
|
||||
* v1.16.13 — web AI-consent grant.
|
||||
* Web AI-consent grant.
|
||||
*
|
||||
* POST /api/consent/ai/web
|
||||
* Mints an `ai_full` consent receipt for the calling web user.
|
||||
* Idempotent — a user with an active `ai_full` receipt is a no-op.
|
||||
* The body decides what a missing receipt means:
|
||||
* POST /api/consent/ai/web body `{ "confirm": true }`
|
||||
* Records the person's explicit `ai_full` consent: they pressed "Agree"
|
||||
* on a card that named the provider and the data. Idempotent: an active
|
||||
* `ai_full` receipt makes it a no-op. As the person's own consent act it
|
||||
* may follow an earlier withdrawal.
|
||||
*
|
||||
* no body (heal) The AI-settings surface calls this on mount
|
||||
* (the web equivalent of the iOS shell-mount
|
||||
* heal) so an account that predates the consent
|
||||
* gate gains a receipt without a re-consent
|
||||
* step. A past revocation stands; the heal
|
||||
* never resurrects a withdrawn consent.
|
||||
* `{ intent: "affirmative" }` The explicit grant control on the same
|
||||
* surface. This IS the user's consent act, so
|
||||
* it may supersede an earlier revocation
|
||||
* exactly as the first grant would.
|
||||
* Without `confirm: true` (no body, `{}`, the pre-v1.43.2 mount heal or
|
||||
* its `{ intent }` body) it mints nothing and answers 400
|
||||
* `consent.ai.confirmRequired`. The settings page used to call this on
|
||||
* every mount and mint a receipt nobody had agreed to; a request that is
|
||||
* not the consent act must never create one.
|
||||
*
|
||||
* Revocation stays on `DELETE /api/consent/ai/latest`; this route only
|
||||
* grants. The full CRUD (explicit grant with a signed artefact, reader,
|
||||
* revoke) lives in `../route.ts` + `../latest/route.ts`.
|
||||
* grants.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
@@ -31,16 +26,16 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
|
||||
import type { WebConsentGrantIntent } from "@/lib/consent/web-grant";
|
||||
import { webConsentGrantBody } from "@/lib/validations/consent";
|
||||
import { grantWebAiConsent } from "@/lib/consent/web-grant";
|
||||
import {
|
||||
CONSENT_CONFIRM_REQUIRED_ERROR_CODE,
|
||||
webConsentGrantBody,
|
||||
} from "@/lib/validations/consent";
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// The web AI-settings surface calls this on every mount; the bucket is
|
||||
// generous enough that the heal path is never throttled in normal use,
|
||||
// tight enough to cap a scripted loop.
|
||||
// Caps a scripted loop; a person tapping Agree never comes near it.
|
||||
const rl = await checkConsentRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
@@ -52,10 +47,9 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
});
|
||||
}
|
||||
|
||||
// The mount heal posts no body; only the explicit grant control sends one.
|
||||
// Absence of a JSON body therefore reads as a heal, and a present body is
|
||||
// Zod-validated so a malformed intent is a 422, never a silent heal.
|
||||
let intent: WebConsentGrantIntent = "heal";
|
||||
// Only the explicit grant sends a JSON body. Anything else (no body, an
|
||||
// empty object, the retired `{ intent }` shape) is not the consent act.
|
||||
let confirmed = false;
|
||||
const contentType = req.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
@@ -67,22 +61,28 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
annotate({ action: { name: "consent.ai.web-grant.invalid_shape" } });
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
intent = parsed.data.intent;
|
||||
confirmed = parsed.data.confirm === true;
|
||||
}
|
||||
if (!confirmed) {
|
||||
annotate({ action: { name: "consent.ai.web-grant.unconfirmed" } });
|
||||
return apiError(
|
||||
"Consent is recorded only from an explicit confirmation",
|
||||
400,
|
||||
{ errorCode: CONSENT_CONFIRM_REQUIRED_ERROR_CODE },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await ensureWebAiConsentReceipt(user.id, intent);
|
||||
const result = await grantWebAiConsent(user.id);
|
||||
|
||||
if (result.minted) {
|
||||
// Audit trail parity with the iOS-driven POST /api/consent/ai grant so
|
||||
// the legal team can reconstruct web-originated consent; `intent`
|
||||
// separates a migration heal from the user's own re-grant click.
|
||||
// Fire-and-forget.
|
||||
// Audit trail parity with the native POST /api/consent/ai grant so the
|
||||
// web-originated consent can be reconstructed. Fire-and-forget.
|
||||
auditLog("consent.ai.grant", {
|
||||
userId: user.id,
|
||||
details: {
|
||||
kind: "ai_full",
|
||||
source: "web",
|
||||
intent,
|
||||
confirmed: true,
|
||||
receiptId: result.receipt.id,
|
||||
},
|
||||
}).catch(() => {});
|
||||
@@ -92,7 +92,6 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
action: { name: "consent.ai.web-grant" },
|
||||
meta: {
|
||||
minted: result.minted,
|
||||
intent,
|
||||
...(result.minted ? { receiptId: result.receipt.id } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user