mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Let the AI-consent card grant again after a withdrawal
POST /api/consent/ai/web now accepts an optional { intent } body. Without
one (the AI-settings mount heal) a standing revocation still blocks the
mint; intent "affirmative" is the user's own consent act and may
supersede an earlier revocation, exactly as a first grant would. The
consent card offers the explicit grant control whenever consent does not
stand, and the audit row records which intent minted the receipt.
The document auto-read opt-in already declared "affirmative" on enable;
the settings-mount heal posts no body. Both stay as they are.
This commit is contained in:
1 parent
fd104dd0ac
commit
e83b58c9da
13 files changed
+268
-41
No files matched your search
@@ -22,7 +22,7 @@ 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 + serialisation.
|
||||
// route test stubs it to focus on auth + rate-limit + intent + serialisation.
|
||||
vi.mock("@/lib/consent/web-grant", () => ({
|
||||
ensureWebAiConsentReceipt: vi.fn(),
|
||||
}));
|
||||
@@ -45,6 +45,22 @@ const SESSION_OK = {
|
||||
|
||||
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
|
||||
|
||||
/** The mount heal posts no body and 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 {
|
||||
return new Request("http://localhost/api/consent/ai/web", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
@@ -54,7 +70,7 @@ beforeEach(() => {
|
||||
describe("POST /api/consent/ai/web", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await POST();
|
||||
const res = await POST(mkHeal());
|
||||
expect(res.status).toBe(401);
|
||||
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -66,9 +82,9 @@ describe("POST /api/consent/ai/web", () => {
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST();
|
||||
const res = await POST(mkHeal());
|
||||
expect(res.status).toBe(429);
|
||||
// The throttle fires before the heal mint runs.
|
||||
// The throttle fires before the mint runs.
|
||||
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -79,7 +95,7 @@ describe("POST /api/consent/ai/web", () => {
|
||||
receipt: { id: "rcpt-web-1" } as never,
|
||||
});
|
||||
|
||||
const res = await POST();
|
||||
const res = await POST(mkHeal());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { minted: boolean; kind: string };
|
||||
@@ -95,11 +111,57 @@ describe("POST /api/consent/ai/web", () => {
|
||||
reason: "already_active",
|
||||
});
|
||||
|
||||
const res = await POST();
|
||||
const res = await POST(mkHeal());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { minted: boolean } };
|
||||
expect(body.data.minted).toBe(false);
|
||||
// No audit row on the no-op path.
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes a bodyless call to the mint as a heal", async () => {
|
||||
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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await POST(mkIntent({ intent: "resurrect" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,41 @@
|
||||
/**
|
||||
* v1.16.13 — web AI-consent grant (heal endpoint).
|
||||
* v1.16.13 — web AI-consent grant.
|
||||
*
|
||||
* POST /api/consent/ai/web
|
||||
* Mints an `ai_full` consent receipt for the calling web user if none
|
||||
* is active, mirroring the iOS master-grant. Idempotent — a user with
|
||||
* an active `ai_full` receipt is a no-op. The web AI-settings surface
|
||||
* calls this on mount (the web equivalent of the iOS shell-mount heal)
|
||||
* so existing web accounts gain a receipt without a re-consent step and
|
||||
* stop hitting the server-managed consent gate's no-key fallback.
|
||||
* 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:
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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`.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
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";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// The web AI-settings surface calls this on every mount; the bucket is
|
||||
@@ -37,14 +52,39 @@ export const POST = apiHandler(async () => {
|
||||
});
|
||||
}
|
||||
|
||||
const result = await ensureWebAiConsentReceipt(user.id, "heal");
|
||||
// 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";
|
||||
const contentType = req.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = webConsentGrantBody.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({ action: { name: "consent.ai.web-grant.invalid_shape" } });
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
intent = parsed.data.intent;
|
||||
}
|
||||
|
||||
const result = await ensureWebAiConsentReceipt(user.id, intent);
|
||||
|
||||
if (result.minted) {
|
||||
// Audit trail parity with the iOS-driven POST /api/consent/ai grant so
|
||||
// the legal team can reconstruct web-originated consent. Fire-and-forget.
|
||||
// the legal team can reconstruct web-originated consent; `intent`
|
||||
// separates a migration heal from the user's own re-grant click.
|
||||
// Fire-and-forget.
|
||||
auditLog("consent.ai.grant", {
|
||||
userId: user.id,
|
||||
details: { kind: "ai_full", source: "web", receiptId: result.receipt.id },
|
||||
details: {
|
||||
kind: "ai_full",
|
||||
source: "web",
|
||||
intent,
|
||||
receiptId: result.receipt.id,
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -52,6 +92,7 @@ export const POST = apiHandler(async () => {
|
||||
action: { name: "consent.ai.web-grant" },
|
||||
meta: {
|
||||
minted: result.minted,
|
||||
intent,
|
||||
...(result.minted ? { receiptId: result.receipt.id } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user