Files
web-page-backend/docs/superpowers/specs/2026-05-05-packs-lab-infra-integration-design.md
gahusb 83192eb66c docs(spec): packs-lab 인프라 통합 + admin mint-token 설계
- POST /api/packs/admin/mint-token (Vercel HMAC → 일회성 upload 토큰)
- Supabase pack_files DDL + 활성/삭제 인덱스
- docker-compose 18950 + nginx 5GB streaming + .env.example 6+1 환경변수
- tests: routes 통합 + DSM client mock + autouse HMAC fixture
- CLAUDE.md: web-backend 5곳 + workspace 1곳 갱신
- DELETE 라우트 docstring 정리(자동 만료)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:51:24 +09:00

17 KiB

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/{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 추가)

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 추가)

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에서:

- 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에서 실행)

-- 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.ymlpacks-lab 서비스

  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
결정 근거
포트 18950 18800(realestate) → 18900(agent-office) → 18950(packs) 순차
PACK_BASE_DIR 마운트 컨테이너 경로 /volume1/docker/webpage/media/packs 고정 routes.py 하드코딩 경로
PACK_DATA_PATH env default ./data/packs (로컬), NAS .env/volume1/docker/webpage/media/packs 명시 운영/로컬 분리

5.2 nginx/default.conf/api/packs/ 라우팅

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 — 6+1 신규 환경변수

# ─── 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

5.4 NAS 디렉토리 준비

운영 첫 배포 시 SSH로 1회:

mkdir -p /volume1/docker/webpage/media/packs/{starter,pro,master}
chown -R PUID:PGID /volume1/docker/webpage/media/packs

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


6. 테스트 전략

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

6.1 tests/conftest.py (신규)

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.프로젝트 개요

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