diff --git a/.gitea/workflows/deploy-production.yml b/.gitea/workflows/deploy-production.yml new file mode 100644 index 0000000..29f6c34 --- /dev/null +++ b/.gitea/workflows/deploy-production.yml @@ -0,0 +1,71 @@ +name: Deploy EG-BIM QA Production + +on: + workflow_dispatch: + +concurrency: + group: qa-production + cancel-in-progress: false + +jobs: + deploy-production: + runs-on: ubuntu-latest + env: + DEPLOY_REF: ${{ gitea.ref }} + DEPLOY_COMMIT_SHA: ${{ gitea.sha }} + CLOUDFLARE_API_TOKEN: ${{ secrets.PROD_CLOUDFLARE_API_TOKEN }} + SESSION_SECRET: ${{ secrets.PROD_SESSION_SECRET }} + ABC_API_KEY: ${{ secrets.PROD_ABC_API_KEY }} + AUTH_CLIENT_ID: ${{ vars.PROD_AUTH_CLIENT_ID }} + ABC_API_BASE_URL: ${{ vars.PROD_ABC_API_BASE_URL }} + ABC_PROJECT_ID: ${{ vars.PROD_ABC_PROJECT_ID }} + ABC_CHANNEL_ID: ${{ vars.PROD_ABC_CHANNEL_ID }} + ORG_CONTEXT_ENABLED: ${{ vars.PROD_ORG_CONTEXT_ENABLED }} + ORG_CONTEXT_API_URL: ${{ vars.PROD_ORG_CONTEXT_API_URL }} + ORG_CONTEXT_SCOPE_MODE: ${{ vars.PROD_ORG_CONTEXT_SCOPE_MODE }} + ORG_CONTEXT_EXPOSURE: ${{ vars.PROD_ORG_CONTEXT_EXPOSURE }} + ORG_CONTEXT_TENANT_IDS: ${{ vars.PROD_ORG_CONTEXT_TENANT_IDS }} + ORG_CONTEXT_CACHE_TTL_SECONDS: ${{ vars.PROD_ORG_CONTEXT_CACHE_TTL_SECONDS }} + ORG_CONTEXT_KEY_ID: ${{ secrets.PROD_ORG_CONTEXT_KEY_ID }} + ORG_CONTEXT_KEY_SECRET: ${{ secrets.PROD_ORG_CONTEXT_KEY_SECRET }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Require main and the selected commit + shell: bash + run: | + set -euo pipefail + test "$DEPLOY_REF" = "refs/heads/main" || { echo "Production must be deployed from main"; exit 1; } + test "$(git rev-parse HEAD)" = "$DEPLOY_COMMIT_SHA" || { echo "Checkout does not match the selected commit"; exit 1; } + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Validate production settings + run: node scripts/deploy-production.mjs --validate-only + + - name: Install dependencies + run: npm ci --no-fund --no-audit + + - name: Check source and tests + run: npm run check && npm test + + - name: Verify production organization API + run: npm run org:verify + + - name: Build production Worker without deploying + run: npm run deploy:production -- --dry-run + + - name: Upload this commit's static files + shell: bash + run: bash scripts/upload-r2.sh baron-qa-write wrangler.production.toml "releases/$DEPLOY_COMMIT_SHA" + + - name: Deploy production Worker and secrets + run: npm run deploy:production + + - name: Verify production HTTP endpoints + run: npm run production:verify diff --git a/README.md b/README.md index b0e2488..670da5e 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,8 @@ Worker는 OAuth Authorization Code + PKCE를 사용하고, callback에서 검증 ## Gitea Actions 등록값 +운영 `qa.baroncs.co.kr` 배포는 [production 파이프라인 안내](docs/qa-production-pipeline.md)를 따른다. 운영 workflow는 `Deploy EG-BIM QA Production`이며 `main`에서 수동으로 실행한다. 아래 기존 등록값은 스테이징 배포용이다. + 저장소 Settings → Actions → Secrets에 아래 기본 Secret 3개를 등록합니다. | 이름 | 종류 | 값 | diff --git a/docs/qa-production-deployment-checklist.md b/docs/qa-production-deployment-checklist.md new file mode 100644 index 0000000..ccca734 --- /dev/null +++ b/docs/qa-production-deployment-checklist.md @@ -0,0 +1,108 @@ +# Q&A 운영 배포 체크리스트 + +대상 도메인: `https://qa.baroncs.co.kr` +정리일: 2026-10-28 + +## 현재 저장소에서 확인한 상태 + +- 스테이징은 `baron-qa-gateway-test`, `qa-test.baroncs.co.kr`, `baron-qa-pages`를 사용한다. +- 운영 Worker `baron-qa-gateway-prod`, custom domain `qa.baroncs.co.kr`, R2 버킷 `baron-qa-write` 생성과 연결을 사용자가 완료했다. +- 운영 전용 `wrangler.production.toml`과 수동 배포 workflow `Deploy EG-BIM QA Production`을 추가했다. +- 운영 등록값과 실행 방법은 [production 파이프라인 안내](qa-production-pipeline.md)를 따른다. 실제 운영 배포는 아직 수행하지 않았다. + +## 내가 준비·확정할 항목 + +### 1. DNS와 Cloudflare + +- [x] `baroncs.co.kr` zone이 운영 Cloudflare 계정에 있는지 확인했다. +- [x] `qa.baroncs.co.kr`을 운영 Worker custom domain으로 연결했다. +- [x] 스테이징과 같은 Cloudflare 계정에 운영 Worker와 버킷을 생성했다. +- [x] 운영 정적 파일용 R2 버킷을 생성했다: `baron-qa-write`. 스테이징 `baron-qa-pages`와 분리해 사용한다. +- [x] 배포용 Cloudflare API token에 계정 범위의 Workers Scripts Read/Write, Workers R2 Storage Read/Write, Account Settings Read 권한을 부여하고 `PROD_CLOUDFLARE_API_TOKEN` Secret에 등록했다. 사용자 등록 완료 보고 기준이며 실제 token 인증은 배포 실행 시 검증한다. 도메인 연결은 대시보드에서 완료했다. [권한 상세](qa-production-pipeline.md) + +### 2. SSO 운영 등록 + +- [x] 운영 callback URI를 SSO에 등록했다: `https://qa.baroncs.co.kr/auth/callback`. 사용자 완료 보고 기준이다. +- [x] 로그아웃 후 이동 URI를 SSO에 등록했다: `https://qa.baroncs.co.kr/`. 사용자 완료 보고 기준이다. +- [x] 운영 SSO 설정에 맞는 `PROD_AUTH_CLIENT_ID` 등 관련 변수 등록을 완료했다. 실제 로그인 동작 확인은 운영 배포 후 수행한다. +- [ ] 운영 도메인에서 실제 사내 사용자로 로그인·로그아웃할 수 있는지 확인한다. + +### 3. ABC UserFeedback 운영 대상 + +- [ ] **보류 — 관리페이지 서버 확정 후 진행:** 운영 API 주소를 확정하고 `PROD_ABC_API_BASE_URL`에 등록한다. +- [ ] **보류 — 관리페이지 서버 확정 후 진행:** 운영 project ID와 channel ID를 확정하고 `PROD_ABC_PROJECT_ID`, `PROD_ABC_CHANNEL_ID`에 등록한다. 현재 저장소의 ID를 운영에서 재사용해도 되는지 확인한다. +- [ ] **보류 — 관리페이지 서버 확정 후 진행:** 운영 project/channel에 문의 생성, 첨부, 댓글 조회·등록·수정·삭제 권한이 있는 API key를 준비하고 Gitea Secret `PROD_ABC_API_KEY`에 등록한다. 이 키가 등록될 때까지 첫 운영 배포는 진행하지 않는다. +- [ ] `requester_affiliation`, `requester_department` 필드가 운영 channel에서 허용되는지 확인한다. +- [ ] 운영 문의를 스테이징 데이터와 분리할지 결정한다. 기존 데이터 이전이 필요하면 별도 이관 범위와 실행 계획을 정한다. +- [ ] 운영 source namespace와 idempotency consumer를 스테이징과 구분할 값을 정한다. + +### 4. 조직 컨텍스트 API + +- [ ] 운영에서 조회할 기준 tenant UUID를 확정한다. 여러 개면 JSON 배열로 제공한다. +- [ ] 운영용 `ORG_CONTEXT_KEY_ID`와 `ORG_CONTEXT_KEY_SECRET`을 준비한다. 스테이징 키 재사용이 허용되는지 API 소유자에게 확인한다. +- [ ] 운영 키가 해당 기준 tenant에 대해 `SCOPED` / `identifiers` 조회 권한을 갖는지 확인한다. +- [ ] 운영 설정으로 org API 사전 검증을 통과시킨다. 키와 조직 원문이 빌드 로그에 출력되지 않는지 확인한다. + +### 5. 운영 배포 값 등록 + +스테이징에 등록한 `STAGING_*` 값은 운영 배포에 자동 적용되지 않는다. [production 파이프라인 안내의 확정된 `PROD_*` 등록 목록](qa-production-pipeline.md)을 사용해 Gitea **Actions Variables / Secrets**에 등록한다. 아래는 운영 대상 값의 요약이다. + +- [x] 사용자 보고 기준, 관리페이지 연동 `PROD_ABC_*` 4개를 제외한 운영 Secrets와 Variables 등록을 완료했다. 실제 연결·권한은 배포 전에 검증한다. + +**운영 설정** + +| 설정 | 운영 값 | +|---|---| +| Worker 이름 | `baron-qa-gateway-prod` | +| Worker custom domain | `qa.baroncs.co.kr` | +| AUTH redirect URI | `https://qa.baroncs.co.kr/auth/callback` | +| AUTH post logout URI | `https://qa.baroncs.co.kr/` | +| AUTH client ID | 운영 SSO client ID | +| ABC API base URL | `https://feedback.hmac.kr` 또는 운영 API 주소 | +| ABC project ID / channel ID | 운영 대상 ID | +| ABC source namespace / idempotency consumer | 스테이징과 구별되는 운영 값 | +| R2 bucket | `baron-qa-write` | +| ORG_CONTEXT_ENABLED | `true` | +| ORG_CONTEXT_API_URL | `https://sadmin.hmac.kr/api/v1/integrations/org-context` | +| ORG_CONTEXT_SCOPE_MODE / EXPOSURE | `SCOPED` / `identifiers` | +| ORG_CONTEXT_TENANT_IDS | 운영 기준 tenant UUID JSON 배열 | +| ORG_CONTEXT_CACHE_TTL_SECONDS | `300` 권장 | + +**운영 Secrets 용도** + +| Secret | 용도 | +|---|---| +| Cloudflare API token | 운영 Worker와 정적 파일 배포 | +| `SESSION_SECRET` | 운영 세션 서명용. 스테이징과 별도 값 | +| `ABC_API_KEY` | 운영 ABC project/channel API 접근 | +| `ORG_CONTEXT_KEY_ID` | 조직 컨텍스트 연동 Key ID | +| `ORG_CONTEXT_KEY_SECRET` | 조직 컨텍스트 연동 Key Secret | + +Secret 원문은 소스, Markdown, 채팅, workflow 로그에 넣지 않는다. + +## 개발 측에서 필요한 변경 + +- [x] 스테이징 설정을 보존하면서 production 전용 Wrangler 설정을 추가했다. +- [x] Worker 이름, domain, OAuth URL, R2 bucket, ABC 대상, 조직 API 값을 환경별로 분리했다. +- [x] 별도 운영 배포 스크립트를 추가하고, 운영 배포 전에 `npm run org:verify`를 수행하도록 연결했다. +- [x] production 수동 배포 workflow에 `PROD_*` 매핑과 필수값 검증을 추가했다. +- [x] 운영 정적 파일을 운영 버킷의 커밋별 release 경로에 업로드하도록 연결했다. +- [ ] 배포 실행 전에 변경 범위와 대상 도메인을 검토하고, staging 배포와 product 배포가 섞이지 않도록 확인한다. + +## 운영 배포 후 확인 + +- [ ] `https://qa.baroncs.co.kr`의 TLS 인증서와 정적 페이지 응답을 확인한다. +- [ ] SSO 로그인·로그아웃 및 세션 쿠키 동작을 확인한다. +- [ ] 회사·부서가 조직 API 기준 정보와 일치하는지 확인한다. +- [ ] 텍스트 문의와 이미지 첨부 문의를 각각 1건 등록하고, 운영 ABC 화면의 requester 정보·회사·부서 값을 확인한다. +- [ ] 목록, 상세, 댓글 조회·등록을 확인하고 댓글 수정·삭제 권한을 작성자 기준으로 확인한다. +- [ ] 브라우저 Network 탭과 Cloudflare Worker logs에 API key, 연동 secret, 사용자 전화번호 원문이 노출되지 않는지 확인한다. +- [ ] 오류 시 이전 Worker 버전으로 되돌리는 방법과 담당자를 기록한다. + +## 진행 순서 + +1. 사용자가 DNS/SSO/ABC/Cloudflare 운영 값과 데이터 분리 방침을 확정한다. +2. 개발 측에서 production 설정·workflow를 분리하고, 운영 변수·secret 이름을 확정해 공유한다. +3. 사용자가 운영용 Gitea Variables/Secrets를 등록한다. +4. production 조직 API 사전 검증과 배포 전 점검을 통과시킨다. +5. production 배포 승인 후 `qa.baroncs.co.kr`에서 위 운영 확인 항목을 완료한다. diff --git a/docs/qa-production-pipeline.md b/docs/qa-production-pipeline.md new file mode 100644 index 0000000..a240470 --- /dev/null +++ b/docs/qa-production-pipeline.md @@ -0,0 +1,117 @@ +# Q&A production 배포 파이프라인 + +## 배포 대상과 실행 방식 + +| 항목 | 운영 값 | +|---|---| +| Workflow | `.gitea/workflows/deploy-production.yml` | +| Actions 표시 이름 | `Deploy EG-BIM QA Production` | +| 실행 | Gitea Actions에서 `main`을 선택해 수동 실행 | +| Worker | `baron-qa-gateway-prod` | +| Custom Domain | `https://qa.baroncs.co.kr` | +| Cloudflare account ID | `81fa2d48964d31dd0da9558f9ce601d1` | +| 정적 파일 R2 bucket | `baron-qa-write` | +| R2 binding 이름 | `QA_BUCKET` | +| 정적 파일 저장 경로 | `releases/<전체 커밋 SHA>/egbim/...`, `releases/<전체 커밋 SHA>/assets/...` | + +운영 Worker와 도메인 연결, 버킷 생성은 사용자가 Cloudflare 대시보드에서 완료했다. 실제 Q&A 코드 배포는 이 workflow로 수행한다. `main` push는 기존 스테이징 workflow를 실행하며, 운영 workflow는 수동 실행만 지원한다. 운영 실행도 `main`의 선택된 커밋과 checkout 결과가 같아야 진행한다. + +배포 파일은 `wrangler.production.toml`을 사용한다. 운영 세션 쿠키·source namespace는 별도로 지정했고 `workers.dev`와 preview URL은 비활성화한다. 운영 접속 주소는 `qa.baroncs.co.kr`이다. 이미 연결한 custom domain은 Cloudflare 대시보드에서 관리한다. Wrangler 설정에는 `route`와 `routes`를 지정하지 않아 배포 시 기존 도메인 연결을 유지하며 zone 라우팅을 변경하지 않는다. + +## Gitea에 등록할 Secrets + +저장소 **Settings → Actions → Secrets**에 등록한다. 기존 기본 Secret이나 `STAGING_*` 값을 자동으로 가져오지 않는다. 기존 키가 운영 리소스에도 권한이 있다면 값은 재사용할 수 있지만, 아래 운영 설정 이름으로 등록해야 한다. `SESSION_SECRET`은 운영 전용 값을 사용한다. + +| 이름 | 값 | +|---|---| +| `PROD_CLOUDFLARE_API_TOKEN` | 기존 운영 Worker를 배포하고 R2 파일을 업로드할 수 있는 Cloudflare token | +| `PROD_SESSION_SECRET` | 최소 32자 운영 세션 서명키. 예: `openssl rand -hex 32`로 생성한 값 | +| `PROD_ABC_API_KEY` | **등록 보류:** 관리페이지 서버 확정 후 운영 ABC project/channel API key 등록 | +| `PROD_ORG_CONTEXT_KEY_ID` | 운영 조직 컨텍스트 연동 Key ID | +| `PROD_ORG_CONTEXT_KEY_SECRET` | 운영 조직 컨텍스트 연동 Key Secret | + +Cloudflare 프로필 **API Tokens → Create Token → Create Custom Token**에서 운영 배포 token을 생성하거나 기존 token의 권한을 확인한다. + +| 범위 | 권한 | 수준 | 용도 | +|---|---|---|---| +| Account / Workers product | Workers | Editor | 기존 Worker 코드·Secret·binding 배포. 레거시 Workers Scripts Edit도 허용 | +| Account | Workers R2 Storage | Edit / Write | 정적 파일을 R2에 업로드 | +| Account | Account Settings | Read | Wrangler 계정 정보 조회 | + +**Account Resources**는 `바른기술개발센터` 계정으로 지정한다. 생성한 token 값은 `PROD_CLOUDFLARE_API_TOKEN` Secret에 넣는다. 도메인 연결은 대시보드에서 이미 완료했으므로 운영 배포 token에는 Workers Routes Write와 Zone Read를 요구하지 않는다. 현재 파이프라인은 Wrangler의 Cloudflare REST API를 사용하므로 R2 S3 Access Key ID / Secret Access Key는 필요하지 않다. + +R2 권한은 계정 범위의 **Workers R2 Storage Write/Edit**를 선택한다. `R2 → baron-qa-write` 범위의 **Workers R2 Storage Bucket Item Read/Write**는 S3 객체 접근용이므로 현재 Wrangler REST 업로드에 필요한 권한을 대신하지 않는다. 운영 배포에는 Zone Write 정책도 필요하지 않다. + +근거: [Workers 권한](https://developers.cloudflare.com/workers/authorization/workers/), [R2 token 권한](https://developers.cloudflare.com/r2/api/tokens/), [대시보드에서 route를 관리하는 Wrangler 설정](https://developers.cloudflare.com/workers/wrangler/configuration/#source-of-truth). + +## Gitea에 등록할 Variables + +저장소 **Settings → Actions → Variables**에 등록한다. + +| 이름 | 필수 | 값 | +|---|---|---| +| `PROD_AUTH_CLIENT_ID` | 필수 | 운영 callback URI가 허용된 SSO client ID | +| `PROD_ABC_API_BASE_URL` | 필수 / 등록 보류 | 관리페이지 서버 확정 후 운영 ABC HTTPS origin 등록. `/api` 경로를 붙이지 않는다. | +| `PROD_ABC_PROJECT_ID` | 필수 / 등록 보류 | 관리페이지 서버 확정 후 운영 ABC project UUID 등록 | +| `PROD_ABC_CHANNEL_ID` | 필수 / 등록 보류 | 관리페이지 서버 확정 후 운영 ABC channel UUID 등록 | +| `PROD_ORG_CONTEXT_ENABLED` | 필수 | `true` | +| `PROD_ORG_CONTEXT_TENANT_IDS` | 필수 | 기준 tenant UUID JSON 배열. 예: `["tenant-UUID"]` | +| `PROD_ORG_CONTEXT_API_URL` | 선택 | 기본 `https://sadmin.hmac.kr/api/v1/integrations/org-context` | +| `PROD_ORG_CONTEXT_SCOPE_MODE` | 선택 | 기본 `SCOPED` | +| `PROD_ORG_CONTEXT_EXPOSURE` | 선택 | 기본 `identifiers` | +| `PROD_ORG_CONTEXT_CACHE_TTL_SECONDS` | 선택 | 기본 `300`, 허용 범위 1~3600 | + +SSO에는 `https://qa.baroncs.co.kr/auth/callback`을 허용하고, 로그아웃 후 이동 URI로 `https://qa.baroncs.co.kr/`을 확인한다. 등록된 SSO client나 ABC project/channel이 운영 대상인지 확정한 후 배포한다. + +지금 준비할 Variables는 `PROD_AUTH_CLIENT_ID`와 `PROD_ORG_CONTEXT_*` 6개다. `PROD_AUTH_CLIENT_ID`의 참고값은 기존 스테이징 client `e16898a7-acd2-420a-a224-cdcd89ff66cd`이며, 이 client에 운영 callback이 허용된 경우에만 운영에서도 재사용한다. 별도 운영 client를 만들었다면 그 ID를 등록한다. 기준 tenant UUID와 조직 연동 키는 실제 발급받은 값을 사용한다. 기존 `STAGING_*` 등록은 `PROD_*` 등록으로 간주하지 않는다. + +회사·부서 조회용 sadmin 조직 컨텍스트 API 설정은 ABC 관리페이지 연동 설정과 별개다. 조직 API 주소·기준 tenant·키의 운영 사용이 확정된 경우 미리 등록할 수 있다. Secret 값이나 테스트 fixture UUID를 실제 설정값으로 문서에 기록하지 않는다. + +Worker runtime에는 `PROD_`를 제거한 이름으로 주입한다. `DEPLOY_COMMIT_SHA`는 workflow가 선택된 커밋에서 자동으로 전달하므로 Gitea에 따로 등록하지 않는다. + +## Workflow 순서 + +1. 소스를 checkout하고 `main` 브랜치·커밋 일치를 확인한다. +2. 운영 필수 설정, UUID, 조직 API 설정과 세션 서명키 길이를 검사한다. 누락 시 배포 전에 중단한다. +3. `npm ci`로 고정된 의존성을 설치한다. +4. `npm run check`와 `npm test`를 실행한다. +5. `npm run org:verify`로 실제 운영 조직 API의 인증·범위·구성원 응답을 검증한다. +6. `npm run deploy:production -- --dry-run`으로 Worker 번들을 만든다. 이 단계는 Cloudflare 리소스를 변경하지 않는다. +7. 해당 커밋의 정적 파일을 `baron-qa-write/releases//...`에 올린다. +8. `npm run deploy:production`으로 코드·runtime 변수·Secret을 같은 Worker 배포에 반영한다. +9. `npm run production:verify`로 익명 세션, CSS 응답·커밋 일치, 비로그인 글쓰기 redirect를 확인한다. 전파 지연에 대비해 최대 5회 확인한다. + +Secret 파일은 임시 디렉터리에 접근 권한 `0600`으로 만들고 배포 명령 종료 후 삭제한다. Secret 값은 CLI 인자나 로그로 출력하지 않는다. + +## 첫 운영 배포 실행 + +현재 `PROD_ABC_API_KEY`, `PROD_ABC_API_BASE_URL`, `PROD_ABC_PROJECT_ID`, `PROD_ABC_CHANNEL_ID` 등록은 관리페이지 서버 확정까지 보류한다. 이 값들이 없으면 필수 설정 검증에서 배포를 중단한다. SSO·조직 컨텍스트·Cloudflare·세션 서명키 설정은 먼저 준비할 수 있다. + +- [x] 관리페이지 연동 `PROD_ABC_*` 4개를 제외한 운영 Secrets와 Variables 등록을 완료했다. 사용자 보고 기준이며 실제 값·권한 검증은 아직 수행하지 않았다. +- [ ] 관리페이지 서버 확정 후 `PROD_ABC_API_KEY`, `PROD_ABC_API_BASE_URL`, `PROD_ABC_PROJECT_ID`, `PROD_ABC_CHANNEL_ID`를 등록한다. +- [x] 운영 SSO callback과 관련 변수 등록을 완료했다. 사용자 완료 보고 기준이다. +- [ ] ABC project/channel과 조직 API의 실제 접근 권한을 확인한다. +- [ ] 이 변경을 `main`에 반영한다. +- [ ] Gitea **Actions → Deploy EG-BIM QA Production → Run workflow**에서 `main`을 선택한다. +- [ ] 설정 검증·조직 API 검증·정적 파일 업로드·Worker 배포·HTTP 확인이 모두 성공하는지 확인한다. +- [ ] 실제 계정으로 로그인하고 문의·첨부·댓글 기능과 저장된 회사·부서를 확인한다. 자동 HTTP 확인은 로그인 이후의 실제 사용자 작업까지 검증하지 않는다. + +## 실패와 롤백 + +- 설정·조직 API·빌드 검증 실패는 운영 정적 파일 업로드와 Worker 배포 전에 중단된다. +- 정적 파일 업로드가 실패해도 기존 Worker는 이전 커밋 경로를 계속 읽는다. 다시 실행하면 같은 커밋 경로로 업로드한다. +- Worker 배포 후 HTTP 확인이 실패하면 workflow는 실패로 표시되지만 이미 반영한 배포를 자동으로 되돌리지는 않는다. 마지막 정상 Worker 버전으로 명시적으로 롤백한다. +- Cloudflare의 `baron-qa-gateway-prod` **Deployments**에서 마지막 정상 버전으로 rollback하면 해당 버전의 `QA_ASSET_PREFIX`와 정적 파일을 다시 사용한다. 이전 release 경로는 보관한다. +- 첫 배포의 이전 버전은 기본 Hello World일 수 있다. 첫 배포 실패 시에는 설정을 수정해 다시 실행하고, 필요하면 기본 버전으로 되돌릴 수 있다. +- R2 release 파일 정리 시 활성 버전과 롤백에 필요한 버전의 prefix를 삭제하지 않는다. 정적 파일 롤백은 ABC에 저장된 문의·댓글 데이터를 되돌리지 않는다. + +## 로컬 확인 + +```sh +npm run check +npm test +node scripts/deploy-production.mjs --validate-only +npm run deploy:production -- --dry-run +``` + +마지막 두 명령은 위 runtime 이름의 운영 설정과 `DEPLOY_COMMIT_SHA`가 실행 환경에 필요하다. `--validate-only`는 설정 형식만 확인하고, `--dry-run`은 로컬 번들만 확인한다. 실제 자격증명과 API의 유효성은 Gitea workflow의 조직 API 검증과 실제 배포에서 확인한다. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cd34dee --- /dev/null +++ b/package-lock.json @@ -0,0 +1,756 @@ +{ + "name": "baron-qa-gateway-test", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "baron-qa-gateway-test", + "devDependencies": { + "wrangler": "4.134.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260917.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260917.1.tgz", + "integrity": "sha512-t981nh4Ol5mjzkH6x7x7Oev/UVmF5n3zjm07btl58BxJ1IgUSUPW0XJz07DR7rWss/EmGey7UlRMUM0qRcWkvg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/android-x64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-x64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/linux-ia32": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/linux-loong64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/linux-s390x": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/netbsd-arm64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/openbsd-arm64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/openharmony-arm64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/sunos-x64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/win32-arm64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/win32-ia32": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/win32-x64": { + "dev": true, + "optional": true + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "5.20260917.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260917.0-alpha.tgz", + "integrity": "sha512-NpZyBR+h/nonVA/YO/+1/UJpIl8lTwCgpFPVp/KVzAhb8JuCRImPDfyrft040659Zna1RS3Lt2pzTs5Vwy5QSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.4", + "undici": "7.29.0", + "workerd": "1.20260917.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/sharp/node_modules/@img/sharp-darwin-arm64": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-darwin-x64": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-freebsd-wasm32": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-darwin-arm64": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-darwin-x64": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linux-arm": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linux-arm64": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linux-ppc64": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linux-riscv64": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linux-s390x": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-linux-arm": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-linux-arm64": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-linux-ppc64": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-linux-riscv64": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-linux-s390x": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-linuxmusl-arm64": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-linuxmusl-x64": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-webcontainers-wasm32": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-win32-arm64": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-win32-ia32": { + "dev": true, + "optional": true + }, + "node_modules/sharp/node_modules/@img/sharp-win32-x64": { + "dev": true, + "optional": true + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260917.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260917.1.tgz", + "integrity": "sha512-k072RxsZfRz2cnyAq1Titt+0VpNfnFizSXUkT3r15CDJECBfBXj6JeKK0Bk5CrBLAHGP+dvOHjI//TP7GHXDEw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260917.1", + "@cloudflare/workerd-darwin-arm64": "1.20260917.1", + "@cloudflare/workerd-linux-64": "1.20260917.1", + "@cloudflare/workerd-linux-arm64": "1.20260917.1", + "@cloudflare/workerd-windows-64": "1.20260917.1" + } + }, + "node_modules/workerd/node_modules/@cloudflare/workerd-darwin-64": { + "dev": true, + "optional": true + }, + "node_modules/workerd/node_modules/@cloudflare/workerd-darwin-arm64": { + "dev": true, + "optional": true + }, + "node_modules/workerd/node_modules/@cloudflare/workerd-linux-arm64": { + "dev": true, + "optional": true + }, + "node_modules/workerd/node_modules/@cloudflare/workerd-windows-64": { + "dev": true, + "optional": true + }, + "node_modules/wrangler": { + "version": "4.134.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.134.0.tgz", + "integrity": "sha512-65JbYVGSt0XpPH8O/y6pNuS0t+AvvwVv+VRPbqYsNI5NMF9oFiJxVoS5GYU47ooamF9ucSXWvGyQ6QnAtXLgRA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260917.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260917.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260917.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/fsevents": { + "dev": true, + "optional": true + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/package.json b/package.json index 9b47fd4..ae01c06 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,12 @@ "type": "module", "scripts": { "deploy": "wrangler deploy", + "deploy:production": "node scripts/deploy-production.mjs", "r2:upload": "bash scripts/upload-r2.sh", - "check": "node --check src/index.js && node --check src/org-context.js && node --check egbim/app.js", + "check": "node --check src/index.js && node --check src/org-context.js && node --check egbim/app.js && node --check scripts/production-settings.mjs && node --check scripts/deploy-production.mjs && node --check scripts/verify-production.mjs && bash -n scripts/upload-r2.sh", "test": "node --test --test-isolation=none tests/*.test.js", - "org:verify": "node scripts/verify-org-context.mjs" + "org:verify": "node scripts/verify-org-context.mjs", + "production:verify": "node scripts/verify-production.mjs" }, "devDependencies": { "wrangler": "4.134.0" diff --git a/scripts/deploy-production.mjs b/scripts/deploy-production.mjs new file mode 100644 index 0000000..d5d15e2 --- /dev/null +++ b/scripts/deploy-production.mjs @@ -0,0 +1,40 @@ +import { spawn } from 'node:child_process'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { productionSettings } from './production-settings.mjs'; + +function execute(args) { + return new Promise((resolve, reject) => { + const child = spawn('npx', ['--no-install', 'wrangler', ...args], { stdio: 'inherit' }); + child.once('error', () => reject(new Error('Production Worker command could not start'))); + child.once('exit', (code) => code === 0 ? resolve() : reject(new Error('Production Worker deployment failed'))); + }); +} + +let secretDirectory; +try { + const options = process.argv.slice(2); + if (options.some((option) => !['--dry-run', '--validate-only'].includes(option))) throw new Error('Unknown production deployment option'); + const settings = productionSettings(process.env); + console.log(JSON.stringify({ worker: settings.worker, bucket: settings.bucket, origin: settings.origin, assets: settings.vars.QA_ASSET_PREFIX })); + if (!options.includes('--validate-only')) { + const args = ['deploy', '--config', settings.config, '--name', settings.worker]; + for (const [name, value] of Object.entries(settings.vars)) args.push('--var', name + ':' + value); + if (options.includes('--dry-run')) { + args.push('--dry-run'); + } else { + // Deploy code and secrets together; keep secret values out of arguments and logs. + secretDirectory = await mkdtemp(join(tmpdir(), 'qa-production-secrets-')); + const secretPath = join(secretDirectory, 'secrets.json'); + await writeFile(secretPath, JSON.stringify(settings.secrets), { mode: 0o600 }); + args.push('--secrets-file', secretPath); + } + await execute(args); + } +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} finally { + if (secretDirectory) await rm(secretDirectory, { recursive: true, force: true }); +} diff --git a/scripts/production-settings.mjs b/scripts/production-settings.mjs new file mode 100644 index 0000000..5f1a2d9 --- /dev/null +++ b/scripts/production-settings.mjs @@ -0,0 +1,55 @@ +import { orgContextConfig } from '../src/org-context.js'; +import { deploymentOrgSettings } from './org-context-settings.mjs'; + +export const productionTarget = Object.freeze({ + worker: 'baron-qa-gateway-prod', + config: 'wrangler.production.toml', + bucket: 'baron-qa-write', + origin: 'https://qa.baroncs.co.kr' +}); + +function required(env, name) { + const value = String(env[name] || '').trim(); + if (!value) throw new Error(name + ' is required for production'); + return value; +} + +function httpsBase(value, name) { + let url; + try { url = new URL(value); } catch { throw new Error(name + ' must be an HTTPS origin'); } + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/') { + throw new Error(name + ' must be an HTTPS origin'); + } + return url.origin; +} + +export function productionSettings(env) { + // These names are mapped exclusively from PROD_* settings in the production workflow. + required(env, 'CLOUDFLARE_API_TOKEN'); + const secrets = { + SESSION_SECRET: required(env, 'SESSION_SECRET'), + ABC_API_KEY: required(env, 'ABC_API_KEY'), + ORG_CONTEXT_KEY_ID: required(env, 'ORG_CONTEXT_KEY_ID'), + ORG_CONTEXT_KEY_SECRET: required(env, 'ORG_CONTEXT_KEY_SECRET') + }; + if (secrets.SESSION_SECRET.length < 32) throw new Error('SESSION_SECRET must contain at least 32 characters'); + if (required(env, 'ORG_CONTEXT_ENABLED') !== 'true') throw new Error('ORG_CONTEXT_ENABLED must be true for production'); + const organization = deploymentOrgSettings(env); + orgContextConfig({ ...organization.vars, ...secrets }); + + const vars = { + ...organization.vars, + AUTH_CLIENT_ID: required(env, 'AUTH_CLIENT_ID'), + ABC_API_BASE_URL: httpsBase(required(env, 'ABC_API_BASE_URL'), 'ABC_API_BASE_URL'), + ABC_PROJECT_ID: required(env, 'ABC_PROJECT_ID'), + ABC_CHANNEL_ID: required(env, 'ABC_CHANNEL_ID') + }; + const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + for (const name of ['ABC_PROJECT_ID', 'ABC_CHANNEL_ID']) { + if (!uuid.test(vars[name])) throw new Error(name + ' must be a UUID'); + } + const revision = required(env, 'DEPLOY_COMMIT_SHA'); + if (!/^[0-9a-f]{40}$/.test(revision)) throw new Error('DEPLOY_COMMIT_SHA must be a full lowercase Git commit SHA'); + vars.QA_ASSET_PREFIX = 'releases/' + revision; + return { ...productionTarget, vars, secrets }; +} diff --git a/scripts/upload-r2.sh b/scripts/upload-r2.sh index 2f08c08..47c8316 100755 --- a/scripts/upload-r2.sh +++ b/scripts/upload-r2.sh @@ -2,12 +2,21 @@ set -euo pipefail bucket="${1:-baron-qa-pages}" +config="${2:-wrangler.toml}" +prefix="${3:-}" + +if [ "$bucket" = "baron-qa-write" ]; then + if [ "$config" != "wrangler.production.toml" ] || [[ ! "$prefix" =~ ^releases/[0-9a-f]{40}$ ]]; then + echo "Production uploads require the production config and a full commit release prefix" >&2 + exit 1 + fi +fi upload() { local file="$1" - local key="$2" + local key="${prefix:+$prefix/}$2" local content_type="$3" - npx wrangler r2 object put "$bucket/$key" --file "$file" --content-type "$content_type" --remote + npx --no-install wrangler r2 object put "$bucket/$key" --config "$config" --file "$file" --content-type "$content_type" --remote } upload egbim/index.html egbim/index.html text/html diff --git a/scripts/verify-production.mjs b/scripts/verify-production.mjs new file mode 100644 index 0000000..b6896d6 --- /dev/null +++ b/scripts/verify-production.mjs @@ -0,0 +1,39 @@ +import { productionTarget } from './production-settings.mjs'; +import { pathToFileURL } from 'node:url'; +import { setTimeout as wait } from 'node:timers/promises'; + +export async function verifyProduction(fetchImpl = fetch, revision = process.env.DEPLOY_COMMIT_SHA) { + const origin = productionTarget.origin; + const request = (path) => fetchImpl(origin + path, { redirect: 'manual', signal: AbortSignal.timeout(15000), headers: { 'cache-control': 'no-cache' } }); + const session = await request('/auth/session'); + if (!session.ok) throw new Error('Production session endpoint failed'); + const body = await session.json(); + if (body.authenticated !== false || body.user !== null) throw new Error('Production session endpoint returned an unexpected anonymous session'); + const asset = await request('/assets/styles.css'); + if (!asset.ok || !String(asset.headers.get('content-type')).includes('text/css') || !(await asset.text()).trim()) { + throw new Error('Production stylesheet is unavailable'); + } + if (revision && asset.headers.get('x-qa-release') !== 'releases/' + revision) { + throw new Error('Production stylesheet does not match the deployed commit'); + } + const protectedPage = await request('/egbim/write.html'); + const login = new URL(protectedPage.headers.get('location') || '/', origin); + if (protectedPage.status !== 302 || login.origin !== origin || login.pathname !== '/auth/login') { + throw new Error('Production write page must redirect anonymous visitors to login'); + } + console.log('Production HTTP checks passed: session, stylesheet, protected write page.'); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + for (let attempt = 1; attempt <= 5; attempt++) { + try { await verifyProduction(); break; } catch (error) { + if (attempt === 5) { + console.error(error.message); + process.exitCode = 1; + } else { + console.log('Waiting for production deployment propagation (' + attempt + '/5).'); + await wait(5000); + } + } + } +} diff --git a/src/index.js b/src/index.js index 1cab6bf..1a23462 100644 --- a/src/index.js +++ b/src/index.js @@ -42,7 +42,8 @@ export default { return Response.redirect(new URL(url.pathname + '/', url), 301); } - const objectKey = objectKeyForPath(url.pathname); + const assetPrefix = String(env.QA_ASSET_PREFIX || '').replace(/^\/+|\/+$/g, ''); + const objectKey = (assetPrefix ? assetPrefix + '/' : '') + objectKeyForPath(url.pathname); let object = await env.QA_BUCKET.get(objectKey); if (!object && objectKey.endsWith('/index.html')) { object = await env.QA_BUCKET.get(objectKey.replace(/\/index\.html$/, 'index.html')); @@ -54,6 +55,7 @@ export default { headers.set('etag', object.httpEtag); headers.set('x-content-type-options', 'nosniff'); headers.set('referrer-policy', 'strict-origin-when-cross-origin'); + if (assetPrefix) headers.set('x-qa-release', assetPrefix); headers.set('cache-control', url.pathname.endsWith('.html') || url.pathname.endsWith('.js') ? 'no-cache' : 'public, max-age=3600'); return new Response(request.method === 'HEAD' ? null : object.body, { headers }); } catch (error) { diff --git a/tests/production-deployment.test.js b/tests/production-deployment.test.js new file mode 100644 index 0000000..b8c9a97 --- /dev/null +++ b/tests/production-deployment.test.js @@ -0,0 +1,148 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { productionSettings } from '../scripts/production-settings.mjs'; +import { verifyProduction } from '../scripts/verify-production.mjs'; +import worker from '../src/index.js'; + +const SHA = 'a'.repeat(40); +const fixture = () => ({ + CLOUDFLARE_API_TOKEN: 'fixture-cloudflare-token', SESSION_SECRET: 'fixture-session-secret-'.repeat(3), + ABC_API_KEY: 'fixture-abc-key', AUTH_CLIENT_ID: 'production-client', ABC_API_BASE_URL: 'https://feedback.example.test/', + ABC_PROJECT_ID: '01900000-0000-7000-8000-000000000001', ABC_CHANNEL_ID: '01900000-0000-7000-8000-000000000002', + ORG_CONTEXT_ENABLED: 'true', ORG_CONTEXT_TENANT_IDS: '["038326b6-954a-48a7-a85f-efd83f62b82a"]', + ORG_CONTEXT_KEY_ID: 'fixture-org-key', ORG_CONTEXT_KEY_SECRET: 'fixture-org-secret', DEPLOY_COMMIT_SHA: SHA +}); + +test('production preserves dashboard-managed domains without requesting zone routing changes', () => { + const config = readFileSync(new URL('../wrangler.production.toml', import.meta.url), 'utf8'); + assert.doesNotMatch(config, /^\s*routes?\s*=/m); + assert.match(config, /^workers_dev\s*=\s*false\s*$/m); +}); + +test('production requires its own credentials, target IDs, organization lookup and immutable release', () => { + for (const name of Object.keys(fixture())) { + const env = fixture(); + delete env[name]; + assert.throws(() => productionSettings(env), new RegExp(name === 'ORG_CONTEXT_TENANT_IDS' ? 'org_context_invalid_tenants' : name)); + } + assert.throws(() => productionSettings({ ...fixture(), ORG_CONTEXT_ENABLED: 'false' }), /must be true/); + assert.throws(() => productionSettings({ ...fixture(), SESSION_SECRET: 'short' }), /at least 32/); + assert.throws(() => productionSettings({ ...fixture(), ABC_CHANNEL_ID: '123' }), /UUID/); + assert.throws(() => productionSettings({ ...fixture(), DEPLOY_COMMIT_SHA: '../staging' }), /full lowercase/); + assert.throws(() => productionSettings({ ...fixture(), ABC_API_BASE_URL: 'https://secret:password@example.test' }), /HTTPS origin/); + const settings = productionSettings(fixture()); + assert.equal(settings.worker, 'baron-qa-gateway-prod'); + assert.equal(settings.bucket, 'baron-qa-write'); + assert.equal(settings.vars.ABC_API_BASE_URL, 'https://feedback.example.test'); + assert.equal(settings.vars.QA_ASSET_PREFIX, 'releases/' + SHA); + for (const name of Object.keys(settings.secrets)) assert.equal(settings.vars[name], undefined); + assert.equal(settings.secrets.CLOUDFLARE_API_TOKEN, undefined); +}); + +function withFakeWrangler(body) { + const dir = mkdtempSync(join(tmpdir(), 'qa-deploy-test-')); + const capture = join(dir, 'commands.jsonl'); + writeFileSync(join(dir, 'npx'), `#!/usr/bin/env node +const fs = require('node:fs'); +const args = process.argv.slice(2); +const index = args.indexOf('--secrets-file'); +const path = index === -1 ? null : args[index + 1]; +fs.appendFileSync(process.env.QA_TEST_CAPTURE, JSON.stringify({ args, path, + secrets: path ? JSON.parse(fs.readFileSync(path)) : null, + mode: path ? fs.statSync(path).mode & 0o777 : null }) + '\\n'); +process.exit(Number(process.env.QA_TEST_EXIT || 0)); +`, { mode: 0o700 }); + const env = { ...fixture(), PATH: dir + ':' + process.env.PATH, QA_TEST_CAPTURE: capture }; + try { body({ dir, env, capture }); } finally { rmSync(dir, { recursive: true, force: true }); } +} + +test('production validation and dry run never register secrets or deploy remotely', () => withFakeWrangler(({ env, capture }) => { + const script = fileURLToPath(new URL('../scripts/deploy-production.mjs', import.meta.url)); + const validation = spawnSync(process.execPath, [script, '--validate-only'], { env, encoding: 'utf8' }); + assert.equal(validation.status, 0, validation.stderr); + assert.equal(existsSync(capture), false); + const dry = spawnSync(process.execPath, [script, '--dry-run'], { env, encoding: 'utf8' }); + assert.equal(dry.status, 0, dry.stderr); + const command = JSON.parse(readFileSync(capture, 'utf8')); + assert.ok(command.args.includes('--dry-run')); + assert.ok(command.args.includes('wrangler.production.toml')); + assert.ok(!command.args.some((arg) => ['--route', '--routes', '--domain', '--domains'].includes(arg))); + assert.equal(command.path, null); + for (const name of ['SESSION_SECRET', 'ABC_API_KEY', 'ORG_CONTEXT_KEY_SECRET']) { + assert.ok(!JSON.stringify(command.args).includes(env[name])); + assert.ok(!validation.stdout.includes(env[name])); + } +})); + +for (const exitCode of [0, 1]) { + test(`production uploads secrets with code and removes the private temporary file after exit ${exitCode}`, () => withFakeWrangler(({ env, capture }) => { + const result = spawnSync(process.execPath, [fileURLToPath(new URL('../scripts/deploy-production.mjs', import.meta.url))], { + env: { ...env, QA_TEST_EXIT: String(exitCode) }, encoding: 'utf8' + }); + assert.equal(result.status, exitCode); + const command = JSON.parse(readFileSync(capture, 'utf8')); + assert.equal(command.secrets.ABC_API_KEY, env.ABC_API_KEY); + assert.equal(command.mode, 0o600); + assert.equal(command.secrets.CLOUDFLARE_API_TOKEN, undefined); + assert.ok(!JSON.stringify(command.args).includes(env.ORG_CONTEXT_KEY_SECRET)); + assert.equal(existsSync(command.path), false); + })); +} + +test('production uploader rejects root uploads and sends every file to the selected commit path', () => withFakeWrangler(({ dir, env, capture }) => { + const script = fileURLToPath(new URL('../scripts/upload-r2.sh', import.meta.url)); + const refused = spawnSync('bash', [script, 'baron-qa-write'], { env, cwd: dir }); + assert.equal(refused.status, 1); + assert.equal(existsSync(capture), false); + mkdirSync(join(dir, 'egbim')); + mkdirSync(join(dir, 'assets')); + for (const file of ['index.html', 'write.html', 'detail.html', 'app.js', 'config.js']) writeFileSync(join(dir, 'egbim', file), 'fixture'); + writeFileSync(join(dir, 'assets/styles.css'), 'body {}'); + const uploaded = spawnSync('bash', [script, 'baron-qa-write', 'wrangler.production.toml', 'releases/' + SHA], { env, cwd: dir, encoding: 'utf8' }); + assert.equal(uploaded.status, 0, uploaded.stderr); + const commands = readFileSync(capture, 'utf8').trim().split('\n').map(JSON.parse); + assert.equal(commands.length, 6); + for (const command of commands) { + assert.ok(command.args[5].startsWith('baron-qa-write/releases/' + SHA + '/')); + assert.ok(command.args.includes('wrangler.production.toml')); + assert.ok(!command.args.includes('baron-qa-pages')); + } + writeFileSync(capture, ''); + const staging = spawnSync('bash', [script], { env, cwd: dir, encoding: 'utf8' }); + assert.equal(staging.status, 0, staging.stderr); + for (const command of readFileSync(capture, 'utf8').trim().split('\n').map(JSON.parse)) { + assert.ok(command.args[5].startsWith('baron-qa-pages/')); + assert.ok(!command.args[5].includes('releases/')); + assert.ok(command.args.includes('wrangler.toml')); + } +})); + +for (const prefix of ['', 'releases/' + SHA]) { + test(`Worker uses ${prefix ? 'release-specific' : 'legacy staging'} asset keys`, async () => { + const response = await worker.fetch(new Request('https://qa.example.test/assets/styles.css'), { + QA_ASSET_PREFIX: prefix, QA_BUCKET: { async get(key) { + assert.equal(key, (prefix ? prefix + '/' : '') + 'assets/styles.css'); + return { body: 'body {}', httpEtag: 'fixture', writeHttpMetadata(headers) { headers.set('content-type', 'text/css'); } }; + } } + }); + assert.equal(response.status, 200); + assert.equal(response.headers.get('x-qa-release'), prefix || null); + }); +} + +test('post-deploy verification rejects Hello World, old releases and unprotected write pages', async () => { + await assert.rejects(verifyProduction(async () => new Response('Hello World!'), SHA)); + const http = (oldRelease = false, unprotected = false) => async (url) => { + if (url.endsWith('/auth/session')) return Response.json({ authenticated: false, user: null }); + if (url.endsWith('/assets/styles.css')) return new Response('body {}', { headers: { 'content-type': 'text/css', 'x-qa-release': oldRelease ? 'releases/old' : 'releases/' + SHA } }); + return new Response('', { status: unprotected ? 200 : 302, headers: { location: '/auth/login?return_url=write' } }); + }; + await assert.rejects(verifyProduction(http(true), SHA), /deployed commit/); + await assert.rejects(verifyProduction(http(false, true), SHA), /redirect/); + await verifyProduction(http(), SHA); +}); diff --git a/wrangler.production.toml b/wrangler.production.toml new file mode 100644 index 0000000..423ef00 --- /dev/null +++ b/wrangler.production.toml @@ -0,0 +1,37 @@ +name = "baron-qa-gateway-prod" +main = "src/index.js" +compatibility_date = "2026-09-18" +account_id = "81fa2d48964d31dd0da9558f9ce601d1" +workers_dev = false +preview_urls = false + +# qa.baroncs.co.kr is already attached to this Worker in the Cloudflare dashboard. +# Keep route/routes absent so deployments preserve that connection without zone writes. + +[vars] +AUTH_REDIRECT_URI = "https://qa.baroncs.co.kr/auth/callback" +AUTH_POST_LOGOUT_REDIRECT_URI = "https://qa.baroncs.co.kr/" +AUTH_SCOPE = "openid tenants profile email" +AUTH_AUTHORIZE_URL = "https://sso.hmac.kr/oidc/oauth2/auth" +AUTH_TOKEN_URL = "https://sso.hmac.kr/oidc/oauth2/token" +AUTH_USERINFO_URL = "https://sso.hmac.kr/oidc/userinfo" +SESSION_COOKIE_NAME = "baron_qa_prod_session_v1" +OAUTH_COOKIE_NAME = "baron_qa_prod_oauth_v1" +SESSION_TTL_SECONDS = "3600" +ABC_SOURCE_NAMESPACE = "EGBIM_QA_PROD" +ABC_IDEMPOTENCY_CONSUMER = "EGBIM_QA_PROD" +PUBLIC_EXACT_PATHS = "/,/index.html,/favicon.ico" +PUBLIC_PREFIXES = "/egbim/,/tova/,/gaia/,/shared/,/assets/,/auth/" +PROTECTED_PREFIXES = "/egbim/,/tova/,/gaia/" +PROTECTED_EXACT_PATHS = "/egbim/write.html,/egbim/detail.html" +INTERNAL_ONLY_PREFIXES = "/protected/" + +[[r2_buckets]] +binding = "QA_BUCKET" +bucket_name = "baron-qa-write" + +[observability] +enabled = true + +[observability.logs] +enabled = true