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
15 changes: 14 additions & 1 deletion src/app/api/answer/stream/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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.",
Expand DownExpand Up@@ -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));
Expand Down
11 changes: 10 additions & 1 deletion src/lib/supabase/errors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion tests/private-access-routes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
});
Expand Down
41 changes: 41 additions & 0 deletions tests/supabase-errors.test.ts
Original file line numberDiff line numberDiff line change
@@ -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();
});
});
Loading