diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index 2d4c8358e8..cb1b11cb3b 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -20,7 +20,7 @@ import { sourceGovernanceWarnings, } from "@/lib/source-governance"; import { createAdminClient } from "@/lib/supabase/admin"; -import { nonProductionSupabaseDemoFallbackReason } from "@/lib/supabase/errors"; +import { isSupabaseApiKeyConfigurationError, nonProductionSupabaseDemoFallbackReason } from "@/lib/supabase/errors"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { logger } from "@/lib/logger"; import { parseJsonBody } from "@/lib/validation/body"; @@ -79,6 +79,16 @@ function streamErrorPayload(error: unknown) { }; } + // Production has no demo fallback for a misconfigured Supabase key, so tag the + // SSE error with a stable code operators can spot in the client/network tab. + if (isSupabaseApiKeyConfigurationError(error)) { + return { + message: "Answer generation failed. Retry with a narrower question.", + status: 500, + details: { code: "supabase_api_key_configuration" }, + }; + } + if (error instanceof Error) { return { message: "Answer generation failed. Retry with a narrower question.", @@ -210,6 +220,9 @@ function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal, }); } catch (error) { logStreamError(error); + // Parity with /api/answer (PR #315): outside production, a misconfigured + // Supabase API key degrades to a visible demo answer instead of a stream + // error — the UI's answer search uses this route, not /api/answer. const fallbackReason = nonProductionSupabaseDemoFallbackReason(error); if (fallbackReason) { send("final", buildDemoStreamAnswer(body, fallbackReason)); diff --git a/src/lib/supabase/errors.ts b/src/lib/supabase/errors.ts index b69557a159..24ea4756d3 100644 --- a/src/lib/supabase/errors.ts +++ b/src/lib/supabase/errors.ts @@ -8,7 +8,16 @@ function errorMessage(error: unknown) { } export function isSupabaseApiKeyConfigurationError(error: unknown) { - return /\b(?:unregistered|invalid)\s+api\s+key\b/i.test(errorMessage(error)); + const message = errorMessage(error); + return ( + /\b(?:unregistered|invalid)\s+api\s+key\b/i.test(message) || + // Post-rotation messages confirmed live (2026-07-06): PostgREST returns + // "Legacy API keys are disabled" once the anon/service_role JWTs are turned + // off, and "Secret API key required" when a publishable key is sent to a + // secret-only endpoint. Both mean the configured key is wrong, not the query. + /\blegacy\s+api\s+keys?\s+(?:are\s+)?disabled\b/i.test(message) || + /\bsecret\s+api\s+key\s+required\b/i.test(message) + ); } export function nonProductionSupabaseDemoFallbackReason(error: unknown) { diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index a01b7f9772..cee6d1230c 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -3431,7 +3431,9 @@ describe("private document API access", () => { expect(errorPayload).toMatchObject({ error: "Answer generation failed. Retry with a narrower question.", status: 500, - details: { code: "Error" }, + // Key-configuration failures carry a stable code so a production outage is + // diagnosable from the client network tab (confirmed live 2026-07-06). + details: { code: "supabase_api_key_configuration" }, }); expect(answerQuestionWithScope).not.toHaveBeenCalled(); }); diff --git a/tests/supabase-errors.test.ts b/tests/supabase-errors.test.ts new file mode 100644 index 0000000000..9a2a091f9d --- /dev/null +++ b/tests/supabase-errors.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + isSupabaseApiKeyConfigurationError, + nonProductionSupabaseDemoFallbackReason, +} from "../src/lib/supabase/errors"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("Supabase API key configuration error detection", () => { + it.each([ + "Unregistered API key", + "Invalid API key", + // Confirmed live 2026-07-06 after key rotation: legacy anon/service_role + // JWTs disabled on 2026-07-05 produce this exact message. + "Legacy API keys are disabled", + "Secret API key required", + ])("matches %j", (message) => { + expect(isSupabaseApiKeyConfigurationError(new Error(message))).toBe(true); + expect(isSupabaseApiKeyConfigurationError({ message })).toBe(true); + }); + + it.each(["JWT expired", "row-level security violation", "network timeout"])( + "does not match unrelated error %j", + (message) => { + expect(isSupabaseApiKeyConfigurationError(new Error(message))).toBe(false); + }, + ); + + it("maps disabled legacy keys to the demo fallback reason outside production", () => { + expect(nonProductionSupabaseDemoFallbackReason(new Error("Legacy API keys are disabled"))).toBe( + "supabase_api_key_configuration_unavailable", + ); + }); + + it("never returns a fallback reason in production", () => { + vi.stubEnv("NODE_ENV", "production"); + expect(nonProductionSupabaseDemoFallbackReason(new Error("Legacy API keys are disabled"))).toBeNull(); + }); +});