From 80a54d056e3f71ccf67e184dedc457dc24a4accb Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 5 May 2026 19:42:41 +0900 Subject: [PATCH] =?UTF-8?q?docs(plan):=20packs-lab=20=EC=9D=B8=ED=94=84?= =?UTF-8?q?=EB=9D=BC=20=ED=86=B5=ED=95=A9=20+=20admin=20mint-token=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9 task TDD 분할: - Task 1: tests/conftest.py — autouse HMAC secret - Task 2: admin mint-token (스키마 + 라우트 + 통합 테스트 3건) - Task 3: 기존 4 라우트 회귀 테스트 (sign-link/upload/list/delete, 8건) - Task 4: test_dsm_client.py — DSM 7.x mock (4건) - Task 5: routes 모듈 docstring 정리 - Task 6: Supabase pack_files DDL - Task 7: 인프라 통합 (compose 18950 + nginx 5GB streaming + env 7개) - Task 8: CLAUDE.md 5곳 + workspace 1줄 - Task 9: 회귀 검증 + NAS 디렉토리 가이드 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-05-packs-lab-infra-integration.md | 976 ++++++++++++++++++ 1 file changed, 976 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-05-packs-lab-infra-integration.md diff --git a/docs/superpowers/plans/2026-05-05-packs-lab-infra-integration.md b/docs/superpowers/plans/2026-05-05-packs-lab-infra-integration.md new file mode 100644 index 0000000..85a3889 --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-packs-lab-infra-integration.md @@ -0,0 +1,976 @@ +# 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 + +**Files:** +- Modify: `docker-compose.yml` (packs-lab 서비스 추가) +- Modify: `nginx/default.conf` (`/api/packs/` 라우팅) +- Modify: `.env.example` (6+1 환경변수) + +- [ ] **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://.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 ` (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)