mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(consent): close the receipt mint race, rate-limit, and share the back link
Mint the web AI-consent receipt inside a serializable transaction with an in-transaction recheck, backed by a partial unique index over one active receipt per user and kind, so two concurrent mounts can no longer leave two active grants; a duplicate maps to a no-op mint. Supersede an active row against the server clock rather than the client-supplied timestamp so a backdated grant cannot invert the audit chain, and revoke through a single atomic update. Rate-limit the consent endpoints per user. Extract the hand-rolled sub-page back link into one component and adopt it across the four pages that duplicated it.
This commit is contained in:
commit
555240cd28
19 files changed
+780
-176
No files matched your search
@@ -0,0 +1,36 @@
|
||||
-- v1.16.16 — at most one active consent receipt per (user, kind).
|
||||
--
|
||||
-- The web-grant heal mint reads `latestActiveReceipt` then `createReceipt`
|
||||
-- non-atomically. Two concurrent web mounts could each see "no active
|
||||
-- receipt" and both insert an active `ai_full` row, leaving duplicate
|
||||
-- active grants the revoke path then has to chase one at a time.
|
||||
--
|
||||
-- The application wraps the mint in a transaction with a re-check, but the
|
||||
-- structural guarantee lives here: a partial unique index over
|
||||
-- (user_id, kind) restricted to non-revoked rows. Revoked rows carry a
|
||||
-- non-NULL `revoked_at` and fall outside the predicate, so the append-only
|
||||
-- grant/revoke history is unaffected — only two *active* rows for the same
|
||||
-- (user, kind) collide.
|
||||
|
||||
-- Pre-flight dedup. The bug this migration closes already produces duplicate
|
||||
-- active rows on affected tenants; `CREATE UNIQUE INDEX` scans existing rows
|
||||
-- and would abort (23505) on any such pair, failing the whole deploy on
|
||||
-- exactly the tenants the fix targets. Revoke all-but-the-newest active row
|
||||
-- per (user_id, kind) first so the index can build. The newest active row
|
||||
-- (latest `created_at`) is the one `latestActiveReceipt` already returns, so
|
||||
-- the surviving active grant matches current read behaviour; the superseded
|
||||
-- duplicates get a `revoked_at` marker and stay in the audit history.
|
||||
UPDATE "consent_receipts" c
|
||||
SET "revoked_at" = now()
|
||||
WHERE "revoked_at" IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "consent_receipts" n
|
||||
WHERE n."user_id" = c."user_id"
|
||||
AND n."kind" = c."kind"
|
||||
AND n."revoked_at" IS NULL
|
||||
AND n."created_at" > c."created_at"
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "consent_receipts_user_id_kind_active_key"
|
||||
ON "consent_receipts" ("user_id", "kind")
|
||||
WHERE "revoked_at" IS NULL;
|
||||
@@ -3363,6 +3363,14 @@ model ConsentReceipt {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt(sort: Desc)])
|
||||
// `consent_receipts_user_id_kind_active_key` is a partial UNIQUE index
|
||||
// over (user_id, kind) `WHERE revoked_at IS NULL` — see migration 0159.
|
||||
// It enforces at most one *active* receipt per (user, kind) so two
|
||||
// concurrent web-grant heals can't both mint an active row. Prisma can't
|
||||
// express a partial-predicate unique in the schema language, so it's
|
||||
// introduced via raw SQL and intentionally omitted here. The append-only
|
||||
// history (revoked rows) stays unconstrained because revoked rows carry a
|
||||
// non-NULL `revoked_at` and fall outside the predicate.
|
||||
@@map("consent_receipts")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
consentReceipt: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
// v1.16.16 — `createReceipt` runs its supersede + insert inside a
|
||||
// transaction; run the callback against the same mock proxy.
|
||||
type TxFn = (tx: unknown) => unknown;
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const consentReceipt = {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
consentReceipt,
|
||||
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// v1.16.16 — rate limit is exercised by its own suite; here we default to
|
||||
// "allowed" and flip a single test to assert the 429 wiring.
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkConsentRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
@@ -38,6 +54,13 @@ import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
|
||||
|
||||
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
|
||||
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
|
||||
};
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -59,6 +82,15 @@ beforeEach(() => {
|
||||
// Re-arm it here so the fire-and-forget `.catch()` in the route
|
||||
// doesn't crash on an undefined return.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
// Re-arm the rate-limit + transaction pass-throughs cleared by
|
||||
// `resetAllMocks`.
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK);
|
||||
$transaction.mockImplementation((fn: TxFn) =>
|
||||
fn({ consentReceipt: prisma.consentReceipt }),
|
||||
);
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/consent/ai", () => {
|
||||
@@ -74,6 +106,25 @@ describe("POST /api/consent/ai", () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user consent bucket is exhausted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST(
|
||||
req({
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt: "2026-05-18T10:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
// The throttle fires before any write touches the receipts table.
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid kind", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await POST(
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
consentReceipt: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
// v1.16.16 — `createReceipt` + `revokeLatest` run inside a transaction; the
|
||||
// mock runs the callback against the same proxy.
|
||||
type TxFn = (tx: unknown) => unknown;
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const consentReceipt = {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
consentReceipt,
|
||||
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkConsentRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
@@ -38,6 +52,13 @@ import { POST as POST_GRANT } from "../../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
|
||||
|
||||
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
|
||||
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
|
||||
};
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -50,6 +71,11 @@ beforeEach(() => {
|
||||
// on `auditLog`. Re-arm so the route's fire-and-forget `.catch()`
|
||||
// chain doesn't NPE on an undefined return.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
// Re-arm rate-limit + transaction pass-throughs cleared by reset.
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK);
|
||||
$transaction.mockImplementation((fn: TxFn) =>
|
||||
fn({ consentReceipt: prisma.consentReceipt }),
|
||||
);
|
||||
});
|
||||
|
||||
describe("GET /api/consent/ai/latest", () => {
|
||||
@@ -69,6 +95,20 @@ describe("GET /api/consent/ai/latest", () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user consent bucket is exhausted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full"),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null receipt when nothing has been granted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
|
||||
@@ -153,21 +193,33 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user consent bucket is exhausted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.consentReceipt.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks the latest receipt as revoked and returns it", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const signedAt = new Date("2026-05-18T10:00:00.000Z");
|
||||
const createdAt = new Date("2026-05-18T10:00:01.000Z");
|
||||
const revokedAt = new Date("2026-05-18T11:00:00.000Z");
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt: null,
|
||||
createdAt,
|
||||
// v1.16.16 — `revokeLatest` atomically flips `revoked_at` via
|
||||
// `updateMany` (count) then re-reads the revoked row for the audit id.
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.update).mockResolvedValue({
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
@@ -188,15 +240,17 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
};
|
||||
expect(body.data.receipt?.id).toBe("rcpt-1");
|
||||
expect(body.data.receipt?.revokedAt).toBe(revokedAt.toISOString());
|
||||
expect(prisma.consentReceipt.update).toHaveBeenCalledWith({
|
||||
where: { id: "rcpt-1" },
|
||||
expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
|
||||
data: { revokedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it("is idempotent — returns null receipt without writing when nothing to revoke", async () => {
|
||||
it("is idempotent — returns null receipt without re-reading when nothing to revoke", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full", {
|
||||
@@ -208,7 +262,7 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
data: { kind: string; receipt: unknown | null };
|
||||
};
|
||||
expect(body.data.receipt).toBeNull();
|
||||
expect(prisma.consentReceipt.update).not.toHaveBeenCalled();
|
||||
expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("master toggle revokes the latest active row per kind", async () => {
|
||||
@@ -217,8 +271,14 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
const createdAt = new Date("2026-05-18T10:00:01.000Z");
|
||||
const revokedAt = new Date("2026-05-18T11:00:00.000Z");
|
||||
|
||||
// First call (ai_full) finds a row, second (ai_insights_only)
|
||||
// finds none, third (ai_coach) finds a row.
|
||||
// v1.16.16 — `revokeLatest` runs per kind in `consentKindEnum.options`
|
||||
// order: ai_full → ai_insights_only → ai_coach. The `updateMany` count
|
||||
// gates the follow-up re-read. ai_full + ai_coach revoke a row;
|
||||
// ai_insights_only has none (count 0, no re-read).
|
||||
vi.mocked(prisma.consentReceipt.updateMany)
|
||||
.mockResolvedValueOnce({ count: 1 } as never) // ai_full
|
||||
.mockResolvedValueOnce({ count: 0 } as never) // ai_insights_only
|
||||
.mockResolvedValueOnce({ count: 1 } as never); // ai_coach
|
||||
vi.mocked(prisma.consentReceipt.findFirst)
|
||||
.mockResolvedValueOnce({
|
||||
id: "rcpt-full",
|
||||
@@ -226,33 +286,19 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt: null,
|
||||
revokedAt,
|
||||
createdAt,
|
||||
} as never)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({
|
||||
id: "rcpt-coach",
|
||||
userId: "user-1",
|
||||
kind: "ai_coach",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt: null,
|
||||
revokedAt,
|
||||
createdAt,
|
||||
} as never);
|
||||
|
||||
vi.mocked(prisma.consentReceipt.update).mockImplementation(
|
||||
((args: { where: { id: string }; data: { revokedAt: Date } }) =>
|
||||
Promise.resolve({
|
||||
id: args.where.id,
|
||||
userId: "user-1",
|
||||
kind: args.where.id === "rcpt-full" ? "ai_full" : "ai_coach",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt,
|
||||
createdAt,
|
||||
})) as never,
|
||||
);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest", {
|
||||
method: "DELETE",
|
||||
@@ -287,7 +333,11 @@ describe("append-only invariant", () => {
|
||||
const signedAt2 = new Date("2026-05-18T12:00:00.000Z");
|
||||
const createdAt2 = new Date("2026-05-18T12:00:01.000Z");
|
||||
|
||||
// Step 1: initial grant.
|
||||
// Step 1: initial grant. `createReceipt` supersedes any active row
|
||||
// (none yet → count 0) then inserts.
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.create).mockResolvedValueOnce({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
@@ -311,17 +361,13 @@ describe("append-only invariant", () => {
|
||||
);
|
||||
expect(grant1.status).toBe(200);
|
||||
|
||||
// Step 2: revoke. The DELETE handler reads + updates.
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValueOnce({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
artefact: "PDF-v1",
|
||||
signedAt: signedAt1,
|
||||
revokedAt: null,
|
||||
createdAt: createdAt1,
|
||||
// Step 2: revoke. `revokeLatest` atomically flips `revoked_at` via
|
||||
// `updateMany` (count 1) then re-reads the revoked row. The row is
|
||||
// NEVER deleted — only marked revoked, so the audit chain survives.
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValueOnce({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.update).mockResolvedValueOnce({
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValueOnce({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
@@ -337,12 +383,11 @@ describe("append-only invariant", () => {
|
||||
}),
|
||||
);
|
||||
expect(revokeRes.status).toBe(200);
|
||||
expect(prisma.consentReceipt.update).toHaveBeenCalledTimes(1);
|
||||
// Critical invariant — `delete` was NEVER called on the receipt.
|
||||
// Only an update setting `revokedAt`. The old row stays in place.
|
||||
const updateCall = vi.mocked(prisma.consentReceipt.update).mock.calls[0][0];
|
||||
expect(updateCall).toMatchObject({
|
||||
where: { id: "rcpt-1" },
|
||||
// Critical invariant — `delete` was NEVER called on the receipt. The
|
||||
// revoke is an in-place `updateMany` setting `revokedAt`.
|
||||
expect(prisma.consentReceipt.delete).toBeUndefined();
|
||||
expect(prisma.consentReceipt.updateMany).toHaveBeenLastCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
|
||||
data: { revokedAt: expect.any(Date) },
|
||||
});
|
||||
|
||||
|
||||
@@ -17,11 +17,13 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
consentKindEnum,
|
||||
consentLatestQuery,
|
||||
@@ -37,6 +39,17 @@ import { serialiseReceipt } from "../route";
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkConsentRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "consent.ai.rate-limited" },
|
||||
meta: { userId: user.id, resetAt: rl.resetAt },
|
||||
});
|
||||
return apiError("Too many consent requests, please wait a moment", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = consentLatestQuery.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
@@ -86,6 +99,17 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkConsentRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "consent.ai.rate-limited" },
|
||||
meta: { userId: user.id, resetAt: rl.resetAt },
|
||||
});
|
||||
return apiError("Too many consent requests, please wait a moment", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = consentLatestQuery.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
|
||||
@@ -9,15 +9,35 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } 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 { consentPostBody } from "@/lib/validations/consent";
|
||||
import { createReceipt } from "@/lib/consent/receipts";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Per-user ceiling in front of the receipts write. 20/min is well outside
|
||||
// any legitimate grant cadence; it caps a scripted loop from filling the
|
||||
// audit table.
|
||||
const rl = await checkConsentRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "consent.ai.rate-limited" },
|
||||
meta: { userId: user.id, resetAt: rl.resetAt },
|
||||
});
|
||||
return apiError("Too many consent requests, please wait a moment", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error } = await safeJson(request, {
|
||||
maxBytes: 128 * 1024,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
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: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// 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.
|
||||
vi.mock("@/lib/consent/web-grant", () => ({
|
||||
ensureWebAiConsentReceipt: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkConsentRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
|
||||
import { checkConsentRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
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 },
|
||||
};
|
||||
|
||||
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK);
|
||||
});
|
||||
|
||||
describe("POST /api/consent/ai/web", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await POST();
|
||||
expect(res.status).toBe(401);
|
||||
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user consent bucket is exhausted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST();
|
||||
expect(res.status).toBe(429);
|
||||
// The throttle fires before the heal mint runs.
|
||||
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mints a receipt and reports minted:true", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({
|
||||
minted: true,
|
||||
receipt: { id: "rcpt-web-1" } as never,
|
||||
});
|
||||
|
||||
const res = await POST();
|
||||
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");
|
||||
});
|
||||
|
||||
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({ minted: false });
|
||||
|
||||
const res = await POST();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -14,14 +14,29 @@
|
||||
* revoke) lives in `../route.ts` + `../latest/route.ts`.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiError, apiSuccess } 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";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
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.
|
||||
const rl = await checkConsentRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "consent.ai.rate-limited" },
|
||||
meta: { userId: user.id, resetAt: rl.resetAt },
|
||||
});
|
||||
return apiError("Too many consent requests, please wait a moment", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const result = await ensureWebAiConsentReceipt(user.id);
|
||||
|
||||
if (result.minted) {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import {
|
||||
CompositeScoreAnatomy,
|
||||
@@ -88,18 +86,11 @@ export default function CompositeScorePage() {
|
||||
description={t(header.description)}
|
||||
explainerMetric={EXPLAINER_METRIC[metric]}
|
||||
backLink={
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="composite-score-back"
|
||||
className="-ml-2 w-fit"
|
||||
>
|
||||
<Link href="/insights">
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{t("insights.subPage.scoresBack")}
|
||||
</Link>
|
||||
</Button>
|
||||
<BackLink
|
||||
href="/insights"
|
||||
label={t("insights.subPage.scoresBack")}
|
||||
dataSlot="composite-score-back"
|
||||
/>
|
||||
}
|
||||
coachLaunch
|
||||
>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
import { notFound, useSearchParams } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
@@ -12,7 +10,7 @@ import {
|
||||
} from "@/lib/insights/values-back-link";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { MeasurementList } from "@/components/measurements/measurement-list";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
|
||||
@@ -63,22 +61,17 @@ export default function InsightsMetricValuesPage({
|
||||
title={t("insights.subPage.valuesPageTitle", { metric: metricLabel })}
|
||||
description={t("insights.subPage.valuesPageDescription")}
|
||||
backLink={
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="metric-values-back"
|
||||
className="-ml-2 w-fit"
|
||||
>
|
||||
<Link href={backHref}>
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{backToOrigin
|
||||
<BackLink
|
||||
href={backHref}
|
||||
label={
|
||||
backToOrigin
|
||||
? t("insights.subPage.valuesBackToMetric", {
|
||||
metric: metricLabel,
|
||||
})
|
||||
: t("insights.subPage.valuesBack")}
|
||||
</Link>
|
||||
</Button>
|
||||
: t("insights.subPage.valuesBack")
|
||||
}
|
||||
dataSlot="metric-values-back"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MeasurementList lockedType={type} />
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useWorkoutDetail } from "@/hooks/use-workouts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -41,18 +39,11 @@ export default function InsightsWorkoutDetailPage({
|
||||
title={t("insights.workouts.title")}
|
||||
description={t("insights.workouts.description")}
|
||||
backLink={
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="workout-detail-back"
|
||||
className="-ml-2 w-fit"
|
||||
>
|
||||
<Link href="/insights/workouts">
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{t("insights.workouts.detail.backToList")}
|
||||
</Link>
|
||||
</Button>
|
||||
<BackLink
|
||||
href="/insights/workouts"
|
||||
label={t("insights.workouts.detail.backToList")}
|
||||
dataSlot="workout-detail-back"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -13,16 +13,15 @@
|
||||
*/
|
||||
|
||||
import { use, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
MedicationDetailTabs,
|
||||
@@ -87,12 +86,7 @@ export default function MedicationDetailPage({
|
||||
if (isError || !medication) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
|
||||
<Link href="/medications">
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{t("medications.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
<BackLink href="/medications" label={t("medications.back")} />
|
||||
<Card
|
||||
className="p-6"
|
||||
role="alert"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { BackLink } from "../back-link";
|
||||
|
||||
describe("<BackLink>", () => {
|
||||
it("renders an anchor to the href carrying the label", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BackLink href="/insights/workouts" label="Back to workouts" />,
|
||||
);
|
||||
expect(html).toContain('href="/insights/workouts"');
|
||||
expect(html).toContain("Back to workouts");
|
||||
// The decorative chevron is hidden from screen readers; the label
|
||||
// carries the meaning.
|
||||
expect(html).toContain('aria-hidden="true"');
|
||||
});
|
||||
|
||||
it("keeps the shared left-bleed alignment on the control", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BackLink href="/medications" label="Back" />,
|
||||
);
|
||||
// The `-ml-2 w-fit` left-bleed is the standard back-nav placement; it
|
||||
// must survive on every adopting page.
|
||||
expect(html).toContain("-ml-2");
|
||||
expect(html).toContain("w-fit");
|
||||
});
|
||||
|
||||
it("threads the optional data-slot for e2e/visual targeting", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BackLink
|
||||
href="/insights"
|
||||
label="Back"
|
||||
dataSlot="composite-score-back"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="composite-score-back"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.16.16 — shared back-navigation control.
|
||||
*
|
||||
* The detail / sub-page surfaces (`/insights/workouts/[id]`,
|
||||
* `/insights/values/[type]`, `/insights/scores/[metric]`,
|
||||
* `/medications/[id]`) each hand-rolled the identical
|
||||
* `Button(ghost, sm, -ml-2 w-fit) > Link > ArrowLeft + label` block. This
|
||||
* folds that into one component so the back-nav reads the same — same hit
|
||||
* area, same icon, same left-bleed alignment — on every page, and a future
|
||||
* change to the affordance lives in one place.
|
||||
*
|
||||
* The label travels in from the caller (each page already owns its own
|
||||
* translation key, e.g. `insights.workouts.detail.backToList`), so this
|
||||
* component adds no new i18n surface.
|
||||
*/
|
||||
export interface BackLinkProps {
|
||||
/** Internal href the back-nav points at. */
|
||||
href: string;
|
||||
/** Already-translated label text. */
|
||||
label: string;
|
||||
/** Optional per-surface `data-slot` for e2e/visual targeting. */
|
||||
dataSlot?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BackLink({ href, label, dataSlot, className }: BackLinkProps) {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot={dataSlot}
|
||||
className={cn("-ml-2 w-fit", className)}
|
||||
>
|
||||
<Link href={href}>
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{label}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,25 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
consentReceipt: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
// `createReceipt` + `revokeLatest` run their supersede/revoke logic inside
|
||||
// a transaction. Run the callback against the same mock proxy. The Prisma
|
||||
// `$transaction` signature is generic; the mock takes a loose callback.
|
||||
type TxFn = (tx: unknown) => unknown;
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const consentReceipt = {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
consentReceipt,
|
||||
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
@@ -19,8 +29,15 @@ import {
|
||||
revokeLatest,
|
||||
} from "../receipts";
|
||||
|
||||
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
|
||||
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
$transaction.mockImplementation((fn: TxFn) =>
|
||||
fn({ consentReceipt: prisma.consentReceipt }),
|
||||
);
|
||||
});
|
||||
|
||||
function row(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
@@ -37,12 +54,23 @@ function row(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
}
|
||||
|
||||
describe("createReceipt", () => {
|
||||
it("inserts a row scoped to (userId, kind) with the artefact + signedAt", async () => {
|
||||
it("supersedes any active row then inserts scoped to (userId, kind)", async () => {
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never);
|
||||
|
||||
const signedAt = new Date("2026-05-18T10:00:00.000Z");
|
||||
const result = await createReceipt("user-1", "ai_full", "PDF…", signedAt);
|
||||
|
||||
// Revokes any currently-active receipt of the same kind first, so the
|
||||
// fresh grant doesn't collide with the partial unique index. The
|
||||
// supersede marker uses a server clock (NOT the client `signedAt`) so a
|
||||
// backdated grant can't invert the prior row's audit timestamps.
|
||||
expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
|
||||
data: { revokedAt: expect.any(Date) },
|
||||
});
|
||||
expect(prisma.consentReceipt.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: "user-1",
|
||||
@@ -52,6 +80,32 @@ describe("createReceipt", () => {
|
||||
},
|
||||
});
|
||||
expect(result.id).toBe("rcpt-1");
|
||||
// The whole mint runs in a transaction.
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses a server clock (not a backdated client signedAt) for the supersede marker", async () => {
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never);
|
||||
|
||||
// A client backdates the grant well into the past.
|
||||
const backdated = new Date("2000-01-01T00:00:00.000Z");
|
||||
const before = Date.now();
|
||||
await createReceipt("user-1", "ai_full", "PDF…", backdated);
|
||||
const after = Date.now();
|
||||
|
||||
const supersedeArg = vi.mocked(prisma.consentReceipt.updateMany).mock
|
||||
.calls[0][0] as { data: { revokedAt: Date } };
|
||||
// The prior row's revocation must be stamped "now", never the backdated
|
||||
// client value, so the audit chain can't be inverted.
|
||||
expect(supersedeArg.data.revokedAt.getTime()).not.toBe(backdated.getTime());
|
||||
expect(supersedeArg.data.revokedAt.getTime()).toBeGreaterThanOrEqual(before);
|
||||
expect(supersedeArg.data.revokedAt.getTime()).toBeLessThanOrEqual(after);
|
||||
// The new row still carries the client signedAt.
|
||||
const createArg = vi.mocked(prisma.consentReceipt.create).mock.calls[0][0];
|
||||
expect(createArg.data.signedAt).toEqual(backdated);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,28 +150,37 @@ describe("latestActiveReceiptsByKind", () => {
|
||||
});
|
||||
|
||||
describe("revokeLatest", () => {
|
||||
it("flips revokedAt on the latest active row and returns the updated row", async () => {
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(row() as never);
|
||||
vi.mocked(prisma.consentReceipt.update).mockResolvedValue(
|
||||
row({ revokedAt: new Date("2026-05-18T11:00:00.000Z") }) as never,
|
||||
it("atomically revokes the active row via updateMany and returns it", async () => {
|
||||
const now = new Date("2026-05-18T11:00:00.000Z");
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
|
||||
row({ revokedAt: now }) as never,
|
||||
);
|
||||
|
||||
const now = new Date("2026-05-18T11:00:00.000Z");
|
||||
const result = await revokeLatest("user-1", "ai_full", now);
|
||||
|
||||
expect(prisma.consentReceipt.update).toHaveBeenCalledWith({
|
||||
where: { id: "rcpt-1" },
|
||||
expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
// Re-reads the just-revoked row so the audit log keeps the receipt id.
|
||||
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: now },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
expect(result?.revokedAt).toEqual(now);
|
||||
});
|
||||
|
||||
it("returns null without writing when no active receipt exists", async () => {
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
|
||||
it("returns null without re-reading when no active receipt exists", async () => {
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const result = await revokeLatest("user-1", "ai_full");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(prisma.consentReceipt.update).not.toHaveBeenCalled();
|
||||
expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,40 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
consentReceipt: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
// The web-grant mint runs its re-check + insert inside a transaction. The
|
||||
// mock runs the callback against the same `prisma` proxy so the in-tx
|
||||
// reads/writes share the test's mock surface. The Prisma `$transaction`
|
||||
// signature is generic; the mock takes a loose callback and casts.
|
||||
type TxFn = (tx: unknown) => unknown;
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const consentReceipt = {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
consentReceipt,
|
||||
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureWebAiConsentReceipt } from "../web-grant";
|
||||
|
||||
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
|
||||
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
|
||||
mockReset: () => void;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// `resetAllMocks` clears the `$transaction` pass-through implementation;
|
||||
// re-arm it so the mint callback still runs against the mock surface.
|
||||
$transaction.mockImplementation((fn: TxFn) =>
|
||||
fn({ consentReceipt: prisma.consentReceipt }),
|
||||
);
|
||||
});
|
||||
|
||||
function row(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
@@ -38,11 +59,14 @@ describe("ensureWebAiConsentReceipt", () => {
|
||||
const result = await ensureWebAiConsentReceipt("user-1", now);
|
||||
|
||||
expect(result.minted).toBe(true);
|
||||
// Reads the active master grant first.
|
||||
// Reads the active master grant first (fast-path + in-tx re-check both
|
||||
// hit the same predicate).
|
||||
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
// The mint is wrapped in a transaction (TOCTOU close).
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
// Mints ai_full with a web-source artefact + the supplied timestamp.
|
||||
const createArg = vi.mocked(prisma.consentReceipt.create).mock.calls[0][0];
|
||||
expect(createArg.data.userId).toBe("user-1");
|
||||
@@ -60,5 +84,83 @@ describe("ensureWebAiConsentReceipt", () => {
|
||||
|
||||
expect(result.minted).toBe(false);
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
// No transaction is opened on the already-granted fast path.
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-checks inside the transaction: a row that appears between the fast-path read and the mint short-circuits the insert", async () => {
|
||||
// Fast-path read sees nothing; the in-tx re-check sees a row a
|
||||
// concurrent grant inserted. The mint must no-op rather than insert a
|
||||
// second active row.
|
||||
vi.mocked(prisma.consentReceipt.findFirst)
|
||||
.mockResolvedValueOnce(null) // fast path
|
||||
.mockResolvedValueOnce(row() as never); // in-tx re-check
|
||||
|
||||
const result = await ensureWebAiConsentReceipt("user-1");
|
||||
|
||||
expect(result.minted).toBe(false);
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps the partial-unique violation (P2002) to a no-op when two grants race to the insert", async () => {
|
||||
// Both reads miss (the re-check loses the race too); the partial unique
|
||||
// index rejects the second insert. The structural backstop must surface
|
||||
// as success-shaped — the user ends with exactly one active receipt.
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.consentReceipt.create).mockRejectedValue({
|
||||
code: "P2002",
|
||||
} as never);
|
||||
|
||||
const result = await ensureWebAiConsentReceipt("user-1");
|
||||
|
||||
expect(result.minted).toBe(false);
|
||||
});
|
||||
|
||||
it("rethrows non-P2002 errors", async () => {
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.consentReceipt.create).mockRejectedValue(
|
||||
new Error("connection reset") as never,
|
||||
);
|
||||
|
||||
await expect(ensureWebAiConsentReceipt("user-1")).rejects.toThrow(
|
||||
"connection reset",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureWebAiConsentReceipt — two concurrent grants", () => {
|
||||
it("yields exactly one active receipt across parallel mounts", async () => {
|
||||
// Model the race at the DB layer: a single shared "active row" slot.
|
||||
// Both callers pass the fast-path read (slot empty). The transaction
|
||||
// serialises their re-checks: the first sees the empty slot and
|
||||
// inserts; the second sees the now-filled slot and no-ops.
|
||||
let activeRow: ReturnType<typeof row> | null = null;
|
||||
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockImplementation((async () =>
|
||||
activeRow) as never);
|
||||
vi.mocked(prisma.consentReceipt.create).mockImplementation((async () => {
|
||||
activeRow = row();
|
||||
return activeRow;
|
||||
}) as never);
|
||||
// Serialise the two transactions so the second observes the first's
|
||||
// committed insert (Serializable isolation in production).
|
||||
let chain: Promise<unknown> = Promise.resolve();
|
||||
$transaction.mockImplementation((fn: TxFn) => {
|
||||
const run = chain.then(() =>
|
||||
fn({ consentReceipt: prisma.consentReceipt }),
|
||||
);
|
||||
chain = run.catch(() => {});
|
||||
return run;
|
||||
});
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
ensureWebAiConsentReceipt("user-1"),
|
||||
ensureWebAiConsentReceipt("user-1"),
|
||||
]);
|
||||
|
||||
// Exactly one mint, exactly one row inserted.
|
||||
const mintedCount = [a, b].filter((r) => r.minted).length;
|
||||
expect(mintedCount).toBe(1);
|
||||
expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+56
-23
@@ -19,9 +19,17 @@ import type { ConsentKind } from "@/lib/validations/consent";
|
||||
export type ConsentReceipt = ConsentReceiptModel;
|
||||
|
||||
/**
|
||||
* Insert a fresh consent receipt for (userId, kind). Always appends;
|
||||
* never collides with an earlier row. The caller has already
|
||||
* Zod-validated `artefact` (≤ 64 KB) and `signedAt` (ISO-8601).
|
||||
* Insert a fresh consent receipt for (userId, kind). The caller has
|
||||
* already Zod-validated `artefact` (≤ 64 KB) and `signedAt` (ISO-8601).
|
||||
*
|
||||
* v1.16.16 — the partial unique index `consent_receipts_user_id_kind_
|
||||
* active_key` (`WHERE revoked_at IS NULL`, migration 0159) caps the table
|
||||
* at one *active* receipt per (user, kind). A plain re-grant of an already
|
||||
* active kind would otherwise collide, so the mint runs in a transaction
|
||||
* that first revokes any active row of the same kind, then appends the new
|
||||
* one. The append-only audit chain is preserved — the superseded row keeps
|
||||
* its history with a `revoked_at` marker — and the constraint is satisfied
|
||||
* because at most one active row ever exists.
|
||||
*/
|
||||
export async function createReceipt(
|
||||
userId: string,
|
||||
@@ -29,13 +37,29 @@ export async function createReceipt(
|
||||
artefact: string,
|
||||
signedAt: Date,
|
||||
): Promise<ConsentReceipt> {
|
||||
return prisma.consentReceipt.create({
|
||||
data: {
|
||||
userId,
|
||||
kind,
|
||||
artefact,
|
||||
signedAt,
|
||||
},
|
||||
// `signedAt` is the client-supplied wall-clock for the NEW grant and is
|
||||
// only validated as well-formed ISO-8601 — it can legitimately be in the
|
||||
// past or out of order with existing rows. The supersede marker on the
|
||||
// PRIOR active row must NOT use it: a backdated `signedAt` would stamp the
|
||||
// old row's `revoked_at` earlier than its own `signed_at`, inverting the
|
||||
// audit chain. Use a server clock for the revocation instead.
|
||||
const supersededAt = new Date();
|
||||
return prisma.$transaction(async (tx) => {
|
||||
// Supersede any currently-active receipt of this kind so the fresh
|
||||
// grant doesn't collide with the partial unique index. `updateMany`
|
||||
// over the active predicate is a no-op when none is active.
|
||||
await tx.consentReceipt.updateMany({
|
||||
where: { userId, kind, revokedAt: null },
|
||||
data: { revokedAt: supersededAt },
|
||||
});
|
||||
return tx.consentReceipt.create({
|
||||
data: {
|
||||
userId,
|
||||
kind,
|
||||
artefact,
|
||||
signedAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,25 +105,34 @@ export async function latestActiveReceiptsByKind(
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the latest active receipt for (userId, kind) as revoked.
|
||||
* Returns the updated row, or `null` if no active receipt exists.
|
||||
* Mark the active receipt for (userId, kind) as revoked. Returns the
|
||||
* updated row, or `null` if no active receipt exists.
|
||||
*
|
||||
* Implementation note: a two-step "read latest → update by id" is
|
||||
* race-tolerant for the single-user revoke flow (iOS toggle is a
|
||||
* per-device action; no concurrent revoke from a second device is
|
||||
* plausible inside the same millisecond). If two devices race we end
|
||||
* up writing `revokedAt` to the same row twice with identical
|
||||
* timestamps — semantically idempotent.
|
||||
* v1.16.16 — the partial unique index guarantees at most one active row
|
||||
* per (user, kind), so a single atomic `updateMany` over the active
|
||||
* predicate revokes "the" active receipt without a separate read. Two
|
||||
* concurrent revokes converge: the first flips `revoked_at`, the second's
|
||||
* predicate no longer matches and writes nothing. The follow-up read
|
||||
* returns the row the caller (audit log) needs; it is `null` only when no
|
||||
* active receipt existed at all.
|
||||
*/
|
||||
export async function revokeLatest(
|
||||
userId: string,
|
||||
kind: ConsentKind,
|
||||
now: Date = new Date(),
|
||||
): Promise<ConsentReceipt | null> {
|
||||
const latest = await latestActiveReceipt(userId, kind);
|
||||
if (!latest) return null;
|
||||
return prisma.consentReceipt.update({
|
||||
where: { id: latest.id },
|
||||
data: { revokedAt: now },
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.consentReceipt.updateMany({
|
||||
where: { userId, kind, revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
if (updated.count === 0) return null;
|
||||
// Re-read the just-revoked row (revoked at exactly `now`) so the audit
|
||||
// trail keeps the receipt id. The active predicate guaranteed a single
|
||||
// row, so this resolves it unambiguously.
|
||||
return tx.consentReceipt.findFirst({
|
||||
where: { userId, kind, revokedAt: now },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -21,7 +21,10 @@
|
||||
* timestamp + source, which is the GDPR Art. 7 audit signal the legal team
|
||||
* needs). It stays well under the 64 KB artefact cap.
|
||||
*/
|
||||
import { createReceipt, latestActiveReceipt } from "@/lib/consent/receipts";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import { latestActiveReceipt } from "@/lib/consent/receipts";
|
||||
import type { ConsentReceipt } from "@/lib/consent/receipts";
|
||||
|
||||
/** Outcome of an idempotent web-grant call. */
|
||||
@@ -42,6 +45,9 @@ export async function ensureWebAiConsentReceipt(
|
||||
userId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<WebConsentGrantResult> {
|
||||
// Fast path: a cheap read outside the transaction short-circuits the
|
||||
// common already-granted case (every mount after the first) without
|
||||
// opening a transaction.
|
||||
const existing = await latestActiveReceipt(userId, "ai_full");
|
||||
if (existing) return { minted: false };
|
||||
|
||||
@@ -52,6 +58,32 @@ export async function ensureWebAiConsentReceipt(
|
||||
note: "In-app affirmative AI consent granted via the web client.",
|
||||
});
|
||||
|
||||
const receipt = await createReceipt(userId, "ai_full", artefact, now);
|
||||
return { minted: true, receipt };
|
||||
// Two web mounts can pass the fast-path read concurrently and both reach
|
||||
// the mint. The transaction re-checks under a Serializable isolation so a
|
||||
// second mint sees the first's row and no-ops; the partial unique index
|
||||
// (migration 0159, `WHERE revoked_at IS NULL`) is the structural backstop
|
||||
// if the re-check still races. A unique violation from the backstop means
|
||||
// a concurrent grant already won — that is success-shaped (the user ends
|
||||
// up with exactly one active receipt), so we map it to `{ minted: false }`.
|
||||
try {
|
||||
return await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const inTx = await tx.consentReceipt.findFirst({
|
||||
where: { userId, kind: "ai_full", revokedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
if (inTx) return { minted: false } as const;
|
||||
|
||||
const receipt = await tx.consentReceipt.create({
|
||||
data: { userId, kind: "ai_full", artefact, signedAt: now },
|
||||
});
|
||||
return { minted: true, receipt } as const;
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||
);
|
||||
} catch (err) {
|
||||
// Partial-unique violation — a concurrent grant landed first.
|
||||
if (isP2002(err)) return { minted: false };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,25 @@ export async function checkAnalyticsReadRateLimit(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.16 — per-user ceiling for the AI-consent surfaces
|
||||
* (`POST /api/consent/ai`, `POST /api/consent/ai/web`,
|
||||
* `GET/DELETE /api/consent/ai/latest`). The web client calls the heal
|
||||
* endpoint on every AI-settings mount and the iOS toggle hits grant/revoke
|
||||
* per flip, so the bucket is generous — 20/min only caps a scripted loop or
|
||||
* a stolen session pinning the receipts table, mirroring the coach-chat
|
||||
* ceiling. One shared bucket keyed by `userId` (not per-route) keeps the cap
|
||||
* meaningful when a loop rotates across the four endpoints.
|
||||
*/
|
||||
const CONSENT_LIMIT = 20;
|
||||
const CONSENT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
export async function checkConsentRateLimit(
|
||||
userId: string,
|
||||
): Promise<RateLimitResult> {
|
||||
return checkRateLimit(`consent:${userId}`, CONSENT_LIMIT, CONSENT_WINDOW_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.43 W13 M-4 — anonymous/trust-violation bucket key shared by every
|
||||
* caller of `checkAuthSurfaceRateLimit`. When `TRUST_PROXY_HOPS` and the
|
||||
|
||||
Reference in new issue
Block a user