diff --git a/frontend/src/app/api/reports/[reportId]/route.ts b/frontend/src/app/api/reports/[reportId]/route.ts
index f1f31de7..08af4663 100644
--- a/frontend/src/app/api/reports/[reportId]/route.ts
+++ b/frontend/src/app/api/reports/[reportId]/route.ts
@@ -57,6 +57,7 @@ export async function GET(request: Request, context: RouteContext) {
requestUrl: request.url,
origin: request.headers.get("origin"),
allowedOrigins: resolveAllowedReportOrigins(process.env),
+ requestHeaders: request.headers,
userId,
reportId,
persistence: persistence ?? {
@@ -105,6 +106,7 @@ export async function DELETE(request: Request, context: RouteContext) {
requestUrl: request.url,
origin: request.headers.get("origin"),
allowedOrigins: resolveAllowedReportOrigins(process.env),
+ requestHeaders: request.headers,
userId,
reportId,
persistence: persistence ?? {
diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts
index 07bbe006..341b1a87 100644
--- a/frontend/src/app/api/reports/route.ts
+++ b/frontend/src/app/api/reports/route.ts
@@ -68,6 +68,7 @@ export async function POST(request: Request) {
requestUrl: request.url,
origin: request.headers.get("origin"),
allowedOrigins: resolveAllowedReportOrigins(process.env),
+ requestHeaders: request.headers,
userId,
rawBody: await request.json().catch(() => null),
profile,
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 30e6af0f..dac5b2f8 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -374,9 +374,8 @@ button:disabled { cursor: default; opacity: .45; }
.chat-header { z-index: 2; min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 20px; border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: 0 var(--space-8); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); text-align: left; }
.chat-header > div { min-width: 0; }
.chat-header strong { max-width: min(560px, 62vw); overflow: hidden; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; letter-spacing: -.3px; }
-.chat-header span { color: var(--color-ink-secondary); margin-top: var(--space-1); font-size: 12px; }
-.status { display: inline-block; margin: 0 6px 1px 0; border-radius: 50%; background: var(--color-success); width: 7px; height: 7px; margin-right: var(--space-2); }
-.status-loading { background: var(--color-action); }
+.chat-header-actions { min-width: max-content; display: flex; flex: 0 0 auto; align-items: center; gap: var(--space-2); white-space: nowrap; }
+.chat-header-actions > * { flex: 0 0 auto; }
.credit-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 11px; cursor: pointer; font-size: 13px; font-variant-numeric: tabular-nums; transition: background-color 120ms ease-out, transform 120ms ease-out; min-width: 64px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); color: var(--color-ink-secondary); font-weight: 500; }
.conversation { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding-bottom: var(--composer-reserve); background: var(--color-canvas); }
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index a0a20d3a..84aa845d 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -3111,11 +3111,6 @@ export default function Home() {
{activeSession?.title || "新对话"}
- {isLoading
- ? (consultationPhase === "undo" ? "即将发送,可撤回" : consultationPhase === "recovering" ? "正在恢复后台回答" : activeStreamingText ? "正在回答" : "正在核对星盘信息")
- : rectificationSurfaceOpen || (!profileComplete && onboardingStep === "rectification")
- ? "正在校正出生时间"
- : personalChartAvailable ? "基于星盘证据回答" : "回答一般占星知识"}
{reportEntryVisible && activeSession && (
diff --git a/frontend/src/components/personal-report/generate-personal-report-button.tsx b/frontend/src/components/personal-report/generate-personal-report-button.tsx
index b3dba1fe..ffc4d4e4 100644
--- a/frontend/src/components/personal-report/generate-personal-report-button.tsx
+++ b/frontend/src/components/personal-report/generate-personal-report-button.tsx
@@ -21,6 +21,7 @@ import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { FileText } from "lucide-react";
+import { toast } from "sonner";
export const DEFAULT_REPORT_THEMES = ["career", "marriage", "wealth", "timing"] as const;
@@ -155,7 +156,6 @@ export function GeneratePersonalReportButton({
}: GeneratePersonalReportButtonProps) {
const router = useRouter();
const [submitting, setSubmitting] = useState(false);
- const [notice, setNotice] = useState
(null);
const inFlight = useRef(false);
async function handleGenerate() {
@@ -164,11 +164,10 @@ export function GeneratePersonalReportButton({
}
inFlight.current = true;
setSubmitting(true);
- setNotice(null);
try {
const requestId = createReportRequestId();
if (requestId === null) {
- setNotice("请使用支持安全请求标识的现代浏览器。");
+ toast.error("请使用支持安全请求标识的现代浏览器。");
return;
}
const body = buildPersonalReportCreateRequest(requestId, sessionId);
@@ -189,9 +188,9 @@ export function GeneratePersonalReportButton({
router.push(`/reports/${encodeURIComponent(outcome.reportId)}`);
return;
}
- setNotice(outcome.message);
+ toast.error(outcome.message);
} catch {
- setNotice("网络异常,请检查连接后重试。");
+ toast.error("网络异常,请检查连接后重试。");
} finally {
inFlight.current = false;
setSubmitting(false);
@@ -203,31 +202,15 @@ export function GeneratePersonalReportButton({
: "生成个人报告;服务端将校验本次咨询是否存在可用证据,无证据时会提示。";
return (
-
-
- {notice !== null ? (
-
- {notice}
-
- ) : evidenceState === "unknown" ? (
-
- 服务端将校验本次咨询证据。
-
- ) : (
-
- 本次对话包含工作流证据记录,最终以服务端校验为准。
-
- )}
-
+
);
}
diff --git a/frontend/src/lib/personal-report-entitlement.ts b/frontend/src/lib/personal-report-entitlement.ts
index 99c304b0..c06b7e89 100644
--- a/frontend/src/lib/personal-report-entitlement.ts
+++ b/frontend/src/lib/personal-report-entitlement.ts
@@ -1,3 +1,5 @@
+import { normalizeIdentityHost } from "@/modules/identity/host";
+
/**
* Personal report export entitlement — independent from the "spend 1 credit"
* consultation RPC. Capability key: report.export.personal.
@@ -45,17 +47,46 @@ export type SameOriginDecision = Readonly<
{ ok: true } | { ok: false; code: "cross_origin_forbidden" }
>;
+type HeaderReader = Pick;
+
+function singleForwardedValue(value: string | null): string | null {
+ const normalized = value?.trim();
+ return normalized && !normalized.includes(",") ? normalized : null;
+}
+
+function matchesForwardedOrigin(origin: string, headers?: HeaderReader): boolean {
+ if (!headers?.has("x-forwarded-host") || !headers.has("x-forwarded-proto")) return false;
+
+ const host = normalizeIdentityHost(headers.get("host"));
+ const forwardedHost = normalizeIdentityHost(singleForwardedValue(headers.get("x-forwarded-host")));
+ const forwardedProto = singleForwardedValue(headers.get("x-forwarded-proto"))?.toLowerCase();
+ if (!host || !forwardedHost || host !== forwardedHost
+ || (forwardedProto !== "http" && forwardedProto !== "https")) {
+ return false;
+ }
+
+ try {
+ const originUrl = new URL(origin);
+ return origin === originUrl.origin
+ && originUrl.host.toLowerCase() === forwardedHost
+ && originUrl.protocol === `${forwardedProto}:`;
+ } catch {
+ return false;
+ }
+}
+
/**
* Same-origin check for report APIs. An absent Origin header (curl, server
* tests, same-origin fetch from the browser never sends Origin for GET but
* does for POST) is accepted; a matching request origin is accepted; a
- * configured trusted-proxy/test allowlist is accepted; anything else is
- * rejected.
+ * configured allowlist or consistent trusted-proxy host/protocol is accepted;
+ * anything else is rejected.
*/
export function checkSameOrigin(
requestUrl: string | URL,
originHeader: string | null,
allowedOrigins: readonly string[],
+ requestHeaders?: HeaderReader,
): SameOriginDecision {
const origin = originHeader?.trim();
if (!origin) return { ok: true };
@@ -67,6 +98,7 @@ export function checkSameOrigin(
}
if (origin === requestOrigin) return { ok: true };
if (allowedOrigins.includes(origin)) return { ok: true };
+ if (matchesForwardedOrigin(origin, requestHeaders)) return { ok: true };
return { ok: false, code: "cross_origin_forbidden" };
}
diff --git a/frontend/src/lib/personal-report-route-core.ts b/frontend/src/lib/personal-report-route-core.ts
index cbb1962c..9023143b 100644
--- a/frontend/src/lib/personal-report-route-core.ts
+++ b/frontend/src/lib/personal-report-route-core.ts
@@ -144,6 +144,7 @@ export type ReportCreateCoreDeps = Readonly<{
requestUrl: string;
origin: string | null;
allowedOrigins: readonly string[];
+ requestHeaders?: Pick;
userId: string | null;
rawBody: unknown;
profile: unknown | null;
@@ -165,7 +166,7 @@ export type ReportCreateCoreDeps = Readonly<{
export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise {
// Same-origin gate first (CSRF), then auth.
- const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins);
+ const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins, deps.requestHeaders);
if (!originDecision.ok) {
return {
status: 403,
@@ -416,6 +417,7 @@ export type ReportReadCoreDeps = Readonly<{
requestUrl: string;
origin: string | null;
allowedOrigins: readonly string[];
+ requestHeaders?: Pick;
userId: string | null;
reportId: string;
persistence: Pick;
@@ -430,7 +432,7 @@ export type ReportReadCoreDeps = Readonly<{
}>;
export async function resolveReportRead(deps: ReportReadCoreDeps): Promise {
- const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins);
+ const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins, deps.requestHeaders);
if (!originDecision.ok) {
return {
status: 403,
@@ -474,13 +476,14 @@ export type ReportDeleteCoreDeps = Readonly<{
requestUrl: string;
origin: string | null;
allowedOrigins: readonly string[];
+ requestHeaders?: Pick;
userId: string | null;
reportId: string;
persistence: Pick;
}>;
export async function resolveReportDelete(deps: ReportDeleteCoreDeps): Promise {
- const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins);
+ const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins, deps.requestHeaders);
if (!originDecision.ok) {
return {
status: 403,
diff --git a/frontend/tests/personal-report-api.test.ts b/frontend/tests/personal-report-api.test.ts
index b1ebbf7e..8de4effa 100644
--- a/frontend/tests/personal-report-api.test.ts
+++ b/frontend/tests/personal-report-api.test.ts
@@ -270,6 +270,23 @@ test("core create: 403 on cross-origin", async () => {
assert.equal(response.body.code, "report_resource_forbidden");
});
+test("core create: accepts staging same-origin POST behind the trusted reverse proxy", async () => {
+ const response = await resolveReportCreate({
+ ...baseDeps({
+ requestUrl: "http://staging.jyotisha.chat/api/reports",
+ origin: "https://staging.jyotisha.chat",
+ }),
+ requestHeaders: new Headers({
+ host: "staging.jyotisha.chat",
+ "x-forwarded-host": "staging.jyotisha.chat",
+ "x-forwarded-proto": "https",
+ }),
+ });
+
+ assert.equal(response.status, 201);
+ assert.equal(response.body.report && typeof response.body.report, "object");
+});
+
test("core create: 400 on invalid payload", async () => {
const response = await resolveReportCreate(baseDeps({ rawBody: { requestId: "not-a-uuid" } }));
assert.equal(response.status, 400);
@@ -742,6 +759,8 @@ test("GET/DELETE use the authenticated client (least privilege) and the core han
test("route core enforces same-origin and never leaks raw exception text", () => {
assert.match(coreSource, /checkSameOrigin/);
assert.match(coreSource, /REPORT_STABLE_CODES\.resourceForbidden/);
+ assert.match(createRoute, /requestHeaders:\s*request\.headers/);
+ assert.match(itemRoute, /requestHeaders:\s*request\.headers/);
assert.doesNotMatch(createRoute, /error\.message\)/);
assert.doesNotMatch(itemRoute, /error\.message\)/);
assert.doesNotMatch(createRoute, /\.stack/);
diff --git a/frontend/tests/personal-report-entitlement.test.ts b/frontend/tests/personal-report-entitlement.test.ts
index 0f3baf36..9b2008ea 100644
--- a/frontend/tests/personal-report-entitlement.test.ts
+++ b/frontend/tests/personal-report-entitlement.test.ts
@@ -61,6 +61,59 @@ test("same-origin check rejects cross-origin and accepts a trusted allowlist", (
);
});
+test("same-origin check trusts only consistent forwarded host and protocol", () => {
+ const headers = new Headers({
+ host: "staging.jyotisha.chat",
+ "x-forwarded-host": "staging.jyotisha.chat",
+ "x-forwarded-proto": "https",
+ });
+
+ assert.deepEqual(
+ checkSameOrigin(
+ "http://staging.jyotisha.chat/api/reports",
+ "https://staging.jyotisha.chat",
+ [],
+ headers,
+ ),
+ { ok: true },
+ );
+ assert.deepEqual(
+ checkSameOrigin(
+ "http://staging.jyotisha.chat/api/reports",
+ "https://evil.example",
+ [],
+ headers,
+ ),
+ { ok: false, code: "cross_origin_forbidden" },
+ );
+ assert.deepEqual(
+ checkSameOrigin(
+ "http://staging.jyotisha.chat/api/reports",
+ "https://staging.jyotisha.chat",
+ [],
+ new Headers({
+ host: "staging.jyotisha.chat",
+ "x-forwarded-host": "evil.example",
+ "x-forwarded-proto": "https",
+ }),
+ ),
+ { ok: false, code: "cross_origin_forbidden" },
+ );
+ assert.deepEqual(
+ checkSameOrigin(
+ "http://staging.jyotisha.chat/api/reports",
+ "https://staging.jyotisha.chat",
+ [],
+ new Headers({
+ host: "staging.jyotisha.chat",
+ "x-forwarded-host": "staging.jyotisha.chat",
+ "x-forwarded-proto": "https, http",
+ }),
+ ),
+ { ok: false, code: "cross_origin_forbidden" },
+ );
+});
+
test("entitlement blocks when the feature is disabled", async () => {
const result = await checkPersonalReportEntitlement({
userId: "u1",
diff --git a/frontend/tests/personal-report-entry.test.ts b/frontend/tests/personal-report-entry.test.ts
index b153fde8..352e901f 100644
--- a/frontend/tests/personal-report-entry.test.ts
+++ b/frontend/tests/personal-report-entry.test.ts
@@ -18,6 +18,7 @@ const componentSource = readFileSync(
"utf8",
);
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
+const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
test("request shape: POST /api/reports body carries only report identity fields", () => {
const body = buildPersonalReportCreateRequest("request-id-1", "session-id-1");
@@ -144,6 +145,16 @@ test("repeat clicks are guarded while a request is in flight", () => {
assert.match(componentSource, /正在生成/);
});
+test("entry stays on one line and sends failures to the existing toast", () => {
+ assert.match(componentSource, /shrink-0/);
+ assert.match(componentSource, /whitespace-nowrap/);
+ assert.match(componentSource, /toast\.error\(outcome\.message\)/);
+ assert.match(componentSource, /toast\.error\("网络异常,请检查连接后重试。"\)/);
+ assert.doesNotMatch(componentSource, /personal-report-entry-note/);
+ assert.doesNotMatch(componentSource, /flex-wrap/);
+ assert.match(globalStyles, /\.chat-header-actions \{[^}]*min-width: max-content;[^}]*display: flex;[^}]*white-space: nowrap;/);
+});
+
test("entry never requests a server PDF and never prints directly", () => {
assert.doesNotMatch(componentSource, /api\/report_artifact/);
assert.doesNotMatch(componentSource, /window\.print/);
diff --git a/frontend/tests/sidebar-contract.test.ts b/frontend/tests/sidebar-contract.test.ts
index 2a912ff2..d03903e2 100644
--- a/frontend/tests/sidebar-contract.test.ts
+++ b/frontend/tests/sidebar-contract.test.ts
@@ -253,9 +253,14 @@ test("changes sidebar state without transition frames", () => {
});
test("keeps the chat title in the flexible left-aligned header column", () => {
+ const page = readProjectFile("src/app/page.tsx");
+ const header = page.slice(page.indexOf('", page.indexOf('')));
+
assert.match(cssBlock(".chat-header"), /grid-template-columns:\s*auto\s+minmax\(0,\s*1fr\)\s+auto/);
assert.match(cssBlock(".chat-header"), /text-align:\s*left/);
assert.doesNotMatch(cssBlock(".chat-header"), /justify-content:\s*space-between/);
+ assert.match(header, /\{activeSession\?\.title \|\| "新对话"\}<\/strong>/);
+ assert.doesNotMatch(header, /status-loading|基于星盘证据回答|回答一般占星知识|正在校正出生时间/);
});
test("anchors the account footer to the bottom edge without trailing sidebar padding", () => {
diff --git a/frontend/tests/starter-questions.test.ts b/frontend/tests/starter-questions.test.ts
index a06266b5..02e02e5d 100644
--- a/frontend/tests/starter-questions.test.ts
+++ b/frontend/tests/starter-questions.test.ts
@@ -62,7 +62,7 @@ test("profiles without a usable birth minute only receive general-knowledge home
"印度占星中的时间推运通常会看哪些因素?",
]);
assert.match(pageSource, /const starterThemes = personalChartAvailable \? themes : generalGuidedJyotishTopics/);
- assert.match(pageSource, /personalChartAvailable[\s\S]*?回答一般占星知识/);
+ assert.doesNotMatch(pageSource, /回答一般占星知识/);
assert.match(pageSource, /完成生时校正后,再讨论个人星盘结论/);
assert.match(pageSource, /personalChartAvailable \? "daily_starlanguage" : null/);
});