mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
fix(consent): a withdrawn AI consent stays withdrawn, and the web can withdraw
The settings mount fires an idempotent consent heal so an account that predates the gate does not fall into the no-key state. It looked only for an active receipt, so a deliberate withdrawal — which sets revokedAt — read the same as never having consented, and the next visit to the AI settings granted again, writing an audit row for an act the user never performed. GDPR Art. 7 (3) puts withdrawal on the same footing as the grant; one undone by a page load is not a withdrawal. The mint now distinguishes the two callers. The mount heal grants only to an account with no consent history at all and leaves a revocation standing; switching document auto-read on is the consent act itself and may supersede an earlier revocation. The revocation is re-checked inside the transaction so a withdrawal landing mid-mint is not overwritten. The revoke endpoint has existed since v1.4.40 but only the native client called it: on the web, consent could be given and never taken back. The AI settings now carry the standing decision and the control that changes it, in all six languages.
This commit is contained in:
1 parent
851a82cc79
commit
1c2cfffdb8
18 files changed
+473
-18
No files matched your search
@@ -107,7 +107,7 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
// `ai_full` receipt (idempotent) so the durable audit trail records it, in
|
||||
// addition to the runtime short-circuit the document consent gate reads.
|
||||
if (next) {
|
||||
await ensureWebAiConsentReceipt(user.id);
|
||||
await ensureWebAiConsentReceipt(user.id, "affirmative");
|
||||
}
|
||||
|
||||
await auditLog("user.documentsAutoAiRead.update", {
|
||||
|
||||
@@ -90,7 +90,10 @@ describe("POST /api/consent/ai/web", () => {
|
||||
|
||||
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 });
|
||||
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({
|
||||
minted: false,
|
||||
reason: "already_active",
|
||||
});
|
||||
|
||||
const res = await POST();
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -37,7 +37,7 @@ export const POST = apiHandler(async () => {
|
||||
});
|
||||
}
|
||||
|
||||
const result = await ensureWebAiConsentReceipt(user.id);
|
||||
const result = await ensureWebAiConsentReceipt(user.id, "heal");
|
||||
|
||||
if (result.minted) {
|
||||
// Audit trail parity with the iOS-driven POST /api/consent/ai grant so
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AiConsentCard } from "../ai-consent-card";
|
||||
|
||||
/**
|
||||
* The web withdrawal affordance. Before this card the revoke endpoint was
|
||||
* reachable only from the native client, so a web-only account could give
|
||||
* AI consent and never take it back.
|
||||
*
|
||||
* These render the card against a pre-seeded query cache (server-side
|
||||
* markup, so the mutation path is not exercised here — the endpoint it
|
||||
* calls has its own route tests). What they pin is that the card reports
|
||||
* the standing decision correctly and offers the withdrawal only when
|
||||
* there is something to withdraw.
|
||||
*/
|
||||
|
||||
const receiptKey = ["consent", "ai", "latest", "ai_full"];
|
||||
|
||||
function renderWith(receipt: unknown) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
client.setQueryData(receiptKey, receipt);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<AiConsentCard isAuthenticated />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("AiConsentCard", () => {
|
||||
it("offers the withdrawal while consent stands", () => {
|
||||
const html = renderWith({
|
||||
id: "rcpt-1",
|
||||
kind: "ai_full",
|
||||
signedAt: "2026-07-01T08:00:00.000Z",
|
||||
revokedAt: null,
|
||||
});
|
||||
|
||||
expect(html).toContain('data-slot="ai-consent-withdraw"');
|
||||
expect(html).toContain("Withdraw consent");
|
||||
});
|
||||
|
||||
it("reports a withdrawal and stops offering it again", () => {
|
||||
const html = renderWith({
|
||||
id: "rcpt-1",
|
||||
kind: "ai_full",
|
||||
signedAt: "2026-07-01T08:00:00.000Z",
|
||||
revokedAt: "2026-07-10T08:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(html).toContain("Withdrawn.");
|
||||
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
|
||||
});
|
||||
|
||||
it("treats no receipt at all as consent not given", () => {
|
||||
const html = renderWith(null);
|
||||
|
||||
expect(html).toContain("Withdrawn.");
|
||||
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
|
||||
});
|
||||
|
||||
it("renders nothing for a signed-out visitor", () => {
|
||||
const client = new QueryClient();
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<AiConsentCard isAuthenticated={false} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
* Standing AI consent — the web side of the withdrawal.
|
||||
*
|
||||
* The revoke endpoint (`DELETE /api/consent/ai/latest?kind=ai_full`) has
|
||||
* existed since v1.4.40, but only the native client ever called it: on the
|
||||
* web, consent could be given and never taken back. GDPR Art. 7 (3) puts
|
||||
* withdrawal on the same footing as the grant, so it belongs on the same
|
||||
* surface, not behind a support request.
|
||||
*
|
||||
* The card shows the standing decision in plain words and offers the one
|
||||
* move that changes it. Withdrawing takes effect immediately — the consent
|
||||
* gate fails closed without an active receipt, so every AI surface falls
|
||||
* back to its no-consent state on the next call. Nothing already stored is
|
||||
* deleted by this; that is the export/erase path in Data & Privacy, and the
|
||||
* copy says so rather than implying more than it does.
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, ShieldCheck, ShieldOff } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
type ConsentReceiptWire = {
|
||||
id: string;
|
||||
kind: string;
|
||||
signedAt: string;
|
||||
revokedAt: string | null;
|
||||
} | null;
|
||||
|
||||
export function AiConsentCard({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
queryFn: async () => {
|
||||
const res = await apiFetchRaw("/api/consent/ai/latest?kind=ai_full");
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return (json.data?.receipt ?? null) as ConsentReceiptWire;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
mutationFn: async () => {
|
||||
const res = await apiFetchRaw("/api/consent/ai/latest?kind=ai_full", {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error("revoke failed");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setConfirming(false);
|
||||
// The receipt gates every AI surface, so anything that reads consent
|
||||
// state has to re-resolve — not just this card.
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsProviderChain(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Say nothing until the state is known: a card that flashes "withdrawn"
|
||||
// before the receipt arrives would misreport the user's own decision.
|
||||
if (!isAuthenticated || isLoading) return null;
|
||||
|
||||
// Narrow to the receipt itself rather than a boolean, so the branches
|
||||
// below can read its fields without an assertion.
|
||||
const activeReceipt = data && data.revokedAt === null ? data : null;
|
||||
|
||||
return (
|
||||
<section className="space-y-3" data-slot="ai-consent">
|
||||
<div className="space-y-1">
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
{activeReceipt ? (
|
||||
<ShieldCheck className="size-4" aria-hidden />
|
||||
) : (
|
||||
<ShieldOff className="text-muted-foreground size-4" aria-hidden />
|
||||
)}
|
||||
{t("settings.ai.consent.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{activeReceipt
|
||||
? t("settings.ai.consent.activeSince", {
|
||||
date: formatDateTime(activeReceipt.signedAt),
|
||||
})
|
||||
: t("settings.ai.consent.withdrawn")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{activeReceipt ? (
|
||||
confirming ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-muted-foreground w-full text-xs">
|
||||
{t("settings.ai.consent.confirmBody")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
data-slot="ai-consent-withdraw-confirm"
|
||||
onClick={() => revoke.mutate()}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
{revoke.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("settings.ai.consent.confirmWithdraw")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setConfirming(false)}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-slot="ai-consent-withdraw"
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{t("settings.ai.consent.withdraw")}
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.ai.consent.regrantHint")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{revoke.isError ? (
|
||||
<p className="text-destructive text-xs" role="alert">
|
||||
{t("settings.ai.consent.error")}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { AdminOpenAIProviderForm } from "./admin-openai-provider-form";
|
||||
import { AiConsentCard } from "./ai-consent-card";
|
||||
import { AnthropicProviderForm } from "./anthropic-provider-form";
|
||||
import { AutoReadCard } from "./auto-read-card";
|
||||
import { CentralCodexSwitch } from "./central-codex-switch";
|
||||
@@ -186,6 +187,11 @@ export function AiInsightsCard({
|
||||
renders when the operator has connected it). */}
|
||||
<CentralCodexSwitch settings={insightsSettings} />
|
||||
|
||||
{/* The standing consent and the one control that withdraws it. It
|
||||
sits last because it governs everything above rather than
|
||||
configuring any single provider. */}
|
||||
<AiConsentCard isAuthenticated={isAuthenticated} />
|
||||
|
||||
<RuntimeActionsRow
|
||||
provider={selectedProvider}
|
||||
userProvider={userProvider}
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("ensureWebAiConsentReceipt", () => {
|
||||
vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never);
|
||||
|
||||
const now = new Date("2026-06-14T10:00:00.000Z");
|
||||
const result = await ensureWebAiConsentReceipt("user-1", now);
|
||||
const result = await ensureWebAiConsentReceipt("user-1", "heal", now);
|
||||
|
||||
expect(result.minted).toBe(true);
|
||||
// Reads the active master grant first (fast-path + in-tx re-check both
|
||||
@@ -82,7 +82,7 @@ describe("ensureWebAiConsentReceipt", () => {
|
||||
row() as never,
|
||||
);
|
||||
|
||||
const result = await ensureWebAiConsentReceipt("user-1");
|
||||
const result = await ensureWebAiConsentReceipt("user-1", "heal");
|
||||
|
||||
expect(result.minted).toBe(false);
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
@@ -98,7 +98,7 @@ describe("ensureWebAiConsentReceipt", () => {
|
||||
.mockResolvedValueOnce(null) // fast path
|
||||
.mockResolvedValueOnce(row() as never); // in-tx re-check
|
||||
|
||||
const result = await ensureWebAiConsentReceipt("user-1");
|
||||
const result = await ensureWebAiConsentReceipt("user-1", "heal");
|
||||
|
||||
expect(result.minted).toBe(false);
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
@@ -113,7 +113,7 @@ describe("ensureWebAiConsentReceipt", () => {
|
||||
code: "P2002",
|
||||
} as never);
|
||||
|
||||
const result = await ensureWebAiConsentReceipt("user-1");
|
||||
const result = await ensureWebAiConsentReceipt("user-1", "heal");
|
||||
|
||||
expect(result.minted).toBe(false);
|
||||
});
|
||||
@@ -124,7 +124,7 @@ describe("ensureWebAiConsentReceipt", () => {
|
||||
new Error("connection reset") as never,
|
||||
);
|
||||
|
||||
await expect(ensureWebAiConsentReceipt("user-1")).rejects.toThrow(
|
||||
await expect(ensureWebAiConsentReceipt("user-1", "heal")).rejects.toThrow(
|
||||
"connection reset",
|
||||
);
|
||||
});
|
||||
@@ -157,8 +157,8 @@ describe("ensureWebAiConsentReceipt — two concurrent grants", () => {
|
||||
});
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
ensureWebAiConsentReceipt("user-1"),
|
||||
ensureWebAiConsentReceipt("user-1"),
|
||||
ensureWebAiConsentReceipt("user-1", "heal"),
|
||||
ensureWebAiConsentReceipt("user-1", "heal"),
|
||||
]);
|
||||
|
||||
// Exactly one mint, exactly one row inserted.
|
||||
@@ -167,3 +167,82 @@ describe("ensureWebAiConsentReceipt — two concurrent grants", () => {
|
||||
expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureWebAiConsentReceipt — a revocation is a standing decision", () => {
|
||||
// The mount heal and the revocation check share one `findFirst` mock, so
|
||||
// route by predicate: `revokedAt: null` is the active-grant lookup,
|
||||
// `revokedAt: { not: null }` is the revocation-history lookup.
|
||||
function arrange({ hasRevoked }: { hasRevoked: boolean }) {
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockImplementation(
|
||||
(async (args: { where: { revokedAt?: unknown } }) => {
|
||||
const wantsRevoked =
|
||||
args.where.revokedAt !== null && args.where.revokedAt !== undefined;
|
||||
if (wantsRevoked)
|
||||
return hasRevoked
|
||||
? (row({
|
||||
revokedAt: new Date("2026-07-01T09:00:00.000Z"),
|
||||
}) as never)
|
||||
: null;
|
||||
return null;
|
||||
}) as never,
|
||||
);
|
||||
vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never);
|
||||
}
|
||||
|
||||
it("does not re-grant on the settings mount after the user revoked", async () => {
|
||||
arrange({ hasRevoked: true });
|
||||
|
||||
const result = await ensureWebAiConsentReceipt("user-1", "heal");
|
||||
|
||||
expect(result).toEqual({ minted: false, reason: "previously_revoked" });
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
// No transaction is opened — the decision is settled on the read.
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still heals an account that never had a receipt at all", async () => {
|
||||
arrange({ hasRevoked: false });
|
||||
|
||||
const result = await ensureWebAiConsentReceipt("user-1", "heal");
|
||||
|
||||
expect(result.minted).toBe(true);
|
||||
expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lets the user grant again through an affirmative act", async () => {
|
||||
arrange({ hasRevoked: true });
|
||||
|
||||
const result = await ensureWebAiConsentReceipt("user-1", "affirmative");
|
||||
|
||||
expect(result.minted).toBe(true);
|
||||
expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("holds the line when the revocation lands mid-transaction", async () => {
|
||||
// The fast-path reads see a clean history; the revocation appears only
|
||||
// once the transaction is open. Without the in-transaction re-check the
|
||||
// mint would overwrite a decision made a moment earlier.
|
||||
let inTransaction = false;
|
||||
$transaction.mockImplementation((fn: TxFn) => {
|
||||
inTransaction = true;
|
||||
return fn({ consentReceipt: prisma.consentReceipt });
|
||||
});
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockImplementation(
|
||||
(async (args: { where: { revokedAt?: unknown } }) => {
|
||||
const wantsRevoked =
|
||||
args.where.revokedAt !== null && args.where.revokedAt !== undefined;
|
||||
if (wantsRevoked && inTransaction)
|
||||
return row({
|
||||
revokedAt: new Date("2026-07-01T09:00:00.000Z"),
|
||||
}) as never;
|
||||
return null;
|
||||
}) as never,
|
||||
);
|
||||
vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never);
|
||||
|
||||
const result = await ensureWebAiConsentReceipt("user-1", "heal");
|
||||
|
||||
expect(result).toEqual({ minted: false, reason: "previously_revoked" });
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,17 @@
|
||||
* shell-mount heal). Explicit revocation flows through the existing
|
||||
* `DELETE /api/consent/ai/latest`; this helper only ever grants.
|
||||
*
|
||||
* It heals an account that never gave consent — never one that took it
|
||||
* back. "No active receipt" has two very different causes: an account that
|
||||
* predates the gate and has no receipt at all, and an account whose owner
|
||||
* deliberately revoked. Both look identical to a `revokedAt: null` lookup,
|
||||
* and healing the second one re-grants a consent the user withdrew, writing
|
||||
* a `consent.ai.grant` audit row for an action they never took. GDPR Art. 7
|
||||
* (3) requires withdrawal to be as easy as granting; a withdrawal undone by
|
||||
* the next page visit is not a withdrawal. So the mint is gated on the
|
||||
* account having no revoked `ai_full` receipt in its history: a prior
|
||||
* revocation is a standing decision, and only an explicit grant may lift it.
|
||||
*
|
||||
* The artefact is a small JSON record of the web grant context (no signed
|
||||
* PDF/JWT — the web grant is an in-app affirmative action recorded with its
|
||||
* timestamp + source, which is the GDPR Art. 7 audit signal the legal team
|
||||
@@ -27,9 +38,30 @@ 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. */
|
||||
/**
|
||||
* Outcome of an idempotent web-grant call. `minted: false` carries a reason
|
||||
* so callers can tell "already granted" apart from "withheld because the
|
||||
* user revoked" — the two are the same non-event to the UI, but only the
|
||||
* second one is worth an audit annotation.
|
||||
*/
|
||||
export type WebConsentGrantResult =
|
||||
{ minted: true; receipt: ConsentReceipt } | { minted: false };
|
||||
| { minted: true; receipt: ConsentReceipt }
|
||||
| { minted: false; reason: "already_active" | "previously_revoked" };
|
||||
|
||||
/**
|
||||
* How the call came about. The distinction decides whether a past revocation
|
||||
* blocks the mint, so it is a required argument rather than a default —
|
||||
* a caller must say which one it is.
|
||||
*
|
||||
* `heal` — fired by the AI-settings mount, not by a user gesture.
|
||||
* Grants only to an account that has no consent history at
|
||||
* all. A revocation stands.
|
||||
* `affirmative`— the user just performed the opt-in itself (switching the
|
||||
* document auto-read on). This IS the consent act, so it
|
||||
* supersedes an earlier revocation, exactly as the first
|
||||
* grant would.
|
||||
*/
|
||||
export type WebConsentGrantIntent = "heal" | "affirmative";
|
||||
|
||||
/**
|
||||
* Ensure the web user has an active `ai_full` consent receipt, minting one
|
||||
@@ -42,13 +74,25 @@ export type WebConsentGrantResult =
|
||||
*/
|
||||
export async function ensureWebAiConsentReceipt(
|
||||
userId: string,
|
||||
intent: WebConsentGrantIntent,
|
||||
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 };
|
||||
if (existing) return { minted: false, reason: "already_active" };
|
||||
|
||||
// A revoked receipt anywhere in this account's history means the user made
|
||||
// a decision. Healing past it would re-grant what they withdrew. An
|
||||
// affirmative act is the user deciding again, so it may proceed.
|
||||
if (intent === "heal") {
|
||||
const revoked = await prisma.consentReceipt.findFirst({
|
||||
where: { userId, kind: "ai_full", revokedAt: { not: null } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (revoked) return { minted: false, reason: "previously_revoked" };
|
||||
}
|
||||
|
||||
const artefact = JSON.stringify({
|
||||
source: "web",
|
||||
@@ -71,7 +115,19 @@ export async function ensureWebAiConsentReceipt(
|
||||
where: { userId, kind: "ai_full", revokedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
if (inTx) return { minted: false } as const;
|
||||
if (inTx) return { minted: false, reason: "already_active" } as const;
|
||||
|
||||
// Re-check the revocation inside the transaction too: a revoke can
|
||||
// land between the fast-path read and here, and without this the
|
||||
// mint would overwrite a decision made a moment earlier.
|
||||
if (intent === "heal") {
|
||||
const revokedInTx = await tx.consentReceipt.findFirst({
|
||||
where: { userId, kind: "ai_full", revokedAt: { not: null } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (revokedInTx)
|
||||
return { minted: false, reason: "previously_revoked" } as const;
|
||||
}
|
||||
|
||||
const receipt = await tx.consentReceipt.create({
|
||||
data: { userId, kind: "ai_full", artefact, signedAt: now },
|
||||
@@ -82,7 +138,7 @@ export async function ensureWebAiConsentReceipt(
|
||||
);
|
||||
} catch (err) {
|
||||
// Partial-unique violation — a concurrent grant landed first.
|
||||
if (isP2002(err)) return { minted: false };
|
||||
if (isP2002(err)) return { minted: false, reason: "already_active" };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -47,4 +47,12 @@ export const settingsKeys = {
|
||||
shareLinks: () => ["share-links"] as const,
|
||||
|
||||
featureFlags: () => ["feature-flags"] as const,
|
||||
|
||||
/**
|
||||
* The account's standing AI consent receipt (`GET /api/consent/ai/latest`).
|
||||
* Keyed by kind because the endpoint answers per kind and a shared key
|
||||
* across kinds would serve one kind's receipt for another.
|
||||
*/
|
||||
aiConsentReceipt: (kind: string) =>
|
||||
["consent", "ai", "latest", kind] as const,
|
||||
};
|
||||
Reference in new issue
Block a user