diff --git a/apps/backend/src/infrastructure/repositories.py b/apps/backend/src/infrastructure/repositories.py
index 8e8ce5e..4b2266c 100644
--- a/apps/backend/src/infrastructure/repositories.py
+++ b/apps/backend/src/infrastructure/repositories.py
@@ -483,8 +483,6 @@ class ArticlesRepository:
{placeholder},
{placeholder},
{placeholder},
- {placeholder},
- {placeholder},
{placeholder}{json_cast},
{placeholder},
{placeholder},
@@ -493,6 +491,8 @@ class ArticlesRepository:
{placeholder},
{placeholder},
{placeholder},
+ {placeholder},
+ {placeholder},
{placeholder}
)
"""
diff --git a/apps/backend/src/presentation/main.py b/apps/backend/src/presentation/main.py
index f182486..407d57c 100644
--- a/apps/backend/src/presentation/main.py
+++ b/apps/backend/src/presentation/main.py
@@ -1,8 +1,10 @@
+import os
from typing import Any
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from fastapi.responses import JSONResponse
+from fastapi.middleware.cors import CORSMiddleware
from src.domain.contracts.openapi import inject_contract_schemas
from src.presentation.routes.agent_jobs import (
@@ -24,6 +26,21 @@ from src.presentation.routes.workflow_templates import router as workflow_templa
app = FastAPI(title="AI Content Pipeline Backend")
+_allowed_origins = [
+ origin.strip()
+ for origin in os.getenv(
+ "PIPELINE_CORS_ALLOW_ORIGINS",
+ "http://localhost:3000,http://localhost:13300",
+ ).split(",")
+ if origin.strip()
+]
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=_allowed_origins,
+ allow_credentials=False,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
app.include_router(auth_router)
app.include_router(articles_router)
app.include_router(assets_router)
diff --git a/apps/backend/tests/integration/test_demo_browser_cors_public_api.py b/apps/backend/tests/integration/test_demo_browser_cors_public_api.py
new file mode 100644
index 0000000..d3d5914
--- /dev/null
+++ b/apps/backend/tests/integration/test_demo_browser_cors_public_api.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+from fastapi.testclient import TestClient
+
+
+BACKEND_ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(BACKEND_ROOT))
+
+from src.application.seed_data import seed_reference_data # noqa: E402
+from src.infrastructure.repositories import open_backend_repository # noqa: E402
+from src.presentation.dependencies import get_repository # noqa: E402
+from src.presentation.main import app # noqa: E402
+
+
+class DemoBrowserCorsPublicApiTest(unittest.TestCase):
+ def setUp(self) -> None:
+ self.tmp_dir = tempfile.TemporaryDirectory()
+ dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'demo-browser-cors.db'}"
+ self.repository = open_backend_repository(dsn)
+ self.repository.setup()
+ seed_reference_data(self.repository)
+ app.dependency_overrides[get_repository] = lambda: self.repository
+ self.client = TestClient(app)
+
+ def tearDown(self) -> None:
+ app.dependency_overrides.clear()
+ self.tmp_dir.cleanup()
+
+ def test_demo_frontend_origin_can_call_backend_with_demo_user_header(self) -> None:
+ response = self.client.options(
+ "/api/admin/workflows",
+ headers={
+ "Origin": "http://localhost:13300",
+ "Access-Control-Request-Method": "GET",
+ "Access-Control-Request-Headers": "X-Demo-User-Email",
+ },
+ )
+
+ self.assertEqual(200, response.status_code, response.text)
+ self.assertEqual(
+ "http://localhost:13300",
+ response.headers.get("access-control-allow-origin"),
+ )
+ self.assertIn(
+ "X-Demo-User-Email",
+ response.headers.get("access-control-allow-headers", ""),
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/apps/frontend/next.config.mjs b/apps/frontend/next.config.mjs
index 3025cc3..4fcd94a 100644
--- a/apps/frontend/next.config.mjs
+++ b/apps/frontend/next.config.mjs
@@ -7,6 +7,15 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const nextConfig = {
reactStrictMode: true,
outputFileTracingRoot: repoRoot,
+ async rewrites() {
+ const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8000";
+ return [
+ {
+ source: "/api/:path*",
+ destination: `${backendUrl}/api/:path*`,
+ },
+ ];
+ },
turbopack: {
root: repoRoot,
},
diff --git a/apps/frontend/src/app/admin/jobs/page.tsx b/apps/frontend/src/app/admin/jobs/page.tsx
index f99ac85..34fced4 100644
--- a/apps/frontend/src/app/admin/jobs/page.tsx
+++ b/apps/frontend/src/app/admin/jobs/page.tsx
@@ -1,3 +1,7 @@
import AdminJobsPage from "@/pages/admin-jobs";
-export default AdminJobsPage;
+export const dynamic = "force-dynamic";
+
+export default function Page() {
+ return ;
+}
diff --git a/apps/frontend/src/app/admin/scripts/page.tsx b/apps/frontend/src/app/admin/scripts/page.tsx
index f0da7c6..88951f8 100644
--- a/apps/frontend/src/app/admin/scripts/page.tsx
+++ b/apps/frontend/src/app/admin/scripts/page.tsx
@@ -1,3 +1,7 @@
import AdminScriptsPage from "@/pages/admin-scripts";
-export { AdminScriptsPage as default };
+export const dynamic = "force-dynamic";
+
+export default function Page() {
+ return ;
+}
diff --git a/apps/frontend/src/app/admin/sites/page.tsx b/apps/frontend/src/app/admin/sites/page.tsx
index d912481..6c8eec3 100644
--- a/apps/frontend/src/app/admin/sites/page.tsx
+++ b/apps/frontend/src/app/admin/sites/page.tsx
@@ -1,3 +1,7 @@
import AdminSitesPage from "@/pages/admin-sites";
-export default AdminSitesPage;
+export const dynamic = "force-dynamic";
+
+export default function Page() {
+ return ;
+}
diff --git a/apps/frontend/src/app/admin/workflows/page.tsx b/apps/frontend/src/app/admin/workflows/page.tsx
index 9bc4239..31fa600 100644
--- a/apps/frontend/src/app/admin/workflows/page.tsx
+++ b/apps/frontend/src/app/admin/workflows/page.tsx
@@ -1,3 +1,7 @@
import AdminWorkflowsPage from "@/pages/admin-workflows";
-export { AdminWorkflowsPage as default };
+export const dynamic = "force-dynamic";
+
+export default function Page() {
+ return ;
+}
diff --git a/apps/frontend/src/app/articles/[articleId]/boundary-questions/page.tsx b/apps/frontend/src/app/articles/[articleId]/boundary-questions/page.tsx
index 80d14f9..7f6d96d 100644
--- a/apps/frontend/src/app/articles/[articleId]/boundary-questions/page.tsx
+++ b/apps/frontend/src/app/articles/[articleId]/boundary-questions/page.tsx
@@ -1,14 +1,16 @@
import ArticleBoundaryQuestionsPage from "@/pages/article-boundary-questions";
+export const dynamic = "force-dynamic";
+
type DynamicArticleBoundaryQuestionsPageProps = {
- params: {
+ params: Promise<{
articleId: string;
- };
+ }>;
};
export default async function DynamicArticleBoundaryQuestionsPage({
params,
}: DynamicArticleBoundaryQuestionsPageProps) {
- const { articleId } = params;
+ const { articleId } = await params;
return ;
}
diff --git a/apps/frontend/src/app/articles/[articleId]/evidence/page.tsx b/apps/frontend/src/app/articles/[articleId]/evidence/page.tsx
index 68596fe..29bac9a 100644
--- a/apps/frontend/src/app/articles/[articleId]/evidence/page.tsx
+++ b/apps/frontend/src/app/articles/[articleId]/evidence/page.tsx
@@ -1,14 +1,16 @@
import ArticleEvidencePage from "@/pages/article-evidence";
+export const dynamic = "force-dynamic";
+
type DynamicArticleEvidencePageProps = {
- params: {
+ params: Promise<{
articleId: string;
- };
+ }>;
};
export default async function DynamicArticleEvidencePage({
params,
}: DynamicArticleEvidencePageProps) {
- const { articleId } = params;
+ const { articleId } = await params;
return ;
}
diff --git a/apps/frontend/src/app/articles/[articleId]/page.tsx b/apps/frontend/src/app/articles/[articleId]/page.tsx
index 837208a..2f82280 100644
--- a/apps/frontend/src/app/articles/[articleId]/page.tsx
+++ b/apps/frontend/src/app/articles/[articleId]/page.tsx
@@ -1,19 +1,22 @@
import ArticleDetailPage from "@/pages/article-detail";
+export const dynamic = "force-dynamic";
+
type DynamicArticleDetailPageProps = {
- params: {
+ params: Promise<{
articleId: string;
- };
- searchParams?: {
+ }>;
+ searchParams?: Promise<{
role?: string;
- };
+ }>;
};
export default async function DynamicArticleDetailPage({
params,
searchParams,
}: DynamicArticleDetailPageProps) {
- const { articleId } = params;
- const viewerRoleHint = searchParams?.role === "admin" ? "admin" : "editor";
+ const { articleId } = await params;
+ const resolvedSearchParams = searchParams ? await searchParams : undefined;
+ const viewerRoleHint = resolvedSearchParams?.role === "admin" ? "admin" : "editor";
return ;
}
diff --git a/apps/frontend/src/app/articles/[articleId]/plans/page.tsx b/apps/frontend/src/app/articles/[articleId]/plans/page.tsx
index 5068fe8..2797bef 100644
--- a/apps/frontend/src/app/articles/[articleId]/plans/page.tsx
+++ b/apps/frontend/src/app/articles/[articleId]/plans/page.tsx
@@ -1,14 +1,16 @@
import ArticlePlansPage from "@/pages/article-plans";
+export const dynamic = "force-dynamic";
+
type DynamicArticlePlansPageProps = {
- params: {
+ params: Promise<{
articleId: string;
- };
+ }>;
};
export default async function DynamicArticlePlansPage({
params,
}: DynamicArticlePlansPageProps) {
- const { articleId } = params;
+ const { articleId } = await params;
return ;
}
diff --git a/apps/frontend/src/app/articles/[articleId]/research/page.tsx b/apps/frontend/src/app/articles/[articleId]/research/page.tsx
index ad7825c..f59eede 100644
--- a/apps/frontend/src/app/articles/[articleId]/research/page.tsx
+++ b/apps/frontend/src/app/articles/[articleId]/research/page.tsx
@@ -1,14 +1,16 @@
import ArticleResearchPage from "@/pages/article-research";
+export const dynamic = "force-dynamic";
+
type DynamicArticleResearchPageProps = {
- params: {
+ params: Promise<{
articleId: string;
- };
+ }>;
};
export default async function DynamicArticleResearchPage({
params,
}: DynamicArticleResearchPageProps) {
- const { articleId } = params;
+ const { articleId } = await params;
return ;
}
diff --git a/apps/frontend/src/app/articles/new/page.tsx b/apps/frontend/src/app/articles/new/page.tsx
index 40f7d7a..26bad12 100644
--- a/apps/frontend/src/app/articles/new/page.tsx
+++ b/apps/frontend/src/app/articles/new/page.tsx
@@ -1,3 +1,7 @@
import NewArticlePage from "@/pages/article-new";
-export { NewArticlePage as default };
+export const dynamic = "force-dynamic";
+
+export default function Page() {
+ return ;
+}
diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx
index ea66226..c7ccc8f 100644
--- a/apps/frontend/src/app/layout.tsx
+++ b/apps/frontend/src/app/layout.tsx
@@ -6,6 +6,8 @@ export const metadata = {
description: "Internal editorial workflow foundation",
};
+export const dynamic = "force-dynamic";
+
export default function RootLayout({
children,
}: Readonly<{
diff --git a/apps/frontend/src/app/page.tsx b/apps/frontend/src/app/page.tsx
index 6476610..6464e1e 100644
--- a/apps/frontend/src/app/page.tsx
+++ b/apps/frontend/src/app/page.tsx
@@ -1,3 +1,7 @@
import DashboardPage from "@/pages/dashboard";
-export { DashboardPage as default };
+export const dynamic = "force-dynamic";
+
+export default function Page() {
+ return ;
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 385d47f..7fb32e3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,7 +6,6 @@ services:
environment:
NEXT_TELEMETRY_DISABLED: "1"
BACKEND_URL: http://backend:8000
- NEXT_PUBLIC_BACKEND_URL: http://backend:8000
ports:
- "${FRONTEND_PORT:-3000}:3000"
depends_on:
diff --git a/tasks/024-demo-browser-api-reachability.md b/tasks/024-demo-browser-api-reachability.md
new file mode 100644
index 0000000..746f604
--- /dev/null
+++ b/tasks/024-demo-browser-api-reachability.md
@@ -0,0 +1,30 @@
+# Task 024: Demo Browser API Reachability
+
+Development description: Make the Docker Compose demo browser actions call the backend through a browser-reachable URL with CORS enabled, so Admin workflow editing and Editor article creation work during a live demo.
+
+## Acceptance Criteria
+
+- [x] Frontend server-side rendering still uses the internal Docker backend URL.
+- [x] Browser-side frontend actions use a localhost backend URL mapped by Docker Compose.
+- [x] Backend allows the demo frontend origin and `X-Demo-User-Email` header.
+- [x] Admin workflow `Open` action works in the browser demo stack.
+- [x] Editor article creation works in the browser demo stack.
+
+## Result
+
+- Status: Implemented.
+- Added backend CORS middleware for demo frontend origins, configurable through `PIPELINE_CORS_ALLOW_ORIGINS`.
+- Updated Docker Compose so `NEXT_PUBLIC_BACKEND_URL` points to `http://localhost:${BACKEND_PORT:-8000}` while `BACKEND_URL` remains `http://backend:8000` for server-side rendering.
+- Switched browser-side API calls to same-origin `/api/*` through Next rewrites, keeping `BACKEND_URL=http://backend:8000` inside Docker.
+- Fixed the Postgres article insert path by applying JSON casting to the workflow template snapshot column, not `publishing_status`.
+- Updated Next 16 dynamic route adapters to await `params` and marked live-data pages dynamic.
+- Added a public API integration test covering preflight for `http://localhost:13300` with `X-Demo-User-Email`.
+- Verification:
+ - `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_demo_browser_cors_public_api -v` -> `Ran 1 test ... OK`.
+ - `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_workflow_template_binding_public_api apps.backend.tests.integration.test_demo_browser_cors_public_api -v` -> `Ran 5 tests ... OK`.
+ - `pnpm typecheck` -> passed.
+ - `BACKEND_URL=http://localhost:13800 FRONTEND_URL=http://localhost:13300 RUNNER_URL=http://localhost:13810 WAIT_SECONDS=120 bash tests/smoke/public-health.sh` -> `public health smoke ok`.
+ - Browser smoke on Docker demo stack:
+ - `/admin/workflows` -> Open seeded workflow -> stage editor loaded with 7 stages and no error.
+ - `/articles/new` -> create article with active workflow -> redirect to article detail.
+ - Article detail -> shows workflow template name, slug, version, 7-stage snapshot, and timeline.