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
52 changes: 48 additions & 4 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,72 @@
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError

from backend.app.middleware.request_context import request_context_middleware
from backend.app.middleware.security_headers import security_headers_middleware
from backend.app.routes import admin_panel_routes, admin_routes, auth_routes, connected_accounts_routes, dashboard_routes, deployment_routes, domain_routes, job_routes, notification_routes, platform_routes, project_routes, repository_analysis_routes, repository_routes
from backend.app.routes import (
admin_panel_routes,
admin_routes,
auth_routes,
connected_accounts_routes,
dashboard_routes,
deployment_routes,
domain_routes,
home_routes,
job_routes,
notification_routes,
platform_routes,
project_routes,
repository_analysis_routes,
repository_routes,
)
from backend.core.config import get_settings
from backend.core.exceptions import YGITError, unhandled_exception_handler, validation_error_handler, ygit_error_handler
from backend.core.exceptions import (
YGITError,
unhandled_exception_handler,
validation_error_handler,
ygit_error_handler,
)
from backend.core.logging import configure_logging
from backend.engines.auth_engine.public import auth_service


def create_app() -> FastAPI:
configure_logging()
settings = get_settings()
app = FastAPI(title="YGIT API", version=settings.app_version, debug=settings.debug, docs_url="/docs", redoc_url="/redoc", openapi_url=f"{settings.api_prefix}/openapi.json")
app = FastAPI(
title="YGIT API",
version=settings.app_version,
debug=settings.debug,
docs_url="/docs",
redoc_url="/redoc",
openapi_url=f"{settings.api_prefix}/openapi.json",
)
auth_service.configure_app_state(app)
app.middleware("http")(request_context_middleware)
app.middleware("http")(security_headers_middleware)
app.add_exception_handler(YGITError, ygit_error_handler)
app.add_exception_handler(RequestValidationError, validation_error_handler)
app.add_exception_handler(Exception, unhandled_exception_handler)
prefix = settings.api_prefix
app.include_router(home_routes.router)
app.include_router(dashboard_routes.router)
app.include_router(admin_panel_routes.router)
for router in [auth_routes.router, auth_routes.me_router, platform_routes.router, project_routes.router, repository_routes.router, repository_analysis_routes.router, connected_accounts_routes.router, domain_routes.router, deployment_routes.router, job_routes.router, notification_routes.router, admin_routes.router]:
for router in [
auth_routes.router,
auth_routes.me_router,
platform_routes.router,
project_routes.router,
repository_routes.router,
repository_analysis_routes.router,
connected_accounts_routes.router,
domain_routes.router,
deployment_routes.router,
job_routes.router,
notification_routes.router,
admin_routes.router,
]:
app.include_router(router, prefix=prefix)
return app


app = create_app()
68 changes: 68 additions & 0 deletions backend/app/routes/home_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from pathlib import Path

from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse

router = APIRouter(tags=["Homepage"])

_ROOT = Path(__file__).resolve().parents[3]
_HOMEPAGE_ROOT = _ROOT / "frontend" / "homepage"

_MEDIA_TYPES = {
".css": "text/css",
".js": "application/javascript",
".txt": "text/plain",
".xml": "application/xml",
}


def _homepage_file(name: str, *, media_type: str | None = None) -> FileResponse:
candidate = (_HOMEPAGE_ROOT / name).resolve()
try:
candidate.relative_to(_HOMEPAGE_ROOT.resolve())
except ValueError as exc:
raise HTTPException(status_code=404, detail="Asset not found") from exc

if not candidate.is_file():
raise HTTPException(status_code=404, detail="Asset not found")

return FileResponse(
candidate,
media_type=media_type or _MEDIA_TYPES.get(candidate.suffix),
)


@router.get("/", include_in_schema=False)
async def homepage_index() -> FileResponse:
"""Serve the public YGIT product homepage."""

return _homepage_file("index.html", media_type="text/html")


@router.get("/privacy", include_in_schema=False)
@router.get("/privacy.html", include_in_schema=False)
async def privacy_page() -> FileResponse:
return _homepage_file("privacy.html", media_type="text/html")


@router.get("/terms", include_in_schema=False)
@router.get("/terms.html", include_in_schema=False)
async def terms_page() -> FileResponse:
return _homepage_file("terms.html", media_type="text/html")


@router.get("/robots.txt", include_in_schema=False)
async def robots_file() -> FileResponse:
return _homepage_file("robots.txt", media_type="text/plain")


@router.get("/sitemap.xml", include_in_schema=False)
async def sitemap_file() -> FileResponse:
return _homepage_file("sitemap.xml", media_type="application/xml")


@router.get("/homepage/{asset_path:path}", include_in_schema=False)
async def homepage_asset(asset_path: str) -> FileResponse:
"""Serve packaged homepage assets without exposing filesystem traversal."""

return _homepage_file(asset_path)
72 changes: 72 additions & 0 deletions backend/tests/test_homepage_auth_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]
MAIN = (ROOT / "backend/app/main.py").read_text(encoding="utf-8")
ROUTES = (ROOT / "backend/app/routes/home_routes.py").read_text(encoding="utf-8")
INDEX = (ROOT / "frontend/homepage/index.html").read_text(encoding="utf-8")
PRIVACY = (ROOT / "frontend/homepage/privacy.html").read_text(encoding="utf-8")
TERMS = (ROOT / "frontend/homepage/terms.html").read_text(encoding="utf-8")
SCRIPT = (ROOT / "frontend/homepage/script.js").read_text(encoding="utf-8")
STYLES = (ROOT / "frontend/homepage/styles.css").read_text(encoding="utf-8")


def test_public_homepage_router_is_registered_before_protected_surfaces() -> None:
assert "home_routes" in MAIN
assert "app.include_router(home_routes.router)" in MAIN
assert MAIN.index("app.include_router(home_routes.router)") < MAIN.index(
"app.include_router(dashboard_routes.router)"
)
assert 'router.get("/", include_in_schema=False)' in ROUTES
assert "Depends(require_user)" not in ROUTES


def test_homepage_package_has_public_and_legal_routes() -> None:
for token in (
'router.get("/privacy"',
'router.get("/terms"',
'router.get("/robots.txt"',
'router.get("/sitemap.xml"',
'router.get("/homepage/{asset_path:path}"',
):
assert token in ROUTES

assert 'href="/privacy"' in INDEX
assert 'href="/terms"' in INDEX
assert 'href="/#flow"' in PRIVACY
assert 'href="/#flow"' in TERMS


def test_login_and_logout_use_existing_auth_engine_routes() -> None:
assert "/api/v1/auth/login?next=/dashboard" in INDEX
assert "/api/v1/me" in SCRIPT
assert "/api/v1/auth/logout" in SCRIPT
assert "method: 'POST'" in SCRIPT
assert "credentials: 'include'" in SCRIPT
assert "window.location.assign('/')" in SCRIPT

assert "app.ygit.net/login" not in INDEX
assert "app.ygit.net/signup" not in INDEX
assert "auth.vib.tools" not in SCRIPT
assert "localStorage" not in SCRIPT
assert "sessionStorage" not in SCRIPT


def test_authenticated_navigation_has_stable_controls() -> None:
for token in (
"data-auth-login",
"data-auth-start",
"data-auth-dashboard",
"data-auth-logout",
"data-auth-primary-cta",
):
assert token in INDEX

assert "[hidden]{ display:none !important; }" in STYLES
assert "button.btn" in STYLES


def test_homepage_uses_packaged_brand_assets() -> None:
for page in (INDEX, PRIVACY, TERMS):
assert "/dashboard/assets/brand/YGIT_Icon.png" in page
assert "/dashboard/assets/brand/YGIT-Logo-For-Dark-Themes.png" in page
assert "raw.githubusercontent.com" not in page
72 changes: 72 additions & 0 deletions docs/frontend/HOMEPAGE_AUTH_INTEGRATION_SPECIFICATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# YGIT Public Homepage and Browser Authentication Integration Specification

Version: 1.0
Status: Implementation Ready
Product: YGIT
Owner: YGIT Presentation Layer

## Purpose

Integrate the approved YGIT public homepage at `/` while preserving the existing
FastAPI, Auth Engine, and protected dashboard architecture.

## Route Contract

```text
GET / Public homepage
GET /privacy Public privacy page
GET /terms Public terms page
GET /robots.txt Public crawler policy
GET /sitemap.xml Public sitemap
GET /homepage/{asset_path} Public packaged CSS/JavaScript assets
GET /dashboard Existing authenticated dashboard
```

## Authentication Contract

The homepage must use only the existing Auth Engine API:

```text
GET /api/v1/auth/login?next=/dashboard
GET /api/v1/me
POST /api/v1/auth/logout
```

The browser must use the secure server-side session cookie with
`credentials: include`.

The homepage must not:

```text
Handle passwords
Store access, refresh, or ID tokens
Use localStorage or sessionStorage for authentication
Call Keycloak directly
Bypass the Auth Engine
Expose provider credentials
```

## Navigation Behavior

Unauthenticated:

```text
Log in
Get started
```

Authenticated:

```text
Dashboard
Log out
```

Primary homepage calls to action become `Open dashboard` for authenticated
users.

## Layering

The homepage is a Presentation Layer client. Authentication business logic and
session lifecycle remain inside the Auth Engine. The dashboard remains
protected by `require_user`.
Loading
Loading