Files
HealthLog/src/lib/ai/consent-guard.ts
T
Marc-André Bombeck 37e9f32f56 fix(ai): require active consent before external-provider egress; rate-limit and harden Fitbit sync
Close the launch-blocking gap where the ConsentReceipt infrastructure was
built but never enforced as a precondition: a direct API caller with a valid
token could forward a health snapshot to a server-managed external LLM with no
receipt on file. Consent was enforced client-side only.

Add a consent guard (src/lib/ai/consent-guard.ts) and call it before the first
external-provider call on the server-managed path. The gate fires only when the
resolved chain could egress via the operator's global key (admin-openai); a
user's own BYOK key (openai/anthropic), their own ChatGPT OAuth account, and
the self-hosted local provider stay ungated — that egress is the user's own act
and the settings flow is its consent. The check fails closed: a BYOK-primary
chain with an admin-openai fallback still requires a receipt, since the runner
may cascade to the operator key. The surface maps to the consent kind (coach ->
ai_coach, insights -> ai_insights_only; master ai_full satisfies either).

- Interactive routes (insights/chat, insights/generate POST) throw
  ConsentRequiredError; api-handler renders a 403 with
  meta.errorCode = "consent.ai.required", mirroring assistant.disabled.* so
  the client can render an inline grant-consent notice.
- Off-request pipelines fail closed quietly: comprehensive generation returns a
  typed skipped:no-consent outcome, and the per-metric status family
  (runStatusCompletion, gating every status card + the period-narrative warm +
  the off-budget Coach memory workers) surfaces the no-key fallback instead of
  egressing.

Also rate-limit POST /api/fitbit/sync (the one Fitbit route without a limiter):
a 5/60s baseline plus a tighter 1/hour bucket on the expensive fullSync walk,
keyed by user id, matching the sibling Fitbit routes.

Harden the Fitbit OAuth redirect_uri: assert it is an absolute https origin (or
http on localhost), lands on /api/fitbit/callback, and stays same-origin with
NEXT_PUBLIC_APP_URL — defence-in-depth behind Google's registered-URI check so
a Host-coerced or misconfigured app URL cannot redirect the authorization code
off-origin.

(cherry picked from commit 46b5fc2cea1b6e80bee0445bfdacf5790442c67d)
2026-06-05 03:01:36 +02:00

121 lines
4.9 KiB
TypeScript

/**
* v1.12.1 — server-side consent gate before external-LLM PHI egress.
*
* The `ConsentReceipt` infrastructure (`latestActiveReceipt`,
* `ai_full | ai_insights_only | ai_coach`, append-only audit trail) was built
* but never enforced as a precondition: a direct API caller with a valid
* bearer/cookie could trigger external-LLM processing of their own health
* snapshot with no receipt on file. Consent was enforced client-side only.
*
* This guard closes that gap. Before the first external-provider call on a
* SERVER-MANAGED path — i.e. the operator's global OpenAI key resolved via
* `resolveAdminProvider` and tagged `admin-openai` in the chain — an active,
* non-revoked receipt is required for the calling user.
*
* Scope, deliberately narrow (per the audit's fix sketch + the launch task):
* - GATED: `admin-openai` — the operator's global key forwards the user's
* metrics to a third-party processor the user did not contract
* with directly. This is the egress consent law cares about.
* - UNGATED: a user's own BYOK key (`openai` / `anthropic`), their own
* ChatGPT OAuth account (`codex`), and the self-hosted `local`
* provider. These are the user's own egress to a processor they
* chose; the existing settings flow is the consent act there.
*
* The check fails CLOSED: a chain that COULD egress via the server-managed
* key requires a receipt even when a BYOK provider sits ahead of it in the
* chain, because the runner may cascade to the admin key on a primary
* failure and we must not race that decision. A receipt of the surface's
* mapped kind OR the superset `ai_full` grant satisfies the gate.
*/
import { latestActiveReceipt } from "@/lib/consent/receipts";
import type { ConsentKind } from "@/lib/validations/consent";
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
/**
* The two AI surfaces that egress PHI. Each maps to the consent kind the iOS
* client collects for it; `ai_full` (the master grant) satisfies either.
*/
export type ConsentSurface = "coach" | "insights";
/** Provider tags that egress via the operator's server-managed/global key. */
const SERVER_MANAGED_PROVIDER_TYPES: ReadonlySet<string> = new Set([
"admin-openai",
]);
/**
* Error thrown when an external-LLM egress on a server-managed key is
* attempted without an active consent receipt. Mirrors `AssistantDisabledError`
* so the api-handler renders the same 403 envelope shape the iOS client
* already branches on:
*
* { data: null, error: "...", meta: { errorCode: "consent.ai.required" } }
*/
export class ConsentRequiredError extends Error {
readonly errorCode = "consent.ai.required" as const;
readonly surface: ConsentSurface;
constructor(surface: ConsentSurface) {
super(
`Active AI consent is required before processing health data on the server-managed provider (surface: ${surface})`,
);
this.name = "ConsentRequiredError";
this.surface = surface;
}
}
/**
* True when the resolved provider chain contains at least one entry that
* would egress via the operator's server-managed/global key. BYOK + local +
* the user's own ChatGPT OAuth never trip this.
*/
export function chainRequiresServerManagedConsent(
chain: ReadonlyArray<ProviderChainResolved>,
): boolean {
return chain.some((entry) =>
SERVER_MANAGED_PROVIDER_TYPES.has(entry.providerType),
);
}
/** The consent kinds that satisfy a given surface (the specific + the master). */
function acceptableKinds(surface: ConsentSurface): ConsentKind[] {
return surface === "coach"
? ["ai_coach", "ai_full"]
: ["ai_insights_only", "ai_full"];
}
/**
* Return whether the user has an active receipt that satisfies `surface`.
* Reads the specific kind first, then the master `ai_full` grant — at most
* two indexed point-reads, short-circuiting on the first hit.
*/
export async function hasActiveConsentForSurface(
userId: string,
surface: ConsentSurface,
): Promise<boolean> {
for (const kind of acceptableKinds(surface)) {
if (await latestActiveReceipt(userId, kind)) return true;
}
return false;
}
/**
* Enforce the consent precondition for a resolved provider chain.
*
* No-op when the chain has no server-managed entry (pure BYOK / local / codex
* egress is the user's own act and stays ungated). When the chain COULD egress
* via the operator's key, throw `ConsentRequiredError` unless an active
* receipt of the surface's mapped kind (or `ai_full`) is on file.
*
* Call this AFTER the chain is resolved and BEFORE the first
* `runRawCompletionWithFallback` / `runWithFallback` call.
*/
export async function assertConsentForChain(args: {
userId: string;
chain: ReadonlyArray<ProviderChainResolved>;
surface: ConsentSurface;
}): Promise<void> {
if (!chainRequiresServerManagedConsent(args.chain)) return;
if (await hasActiveConsentForSurface(args.userId, args.surface)) return;
throw new ConsentRequiredError(args.surface);
}