# packs-lab 인프라 통합 + admin mint-token 설계

> 대상: `web-backend/packs-lab/`
> 외부 의존: Supabase(`pack_files` 테이블) + Vercel SaaS(HMAC 호출자)
> 후속 별도 스펙: Vercel-side admin UI / 사용자 다운로드 / cleanup cron / multi-admin

---

## 1. 목표

`packs-lab`은 NAS 자료 다운로드 자동화 백엔드. Synology DSM 공유 링크 발급 + 5GB 멀티파트 업로드 수신을 담당하고, Vercel SaaS와 HMAC으로 통신한다. 사용자 인증은 Vercel이 Supabase로 처리하고 본 서비스는 외부 인증을 다루지 않는다.

이미 코드(HMAC 미들웨어 / DSM client / 4 라우트)는 작성되어 있으나 인프라 통합 + Supabase 스키마 + admin upload 토큰 발급 흐름이 빠져 있어 운영 가능 상태가 아니다. 본 스펙은 그 갭을 메운다.

### 핵심 변경

- **신규 라우트**: `POST /api/packs/admin/mint-token` (Vercel HMAC → 일회성 업로드 토큰)
- **Supabase DDL**: `pack_files` 테이블 + 활성·삭제 인덱스
- **인프라**: docker-compose `packs-lab` 서비스 등록(18950) + nginx `/api/packs/` 5GB 통과 + `.env.example` 6+1 환경변수
- **테스트**: routes 통합 + DSM client mock
- **문서**: web-backend / workspace CLAUDE.md 5곳 갱신
- **DELETE 라우트 docstring**: "DSM 공유 정리" 표현을 "DSM 공유 자동 만료"로 수정 (실제 동작과 일치)

### 변경하지 않는 것

- 기존 `auth.py` (`mint_upload_token` 그대로 활용)
- 기존 `dsm_client.py`
- 기존 `routes.py`의 sign-link / upload / list / delete 본문
- DSM 공유 추적 테이블 — 4시간 자동 만료로 충분(브레인스토밍 결정)

---

## 2. 컴포넌트 + 통신 흐름

### 2.1 변경 받는 파일

| 영역 | 파일 | 변경 |
|------|------|------|
| 백엔드 | `packs-lab/app/routes.py` | DELETE docstring 수정 + admin mint-token 라우트 추가 |
| 백엔드 | `packs-lab/app/models.py` | `MintTokenRequest`, `MintTokenResponse` 스키마 추가 |
| 백엔드 | `packs-lab/app/auth.py` | 변경 없음 (기존 `mint_upload_token` 활용) |
| 테스트 | `packs-lab/tests/conftest.py` (신규) | autouse `BACKEND_HMAC_SECRET` 셋팅 |
| 테스트 | `packs-lab/tests/test_routes.py` (신규) | 5 라우트 통합 테스트 |
| 테스트 | `packs-lab/tests/test_dsm_client.py` (신규) | DSM 7.x API mock 테스트 |
| DB | `packs-lab/supabase/pack_files.sql` (신규) | DDL + 인덱스 |
| 인프라 | `docker-compose.yml` | `packs-lab` 서비스 추가 |
| 인프라 | `nginx/default.conf` | `/api/packs/` 라우팅 (`client_max_body_size 5G` + streaming) |
| 인프라 | `.env.example` | 6+1 신규 환경변수 |
| 문서 | `web-backend/CLAUDE.md` | 1·4·5·8·9 섹션 갱신 |
| 문서 | `workspace/CLAUDE.md` | 컨테이너 표 한 줄 추가 |

### 2.2 통신 흐름

**ADMIN 업로드**

```
Vercel admin UI ─────→ Vercel API (HMAC 헤더 추가)
                                    │
                                    ▼
                         POST /api/packs/admin/mint-token
                                    │
                  backend: verify_request_hmac
                                    │
                  mint_upload_token({tier, label, filename, size_bytes, jti, expires_at})
                                    │
Vercel ←─────────────── token ──────┘
   │
   ▼
admin browser → POST /api/packs/upload
                  Authorization: Bearer <token>
                  multipart body (≤5GB)
                                    │
                  backend: verify_upload_token + JTI mark
                                    │
                  파일 저장 (PACK_BASE_DIR/{filename}, 평면 구조 — tier는 filename 규칙으로 구분)
                                    │
                  Supabase INSERT pack_files
```

**사용자 다운로드**

```
사용자 → Vercel SaaS (Supabase auth + tier·결제 검증)
                              │
                              ▼
                 POST /api/packs/sign-link (HMAC + file_path)
                              │
                  backend: verify_request_hmac
                              │
                  DSM Sharing.create (4시간 만료)
                              │
사용자 ← Vercel ← 다운로드 URL (4시간 유효)
```

### 2.3 기각된 대안

| 대안 | 기각 사유 |
|------|-----------|
| Vercel-side 토큰 발급 | 토큰 포맷 양쪽 분산, 변경 시 동기화 부담 |
| admin browser → backend 직접 HMAC | admin browser에 secret 노출, 보안 약화 |
| DSM 공유 추적 테이블 | 4시간 자동 만료로 충분, YAGNI |
| Resumable multipart upload | 5GB는 단일 stream으로 충분, 복잡도 증가 |
| `pack_files.min_tier`를 PostgreSQL ENUM | tier 추가 시 ALTER TYPE 번거로움. text+CHECK 채택 |

---

## 3. `POST /api/packs/admin/mint-token`

### 3.1 Pydantic 스키마 (`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
```

### 3.2 라우트 본문 (`routes.py` 추가)

```python
import time, uuid
from datetime import datetime, timezone

from .auth import mint_upload_token, verify_request_hmac
from .models import MintTokenRequest, MintTokenResponse

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

@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)  # upload 라우트와 동일 검증

    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,
    )
```

### 3.3 결정 근거

| 항목 | 값 | 근거 |
|------|-----|------|
| TTL default | 1800s (30분) | 5GB 업로드 시작 + 진행 시간 여유. 1Gbps에서 약 40s, 50Mbps에서 약 14분 |
| TTL env override | `UPLOAD_TOKEN_TTL_SEC` | 운영 중 조정 가능 |
| filename 검증 | upload와 동일 (`_check_filename`) | 토큰 발급 시점에 미리 거부 → admin UI 즉시 피드백 |
| jti 응답 포함 | yes | admin이 업로드 결과 추적용 |
| Vercel ↔ backend | HMAC (`X-Timestamp` + `X-Signature`) | 다른 admin 라우트와 동일 패턴 |
| admin browser ↔ backend | Bearer token (단발성 jti) | 기존 upload 라우트 그대로 |

### 3.4 DELETE 라우트 docstring 수정

`routes.py` 모듈 docstring에서:

```diff
- DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete + DSM 공유 정리
+ DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete (DSM 공유는 자동 만료)
```

`delete_file` 함수에는 변경 없음.

---

## 4. Supabase `pack_files` DDL

**파일**: `packs-lab/supabase/pack_files.sql` (신규, 운영 배포 시 Supabase SQL editor에서 실행)

```sql
-- pack_files: NAS에 저장된 다운로드 가능한 패키지 파일 메타
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,    -- NAS 절대경로, 동일 경로 중복 방지
    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;
```

### 4.1 필드 결정 근거

| 필드 | 타입 / 제약 | 근거 |
|------|------------|------|
| `id` | uuid PK + `gen_random_uuid()` default | routes.py가 client-side `uuid.uuid4()` 생성하지만 default도 둬 fallback |
| `min_tier` | text + CHECK | enum 대신 text+CHECK가 PostgreSQL에서 더 유연 |
| `file_path` | text NOT NULL UNIQUE | 같은 tier/filename 충돌은 파일시스템에서 잡지만 DB 레벨도 보강 |
| `size_bytes` | bigint + CHECK > 0 | 5GB는 int 범위 안이지만 미래 대비 bigint |
| `sort_order` | int NOT NULL default 0 | routes INSERT가 sort_order 미지정 → 0 기본 |
| `uploaded_at` | timestamptz default now() | routes 코드가 `res.data[0]["uploaded_at"]` 그대로 응답에 사용 — DB가 채워줌 |
| `deleted_at` | nullable | soft delete |

### 4.2 RLS

비활성. backend가 `service_role` key 사용하므로 RLS 우회. Vercel/사용자 직접 접근 없음 → unsafe 아님.

---

## 5. 인프라 통합

### 5.1 `docker-compose.yml` — `packs-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}
      PACK_BASE_DIR: ${PACK_BASE_DIR:-/app/data/packs}
      PACK_HOST_DIR: ${PACK_HOST_DIR:-${PACK_DATA_PATH:-./data/packs}}
    volumes:
      - ${PACK_DATA_PATH:-./data/packs}:${PACK_BASE_DIR:-/app/data/packs}
```

| 결정 | 값 | 근거 |
|------|-----|------|
| 포트 | 18950 | 18800(realestate) → 18900(agent-office) → 18950(packs) 순차 |
| `PACK_BASE_DIR` (컨테이너 내부) | `/app/data/packs` | routes.py upload target. docker-compose volume 우측. |
| `PACK_HOST_DIR` (NAS 호스트) | 운영 `/volume1/docker/webpage/media/packs` / 로컬 fallback `./data/packs` | DSM·Supabase에 노출되는 절대경로. routes.py가 file_path로 저장. 미설정 시 `PACK_BASE_DIR`로 fallback. |
| `PACK_DATA_PATH` (호스트 마운트) | default `./data/packs` (로컬), NAS `/volume1/docker/webpage/media/packs` | docker-compose volume 좌측만 사용 |

### 5.2 `nginx/default.conf` — `/api/packs/` 라우팅

```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;     # 스트리밍 통과 (메모리/디스크 buffer 회피)
    proxy_read_timeout 1800s;
    proxy_send_timeout 1800s;
}
```

| 결정 | 근거 |
|------|------|
| `client_max_body_size 5G` | 라우트 단위 — 다른 location은 default 유지 |
| `proxy_request_buffering off` | 5GB 파일을 nginx가 모두 받고 backend에 forward하면 ~5GB 디스크 buffer 발생 |
| `proxy_read/send_timeout 1800s` | 30분 — 업로드 토큰 TTL과 일치, 느린 업링크에서 5GB 전송 여유 |

### 5.3 `.env.example` — 신규 환경변수 (7 + 3 path)

```bash
# ─── packs-lab — NAS 자료 다운로드 자동화 ────────────────────────────
# Synology DSM 7.x 인증 (공유 링크 발급용)
DSM_HOST=https://gahusb.synology.me:5001
DSM_USER=
DSM_PASS=
# LAN IP + self-signed cert 환경에서 IP mismatch 시 false (LAN 내부 통신이라 허용)
DSM_VERIFY_SSL=false

# 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

# 컨테이너 내부 저장 경로 (routes.py upload target. docker-compose volume 우측)
PACK_BASE_DIR=/app/data/packs

# DSM API용 path. Synology DSM API는 일반 사용자 권한일 때 /<shared_folder>/... 형식만 인식하고 /volume1/... 절대경로는 거부(error 408).
# 운영 NAS는 반드시 shared folder 시점 — /docker/webpage/media/packs.
# admin 사용자는 /volume1/... 도 가능하지만 보안상 별도 packs-bot user 권장.
PACK_HOST_DIR=/docker/webpage/media/packs
```

### 5.4 NAS 디렉토리 준비

운영 첫 배포 시 SSH로 1회. 파일은 `PACK_HOST_DIR` 평면에 직접 저장 — tier 디렉토리 분기는 만들지 않음(tier 구분은 filename 규칙으로 admin이 관리):

```bash
mkdir -p /volume1/docker/webpage/media/packs    # 호스트 OS path (volume 마운트용)
chown -R PUID:PGID /volume1/docker/webpage/media/packs
```

PUID/PGID는 `.env`의 기존 값 사용.

> ⚠️ **DSM 사용자 권한 — File Station + Sharing 둘 다 필요**: Control Panel → User → packs-bot(또는 admin) → Permissions → File Station에서 `docker` shared folder Read 권한 + Applications → Sharing 권한 ON.

### 5.5 `scripts/deploy-nas.sh` SERVICES 화이트리스트

webhook 자동 배포(deployer)가 호출하는 sync 스크립트는 화이트리스트로 동기화 대상 디렉토리를 명시한다. 신규 서비스 추가 시 반드시 함께 수정해야 NAS 운영 디렉토리에 소스 sync + docker compose 빌드가 동작한다.

```bash
SERVICES="lotto travel-proxy deployer stock-lab music-lab blog-lab realestate-lab agent-office personal packs-lab nginx scripts"
```

(packs-lab 누락 시 `docker compose ps`에 packs-lab 미등장 — 첫 배포 시 가장 흔한 누락 항목)

---

## 6. 테스트 전략

기존 `tests/test_auth.py` 유지. 신규 3 파일.

### 6.1 `tests/conftest.py` (신규)

```python
import pytest

@pytest.fixture(autouse=True)
def _hmac_secret(monkeypatch):
    """모든 테스트에서 동일한 HMAC secret 사용."""
    monkeypatch.setenv("BACKEND_HMAC_SECRET", "test-secret-do-not-use-in-prod")
```

### 6.2 `tests/test_routes.py` (신규) — 통합 테스트

DSM·Supabase 모두 mock. `pytest`, `monkeypatch`, `unittest.mock`, `fastapi.testclient.TestClient` 사용.

| 테스트 | 검증 |
|--------|------|
| `test_sign_link_hmac_required` | timestamp/signature 헤더 누락 → 401 |
| `test_sign_link_outside_base_dir` | file_path가 `PACK_BASE_DIR` 외부 → 400 |
| `test_sign_link_calls_dsm` | mock된 `create_share_link` 호출 검증, URL 응답 |
| `test_mint_token_hmac_required` | HMAC 누락 → 401 |
| `test_mint_token_returns_valid_token` | 발급된 token이 `verify_upload_token`으로 통과 |
| `test_mint_token_invalid_filename` | 확장자 미허용 → 400 |
| `test_upload_token_required` | Authorization Bearer 누락 → 401 |
| `test_upload_size_mismatch` | 토큰 size_bytes ≠ 실제 → 400 |
| `test_upload_jti_replay` | 같은 토큰 두 번 → 두 번째 409 |
| `test_list_returns_active_only` | mock supabase 응답에서 deleted_at NULL만 반환 |
| `test_delete_soft_deletes` | mock supabase update에 deleted_at ISO timestamp 들어감 |

### 6.3 `tests/test_dsm_client.py` (신규)

httpx mock(`respx` 또는 `MockTransport`) 또는 `monkeypatch.setattr` 패치.

| 테스트 | 검증 |
|--------|------|
| `test_create_share_link_login_logout` | login → Sharing.create → logout 순서 |
| `test_create_share_link_returns_url_and_expiry` | 응답 파싱 |
| `test_dsm_login_failure_raises` | login API success=false → DSMError |
| `test_dsm_share_failure_logs_out` | Sharing.create 실패해도 logout 호출 (try/finally) |

---

## 7. 문서 갱신

### 7.1 `web-backend/CLAUDE.md` — 5곳

**1. 1.프로젝트 개요**

```diff
- 서비스: 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개)
```

**2. 4.Docker 서비스 표** — 신규 행

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

**3. 5.Nginx 라우팅 표** — 신규 행

```
| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 (`client_max_body_size 5G` + `proxy_request_buffering off`) |
```

**4. 8.로컬 개발 표** — 신규 행

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

**5. 9.서비스별** — `### packs-lab (packs-lab/)` 신규 섹션

내용:
- 용도 (NAS DSM 공유링크 + 5GB 업로드 + Vercel HMAC, 사용자 인증은 Vercel이 Supabase로 처리)
- 환경변수 6+1개
- DB는 외부 Supabase `pack_files` (DDL은 `packs-lab/supabase/pack_files.sql`)
- 파일 구조: `main.py`, `auth.py`, `dsm_client.py`, `routes.py`, `models.py`
- API 표 5개:
  - `POST /api/packs/sign-link` (Vercel HMAC → DSM Sharing.create)
  - `POST /api/packs/admin/mint-token` (Vercel HMAC → upload 토큰)
  - `POST /api/packs/upload` (Bearer token → multipart 5GB)
  - `GET /api/packs/list` (Vercel HMAC → 활성 파일 목록)
  - `DELETE /api/packs/{file_id}` (Vercel HMAC → soft delete)

### 7.2 `workspace/CLAUDE.md`

컨테이너 표에 한 줄 추가:

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

---

## 8. 스코프

### 본 spec 범위

- ✅ admin mint-token 라우트 신설
- ✅ Supabase `pack_files` DDL
- ✅ docker-compose / nginx / .env.example / NAS 디렉토리 마운트
- ✅ tests (auth 유지 + routes 통합 + dsm_client mock)
- ✅ CLAUDE.md 2곳 갱신
- ✅ DELETE 라우트 docstring 수정

### 후속 별도 spec

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