Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/app/api/answer-feedback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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),
Expand All @@ -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,
Expand Down
12 changes: 7 additions & 5 deletions src/app/api/answer/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -42,6 +43,8 @@ const answerSchema = z.object({
});

type AnswerRequestBody = z.infer<typeof answerSchema>;
const emptyScopeAnswer =
"The selected filters did not match any indexed documents, so I cannot generate an answer for that scope.";

function answerDegradedModeSignal(answer?: Pick<RagAnswer, "degradedMode" | "answerQualityTier" | "fallbackReason">) {
if (answer?.degradedMode) return answer.degradedMode;
Expand Down Expand Up @@ -105,16 +108,15 @@ 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: [],
sources: [],
degradedMode: answerDegradedModeSignal(),
scope: { ...scope, queryMode: answerBody.queryMode },
sourceGovernanceWarnings: sourceGovernanceWarnings({ results: [] }),
interactionId,
...answerFeedbackMetadata(interactionId, emptyScopeAnswer),
});
}

Expand Down Expand Up @@ -169,7 +171,7 @@ export async function POST(request: Request) {
degradedMode: answerDegradedModeSignal(answer),
scope: { ...scope, queryMode: answerBody.queryMode },
sourceGovernanceWarnings: warnings,
interactionId,
...answerFeedbackMetadata(interactionId, sourceGovernanceRefusalAnswer),
});
}

Expand All @@ -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,
);
Expand Down
16 changes: 11 additions & 5 deletions src/app/api/answer/stream/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -46,6 +47,8 @@ const answerSchema = z.object({
});

type AnswerBody = z.infer<typeof answerSchema>;
const emptyScopeAnswer =
"The selected filters did not match any indexed documents, so I cannot generate an answer for that scope.";

function answerDegradedModeSignal(answer?: Pick<RagAnswer, "degradedMode" | "answerQualityTier" | "fallbackReason">) {
if (answer?.degradedMode) return answer.degradedMode;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -185,16 +192,15 @@ 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: [],
sources: [],
degradedMode: answerDegradedModeSignal(),
scope: { ...scope, queryMode: body.queryMode },
sourceGovernanceWarnings: sourceGovernanceWarnings({ results: [] }),
interactionId,
...answerFeedbackMetadata(interactionId, emptyScopeAnswer),
});
return;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -2636,6 +2636,7 @@ export function ClinicalDashboard({
},
body: JSON.stringify({
interactionId: answer.interactionId,
feedbackToken: answer.feedbackToken,
feedbackCategory: feedbackType,
answerHash,
sourceIds: sourceChunkIds,
Expand Down
102 changes: 102 additions & 0 deletions src/lib/answer-feedback-token.ts
Original file line number Diff line number Diff line change
@@ -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<AnswerFeedbackTokenClaims>;
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;
}
}
1 change: 1 addition & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,7 @@ export type ComparisonMatrix = {

export type RagAnswer = {
interactionId?: string;
feedbackToken?: string;
answer: string;
grounded: boolean;
confidence: "high" | "medium" | "low" | "unsupported";
Expand Down
53 changes: 53 additions & 0 deletions tests/answer-feedback-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -37,6 +40,7 @@ describe("answer feedback route", () => {
interactionId,
feedbackCategory: "needs_correction",
answerHash: "a".repeat(64),
feedbackToken: "signed-feedback-token",
sourceIds: [sourceId],
citedSourceIds: [sourceId],
}),
Expand Down Expand Up @@ -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(
Expand All @@ -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();
});
});
Loading