diff --git a/src/app/api/answer-feedback/route.ts b/src/app/api/answer-feedback/route.ts index 8bfb8fbd3..c11f1f5ce 100644 --- a/src/app/api/answer-feedback/route.ts +++ b/src/app/api/answer-feedback/route.ts @@ -11,6 +11,7 @@ import { publicAccessContext } from "@/lib/public-api-access"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { parseJsonBody } from "@/lib/validation/body"; +import { verifyAnswerFeedbackToken } from "@/lib/answer-feedback-token"; export const runtime = "nodejs"; @@ -29,6 +30,7 @@ const bodySchema = z "outdated_guidance", ]), answerHash: z.string().regex(/^[a-f0-9]{64}$/), + feedbackToken: z.string().trim().min(1).max(1024), citedSourceIds: z.array(uuid).max(80).optional().default([]), sourceIds: z.array(uuid).max(80).optional().default([]), route: z.string().trim().max(100).nullable().optional().default(null), @@ -52,6 +54,18 @@ export async function POST(request: Request) { }); if (rateLimit.limited) return rateLimitJsonResponse("Too many feedback requests. Retry shortly.", rateLimit); + if ( + !verifyAnswerFeedbackToken({ + token: body.feedbackToken, + interactionId: body.interactionId, + answerHash: body.answerHash, + }) + ) { + throw new PublicApiError("Answer feedback could not be verified. Run the question again.", 400, { + code: "invalid_feedback_token", + }); + } + const { error } = await supabase.from("rag_answer_feedback").insert({ interaction_id: body.interactionId, owner_id: access.ownerId ?? null, diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index c7c5e58b2..4cdb7610d 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -30,6 +30,7 @@ import { logAnswerDiagnostics } from "@/lib/answer-telemetry"; import { nonProductionSupabaseDemoFallbackReason } from "@/lib/supabase/errors"; import * as serverAuth from "@/lib/supabase/auth"; import type { RagAnswer } from "@/lib/types"; +import { answerFeedbackMetadata } from "@/lib/answer-feedback-token"; export const runtime = "nodejs"; @@ -42,6 +43,8 @@ const answerSchema = z.object({ }); type AnswerRequestBody = z.infer; +const emptyScopeAnswer = + "The selected filters did not match any indexed documents, so I cannot generate an answer for that scope."; function answerDegradedModeSignal(answer?: Pick) { if (answer?.degradedMode) return answer.degradedMode; @@ -105,8 +108,7 @@ export async function POST(request: Request) { }); if (scope.documentIds?.length === 0) { return NextResponse.json({ - answer: - "The selected filters did not match any indexed documents, so I cannot generate an answer for that scope.", + answer: emptyScopeAnswer, grounded: false, confidence: "unsupported", citations: [], @@ -114,7 +116,7 @@ export async function POST(request: Request) { degradedMode: answerDegradedModeSignal(), scope: { ...scope, queryMode: answerBody.queryMode }, sourceGovernanceWarnings: sourceGovernanceWarnings({ results: [] }), - interactionId, + ...answerFeedbackMetadata(interactionId, emptyScopeAnswer), }); } @@ -169,7 +171,7 @@ export async function POST(request: Request) { degradedMode: answerDegradedModeSignal(answer), scope: { ...scope, queryMode: answerBody.queryMode }, sourceGovernanceWarnings: warnings, - interactionId, + ...answerFeedbackMetadata(interactionId, sourceGovernanceRefusalAnswer), }); } @@ -187,7 +189,7 @@ export async function POST(request: Request) { degradedMode: answerDegradedModeSignal(answer), scope: { ...scope, queryMode: answerBody.queryMode }, sourceGovernanceWarnings: warnings, - interactionId, + ...answerFeedbackMetadata(interactionId, answer.answer), }, serverTiming ? { headers: { "Server-Timing": serverTiming } } : undefined, ); diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index 697212928..6bf8ff972 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -34,6 +34,7 @@ import { startSseHeartbeat } from "@/lib/sse-heartbeat"; import { parseJsonBody } from "@/lib/validation/body"; import { toPublicAnswerProgressEvent } from "@/lib/answer-progress-public"; import type { RagAnswer } from "@/lib/types"; +import { answerFeedbackMetadata } from "@/lib/answer-feedback-token"; export const runtime = "nodejs"; @@ -46,6 +47,8 @@ const answerSchema = z.object({ }); type AnswerBody = z.infer; +const emptyScopeAnswer = + "The selected filters did not match any indexed documents, so I cannot generate an answer for that scope."; function answerDegradedModeSignal(answer?: Pick) { if (answer?.degradedMode) return answer.degradedMode; @@ -99,6 +102,10 @@ function streamErrorPayload(error: unknown) { }; } +function streamAnswerFeedbackMetadata(interactionId: string, answer: string) { + return isDemoMode() ? { interactionId } : answerFeedbackMetadata(interactionId, answer); +} + function logStreamError(error: unknown, signal?: AbortSignal) { logger.error("Search stream failed", safeErrorLogDetails(error)); // Report only server-fault failures: client aborts (Stop button / watchdog) and @@ -185,8 +192,7 @@ function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signa sendProgress({ stage: "retrieving" }); if (scope?.documentIds?.length === 0) { sendFinal({ - answer: - "The selected filters did not match any indexed documents, so I cannot generate an answer for that scope.", + answer: emptyScopeAnswer, grounded: false, confidence: "unsupported", citations: [], @@ -194,7 +200,7 @@ function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signa degradedMode: answerDegradedModeSignal(), scope: { ...scope, queryMode: body.queryMode }, sourceGovernanceWarnings: sourceGovernanceWarnings({ results: [] }), - interactionId, + ...answerFeedbackMetadata(interactionId, emptyScopeAnswer), }); return; } @@ -252,7 +258,7 @@ function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signa degradedMode: answerDegradedModeSignal(answer), scope: scope ? { ...scope, queryMode: body.queryMode } : undefined, sourceGovernanceWarnings: warnings, - interactionId, + ...streamAnswerFeedbackMetadata(interactionId, sourceGovernanceRefusalAnswer), }); return; } @@ -268,7 +274,7 @@ function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signa degradedMode: answerDegradedModeSignal(answer), scope: scope ? { ...scope, queryMode: body.queryMode } : undefined, sourceGovernanceWarnings: warnings, - interactionId, + ...streamAnswerFeedbackMetadata(interactionId, answer.answer), }); } catch (error) { // Parity with /api/answer (PR #315): outside production, a misconfigured diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index c8ae79525..59556b204 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -2617,7 +2617,7 @@ export function ClinicalDashboard({ setActionNotice({ tone: "warning", message: "Answer review is unavailable for synthetic demo answers." }); return; } - if (!answer.interactionId) { + if (!answer.interactionId || !answer.feedbackToken) { setActionNotice({ tone: "warning", message: "This answer predates traceable feedback. Run the question again." }); return; } @@ -2636,6 +2636,7 @@ export function ClinicalDashboard({ }, body: JSON.stringify({ interactionId: answer.interactionId, + feedbackToken: answer.feedbackToken, feedbackCategory: feedbackType, answerHash, sourceIds: sourceChunkIds, diff --git a/src/lib/answer-feedback-token.ts b/src/lib/answer-feedback-token.ts new file mode 100644 index 000000000..0561be2d3 --- /dev/null +++ b/src/lib/answer-feedback-token.ts @@ -0,0 +1,102 @@ +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; +import { env } from "@/lib/env"; + +const tokenVersion = 1; +const tokenLifetimeMs = 24 * 60 * 60 * 1000; +const clockSkewMs = 5 * 60 * 1000; +const signingContext = "clinical-kb:answer-feedback:v1"; +const answerHashPattern = /^[a-f0-9]{64}$/; + +type AnswerFeedbackTokenClaims = { + v: typeof tokenVersion; + interactionId: string; + answerHash: string; + issuedAt: number; + expiresAt: number; +}; + +function signingSecret() { + return env.RAG_QUERY_HASH_SECRET; +} + +function signatureFor(encodedClaims: string, secret: string) { + return createHmac("sha256", secret).update(`${signingContext}.${encodedClaims}`).digest("base64url"); +} + +function safeSignatureMatch(expected: string, received: string) { + const expectedBytes = Buffer.from(expected, "utf8"); + const receivedBytes = Buffer.from(received, "utf8"); + return expectedBytes.length === receivedBytes.length && timingSafeEqual(expectedBytes, receivedBytes); +} + +export function hashAnswerForFeedback(answer: string) { + return createHash("sha256").update(answer, "utf8").digest("hex"); +} + +export function createAnswerFeedbackToken({ + interactionId, + answer, + now = Date.now(), +}: { + interactionId: string; + answer: string; + now?: number; +}) { + const secret = signingSecret(); + if (!secret) return undefined; + + const claims: AnswerFeedbackTokenClaims = { + v: tokenVersion, + interactionId, + answerHash: hashAnswerForFeedback(answer), + issuedAt: now, + expiresAt: now + tokenLifetimeMs, + }; + const encodedClaims = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url"); + return `${encodedClaims}.${signatureFor(encodedClaims, secret)}`; +} + +export function answerFeedbackMetadata(interactionId: string, answer: string) { + const feedbackToken = createAnswerFeedbackToken({ interactionId, answer }); + return feedbackToken ? { interactionId, feedbackToken } : { interactionId }; +} + +export function verifyAnswerFeedbackToken({ + token, + interactionId, + answerHash, + now = Date.now(), +}: { + token: string; + interactionId: string; + answerHash: string; + now?: number; +}) { + const secret = signingSecret(); + if (!secret || !answerHashPattern.test(answerHash)) return false; + + const parts = token.split("."); + if (parts.length !== 2) return false; + const [encodedClaims, receivedSignature] = parts; + if (!encodedClaims || !receivedSignature) return false; + if (!safeSignatureMatch(signatureFor(encodedClaims, secret), receivedSignature)) return false; + + try { + const claims = JSON.parse( + Buffer.from(encodedClaims, "base64url").toString("utf8"), + ) as Partial; + if ( + claims.v !== tokenVersion || + claims.interactionId !== interactionId || + claims.answerHash !== answerHash || + typeof claims.issuedAt !== "number" || + typeof claims.expiresAt !== "number" + ) { + return false; + } + if (claims.issuedAt > now + clockSkewMs || claims.expiresAt <= now) return false; + return claims.expiresAt - claims.issuedAt === tokenLifetimeMs; + } catch { + return false; + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 6d7bf05e7..af1997813 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -914,6 +914,7 @@ export type ComparisonMatrix = { export type RagAnswer = { interactionId?: string; + feedbackToken?: string; answer: string; grounded: boolean; confidence: "high" | "medium" | "low" | "unsupported"; diff --git a/tests/answer-feedback-route.test.ts b/tests/answer-feedback-route.test.ts index 71a34bae0..64fa1575c 100644 --- a/tests/answer-feedback-route.test.ts +++ b/tests/answer-feedback-route.test.ts @@ -27,6 +27,9 @@ describe("answer feedback route", () => { consumeSubjectApiRateLimit: vi.fn(async () => ({ limited: false })), rateLimitJsonResponse: vi.fn(), })); + vi.doMock("@/lib/answer-feedback-token", () => ({ + verifyAnswerFeedbackToken: vi.fn(() => true), + })); const { POST } = await import("../src/app/api/answer-feedback/route"); const response = await POST( @@ -37,6 +40,7 @@ describe("answer feedback route", () => { interactionId, feedbackCategory: "needs_correction", answerHash: "a".repeat(64), + feedbackToken: "signed-feedback-token", sourceIds: [sourceId], citedSourceIds: [sourceId], }), @@ -72,6 +76,9 @@ describe("answer feedback route", () => { consumeSubjectApiRateLimit: vi.fn(async () => ({ limited: false })), rateLimitJsonResponse: vi.fn(), })); + vi.doMock("@/lib/answer-feedback-token", () => ({ + verifyAnswerFeedbackToken: vi.fn(() => true), + })); const { POST } = await import("../src/app/api/answer-feedback/route"); const response = await POST( @@ -82,10 +89,56 @@ describe("answer feedback route", () => { interactionId, feedbackCategory: "verified", answerHash: "b".repeat(64), + feedbackToken: "signed-feedback-token", }), }), ); expect(response.status).toBe(409); }); + + it("rejects fabricated interaction IDs before inserting feedback", async () => { + const insert = vi.fn(async () => ({ error: null })); + const verifyAnswerFeedbackToken = vi.fn(() => false); + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: () => ({ from: vi.fn(() => ({ insert })) }), + })); + vi.doMock("@/lib/public-api-access", () => ({ + publicAccessContext: vi.fn(async () => ({ + authenticated: false, + ownerId: undefined, + rateLimitSubject: { kind: "anonymous", subjectKey: "anon:test" }, + })), + })); + vi.doMock("@/lib/api-rate-limit", () => ({ + allowRateLimitInMemoryFallbackOnUnavailable: () => true, + consumeSubjectApiRateLimit: vi.fn(async () => ({ limited: false })), + rateLimitJsonResponse: vi.fn(), + })); + vi.doMock("@/lib/answer-feedback-token", () => ({ verifyAnswerFeedbackToken })); + + const { POST } = await import("../src/app/api/answer-feedback/route"); + const response = await POST( + new Request("http://localhost/api/answer-feedback", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + interactionId, + feedbackCategory: "verified", + answerHash: "c".repeat(64), + feedbackToken: "fabricated-feedback-token", + }), + }), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ code: "invalid_feedback_token" }); + expect(verifyAnswerFeedbackToken).toHaveBeenCalledWith({ + token: "fabricated-feedback-token", + interactionId, + answerHash: "c".repeat(64), + }); + expect(insert).not.toHaveBeenCalled(); + }); }); diff --git a/tests/answer-feedback-token.test.ts b/tests/answer-feedback-token.test.ts new file mode 100644 index 000000000..8da431699 --- /dev/null +++ b/tests/answer-feedback-token.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const interactionId = "11111111-1111-4111-8111-111111111111"; +const issuedAt = Date.UTC(2026, 6, 14, 0, 0, 0); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +async function tokenModule() { + vi.stubEnv("RAG_QUERY_HASH_SECRET", "test-answer-feedback-secret-at-least-16-chars"); + return import("../src/lib/answer-feedback-token"); +} + +describe("answer feedback tokens", () => { + it("binds a signed token to the interaction and exact answer hash", async () => { + const { createAnswerFeedbackToken, hashAnswerForFeedback, verifyAnswerFeedbackToken } = await tokenModule(); + const answer = "Use the cited monitoring schedule."; + const answerHash = hashAnswerForFeedback(answer); + const token = createAnswerFeedbackToken({ interactionId, answer, now: issuedAt }); + + expect(token).toBeTypeOf("string"); + expect(verifyAnswerFeedbackToken({ token: token!, interactionId, answerHash, now: issuedAt + 60_000 })).toBe(true); + expect( + verifyAnswerFeedbackToken({ + token: token!, + interactionId: "22222222-2222-4222-8222-222222222222", + answerHash, + now: issuedAt + 60_000, + }), + ).toBe(false); + expect( + verifyAnswerFeedbackToken({ token: token!, interactionId, answerHash: "f".repeat(64), now: issuedAt + 60_000 }), + ).toBe(false); + }); + + it("rejects tampered and expired tokens", async () => { + const { createAnswerFeedbackToken, hashAnswerForFeedback, verifyAnswerFeedbackToken } = await tokenModule(); + const answer = "Use the cited monitoring schedule."; + const answerHash = hashAnswerForFeedback(answer); + const token = createAnswerFeedbackToken({ interactionId, answer, now: issuedAt })!; + const tampered = `${token.slice(0, -1)}${token.endsWith("a") ? "b" : "a"}`; + + expect(verifyAnswerFeedbackToken({ token: tampered, interactionId, answerHash, now: issuedAt + 60_000 })).toBe( + false, + ); + expect(verifyAnswerFeedbackToken({ token, interactionId, answerHash, now: issuedAt + 24 * 60 * 60 * 1000 })).toBe( + false, + ); + }); + + it("does not mint or accept tokens without the server signing secret", async () => { + vi.stubEnv("RAG_QUERY_HASH_SECRET", undefined); + const { createAnswerFeedbackToken, hashAnswerForFeedback, verifyAnswerFeedbackToken } = + await import("../src/lib/answer-feedback-token"); + const answerHash = hashAnswerForFeedback("answer"); + + expect(createAnswerFeedbackToken({ interactionId, answer: "answer", now: issuedAt })).toBeUndefined(); + expect(verifyAnswerFeedbackToken({ token: "payload.signature", interactionId, answerHash, now: issuedAt })).toBe( + false, + ); + }); +}); diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index bf060e9f2..f67cd85d0 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -308,6 +308,7 @@ function mockRuntime( RAG_ANSWER_CACHE_TTL_MS: 0, RAG_ANSWER_CACHE_SIZE: 0, RAG_AWAIT_QUERY_LOGS: false, + RAG_QUERY_HASH_SECRET: "test-query-hash-secret-at-least-16-chars", // A key is present and provider mode is "auto" by default, so retrieval uses the online // embedding/hybrid path; tests can override to exercise the source-only path. OPENAI_API_KEY: options.openAiKey ?? "sk-test", @@ -3303,6 +3304,10 @@ describe("private document API access", () => { expect(searchResponse.status).toBe(200); expect(answerResponse.status).toBe(200); + expect(await payload(answerResponse)).toMatchObject({ + interactionId: expect.any(String), + feedbackToken: expect.any(String), + }); expect(searchChunksWithTelemetry).toHaveBeenCalledWith( expect.objectContaining({ ownerId: undefined, allowGlobalSearch: true }), ); @@ -3954,6 +3959,7 @@ describe("private document API access", () => { expect(response.status).toBe(200); expectSingleCompletionBeforeFinal(body); expect(ssePayload(body, "final")).toMatchObject({ demoMode: true }); + expect(ssePayload(body, "final")).not.toHaveProperty("feedbackToken"); expect(answerQuestionWithScope).not.toHaveBeenCalled(); }); @@ -3990,6 +3996,10 @@ describe("private document API access", () => { expect(body).toContain('"message":"Loading a recent cited answer."'); expect(body).not.toContain("private-cache"); expectSingleCompletionBeforeFinal(body); + expect(ssePayload(body, "final")).toMatchObject({ + interactionId: expect.any(String), + feedbackToken: expect.any(String), + }); }); it("emits a structured SSE error when authenticated streaming answers are rate limited", async () => {