dc92c3d에서 "완료된 spec/plan 제거"로 함께 정리됐던 두 파일을 복구하고,
이후 적용된 운영 변경사항을 반영해 문서-구현 추적성 회복:
- PACK_HOST_DIR 환경변수 도입 (NAS 호스트 절대경로, DSM·Supabase에 노출)
- 평면 저장 구조 (PACK_BASE_DIR/{filename}, tier 디렉토리 분기 제거 — tier는 filename 규칙으로)
- scripts/deploy-nas.sh의 SERVICES 화이트리스트에 packs-lab 추가 (누락 시 NAS 컨테이너 미등장)
- .env.example 환경변수 6+3 path (DSM 3 / HMAC / Supabase 2 / TTL / DATA_PATH / BASE_DIR / HOST_DIR)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
32 KiB
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:
"""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 회귀 검증
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: 커밋
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:
"""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: 실패 확인
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 끝부분에 추가:
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 블록에 다음을 추가:
import time
from datetime import timezone
(이미 import uuid, from datetime import datetime은 있음)
from .auth import 라인을 다음과 같이 확장:
from .auth import mint_upload_token, verify_request_hmac, verify_upload_token
from .models import 라인을 다음과 같이 확장:
from .models import (
MintTokenRequest,
MintTokenResponse,
PackFileItem,
SignLinkRequest,
SignLinkResponse,
UploadResponse,
)
상수 추가 (MAX_BYTES 다음 줄에):
UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default
라우트 추가 (sign_link 함수 다음, upload 함수 앞):
@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: 테스트 통과 확인
cd packs-lab
python -m pytest tests/test_routes.py -v
Expected: 3 passed.
- Step 6: 커밋
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 끝에 추가:
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 테스트 추가
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 테스트 추가
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: 테스트 실행
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: 커밋
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:
"""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: 테스트 실행
cd packs-lab
python -m pytest tests/test_dsm_client.py -v
Expected: 4 passed.
- Step 3: 커밋
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을 다음으로 변경:
"""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: 회귀 검증
cd packs-lab
python -m pytest tests/ -v
Expected: 모든 테스트 그대로 통과 (15 passed).
- Step 3: 커밋
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:
-- 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: 커밋
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) 정의 다음에 추가:
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/... 라우트들 근처)에 추가:
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 끝에 추가:
# ─── 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 검증
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: 커밋
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 추적 대상.
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
cd packs-lab
python -m pytest tests/ -v
Expected: 모든 테스트 통과 (test_auth + test_routes + test_dsm_client = 약 15+ tests).
- Step 2: docker compose config 검증
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에서는 명령만 제시 (실행은 사용자):
# 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으로 마일스톤 표시 — 선택)
# 만약 위 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회):
- Supabase에서
pack_files테이블 생성 (DDL 실행) - NAS SSH로
/volume1/docker/webpage/media/packs/{starter,pro,master}디렉토리 생성 + 권한 - 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)