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)
This commit is contained in:
Marc-André Bombeck committed 2026-06-05 03:01:36 +02:00
1 parent 3021d54068
commit 37e9f32f56
22 files changed
+546 -7

No files matched your search

+32 -1
View File
@@ -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 });
});
+8
View File
@@ -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 {
@@ -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" })),
},
},
}));
+7
View File
@@ -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
+167
View File
@@ -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<typeof latestActiveReceipt>
>;
/**
* 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);
});
});
+1
View File
@@ -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,
+1
View File
@@ -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,
+120
View File
@@ -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<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);
}
+16
View File
@@ -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<T extends (...args: any[]) => Promise<Response>>(
},
{ 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(
+61 -1
View File
@@ -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", () => {
+69 -4
View File
@@ -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();
}
/**
@@ -537,6 +537,7 @@ export async function generateBloodPressureStatusForUser(
const outcome = await runStatusCompletion({
userId,
cacheAction,
consentSurface: "insights",
systemPrompt: getBloodPressureSystemPrompt(locale),
userPrompt: getBloodPressureUserPrompt(
snapshotJson,
+1
View File
@@ -241,6 +241,7 @@ export async function generateBmiStatusForUser(
const outcome = await runStatusCompletion({
userId,
cacheAction,
consentSurface: "insights",
systemPrompt: getBmiSystemPrompt(locale),
userPrompt: getBmiUserPrompt(
snapshotJson,
+14 -1
View File
@@ -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<string> = [
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<ReturnType<typeof extractFeatures>>;
try {
+1
View File
@@ -362,6 +362,7 @@ export async function generateGeneralStatusForUser(
const outcome = await runStatusCompletion({
userId,
cacheAction,
consentSurface: "insights",
systemPrompt: getGeneralStatusSystemPrompt(locale),
userPrompt: getGeneralStatusUserPrompt(
snapshotJson,
@@ -353,6 +353,7 @@ export async function generateMedicationComplianceStatusForUser(
const outcome = await runStatusCompletion({
userId,
cacheAction,
consentSurface: "insights",
systemPrompt: getMedicationComplianceSystemPrompt(locale),
userPrompt: getMedicationComplianceUserPrompt(
snapshotJson,
+1
View File
@@ -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,
+1
View File
@@ -459,6 +459,7 @@ export async function generateMoodStatusForUser(
const outcome = await runStatusCompletion({
userId,
cacheAction,
consentSurface: "insights",
systemPrompt: getMoodSystemPrompt(locale),
userPrompt: getMoodUserPrompt(
snapshotJson,
@@ -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,
+1
View File
@@ -284,6 +284,7 @@ export async function generatePulseStatusForUser(
const outcome = await runStatusCompletion({
userId,
cacheAction,
consentSurface: "insights",
systemPrompt: getPulseSystemPrompt(locale),
userPrompt: getPulseUserPrompt(
snapshotJson,
+33
View File
@@ -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({
+1
View File
@@ -436,6 +436,7 @@ export async function generateWeightStatusForUser(
const outcome = await runStatusCompletion({
userId,
cacheAction,
consentSurface: "insights",
systemPrompt: getWeightSystemPrompt(locale),
userPrompt: getWeightUserPrompt(
snapshotJson,