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
4 changes: 2 additions & 2 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ const eslintConfig = defineConfig([
".tmp-visual/**",
"sample-documents/**",
"scratch/**",
".claude/**",
// Recursive `**/` variants cover nested worktrees; the bare globs above already
// ignore the repo-root dirs, so only the recursive forms are kept here.
"**/.claude/**",
".tmp-visual/**",
"**/.tmp-visual/**",
"next-env.d.ts",
]),
Expand Down
11 changes: 0 additions & 11 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"build": "node scripts/guard-next-build.mjs && node --max-old-space-size=8192 ./node_modules/next/dist/bin/next build --webpack && node scripts/check-client-bundle-secrets.mjs",
"build:analyze": "node scripts/build-analyze.mjs",
"start": "node scripts/dev-free-port.mjs start",
"lint": "node --max-old-space-size=8192 ./node_modules/eslint/bin/eslint.js src tests scripts worker supabase playwright eslint.config.mjs next.config.ts playwright.config.ts playwright.visual.config.ts vitest.config.mts --no-error-on-unmatched-pattern",
"lint": "node --max-old-space-size=8192 ./node_modules/eslint/bin/eslint.js src tests scripts worker supabase playwright eslint.config.mjs next.config.ts playwright.config.ts playwright.visual.config.ts vitest.config.mts --max-warnings 0 --no-error-on-unmatched-pattern",
"typecheck": "node ./node_modules/typescript/bin/tsc --noEmit",
"test": "node scripts/run-vitest.mjs run --reporter=dot",
"test:coverage": "node scripts/run-vitest.mjs run --coverage",
Expand Down Expand Up @@ -145,7 +145,6 @@
"@next/bundle-analyzer": "^16.2.10",
"@tailwindcss/postcss": "^4.3.2",
"@types/node": "^24.13.2",
"@types/pdf-parse": "^1.1.5",
"@types/pdfkit": "^0.17.6",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
Expand Down
13 changes: 1 addition & 12 deletions src/app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,10 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { env, isDemoMode } from "@/lib/env";
import { allowDeepHealthProbe } from "@/lib/deep-probe-auth";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

function allowDeepHealthProbe(request: Request): boolean {
const secret = env.HEALTH_DEEP_PROBE_SECRET;
if (!secret) return false;
const token = request.headers.get("x-health-deep-token");
if (!token) return false;
if (token.length !== secret.length) return false;
const expected = Buffer.from(secret, "utf8");
const received = Buffer.from(token, "utf8");
return timingSafeEqual(expected, received);
}

export async function GET(request: Request) {
const deep = new URL(request.url).searchParams.get("deep") === "1";
const supabaseConfigured = Boolean(env.NEXT_PUBLIC_SUPABASE_URL && env.SUPABASE_SERVICE_ROLE_KEY);
Expand Down
31 changes: 29 additions & 2 deletions src/app/api/setup-status/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { env, isDemoMode } from "@/lib/env";
import { env, isDemoMode, isLocalNoAuthMode } from "@/lib/env";
import { allowDeepHealthProbe } from "@/lib/deep-probe-auth";
import { localProjectRequestIdentityPayload, unsafeLocalProjectResponse } from "@/lib/local-project-guard";
import { createAdminClient } from "@/lib/supabase/admin";
import { formatSupabaseUnavailableError, isSupabaseUnavailableError, probeSupabaseHealth } from "@/lib/supabase/health";
Expand Down Expand Up @@ -284,6 +285,24 @@ function setupStatusResponse(payload: SetupStatusPayload) {
});
}

// Coarse per-check detail for anonymous production callers. The full `detail` strings can carry
// raw Supabase error text and project-config specifics (schema/search/worker fan-out errors,
// formatSupabaseProjectCheck), which must not leak to unauthenticated internet clients. Status
// and label are preserved so the polled setup UI still renders; operators fetch full detail with
// the shared deep-probe secret.
const COARSE_SETUP_DETAIL: Record<SetupCheckStatus, string> = {
ready: "Ready.",
needs_setup: "Setup incomplete. Operators can see specifics via the health deep probe or server logs.",
unknown: "Status unavailable. Operators can see specifics via the health deep probe or server logs.",
};

function coarseSetupStatusPayload(payload: SetupStatusPayload): SetupStatusPayload {
return {
...payload,
checks: payload.checks.map((item) => ({ ...item, detail: COARSE_SETUP_DETAIL[item.status] })),
};
}

async function buildSetupStatusPayload(): Promise<SetupStatusPayload> {
const supabase = supabaseProjectCanBeQueried ? createAdminClient() : null;
const unavailable = await readSupabaseAvailability(supabase);
Expand Down Expand Up @@ -418,5 +437,13 @@ export async function GET(request: Request) {
return unsafeLocalProjectResponse(identity);
}

return setupStatusResponse(await readSetupStatusPayload());
const payload = await readSetupStatusPayload();
// Whether the caller may see raw per-check detail (raw Supabase error text / project posture).
// Gate on a TRUSTED server-side runtime signal, never on request.url's host — behind a proxy the
// Host header is client-controlled, so a spoofed `localhost:<managed-port>` must not unlock
// detail. In a real production runtime, only the operator deep-probe token unlocks it; local dev
// and single-instance local-no-auth keep full detail (their `detail` is not an internet leak).
const requiresOperatorToken = process.env.NODE_ENV === "production" && !isDemoMode() && !isLocalNoAuthMode();
const authorizedForDetail = !requiresOperatorToken || allowDeepHealthProbe(request);
return setupStatusResponse(authorizedForDetail ? payload : coarseSetupStatusPayload(payload));
}
8 changes: 6 additions & 2 deletions src/lib/answer-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,12 @@ function hasActionableNumericContext(answer: RagAnswer) {
return actionableNumericAnswerPattern.test(text);
}

export function applyNumericVerification(answer: RagAnswer): RagAnswer {
const sources = answer.sources ?? [];
// `verificationSources` overrides the corpus numbers are checked against. The model path
// passes the packed context it actually generated from (answer.sources stays the unpacked
// answer-input set for the client/eval boundary); other callers omit it and verify against
// answer.sources as before.
export function applyNumericVerification(answer: RagAnswer, verificationSources?: SearchResult[]): RagAnswer {
const sources = verificationSources ?? answer.sources ?? [];
const unverified = new Set<string>();

// B4: the model is instructed to put dose details in structured
Expand Down
20 changes: 15 additions & 5 deletions src/lib/api-rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,19 @@ export function allowRateLimitInMemoryFallbackOnUnavailable() {
return isLocalNoAuthMode() || process.env.NODE_ENV === "production";
}

// Buckets that must FAIL CLOSED (503) rather than fall back to a per-instance in-memory limiter
// when the durable limiter is unavailable. A per-process Map gives N× the intended limit across N
// horizontally-scaled instances during a limiter outage — unacceptable for expensive/abusable
// paths: `answer` (paid provider generation) and `document_upload` (storage writes + ingestion
// cost). Local-no-auth dev keeps the in-memory fallback for single-instance usability.
function failsClosedOnLimiterUnavailable(bucket: ApiRateLimitBucket) {
return bucket === "answer" || bucket === "document_upload";
}

function allowAnonymousRateLimitFallback(bucket: ApiRateLimitBucket, allowInMemoryFallbackOnUnavailable?: boolean) {
// Paid answer generation must not fall back to a per-instance limiter in a
// distributed production runtime. If the durable limiter is unavailable,
// fail closed before retrieval or provider generation can start.
if (bucket === "answer" && !isLocalNoAuthMode()) return false;
// Fail-closed buckets must not fall back to a per-instance limiter in a distributed production
// runtime. If the durable limiter is unavailable, fail closed before any expensive work starts.
if (failsClosedOnLimiterUnavailable(bucket) && !isLocalNoAuthMode()) return false;
if (allowInMemoryFallbackOnUnavailable) return true;

// Anonymous public read/search paths must stay reachable if the durable limiter
Expand Down Expand Up @@ -150,7 +158,9 @@ export async function consumeSubjectApiRateLimit(args: {
allowInMemoryFallbackOnUnavailable?: boolean;
}): Promise<ApiRateLimitResult> {
const allowInMemoryFallbackOnUnavailable =
args.bucket === "answer" && !isLocalNoAuthMode() ? false : args.allowInMemoryFallbackOnUnavailable;
failsClosedOnLimiterUnavailable(args.bucket) && !isLocalNoAuthMode()
? false
: args.allowInMemoryFallbackOnUnavailable;

if (args.subject.kind === "owner") {
return consumeApiRateLimit({
Expand Down
23 changes: 23 additions & 0 deletions src/lib/deep-probe-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { timingSafeEqual } from "node:crypto";
import { env } from "@/lib/env";

// Shared operator gate for internal health/status detail. A caller proves it is an operator
// (not an anonymous internet client) by presenting HEALTH_DEEP_PROBE_SECRET via the
// `x-health-deep-token` header. Used by /api/health (deep Supabase probe) and
// /api/setup-status (detailed setup checks) so both surfaces gate internal error text and
// project posture behind the same secret. Constant-time compare; fails closed when the secret
// is unset or the token length/value differs.
export function allowDeepHealthProbe(request: Request): boolean {
const secret = env.HEALTH_DEEP_PROBE_SECRET;
if (!secret) return false;
const token = request.headers.get("x-health-deep-token");
if (!token) return false;
// Compare UTF-8 BYTE lengths, not JS string (UTF-16 code-unit) lengths: a crafted multi-byte
// token with the same code-unit count but a different byte count would otherwise pass a
// `token.length === secret.length` check and make timingSafeEqual throw on mismatched buffers
// (an unhandled RangeError → crafted-header 500). Build the buffers first, then length-gate.
const expected = Buffer.from(secret, "utf8");
const received = Buffer.from(token, "utf8");
if (expected.length !== received.length) return false;
return timingSafeEqual(expected, received);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
21 changes: 21 additions & 0 deletions src/lib/rag-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,3 +687,24 @@ export async function packAdjacentSourceContext(
return results;
}
}

// The numeric-faithfulness gate must verify answer figures against the SAME text
// the model was shown. Generation runs on the packed context (packAdjacentSourceContext
// merges neighbour-chunk text into adjacent_context), but answer.sources is the
// unpacked answer-input set — so a dose/threshold the model faithfully copied from a
// neighbour chunk would be absent from the finalize-time verification corpus and wrongly
// flagged unverified, blanking a correct answer. Overlay the packed adjacent_context onto
// the answer-input results (by chunk id) to rebuild the exact verification corpus, WITHOUT
// mutating answer.sources itself (the route-boundary client trim and eval byte-identity
// both depend on answer.sources staying unpacked — see answer-client-payload.ts).
export function attachAdjacentContext(results: SearchResult[], packed: SearchResult[]): SearchResult[] {
const adjacentById = new Map<string, string>();
for (const source of packed) {
if (source.adjacent_context) adjacentById.set(source.id, source.adjacent_context);
}
if (adjacentById.size === 0) return results;
return results.map((result) => {
const adjacent = adjacentById.get(result.id);
return adjacent && adjacent !== result.adjacent_context ? { ...result, adjacent_context: adjacent } : result;
});
}
29 changes: 21 additions & 8 deletions src/lib/rag-extractive-answer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1730,12 +1730,22 @@ function applyProviderLabels(answer: RagAnswer): RagAnswer {
// Public wrapper: runs quality finalization, then stamps provider/quality labels so the UI can
// disclose source-only (lower-quality) answers and verify-against-sources guidance.
/** Finalize rag answer quality. */
export function finalizeRagAnswerQuality(answer: RagAnswer, query: string, queryClass: RagQueryClass): RagAnswer {
return applyProviderLabels(finalizeRagAnswerQualityCore(answer, query, queryClass));
export function finalizeRagAnswerQuality(
answer: RagAnswer,
query: string,
queryClass: RagQueryClass,
verificationSources?: SearchResult[],
): RagAnswer {
return applyProviderLabels(finalizeRagAnswerQualityCore(answer, query, queryClass, verificationSources));
}

/** Finalize rag answer quality core. */
function finalizeRagAnswerQualityCore(answer: RagAnswer, query: string, queryClass: RagQueryClass): RagAnswer {
function finalizeRagAnswerQualityCore(
answer: RagAnswer,
query: string,
queryClass: RagQueryClass,
verificationSources?: SearchResult[],
): RagAnswer {
// Deterministic, template-built answers (document-support lists, table/visual source
// references) are well-formed by construction and carry no free-text clinical claims.
// The clinical-prose sanitizer/quality gate below is designed for model prose and would
Expand Down Expand Up @@ -1812,9 +1822,12 @@ function finalizeRagAnswerQualityCore(answer: RagAnswer, query: string, queryCla
})
.filter((section): section is Exclude<typeof section, null> => Boolean(section));

return applyNumericVerification({
...answer,
answer: boldHighYieldClinicalText(cleanedAnswer, query),
answerSections,
});
return applyNumericVerification(
{
...answer,
answer: boldHighYieldClinicalText(cleanedAnswer, query),
answerSections,
},
verificationSources,
);
}
34 changes: 24 additions & 10 deletions src/lib/rag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
cacheIndexingVersion,
cloneAnswer,
getCachedAnswer,
attachAdjacentContext,
getCachedSearch,
getSharedCachedAnswer,
getSharedCachedSearch,
Expand Down Expand Up @@ -3611,15 +3612,22 @@ export async function answerQuestionWithScope(args: AnswerQuestionWithScopeArgs)
message: "Waiting for an identical cited answer request already in progress.",
reason: "answer_inflight_coalesced",
});
const answer = cloneAnswer(await existing);
answer.routingReason = answer.routingReason
? `${answer.routingReason}; answer_inflight_coalesced`
: "answer_inflight_coalesced";
answer.latencyTimings = {
...answer.latencyTimings,
total_latency_ms: Date.now() - startedAt,
};
return answer;
try {
const answer = cloneAnswer(await existing);
answer.routingReason = answer.routingReason
? `${answer.routingReason}; answer_inflight_coalesced`
: "answer_inflight_coalesced";
answer.latencyTimings = {
...answer.latencyTimings,
total_latency_ms: Date.now() - startedAt,
};
return answer;
} catch {
// The in-flight request we coalesced onto failed — most often because the ORIGINATING
// caller aborted mid-flight (its AbortSignal is not ours) or its search phase threw. Do
// not propagate another caller's failure to this still-connected request: fall through to
// an independent, uncoalesced run so this request is decided only by its own inputs.
}
Comment on lines +3625 to +3630

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-coalesce waiters after in-flight aborts

When two or more identical callers are awaiting the same existing promise and the originating request aborts, every waiter enters this catch and then falls through to create its own pending request without rechecking answerInflight. That turns one aborted in-flight answer into N independent retrieval/OpenAI generations instead of one shared replacement for the still-connected callers, multiplying paid/provider and DB load under concurrent retries; a regression with two coalesced waiters after one aborted origin should assert only one replacement generation.

Useful? React with 👍 / 👎.

}

const pending = answerQuestionWithScopeUncoalesced(args, startedAt).finally(() => {
Expand Down Expand Up @@ -4640,6 +4648,11 @@ ${qualityRetryInstruction}`
// citations from the retrieved results, so trigger recovery whenever the
// generated answer is unusable and we have retrieved results to extract from.
const canRecoverExtractively = !usedStrongModel && (answer.citations.length > 0 || answerInputResults.length > 0);
// Numeric faithfulness at finalize time must verify against the packed context the model
// actually generated from, not the unpacked answer.sources — otherwise a figure copied from
// a neighbour chunk's adjacent_context reads as unverified and blanks a correct dose/threshold
// answer. Only the model path needs this; the extractive branch verifies against its own sources.
let numericVerificationSources: SearchResult[] | undefined;
if (canRecoverExtractively && isUnusableGeneratedAnswer(answer)) {
answer = buildExtractiveAnswer({
query: args.query,
Expand All @@ -4661,6 +4674,7 @@ ${qualityRetryInstruction}`
} else {
answer = boldRagAnswerHighYieldText(answer, args.query);
answer.sources = answerInputResults;
numericVerificationSources = attachAdjacentContext(answerInputResults, packedContextResults);
answer.quoteCards = reconcileQuoteCards(answer.quoteCards, answerInputResults, args.query);
answer.documentBreakdown = documentBreakdown;
answer.evidenceSummary = evidenceSummary;
Expand Down Expand Up @@ -4692,7 +4706,7 @@ ${qualityRetryInstruction}`
...retrievalDiagnostics,
routeMode: answer.routingMode ?? retrievalDiagnostics.routeMode,
});
answer = finalizeRagAnswerQuality(answer, args.query, queryClass);
answer = finalizeRagAnswerQuality(answer, args.query, queryClass, numericVerificationSources);

if (args.logQuery !== false)
await logRagQuery({
Expand Down
11 changes: 6 additions & 5 deletions src/lib/security-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,12 @@ export function buildContentSecurityPolicy({
"frame-ancestors 'none'; " +
"form-action 'self'; " +
upgradeInsecureRequests +
// img-src must allow https: so cross-origin Supabase Storage signed-URL
// images (document pages) can load. connect-src must include *.supabase.co
// for the signed-URL/API fetches.
"img-src 'self' data: blob: https:; " +
"media-src 'self' https:; " +
// img-src/media-src are scoped to the Supabase Storage origin that serves the
// signed-URL images (document pages) — the app loads no other cross-origin
// media, so a bare `https:` would needlessly widen the exfil surface. connect-src
// must include *.supabase.co for the signed-URL/API fetches.
"img-src 'self' data: blob: https://*.supabase.co; " +
"media-src 'self' https://*.supabase.co; " +
"connect-src 'self' https://*.supabase.co https://api.openai.com; " +
scriptSrc +
"style-src 'self' 'unsafe-inline'"
Expand Down
Loading
Loading