Task 004: add demo auth and role checks

This commit is contained in:
2026-05-21 18:31:40 +03:00
parent 7a11d50aa7
commit 7972e9a95f
26 changed files with 2207 additions and 23 deletions
+1
View File
@@ -1,4 +1,5 @@
fastapi==0.115.6
httpx==0.28.1
uvicorn[standard]==0.34.0
alembic==1.14.0
SQLAlchemy==2.0.36
+23 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import NAMESPACE_URL, uuid5
from uuid import NAMESPACE_URL, UUID, uuid5
from src.domain.contracts import (
ArticleCreateRequest,
@@ -9,6 +9,10 @@ from src.domain.contracts import (
ArticleSummary,
ArticleWorkflowStatus,
PublishingStatus,
ReviewActionResponse,
ReviewStatus,
ReviewSummary,
ReviewType,
)
@@ -37,3 +41,21 @@ def build_placeholder_article_response(
updated_at=PLACEHOLDER_CREATED_AT,
)
return ArticleCreateResponse(article=article)
def build_placeholder_plan_approval_response(
*,
article_id: UUID,
plan_id: UUID,
reviewer_id: UUID,
) -> ReviewActionResponse:
review = ReviewSummary(
id=uuid5(NAMESPACE_URL, f"plan-approval:{article_id}:{plan_id}:{reviewer_id}"),
article_id=article_id,
review_type=ReviewType.PLAN,
status=ReviewStatus.APPROVED,
reviewer_id=reviewer_id,
notes=None,
created_at=PLACEHOLDER_CREATED_AT,
)
return ReviewActionResponse(review=review)
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from src.domain.contracts import CurrentUser
def get_current_user_by_email(repository: object, email: str) -> CurrentUser:
user = repository.users.get_by_email(email)
return CurrentUser(
id=user.id,
email=email,
display_name=user.display_name,
role=user.role,
)
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
from datetime import UTC, datetime
from hashlib import sha256
from uuid import NAMESPACE_URL, UUID, uuid5
from src.domain.contracts import (
CurrentUser,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionResponse,
ScriptConfigVersionStatus,
ScriptConfigVersionSummary,
TargetSiteConfigCreateRequest,
TargetSiteConfigResponse,
TargetSiteConfigUpdateRequest,
)
def create_target_site(
repository: object,
request: TargetSiteConfigCreateRequest,
) -> TargetSiteConfigResponse:
now = _now()
site = repository.target_sites.upsert(
site_id=_stable_uuid(f"target-site:{request.slug}"),
name=request.name,
slug=request.slug,
publishing_type=request.publishing_type,
default_language=request.default_language,
brand_voice=request.brand_voice,
audience=request.audience,
seo_rules=request.seo_rules,
visual_rules=request.visual_rules,
source_rules=request.source_rules,
publishing_rules=request.publishing_rules,
active_script_config_version_id=request.active_script_config_version_id,
created_at=now,
updated_at=now,
)
return TargetSiteConfigResponse(site=site)
def update_target_site(
repository: object,
site_id: UUID,
request: TargetSiteConfigUpdateRequest,
) -> TargetSiteConfigResponse:
existing = repository.target_sites.get_by_id(site_id)
changes = request.model_dump(exclude_unset=True)
publishing_rules = (
request.publishing_rules
if "publishing_rules" in changes
else existing.publishing_rules
)
now = _now()
site = repository.target_sites.update(
site_id=existing.id,
name=changes.get("name", existing.name),
slug=changes.get("slug", existing.slug),
publishing_type=changes.get("publishing_type", existing.publishing_type),
default_language=changes.get("default_language", existing.default_language),
brand_voice=changes.get("brand_voice", existing.brand_voice),
audience=changes.get("audience", existing.audience),
seo_rules=changes.get("seo_rules", existing.seo_rules),
visual_rules=changes.get("visual_rules", existing.visual_rules),
source_rules=changes.get("source_rules", existing.source_rules),
publishing_rules=publishing_rules,
active_script_config_version_id=changes.get(
"active_script_config_version_id",
existing.active_script_config_version_id,
),
updated_at=now,
)
return TargetSiteConfigResponse(site=site)
def create_script_config_version(
repository: object,
*,
site_id: UUID,
current_user: CurrentUser,
request: ScriptConfigVersionCreateRequest,
) -> ScriptConfigVersionResponse:
repository.target_sites.get_by_id(site_id)
existing_versions = repository.script_config_versions.list_for_site(site_id)
version = request.version or _next_version(existing_versions)
now = _now()
version_id = _stable_uuid(f"script-config-version:{site_id}:{version}")
status = (
ScriptConfigVersionStatus.ACTIVE
if request.activate
else ScriptConfigVersionStatus.DRAFT
)
row = repository.script_config_versions.upsert(
version_id=version_id,
target_site_id=site_id,
version=version,
status=status,
created_by=current_user.id,
created_at=now,
updated_at=now,
diff=request.diff,
rollback_target_version_id=request.rollback_target_version_id,
activated_at=now if request.activate else None,
publishing_yaml=request.publishing_yaml,
publishing_yaml_hash=_sha256(request.publishing_yaml),
transform_script=request.transform_script,
transform_script_hash=_sha256(request.transform_script),
)
if request.activate:
row = repository.script_config_versions.activate(
target_site_id=site_id,
version_id=version_id,
activated_at=now,
)
return ScriptConfigVersionResponse(version=_script_version_summary(row))
def activate_script_config_version(
repository: object,
*,
site_id: UUID,
version_id: UUID,
) -> ScriptConfigVersionResponse:
repository.target_sites.get_by_id(site_id)
row = repository.script_config_versions.activate(
target_site_id=site_id,
version_id=version_id,
activated_at=_now(),
)
return ScriptConfigVersionResponse(version=_script_version_summary(row))
def list_script_config_versions(
repository: object,
site_id: UUID,
) -> list[ScriptConfigVersionSummary]:
repository.target_sites.get_by_id(site_id)
rows = repository.script_config_versions.list_for_site(site_id)
return [_script_version_summary(row) for row in rows]
def _script_version_summary(row: dict[str, object]) -> ScriptConfigVersionSummary:
return ScriptConfigVersionSummary(
id=row["id"],
target_site_id=row["target_site_id"],
version=row["version"],
status=row["status"],
publishing_yaml_hash=row["publishing_yaml_hash"],
transform_script_hash=row["transform_script_hash"],
created_by=row["created_by"],
created_at=row["created_at"],
)
def _next_version(rows: list[dict[str, object]]) -> int:
versions = [int(row["version"]) for row in rows]
if not versions:
return 1
return max(versions) + 1
def _stable_uuid(value: str) -> UUID:
return uuid5(NAMESPACE_URL, f"ai-content-pipeline:{value}")
def _sha256(value: str) -> str:
return sha256(value.encode("utf-8")).hexdigest()
def _now() -> datetime:
return datetime.now(UTC)
+7
View File
@@ -0,0 +1,7 @@
from __future__ import annotations
from src.domain.contracts.enums import Role
ADMIN_ROLES = frozenset({Role.ADMIN})
EDITOR_OR_ADMIN_ROLES = frozenset({Role.ADMIN, Role.EDITOR})
@@ -24,6 +24,8 @@ from .models import (
ArticleSummary,
AssetSummary,
ClaimSummary,
CurrentUser,
CurrentUserResponse,
DraftSummary,
EvidenceSummary,
PlanSectionSummary,
@@ -32,10 +34,17 @@ from .models import (
PublishingRules,
ResearchArtifactManifestSummary,
ResearchArtifactSummary,
ReviewActionResponse,
ReviewSummary,
RunnerFileRef,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
ScriptConfigVersionResponse,
ScriptConfigVersionSummary,
TargetSiteConfig,
TargetSiteConfigCreateRequest,
TargetSiteConfigResponse,
TargetSiteConfigUpdateRequest,
UserSummary,
)
from .workflow import (
@@ -64,6 +73,8 @@ __all__ = [
"ClaimRiskLevel",
"ClaimSummary",
"ClaimSupportStatus",
"CurrentUser",
"CurrentUserResponse",
"DraftSummary",
"EvidenceSummary",
"PlanReviewStatus",
@@ -74,14 +85,21 @@ __all__ = [
"PublishingStatus",
"ResearchArtifactManifestSummary",
"ResearchArtifactSummary",
"ReviewActionResponse",
"ReviewStatus",
"ReviewSummary",
"ReviewType",
"Role",
"RunnerFileRef",
"ScriptConfigVersionCreateRequest",
"ScriptConfigVersionListResponse",
"ScriptConfigVersionResponse",
"ScriptConfigVersionStatus",
"ScriptConfigVersionSummary",
"TargetSiteConfig",
"TargetSiteConfigCreateRequest",
"TargetSiteConfigResponse",
"TargetSiteConfigUpdateRequest",
"UserSummary",
"next_workflow_statuses",
]
@@ -37,6 +37,17 @@ class UserSummary(ContractModel):
role: Role
class CurrentUser(ContractModel):
id: UUID
email: str = Field(min_length=1)
display_name: str = Field(min_length=1)
role: Role
class CurrentUserResponse(ContractModel):
user: CurrentUser
class PublishingRules(ContractModel):
repository_url: str = Field(min_length=1)
production_branch: str = Field(min_length=1)
@@ -65,6 +76,38 @@ class TargetSiteConfig(ContractModel):
updated_at: datetime
class TargetSiteConfigCreateRequest(ContractModel):
name: str = Field(min_length=1)
slug: str = Field(min_length=1)
publishing_type: str = Field(default="git_next", min_length=1)
default_language: str = Field(default="en", min_length=2)
brand_voice: str = Field(min_length=1)
audience: str = Field(min_length=1)
seo_rules: JsonObject = Field(default_factory=dict)
visual_rules: JsonObject = Field(default_factory=dict)
source_rules: JsonObject = Field(default_factory=dict)
publishing_rules: PublishingRules
active_script_config_version_id: UUID | None = None
class TargetSiteConfigUpdateRequest(ContractModel):
name: str | None = Field(default=None, min_length=1)
slug: str | None = Field(default=None, min_length=1)
publishing_type: str | None = Field(default=None, min_length=1)
default_language: str | None = Field(default=None, min_length=2)
brand_voice: str | None = Field(default=None, min_length=1)
audience: str | None = Field(default=None, min_length=1)
seo_rules: JsonObject | None = None
visual_rules: JsonObject | None = None
source_rules: JsonObject | None = None
publishing_rules: PublishingRules | None = None
active_script_config_version_id: UUID | None = None
class TargetSiteConfigResponse(ContractModel):
site: TargetSiteConfig
class ScriptConfigVersionSummary(ContractModel):
id: UUID
target_site_id: UUID
@@ -76,6 +119,23 @@ class ScriptConfigVersionSummary(ContractModel):
created_at: datetime
class ScriptConfigVersionCreateRequest(ContractModel):
publishing_yaml: str = Field(min_length=1)
transform_script: str = Field(min_length=1)
diff: JsonObject = Field(default_factory=dict)
rollback_target_version_id: UUID | None = None
activate: bool = False
version: int | None = Field(default=None, ge=1)
class ScriptConfigVersionResponse(ContractModel):
version: ScriptConfigVersionSummary
class ScriptConfigVersionListResponse(ContractModel):
versions: list[ScriptConfigVersionSummary]
class ArticleCreateRequest(ContractModel):
target_site_id: UUID
brief_description: str = Field(min_length=1)
@@ -208,6 +268,10 @@ class ReviewSummary(ContractModel):
created_at: datetime
class ReviewActionResponse(ContractModel):
review: ReviewSummary
class PublishCommitSummary(ContractModel):
id: UUID
article_id: UUID
@@ -31,6 +31,8 @@ from .models import (
ArticleSummary,
AssetSummary,
ClaimSummary,
CurrentUser,
CurrentUserResponse,
DraftSummary,
EvidenceSummary,
PlanSectionSummary,
@@ -39,10 +41,17 @@ from .models import (
PublishingRules,
ResearchArtifactManifestSummary,
ResearchArtifactSummary,
ReviewActionResponse,
ReviewSummary,
RunnerFileRef,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
ScriptConfigVersionResponse,
ScriptConfigVersionSummary,
TargetSiteConfig,
TargetSiteConfigCreateRequest,
TargetSiteConfigResponse,
TargetSiteConfigUpdateRequest,
UserSummary,
)
@@ -66,9 +75,17 @@ CONTRACT_ENUMS: tuple[type[Enum], ...] = (
CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
UserSummary,
CurrentUser,
CurrentUserResponse,
PublishingRules,
TargetSiteConfig,
TargetSiteConfigCreateRequest,
TargetSiteConfigUpdateRequest,
TargetSiteConfigResponse,
ScriptConfigVersionSummary,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionResponse,
ScriptConfigVersionListResponse,
ArticleCreateRequest,
ArticleSummary,
ArticleCreateResponse,
@@ -82,6 +99,7 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
DraftSummary,
AssetSummary,
ReviewSummary,
ReviewActionResponse,
PublishCommitSummary,
RunnerFileRef,
AgentJobSummary,
@@ -270,6 +270,93 @@ class TargetSitesRepository:
raise LookupError(f"Target site not found: {slug}")
return _target_site_from_row(row)
def get_by_id(self, site_id: UUID) -> TargetSiteConfig:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT
id,
name,
slug,
publishing_type,
default_language,
brand_voice,
audience,
seo_rules,
visual_rules,
source_rules,
publishing_rules,
active_script_config_version_id,
created_at,
updated_at
FROM target_sites
WHERE id = {placeholder}
""",
(str(site_id),),
).fetchone()
if row is None:
raise LookupError(f"Target site not found: {site_id}")
return _target_site_from_row(row)
def update(
self,
*,
site_id: UUID,
name: str,
slug: str,
publishing_type: str,
default_language: str,
brand_voice: str,
audience: str,
seo_rules: JsonObject,
visual_rules: JsonObject,
source_rules: JsonObject,
publishing_rules: PublishingRules,
active_script_config_version_id: UUID | None,
updated_at: datetime,
) -> TargetSiteConfig:
placeholder = self._repository.placeholder()
json_cast = self._repository.json_cast()
with self._repository.connection() as connection:
connection.execute(
f"""
UPDATE target_sites
SET
name = {placeholder},
slug = {placeholder},
publishing_type = {placeholder},
default_language = {placeholder},
brand_voice = {placeholder},
audience = {placeholder},
seo_rules = {placeholder}{json_cast},
visual_rules = {placeholder}{json_cast},
source_rules = {placeholder}{json_cast},
publishing_rules = {placeholder}{json_cast},
active_script_config_version_id = {placeholder},
updated_at = {placeholder}
WHERE id = {placeholder}
""",
(
name,
slug,
publishing_type,
default_language,
brand_voice,
audience,
_json_value(seo_rules),
_json_value(visual_rules),
_json_value(source_rules),
_json_value(publishing_rules.model_dump(mode="json")),
_uuid_value(active_script_config_version_id),
_datetime_value(updated_at),
str(site_id),
),
)
return self.get_by_id(site_id)
def list(self) -> list[TargetSiteConfig]:
with self._repository.connection() as connection:
rows = connection.execute(
@@ -407,6 +494,88 @@ class ScriptConfigVersionsRepository:
)
return _plain_row(row)
def get_by_id(self, version_id: UUID) -> dict[str, Any]:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT *
FROM script_config_versions
WHERE id = {placeholder}
""",
(str(version_id),),
).fetchone()
if row is None:
raise LookupError(f"Script config version not found: {version_id}")
return _plain_row(row)
def activate(
self,
*,
target_site_id: UUID,
version_id: UUID,
activated_at: datetime,
) -> dict[str, Any]:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
existing = connection.execute(
f"""
SELECT id
FROM script_config_versions
WHERE id = {placeholder} AND target_site_id = {placeholder}
""",
(str(version_id), str(target_site_id)),
).fetchone()
if existing is None:
raise LookupError(
f"Script config version not found: {target_site_id} {version_id}"
)
connection.execute(
f"""
UPDATE script_config_versions
SET
status = {placeholder},
updated_at = {placeholder}
WHERE target_site_id = {placeholder} AND id <> {placeholder}
""",
(
ScriptConfigVersionStatus.DEPRECATED.value,
_datetime_value(activated_at),
str(target_site_id),
str(version_id),
),
)
connection.execute(
f"""
UPDATE script_config_versions
SET
status = {placeholder},
activated_at = {placeholder},
updated_at = {placeholder}
WHERE id = {placeholder}
""",
(
ScriptConfigVersionStatus.ACTIVE.value,
_datetime_value(activated_at),
_datetime_value(activated_at),
str(version_id),
),
)
connection.execute(
f"""
UPDATE target_sites
SET
active_script_config_version_id = {placeholder},
updated_at = {placeholder}
WHERE id = {placeholder}
""",
(str(version_id), _datetime_value(activated_at), str(target_site_id)),
)
return self.get_by_id(version_id)
def list_for_site(self, target_site_id: UUID) -> list[dict[str, Any]]:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
@@ -0,0 +1,86 @@
from __future__ import annotations
import os
import tempfile
from collections.abc import Callable, Iterable
from pathlib import Path
from fastapi import Depends, Header, HTTPException, status
from src.application.auth import get_current_user_by_email
from src.application.seed_data import seed_reference_data
from src.domain.contracts import CurrentUser, Role
from src.infrastructure.repositories import BackendRepository, open_backend_repository
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
UNAUTHORIZED_DETAIL = "Unauthorized"
FORBIDDEN_DETAIL = "Forbidden"
_runtime_repository: BackendRepository | None = None
_runtime_repository_dsn: str | None = None
_runtime_temp_dir: tempfile.TemporaryDirectory[str] | None = None
def get_repository() -> BackendRepository:
global _runtime_repository
global _runtime_repository_dsn
global _runtime_temp_dir
dsn = (
os.environ.get("DATABASE_URL")
or os.environ.get("POSTGRES_DSN")
or os.environ.get("PIPELINE_TEST_DATABASE_DSN")
)
if dsn is None:
if _runtime_temp_dir is None:
_runtime_temp_dir = tempfile.TemporaryDirectory()
sqlite_path = Path(_runtime_temp_dir.name) / "pipeline-demo-auth.db"
dsn = f"sqlite:///{sqlite_path}"
if _runtime_repository is not None and _runtime_repository_dsn == dsn:
return _runtime_repository
repository = open_backend_repository(dsn)
repository.setup()
seed_reference_data(repository)
_runtime_repository = repository
_runtime_repository_dsn = dsn
return repository
def get_current_user(
demo_user_email: str | None = Header(default=None, alias=DEMO_USER_EMAIL_HEADER),
repository: BackendRepository = Depends(get_repository),
) -> CurrentUser:
if not demo_user_email:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=UNAUTHORIZED_DETAIL,
)
try:
return get_current_user_by_email(repository, demo_user_email)
except LookupError as error:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=UNAUTHORIZED_DETAIL,
) from error
def require_roles(
allowed_roles: Iterable[Role],
) -> Callable[[CurrentUser], CurrentUser]:
allowed = frozenset(allowed_roles)
def dependency(
current_user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
if current_user.role not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=FORBIDDEN_DETAIL,
)
return current_user
return dependency
+4
View File
@@ -6,10 +6,14 @@ from fastapi.responses import JSONResponse
from src.domain.contracts.openapi import inject_contract_schemas
from src.presentation.routes.articles import router as articles_router
from src.presentation.routes.auth import router as auth_router
from src.presentation.routes.sites import router as sites_router
app = FastAPI(title="AI Content Pipeline Backend")
app.include_router(auth_router)
app.include_router(articles_router)
app.include_router(sites_router)
@app.get("/health")
@@ -1,9 +1,21 @@
from __future__ import annotations
from fastapi import APIRouter, status
from uuid import UUID
from src.application.articles import build_placeholder_article_response
from src.domain.contracts import ArticleCreateRequest, ArticleCreateResponse
from fastapi import APIRouter, Depends, status
from src.application.articles import (
build_placeholder_article_response,
build_placeholder_plan_approval_response,
)
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
ArticleCreateRequest,
ArticleCreateResponse,
CurrentUser,
ReviewActionResponse,
)
from src.presentation.dependencies import require_roles
router = APIRouter(prefix="/api", tags=["articles"])
@@ -14,5 +26,24 @@ router = APIRouter(prefix="/api", tags=["articles"])
response_model=ArticleCreateResponse,
status_code=status.HTTP_201_CREATED,
)
def create_article(request: ArticleCreateRequest) -> ArticleCreateResponse:
def create_article(
request: ArticleCreateRequest,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
) -> ArticleCreateResponse:
return build_placeholder_article_response(request)
@router.post(
"/articles/{article_id}/plans/{plan_id}/approve",
response_model=ReviewActionResponse,
)
def approve_plan(
article_id: UUID,
plan_id: UUID,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
) -> ReviewActionResponse:
return build_placeholder_plan_approval_response(
article_id=article_id,
plan_id=plan_id,
reviewer_id=current_user.id,
)
@@ -0,0 +1,16 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from src.domain.contracts import CurrentUser, CurrentUserResponse
from src.presentation.dependencies import get_current_user
router = APIRouter(prefix="/api", tags=["auth"])
@router.get("/me", response_model=CurrentUserResponse)
def get_me(
current_user: CurrentUser = Depends(get_current_user),
) -> CurrentUserResponse:
return CurrentUserResponse(user=current_user)
@@ -0,0 +1,120 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.site_config import (
activate_script_config_version,
create_script_config_version,
create_target_site,
list_script_config_versions,
update_target_site,
)
from src.domain.auth import ADMIN_ROLES, EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
CurrentUser,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
ScriptConfigVersionResponse,
TargetSiteConfigCreateRequest,
TargetSiteConfigResponse,
TargetSiteConfigUpdateRequest,
)
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["sites"])
@router.get("/sites", response_model=list[TargetSiteConfigResponse])
def list_sites(
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> list[TargetSiteConfigResponse]:
return [TargetSiteConfigResponse(site=site) for site in repository.target_sites.list()]
@router.post(
"/sites",
response_model=TargetSiteConfigResponse,
status_code=status.HTTP_201_CREATED,
)
def post_site(
request: TargetSiteConfigCreateRequest,
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> TargetSiteConfigResponse:
return create_target_site(repository, request)
@router.patch("/sites/{site_id}", response_model=TargetSiteConfigResponse)
def patch_site(
site_id: UUID,
request: TargetSiteConfigUpdateRequest,
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> TargetSiteConfigResponse:
try:
return update_target_site(repository, site_id, request)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.get(
"/sites/{site_id}/publishing-config/versions",
response_model=ScriptConfigVersionListResponse,
)
def get_script_config_versions(
site_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ScriptConfigVersionListResponse:
try:
versions = list_script_config_versions(repository, site_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
return ScriptConfigVersionListResponse(versions=versions)
@router.post(
"/sites/{site_id}/publishing-config/versions",
response_model=ScriptConfigVersionResponse,
status_code=status.HTTP_201_CREATED,
)
def post_script_config_version(
site_id: UUID,
request: ScriptConfigVersionCreateRequest,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ScriptConfigVersionResponse:
try:
return create_script_config_version(
repository,
site_id=site_id,
current_user=current_user,
request=request,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/sites/{site_id}/publishing-config/versions/{version_id}/activate",
response_model=ScriptConfigVersionResponse,
)
def post_activate_script_config_version(
site_id: UUID,
version_id: UUID,
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ScriptConfigVersionResponse:
try:
return activate_script_config_version(
repository,
site_id=site_id,
version_id=version_id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@@ -0,0 +1,227 @@
from __future__ import annotations
import sys
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.presentation.main import app # noqa: E402
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_ADMIN_EMAIL = "admin@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class AuthAuthorizationPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.client = TestClient(app)
def test_me_returns_selected_demo_user_and_role(self) -> None:
response = self.client.get(
"/api/me",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
self.assertEqual(DEMO_ADMIN_EMAIL, response.json()["user"]["email"])
self.assertEqual("ADMIN", response.json()["user"]["role"])
def test_missing_and_unknown_demo_users_are_rejected_consistently(self) -> None:
missing_response = self.client.get("/api/me")
unknown_response = self.client.get(
"/api/me",
headers={DEMO_USER_EMAIL_HEADER: "missing@example.com"},
)
self.assertEqual(401, missing_response.status_code, missing_response.text)
self.assertEqual(401, unknown_response.status_code, unknown_response.text)
self.assertEqual({"detail": "Unauthorized"}, missing_response.json())
self.assertEqual({"detail": "Unauthorized"}, unknown_response.json())
def test_editor_cannot_create_target_site_config(self) -> None:
response = self.client.post(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json=self._site_payload("editorial_ops_blog"),
)
self.assertEqual(403, response.status_code, response.text)
self.assertEqual({"detail": "Forbidden"}, response.json())
def test_admin_can_create_and_update_target_site_config(self) -> None:
create_response = self.client.post(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json=self._site_payload("admin_ops_blog"),
)
self.assertEqual(201, create_response.status_code, create_response.text)
site = create_response.json()["site"]
self.assertEqual("admin_ops_blog", site["slug"])
update_response = self.client.patch(
f"/api/sites/{site['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json={"brand_voice": "Updated practical editorial operations guidance."},
)
self.assertEqual(200, update_response.status_code, update_response.text)
self.assertEqual(
"Updated practical editorial operations guidance.",
update_response.json()["site"]["brand_voice"],
)
def test_editor_cannot_update_site_or_mutate_script_versions(self) -> None:
create_response = self.client.post(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json=self._site_payload("script_editor_denied_blog"),
)
self.assertEqual(201, create_response.status_code, create_response.text)
site_id = create_response.json()["site"]["id"]
version_response = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json=self._script_version_payload(),
)
self.assertEqual(201, version_response.status_code, version_response.text)
version_id = version_response.json()["version"]["id"]
site_update_response = self.client.patch(
f"/api/sites/{site_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"brand_voice": "Editor should not mutate Admin config."},
)
version_create_response = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json=self._script_version_payload(),
)
version_activate_response = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions/{version_id}/activate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(403, site_update_response.status_code, site_update_response.text)
self.assertEqual(
403,
version_create_response.status_code,
version_create_response.text,
)
self.assertEqual(
403,
version_activate_response.status_code,
version_activate_response.text,
)
def test_admin_can_create_and_activate_script_config_version(self) -> None:
site_response = self.client.post(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json=self._site_payload("script_admin_blog"),
)
self.assertEqual(201, site_response.status_code, site_response.text)
site_id = site_response.json()["site"]["id"]
create_response = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json=self._script_version_payload(),
)
self.assertEqual(201, create_response.status_code, create_response.text)
self.assertEqual("DRAFT", create_response.json()["version"]["status"])
version_id = create_response.json()["version"]["id"]
activate_response = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions/{version_id}/activate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(200, activate_response.status_code, activate_response.text)
self.assertEqual("ACTIVE", activate_response.json()["version"]["status"])
def test_editor_can_create_articles_and_approve_plan_review(self) -> None:
sites_response = self.client.get(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, sites_response.status_code, sites_response.text)
target_site_id = sites_response.json()[0]["site"]["id"]
article_response = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": target_site_id,
"brief_description": "Write about editorial AI workflow controls.",
"working_title": "Editorial AI Workflow Controls",
},
)
self.assertEqual(201, article_response.status_code, article_response.text)
self.assertEqual(
"ARTICLE_BRIEF_CREATED",
article_response.json()["article"]["status"],
)
article_id = article_response.json()["article"]["id"]
review_response = self.client.post(
f"/api/articles/{article_id}/plans/00000000-0000-0000-0000-000000000123/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, review_response.status_code, review_response.text)
self.assertEqual("PLAN", review_response.json()["review"]["review_type"])
self.assertEqual("APPROVED", review_response.json()["review"]["status"])
def test_unauthorized_requests_are_rejected_for_protected_mutations(self) -> None:
response = self.client.post(
"/api/articles",
json={
"target_site_id": "00000000-0000-0000-0000-000000000001",
"brief_description": "No user selected.",
},
)
self.assertEqual(401, response.status_code, response.text)
self.assertEqual({"detail": "Unauthorized"}, response.json())
def _site_payload(self, slug: str) -> dict[str, object]:
return {
"name": "Editorial Ops Blog",
"slug": slug,
"publishing_type": "git_next",
"default_language": "en",
"brand_voice": "Practical editorial operations guidance.",
"audience": "Editorial teams running AI-assisted workflows.",
"seo_rules": {"primary_keyword_required": True},
"visual_rules": {"hero_style": "product editorial"},
"source_rules": {"minimum_sources": 3},
"publishing_rules": {
"repository_url": "git@github.com:example/editorial-ops.git",
"production_branch": "main",
"content_format": "mdx",
"content_path_template": "content/articles/{slug}.mdx",
"asset_path_template": "public/articles/{slug}/{filename}",
"frontmatter_mapping": {"title": "title"},
"dry_run_renderer": "next-mdx",
},
}
def _script_version_payload(self) -> dict[str, object]:
return {
"publishing_yaml": "target: editorial_ops\nrepository:\n branch: main\n",
"transform_script": "export function transformArticle(article) { return article; }\n",
"diff": {"summary": "Task 004 authorization fixture."},
}
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -5,6 +5,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"test:roles": "node tests/role-navigation.test.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
+67
View File
@@ -19,3 +19,70 @@ main {
min-height: 100vh;
padding: 48px;
}
.pageHeader {
display: flex;
max-width: 920px;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-bottom: 28px;
}
.pageHeader h1 {
margin: 0 0 8px;
font-size: 32px;
line-height: 1.2;
letter-spacing: 0;
}
.pageHeader p {
margin: 0;
color: #667085;
}
.roleBadge {
display: inline-flex;
min-height: 32px;
align-items: center;
border: 1px solid #b7c0cc;
border-radius: 6px;
padding: 0 12px;
background: #ffffff;
color: #344054;
font-size: 14px;
}
.roleNavigation {
display: grid;
max-width: 920px;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
padding: 0;
margin: 0;
list-style: none;
}
.roleNavigation a {
display: flex;
min-height: 52px;
align-items: center;
border: 1px solid #d0d5dd;
border-radius: 8px;
padding: 0 16px;
background: #ffffff;
color: #1d2733;
font-weight: 600;
text-decoration: none;
}
@media (max-width: 640px) {
main {
padding: 28px;
}
.pageHeader {
align-items: flex-start;
flex-direction: column;
}
}
+10 -5
View File
@@ -1,5 +1,7 @@
import type { ArticleListResponse } from "@pipeline/shared";
import { RoleNavigation } from "@/widgets/role-navigation";
const initialArticles: ArticleListResponse = {
articles: [],
};
@@ -7,11 +9,14 @@ const initialArticles: ArticleListResponse = {
export default function HomePage() {
return (
<main>
<h1>AI Content Pipeline</h1>
<p>Dockerized monorepo foundation is running.</p>
<p>
{initialArticles.articles.length} articles in the contract-backed dashboard.
</p>
<header className="pageHeader">
<div>
<h1>AI Content Pipeline</h1>
<p>{initialArticles.articles.length} active article workflows</p>
</div>
<span className="roleBadge">Editor demo</span>
</header>
<RoleNavigation role="EDITOR" />
</main>
);
}
@@ -0,0 +1,3 @@
export { getRoleNavigationItems, ROLE_NAVIGATION_ITEMS } from "./model";
export type { NavigationItem } from "./model";
export { RoleNavigation } from "./ui";
@@ -0,0 +1,47 @@
import type { Role } from "@pipeline/shared";
export type NavigationItem = {
id: string;
label: string;
href: string;
roles: readonly Role[];
};
export const ROLE_NAVIGATION_ITEMS = [
{
id: "dashboard",
label: "Dashboard",
href: "/",
roles: ["ADMIN", "EDITOR"],
},
{
id: "new-article",
label: "New article",
href: "/articles/new",
roles: ["ADMIN", "EDITOR"],
},
{
id: "review",
label: "Review",
href: "/review",
roles: ["ADMIN", "EDITOR"],
},
{
id: "site-config",
label: "Site configuration",
href: "/admin/sites",
roles: ["ADMIN"],
},
{
id: "script-versions",
label: "Script versions",
href: "/admin/scripts",
roles: ["ADMIN"],
},
] as const satisfies readonly NavigationItem[];
export function getRoleNavigationItems(role: Role): NavigationItem[] {
return ROLE_NAVIGATION_ITEMS.filter((item) =>
item.roles.some((allowedRole) => allowedRole === role),
);
}
@@ -0,0 +1,23 @@
import type { Role } from "@pipeline/shared";
import { getRoleNavigationItems } from "./model";
type RoleNavigationProps = {
role: Role;
};
export function RoleNavigation({ role }: RoleNavigationProps) {
const items = getRoleNavigationItems(role);
return (
<nav aria-label="Primary navigation">
<ul className="roleNavigation">
{items.map((item) => (
<li key={item.id}>
<a href={item.href}>{item.label}</a>
</li>
))}
</ul>
</nav>
);
}
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const testDir = dirname(fileURLToPath(import.meta.url));
const sourcePath = resolve(testDir, "../src/widgets/role-navigation/model.ts");
const source = readFileSync(sourcePath, "utf8");
const transpiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
});
const exports = {};
new Function("exports", transpiled.outputText)(exports);
const editorItems = exports.getRoleNavigationItems("EDITOR");
const adminItems = exports.getRoleNavigationItems("ADMIN");
assert.deepEqual(
editorItems.map((item) => item.id),
["dashboard", "new-article", "review"],
);
assert.equal(
editorItems.some((item) => item.id === "site-config" || item.id === "script-versions"),
false,
);
assert.equal(
adminItems.some((item) => item.id === "site-config"),
true,
);
assert.equal(
adminItems.some((item) => item.id === "script-versions"),
true,
);
console.log("role navigation hides Admin-only items for Editors");
+1
View File
@@ -6,6 +6,7 @@
"dev:stack": "docker compose up --build",
"generate:contracts": "python3 scripts/generate_openapi_contracts.py",
"smoke": "bash tests/smoke/public-health.sh",
"test:frontend:roles": "pnpm --filter @pipeline/frontend test:roles",
"typecheck": "pnpm --filter @pipeline/shared typecheck && pnpm --filter @pipeline/frontend typecheck"
},
"workspaces": [
+892
View File
@@ -703,6 +703,50 @@
"title": "ClaimSupportStatus",
"type": "string"
},
"CurrentUser": {
"additionalProperties": false,
"properties": {
"display_name": {
"minLength": 1,
"title": "Display Name",
"type": "string"
},
"email": {
"minLength": 1,
"title": "Email",
"type": "string"
},
"id": {
"format": "uuid",
"title": "Id",
"type": "string"
},
"role": {
"$ref": "#/components/schemas/Role"
}
},
"required": [
"id",
"email",
"display_name",
"role"
],
"title": "CurrentUser",
"type": "object"
},
"CurrentUserResponse": {
"additionalProperties": false,
"properties": {
"user": {
"$ref": "#/components/schemas/CurrentUser"
}
},
"required": [
"user"
],
"title": "CurrentUserResponse",
"type": "object"
},
"DraftSummary": {
"additionalProperties": false,
"properties": {
@@ -1285,6 +1329,19 @@
"title": "ResearchArtifactSummary",
"type": "object"
},
"ReviewActionResponse": {
"additionalProperties": false,
"properties": {
"review": {
"$ref": "#/components/schemas/ReviewSummary"
}
},
"required": [
"review"
],
"title": "ReviewActionResponse",
"type": "object"
},
"ReviewStatus": {
"enum": [
"PENDING",
@@ -1400,6 +1457,93 @@
"title": "RunnerFileRef",
"type": "object"
},
"ScriptConfigVersionCreateRequest": {
"additionalProperties": false,
"properties": {
"activate": {
"default": false,
"title": "Activate",
"type": "boolean"
},
"diff": {
"additionalProperties": true,
"title": "Diff",
"type": "object"
},
"publishing_yaml": {
"minLength": 1,
"title": "Publishing Yaml",
"type": "string"
},
"rollback_target_version_id": {
"anyOf": [
{
"format": "uuid",
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Rollback Target Version Id"
},
"transform_script": {
"minLength": 1,
"title": "Transform Script",
"type": "string"
},
"version": {
"anyOf": [
{
"minimum": 1,
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"title": "Version"
}
},
"required": [
"publishing_yaml",
"transform_script"
],
"title": "ScriptConfigVersionCreateRequest",
"type": "object"
},
"ScriptConfigVersionListResponse": {
"additionalProperties": false,
"properties": {
"versions": {
"items": {
"$ref": "#/components/schemas/ScriptConfigVersionSummary"
},
"title": "Versions",
"type": "array"
}
},
"required": [
"versions"
],
"title": "ScriptConfigVersionListResponse",
"type": "object"
},
"ScriptConfigVersionResponse": {
"additionalProperties": false,
"properties": {
"version": {
"$ref": "#/components/schemas/ScriptConfigVersionSummary"
}
},
"required": [
"version"
],
"title": "ScriptConfigVersionResponse",
"type": "object"
},
"ScriptConfigVersionStatus": {
"enum": [
"DRAFT",
@@ -1559,6 +1703,244 @@
"title": "TargetSiteConfig",
"type": "object"
},
"TargetSiteConfigCreateRequest": {
"additionalProperties": false,
"properties": {
"active_script_config_version_id": {
"anyOf": [
{
"format": "uuid",
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Active Script Config Version Id"
},
"audience": {
"minLength": 1,
"title": "Audience",
"type": "string"
},
"brand_voice": {
"minLength": 1,
"title": "Brand Voice",
"type": "string"
},
"default_language": {
"default": "en",
"minLength": 2,
"title": "Default Language",
"type": "string"
},
"name": {
"minLength": 1,
"title": "Name",
"type": "string"
},
"publishing_rules": {
"$ref": "#/components/schemas/PublishingRules"
},
"publishing_type": {
"default": "git_next",
"minLength": 1,
"title": "Publishing Type",
"type": "string"
},
"seo_rules": {
"additionalProperties": true,
"title": "Seo Rules",
"type": "object"
},
"slug": {
"minLength": 1,
"title": "Slug",
"type": "string"
},
"source_rules": {
"additionalProperties": true,
"title": "Source Rules",
"type": "object"
},
"visual_rules": {
"additionalProperties": true,
"title": "Visual Rules",
"type": "object"
}
},
"required": [
"name",
"slug",
"brand_voice",
"audience",
"publishing_rules"
],
"title": "TargetSiteConfigCreateRequest",
"type": "object"
},
"TargetSiteConfigResponse": {
"additionalProperties": false,
"properties": {
"site": {
"$ref": "#/components/schemas/TargetSiteConfig"
}
},
"required": [
"site"
],
"title": "TargetSiteConfigResponse",
"type": "object"
},
"TargetSiteConfigUpdateRequest": {
"additionalProperties": false,
"properties": {
"active_script_config_version_id": {
"anyOf": [
{
"format": "uuid",
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Active Script Config Version Id"
},
"audience": {
"anyOf": [
{
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Audience"
},
"brand_voice": {
"anyOf": [
{
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Brand Voice"
},
"default_language": {
"anyOf": [
{
"minLength": 2,
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Default Language"
},
"name": {
"anyOf": [
{
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Name"
},
"publishing_rules": {
"anyOf": [
{
"$ref": "#/components/schemas/PublishingRules"
},
{
"type": "null"
}
],
"default": null
},
"publishing_type": {
"anyOf": [
{
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Publishing Type"
},
"seo_rules": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"default": null,
"title": "Seo Rules"
},
"slug": {
"anyOf": [
{
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Slug"
},
"source_rules": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"default": null,
"title": "Source Rules"
},
"visual_rules": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"default": null,
"title": "Visual Rules"
}
},
"title": "TargetSiteConfigUpdateRequest",
"type": "object"
},
"UserSummary": {
"additionalProperties": false,
"properties": {
@@ -1628,6 +2010,24 @@
"/api/articles": {
"post": {
"operationId": "create_article_api_articles_post",
"parameters": [
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"requestBody": {
"content": {
"application/json": {
@@ -1666,6 +2066,498 @@
]
}
},
"/api/articles/{article_id}/plans/{plan_id}/approve": {
"post": {
"operationId": "approve_plan_api_articles__article_id__plans__plan_id__approve_post",
"parameters": [
{
"in": "path",
"name": "article_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Article Id",
"type": "string"
}
},
{
"in": "path",
"name": "plan_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Plan Id",
"type": "string"
}
},
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReviewActionResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Approve Plan",
"tags": [
"articles"
]
}
},
"/api/me": {
"get": {
"operationId": "get_me_api_me_get",
"parameters": [
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CurrentUserResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Get Me",
"tags": [
"auth"
]
}
},
"/api/sites": {
"get": {
"operationId": "list_sites_api_sites_get",
"parameters": [
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/TargetSiteConfigResponse"
},
"title": "Response List Sites Api Sites Get",
"type": "array"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "List Sites",
"tags": [
"sites"
]
},
"post": {
"operationId": "post_site_api_sites_post",
"parameters": [
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TargetSiteConfigCreateRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TargetSiteConfigResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Post Site",
"tags": [
"sites"
]
}
},
"/api/sites/{site_id}": {
"patch": {
"operationId": "patch_site_api_sites__site_id__patch",
"parameters": [
{
"in": "path",
"name": "site_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Site Id",
"type": "string"
}
},
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TargetSiteConfigUpdateRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TargetSiteConfigResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Patch Site",
"tags": [
"sites"
]
}
},
"/api/sites/{site_id}/publishing-config/versions": {
"get": {
"operationId": "get_script_config_versions_api_sites__site_id__publishing_config_versions_get",
"parameters": [
{
"in": "path",
"name": "site_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Site Id",
"type": "string"
}
},
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScriptConfigVersionListResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Get Script Config Versions",
"tags": [
"sites"
]
},
"post": {
"operationId": "post_script_config_version_api_sites__site_id__publishing_config_versions_post",
"parameters": [
{
"in": "path",
"name": "site_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Site Id",
"type": "string"
}
},
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScriptConfigVersionCreateRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScriptConfigVersionResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Post Script Config Version",
"tags": [
"sites"
]
}
},
"/api/sites/{site_id}/publishing-config/versions/{version_id}/activate": {
"post": {
"operationId": "post_activate_script_config_version_api_sites__site_id__publishing_config_versions__version_id__activate_post",
"parameters": [
{
"in": "path",
"name": "site_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Site Id",
"type": "string"
}
},
{
"in": "path",
"name": "version_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Version Id",
"type": "string"
}
},
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScriptConfigVersionResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Post Activate Script Config Version",
"tags": [
"sites"
]
}
},
"/health": {
"get": {
"operationId": "health_health_get",
+64
View File
@@ -110,6 +110,17 @@ export type ClaimSummary = {
export type ClaimSupportStatus = "SUPPORTED" | "UNSUPPORTED" | "NEEDS_REVIEW";
export type CurrentUser = {
display_name: string;
email: string;
id: string;
role: Role;
};
export type CurrentUserResponse = {
user: CurrentUser;
};
export type DraftSummary = {
article_id: string;
body_object_key?: string | null;
@@ -209,6 +220,10 @@ export type ResearchArtifactSummary = {
source_url: string;
};
export type ReviewActionResponse = {
review: ReviewSummary;
};
export type ReviewStatus = "PENDING" | "APPROVED" | "CHANGES_REQUESTED";
export type ReviewSummary = {
@@ -230,6 +245,23 @@ export type RunnerFileRef = {
path: string;
};
export type ScriptConfigVersionCreateRequest = {
activate?: boolean;
diff?: Record<string, unknown>;
publishing_yaml: string;
rollback_target_version_id?: string | null;
transform_script: string;
version?: number | null;
};
export type ScriptConfigVersionListResponse = {
versions: ScriptConfigVersionSummary[];
};
export type ScriptConfigVersionResponse = {
version: ScriptConfigVersionSummary;
};
export type ScriptConfigVersionStatus = "DRAFT" | "ACTIVE" | "DEPRECATED";
export type ScriptConfigVersionSummary = {
@@ -260,6 +292,38 @@ export type TargetSiteConfig = {
visual_rules?: Record<string, unknown>;
};
export type TargetSiteConfigCreateRequest = {
active_script_config_version_id?: string | null;
audience: string;
brand_voice: string;
default_language?: string;
name: string;
publishing_rules: PublishingRules;
publishing_type?: string;
seo_rules?: Record<string, unknown>;
slug: string;
source_rules?: Record<string, unknown>;
visual_rules?: Record<string, unknown>;
};
export type TargetSiteConfigResponse = {
site: TargetSiteConfig;
};
export type TargetSiteConfigUpdateRequest = {
active_script_config_version_id?: string | null;
audience?: string | null;
brand_voice?: string | null;
default_language?: string | null;
name?: string | null;
publishing_rules?: PublishingRules | null;
publishing_type?: string | null;
seo_rules?: Record<string, unknown> | null;
slug?: string | null;
source_rules?: Record<string, unknown> | null;
visual_rules?: Record<string, unknown> | null;
};
export type UserSummary = {
display_name: string;
id: string;
+85 -13
View File
@@ -24,13 +24,13 @@ Development description: Implement simple internal authentication and role-based
## Acceptance Criteria
- [ ] TDD pre-requirement: before implementation, write one failing public API authorization test for an Editor attempting an Admin-only action; proceed one permission behavior at a time and record red-green evidence in `Result`.
- [ ] `GET /api/me` returns the active user and role.
- [ ] Admin can create/update site config and script versions.
- [ ] Editor cannot create/update site config or script versions.
- [ ] Editor can create articles and perform review actions.
- [ ] Unauthorized requests are rejected consistently.
- [ ] Frontend hides Admin-only navigation for Editors.
- [x] TDD pre-requirement: before implementation, write one failing public API authorization test for an Editor attempting an Admin-only action; proceed one permission behavior at a time and record red-green evidence in `Result`.
- [x] `GET /api/me` returns the active user and role.
- [x] Admin can create/update site config and script versions.
- [x] Editor cannot create/update site config or script versions.
- [x] Editor can create articles and perform review actions.
- [x] Unauthorized requests are rejected consistently.
- [x] Frontend hides Admin-only navigation for Editors.
## Verification
@@ -40,9 +40,81 @@ Development description: Implement simple internal authentication and role-based
## Result
- Status: Pending execution.
- TDD plan: To be filled during execution.
- Red evidence: To be filled during execution.
- Green evidence: To be filled during execution.
- Refactor notes: To be filled during execution.
- Verification output: To be filled during execution.
- Status: Completed.
- TDD plan:
- First behavior slice: public FastAPI HTTP authorization denial for a demo
Editor attempting an Admin-only site configuration mutation.
- Public API contract selected for local demo auth:
`X-Demo-User-Email: editor@example.com`.
- Red test: `POST /api/sites` with an Editor-selected demo user must return
stable `403`.
- Green slices: `GET /api/me`, Admin allow/Editor deny for site config and
script version mutations, Editor article create and plan approval review
action, stable `401`/`403` responses, and role-aware frontend navigation.
- Red evidence:
- Command:
`docker compose run --rm --build -v /Users/gavrilovdev/tmp/pupline:/app -w /app backend python apps/backend/tests/integration/test_auth_authorization_public_api.py`
- Result: failed as expected because `POST /api/sites` and auth/role checks
are not implemented yet.
- Output:
```text
F
======================================================================
FAIL: test_editor_cannot_create_target_site_config (__main__.AuthAuthorizationPublicApiTest.test_editor_cannot_create_target_site_config)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/app/apps/backend/tests/integration/test_auth_authorization_public_api.py", line 49, in test_editor_cannot_create_target_site_config
self.assertEqual(403, response.status_code, response.text)
AssertionError: 403 != 404 : {"detail":"Not Found"}
----------------------------------------------------------------------
Ran 1 test in 0.004s
FAILED (failures=1)
```
- Green evidence:
- Command:
`docker compose run --rm --build -v /Users/gavrilovdev/tmp/pupline:/app -w /app backend python apps/backend/tests/integration/test_auth_authorization_public_api.py`
- Output:
```text
........
----------------------------------------------------------------------
Ran 8 tests in 0.249s
OK
```
- Refactor notes:
- Added local demo auth via `X-Demo-User-Email` using seeded users.
- Kept role constants in domain, current-user/site-config orchestration in
application, repository persistence in infrastructure, and FastAPI
dependencies/guards in presentation.
- Added minimal plan approval review action endpoint for Task 004 role checks.
- Added FSD role navigation model/component and a deterministic Node test that
executes the TypeScript source.
- Regenerated `packages/shared/openapi.json` and `packages/shared/src/api-types.ts`.
- Verification output:
- `docker compose run --rm --build -v /Users/gavrilovdev/tmp/pupline:/app -w /app backend python -m unittest discover apps/backend/tests`
```text
..............s....
----------------------------------------------------------------------
Ran 19 tests in 0.364s
OK (skipped=1)
```
- `pnpm --filter @pipeline/frontend test:roles`
```text
role navigation hides Admin-only items for Editors
```
- `pnpm typecheck`
```text
@pipeline/shared typecheck: tsc --noEmit
@pipeline/frontend typecheck: tsc --noEmit
```
- `bash tests/smoke/public-health.sh`
```text
backend health ok
runner health ok
frontend health ok
backend dependencies ok
public health smoke ok
```