fix: use same-origin data APIs on homepage

This commit is contained in:
Jesse_Chen
2026-07-27 15:47:18 +08:00
parent aa6365d0d5
commit f402e6f79c
5 changed files with 86 additions and 72 deletions
+11 -11
View File
@@ -186,21 +186,21 @@
- 复发自:无
- 修复版本:待提交(本地可测)
## BUG-010 | 浏览器直连 Supabase 写会话泄露 `TypeError: Load failed`
## BUG-010 | 浏览器直连 Supabase 导致自托管 PostgreSQL staging 误报未配置
- 状态:resolved
- 首次发现:2026-07-22
- 最近更新:2026-07-22
- 影响面:回答完成后的聊天记录持久化、移动 Safari 错误提示
- 用户现象:回答已经生成,但页面反复显示“云端同步失败:TypeError: Load failed”,并要求复制保存后重试
- 触发条件:浏览器直接向 Supabase `chat_sessions` 发起跨域写入时发生传输失败
- 根因:会话读取和多数业务写入已经使用同源 Next.js API,但会话创建与更新仍由浏览器客户端直写 Supabase;异常原文又被拼进回答错误区域
- 修复:新增同源 `POST /api/sessions` `PATCH /api/sessions/[id]`,服务端校验登录、所有权和写入负载;客户端对可重试失败短重试一次,并把最终失败降级为输入区状态提示,不再把浏览器异常原文渲染成回答错误
- 验证:`frontend/tests/chat-session-write.test.ts` 覆盖同源路由、所有者约束、短重试和 `Load failed` 脱敏;相关咨询与资料回归测试通过
- 防复发:会话写入契约禁止页面直接调用 `supabase.from("chat_sessions")`;网络异常必须映射为稳定用户文案
- 最近更新:2026-07-27
- 影响面:聊天首页启动、会话模型选择、退出登录;自托管 PostgreSQL staging。
- 用户现象:数据库和身份服务正常,首页却显示“Supabase 尚未配置”
- 触发条件:`AUTH_PROVIDER=self-hosted` 且不提供浏览器 Supabase 公钥
- 根因:页面启动、会话模型更新和退出登录仍直接创建浏览器 Supabase client;仅服务端 API 已切换到同源 PostgreSQL 路径
- 修复:`/api/account` 返回当前用户完整 profile;首页通过 `/api/account``/api/sessions` `/api/sessions/:id` 读写,退出使用 self-hosted identity client
- 验证:`frontend/tests/chat-session-write.test.ts``frontend/tests/session-model-persistence.test.ts``frontend/tests/account-api.test.ts`、ESLint、`next build`
- 防复发:首页不得引用 `createBrowserSupabaseClient`;会话与 profile 只能经同源 API 访问
- 相关记录:BUG-001、BUG-003
- 复发自:
- 修复版本:待提交(本地可测)
- 复发自:BUG-010
- 修复版本:待发布 staging
## BUG-011 | 对话消息暴露内部证据审计状态
+3 -2
View File
@@ -78,14 +78,14 @@ export async function GET() {
// an older case. A concurrently created case simply appears on refresh.
let { data: profile, error: profileError } = await supabase
.from("profiles")
.select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source")
.select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,name,birth_time,rectification_case_id")
.eq("id", userId)
.single();
if (profileError && isMissingProfileColumn(profileError)) {
const fallback = await supabase
.from("profiles")
.select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset")
.select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,name,birth_time,rectification_case_id")
.eq("id", userId)
.single();
profile = fallback.data ? {
@@ -118,6 +118,7 @@ export async function GET() {
hasConfirmedBirthTime: profile.birth_time_status === "confirmed"
&& typeof profile.active_birth_time === "string",
rectificationCase,
profile,
birthLocation: {
label: profile.birth_place_label ?? null,
placeType: profile.birth_place_type ?? null,
+58 -58
View File
@@ -94,7 +94,7 @@ import {
resolveSessionModelId,
type PublicLanguageModelCatalog,
} from "@/lib/public-models";
import { createBrowserSupabaseClient } from "@/lib/supabase/client";
import { selfHostedOtpActions } from "@/modules/identity/client";
gsap.registerPlugin(useGSAP);
@@ -192,6 +192,7 @@ type Account = {
rectificationPriceCredits: number;
hasConfirmedBirthTime: boolean;
rectificationCase: AccountRectificationCaseState | null;
profile: unknown;
};
type OnboardingStep = "name" | "birth" | "place" | "rectification";
type AccountDialog = "profile" | "redeem" | "logout";
@@ -885,6 +886,33 @@ async function fetchModelCatalog(signal?: AbortSignal) {
return parsePublicModelCatalog(payload);
}
async function fetchSessions(signal?: AbortSignal): Promise<unknown> {
const response = await fetch("/api/sessions", { signal, cache: "no-store" });
if (response.status === 401) {
window.location.assign("/login");
throw new Error("请先登录");
}
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录"));
return payload && typeof payload === "object" ? (payload as { sessions?: unknown }).sessions : null;
}
async function patchSessionModel(sessionId: string, modelId: string, signal?: AbortSignal) {
const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ model_id: modelId }),
signal,
});
if (response.status === 401) {
window.location.assign("/login");
throw new Error("请先登录");
}
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payloadMessage(payload, "模型选择暂时无法同步到云端。"));
}
export default function Home() {
const [profile, setProfile] = useState<Profile>(emptyProfile);
const [profileDraft, setProfileDraft] = useState<Profile>(emptyProfile);
@@ -1228,6 +1256,7 @@ export default function Home() {
rectificationPriceCredits: 1,
hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed",
rectificationCase: null,
profile: previewProfile,
});
setModelCatalog(previewModelCatalog);
setProfile(previewProfile);
@@ -1250,16 +1279,7 @@ export default function Home() {
return;
}
const supabase = createBrowserSupabaseClient();
const { data: authData, error: authError } = await supabase.auth.getSession();
if (authError) throw authError;
if (controller.signal.aborted) return;
if (!authData.session) {
window.location.assign("/login");
return;
}
const [nextAccount, modelCatalogResult] = await Promise.all([
const [nextAccount, modelCatalogResult, sessionsPayload] = await Promise.all([
fetchAccount(controller.signal),
fetchModelCatalog(controller.signal)
.then((catalog) => ({ catalog, unavailable: false }))
@@ -1267,50 +1287,30 @@ export default function Home() {
if (caught instanceof Error && caught.name === "AbortError") throw caught;
return { catalog: null, unavailable: true };
}),
fetchSessions(controller.signal),
]);
const nextModelCatalog = modelCatalogResult.catalog;
const [profileResult, sessionsResult] = await Promise.all([
supabase
.from("profiles")
.select("name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,latitude,longitude,timezone_id,timezone_offset,timezone_source")
.eq("id", nextAccount.user.id)
.abortSignal(controller.signal)
.maybeSingle(),
supabase
.from("chat_sessions")
.select("id,title,theme,model_id,messages,session_type,rectification_case_id,updated_at")
.abortSignal(controller.signal)
.order("updated_at", { ascending: false }),
]);
if (profileResult.error) throw profileResult.error;
if (sessionsResult.error) throw sessionsResult.error;
const parsedSessions = readSessions(sessionsResult.data, nextModelCatalog);
const parsedSessions = readSessions(sessionsPayload, nextModelCatalog);
let nextSessions = parsedSessions.sessions;
if (nextSessions.length === 0) {
if (controller.signal.aborted) return;
const initialSession = createSession(nextModelCatalog?.defaultModelId ?? "");
const { error } = await supabase
.from("chat_sessions")
.insert({
id: initialSession.id,
user_id: nextAccount.user.id,
if (nextModelCatalog) {
await writeChatSession(initialSession.id, {
title: initialSession.title,
theme: initialSession.theme,
model_id: initialSession.modelId || null,
model_id: initialSession.modelId,
messages: initialSession.messages,
session_type: initialSession.sessionType,
rectification_case_id: initialSession.rectificationCaseId,
updated_at: new Date(initialSession.updatedAt).toISOString(),
})
.abortSignal(controller.signal);
if (error) throw error;
}, "create");
}
nextSessions = [initialSession];
}
if (controller.signal.aborted) return;
const nextProfile = readProfile(profileResult.data);
const nextProfile = readProfile(nextAccount.profile);
setAccount(nextAccount);
setModelCatalog(nextModelCatalog);
setProfile(nextProfile);
@@ -1327,14 +1327,14 @@ export default function Home() {
setAccountError("");
if (nextModelCatalog && parsedSessions.fallbackSessionIds.length > 0) {
const { error } = await supabase
.from("chat_sessions")
.update({ model_id: nextModelCatalog.defaultModelId })
.eq("user_id", nextAccount.user.id)
.in("id", parsedSessions.fallbackSessionIds)
.abortSignal(controller.signal);
if (error && !controller.signal.aborted) {
setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。");
try {
await Promise.all(parsedSessions.fallbackSessionIds.map((sessionId) =>
patchSessionModel(sessionId, nextModelCatalog.defaultModelId, controller.signal),
));
} catch {
if (!controller.signal.aborted) {
setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。");
}
}
}
} catch (caught) {
@@ -1636,18 +1636,19 @@ export default function Home() {
try {
await modelPersistence.current.enqueue(nextSession.id, () => persistSessionModelSelection(
async ({ values, sessionId, userId: ownerId }) => {
async ({ values, sessionId }) => {
if (process.env.NODE_ENV === "development" && uiPreview.current) {
return { found: true, error: null };
}
const { data, error } = await createBrowserSupabaseClient()
.from("chat_sessions")
.update(values)
.eq("id", sessionId)
.eq("user_id", ownerId)
.select("id")
.maybeSingle();
return { found: Boolean(data), error: error?.message ?? null };
try {
await patchSessionModel(sessionId, values.model_id);
return { found: true, error: null };
} catch (error) {
return {
found: false,
error: error instanceof Error ? error.message : "模型选择暂时无法同步到云端。",
};
}
},
userId,
nextSession.id,
@@ -1989,8 +1990,7 @@ export default function Home() {
setSigningOut(true);
setAccountError("");
try {
const { error } = await createBrowserSupabaseClient().auth.signOut();
if (error) throw error;
await selfHostedOtpActions.signOut();
window.location.assign("/login");
} catch (caught) {
const message = caught instanceof Error ? caught.message : "退出失败";
+1 -1
View File
@@ -50,7 +50,7 @@ test("account GET falls back when global birthplace columns are not migrated yet
const getSource = source.slice(source.indexOf("export async function GET"), source.indexOf("export async function PATCH"));
assert.match(getSource, /profileError && isMissingProfileColumn\(profileError\)/);
assert.match(getSource, /select\("credits,active_birth_time[^"]*timezone_offset"\)/);
assert.match(getSource, /select\("credits,active_birth_time[^"]*timezone_offset(?:,[^"]*)?"\)/);
assert.match(getSource, /birth_place_label: undefined/);
assert.match(getSource, /timezone_id: undefined/);
});
+13
View File
@@ -63,3 +63,16 @@ test("session API owns create and update while answer UI keeps sync failures out
assert.match(itemRoute, /export async function PATCH/);
assert.match(itemRoute, /\.eq\("user_id", user\.id\)/);
});
test("homepage bootstrap reads profiles and sessions through same-origin APIs", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const accountRoute = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8");
assert.doesNotMatch(page, /createBrowserSupabaseClient/);
assert.match(page, /fetch\("\/api\/account"/);
assert.match(page, /fetch\("\/api\/sessions"/);
assert.match(page, /writeChatSession\(initialSession\.id,[\s\S]*?"create"\)/);
assert.match(page, /fetch\(`\/api\/sessions\/\$\{encodeURIComponent\(sessionId\)\}`/);
assert.match(accountRoute, /rectificationCase,\s*profile,/);
});