Skip to content
Draft
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,8 @@ If `http://localhost:3000` is reachable, `scripts/validate_frontend_mvp.py` also
Expected Phase 6 behavior:

- Required frontend pages exist.
- The UI includes Open Source evidence text and a high value marker.
- The UI includes Open Source / 打开来源 evidence text and a High Value / 高价值 marker.
- The frontend UI is localized to Chinese while preserving backend enum values, URL paths, and API request parameters.
- Settings does not render `encrypted_payload`.
- Reports expose markdown and csv export controls.
- The frontend API client accepts only SignalForge backend-relative paths.
Expand Down
20 changes: 19 additions & 1 deletion apps/api/app/config.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
import os


DEFAULT_CORS_ALLOW_ORIGINS = ("http://localhost:3000", "http://127.0.0.1:3000")


def parse_cors_allow_origins(value: str | None) -> tuple[str, ...]:
if value is None:
return DEFAULT_CORS_ALLOW_ORIGINS

origins: list[str] = []
for origin in value.split(","):
normalized = origin.strip().rstrip("/")
if normalized:
origins.append(normalized)
return tuple(origins) or DEFAULT_CORS_ALLOW_ORIGINS


@dataclass(frozen=True)
class Settings:
database_url: str = os.getenv("DATABASE_URL", "")
redis_url: str = os.getenv("REDIS_URL", "")
cors_allow_origins: tuple[str, ...] = field(
default_factory=lambda: parse_cors_allow_origins(os.getenv("CORS_ALLOW_ORIGINS"))
)


settings = Settings()
3 changes: 2 additions & 1 deletion apps/api/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@
settings,
signals,
)
from app.config import settings as app_settings

app = FastAPI(title="SignalForge API", version="0.1.0-phase-2")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
allow_origins=app_settings.cors_allow_origins,
allow_credentials=False,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],
Expand Down
28 changes: 28 additions & 0 deletions apps/api/tests/test_cors_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from app.config import DEFAULT_CORS_ALLOW_ORIGINS, parse_cors_allow_origins, settings
from app.main import app


def test_cors_allow_origins_parser_default() -> None:
assert parse_cors_allow_origins(None) == DEFAULT_CORS_ALLOW_ORIGINS


def test_cors_allow_origins_parser_empty_value_uses_default() -> None:
assert parse_cors_allow_origins(" , , /// ") == DEFAULT_CORS_ALLOW_ORIGINS


def test_cors_allow_origins_parser_trims_spaces_filters_empty_and_removes_trailing_slash() -> None:
assert parse_cors_allow_origins(" http://localhost:3000/ , , http://127.0.0.1:3000/// ") == (
"http://localhost:3000",
"http://127.0.0.1:3000",
)


def test_main_cors_allow_origins_does_not_include_wildcard() -> None:
cors_middleware = next(
middleware
for middleware in app.user_middleware
if middleware.cls.__name__ == "CORSMiddleware"
)

assert cors_middleware.kwargs["allow_origins"] == settings.cors_allow_origins
assert "*" not in cors_middleware.kwargs["allow_origins"]
141 changes: 141 additions & 0 deletions apps/web/app/api/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { SERVER_API_BASE_URL } from "../../../lib/constants";

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

type RouteContext = {
params: Promise<{ path?: string[] }> | { path?: string[] };
};

const BLOCKED_REQUEST_HEADERS = new Set([
"authorization",
"connection",
"cookie",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"sf-token",
"sf_token",
"x-sf-token",
"te",
"trailer",
"transfer-encoding",
"upgrade"
]);

const BLOCKED_RESPONSE_HEADERS = new Set(["connection", "content-encoding", "set-cookie"]);

export async function GET(request: Request, context: RouteContext) {
return proxyBackendRequest(request, context);
}

export async function POST(request: Request, context: RouteContext) {
return proxyBackendRequest(request, context);
}

export async function PUT(request: Request, context: RouteContext) {
return proxyBackendRequest(request, context);
}

export async function PATCH(request: Request, context: RouteContext) {
return proxyBackendRequest(request, context);
}

export async function DELETE(request: Request, context: RouteContext) {
return proxyBackendRequest(request, context);
}

export async function OPTIONS(request: Request, context: RouteContext) {
return proxyBackendRequest(request, context);
}

export async function HEAD(request: Request, context: RouteContext) {
return proxyBackendRequest(request, context);
}

async function proxyBackendRequest(request: Request, context: RouteContext): Promise<Response> {
try {
const targetUrl = await buildTargetUrl(request, context);
const body = await getRequestBody(request);

const upstreamResponse = await fetch(targetUrl, {
body,
cache: "no-store",
headers: getForwardHeaders(request.headers),
method: request.method,
redirect: "manual"
});

return new Response(upstreamResponse.body, {
headers: getResponseHeaders(upstreamResponse.headers),
status: upstreamResponse.status,
statusText: upstreamResponse.statusText
});
} catch {
return Response.json(
{
error: {
code: "backend_proxy_unavailable",
message: "SignalForge backend is unavailable through the web proxy.",
details: {}
}
},
{
status: 502,
headers: {
"Cache-Control": "no-store"
}
}
);
}
}

async function buildTargetUrl(request: Request, context: RouteContext): Promise<URL> {
const incomingUrl = new URL(request.url);
const params = await Promise.resolve(context.params);
const path = (params.path ?? []).join("/");
const upstreamPath = path === "health" ? "/health" : `/api/${path}`;
const targetUrl = new URL(upstreamPath, `${SERVER_API_BASE_URL}/`);

incomingUrl.searchParams.forEach((value, key) => {
if (key !== "sf_token") {
targetUrl.searchParams.append(key, value);
}
});

return targetUrl;
}

async function getRequestBody(request: Request): Promise<ArrayBuffer | undefined> {
if (request.method === "GET" || request.method === "HEAD") {
return undefined;
}

const body = await request.arrayBuffer();
return body.byteLength > 0 ? body : undefined;
}

function getForwardHeaders(headers: Headers): Headers {
const forwardHeaders = new Headers();

headers.forEach((value, key) => {
if (!BLOCKED_REQUEST_HEADERS.has(key.toLowerCase())) {
forwardHeaders.set(key, value);
}
});

return forwardHeaders;
}

function getResponseHeaders(headers: Headers): Headers {
const responseHeaders = new Headers();

headers.forEach((value, key) => {
if (!BLOCKED_RESPONSE_HEADERS.has(key.toLowerCase())) {
responseHeaders.set(key, value);
}
});

return responseHeaders;
}
4 changes: 2 additions & 2 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import "./styles.css";

export const metadata: Metadata = {
title: "SignalForge",
description: "SignalForge research workstation"
description: "SignalForge 研究工作台"
};

export default function RootLayout({
Expand All @@ -14,7 +14,7 @@ export default function RootLayout({
children: ReactNode;
}>) {
return (
<html lang="en">
<html lang="zh-CN">
<body>
<AppShell>{children}</AppShell>
</body>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/opportunities/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default async function OpportunityDetailPage({

return <OpportunityDetail initialOpportunity={opportunity} projectId={projectId} />;
} catch (error) {
return <ErrorState error={error} title="Unable to load opportunity detail" />;
return <ErrorState error={error} title="无法加载机会详情" />;
}
}

Expand Down
51 changes: 12 additions & 39 deletions apps/web/app/opportunities/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { CSSProperties } from "react";
import { OpportunityBoard } from "../../components/opportunities/OpportunityBoard";
import { EmptyState } from "../../components/ui/EmptyState";
import { ErrorState } from "../../components/ui/ErrorState";
Expand All @@ -10,42 +9,15 @@ type OpportunitiesPageProps = {
searchParams?: Promise<SearchParams>;
};

const pageStyle: CSSProperties = {
display: "grid",
gap: 16
};

const headerStyle: CSSProperties = {
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 16,
flexWrap: "wrap"
};

const titleStyle: CSSProperties = {
margin: 0,
color: "var(--text)",
fontSize: 24,
fontWeight: 800,
lineHeight: 1.2
};

const subtitleStyle: CSSProperties = {
margin: "6px 0 0",
color: "var(--muted)",
lineHeight: 1.5
};

export default async function OpportunitiesPage({ searchParams }: OpportunitiesPageProps) {
const resolvedSearchParams: SearchParams = searchParams ? await searchParams : {};
const projectId = getSingleSearchParam(resolvedSearchParams.projectId);

if (!projectId) {
return (
<EmptyState
description="Select a project from the sidebar to load project opportunities."
title="No project selected"
description="请先在侧边栏选择项目,以加载项目机会。"
title="请选择项目"
/>
);
}
Expand All @@ -54,28 +26,29 @@ export default async function OpportunitiesPage({ searchParams }: OpportunitiesP
const response = await api.opportunities.list(projectId, { page_size: 100 });

return (
<section style={pageStyle}>
<header style={headerStyle}>
<section className="detailPage">
<header className="pageHeader">
<div>
<h1 style={titleStyle}>Opportunity Board</h1>
<p style={subtitleStyle}>
Grouped by status for project {projectId}. Showing {response.items.length} of{" "}
{response.total} opportunities.
<p className="pageEyebrow">机会</p>
<h1 className="pageTitle">机会看板</h1>
<p className="pageSubtitle">
按状态分组展示项目 {projectId} 的机会。当前显示 {response.items.length} /{" "}
{response.total} 个机会。
</p>
</div>
</header>
{response.items.length === 0 ? (
<EmptyState
description="No opportunities returned by the backend for this project."
title="No opportunities"
description="后端未返回当前项目的机会。"
title="暂无机会"
/>
) : (
<OpportunityBoard opportunities={response.items} projectId={projectId} />
)}
</section>
);
} catch (error) {
return <ErrorState error={error} title="Unable to load opportunities" />;
return <ErrorState error={error} title="无法加载机会" />;
}
}

Expand Down
Loading
Loading