From 37e9f32f567af533f31329d5d4a6894266583851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 02:42:05 +0200 Subject: [PATCH] fix(ai): require active consent before external-provider egress; rate-limit and harden Fitbit sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/api/fitbit/sync/route.ts | 33 +++- src/app/api/insights/chat/route.ts | 8 + .../insights/generate/__tests__/route.test.ts | 8 + src/app/api/insights/generate/route.ts | 7 + src/lib/ai/__tests__/consent-guard.test.ts | 167 ++++++++++++++++++ src/lib/ai/coach/conversation-summary.ts | 1 + src/lib/ai/coach/facts.ts | 1 + src/lib/ai/consent-guard.ts | 120 +++++++++++++ src/lib/api-handler.ts | 16 ++ src/lib/fitbit/__tests__/client.test.ts | 62 ++++++- src/lib/fitbit/client.ts | 73 +++++++- src/lib/insights/blood-pressure-status.ts | 1 + src/lib/insights/bmi-status.ts | 1 + src/lib/insights/comprehensive-generate.ts | 15 +- src/lib/insights/general-status.ts | 1 + .../insights/medication-compliance-status.ts | 1 + src/lib/insights/metric-status.ts | 1 + src/lib/insights/mood-status.ts | 1 + .../narrative/period-narrative-generate.ts | 1 + src/lib/insights/pulse-status.ts | 1 + src/lib/insights/status-provider.ts | 33 ++++ src/lib/insights/weight-status.ts | 1 + 22 files changed, 546 insertions(+), 7 deletions(-) create mode 100644 src/lib/ai/__tests__/consent-guard.test.ts create mode 100644 src/lib/ai/consent-guard.ts diff --git a/src/app/api/fitbit/sync/route.ts b/src/app/api/fitbit/sync/route.ts index e99c8873f..d37696252 100644 --- a/src/app/api/fitbit/sync/route.ts +++ b/src/app/api/fitbit/sync/route.ts @@ -1,5 +1,6 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { syncUserFitbit } from "@/lib/fitbit/sync"; import { NextRequest } from "next/server"; @@ -9,11 +10,25 @@ import { NextRequest } from "next/server"; * * Mirrors the WHOOP manual-sync route: incremental by default, full history when * `{ fullSync: true }` is posted. + * + * v1.12.1 — rate-limited (M-1). Every sibling Fitbit route (connect / test / + * resume) is limited; this one was the outlier, while `{ fullSync: true }` + * drives four paginated Google walkers (each capped at 1000 pages). A tight + * loop or a stolen native token could pin Prisma + the Google Health quota. + * A baseline 5/60s bucket (matching the test route) gates the route, and the + * expensive `fullSync` path carries a tighter 1/hour bucket of its own. */ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "fitbit.sync" } }); + const rl = await checkRateLimit(`fitbit-sync:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many sync requests", 429, { + errorCode: "rate_limited_self", + }); + } + let fullSync = false; try { const body = await request.json(); @@ -22,6 +37,22 @@ export const POST = apiHandler(async (request: NextRequest) => { // no body provided -> default incremental sync } + // The full-history walk is the expensive path (four paginated resource + // walkers); cap it well below the incremental bucket so a re-trigger loop + // cannot churn the Google quota. + if (fullSync) { + const fullRl = await checkRateLimit( + `fitbit-sync-full:${user.id}`, + 1, + 60 * 60_000, + ); + if (!fullRl.allowed) { + return apiError("Full sync is limited to once per hour", 429, { + errorCode: "rate_limited_self", + }); + } + } + const imported = await syncUserFitbit(user.id, { fullSync }); return apiSuccess({ imported, fullSync }); }); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 4a944a0d7..85e2aff5e 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -42,6 +42,7 @@ import { runRawCompletionWithFallback, } from "@/lib/ai/provider-runner"; import { resolveProviderChain, resolveProvider } from "@/lib/ai/provider"; +import { assertConsentForChain } from "@/lib/ai/consent-guard"; import type { CompletionResult } from "@/lib/ai/types"; import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator"; @@ -350,6 +351,13 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; chain.push({ providerType: "admin-openai", instance: legacy }); } + // v1.12.1 — consent gate before any server-managed external egress. When + // the chain could egress via the operator's global key, require an active + // `ai_coach` (or master `ai_full`) receipt. BYOK / local / ChatGPT-OAuth + // chains are the user's own egress and stay ungated. Throws + // ConsentRequiredError → apiHandler returns 403 + `consent.ai.required`. + await assertConsentForChain({ userId, chain, surface: "coach" }); + let result: CompletionResult; let workingProviderType: string; try { diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index abf9f5669..b9fa0b3e0 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -49,6 +49,14 @@ vi.mock("@/lib/db", () => ({ // the cache-invalidation test can assert against it. deleteMany: vi.fn(async () => ({ count: 0 })), }, + // v1.12.1 — the server-managed consent gate reads the latest active + // receipt before egress. These fixtures resolve to an admin-openai + // chain, so the gate runs; return an active receipt so the generation + // path under test proceeds. The gate's own behaviour is covered in + // `consent-guard.test.ts`. + consentReceipt: { + findFirst: vi.fn(async () => ({ id: "receipt-1" })), + }, }, })); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index d2c8b9a7a..5929bba4b 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -33,6 +33,7 @@ import { AllProvidersFailedError, runRawCompletionWithFallback, } from "@/lib/ai/provider-runner"; +import { assertConsentForChain } from "@/lib/ai/consent-guard"; import { isLegacyInsightPayload } from "@/lib/ai/legacy-payload"; import { checkRateLimit } from "@/lib/rate-limit"; import { NextRequest } from "next/server"; @@ -372,6 +373,12 @@ export const POST = apiHandler(async (request: NextRequest) => { chain.push({ providerType: "admin-openai", instance: legacy }); } + // v1.12.1 — consent gate before any server-managed external egress. + // Required for the operator's global key (`admin-openai`); BYOK / local / + // ChatGPT-OAuth chains stay ungated. Throws ConsentRequiredError → + // apiHandler returns 403 + `consent.ai.required`. + await assertConsentForChain({ userId, chain, surface: "insights" }); + const includeRaw = dbUser?.insightsPrivacyMode === "raw"; // v1.4.36 W3 T1 — `extractFeatures` enforces a 5 MB ceiling on the // serialised payload. If the raw (bucketed) shape ever blows past diff --git a/src/lib/ai/__tests__/consent-guard.test.ts b/src/lib/ai/__tests__/consent-guard.test.ts new file mode 100644 index 000000000..76c7656f1 --- /dev/null +++ b/src/lib/ai/__tests__/consent-guard.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/consent/receipts", () => ({ + latestActiveReceipt: vi.fn(), +})); + +import { + ConsentRequiredError, + assertConsentForChain, + chainRequiresServerManagedConsent, + hasActiveConsentForSurface, +} from "../consent-guard"; +import { latestActiveReceipt } from "@/lib/consent/receipts"; +import type { ProviderChainResolved } from "../provider-runner"; +import type { ConsentKind } from "@/lib/validations/consent"; + +const mockedLatest = vi.mocked(latestActiveReceipt); + +/** Minimal chain entry — the gate only inspects `providerType`. */ +function entry(providerType: string): ProviderChainResolved { + return { + providerType: providerType as ProviderChainResolved["providerType"], + instance: { + type: providerType, + generateCompletion: vi.fn(), + } as unknown as ProviderChainResolved["instance"], + }; +} + +/** A receipt stub — only presence/absence matters to the gate. */ +const RECEIPT = { id: "r1" } as unknown as Awaited< + ReturnType +>; + +/** + * Drive `latestActiveReceipt` from a set of kinds the user is granted. + * Any kind in the set returns a receipt; everything else returns null. + */ +function grant(kinds: ConsentKind[]): void { + const set = new Set(kinds); + mockedLatest.mockImplementation(async (_userId: string, kind: ConsentKind) => + set.has(kind) ? RECEIPT : null, + ); +} + +describe("chainRequiresServerManagedConsent", () => { + it("is true only when the chain contains a server-managed (admin-openai) entry", () => { + expect(chainRequiresServerManagedConsent([entry("admin-openai")])).toBe( + true, + ); + expect( + chainRequiresServerManagedConsent([entry("openai"), entry("admin-openai")]), + ).toBe(true); + }); + + it("is false for pure BYOK / local / codex chains", () => { + expect(chainRequiresServerManagedConsent([entry("openai")])).toBe(false); + expect(chainRequiresServerManagedConsent([entry("anthropic")])).toBe(false); + expect(chainRequiresServerManagedConsent([entry("local")])).toBe(false); + expect(chainRequiresServerManagedConsent([entry("codex")])).toBe(false); + expect( + chainRequiresServerManagedConsent([entry("codex"), entry("local")]), + ).toBe(false); + }); +}); + +describe("hasActiveConsentForSurface", () => { + beforeEach(() => mockedLatest.mockReset()); + + it("accepts the surface-specific kind for coach", async () => { + grant(["ai_coach"]); + expect(await hasActiveConsentForSurface("u1", "coach")).toBe(true); + }); + + it("accepts the surface-specific kind for insights", async () => { + grant(["ai_insights_only"]); + expect(await hasActiveConsentForSurface("u1", "insights")).toBe(true); + }); + + it("accepts the master ai_full grant for either surface", async () => { + grant(["ai_full"]); + expect(await hasActiveConsentForSurface("u1", "coach")).toBe(true); + expect(await hasActiveConsentForSurface("u1", "insights")).toBe(true); + }); + + it("does NOT cross surfaces: insights-only does not satisfy coach", async () => { + grant(["ai_insights_only"]); + expect(await hasActiveConsentForSurface("u1", "coach")).toBe(false); + }); + + it("returns false when no receipt is on file", async () => { + grant([]); + expect(await hasActiveConsentForSurface("u1", "insights")).toBe(false); + }); +}); + +describe("assertConsentForChain", () => { + beforeEach(() => mockedLatest.mockReset()); + + it("throws ConsentRequiredError on a server-managed chain with no receipt", async () => { + grant([]); + await expect( + assertConsentForChain({ + userId: "u1", + chain: [entry("admin-openai")], + surface: "insights", + }), + ).rejects.toBeInstanceOf(ConsentRequiredError); + }); + + it("carries the locked errorCode for the iOS contract", async () => { + grant([]); + const err = await assertConsentForChain({ + userId: "u1", + chain: [entry("admin-openai")], + surface: "coach", + }).catch((e) => e); + expect(err).toBeInstanceOf(ConsentRequiredError); + expect((err as ConsentRequiredError).errorCode).toBe("consent.ai.required"); + }); + + it("proceeds (no throw) on a server-managed chain WITH an active receipt", async () => { + grant(["ai_coach"]); + await expect( + assertConsentForChain({ + userId: "u1", + chain: [entry("admin-openai")], + surface: "coach", + }), + ).resolves.toBeUndefined(); + }); + + it("never reads consent and never throws for a pure BYOK chain", async () => { + grant([]); + await expect( + assertConsentForChain({ + userId: "u1", + chain: [entry("openai")], + surface: "insights", + }), + ).resolves.toBeUndefined(); + expect(mockedLatest).not.toHaveBeenCalled(); + }); + + it("never reads consent and never throws for a pure local chain", async () => { + grant([]); + await expect( + assertConsentForChain({ + userId: "u1", + chain: [entry("local")], + surface: "insights", + }), + ).resolves.toBeUndefined(); + expect(mockedLatest).not.toHaveBeenCalled(); + }); + + it("STILL gates a BYOK-primary chain that has an admin-openai fallback (fail-closed)", async () => { + grant([]); + await expect( + assertConsentForChain({ + userId: "u1", + chain: [entry("openai"), entry("admin-openai")], + surface: "insights", + }), + ).rejects.toBeInstanceOf(ConsentRequiredError); + }); +}); diff --git a/src/lib/ai/coach/conversation-summary.ts b/src/lib/ai/coach/conversation-summary.ts index 06e28c4a8..eb806993a 100644 --- a/src/lib/ai/coach/conversation-summary.ts +++ b/src/lib/ai/coach/conversation-summary.ts @@ -192,6 +192,7 @@ export async function refreshConversationSummary( const completion: StatusProviderResult = await runCompletion({ userId, cacheAction: "coach.summary", + consentSurface: "coach", systemPrompt: locale === "de" ? SYSTEM_PROMPT_DE : SYSTEM_PROMPT_EN, userPrompt: buildSummaryUserPrompt(priorSummary, foldedTurns, locale), temperature: 0.3, diff --git a/src/lib/ai/coach/facts.ts b/src/lib/ai/coach/facts.ts index a1045d112..f1f237324 100644 --- a/src/lib/ai/coach/facts.ts +++ b/src/lib/ai/coach/facts.ts @@ -293,6 +293,7 @@ export async function extractAndStoreFacts( const result = await runCompletion({ userId, cacheAction: "coach.facts", + consentSurface: "coach", systemPrompt: extractionSystemPrompt(opts?.locale), userPrompt: buildUserPrompt(turns, activeTexts), temperature: 0.2, diff --git a/src/lib/ai/consent-guard.ts b/src/lib/ai/consent-guard.ts new file mode 100644 index 000000000..83f39c294 --- /dev/null +++ b/src/lib/ai/consent-guard.ts @@ -0,0 +1,120 @@ +/** + * 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 = 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, +): 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 { + 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; + surface: ConsentSurface; +}): Promise { + if (!chainRequiresServerManagedConsent(args.chain)) return; + if (await hasActiveConsentForSurface(args.userId, args.surface)) return; + throw new ConsentRequiredError(args.surface); +} diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index eb208a54c..65fd5cd0e 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -10,6 +10,7 @@ import { hashToken } from "./auth/hmac"; import { prisma } from "./db"; import { auditLog } from "./auth/audit"; import { AssistantDisabledError } from "./feature-flags"; +import { ConsentRequiredError } from "./ai/consent-guard"; /** * Custom error class for HTTP errors with status codes. @@ -180,6 +181,21 @@ export function apiHandler Promise>( }, { status: 403 }, ); + } else if (error instanceof ConsentRequiredError) { + // v1.12.1 — server-side consent gate before external-LLM PHI + // egress on the operator's server-managed key. Mirrors the + // AssistantDisabledError envelope (403 + meta.errorCode) so the + // iOS client renders an inline "grant consent" notice instead of + // a generic failure. + evt.setError(error); + response = NextResponse.json( + { + data: null, + error: error.message, + meta: { errorCode: error.errorCode }, + }, + { status: 403 }, + ); } else if (error instanceof HttpError) { evt.setError(error); response = NextResponse.json( diff --git a/src/lib/fitbit/__tests__/client.test.ts b/src/lib/fitbit/__tests__/client.test.ts index 757008c4a..38b54aeeb 100644 --- a/src/lib/fitbit/__tests__/client.test.ts +++ b/src/lib/fitbit/__tests__/client.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { FITBIT_API_BASE, FITBIT_DATA_TYPES, @@ -44,7 +44,16 @@ function installFetchMock(pages: Array<{ status: number; body: unknown }>) { return fetchMock; } +// v1.12.1 — the redirect_uri allowlist (M-5) requires a configured, absolute +// https app origin. Pin one for the OAuth-handshake cases. +const PRIOR_APP_URL = process.env.NEXT_PUBLIC_APP_URL; +beforeEach(() => { + process.env.NEXT_PUBLIC_APP_URL = "https://app.example.test"; +}); + afterEach(() => { + if (PRIOR_APP_URL === undefined) delete process.env.NEXT_PUBLIC_APP_URL; + else process.env.NEXT_PUBLIC_APP_URL = PRIOR_APP_URL; vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -82,6 +91,57 @@ describe("getAuthorizationUrl", () => { expect(FITBIT_OAUTH_SCOPE).not.toContain("nutrition"); expect(FITBIT_OAUTH_SCOPE).not.toContain("location"); }); + + it("carries the configured app origin in the redirect_uri", () => { + const url = getAuthorizationUrl("nonce123", CREDS); + const redirect = new URL(url).searchParams.get("redirect_uri"); + expect(redirect).toBe("https://app.example.test/api/fitbit/callback"); + }); +}); + +describe("redirect_uri allowlist (M-5)", () => { + const PRIOR_REDIRECT = process.env.FITBIT_REDIRECT_URI; + afterEach(() => { + if (PRIOR_REDIRECT === undefined) delete process.env.FITBIT_REDIRECT_URI; + else process.env.FITBIT_REDIRECT_URI = PRIOR_REDIRECT; + }); + + it("rejects a non-https app origin", () => { + process.env.NEXT_PUBLIC_APP_URL = "http://app.example.test"; + expect(() => getAuthorizationUrl("n", CREDS)).toThrow(/must be https/); + }); + + it("allows http on localhost (dev)", () => { + process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000"; + const url = getAuthorizationUrl("n", CREDS); + expect(new URL(url).searchParams.get("redirect_uri")).toBe( + "http://localhost:3000/api/fitbit/callback", + ); + }); + + it("rejects an explicit redirect that targets the wrong path", () => { + process.env.FITBIT_REDIRECT_URI = "https://app.example.test/evil"; + expect(() => getAuthorizationUrl("n", CREDS)).toThrow( + /must target \/api\/fitbit\/callback/, + ); + }); + + it("rejects an explicit redirect whose origin differs from NEXT_PUBLIC_APP_URL", () => { + process.env.NEXT_PUBLIC_APP_URL = "https://app.example.test"; + process.env.FITBIT_REDIRECT_URI = + "https://attacker.example.test/api/fitbit/callback"; + expect(() => getAuthorizationUrl("n", CREDS)).toThrow(/does not match/); + }); + + it("accepts an explicit same-origin redirect", () => { + process.env.NEXT_PUBLIC_APP_URL = "https://app.example.test"; + process.env.FITBIT_REDIRECT_URI = + "https://app.example.test/api/fitbit/callback"; + const url = getAuthorizationUrl("n", CREDS); + expect(new URL(url).searchParams.get("redirect_uri")).toBe( + "https://app.example.test/api/fitbit/callback", + ); + }); }); describe("token exchange + refresh", () => { diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts index ded8403fd..25c217140 100644 --- a/src/lib/fitbit/client.ts +++ b/src/lib/fitbit/client.ts @@ -34,11 +34,76 @@ export interface FitbitCredentials { clientSecret: string; } +/** + * Resolve the OAuth `redirect_uri`, then assert it against an allowlist + * (v1.12.1 — M-5, defence-in-depth). + * + * The value is operator-controlled config (`FITBIT_REDIRECT_URI`, else derived + * from `NEXT_PUBLIC_APP_URL`), not user input, so Google's registered-redirect + * check is the real backstop. But a misconfigured or `Host`-coerced + * `NEXT_PUBLIC_APP_URL` (a mis-deployed reverse proxy reflecting a forwarded + * Host) would otherwise send the authorization code's landing URL off-origin. + * Pin the target here so a malformed origin fails fast at the handshake rather + * than silently redirecting elsewhere: + * - must be an absolute, parseable URL, + * - must be https (the one exception is a localhost/loopback dev host, which + * Google itself permits over http), + * - must land on the fixed `/api/fitbit/callback` path, + * - when derived from `NEXT_PUBLIC_APP_URL`, must stay same-origin with it. + */ function getRedirectUri(): string { - return ( - process.env.FITBIT_REDIRECT_URI ?? - `${process.env.NEXT_PUBLIC_APP_URL}/api/fitbit/callback` - ); + const explicit = process.env.FITBIT_REDIRECT_URI; + const appUrl = process.env.NEXT_PUBLIC_APP_URL; + const raw = explicit ?? (appUrl ? `${appUrl}/api/fitbit/callback` : undefined); + + if (!raw) { + throw new Error( + "Fitbit redirect_uri is not configured — set FITBIT_REDIRECT_URI or NEXT_PUBLIC_APP_URL", + ); + } + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new Error(`Fitbit redirect_uri is not an absolute URL: ${raw}`); + } + + const isLoopback = + parsed.hostname === "localhost" || + parsed.hostname === "127.0.0.1" || + parsed.hostname === "[::1]" || + parsed.hostname === "::1"; + if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopback)) { + throw new Error( + `Fitbit redirect_uri must be https (or http on localhost): ${parsed.origin}`, + ); + } + + if (parsed.pathname !== "/api/fitbit/callback") { + throw new Error( + `Fitbit redirect_uri must target /api/fitbit/callback, got ${parsed.pathname}`, + ); + } + + // When an explicit FITBIT_REDIRECT_URI is set alongside NEXT_PUBLIC_APP_URL, + // require them to share an origin so the pinned value can't drift to an + // unexpected host relative to the app's own base URL. + if (explicit && appUrl) { + let appOrigin: string; + try { + appOrigin = new URL(appUrl).origin; + } catch { + throw new Error(`NEXT_PUBLIC_APP_URL is not an absolute URL: ${appUrl}`); + } + if (parsed.origin !== appOrigin) { + throw new Error( + `Fitbit redirect_uri origin ${parsed.origin} does not match NEXT_PUBLIC_APP_URL origin ${appOrigin}`, + ); + } + } + + return parsed.toString(); } /** diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index ea69fe31a..1eceae9ab 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -537,6 +537,7 @@ export async function generateBloodPressureStatusForUser( const outcome = await runStatusCompletion({ userId, cacheAction, + consentSurface: "insights", systemPrompt: getBloodPressureSystemPrompt(locale), userPrompt: getBloodPressureUserPrompt( snapshotJson, diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 93e9bae97..524de108d 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -241,6 +241,7 @@ export async function generateBmiStatusForUser( const outcome = await runStatusCompletion({ userId, cacheAction, + consentSurface: "insights", systemPrompt: getBmiSystemPrompt(locale), userPrompt: getBmiUserPrompt( snapshotJson, diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index e5d8a8d01..25681b7f9 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -46,6 +46,7 @@ import { AllProvidersFailedError, runRawCompletionWithFallback, } from "@/lib/ai/provider-runner"; +import { chainRequiresServerManagedConsent, hasActiveConsentForSurface } from "@/lib/ai/consent-guard"; import { invalidateUserInsights } from "@/lib/cache/invalidate"; import { enqueueStatusGeneration, @@ -100,7 +101,7 @@ const MAX_DOWNGRADE_TOKENS: ReadonlyArray = [ export type GenerateOutcome = | { status: "cached" } | { status: "generated"; providerType: string } - | { status: "skipped"; reason: "no-provider" } + | { status: "skipped"; reason: "no-provider" | "no-consent" } | { status: "failed"; reason: string }; /** @@ -464,6 +465,18 @@ export async function generateComprehensiveInsight( chain.push({ providerType: "admin-openai", instance: legacy }); } + // v1.12.1 — consent gate before any server-managed external egress. This + // pipeline runs off-request (nightly cron + on-demand force-warm), so a + // missing receipt is a typed `skipped` outcome rather than a throw — the + // cron batch continues to the next user. BYOK / local / ChatGPT-OAuth + // chains never trip the check. + if ( + chainRequiresServerManagedConsent(chain) && + !(await hasActiveConsentForSurface(userId, "insights")) + ) { + return { status: "skipped", reason: "no-consent" }; + } + const includeRaw = dbUser?.insightsPrivacyMode === "raw"; let features: Awaited>; try { diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 7ac091cd1..6f3a9b07f 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -362,6 +362,7 @@ export async function generateGeneralStatusForUser( const outcome = await runStatusCompletion({ userId, cacheAction, + consentSurface: "insights", systemPrompt: getGeneralStatusSystemPrompt(locale), userPrompt: getGeneralStatusUserPrompt( snapshotJson, diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 9bc4da863..420290613 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -353,6 +353,7 @@ export async function generateMedicationComplianceStatusForUser( const outcome = await runStatusCompletion({ userId, cacheAction, + consentSurface: "insights", systemPrompt: getMedicationComplianceSystemPrompt(locale), userPrompt: getMedicationComplianceUserPrompt( snapshotJson, diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 94ca0ca76..d0b94bf5c 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -318,6 +318,7 @@ export async function generateMetricStatus(args: { const outcome = await runStatusCompletion({ userId: args.userId, cacheAction, + consentSurface: "insights", systemPrompt: getMetricArchetypeSystemPrompt(meta, locale), userPrompt: getMetricArchetypeUserPrompt( meta, diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index aef2db266..2b71e3a49 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -459,6 +459,7 @@ export async function generateMoodStatusForUser( const outcome = await runStatusCompletion({ userId, cacheAction, + consentSurface: "insights", systemPrompt: getMoodSystemPrompt(locale), userPrompt: getMoodUserPrompt( snapshotJson, diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index f4589e6a8..d3a04e6d0 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -254,6 +254,7 @@ export async function generatePeriodNarrative( const completion = await runCompletion({ userId, cacheAction: `insights.narrative.${period}.${locale}`, + consentSurface: "insights", systemPrompt: locale === "de" ? SYSTEM_PROMPT_DE : SYSTEM_PROMPT_EN, userPrompt: buildNarrativeUserPrompt(context, locale), temperature: 0.3, diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index ebc4dea02..5885c83b7 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -284,6 +284,7 @@ export async function generatePulseStatusForUser( const outcome = await runStatusCompletion({ userId, cacheAction, + consentSurface: "insights", systemPrompt: getPulseSystemPrompt(locale), userPrompt: getPulseUserPrompt( snapshotJson, diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index b69706cb6..1c2d6ae1e 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -4,6 +4,11 @@ import { runRawCompletionWithFallback, type ProviderChainResolved, } from "@/lib/ai/provider-runner"; +import { + chainRequiresServerManagedConsent, + hasActiveConsentForSurface, + type ConsentSurface, +} from "@/lib/ai/consent-guard"; import { annotate } from "@/lib/logging/context"; import { STATUS_PROVIDER_TIMEOUT_MS, withTimeout } from "./with-timeout"; @@ -45,6 +50,16 @@ interface RunStatusCompletionArgs { userPrompt: string; temperature?: number; maxTokens?: number; + /** + * v1.12.1 — which AI surface this generation serves, for the consent gate. + * `insights` for the per-metric status cards + period narrative; `coach` + * for the off-budget Coach memory workers (rolling summary + fact + * extraction). When the resolved chain would egress via the operator's + * server-managed key and no active receipt of the matching kind exists, + * the run short-circuits to `{ kind: "none" }` (the no-key fallback) so no + * PHI leaves the server. BYOK / local / ChatGPT-OAuth chains are ungated. + */ + consentSurface: ConsentSurface; } /** @@ -97,6 +112,24 @@ export async function runStatusCompletion( return { kind: "none" }; } + // v1.12.1 — consent gate before server-managed external egress. A chain + // that could egress via the operator's global key requires an active + // receipt of the surface's mapped kind (or master `ai_full`). Without one, + // surface the no-key fallback (`none`) rather than egress the snapshot — + // identical to a missing provider from the caller's perspective, so no + // generator branch needs to change. BYOK / local / ChatGPT-OAuth chains + // never trip this. + if ( + chainRequiresServerManagedConsent(chain) && + !(await hasActiveConsentForSurface(userId, args.consentSurface)) + ) { + annotate({ + action: { name: "insights.status.consent_required" }, + meta: { cacheAction, surface: args.consentSurface }, + }); + return { kind: "none" }; + } + const raced = await withTimeout( () => runRawCompletionWithFallback({ diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index b76b71c0a..2c6be962c 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -436,6 +436,7 @@ export async function generateWeightStatusForUser( const outcome = await runStatusCompletion({ userId, cacheAction, + consentSurface: "insights", systemPrompt: getWeightSystemPrompt(locale), userPrompt: getWeightUserPrompt( snapshotJson,