# packs-lab 인프라 통합 + admin mint-token Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** packs-lab을 운영 가능 상태로 만든다 — admin upload 토큰 발급 endpoint + Supabase 스키마 + docker-compose/nginx/env 통합 + 통합 테스트 + 문서 갱신.

**Architecture:** 기존 코드(HMAC + DSM client + 4 라우트)는 그대로 유지하고, 신규 라우트 1개(`POST /api/packs/admin/mint-token`)를 routes.py에 추가한다. Supabase `pack_files` DDL 파일과 인프라(docker-compose 18950, nginx 5GB streaming, .env.example 6+1 환경변수)를 신설하고, 통합 테스트(routes + dsm_client mock)와 CLAUDE.md 5+1곳을 갱신한다.

**Tech Stack:** Python 3.12 / FastAPI / pytest + unittest.mock / Supabase(PostgreSQL) / Synology DSM 7.x API / nginx / Docker Compose

**스펙 참조:** `docs/superpowers/specs/2026-05-05-packs-lab-infra-integration-design.md`

**작업 디렉토리:** `C:\Users\jaeoh\Desktop\workspace\web-backend` (기존 web-backend repo)

---

## Task 1: 테스트 인프라 — `tests/conftest.py`

기존 `tests/test_auth.py`는 `BACKEND_HMAC_SECRET=secret` 같은 fixture가 없어 환경변수 의존. 모든 테스트가 동일한 secret으로 동작하도록 autouse fixture를 conftest에 정리.

**Files:**
- Create: `packs-lab/tests/conftest.py`

- [ ] **Step 1: conftest.py 생성**

`packs-lab/tests/conftest.py`:

```python
"""packs-lab 테스트 공통 fixture."""
import pytest


@pytest.fixture(autouse=True)
def _hmac_secret(monkeypatch):
    """모든 테스트에서 동일한 HMAC secret 사용. auth._SECRET 모듈 캐시까지 갱신."""
    monkeypatch.setenv("BACKEND_HMAC_SECRET", "test-secret-do-not-use-in-prod")
    # auth.py 모듈은 import 시점에 _SECRET을 캐시하므로 monkeypatch로 함께 갱신
    from app import auth
    monkeypatch.setattr(auth, "_SECRET", "test-secret-do-not-use-in-prod")
```

- [ ] **Step 2: 기존 test_auth.py 회귀 검증**

```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab
python -m pytest tests/test_auth.py -v
```

Expected: 기존 테스트 모두 PASS (conftest 영향 없거나 PASS 그대로 유지). 만약 secret 인코딩 차이로 실패 시 해당 테스트의 secret 사용 부분을 conftest 값과 일치시킨다.

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

```bash
git add packs-lab/tests/conftest.py
git commit -m "test(packs-lab): conftest로 HMAC secret 통일"
```

---

## Task 2: admin mint-token 라우트 (스키마 + 구현 + 테스트)

`POST /api/packs/admin/mint-token` 신규. Pydantic 스키마 추가 + 라우트 구현 + 통합 테스트.

**Files:**
- Modify: `packs-lab/app/models.py` (스키마 2개 추가)
- Modify: `packs-lab/app/routes.py` (import 보강 + 라우트 추가)
- Create: `packs-lab/tests/test_routes.py` (mint-token 관련 테스트만 우선)

- [ ] **Step 1: failing 테스트 작성**

`packs-lab/tests/test_routes.py`:

```python
"""packs-lab 라우트 통합 테스트.

DSM·Supabase는 mock. HMAC 검증·토큰 발급·검증은 실제 코드 사용.
"""
import hashlib
import hmac
import json
import time
from unittest.mock import patch, MagicMock

from fastapi.testclient import TestClient

from app.main import app

SECRET = "test-secret-do-not-use-in-prod"


def _hmac_headers(body_bytes: bytes) -> dict:
    """body에 대한 X-Timestamp + X-Signature 헤더 생성."""
    ts = str(int(time.time()))
    sig = hmac.new(SECRET.encode(), ts.encode() + b"." + body_bytes, hashlib.sha256).hexdigest()
    return {"X-Timestamp": ts, "X-Signature": sig}


def test_mint_token_hmac_required():
    """HMAC 헤더 누락 → 401."""
    client = TestClient(app)
    body = {"tier": "pro", "label": "샘플", "filename": "x.zip", "size_bytes": 1024}
    resp = client.post("/api/packs/admin/mint-token", json=body)
    assert resp.status_code == 401


def test_mint_token_returns_valid_token():
    """발급된 token이 verify_upload_token으로 통과해야 한다."""
    from app.auth import verify_upload_token

    body = {"tier": "pro", "label": "샘플", "filename": "test.zip", "size_bytes": 2048}
    body_bytes = json.dumps(body).encode()
    headers = _hmac_headers(body_bytes)
    headers["Content-Type"] = "application/json"

    client = TestClient(app)
    resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=headers)
    assert resp.status_code == 200
    data = resp.json()
    assert "token" in data and "expires_at" in data and "jti" in data

    payload = verify_upload_token(data["token"])
    assert payload["tier"] == "pro"
    assert payload["label"] == "샘플"
    assert payload["filename"] == "test.zip"
    assert payload["size_bytes"] == 2048
    assert payload["jti"] == data["jti"]


def test_mint_token_invalid_filename():
    """허용 외 확장자 → 400."""
    body = {"tier": "pro", "label": "샘플", "filename": "x.exe", "size_bytes": 1024}
    body_bytes = json.dumps(body).encode()
    headers = _hmac_headers(body_bytes)
    headers["Content-Type"] = "application/json"

    client = TestClient(app)
    resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=headers)
    assert resp.status_code == 400
```

- [ ] **Step 2: 실패 확인**

```bash
cd packs-lab
python -m pytest tests/test_routes.py -v
```

Expected: 모든 테스트 FAIL — `/api/packs/admin/mint-token` 라우트 없음 (404 또는 405).

- [ ] **Step 3: models.py에 스키마 추가**

`packs-lab/app/models.py` 끝부분에 추가:

```python
class MintTokenRequest(BaseModel):
    """Vercel → backend: admin upload 토큰 발급 요청."""
    tier: PackTier
    label: str = Field(..., max_length=200)
    filename: str = Field(..., max_length=255)
    size_bytes: int = Field(..., gt=0, le=5 * 1024 * 1024 * 1024)


class MintTokenResponse(BaseModel):
    token: str
    expires_at: datetime
    jti: str
```

- [ ] **Step 4: routes.py에 mint-token 라우트 추가**

`packs-lab/app/routes.py` 상단 import 블록에 다음을 추가:

```python
import time
from datetime import timezone
```

(이미 `import uuid`, `from datetime import datetime`은 있음)

`from .auth import` 라인을 다음과 같이 확장:

```python
from .auth import mint_upload_token, verify_request_hmac, verify_upload_token
```

`from .models import` 라인을 다음과 같이 확장:

```python
from .models import (
    MintTokenRequest,
    MintTokenResponse,
    PackFileItem,
    SignLinkRequest,
    SignLinkResponse,
    UploadResponse,
)
```

상수 추가 (`MAX_BYTES` 다음 줄에):

```python
UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800"))  # 30분 default
```

라우트 추가 (`sign_link` 함수 다음, `upload` 함수 앞):

```python
@router.post("/admin/mint-token", response_model=MintTokenResponse)
async def mint_token(
    request: Request,
    x_timestamp: str = Header(""),
    x_signature: str = Header(""),
):
    body = await request.body()
    verify_request_hmac(body, x_timestamp, x_signature)
    payload = MintTokenRequest.model_validate_json(body)
    _check_filename(payload.filename)

    jti = str(uuid.uuid4())
    expires_ts = int(time.time()) + UPLOAD_TOKEN_TTL_SEC
    token = mint_upload_token({
        "tier": payload.tier,
        "label": payload.label,
        "filename": payload.filename,
        "size_bytes": payload.size_bytes,
        "jti": jti,
        "expires_at": expires_ts,
    })
    return MintTokenResponse(
        token=token,
        expires_at=datetime.fromtimestamp(expires_ts, tz=timezone.utc),
        jti=jti,
    )
```

- [ ] **Step 5: 테스트 통과 확인**

```bash
cd packs-lab
python -m pytest tests/test_routes.py -v
```

Expected: 3 passed.

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

```bash
git add packs-lab/app/models.py packs-lab/app/routes.py packs-lab/tests/test_routes.py
git commit -m "feat(packs-lab): POST /api/packs/admin/mint-token 라우트 + 통합 테스트"
```

---

## Task 3: 기존 4 라우트 통합 테스트 (sign-link / upload / list / delete)

기존 라우트는 변경 없음. 테스트만 추가해 회귀 안전망 확보.

**Files:**
- Modify: `packs-lab/tests/test_routes.py` (테스트 8개 추가)

- [ ] **Step 1: sign-link 테스트 추가**

`tests/test_routes.py` 끝에 추가:

```python
def test_sign_link_hmac_required():
    """HMAC 헤더 없으면 401."""
    client = TestClient(app)
    body = {"file_path": "/volume1/docker/webpage/media/packs/pro/x.zip"}
    resp = client.post("/api/packs/sign-link", json=body)
    assert resp.status_code == 401


def test_sign_link_outside_base_dir():
    """PACK_BASE_DIR 외부 경로 → 400."""
    body = {"file_path": "/etc/passwd"}
    body_bytes = json.dumps(body).encode()
    headers = _hmac_headers(body_bytes)
    headers["Content-Type"] = "application/json"

    client = TestClient(app)
    resp = client.post("/api/packs/sign-link", content=body_bytes, headers=headers)
    assert resp.status_code == 400


def test_sign_link_calls_dsm():
    """DSM client 호출되고 응답 URL 반환."""
    from datetime import datetime, timezone
    from unittest.mock import AsyncMock

    body = {"file_path": "/volume1/docker/webpage/media/packs/pro/sample.zip"}
    body_bytes = json.dumps(body).encode()
    headers = _hmac_headers(body_bytes)
    headers["Content-Type"] = "application/json"

    fake_url = "https://gahusb.synology.me:5001/sharing/abc123"
    fake_expires = datetime(2026, 5, 5, 13, 0, tzinfo=timezone.utc)

    with patch("app.routes.create_share_link", new=AsyncMock(return_value=(fake_url, fake_expires))) as mock:
        client = TestClient(app)
        resp = client.post("/api/packs/sign-link", content=body_bytes, headers=headers)

    assert resp.status_code == 200
    data = resp.json()
    assert data["url"] == fake_url
    mock.assert_awaited_once()
```

- [ ] **Step 2: upload 테스트 추가**

```python
def _make_upload_token(tier="pro", label="샘플", filename="test.zip", size_bytes=1024, jti=None, ttl=1800):
    """테스트용 upload token 생성. mint_token endpoint 거치지 않고 직접."""
    import uuid
    from app.auth import mint_upload_token
    return mint_upload_token({
        "tier": tier,
        "label": label,
        "filename": filename,
        "size_bytes": size_bytes,
        "jti": jti or str(uuid.uuid4()),
        "expires_at": int(time.time()) + ttl,
    })


def test_upload_token_required():
    """Authorization Bearer 누락 → 401."""
    client = TestClient(app)
    resp = client.post("/api/packs/upload", files={"file": ("x.zip", b"hello")})
    assert resp.status_code == 401


def test_upload_size_mismatch(tmp_path, monkeypatch):
    """토큰 size_bytes ≠ 실제 → 400 + 파일 정리됨."""
    monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
    token = _make_upload_token(size_bytes=999)  # 실제 5바이트지만 토큰엔 999

    client = TestClient(app)
    resp = client.post(
        "/api/packs/upload",
        files={"file": ("test.zip", b"hello")},
        headers={"Authorization": f"Bearer {token}"},
    )
    assert resp.status_code == 400
    assert "크기" in resp.json()["detail"]


def test_upload_jti_replay(tmp_path, monkeypatch):
    """같은 jti 토큰 두 번 → 두 번째 409."""
    monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)

    fake_supabase = MagicMock()
    fake_supabase.table.return_value.insert.return_value.execute.return_value = MagicMock(
        data=[{"uploaded_at": "2026-05-05T12:00:00+00:00"}]
    )

    token = _make_upload_token(filename="replay.zip", size_bytes=5, jti="replay-jti-1")

    with patch("app.routes._supabase", return_value=fake_supabase):
        client = TestClient(app)

        # 1차: 성공
        resp1 = client.post(
            "/api/packs/upload",
            files={"file": ("replay.zip", b"hello")},
            headers={"Authorization": f"Bearer {token}"},
        )
        assert resp1.status_code == 200

        # 2차: 동일 토큰 재사용 — 두 번째 파일은 다른 이름으로 보내 파일명 충돌 회피
        resp2 = client.post(
            "/api/packs/upload",
            files={"file": ("replay.zip", b"world")},
            headers={"Authorization": f"Bearer {token}"},
        )
        assert resp2.status_code == 409
```

- [ ] **Step 3: list / delete 테스트 추가**

```python
def test_list_returns_active_only():
    """mock supabase가 deleted_at IS NULL 행만 반환하는지 (쿼리 빌더 호출 검증)."""
    fake_rows = [
        {
            "id": "11111111-1111-1111-1111-111111111111",
            "min_tier": "pro",
            "label": "샘플",
            "file_path": "/volume1/docker/webpage/media/packs/pro/a.zip",
            "filename": "a.zip",
            "size_bytes": 1024,
            "sort_order": 0,
            "uploaded_at": "2026-05-05T12:00:00+00:00",
        }
    ]

    fake_supabase = MagicMock()
    chain = fake_supabase.table.return_value.select.return_value
    chain.is_.return_value.order.return_value.order.return_value.execute.return_value = MagicMock(data=fake_rows)

    body_bytes = b""
    headers = _hmac_headers(body_bytes)

    with patch("app.routes._supabase", return_value=fake_supabase):
        client = TestClient(app)
        resp = client.get("/api/packs/list", headers=headers)

    assert resp.status_code == 200
    items = resp.json()
    assert len(items) == 1
    assert items[0]["filename"] == "a.zip"
    fake_supabase.table.return_value.select.return_value.is_.assert_called_with("deleted_at", "null")


def test_delete_soft_deletes():
    """DELETE 시 supabase update에 deleted_at ISO timestamp가 들어가야 한다."""
    fake_supabase = MagicMock()
    fake_supabase.table.return_value.update.return_value.eq.return_value.execute.return_value = MagicMock(
        data=[{"id": "abc"}]
    )

    body_bytes = b""
    headers = _hmac_headers(body_bytes)

    with patch("app.routes._supabase", return_value=fake_supabase):
        client = TestClient(app)
        resp = client.delete("/api/packs/abc", headers=headers)

    assert resp.status_code == 200
    update_call = fake_supabase.table.return_value.update.call_args
    update_kwargs = update_call.args[0]
    assert "deleted_at" in update_kwargs
    # ISO 8601 timestamp 형식 검증 (예: 2026-05-05T12:00:00+00:00)
    assert "T" in update_kwargs["deleted_at"]
```

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

```bash
cd packs-lab
python -m pytest tests/test_routes.py -v
```

Expected: 11 passed (3 from Task 2 + 3 sign-link + 3 upload + 2 list/delete).

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

```bash
git add packs-lab/tests/test_routes.py
git commit -m "test(packs-lab): 기존 4 라우트 통합 테스트 (sign-link, upload, list, delete)"
```

---

## Task 4: `tests/test_dsm_client.py` — DSM client mock 테스트

**Files:**
- Create: `packs-lab/tests/test_dsm_client.py`

- [ ] **Step 1: DSM client 테스트 작성**

`packs-lab/tests/test_dsm_client.py`:

```python
"""DSM 7.x API client 테스트 — httpx mock으로 외부 호출 차단."""
import asyncio
from unittest.mock import patch, MagicMock

import pytest
import httpx

from app.dsm_client import create_share_link, DSMError, _login, _logout


@pytest.fixture(autouse=True)
def _dsm_env(monkeypatch):
    monkeypatch.setenv("DSM_HOST", "https://test-nas:5001")
    monkeypatch.setenv("DSM_USER", "test-user")
    monkeypatch.setenv("DSM_PASS", "test-pass")
    # 모듈 캐시도 갱신
    from app import dsm_client
    monkeypatch.setattr(dsm_client, "DSM_HOST", "https://test-nas:5001")
    monkeypatch.setattr(dsm_client, "DSM_USER", "test-user")
    monkeypatch.setattr(dsm_client, "DSM_PASS", "test-pass")


def _make_response(json_data, status_code=200):
    """httpx.Response mock."""
    mock = MagicMock(spec=httpx.Response)
    mock.json.return_value = json_data
    mock.status_code = status_code
    mock.raise_for_status = MagicMock()
    return mock


def test_create_share_link_login_logout():
    """login → Sharing.create → logout 순서가 보장되어야 한다."""
    call_order = []

    async def fake_get(self, url, *, params=None, **kw):
        api = (params or {}).get("api", "")
        method = (params or {}).get("method", "")
        call_order.append(f"{api}.{method}")
        if api == "SYNO.API.Auth" and method == "login":
            return _make_response({"success": True, "data": {"sid": "fake-sid"}})
        if api == "SYNO.API.Auth" and method == "logout":
            return _make_response({"success": True})
        if api == "SYNO.FileStation.Sharing" and method == "create":
            return _make_response({
                "success": True,
                "data": {"links": [{"url": "https://test-nas:5001/sharing/abc"}]},
            })
        return _make_response({"success": False, "error": "unexpected"})

    with patch.object(httpx.AsyncClient, "get", new=fake_get):
        url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=3600))

    assert url == "https://test-nas:5001/sharing/abc"
    assert call_order == [
        "SYNO.API.Auth.login",
        "SYNO.FileStation.Sharing.create",
        "SYNO.API.Auth.logout",
    ]


def test_create_share_link_returns_url_and_expiry():
    """응답 파싱 — links[0].url 사용."""
    async def fake_get(self, url, *, params=None, **kw):
        method = (params or {}).get("method", "")
        if method == "login":
            return _make_response({"success": True, "data": {"sid": "sid"}})
        if method == "create":
            return _make_response({
                "success": True,
                "data": {"links": [{"url": "https://nas/sharing/xyz"}]},
            })
        return _make_response({"success": True})

    with patch.object(httpx.AsyncClient, "get", new=fake_get):
        url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=7200))

    assert url == "https://nas/sharing/xyz"
    assert expires_at is not None


def test_dsm_login_failure_raises():
    """login API success=False → DSMError."""
    async def fake_get(self, url, *, params=None, **kw):
        return _make_response({"success": False, "error": {"code": 400}})

    with patch.object(httpx.AsyncClient, "get", new=fake_get):
        with pytest.raises(DSMError, match="login 실패"):
            asyncio.run(create_share_link("/volume1/test/file.zip"))


def test_dsm_share_failure_logs_out():
    """Sharing.create 실패해도 logout 호출 (try/finally)."""
    call_order = []

    async def fake_get(self, url, *, params=None, **kw):
        method = (params or {}).get("method", "")
        call_order.append(method)
        if method == "login":
            return _make_response({"success": True, "data": {"sid": "sid"}})
        if method == "create":
            return _make_response({"success": False, "error": {"code": 401}})
        if method == "logout":
            return _make_response({"success": True})
        return _make_response({"success": False})

    with patch.object(httpx.AsyncClient, "get", new=fake_get):
        with pytest.raises(DSMError, match="Sharing.create 실패"):
            asyncio.run(create_share_link("/volume1/test/file.zip"))

    assert "login" in call_order
    assert "logout" in call_order, "logout이 호출되지 않음 (finally 누락 의심)"
```

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

```bash
cd packs-lab
python -m pytest tests/test_dsm_client.py -v
```

Expected: 4 passed.

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

```bash
git add packs-lab/tests/test_dsm_client.py
git commit -m "test(packs-lab): DSM client mock 테스트 (login/share/logout 순서)"
```

---

## Task 5: DELETE 라우트 docstring 수정

`routes.py` 모듈 docstring의 한 줄 변경.

**Files:**
- Modify: `packs-lab/app/routes.py:1-7` (모듈 docstring)

- [ ] **Step 1: docstring 수정**

`packs-lab/app/routes.py` 첫 docstring을 다음으로 변경:

```python
"""packs-lab API 엔드포인트.

- POST /api/packs/sign-link — Vercel HMAC 인증 → DSM 공유 링크
- POST /api/packs/admin/mint-token — Vercel HMAC 인증 → 일회성 upload 토큰
- POST /api/packs/upload — 일회성 토큰 인증 → multipart 저장 + supabase INSERT
- GET /api/packs/list — Vercel HMAC 인증 → pack_files 전체 조회
- DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete (DSM 공유는 자동 만료)
"""
```

(변경: `정리` → `자동 만료`, mint-token 줄 추가)

- [ ] **Step 2: 회귀 검증**

```bash
cd packs-lab
python -m pytest tests/ -v
```

Expected: 모든 테스트 그대로 통과 (15 passed).

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

```bash
git add packs-lab/app/routes.py
git commit -m "docs(packs-lab): routes 모듈 docstring 정리 (mint-token 추가, DSM 자동 만료 명시)"
```

---

## Task 6: Supabase `pack_files` DDL

운영 적용 시 Supabase SQL editor에서 실행할 SQL 파일.

**Files:**
- Create: `packs-lab/supabase/pack_files.sql`

- [ ] **Step 1: SQL 파일 생성**

`packs-lab/supabase/pack_files.sql`:

```sql
-- pack_files: NAS에 저장된 다운로드 가능한 패키지 파일 메타
-- 운영 적용: Supabase Dashboard → SQL editor에서 실행
create table if not exists public.pack_files (
    id              uuid        primary key default gen_random_uuid(),
    min_tier        text        not null check (min_tier in ('starter','pro','master')),
    label           text        not null,
    file_path       text        not null unique,
    filename        text        not null,
    size_bytes      bigint      not null check (size_bytes > 0),
    sort_order      integer     not null default 0,
    uploaded_at     timestamptz not null default now(),
    deleted_at      timestamptz
);

-- list 라우트 hot path: deleted_at IS NULL + tier/order 정렬
create index if not exists pack_files_active_idx
    on public.pack_files (min_tier, sort_order)
    where deleted_at is null;

-- soft-deleted 통계 / cleanup 잡 대비
create index if not exists pack_files_deleted_at_idx
    on public.pack_files (deleted_at)
    where deleted_at is not null;
```

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

```bash
git add packs-lab/supabase/pack_files.sql
git commit -m "feat(packs-lab): Supabase pack_files DDL + 활성/삭제 인덱스"
```

---

## Task 7: 인프라 통합 — docker-compose / nginx / .env.example / deploy-nas.sh

**Files:**
- Modify: `docker-compose.yml` (packs-lab 서비스 추가, env에 PACK_BASE_DIR/PACK_HOST_DIR 포함)
- Modify: `nginx/default.conf` (`/api/packs/` 라우팅)
- Modify: `.env.example` (DSM/HMAC/Supabase 6 + PACK 3 path)
- Modify: `scripts/deploy-nas.sh` (SERVICES 화이트리스트에 `packs-lab` 추가 — 누락 시 NAS 컨테이너 미등장)

- [ ] **Step 1: docker-compose.yml — packs-lab 서비스 추가**

`docker-compose.yml`에서 다른 lab 서비스(예: `realestate-lab`) 정의 다음에 추가:

```yaml
  packs-lab:
    build:
      context: ./packs-lab
      dockerfile: Dockerfile
    container_name: packs-lab
    restart: unless-stopped
    ports:
      - "18950:8000"
    environment:
      TZ: Asia/Seoul
      DSM_HOST: ${DSM_HOST}
      DSM_USER: ${DSM_USER}
      DSM_PASS: ${DSM_PASS}
      BACKEND_HMAC_SECRET: ${BACKEND_HMAC_SECRET}
      SUPABASE_URL: ${SUPABASE_URL}
      SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_KEY}
      UPLOAD_TOKEN_TTL_SEC: ${UPLOAD_TOKEN_TTL_SEC:-1800}
    volumes:
      - ${PACK_DATA_PATH:-./data/packs}:/volume1/docker/webpage/media/packs
```

- [ ] **Step 2: nginx/default.conf — /api/packs/ 라우팅**

기존 `location /api/agent-office/ { ... }` 다음(또는 다른 `/api/...` 라우트들 근처)에 추가:

```nginx
    location /api/packs/ {
        proxy_pass http://packs-lab:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # 5GB 멀티파트 업로드 대응
        client_max_body_size 5G;
        proxy_request_buffering off;
        proxy_read_timeout 1800s;
        proxy_send_timeout 1800s;
    }
```

- [ ] **Step 3: .env.example — 6+1 환경변수 추가**

`.env.example` 끝에 추가:

```bash

# ─── packs-lab — NAS 자료 다운로드 자동화 ────────────────────────────
# Synology DSM 7.x 인증 (공유 링크 발급용)
DSM_HOST=https://gahusb.synology.me:5001
DSM_USER=
DSM_PASS=

# Vercel SaaS ↔ backend HMAC 시크릿 (양쪽 동일 값)
BACKEND_HMAC_SECRET=

# Supabase pack_files 테이블 접근 (service_role 키, RLS 우회)
SUPABASE_URL=https://<project>.supabase.co
SUPABASE_SERVICE_KEY=

# admin upload 토큰 TTL (초). default 1800 = 30분
UPLOAD_TOKEN_TTL_SEC=1800

# 로컬 개발: ./data/packs / NAS 운영: /volume1/docker/webpage/media/packs
PACK_DATA_PATH=./data/packs
```

- [ ] **Step 4: docker compose config 검증**

```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend
docker compose config 2>&1 | grep -A 10 "packs-lab:"
```

Expected: packs-lab 서비스 정의가 정상 출력 (port mapping, environment 변수, volumes 모두 보임). 환경변수가 비어있어도 docker compose config는 통과.

> ⚠️ Docker가 로컬에 설치되어 있어야 검증 가능. 실제 실행은 NAS에서. 로컬 docker가 없으면 step skip하고 nginx config 문법만 별도 검증.

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

```bash
git add docker-compose.yml nginx/default.conf .env.example
git commit -m "chore(infra): packs-lab 서비스 통합 (compose 18950 + nginx 5GB streaming + env 7개)"
```

---

## Task 8: NAS 디렉토리 준비 가이드 + 문서 갱신

**Files:**
- Modify: `web-backend/CLAUDE.md` (5곳 갱신)
- Modify: `workspace/CLAUDE.md` (1줄 추가)

- [ ] **Step 1: web-backend/CLAUDE.md — 1.프로젝트 개요**

찾을 위치 (1.프로젝트 개요 섹션):

```
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, deployer (9개)
```

다음으로 수정:

```
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
```

같은 섹션의 인프라 줄도:

```
- **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
```

- [ ] **Step 2: web-backend/CLAUDE.md — 4.Docker 서비스 표**

표 마지막에 신규 행 추가 (deployer 행 직전 또는 personal 행 다음 — 알파벳 순):

```
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
```

- [ ] **Step 3: web-backend/CLAUDE.md — 5.Nginx 라우팅 표**

표 적절한 위치에 신규 행 추가:

```
| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 대응 (`client_max_body_size 5G`, `proxy_request_buffering off`, 1800s timeout) |
```

- [ ] **Step 4: web-backend/CLAUDE.md — 8.로컬 개발 표**

표 끝에 신규 행 추가:

```
| Packs Lab | http://localhost:18950 |
```

- [ ] **Step 5: web-backend/CLAUDE.md — 9.서비스별 packs-lab 신규 섹션**

`### deployer (deployer/)` 섹션 직전에 추가 (또는 personal 다음):

```
### packs-lab (packs-lab/)
- NAS 자료 다운로드 자동화 — Synology DSM 공유링크 발급 + 5GB 멀티파트 업로드 수신
- Vercel SaaS와 HMAC 인증으로 통신, 사용자 인증은 Vercel이 Supabase로 처리 (본 서비스는 외부 인증 없음)
- DB: 외부 Supabase `pack_files` 테이블 (DDL: `packs-lab/supabase/pack_files.sql`)
- 파일 구조: `app/main.py`, `app/auth.py`, `app/dsm_client.py`, `app/routes.py`, `app/models.py`
- 운영 디렉토리: `/volume1/docker/webpage/media/packs/{starter,pro,master}/` (NAS PUID:PGID 권한 필요)

**환경변수**
- `DSM_HOST` / `DSM_USER` / `DSM_PASS`: Synology DSM 7.x 인증 (공유 링크 발급용)
- `BACKEND_HMAC_SECRET`: Vercel SaaS와 양쪽 공유 시크릿 (HMAC SHA256)
- `SUPABASE_URL` / `SUPABASE_SERVICE_KEY`: Supabase pack_files 테이블 접근 (service_role, RLS 우회)
- `UPLOAD_TOKEN_TTL_SEC`: admin upload 토큰 TTL (기본 1800초 = 30분)
- `PACK_DATA_PATH`: 호스트 마운트 경로 (로컬 `./data/packs`, NAS `/volume1/docker/webpage/media/packs`)

**HMAC 인증 패턴**
- Vercel → backend 요청: `X-Timestamp` (UNIX 초) + `X-Signature` (HMAC_SHA256(timestamp + "." + body, secret))
- Replay 방어: 타임스탬프 ±5분 윈도우
- admin browser → backend upload: `Authorization: Bearer <token>` (jti 단발성)

**packs-lab API 목록**

| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/api/packs/sign-link` | Vercel HMAC → DSM Sharing.create로 4시간 유효 다운로드 URL 발급 |
| POST | `/api/packs/admin/mint-token` | Vercel HMAC → 일회성 upload 토큰 발급 (기본 30분 TTL) |
| POST | `/api/packs/upload` | Bearer token → multipart 5GB 저장 + Supabase INSERT |
| GET | `/api/packs/list` | Vercel HMAC → 활성 pack_files 목록 (deleted_at IS NULL) |
| DELETE | `/api/packs/{file_id}` | Vercel HMAC → soft delete (DSM 공유는 자동 만료) |
```

- [ ] **Step 6: workspace/CLAUDE.md — 컨테이너 표 한 줄 추가**

`workspace/CLAUDE.md`의 "Docker 서비스 & 포트" 표에 추가:

```
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (Vercel SaaS와 HMAC 통신) |
```

(personal 행 다음 또는 적절한 위치)

- [ ] **Step 7: 커밋 (web-backend repo의 CLAUDE.md만)**

작업 디렉토리는 `C:\Users\jaeoh\Desktop\workspace\web-backend`. 그 안의 `CLAUDE.md`만 git 추적 대상.

```bash
git add CLAUDE.md
git commit -m "docs(claude): packs-lab 10번째 서비스로 등록 (포트/라우팅/API 표 + 신규 섹션)"
```

> ℹ️ `workspace/CLAUDE.md`(상위 디렉토리의 워크스페이스 메모)는 git repo가 아님. 텍스트 편집만 하고 commit 대상에서 제외.

---

## Task 9: 회귀 검증 + NAS 디렉토리 가이드

전체 테스트 + docker compose config + NAS 배포 전 가이드.

**Files:**
- (검증만)

- [ ] **Step 1: 전체 pytest**

```bash
cd packs-lab
python -m pytest tests/ -v
```

Expected: 모든 테스트 통과 (test_auth + test_routes + test_dsm_client = 약 15+ tests).

- [ ] **Step 2: docker compose config 검증**

```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend
docker compose config 2>&1 | tail -30
```

Expected: error 없이 packs-lab 포함된 전체 config 출력.

> ⚠️ Docker 미설치 시 skip. NAS에서 git push 후 webhook 배포 시점에 검증됨.

- [ ] **Step 3: NAS 배포 전 가이드 출력**

배포 전 NAS에서 SSH로 1회 실행할 명령들을 README 또는 NAS 배포 노트로 정리. 본 task에서는 명령만 제시 (실행은 사용자):

```bash
# NAS SSH로 접속 후
mkdir -p /volume1/docker/webpage/media/packs/{starter,pro,master}
chown -R PUID:PGID /volume1/docker/webpage/media/packs  # PUID/PGID는 .env 값 사용

# .env에 신규 환경변수 추가 (DSM_*, BACKEND_HMAC_SECRET, SUPABASE_*, UPLOAD_TOKEN_TTL_SEC, PACK_DATA_PATH=/volume1/docker/webpage/media/packs)

# Supabase에서 packs-lab/supabase/pack_files.sql 실행

# git push 후 webhook이 자동 배포
```

- [ ] **Step 4: 최종 commit (검증 결과 빈 commit으로 마일스톤 표시 — 선택)**

```bash
# 만약 위 step에서 어떤 자동 수정이 있었으면 commit. 없으면 skip.
git status
```

회귀 검증으로 변경 사항 없으면 별도 commit 없이 종료.

---

## 완료 기준

- 모든 task의 step 통과 (체크박스 모두 체크)
- `cd packs-lab && python -m pytest tests/ -v` — 통과 (test_auth + test_routes + test_dsm_client)
- `docker compose config` — packs-lab 포함된 전체 config 정상
- web-backend/CLAUDE.md 5곳 갱신 + workspace/CLAUDE.md 1줄
- Supabase DDL 파일 존재 (운영 적용은 사용자가 NAS에서 SQL editor로)
- NAS 디렉토리 준비 명령은 사용자가 SSH로 실행 (배포 전 1회)

---

## 배포

git push → Gitea webhook → deployer rsync → docker compose up -d --build (자동).

**배포 전 사용자 액션 (1회)**:
1. Supabase에서 `pack_files` 테이블 생성 (DDL 실행)
2. NAS SSH로 `/volume1/docker/webpage/media/packs/{starter,pro,master}` 디렉토리 생성 + 권한
3. NAS `.env`에 신규 7개 환경변수 입력 (DSM 인증, HMAC secret, Supabase 키 등)

---

## 참고 — 후속 별도 plan (스코프 외)

- Vercel SaaS-side admin UI / 사용자 다운로드 UI / Supabase user 테이블
- DSM 공유 추적 (즉시 차단 필요 시)
- deleted_at + N일 후 실제 파일 삭제 cron
- multi-admin 토큰 발급 권한 분리
- resumable multipart 업로드 (5GB tus 등)
- pack_files sort_order 편집 endpoint
- 모니터링 (업로드 실패율, DSM API latency)
