# Phase 1 구현 플랜: 인증 + 사용자 관리 + 프로젝트 CRUD + 활동 로그

> **에이전트 작업자 필수:** superpowers:subagent-driven-development (서브에이전트 사용 가능 시) 또는 superpowers:executing-plans를 사용하여 이 플랜을 실행할 것. 각 단계는 체크박스(`- [ ]`) 구문으로 추적.

**목표:** 세션 기반 인증, 사용자 관리, 프로젝트 CRUD, 활동 로그를 갖춘 TheRoad Scene Lab 기반 시스템 구축

**아키텍처:** FastAPI 백엔드 + React/Vite/TS 프론트엔드 모노레포. 카탈로그 SQLite(전역) + 프로젝트별 SQLite 분리 구조. 모든 문자열 i18n 외부화.

**기술 스택:** Python 3.12+, FastAPI, SQLAlchemy, Alembic, bcrypt, React 18+, Vite, TypeScript

**스펙 문서:** `docs/superpowers/specs/2026-03-14-phase1-auth-project-design.md`

---

## Chunk 1: 백엔드 기반

### Task 1: 백엔드 프로젝트 스캐폴딩

**파일:**
- 생성: `backend/requirements.txt`
- 생성: `backend/.env`
- 생성: `backend/app/__init__.py`
- 생성: `backend/app/main.py`
- 생성: `backend/app/core/__init__.py`
- 생성: `backend/app/core/config.py`
- 생성: `backend/app/models/project.py` (Phase 2 스텁)
- 수정: `.gitignore` (backend/frontend 관련 항목 추가)
- 생성: `prompts/manifest.json`
- 생성: `prompts/_base/.gitkeep`
- 생성: `prompts/projects/.gitkeep`

- [ ] **Step 1: backend 디렉토리 생성 및 requirements.txt 작성**

```
# backend/requirements.txt
fastapi>=0.115.0
uvicorn>=0.30.0
sqlalchemy>=2.0.0
alembic>=1.13.0
bcrypt>=4.2.0
python-multipart>=0.0.9
pydantic>=2.0.0
pydantic-settings>=2.0.0
```

- [ ] **Step 2: .env 파일 작성**

```
# backend/.env
APP_NAME=TheRoad Scene Lab
SECRET_KEY=dev-secret-key-change-in-production
CATALOG_DB_PATH=../data/service.sqlite
PROJECTS_DIR=../projects
SESSION_TTL_HOURS=24
DEFAULT_ADMIN_USERNAME=admin
DEFAULT_ADMIN_PASSWORD=admin123
DEFAULT_CREATOR_USERNAME=creator
DEFAULT_CREATOR_PASSWORD=creator123
```

- [ ] **Step 3: config.py 작성**

```python
# backend/app/core/config.py
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    app_name: str = "TheRoad Scene Lab"
    secret_key: str = "dev-secret-key"
    catalog_db_path: str = "../data/service.sqlite"
    projects_dir: str = "../projects"
    session_ttl_hours: int = 24
    default_admin_username: str = "admin"
    default_admin_password: str = "admin123"
    default_creator_username: str = "creator"
    default_creator_password: str = "creator123"

    class Config:
        env_file = ".env"

settings = Settings()
```

- [ ] **Step 4: main.py 앱 팩토리 작성**

```python
# backend/app/main.py
from fastapi import FastAPI
from app.core.config import settings

def create_app() -> FastAPI:
    app = FastAPI(title=settings.app_name, docs_url="/api/docs", openapi_url="/api/openapi.json")
    return app

app = create_app()
```

- [ ] **Step 5: .gitignore 업데이트**

`.gitignore`에 추가:
```
# Backend
backend/.venv/
backend/.env
data/

# Frontend
frontend/node_modules/
frontend/dist/
```

- [ ] **Step 6: prompts 디렉토리 스캐폴딩**

```json
// prompts/manifest.json
{
  "current_version": "v5",
  "versions": {}
}
```

`prompts/_base/.gitkeep`, `prompts/projects/.gitkeep` 생성.

- [ ] **Step 7: project.py 스텁 생성**

```python
# backend/app/models/project.py
"""프로젝트 범위 모델 — Phase 2에서 구현 예정."""
```

- [ ] **Step 8: venv 생성 및 의존성 설치**

실행: `cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`

- [ ] **Step 9: 서버 기동 확인**

실행: `cd backend && .venv/bin/python -m uvicorn app.main:app --port 8000`
기대: 서버 기동, `http://localhost:8000/api/docs` 접근 가능

- [ ] **Step 10: 커밋**

```bash
git add backend/ .gitignore prompts/
git commit -m "feat: 백엔드 프로젝트 스캐폴딩 — FastAPI, config, prompts 구조, .gitignore"
```

---

### Task 2: i18n 모듈

**파일:**
- 생성: `backend/app/i18n/__init__.py`
- 생성: `backend/app/i18n/ko.json`
- 생성: `backend/app/i18n/en.json`
- 생성: `backend/app/i18n/loader.py`
- 테스트: `backend/tests/test_i18n.py`

- [ ] **Step 1: 한국어 문자열 JSON 작성**

```json
{
  "auth.login_failed": "아이디 또는 비밀번호가 일치하지 않습니다",
  "auth.session_expired": "세션이 만료되었습니다",
  "auth.not_authenticated": "로그인이 필요합니다",
  "user.not_found": "사용자를 찾을 수 없습니다",
  "user.duplicate_username": "이미 존재하는 사용자명입니다",
  "user.cannot_deactivate_last_admin": "마지막 관리자는 비활성화할 수 없습니다",
  "user.deactivated": "비활성화된 계정입니다",
  "project.not_found": "프로젝트를 찾을 수 없습니다",
  "project.access_denied": "프로젝트 접근 권한이 없습니다",
  "project.modify_denied": "프로젝트 수정 권한이 없습니다",
  "member.already_exists": "이미 프로젝트 멤버입니다",
  "member.not_found": "멤버를 찾을 수 없습니다",
  "member.cannot_remove_owner": "프로젝트 소유자는 제거할 수 없습니다",
  "member.must_have_owner": "프로젝트에는 최소 1명의 소유자가 필요합니다",
  "permission.admin_required": "관리자 권한이 필요합니다",
  "validation.invalid_role": "유효하지 않은 역할입니다"
}
```

- [ ] **Step 2: i18n loader 작성**

```python
# backend/app/i18n/loader.py
import json
from pathlib import Path
from typing import Dict

_strings: Dict[str, Dict[str, str]] = {}
_default_locale = "ko"

def load_strings() -> None:
    i18n_dir = Path(__file__).parent
    for json_file in i18n_dir.glob("*.json"):
        locale = json_file.stem
        _strings[locale] = json.loads(json_file.read_text(encoding="utf-8"))

def t(key: str, locale: str | None = None, **kwargs: object) -> str:
    loc = locale or _default_locale
    strings = _strings.get(loc, _strings.get(_default_locale, {}))
    template = strings.get(key, key)
    if kwargs:
        return template.format(**kwargs)
    return template
```

- [ ] **Step 3: 테스트 작성**

```python
# backend/tests/test_i18n.py
from app.i18n.loader import load_strings, t

def test_t_returns_korean_string():
    load_strings()
    assert t("auth.login_failed") == "아이디 또는 비밀번호가 일치하지 않습니다"

def test_t_returns_key_for_missing():
    load_strings()
    assert t("nonexistent.key") == "nonexistent.key"
```

- [ ] **Step 4: 테스트 실행**

실행: `cd backend && .venv/bin/python -m pytest tests/test_i18n.py -v`
기대: PASS

- [ ] **Step 5: main.py에 i18n 로드 추가**

`create_app()`의 startup 이벤트에 `load_strings()` 호출 추가.

- [ ] **Step 6: 커밋**

```bash
git add backend/app/i18n/ backend/tests/test_i18n.py
git commit -m "feat: i18n 모듈 — 한국어 문자열 외부화, loader, 테스트"
```

---

### Task 3: 데이터베이스 엔진 + SQLAlchemy 모델

**파일:**
- 생성: `backend/app/core/database.py`
- 생성: `backend/app/models/__init__.py`
- 생성: `backend/app/models/catalog.py`
- 생성: `backend/app/logging/__init__.py`
- 생성: `backend/app/logging/models.py`
- 테스트: `backend/tests/test_models.py`

- [ ] **Step 1: database.py 작성 — 카탈로그 + 프로젝트 DB 엔진 팩토리**

```python
# backend/app/core/database.py
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.core.config import settings
from pathlib import Path

class Base(DeclarativeBase):
    pass

def _enable_fk(dbapi_conn, connection_record):
    cursor = dbapi_conn.cursor()
    cursor.execute("PRAGMA foreign_keys=ON")
    cursor.close()

def get_catalog_engine():
    db_path = Path(settings.catalog_db_path)
    db_path.parent.mkdir(parents=True, exist_ok=True)
    engine = create_engine(f"sqlite:///{db_path}", echo=False)
    event.listen(engine, "connect", _enable_fk)
    return engine

def get_project_engine(db_path: str):
    path = Path(db_path)
    path.parent.mkdir(parents=True, exist_ok=True)
    engine = create_engine(f"sqlite:///{path}", echo=False)
    event.listen(engine, "connect", _enable_fk)
    return engine

catalog_engine = get_catalog_engine()
CatalogSession = sessionmaker(bind=catalog_engine)
```

- [ ] **Step 2: catalog.py — SQLAlchemy 모델 작성**

```python
# backend/app/models/catalog.py
import uuid
from datetime import datetime
from sqlalchemy import Column, Text, Integer, ForeignKey, UniqueConstraint
from app.core.database import Base

def new_uuid() -> str:
    return str(uuid.uuid4())

def now_iso() -> str:
    return datetime.now().isoformat(timespec="seconds")

class UserAccount(Base):
    __tablename__ = "user_account"
    id = Column(Text, primary_key=True, default=new_uuid)
    username = Column(Text, unique=True, nullable=False)
    display_name = Column(Text, nullable=False)
    password_hash = Column(Text, nullable=False)
    role = Column(Text, nullable=False)
    is_active = Column(Integer, default=1)
    created_at = Column(Text, nullable=False, default=now_iso)
    updated_at = Column(Text, nullable=False, default=now_iso)

class Session(Base):
    __tablename__ = "session"
    id = Column(Text, primary_key=True)
    user_id = Column(Text, ForeignKey("user_account.id"), nullable=False)
    created_at = Column(Text, nullable=False, default=now_iso)
    expires_at = Column(Text, nullable=False)

class ProjectRegistry(Base):
    __tablename__ = "project_registry"
    id = Column(Text, primary_key=True, default=new_uuid)
    name = Column(Text, nullable=False)
    description = Column(Text, default="")
    status = Column(Text, default="active")
    db_path = Column(Text, nullable=False)
    created_by = Column(Text, ForeignKey("user_account.id"), nullable=False)
    created_at = Column(Text, nullable=False, default=now_iso)
    updated_at = Column(Text, nullable=False, default=now_iso)

class ProjectMember(Base):
    __tablename__ = "project_member"
    id = Column(Text, primary_key=True, default=new_uuid)
    project_id = Column(Text, ForeignKey("project_registry.id"), nullable=False)
    user_id = Column(Text, ForeignKey("user_account.id"), nullable=False)
    role = Column(Text, nullable=False)
    added_by = Column(Text, ForeignKey("user_account.id"))
    created_at = Column(Text, nullable=False, default=now_iso)
    __table_args__ = (UniqueConstraint("project_id", "user_id"),)
```

- [ ] **Step 3: ActivityLog 모델 작성**

```python
# backend/app/logging/models.py
from sqlalchemy import Column, Text, ForeignKey
from app.core.database import Base
from app.models.catalog import new_uuid, now_iso

class ActivityLog(Base):
    __tablename__ = "activity_log"
    id = Column(Text, primary_key=True, default=new_uuid)
    actor_id = Column(Text, ForeignKey("user_account.id"), nullable=False)
    action = Column(Text, nullable=False)
    resource_type = Column(Text, nullable=False)
    resource_id = Column(Text)
    project_id = Column(Text)
    detail_json = Column(Text, default="{}")
    ip_address = Column(Text)
    created_at = Column(Text, nullable=False, default=now_iso)
```

- [ ] **Step 4: 모델 테스트 작성**

```python
# backend/tests/test_models.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.database import Base
from app.models.catalog import UserAccount, ProjectRegistry, ProjectMember
from app.logging.models import ActivityLog

def test_create_tables_in_memory():
    engine = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(engine)
    Session = sessionmaker(bind=engine)
    with Session() as session:
        user = UserAccount(username="test", display_name="Test", password_hash="x", role="admin")
        session.add(user)
        session.commit()
        assert session.query(UserAccount).count() == 1
```

- [ ] **Step 5: 테스트 실행**

실행: `cd backend && .venv/bin/python -m pytest tests/test_models.py -v`
기대: PASS

- [ ] **Step 6: 커밋**

```bash
git add backend/app/core/database.py backend/app/models/ backend/app/logging/ backend/tests/test_models.py
git commit -m "feat: DB 엔진 팩토리 + SQLAlchemy 모델 — 카탈로그 5테이블"
```

---

### Task 4: security 모듈 + ActivityLogger

**파일:**
- 생성: `backend/app/core/security.py`
- 생성: `backend/app/logging/activity_logger.py`
- 테스트: `backend/tests/test_security.py`
- 테스트: `backend/tests/test_activity_logger.py`

- [ ] **Step 1: security.py 작성**

```python
# backend/app/core/security.py
import secrets
import bcrypt
from datetime import datetime, timedelta
from app.core.config import settings

def hash_password(password: str) -> str:
    return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()

def verify_password(password: str, password_hash: str) -> bool:
    return bcrypt.checkpw(password.encode(), password_hash.encode())

def create_session_token() -> str:
    return secrets.token_urlsafe(32)

def session_expires_at() -> str:
    return (datetime.now() + timedelta(hours=settings.session_ttl_hours)).isoformat(timespec="seconds")

def is_session_expired(expires_at: str) -> bool:
    return datetime.fromisoformat(expires_at) < datetime.now()
```

- [ ] **Step 2: security 테스트 작성**

```python
# backend/tests/test_security.py
from app.core.security import hash_password, verify_password, create_session_token

def test_password_hash_and_verify():
    h = hash_password("test123")
    assert verify_password("test123", h)
    assert not verify_password("wrong", h)

def test_session_token_is_unique():
    a = create_session_token()
    b = create_session_token()
    assert a != b
    assert len(a) > 20
```

- [ ] **Step 3: ActivityLogger 작성**

```python
# backend/app/logging/activity_logger.py
import json
from sqlalchemy.orm import Session as DBSession
from app.logging.models import ActivityLog
from app.models.catalog import new_uuid, now_iso

class ActivityLogger:
    def __init__(self, db: DBSession):
        self._db = db

    def log(self, *, actor_id: str, action: str, resource_type: str,
            resource_id: str | None = None, project_id: str | None = None,
            detail: dict | None = None, ip_address: str | None = None) -> None:
        try:
            entry = ActivityLog(
                id=new_uuid(),
                actor_id=actor_id,
                action=action,
                resource_type=resource_type,
                resource_id=resource_id,
                project_id=project_id,
                detail_json=json.dumps(detail or {}, ensure_ascii=False),
                ip_address=ip_address,
                created_at=now_iso(),
            )
            self._db.add(entry)
            self._db.flush()
        except Exception:
            pass  # 로그 실패가 메인 동작을 막지 않음
```

- [ ] **Step 4: ActivityLogger 테스트 작성**

```python
# backend/tests/test_activity_logger.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.database import Base
from app.logging.activity_logger import ActivityLogger
from app.models.catalog import UserAccount
from app.logging.models import ActivityLog

def _setup_db():
    engine = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(engine)
    return sessionmaker(bind=engine)

def test_log_creates_entry():
    Session = _setup_db()
    with Session() as db:
        user = UserAccount(username="admin", display_name="Admin", password_hash="x", role="admin")
        db.add(user)
        db.commit()
        logger = ActivityLogger(db)
        logger.log(actor_id=user.id, action="user.create", resource_type="user", resource_id="new-id")
        db.commit()
        assert db.query(ActivityLog).count() == 1
        entry = db.query(ActivityLog).first()
        assert entry.action == "user.create"
```

- [ ] **Step 5: 테스트 실행**

실행: `cd backend && .venv/bin/python -m pytest tests/test_security.py tests/test_activity_logger.py -v`
기대: PASS

- [ ] **Step 6: 커밋**

```bash
git add backend/app/core/security.py backend/app/logging/activity_logger.py backend/tests/
git commit -m "feat: security 모듈 (bcrypt, 세션 토큰) + ActivityLogger 독립 모듈"
```

---

### Task 5: Pydantic 스키마 + 의존성 주입

**파일:**
- 생성: `backend/app/schemas/__init__.py`
- 생성: `backend/app/schemas/auth.py`
- 생성: `backend/app/schemas/user.py`
- 생성: `backend/app/schemas/project.py`
- 생성: `backend/app/schemas/common.py`
- 생성: `backend/app/api/__init__.py`
- 생성: `backend/app/api/deps.py`

- [ ] **Step 1: common.py — 페이지네이션 응답, 에러 응답 스키마**

```python
# backend/app/schemas/common.py
from pydantic import BaseModel
from typing import Generic, TypeVar, List

T = TypeVar("T")

class ErrorDetail(BaseModel):
    code: str
    message: str

class ErrorResponse(BaseModel):
    error: ErrorDetail

class PaginatedResponse(BaseModel, Generic[T]):
    items: List[T]
    total: int
    page: int
    per_page: int
```

- [ ] **Step 2: auth.py, user.py, project.py 스키마 작성**

```python
# backend/app/schemas/auth.py
from pydantic import BaseModel

class LoginRequest(BaseModel):
    username: str
    password: str

class UserResponse(BaseModel):
    id: str
    username: str
    display_name: str
    role: str
    is_active: bool
    created_at: str
```

```python
# backend/app/schemas/user.py
from pydantic import BaseModel

class UserCreate(BaseModel):
    username: str
    display_name: str
    password: str
    role: str = "creator"

class UserUpdate(BaseModel):
    display_name: str | None = None
    password: str | None = None
    role: str | None = None
    is_active: bool | None = None
```

```python
# backend/app/schemas/project.py
from pydantic import BaseModel

class ProjectCreate(BaseModel):
    name: str
    description: str = ""

class ProjectUpdate(BaseModel):
    name: str | None = None
    description: str | None = None
    status: str | None = None

class ProjectResponse(BaseModel):
    id: str
    name: str
    description: str
    status: str
    member_count: int = 0
    my_role: str | None = None
    created_by: str
    created_at: str

class MemberAdd(BaseModel):
    user_id: str
    role: str = "member"

class MemberUpdate(BaseModel):
    role: str

class MemberResponse(BaseModel):
    id: str
    user_id: str
    username: str
    display_name: str
    role: str
    created_at: str
```

- [ ] **Step 3: 커스텀 에러 핸들러 추가 (main.py)**

스펙의 에러 응답 형태 `{"error": {"code": ..., "message": ...}}`를 맞추기 위해 FastAPI 커스텀 예외 핸들러 추가. `AppError` 커스텀 예외 클래스를 만들어 `{"error": {...}}` 직접 반환.

```python
# backend/app/core/errors.py
from fastapi import Request
from fastapi.responses import JSONResponse

class AppError(Exception):
    def __init__(self, code: str, message: str, status_code: int = 400):
        self.code = code
        self.message = message
        self.status_code = status_code

async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
    return JSONResponse(status_code=exc.status_code, content={"error": {"code": exc.code, "message": exc.message}})
```

`main.py`에서 `app.add_exception_handler(AppError, app_error_handler)` 등록.

- [ ] **Step 4: deps.py — DB 세션 + 현재 사용자 의존성**

```python
# backend/app/api/deps.py
from typing import Generator
from fastapi import Depends, Request
from sqlalchemy.orm import Session as DBSession
from app.core.database import CatalogSession
from app.models.catalog import UserAccount, Session
from app.core.security import is_session_expired
from app.core.errors import AppError
from app.i18n.loader import t

def get_db() -> Generator[DBSession, None, None]:
    db = CatalogSession()
    try:
        yield db
    finally:
        db.close()

def get_current_user(request: Request, db: DBSession = Depends(get_db)) -> UserAccount:
    session_id = request.cookies.get("session_id")
    if not session_id:
        raise AppError("auth.not_authenticated", t("auth.not_authenticated"), 401)
    session = db.query(Session).filter(Session.id == session_id).first()
    if not session or is_session_expired(session.expires_at):
        if session:
            db.delete(session)
            db.commit()
        raise AppError("auth.session_expired", t("auth.session_expired"), 401)
    user = db.query(UserAccount).filter(UserAccount.id == session.user_id, UserAccount.is_active == 1).first()
    if not user:
        raise AppError("user.deactivated", t("user.deactivated"), 401)
    return user

def require_admin(user: UserAccount = Depends(get_current_user)) -> UserAccount:
    if user.role != "admin":
        raise AppError("permission.admin_required", t("permission.admin_required"), 403)
    return user
```

- [ ] **Step 4: 커밋**

```bash
git add backend/app/schemas/ backend/app/api/
git commit -m "feat: Pydantic 스키마 + 의존성 주입 (세션 인증, admin 권한 체크)"
```

---

## Chunk 2: 백엔드 서비스 + API

### Task 6: 인증 서비스 + API

**파일:**
- 생성: `backend/app/services/__init__.py`
- 생성: `backend/app/services/auth_service.py`
- 생성: `backend/app/api/v1/__init__.py`
- 생성: `backend/app/api/v1/auth.py`
- 테스트: `backend/tests/test_auth_api.py`

- [ ] **Step 1: auth_service.py 작성**

```python
# backend/app/services/auth_service.py
from sqlalchemy.orm import Session as DBSession
from app.models.catalog import UserAccount, Session
from app.core.security import verify_password, create_session_token, session_expires_at
from app.logging.activity_logger import ActivityLogger
from app.i18n.loader import t
from app.models.catalog import new_uuid, now_iso

class AuthService:
    def __init__(self, db: DBSession, logger: ActivityLogger):
        self._db = db
        self._logger = logger

    def login(self, username: str, password: str, ip: str | None = None) -> tuple[UserAccount, str]:
        user = self._db.query(UserAccount).filter(
            UserAccount.username == username, UserAccount.is_active == 1
        ).first()
        if not user or not verify_password(password, user.password_hash):
            raise ValueError(t("auth.login_failed"))
        token = create_session_token()
        session = Session(id=token, user_id=user.id, created_at=now_iso(), expires_at=session_expires_at())
        self._db.add(session)
        self._logger.log(actor_id=user.id, action="auth.login", resource_type="session", ip_address=ip)
        self._db.commit()
        return user, token

    def logout(self, session_id: str, user_id: str, ip: str | None = None) -> None:
        session = self._db.query(Session).filter(Session.id == session_id).first()
        if session:
            self._db.delete(session)
        self._logger.log(actor_id=user_id, action="auth.logout", resource_type="session", ip_address=ip)
        self._db.commit()
```

- [ ] **Step 2: auth API 라우터 작성**

```python
# backend/app/api/v1/auth.py
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session as DBSession
from app.api.deps import get_db, get_current_user
from app.schemas.auth import LoginRequest, UserResponse
from app.services.auth_service import AuthService
from app.logging.activity_logger import ActivityLogger

router = APIRouter(prefix="/api/v1/auth", tags=["auth"])

@router.post("/login")
def login(body: LoginRequest, request: Request, response: Response, db: DBSession = Depends(get_db)):
    svc = AuthService(db, ActivityLogger(db))
    try:
        user, token = svc.login(body.username, body.password, ip=request.client.host if request.client else None)
    except ValueError as e:
        raise HTTPException(status_code=401, detail={"error": {"code": "auth.login_failed", "message": str(e)}})
    response.set_cookie("session_id", token, httponly=True, samesite="lax", max_age=86400)
    return UserResponse(id=user.id, username=user.username, display_name=user.display_name, role=user.role, is_active=bool(user.is_active), created_at=user.created_at)

@router.post("/logout")
def logout(request: Request, response: Response, user=Depends(get_current_user), db: DBSession = Depends(get_db)):
    session_id = request.cookies.get("session_id", "")
    svc = AuthService(db, ActivityLogger(db))
    svc.logout(session_id, user.id, ip=request.client.host if request.client else None)
    response.delete_cookie("session_id")
    return {"ok": True}

@router.get("/me")
def me(user=Depends(get_current_user)):
    return UserResponse(id=user.id, username=user.username, display_name=user.display_name, role=user.role, is_active=bool(user.is_active), created_at=user.created_at)
```

- [ ] **Step 3: 라우터를 main.py에 등록**

`create_app()`에 `app.include_router(auth_router)` 추가.

- [ ] **Step 4: 초기 계정 생성 로직 추가**

`main.py` startup에서 admin/creator 기본 계정 존재 확인 및 생성.

- [ ] **Step 5: API 테스트 작성**

```python
# backend/tests/test_auth_api.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_login_success():
    r = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"})
    assert r.status_code == 200
    assert r.json()["username"] == "admin"
    assert "session_id" in r.cookies

def test_login_fail():
    r = client.post("/api/v1/auth/login", json={"username": "admin", "password": "wrong"})
    assert r.status_code == 401

def test_me_without_session():
    r = client.get("/api/v1/auth/me")
    assert r.status_code == 401

def test_me_with_session():
    login = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"})
    r = client.get("/api/v1/auth/me", cookies=login.cookies)
    assert r.status_code == 200
    assert r.json()["role"] == "admin"

def test_logout():
    login = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"})
    r = client.post("/api/v1/auth/logout", cookies=login.cookies)
    assert r.status_code == 200
    r2 = client.get("/api/v1/auth/me", cookies=login.cookies)
    assert r2.status_code == 401
```

- [ ] **Step 6: 테스트 실행**

실행: `cd backend && .venv/bin/python -m pytest tests/test_auth_api.py -v`
기대: PASS

- [ ] **Step 7: 커밋**

```bash
git add backend/app/services/ backend/app/api/ backend/tests/test_auth_api.py
git commit -m "feat: 인증 서비스 + API — 로그인/로그아웃/내정보, 세션 쿠키, 기본 계정 자동 생성"
```

---

### Task 7: 사용자 관리 서비스 + API

**파일:**
- 생성: `backend/app/services/user_service.py`
- 생성: `backend/app/api/v1/users.py`
- 테스트: `backend/tests/test_users_api.py`

- [ ] **Step 1: user_service.py 작성**

UserService 클래스: list_users, create_user, get_user, update_user, deactivate_user 메서드. 각 메서드에서 ActivityLogger 호출. 마지막 admin 비활성화 방지 로직 포함. `deactivate_user`에서 해당 사용자의 모든 세션을 즉시 삭제 (`db.query(Session).filter(Session.user_id == user_id).delete()`).

- [ ] **Step 2: users API 라우터 작성**

`require_admin` 의존성으로 admin 전용 보호. 페이지네이션 지원 (`page`, `per_page` 쿼리 파라미터).

- [ ] **Step 3: API 테스트 작성**

admin 로그인 → 사용자 생성 → 목록 조회 → 상세 조회 → 수정 → 비활성화 테스트. creator 로그인 시 403 확인. 중복 username 409 확인. 마지막 admin 비활성화 방지 확인.

- [ ] **Step 4: 테스트 실행**

실행: `cd backend && .venv/bin/python -m pytest tests/test_users_api.py -v`
기대: PASS

- [ ] **Step 5: 커밋**

```bash
git add backend/app/services/user_service.py backend/app/api/v1/users.py backend/tests/test_users_api.py
git commit -m "feat: 사용자 관리 서비스 + API — CRUD, admin 전용, 마지막 admin 보호"
```

---

### Task 8: 프로젝트 CRUD 서비스 + API

**파일:**
- 생성: `backend/app/services/project_service.py`
- 생성: `backend/app/api/v1/projects.py`
- 테스트: `backend/tests/test_projects_api.py`

- [ ] **Step 1: project_service.py 작성**

ProjectService 클래스: list_projects (admin 전체, creator 자기 것만), create_project, get_project, update_project, delete_project (소프트), add_member, update_member_role, remove_member, list_members. 각 메서드에서 ActivityLogger 호출.

`create_project`에서:
1. `projects/{uuid}/` 디렉토리 생성
2. `projects/{uuid}/assets/screenplays/`, `references/`, `generated/`, `exports/` 서브디렉토리 생성
3. `projects/{uuid}/project.sqlite` 생성 + `project_meta` 테이블 초기화
4. `project_meta`에 `name`, `created_at`, `schema_version=1` 삽입
5. `project_registry`에 등록 + `project_member`에 owner 자동 추가

- [ ] **Step 2: projects API 라우터 작성**

프로젝트 CRUD + 멤버 관리 엔드포인트. 권한 체크: 멤버 접근은 `get_current_user` + 멤버 확인, 수정/삭제는 owner/admin 확인.

- [ ] **Step 3: 활동 로그 조회 엔드포인트 추가**

`GET /api/v1/activities` (admin 전용), `GET /api/v1/projects/{id}/activities` (멤버 접근 가능). 필터링: `action`, `actor_id`, `from`, `to` 쿼리 파라미터. 쿼리 빌더에서 각 필터를 조건부 `.filter()` 체이닝. 페이지네이션 `page`, `per_page` 지원.

- [ ] **Step 4: API 테스트 작성**

프로젝트 생성 → 상세 조회 → 수정 → 멤버 추가 → 멤버 역할 변경 → 멤버 제거 → 삭제 → 활동 로그 조회. 권한 위반 케이스 테스트. owner 제거 불가 테스트. 프로젝트 디렉토리 생성 확인.

- [ ] **Step 5: 테스트 실행**

실행: `cd backend && .venv/bin/python -m pytest tests/test_projects_api.py -v`
기대: PASS

- [ ] **Step 6: 커밋**

```bash
git add backend/app/services/project_service.py backend/app/api/v1/projects.py backend/tests/test_projects_api.py
git commit -m "feat: 프로젝트 CRUD + 멤버 관리 + 활동 로그 조회 API"
```

---

### Task 9: 버전 레지스트리 + 개발 로그 초기화

**파일:**
- 생성: `backend/app/core/version_registry.py`
- 생성: `docs/dev-log.md`

- [ ] **Step 1: version_registry.py 작성**

```python
# backend/app/core/version_registry.py
MODULE_VERSIONS = {
    "auth_service": {
        "version": "1.0.0",
        "updated_at": "2026-03-14T20:00:00",
        "prompt_dependency": None,
        "description": "세션 기반 인증 서비스",
    },
    "user_service": {
        "version": "1.0.0",
        "updated_at": "2026-03-14T20:00:00",
        "prompt_dependency": None,
        "description": "사용자 CRUD 서비스",
    },
    "project_service": {
        "version": "1.0.0",
        "updated_at": "2026-03-14T20:00:00",
        "prompt_dependency": None,
        "description": "프로젝트 CRUD + 멤버 관리 서비스",
    },
    "activity_logger": {
        "version": "1.0.0",
        "updated_at": "2026-03-14T20:00:00",
        "prompt_dependency": None,
        "description": "활동 로그 독립 모듈",
    },
}
```

- [ ] **Step 2: dev-log.md 초기화**

```markdown
# 개발 로그

## 2026-03-14

### commit XXXXXXX — Phase 1: 백엔드 기반 구축
- **모듈**: auth_service v1.0.0, user_service v1.0.0, project_service v1.0.0, activity_logger v1.0.0
- **프롬프트 연동**: 없음 (Phase 1은 인증/관리 기반)
- **변경 내용**: FastAPI 백엔드, 세션 인증, 사용자 CRUD, 프로젝트 CRUD, 멤버 관리, 활동 로그
```

- [ ] **Step 3: 커밋**

```bash
git add backend/app/core/version_registry.py docs/dev-log.md
git commit -m "feat: 버전 레지스트리 + 개발 로그 초기화"
```

---

## Chunk 3: 프론트엔드 기반

### Task 10: 프론트엔드 프로젝트 스캐폴딩

**파일:**
- 생성: `frontend/package.json`
- 생성: `frontend/tsconfig.json`
- 생성: `frontend/vite.config.ts`
- 생성: `frontend/index.html`
- 생성: `frontend/src/main.tsx`
- 생성: `frontend/src/App.tsx`
- 생성: `frontend/src/vite-env.d.ts`

- [ ] **Step 1: Vite + React + TS 프로젝트 초기화**

실행: `cd frontend && npm create vite@latest . -- --template react-ts`
또는 직접 package.json 작성.

- [ ] **Step 2: 추가 의존성 설치**

실행: `cd frontend && npm install react-router-dom`
실행: `cd frontend && npm install -D @types/react-router-dom`

- [ ] **Step 3: vite.config.ts에 프록시 설정**

```typescript
// frontend/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://localhost:8000',
        changeOrigin: true,
      },
    },
  },
})
```

- [ ] **Step 4: 빌드 확인**

실행: `cd frontend && npm run dev`
기대: Vite 개발 서버 기동, `http://localhost:3000` 접근 가능

- [ ] **Step 5: 커밋**

```bash
git add frontend/
git commit -m "feat: 프론트엔드 스캐폴딩 — React + Vite + TS, API 프록시 설정"
```

---

### Task 11: i18n 훅 + API 클라이언트

**파일:**
- 생성: `frontend/src/i18n/ko.json`
- 생성: `frontend/src/i18n/useI18n.ts`
- 생성: `frontend/src/api/client.ts`
- 생성: `frontend/src/hooks/useAuth.ts`
- 생성: `frontend/src/hooks/useApi.ts`

- [ ] **Step 1: 한국어 UI 문자열 작성**

```json
{
  "app.title": "TheRoad Scene Lab",
  "nav.dashboard": "대시보드",
  "nav.admin": "관리",
  "nav.users": "사용자 관리",
  "nav.activity": "활동 로그",
  "nav.project.overview": "개요",
  "nav.project.members": "멤버",
  "nav.project.activity": "활동",
  "nav.project.episodes": "에피소드",
  "nav.project.entities": "엔티티",
  "login.title": "로그인",
  "login.username": "사용자명",
  "login.password": "비밀번호",
  "login.submit": "로그인",
  "login.error": "아이디 또는 비밀번호가 일치하지 않습니다",
  "project.create": "새 프로젝트",
  "project.name": "프로젝트 이름",
  "project.description": "설명",
  "project.status.active": "활성",
  "project.status.archived": "보관됨",
  "project.status.deleted": "삭제됨",
  "project.role.owner": "소유자",
  "project.role.member": "멤버",
  "user.create": "사용자 생성",
  "user.username": "사용자명",
  "user.display_name": "표시 이름",
  "user.password": "비밀번호",
  "user.role": "역할",
  "user.role.admin": "관리자",
  "user.role.creator": "크리에이터",
  "user.status.active": "활성",
  "user.status.inactive": "비활성",
  "btn.save": "저장",
  "btn.cancel": "취소",
  "btn.delete": "삭제",
  "btn.edit": "수정",
  "btn.add": "추가",
  "btn.logout": "로그아웃",
  "btn.back": "뒤로",
  "member.add": "멤버 추가",
  "member.remove": "제거",
  "activity.title": "활동 로그",
  "common.loading": "로딩 중...",
  "common.no_data": "데이터가 없습니다",
  "common.confirm_delete": "정말 삭제하시겠습니까?"
}
```

- [ ] **Step 1-b: en.json 작성 (빈 파일, 향후 사용)**

```json
{}
```

- [ ] **Step 2: useI18n 훅 작성**

```typescript
// frontend/src/i18n/useI18n.ts
import ko from './ko.json'

const strings: Record<string, Record<string, string>> = { ko }
const defaultLocale = 'ko'

export function useI18n(locale = defaultLocale) {
  const t = (key: string): string => {
    return strings[locale]?.[key] ?? key
  }
  return { t }
}
```

- [ ] **Step 3: API 클라이언트 작성**

```typescript
// frontend/src/api/client.ts
const BASE = ''  // 프록시로 동일 출처

export async function api<T>(path: string, options?: RequestInit): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    credentials: 'include',
    headers: { 'Content-Type': 'application/json', ...options?.headers },
    ...options,
  })
  if (!res.ok) {
    const body = await res.json().catch(() => ({}))
    throw { status: res.status, ...body }
  }
  return res.json()
}
```

- [ ] **Step 4: useAuth 훅 작성**

```typescript
// frontend/src/hooks/useAuth.ts
import { useState, useEffect, createContext, useContext } from 'react'
import { api } from '../api/client'

interface User { id: string; username: string; display_name: string; role: string }
interface AuthCtx { user: User | null; loading: boolean; login: (u: string, p: string) => Promise<void>; logout: () => Promise<void> }

export const AuthContext = createContext<AuthCtx>({ user: null, loading: true, login: async () => {}, logout: async () => {} })
export const useAuth = () => useContext(AuthContext)

export function useAuthProvider(): AuthCtx {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    api<User>('/api/v1/auth/me').then(setUser).catch(() => setUser(null)).finally(() => setLoading(false))
  }, [])

  const login = async (username: string, password: string) => {
    const u = await api<User>('/api/v1/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) })
    setUser(u)
  }
  const logout = async () => {
    await api('/api/v1/auth/logout', { method: 'POST' })
    setUser(null)
  }
  return { user, loading, login, logout }
}
```

- [ ] **Step 5: useApi 훅 작성**

```typescript
// frontend/src/hooks/useApi.ts
import { useState, useCallback } from 'react'
import { api } from '../api/client'

export function useApi<T>(path: string) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const fetch = useCallback(async (options?: RequestInit) => {
    setLoading(true)
    setError(null)
    try {
      const result = await api<T>(path, options)
      setData(result)
      return result
    } catch (e: any) {
      setError(e?.error?.message ?? 'Error')
      throw e
    } finally {
      setLoading(false)
    }
  }, [path])

  return { data, loading, error, fetch }
}
```

- [ ] **Step 6: 커밋**

```bash
git add frontend/src/i18n/ frontend/src/api/ frontend/src/hooks/
git commit -m "feat: 프론트엔드 i18n 훅, API 클라이언트, 인증 훅, useApi"
```

---

### Task 12: 레이아웃 컴포넌트 + CSS

**파일:**
- 생성: `frontend/src/styles/global.css`
- 생성: `frontend/src/components/layout/AppShell.tsx`
- 생성: `frontend/src/components/layout/Sidebar.tsx`
- 생성: `frontend/src/components/layout/Topbar.tsx`
- 생성: `frontend/src/components/ui/Button.tsx`
- 생성: `frontend/src/components/ui/Input.tsx`
- 생성: `frontend/src/components/ui/Modal.tsx`
- 생성: `frontend/src/components/ui/Badge.tsx`
- 생성: `frontend/src/components/ui/Table.tsx`

- [ ] **Step 1: global.css 작성 — 다크 테마 CSS 변수**

기존 프로토타입의 다크 테마 CSS 변수 기반. `--bg`, `--bg-card`, `--text`, `--accent`, `--green`, `--orange` 등.

- [ ] **Step 2: AppShell 작성 — Sidebar + Topbar + content 영역**

Sidebar (왼쪽 고정) + Topbar (상단) + main content 영역. `useAuth()`로 사용자 정보 표시.

- [ ] **Step 3: Sidebar 작성 — 네비게이션**

Dashboard 링크, admin 메뉴 (admin만 보임), 현재 프로젝트 메뉴 (프로젝트 선택 시). `useI18n()`으로 모든 텍스트. Phase 2 메뉴 항목 (에피소드, 엔티티)을 비활성 상태로 표시 — `opacity: 0.4`, `pointer-events: none`, "(준비중)" 라벨.

- [ ] **Step 4: UI 기본 컴포넌트 작성**

Button, Input, Modal, Badge, Table — 재사용 가능한 기본 컴포넌트. 각각 CSS module 또는 inline style.

- [ ] **Step 5: 커밋**

```bash
git add frontend/src/styles/ frontend/src/components/
git commit -m "feat: 레이아웃 컴포넌트 (AppShell, Sidebar, Topbar) + UI 기본 컴포넌트 + 다크 테마"
```

---

## Chunk 4: 프론트엔드 페이지

### Task 13: 로그인 페이지

**파일:**
- 생성: `frontend/src/pages/Login.tsx`
- 생성: `frontend/src/pages/NotFound.tsx`
- 수정: `frontend/src/App.tsx` — 라우팅 추가 (catch-all 404 포함)

- [ ] **Step 1: Login.tsx 작성**

다크 테마 중앙 카드, username/password 입력, i18n 문자열 사용, 에러 메시지 표시. `useAuth().login()` 호출 후 대시보드로 리다이렉트.

- [ ] **Step 2: App.tsx에 라우팅 설정**

`react-router-dom` BrowserRouter, 로그인/대시보드/프로젝트상세/admin 라우트. 인증 안 된 경우 로그인으로 리다이렉트. `NotFound.tsx`를 catch-all `*` 라우트로 추가.

- [ ] **Step 3: 브라우저에서 로그인 테스트**

백엔드 + 프론트엔드 동시 기동, `http://localhost:3000` 접속, admin/admin123 로그인 확인.

- [ ] **Step 4: 커밋**

```bash
git add frontend/src/pages/Login.tsx frontend/src/App.tsx
git commit -m "feat: 로그인 페이지 + 라우팅 설정"
```

---

### Task 14: 대시보드 페이지

**파일:**
- 생성: `frontend/src/pages/Dashboard.tsx`

- [ ] **Step 1: Dashboard.tsx 작성**

프로젝트 카드 그리드. API에서 프로젝트 목록 조회. 각 카드에 이름, 멤버 수, 상태 뱃지, 역할 뱃지. "새 프로젝트" 버튼 → 모달에서 이름/설명 입력 → POST `/api/v1/projects`.

- [ ] **Step 2: 프로젝트 생성 모달 구현**

Modal 컴포넌트 사용, Input으로 이름/설명 입력, 생성 후 목록 갱신.

- [ ] **Step 3: 브라우저 확인**

로그인 후 대시보드 표시, 프로젝트 생성 동작 확인.

- [ ] **Step 4: 커밋**

```bash
git add frontend/src/pages/Dashboard.tsx
git commit -m "feat: 대시보드 — 프로젝트 목록 카드 + 프로젝트 생성 모달"
```

---

### Task 15: 프로젝트 상세 페이지

**파일:**
- 생성: `frontend/src/pages/ProjectDetail.tsx`
- 생성: `frontend/src/components/shared/ActivityFeed.tsx`
- 생성: `frontend/src/components/shared/MemberList.tsx`

- [ ] **Step 1: ProjectDetail.tsx 작성 — 탭 구조**

개요/멤버/활동 3개 탭. URL 파라미터로 프로젝트 ID. API에서 프로젝트 상세 + 멤버 + 활동 조회.

- [ ] **Step 2: ActivityFeed 컴포넌트 작성**

활동 로그 목록 표시. actor, action, 시간 표시. 프로젝트별/전체 두 곳에서 재사용.

- [ ] **Step 3: MemberList 컴포넌트 작성**

멤버 테이블 + 역할 뱃지. owner/admin인 경우 멤버 추가 모달, 역할 변경, 제거 버튼 표시.

- [ ] **Step 4: 브라우저 확인**

프로젝트 상세 페이지 동작 확인. 멤버 추가/제거, 활동 로그 확인.

- [ ] **Step 5: 커밋**

```bash
git add frontend/src/pages/ProjectDetail.tsx frontend/src/components/shared/
git commit -m "feat: 프로젝트 상세 — 개요/멤버/활동 탭, ActivityFeed, MemberList"
```

---

### Task 16: Admin 페이지들

**파일:**
- 생성: `frontend/src/pages/admin/Users.tsx`
- 생성: `frontend/src/pages/admin/ActivityLog.tsx`

- [ ] **Step 1: Users.tsx 작성**

사용자 테이블 (Table 컴포넌트 사용). 사용자 생성 모달, 수정 모달. admin만 접근 — 라우트에서 role 체크.

- [ ] **Step 2: ActivityLog.tsx 작성**

전체 활동 로그 테이블. 필터: 액션 종류, 사용자, 날짜 범위. 페이지네이션. ActivityFeed 컴포넌트 재사용.

- [ ] **Step 3: 브라우저 확인**

admin 로그인 → 사용자 관리 → 사용자 생성 → 활동 로그 확인. creator 로그인 시 admin 메뉴 미표시 확인.

- [ ] **Step 4: 커밋**

```bash
git add frontend/src/pages/admin/
git commit -m "feat: Admin 페이지 — 사용자 관리 + 전체 활동 로그"
```

---

## Chunk 5: 통합 + 마무리

### Task 17: 통합 테스트 + 브라우저 검증

- [ ] **Step 1: 전체 백엔드 테스트 실행**

실행: `cd backend && .venv/bin/python -m pytest tests/ -v`
기대: 전체 PASS

- [ ] **Step 2: 프론트엔드 빌드 확인**

실행: `cd frontend && npm run build`
기대: 빌드 성공

- [ ] **Step 3: 전체 흐름 브라우저 확인**

1. admin 로그인
2. 사용자 생성 (creator02)
3. 프로젝트 생성
4. 프로젝트에 creator02 멤버 추가
5. 활동 로그에 위 4단계 모두 기록 확인
6. creator02로 로그인 → 해당 프로젝트만 보이는지 확인
7. creator로 로그인 → admin 메뉴 안 보이는지 확인

- [ ] **Step 4: 커밋**

```bash
git commit -m "test: 통합 테스트 통과 확인"
```

---

### Task 18: .gitignore 업데이트 + 개발 로그 기록 + push

- [ ] **Step 1: dev-log.md 업데이트**

실제 커밋 해시들로 개발 로그 업데이트.

- [ ] **Step 2: 최종 커밋 + push**

```bash
git add -A
git commit -m "feat: Phase 1 완료 — 인증, 사용자 관리, 프로젝트 CRUD, 활동 로그, 프론트엔드"
git push origin main
```
