mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
1 parent
3021d54068
commit
37e9f32f56
22 files changed
+546
-7
No files matched your search
@@ -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 });
|
||||
});
|
||||
@@ -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" })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -241,6 +241,7 @@ export async function generateBmiStatusForUser(
|
||||
const outcome = await runStatusCompletion({
|
||||
userId,
|
||||
cacheAction,
|
||||
consentSurface: "insights",
|
||||
systemPrompt: getBmiSystemPrompt(locale),
|
||||
userPrompt: getBmiUserPrompt(
|
||||
snapshotJson,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -284,6 +284,7 @@ export async function generatePulseStatusForUser(
|
||||
const outcome = await runStatusCompletion({
|
||||
userId,
|
||||
cacheAction,
|
||||
consentSurface: "insights",
|
||||
systemPrompt: getPulseSystemPrompt(locale),
|
||||
userPrompt: getPulseUserPrompt(
|
||||
snapshotJson,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -436,6 +436,7 @@ export async function generateWeightStatusForUser(
|
||||
const outcome = await runStatusCompletion({
|
||||
userId,
|
||||
cacheAction,
|
||||
consentSurface: "insights",
|
||||
systemPrompt: getWeightSystemPrompt(locale),
|
||||
userPrompt: getWeightUserPrompt(
|
||||
snapshotJson,
|
||||
|
||||
Reference in new issue
Block a user