From e31bf549a86e47637dcae3f40582a357920a71f0 Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 11 May 2026 03:03:00 +0900 Subject: [PATCH] =?UTF-8?q?docs(spec/plan):=20packs-lab=20spec/plan=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20+=20PACK=5FHOST=5FDIR/=ED=8F=89=EB=A9=B4?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0/SERVICES=20=ED=99=94=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../2026-05-05-packs-lab-infra-integration.md | 977 ++++++++++++++++++ ...5-05-packs-lab-infra-integration-design.md | 465 +++++++++ 2 files changed, 1442 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-05-packs-lab-infra-integration.md create mode 100644 docs/superpowers/specs/2026-05-05-packs-lab-infra-integration-design.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..a8236a5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-packs-lab-infra-integration.md @@ -0,0 +1,977 @@ +# 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://.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) diff --git a/docs/superpowers/specs/2026-05-05-packs-lab-infra-integration-design.md b/docs/superpowers/specs/2026-05-05-packs-lab-infra-integration-design.md new file mode 100644 index 0000000..545c831 --- /dev/null +++ b/docs/superpowers/specs/2026-05-05-packs-lab-infra-integration-design.md @@ -0,0 +1,465 @@ +# 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 + 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` — 신규 환경변수 (6 + 3 path) + +```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 + +# 컨테이너 내부 저장 경로 (routes.py upload target. docker-compose volume 우측) +PACK_BASE_DIR=/app/data/packs + +# DSM·Supabase에 노출되는 NAS 호스트 절대경로. 운영에서 반드시 설정 — 미설정 시 sign-link 시 DSM에 컨테이너 경로 전달돼 파일 못 찾음. +PACK_HOST_DIR=/volume1/docker/webpage/media/packs +``` + +### 5.4 NAS 디렉토리 준비 + +운영 첫 배포 시 SSH로 1회. 파일은 `PACK_HOST_DIR` 평면에 직접 저장 — tier 디렉토리 분기는 만들지 않음(tier 구분은 filename 규칙으로 admin이 관리): + +```bash +mkdir -p /volume1/docker/webpage/media/packs +chown -R PUID:PGID /volume1/docker/webpage/media/packs +``` + +PUID/PGID는 `.env`의 기존 값 사용. + +### 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)