Compare commits

..
8 Commits
Author SHA1 Message Date
root e2e70c13ac fix(ci): preserve staging dashboard domain during deployment
Deploy EG-BIM QA Gateway / deploy (push) Successful in 2m5s
Omit staging route declarations and disable workers.dev so Wrangler preserves the existing qa-test custom domain without requesting zone routing access. Match the production domain management model.

Validation: 123 tests and a real Wrangler dry-run pass. New staging organization key passes org:verify; run 227269 attempt 2 then failed on the zone workers/routes API before this configuration fix.
2026-11-11 12:53:46 +09:00
root e2a6168744 fix(ci): classify organization API access denials safely
Deploy EG-BIM QA Gateway / deploy (push) Failing after 54s
Report documented operation, scope and exposure error codes, permitted exposure enums and proxy challenge indicators during deployment checks. Exclude arbitrary upstream messages and credential material while retaining failure on denied access.

Validation: source checks and 123 tests pass. Retried staging run 227194 still returns org_context_http_403; the new diagnostic requires the user-managed push before a further run.
2026-11-11 12:36:00 +09:00
root bed970a175 fix(ci): identify denied organization roots in access checks
Log the requested tenant, endpoint, operation and exposure for organization HTTP 401/403 responses. Keep credentials and upstream response bodies out of diagnostics and retain the deployment gate.

Validation: source checks and 118 tests pass, including forbidden-root identification and diagnostic privacy coverage.
2026-11-11 11:35:36 +09:00
root 7c09c89da5 운영버킷 배포
Deploy EG-BIM QA Gateway / deploy (push) Failing after 12s
2026-11-11 11:03:51 +09:00
root 0d9b1fd703 fix(ci): isolate staging Actions settings under STG prefix
Map staging Secrets and Variables exclusively from STG names, validate explicit API and SSO settings, and pass them to the staging Worker. Preserve production settings and document the variable registration and Secret migration.

Validation: source checks and 113 tests pass. Gitea STG Variables were created and verified; Secret registration remains with the user.
2026-11-11 10:32:46 +09:00
root 319a8afaab fix(ui): rename inquiry heading to S/W Q&A 2026-11-02 20:07:00 +09:00
root beaa2fc423 feat: add account menu and redirect after Q&A logout
Deploy EG-BIM QA Gateway / deploy (push) Successful in 54s
Add the top-right account icon with the signed-in name and logout link. Expire the Q&A session before redirecting to the configured inquiry homepage in both environments. Preserve login return URL checks and ignore request-controlled logout destinations.

Validation: 106 tests, syntax checks, and mocked Chromium desktop/mobile regressions pass, including session-cookie removal and the destination navigation request with external networking blocked.
2026-11-02 14:18:23 +09:00
root 2b37bed2b8 fix(ui): center EG-BIM tab and hide upcoming products
Deploy EG-BIM QA Gateway / deploy (push) Successful in 59s
2026-11-02 13:32:26 +09:00
28 changed files with 659 additions and 101 deletions

No files matched your search

+5 -1
View File
@@ -17,11 +17,12 @@ jobs:
SESSION_SECRET: ${{ secrets.PROD_SESSION_SECRET }}
ABC_API_KEY: ${{ secrets.PROD_ABC_API_KEY }}
AUTH_CLIENT_ID: ${{ vars.PROD_AUTH_CLIENT_ID }}
AUTH_ISSUER: ${{ vars.PROD_AUTH_ISSUER }}
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_API_URL: ${{ vars.PROD_ORG_CONTEXT_API_URL || 'https://app.brsw.kr/api/v1/integrations/org-context' }}
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 }}
@@ -57,6 +58,9 @@ jobs:
- name: Verify production organization API
run: npm run org:verify
- name: Verify production ABC project and channel access
run: npm run abc:verify
- name: Build production Worker without deploying
run: npm run deploy:production -- --dry-run
+20 -14
View File
@@ -10,17 +10,22 @@ jobs:
deploy:
runs-on: ubuntu-latest
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
ABC_API_KEY: ${{ secrets.ABC_API_KEY }}
ORG_CONTEXT_ENABLED: ${{ vars.STAGING_ORG_CONTEXT_ENABLED }}
ORG_CONTEXT_API_URL: ${{ vars.STAGING_ORG_CONTEXT_API_URL }}
ORG_CONTEXT_SCOPE_MODE: ${{ vars.STAGING_ORG_CONTEXT_SCOPE_MODE }}
ORG_CONTEXT_EXPOSURE: ${{ vars.STAGING_ORG_CONTEXT_EXPOSURE }}
ORG_CONTEXT_TENANT_IDS: ${{ vars.STAGING_ORG_CONTEXT_TENANT_IDS }}
ORG_CONTEXT_CACHE_TTL_SECONDS: ${{ vars.STAGING_ORG_CONTEXT_CACHE_TTL_SECONDS }}
ORG_CONTEXT_KEY_ID: ${{ secrets.STAGING_ORG_CONTEXT_KEY_ID }}
ORG_CONTEXT_KEY_SECRET: ${{ secrets.STAGING_ORG_CONTEXT_KEY_SECRET }}
CLOUDFLARE_API_TOKEN: ${{ secrets.STG_CLOUDFLARE_API_TOKEN }}
SESSION_SECRET: ${{ secrets.STG_SESSION_SECRET }}
ABC_API_KEY: ${{ secrets.STG_ABC_API_KEY }}
AUTH_CLIENT_ID: ${{ vars.STG_AUTH_CLIENT_ID }}
AUTH_ISSUER: ${{ vars.STG_AUTH_ISSUER }}
ABC_API_BASE_URL: ${{ vars.STG_ABC_API_BASE_URL }}
ABC_PROJECT_ID: ${{ vars.STG_ABC_PROJECT_ID }}
ABC_CHANNEL_ID: ${{ vars.STG_ABC_CHANNEL_ID }}
ORG_CONTEXT_ENABLED: ${{ vars.STG_ORG_CONTEXT_ENABLED }}
ORG_CONTEXT_API_URL: ${{ vars.STG_ORG_CONTEXT_API_URL }}
ORG_CONTEXT_SCOPE_MODE: ${{ vars.STG_ORG_CONTEXT_SCOPE_MODE }}
ORG_CONTEXT_EXPOSURE: ${{ vars.STG_ORG_CONTEXT_EXPOSURE }}
ORG_CONTEXT_TENANT_IDS: ${{ vars.STG_ORG_CONTEXT_TENANT_IDS }}
ORG_CONTEXT_CACHE_TTL_SECONDS: ${{ vars.STG_ORG_CONTEXT_CACHE_TTL_SECONDS }}
ORG_CONTEXT_KEY_ID: ${{ secrets.STG_ORG_CONTEXT_KEY_ID }}
ORG_CONTEXT_KEY_SECRET: ${{ secrets.STG_ORG_CONTEXT_KEY_SECRET }}
steps:
- name: Checkout
@@ -35,9 +40,10 @@ jobs:
shell: bash
run: |
set -euo pipefail
test -n "$CLOUDFLARE_API_TOKEN" || { echo "CLOUDFLARE_API_TOKEN is missing"; exit 1; }
test -n "$SESSION_SECRET" || { echo "SESSION_SECRET is missing"; exit 1; }
test -n "$ABC_API_KEY" || { echo "ABC_API_KEY is missing"; exit 1; }
test -n "$CLOUDFLARE_API_TOKEN" || { echo "STG_CLOUDFLARE_API_TOKEN is missing"; exit 1; }
test -n "$SESSION_SECRET" || { echo "STG_SESSION_SECRET is missing"; exit 1; }
test -n "$ABC_API_KEY" || { echo "STG_ABC_API_KEY is missing"; exit 1; }
node scripts/deploy-worker.mjs --validate-only
- name: Install dependencies
run: npm ci --no-fund --no-audit
+45 -16
View File
@@ -1,8 +1,8 @@
# EG-BIM Q&A static pages
`egbim/index.html`의 목록 위에 작성·상세 모달을 여는 EG-BIM Q&A UI입니다. `SW문의_소스패키지`의 히어로·제품 탭·목록·모달 디자인을 적용하고, 기존 첨부·댓글·비밀글·작성자 권한·검색을 유지합니다. 회사 홈페이지의 헤더·메뉴·푸터는 포함하지 않습니다. 기존 `write.html`, `detail.html?id=...` 주소는 같은 목록의 모달로 연결됩니다.
`egbim/index.html`의 목록 위에 작성·상세 모달을 여는 EG-BIM Q&A UI입니다. `SW문의_소스패키지`의 히어로·제품 탭·목록·모달 디자인을 적용하고, 기존 첨부·댓글·비밀글·작성자 권한·검색을 유지합니다. 회사 홈페이지의 헤더·메뉴·푸터는 포함하지 않습니다. 기존 `write.html`, `detail.html?id=...` 주소는 같은 목록의 모달로 연결됩니다. 상단 오른쪽 사람 아이콘의 계정 메뉴에서 로그인 사용자 이름을 확인하고 로그아웃할 수 있습니다.
EG-BIM을 우선 제공하며 TOVA·GAIA는 준비중 탭입니다. 원격지원은 `https://939.co.kr/saman/`으로 열립니다. UI 적용 범위·검증·남은 배포 확인은 [작업 목록](docs/qa-inquiry-ui-redesign-tasks.md)에 정리했습니다.
EG-BIM 탭만 가운데 표시하며 TOVA·GAIA는 오픈 전까지 숨깁니다. 원격지원은 `https://939.co.kr/saman/`으로 열립니다. UI 적용 범위·검증·남은 배포 확인은 [작업 목록](docs/qa-inquiry-ui-redesign-tasks.md)에 정리했습니다.
로그인부터 글 저장까지의 화면·처리 전이맵은 [`docs/qa-login-to-save-transition.md`](docs/qa-login-to-save-transition.md)에 정리했습니다.
@@ -78,17 +78,41 @@ npx wrangler secret bulk qa-secrets.json --name baron-qa-gateway-test
Worker는 OAuth Authorization Code + PKCE를 사용하고, callback에서 검증한 사용자 claim을 서명된 HttpOnly 세션 쿠키에 저장합니다. 브라우저의 `GET /auth/session`은 정규화된 작성자 정보만 반환합니다.
로그아웃 메뉴는 `/auth/logout`으로 이동해 해당 환경의 Q&A 세션 쿠키를 삭제한 뒤 `https://test.baroncs.co.kr/ko/sw-inquiry.html`로 돌아갑니다. 스테이징·운영의 `AUTH_POST_LOGOUT_REDIRECT_URI`에 같은 목적지를 설정합니다. 목적지는 서버 배포 설정에서만 읽으며 요청 쿼리로 변경할 수 없습니다. 중앙 SSO의 로그인 종료 요청은 별도로 보내지 않습니다.
## Gitea Actions 등록값
운영 `qa.baroncs.co.kr` 배포는 [production 파이프라인 안내](docs/qa-production-pipeline.md)를 따른다. 운영 workflow는 `Deploy EG-BIM QA Production`이며 `main`에서 수동으로 실행한다. 아래 기존 등록값은 스테이징 배포용이다.
운영 `qa.baroncs.co.kr` 배포는 [production 파이프라인 안내](docs/qa-production-pipeline.md)를 따른다. 운영 workflow는 `Deploy EG-BIM QA Production`이며 `main`에서 수동으로 실행한다. 스테이징 Actions 설정은 `STG_*`, 운영은 `PROD_*` 이름을 사용한다.
저장소 Settings → Actions → Secrets에 아래 기본 Secret 3개를 등록합니다.
저장소 Settings → Actions → Secrets에 아래 스테이징 Secret을 등록합니다. 기존 접두사 없는 이름과 `STAGING_*` 이름은 새 workflow에서 읽지 않습니다.
| 이름 | 종류 | 값 |
|---|---|---|
| `CLOUDFLARE_API_TOKEN` | Secret | Workers Scripts Edit + Workers R2 Storage Edit 권한의 Cloudflare API Token |
| `SESSION_SECRET` | Secret | `openssl rand -hex 32`로 생성한 세션 서명키 |
| `ABC_API_KEY` | Secret | 프로젝트 전용 ABC UserFeedback API Key |
| `STG_CLOUDFLARE_API_TOKEN` | Secret | 스테이징 Worker·R2 배포용 Cloudflare API Token |
| `STG_SESSION_SECRET` | Secret | 스테이징 세션 서명키. 기존 서명키를 유지하면 기존 로그인 세션도 유지 |
| `STG_ABC_API_KEY` | Secret | 스테이징 ABC project/channel API Key |
| `STG_ORG_CONTEXT_KEY_ID` | Secret | 스테이징 조직 API Key ID |
| `STG_ORG_CONTEXT_KEY_SECRET` | Secret | 스테이징 조직 API Key Secret |
스테이징 일반 변수는 기존 설정에서 확인한 값으로 아래와 같이 분리한다. 운영 프로젝트·채널 ID를 스테이징에 복사하지 않는다.
| Variable | 스테이징 값 |
|---|---|
| `STG_AUTH_CLIENT_ID` | `e16898a7-acd2-420a-a224-cdcd89ff66cd` |
| `STG_AUTH_ISSUER` | `https://sso.hmac.kr/oidc` |
| `STG_ABC_API_BASE_URL` | `https://feedback.hmac.kr` |
| `STG_ABC_PROJECT_ID` | `01a0ae3f-fcf6-74b5-bdc4-70d942d6ad72` |
| `STG_ABC_CHANNEL_ID` | `01a0ae40-51c2-7647-a93c-0249b3759777` |
| `STG_ORG_CONTEXT_ENABLED` | `true` |
| `STG_ORG_CONTEXT_API_URL` | `https://sadmin.hmac.kr/api/v1/integrations/org-context` |
| `STG_ORG_CONTEXT_SCOPE_MODE` | `SCOPED` |
| `STG_ORG_CONTEXT_EXPOSURE` | `identifiers` |
| `STG_ORG_CONTEXT_TENANT_IDS` | 기존 스테이징 기준 tenant 7개 JSON 배열 |
| `STG_ORG_CONTEXT_CACHE_TTL_SECONDS` | `300` |
workflow가 `STG_`를 제거한 Worker/CLI 환경 이름으로 매핑한다. `scripts/deploy-worker.mjs`는 필수 API·SSO 값이 비었을 때 다른 환경으로 대체하지 않고 중단한다. `AUTH_ISSUER`로 authorization·token·userinfo 주소를 구성한다. `--validate-only`와 `--dry-run`은 Worker·Secret을 변경하지 않는다.
2026-11-11 확인: 일반 변수 11개를 이 저장소의 Gitea Actions Variables에 등록하고 값 일치를 확인했다. `STG_*` Secret 5개는 사용자가 등록한다. 현재 원격 workflow가 사용하는 기존 이름은 새 Secret 등록 및 workflow 반영 이후 정리한다. 운영 등록 목록은 [production 안내](docs/qa-production-pipeline.md)를 따른다.
`CLOUDFLARE_ACCOUNT_ID`는 secret으로 등록할 필요가 없습니다. `wrangler.toml`에 `81fa2d48964d31dd0da9558f9ce601d1`로 설정되어 있습니다.
@@ -97,7 +121,8 @@ Cloudflare API Token에는 최소한 다음 권한이 필요합니다.
- Account → Workers Scripts → Edit
- Account → Workers R2 Storage → Edit
- Account → Account Settings → Read
- Custom Domain route를 Actions에서 변경할 경우 Zone → Workers Routes → Edit
스테이징 `qa-test.baroncs.co.kr`과 운영 `qa.baroncs.co.kr`의 도메인 연결은 Cloudflare 대시보드에서 관리합니다. 두 Wrangler 설정 모두 `route`/`routes`를 생략하고 `workers_dev=false`로 지정해 기존 연결을 유지합니다. 배포 시 Zone 라우팅 API를 호출하지 않으므로 이 작업에 Zone 권한을 추가할 필요가 없습니다. [Cloudflare 설정 안내](https://developers.cloudflare.com/workers/wrangler/configuration/#source-of-truth)
`.gitea/workflows/deploy.yml`은 `main` push 또는 수동 실행 시 세션 secret을 Worker에 등록하고 R2 업로드 후 `baron-qa-gateway-test`를 배포합니다. 실제 secret 값은 로그에 출력하지 않습니다.
@@ -110,19 +135,23 @@ Gitea의 스테이징 설정을 `.gitea/workflows/deploy.yml`에서 Worker 런
| Gitea | 위치 | Worker 런타임 |
|---|---|---|
| `STAGING_ORG_CONTEXT_KEY_ID` | Secrets | `ORG_CONTEXT_KEY_ID` (Secret) |
| `STAGING_ORG_CONTEXT_KEY_SECRET` | Secrets | `ORG_CONTEXT_KEY_SECRET` (Secret) |
| `STAGING_ORG_CONTEXT_ENABLED` | Variables | `ORG_CONTEXT_ENABLED` |
| `STAGING_ORG_CONTEXT_API_URL` | Variables | `ORG_CONTEXT_API_URL` |
| `STAGING_ORG_CONTEXT_SCOPE_MODE` | Variables | `ORG_CONTEXT_SCOPE_MODE` |
| `STAGING_ORG_CONTEXT_EXPOSURE` | Variables | `ORG_CONTEXT_EXPOSURE` |
| `STAGING_ORG_CONTEXT_TENANT_IDS` | Variables | `ORG_CONTEXT_TENANT_IDS` |
| `STAGING_ORG_CONTEXT_CACHE_TTL_SECONDS` | Variables, 선택 | `ORG_CONTEXT_CACHE_TTL_SECONDS` (기본 300) |
| `STG_ORG_CONTEXT_KEY_ID` | Secrets | `ORG_CONTEXT_KEY_ID` (Secret) |
| `STG_ORG_CONTEXT_KEY_SECRET` | Secrets | `ORG_CONTEXT_KEY_SECRET` (Secret) |
| `STG_ORG_CONTEXT_ENABLED` | Variables | `ORG_CONTEXT_ENABLED` |
| `STG_ORG_CONTEXT_API_URL` | Variables | `ORG_CONTEXT_API_URL` |
| `STG_ORG_CONTEXT_SCOPE_MODE` | Variables | `ORG_CONTEXT_SCOPE_MODE` |
| `STG_ORG_CONTEXT_EXPOSURE` | Variables | `ORG_CONTEXT_EXPOSURE` |
| `STG_ORG_CONTEXT_TENANT_IDS` | Variables | `ORG_CONTEXT_TENANT_IDS` |
| `STG_ORG_CONTEXT_CACHE_TTL_SECONDS` | Variables, 선택 | `ORG_CONTEXT_CACHE_TTL_SECONDS` (기본 300) |
조회는 `SCOPED`, `identifiers`를 사용합니다. 기준 테넌트는 UUID JSON 배열로 등록하며 UUID v4를 허용합니다. 각 테넌트를 최대 3개씩 동시 조회하고, 완전한 응답만 캐시합니다. 페이지 조회 타임아웃은 요청당 5초입니다. 배포 전 `npm run org:verify`는 요청당 15초를 허용하고, 타임아웃·통신 실패·HTTP 429/502/503/504에 한해 2초 간격으로 최대 3회 시도합니다. 인증 실패·범위 불일치·구성원 누락은 재시도 없이 검증 실패로 처리합니다.
기본 `ORG_CONTEXT_ENABLED=false`에서는 조직 API를 호출하지 않습니다. `true`로 배포하면 `npm run org:verify`가 실제 키로 조직 API를 먼저 검증하며, 실패 시 새 Worker 배포를 진행하지 않습니다. 성공 시에는 조직·사용자·소속 건수만 로그에 출력합니다. 전체 응답·전화번호·키 원문은 출력하지 않습니다.
배포 전 검증에서 HTTP 401/403이 발생하면 요청한 API 주소·기준 tenant UUID·OperationID `baron.org-context.read`·exposure `identifiers`를 함께 출력합니다. 키 원문과 API 응답 본문은 출력하지 않습니다. 403이면 발급 서버의 API 키 관리에서 이 작업·노출 단계·해당 tenant subtree 조회 범위를 확인합니다. 여러 기준 tenant 중 하나만 거부돼도 전체 검증은 실패하며, 권한 밖 조직을 임의로 제외하거나 검증을 건너뛰지 않습니다.
서버가 제공하는 오류 중 문서에 정의된 코드만 `upstreamCode`로 출력합니다. `credential_operation_mismatch`는 허용 작업, `credential_scope_exceeded`는 조직 범위, `credential_exposure_exceeded`는 노출 단계 거절입니다. 서버가 반환한 허용/요청 노출 단계도 정해진 enum에 해당할 때만 표시합니다. HTML 응답이나 Cloudflare challenge 여부는 `responseKind`, `proxyChallenge`로 구분합니다. 알 수 없는 코드·오류 메시지·HTML 원문은 출력하지 않습니다.
Worker는 서명된 세션의 사용자 UUID로 소속을 찾습니다. 조회 조직 컨텍스트·대표 소속을 선택하고, 기존 requester ID·tenant ID·권한은 유지합니다. 새 문의는 `requester_affiliation`에 회사, `requester_department`에 부서를 저장합니다. JSON과 첨부 multipart에 같은 값을 사용합니다. 목록·상세는 글에 저장된 정보를 사용하며 조회 시점의 조직으로 과거 글을 바꾸지 않습니다.
조직 조회가 실패하면 기존 세션·글쓰기 흐름과 확인된 SSO 정보를 유지합니다. 누락되거나 모호한 사용자는 `orgContextStatus`로 구분하고 다른 작성자의 정보를 대신 쓰지 않습니다. 로그인 후 `/auth/session`의 `user.orgContextStatus=unavailable`이면 `user.orgContextError`에서 허용된 오류 코드만 확인할 수 있습니다. `org_context_missing_credentials`는 Worker의 조직 API 키 누락, `org_context_http_401`은 API 인증 거부, `org_context_http_403`은 API 접근 거부, `org_context_timeout`은 요청 시간 초과를 뜻합니다. 알 수 없는 오류는 `org_context_unavailable`로 표시하며 키·원본 오류 메시지·조직 디렉터리는 응답에 넣지 않습니다. 조회가 정상화되면 이전 `orgContextError`는 제거됩니다. HTTP 200이나 요청 로그의 `outcome=ok`만으로 조직 조회 성공을 판단하지 않습니다.
+15 -3
View File
@@ -8,10 +8,21 @@
.inquiry-hero__copy { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #fff; text-align: center; padding: 40px 24px; }
.inquiry-hero h1 { font-size: 70px; font-weight: 700; line-height: 1.4; letter-spacing: -0.04em; }
.inquiry-hero p { font-size: 22px; line-height: 1.6; }
.inquiry-account { position: absolute; top: 24px; right: 32px; z-index: 3; color: #fff; }
.inquiry-account summary { display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; border: 1px solid #ffffff99; border-radius: 50%; background: #0b274366; cursor: pointer; list-style: none; }
.inquiry-account summary::-webkit-details-marker { display: none; }
.inquiry-account summary:hover, .inquiry-account[open] summary { background: #0b2743b3; }
.inquiry-account summary:focus-visible { outline-color: #fff; }
.inquiry-account svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.inquiry-account__panel { position: absolute; top: 54px; right: 0; width: 192px; padding: 8px; border: 1px solid #e5e9ef; border-radius: 10px; background: #fff; color: #26374f; box-shadow: 0 8px 28px #0003; }
.inquiry-hero .inquiry-account__panel p { overflow: hidden; padding: 10px 12px; color: #697386; font-size: 13px; white-space: nowrap; text-overflow: ellipsis; }
.inquiry-account__panel a { display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 10px 12px; border-radius: 6px; font-size: 14px; }
.inquiry-account__panel a:hover { background: #edf3fd; color: #164b91; }
.inquiry-account__panel a svg { width: 18px; height: 18px; }
.inquiry-workspace { position: relative; z-index: 1; width: min(1248px, 100%); padding: 0 24px; margin: -98px auto 120px; }
.inquiry-tabs-shell { --inquiry-tab-curve: 28px; padding: 19px 32px 0; border-radius: 48px 48px 0 0; background: linear-gradient(180deg, #174b64a6, #082f43d9); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); box-shadow: inset 0 1px 0 #74c7f4b3, inset 0 0 0 1px #5fadd666, inset 0 8px 16px #ffffff12, 0 -18px 12px #0a2f4843; }
.inquiry-tabs { display: flex; gap: 60px; position: relative; isolation: isolate; }
.inquiry-tabs button { position: relative; flex: 1; min-width: 0; min-height: 76px; padding: 8px 12px 10px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; border: 0; border-radius: 34px 34px 0 0; color: #b9d4e0a6; background: transparent; cursor: pointer; transition: background-color 0.2s ease, color 0.2s ease; }
.inquiry-tabs { display: flex; justify-content: center; gap: 60px; position: relative; isolation: isolate; }
.inquiry-tabs button { position: relative; flex: 0 1 340px; min-width: 0; min-height: 76px; padding: 8px 12px 10px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; border: 0; border-radius: 34px 34px 0 0; color: #b9d4e0a6; background: transparent; cursor: pointer; transition: background-color 0.2s ease, color 0.2s ease; }
.inquiry-tabs button:not([aria-selected="true"]):hover { color: #d6f0ff; background: #ffffff12; }
.inquiry-tabs [aria-selected="true"] { z-index: 1; color: #00a2ef; background: linear-gradient(180deg, #e8f8ff 0%, #fff 58%); box-shadow: inset 0 2px 0 #ffffffb3, 0 -3px 10px #163f5b08; }
/* Concave lower corners join the selected tab to the white board beneath it. */
@@ -116,10 +127,11 @@ html.inquiry-modal-open body { overflow: hidden !important; }
.inquiry-hero { --inquiry-hero-shade: rgba(5, 24, 44, 0.38); height: 340px; background-position: 22% center; }
.inquiry-hero h1 { font-size: 48px; }
.inquiry-hero p { font-size: 17px; }
.inquiry-account { top: 16px; right: 16px; }
.inquiry-workspace { margin: -96px auto 72px; padding: 0 16px; }
.inquiry-tabs-shell { --inquiry-tab-curve: 13px; padding: 12px 13px 0; border-radius: 30px 30px 0 0; }
.inquiry-tabs { gap: 5px; }
.inquiry-tabs button { min-height: 84px; padding: 8px 4px 12px; gap: 4px; border-radius: 22px 22px 0 0; }
.inquiry-tabs button { flex-basis: 200px; min-height: 84px; padding: 8px 4px 12px; gap: 4px; border-radius: 22px 22px 0 0; }
.inquiry-tab-logo { width: 145px; max-width: 100%; height: 28px; }
.inquiry-tab-logo img { height: auto; max-height: 28px; }
.inquiry-tabs [data-service="egbim"] img { width: 49px; height: 25px; }
+17 -8
View File
@@ -11,7 +11,7 @@
| --- | --- |
| 작성·상세 표시 | 목록 위에 뜨는 페이지 내부 모달로 전환. 원본의 `<dialog>` 방식 기준 |
| 기존 주소 | 기존 작성·상세 주소 연결 유지 |
| 소프트웨어 | EG-BIM 우선 적용. TOVA·GAIA 탭은 준비중 표시 |
| 소프트웨어 | EG-BIM 탭만 가운데 표시. TOVA·GAIA 탭은 오픈 전까지 숨김 |
| 디자인 적용 범위 | 문의 화면·작성·상세 영역. 홈페이지 헤더·전체 메뉴·푸터는 제외 |
| 입력 중 닫기 | 입력 변경이 있으면 폐기 여부를 확인한 뒤 닫기 |
| 원격지원 | `https://939.co.kr/saman/` 활성화. 원본처럼 새 창으로 열기 |
@@ -27,7 +27,7 @@
| `SW문의_소스패키지/assets/js/inquiry.js` | 예시 게시물·공지 생성, 모달 열기/닫기, 미리보기 등록 안내 | 모달 UX를 참고하고 실제 데이터 처리는 기존 Q&A 로직으로 연결 |
| `SW문의_소스패키지/assets/js/inquiry-copy.js` | 예시 데이터·상태명·KO/EN 미리보기 문구 | 실제 서비스 문구와 상태명으로 정리 |
| `SW문의_소스패키지/assets/img/inquiry-hero.webp` | 스크린샷의 상단 배경 이미지 | 해당 자산 사용 |
| `SW문의_소스패키지/ko/{egbim,tova,gaia}/img/` | 제품 로고와 모바일용 EG-BIM 심볼 | 활성·준비중 탭에 사용 |
| `SW문의_소스패키지/ko/{egbim,tova,gaia}/img/` | 제품 로고와 모바일용 EG-BIM 심볼 | EG-BIM 탭과 추후 오픈할 숨긴 탭의 자산으로 보존 |
| `SW문의_소스패키지/assets/js/common.js`, `baron-sso-*.js`, `_include/` | 홈페이지 메뉴·헤더·푸터와 별도 SSO 흐름에 의존 | Q&A의 기존 Worker SSO 유지. 필요한 디자인 자산만 반영 |
| `egbim/app.js`, `src/index.js` | 실제 API·SSO·첨부·댓글·검색·작성자 버튼 처리 | 기능 이전의 기준 |
@@ -43,7 +43,7 @@
- 반투명 배경의 제품 탭과 선택된 EG-BIM 탭의 곡선·로고·색상을 반영한다.
- 홈페이지 헤더를 제외하므로 원본의 헤더 높이 보정용 상단 여백을 함께 조정한다.
- 문의 내역 제목, 원격지원 버튼, 둥근 분류 필터·검색창, 표·페이지 이동·문의등록 버튼을 맞춘다.
- TOVA·GAIA는 준비중임을 표시하고 실제 목록 조회·등록 대상으로 선택되지 않도록 한다.
- TOVA·GAIA는 오픈 전까지 숨기고 EG-BIM 탭을 가운데 배치한다.
- 실제 문의 데이터와 현행 검색·상태 표시를 사용한다. 예시 공지·예시 고객 데이터·미리보기 문구는 배포 화면에 넣지 않는다.
### 작성 모달
@@ -131,7 +131,7 @@ flowchart LR
| 댓글 수정·삭제 | `PATCH/DELETE /api/feedbacks/{id}/comments/{commentId}` | 실제 댓글 ID와 작성자 연결 |
| 완료 확인 | `POST /api/feedbacks/{id}/completion-confirmation` | 기존 완료 확인 처리 |
이번 범위에서 새 DB나 별도 업로드 API를 도입할 필요는 없다. TOVA·GAIA를 준비중으로 두므로 제품별 API 키·프로젝트·채널 설정 확대도 이번 작업의 선행 조건이 아니다.
이번 범위에서 새 DB나 별도 업로드 API를 도입할 필요는 없다. TOVA·GAIA를 숨겨 두므로 제품별 API 키·프로젝트·채널 설정 확대도 이번 작업의 선행 조건이 아니다.
## 7. 단계별 작업 목록
@@ -149,7 +149,7 @@ flowchart LR
- [x] `UI-01` 문의 전용 스타일 범위를 정하고 기존 공통 버튼·폼·레이아웃과의 충돌을 정리한다.
- [x] `UI-02` 히어로 이미지·제품 로고·필요한 폰트만 배포 자산으로 정리하고 상대 경로를 맞춘다.
- [x] `UI-03` 목록의 히어로·곡선 탭·필터·검색창·표·페이지 이동·등록 버튼을 원본 기준으로 구성한다.
- [x] `UI-04` TOVA·GAIA 준비중 표기와 접근 가능한 비활성 탭 처리를 추가한다.
- [x] `UI-04` TOVA·GAIA를 숨기고 EG-BIM 탭을 데스크톱·모바일에서 가운데 배치한다.
- [x] `UI-05` 원격지원 링크를 확정 주소로 연결하고 새 창 안내와 `noopener noreferrer`를 유지한다.
- [x] `UI-06` 실제 데이터가 없는 예시 공지·예시 댓글 수·미리보기 문구를 제거한다. 댓글 수 배지가 필요하면 실제 API 제공값을 확인하고 연결하며, 행마다 댓글 API를 추가 호출하는 방식은 피한다.
@@ -222,13 +222,13 @@ flowchart LR
- [x] fuzzy 검색과 전체 페이지 수집·필터·페이지 이동을 유지한다.
- [x] 입력 변경 후 닫기는 확인을 거치며, 닫기 취소 시 입력과 파일을 보존한다.
- [x] 모달을 닫아도 목록 조건·위치가 유지되고 새 글·수정·삭제 결과가 반영된다.
- [x] TOVA·GAIA 준비중 표시와 확정된 원격지원 링크가 동작한다.
- [x] TOVA·GAIA 숨김·EG-BIM 탭 중앙 배치와 확정된 원격지원 링크가 동작한다.
- [x] 예시 데이터·미리보기 전용 문구가 남지 않고 모바일·키보드 사용과 회귀 검증을 통과했다.
## 10. 구현·검증 결과
변경은 정적 HTML/CSS/JS와 자산 업로드 MIME·검증·문서에 한정했다. Worker·API 계약·DB·SSO 설정·기존 배포 트리거는 변경하지 않았다. 기존 주소는 프론트의 `assets/inquiry/entry.js`로 연결하며, 새 화면은 기존 API를 그대로 호출한다. 원본 패키지 전체를 서비스 자산으로 복사하지 않았다.
초기 목록·모달 UI 변경은 정적 HTML/CSS/JS와 자산 업로드 MIME·검증·문서에 한정했다. 해당 작업에서 Worker·API 계약·DB·SSO 설정·기존 배포 트리거는 변경하지 않았다. 기존 주소는 프론트의 `assets/inquiry/entry.js`로 연결하며, 새 화면은 기존 API를 그대로 호출한다. 원본 패키지 전체를 서비스 자산으로 복사하지 않았다.
- `npm run check`, `npm test`: 기존 94개와 구형 주소 연결 4개, 총 98개 테스트 통과.
- R2 업로드 검사: 새 하위 경로의 진입 JS와 WebP 이미지가 기존 배포 경로에 포함되며 MIME은 각각 `application/javascript`, `image/webp`다.
@@ -254,4 +254,13 @@ CHROMIUM_EXECUTABLE_PATH=/absolute/path/to/chromium \
npm run test:ui
```
기본 스크린샷 출력 위치는 임시 디렉토리의 `qa-inquiry-ui/`다. `UI_SCREENSHOT_DIR`로 바꿀 수 있으며, 데스크톱·모바일 목록/작성/상세 6장을 생성한다. 실제 사용자 세션·API Key는 필요하지 않다.
기본 스크린샷 출력 위치는 임시 디렉토리의 `qa-inquiry-ui/`다. `UI_SCREENSHOT_DIR`로 바꿀 수 있으며, 데스크톱·모바일 목록/작성/상세 6장과 모바일 계정 메뉴 화면을 생성한다. 실제 사용자 세션·API Key는 필요하지 않다.
## 11. 후속 계정 메뉴
- 상단 오른쪽 사람 아이콘으로 로그인 사용자 이름·로그아웃 메뉴를 연다. 바깥 클릭·ESC로 닫는다.
- 로그아웃은 기존 `/auth/logout`에서 Q&A 세션 쿠키를 삭제한 뒤 `https://test.baroncs.co.kr/ko/sw-inquiry.html`로 이동한다.
- 스테이징·운영의 `AUTH_POST_LOGOUT_REDIRECT_URI`를 위 주소로 설정했다. 서버 설정의 HTTPS 목적지로 이동할 수 있게 기존 로그아웃 처리를 조정했으며 로그인 `return_url` 정책은 유지한다.
- 중앙 SSO 세션 종료 API는 호출하지 않는다. 추가 Secret·SSO client/callback 변경은 없다.
- 검증: 스테이징/운영 세션 쿠키 삭제·고정 목적지·쿼리 덮어쓰기 방지·잘못된 설정 처리 테스트, 브라우저 메뉴/키보드/모바일 표시 및 실제 Worker 핸들러를 통한 쿠키 삭제·목적지 이동 요청을 확인한다. 리다이렉트 목적지로의 실제 외부 접속은 Chromium 네트워크 차단으로 막는다. 기본 테스트 총 106개와 브라우저 회귀 검사를 통과했다.
+5 -1
View File
@@ -1,6 +1,6 @@
# EG-BIM Q&A 로그인·화면·저장 흐름
기준일: 2026-11-02. 스테이징 `qa-test.baroncs.co.kr`, 운영 `qa.baroncs.co.kr`의 현재 구조를 설명한다. 이번 UI 개편은 프론트에 한정하며 Worker API·DB·SSO 설정을 변경하지 않는다.
기준일: 2026-11-02. 스테이징 `qa-test.baroncs.co.kr`, 운영 `qa.baroncs.co.kr`의 현재 구조를 설명한다. 목록·모달 UI는 기존 API·DB·SSO 로그인 흐름을 유지한다. 후속 계정 메뉴 작업에서는 기존 로그아웃 경로의 이동 목적지를 홈페이지 문의 페이지로 연결했다.
## 로그인과 화면 진입
@@ -36,6 +36,10 @@ flowchart TD
M -->|아니오| O[모달 오류 표시·입력과 파일 유지]
```
## 계정 메뉴와 로그아웃
상단 오른쪽 사람 아이콘을 누르면 로그인 사용자 이름과 로그아웃 링크가 나온다. 바깥 클릭이나 ESC로 메뉴를 닫을 수 있다. `/auth/logout`은 현재 환경의 Q&A 세션 쿠키를 만료시키고 `https://test.baroncs.co.kr/ko/sw-inquiry.html`로 302 이동한다. 목적지는 스테이징·운영 Wrangler 설정의 `AUTH_POST_LOGOUT_REDIRECT_URI`에서 읽으며 사용자 쿼리로 덮어쓸 수 없다. 중앙 SSO 세션 종료 API는 호출하지 않는다.
## 작성·첨부 저장
브라우저는 기존 UUID v7 생성 방식으로 `feedbackId`를 만들고 아래 필드를 보낸다. 회사·부서·작성자·이메일 칸은 읽기 전용이며, 서버가 기존 세션·조직 조회 결과에서 작성자 정보를 결정한다.
+2 -2
View File
@@ -23,7 +23,7 @@
### 2. SSO 운영 등록
- [x] 운영 callback URI를 SSO에 등록했다: `https://qa.baroncs.co.kr/auth/callback`. 사용자 완료 보고 기준이다.
- [x] 로그아웃 후 이동 URI를 SSO에 등록했다: `https://qa.baroncs.co.kr/`. 사용자 완료 보고 기준이다.
- [x] 초기 SSO 등록의 로그아웃 후 URI는 `https://qa.baroncs.co.kr/`로 등록했다. 사용자 완료 보고 기준이다. 현재 계정 메뉴의 Q&A 로그아웃은 Worker에서 세션을 삭제하고 별도 설정된 홈페이지 문의 주소로 직접 이동한다.
- [x] 운영 SSO 설정에 맞는 `PROD_AUTH_CLIENT_ID` 등 관련 변수 등록을 완료했다. 실제 로그인 동작 확인은 운영 배포 후 수행한다.
- [ ] 운영 도메인에서 실제 사내 사용자로 로그인·로그아웃할 수 있는지 확인한다.
@@ -56,7 +56,7 @@
| 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/` |
| Q&A 로그아웃 후 이동 URI | `https://test.baroncs.co.kr/ko/sw-inquiry.html` (`AUTH_POST_LOGOUT_REDIRECT_URI`) |
| AUTH client ID | 운영 SSO client ID |
| ABC API base URL | `https://feedback.hmac.kr` 또는 운영 API 주소 |
| ABC project ID / channel ID | 운영 대상 ID |
+26 -16
View File
@@ -9,6 +9,11 @@
| 실행 | Gitea Actions에서 `main`을 선택해 수동 실행 |
| Worker | `baron-qa-gateway-prod` |
| Custom Domain | `https://qa.baroncs.co.kr` |
| EGBIM 작성페이지 | `https://qa.baroncs.co.kr/egbim/` |
| 운영 ABC API | `https://qa.brsw.kr` |
| 운영 EGBIM 프로젝트 ID | `01a1c543-ab4f-744f-8eff-ab85f2644271` |
| 운영 Q&A 채널 ID | `01a1c544-35ac-7674-9bf0-2469bfbe3e52` |
| 운영 SSO issuer | `https://app.brsw.kr/oidc` |
| Cloudflare account ID | `81fa2d48964d31dd0da9558f9ce601d1` |
| 정적 파일 R2 bucket | `baron-qa-write` |
| R2 binding 이름 | `QA_BUCKET` |
@@ -26,7 +31,7 @@
|---|---|
| `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_ABC_API_KEY` | 운영 EGBIM 프로젝트에서 발급한 API key. 작성페이지 저장소에 별도 등록 |
| `PROD_ORG_CONTEXT_KEY_ID` | 운영 조직 컨텍스트 연동 Key ID |
| `PROD_ORG_CONTEXT_KEY_SECRET` | 운영 조직 컨텍스트 연동 Key Secret |
@@ -50,22 +55,25 @@ R2 권한은 계정 범위의 **Workers R2 Storage Write/Edit**를 선택한다.
| 이름 | 필수 | 값 |
|---|---|---|
| `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_AUTH_CLIENT_ID` | 필수 | 운영 SSO의 Public PKCE client ID. callback은 `https://qa.baroncs.co.kr/auth/callback` |
| `PROD_AUTH_ISSUER` | 선택 | 기본 `https://app.brsw.kr/oidc`. authorization·token·userinfo 주소를 이 issuer에서 생성 |
| `PROD_ABC_API_BASE_URL` | 선택 | 기본 `https://qa.brsw.kr`. 다른 origin은 배포 차단. `/api`와 `/egbim` 경로를 붙이지 않는다. |
| `PROD_ABC_PROJECT_ID` | 선택 | 기본 `01a1c543-ab4f-744f-8eff-ab85f2644271`. 다른 값은 배포 차단 |
| `PROD_ABC_CHANNEL_ID` | 선택 | 기본 `01a1c544-35ac-7674-9bf0-2469bfbe3e52`. EGBIM의 Q&A 채널이며 다른 값은 배포 차단 |
| `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_API_URL` | 선택 | 기본 `https://app.brsw.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이 운영 대상인지 확정한 후 배포한다.
SSO에는 `https://qa.baroncs.co.kr/auth/callback`을 허용한다. Q&A 로그아웃은 Worker가 세션 쿠키를 삭제한 뒤 `AUTH_POST_LOGOUT_REDIRECT_URI`에 설정된 `https://test.baroncs.co.kr/ko/sw-inquiry.html`로 이동한다. 이 이동은 중앙 SSO 로그아웃 API를 사용하지 않는다. 등록된 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_*` 등록으로 간주하지 않는다.
`PROD_AUTH_CLIENT_ID`는 운영 issuer에 등록된 Public PKCE client여야 한다. 관리페이지의 비공개 Server-side Web client를 그대로 사용하지 않는다. 운영 작성페이지 callback `https://qa.baroncs.co.kr/auth/callback`과 필요한 scope를 해당 client에 허용한다. 기준 tenant UUID와 조직 연동 키는 운영 값을 사용한다. 기존 `STAGING_*` 등록은 `PROD_*` 등록으로 간주하지 않는다.
회사·부서 조회용 sadmin 조직 컨텍스트 API 설정은 ABC 관리페이지 연동 설정과 별개다. 조직 API 주소·기준 tenant·키의 운영 사용이 확정된 경우 미리 등록할 수 있다. Secret 값이나 테스트 fixture UUID를 실제 설정값으로 문서에 기록하지 않는다.
작성페이지의 `PROD_ABC_API_KEY`는 이 저장소(`disong/baron_qa_write`)의 Secret이다. 관리페이지 저장소의 `PROD_SECRETARY_ABC_API_KEY`와 등록 위치가 다르다. 브라우저는 작성페이지 Worker의 `/api/feedbacks`를 호출하고, Worker만 이 키로 운영 ABC API를 호출한다. `egbim/config.js`에 API 키를 넣지 않는다.
회사·부서 조회용 조직 컨텍스트 API 설정은 ABC 관리페이지 연동 설정과 별개다. 조직 API 주소·기준 tenant·키의 운영 사용이 확정된 경우 미리 등록할 수 있다. Secret 값이나 테스트 fixture UUID를 실제 설정값으로 문서에 기록하지 않는다.
Worker runtime에는 `PROD_`를 제거한 이름으로 주입한다. `DEPLOY_COMMIT_SHA`는 workflow가 선택된 커밋에서 자동으로 전달하므로 Gitea에 따로 등록하지 않는다.
@@ -76,19 +84,21 @@ Worker runtime에는 `PROD_`를 제거한 이름으로 주입한다. `DEPLOY_COM
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/<SHA>/...`에 올린다.
8. `npm run deploy:production`으로 코드·runtime 변수·Secret을 같은 Worker 배포에 반영한다.
9. `npm run production:verify`로 익명 세션, CSS 응답·커밋 일치, 비로그인 글쓰기 redirect를 확인한다. 전파 지연에 대비해 최대 5회 확인한다.
6. `npm run abc:verify`로 운영 프로젝트·채널·API key와 문의 필드를 읽기 전용으로 확인한다. 잘못된 키·UUID·필드 구성은 정적 파일 업로드 전에 차단한다.
7. `npm run deploy:production -- --dry-run`으로 Worker 번들을 만든다. 이 단계는 Cloudflare 리소스를 변경하지 않는다.
8. 해당 커밋의 정적 파일을 `baron-qa-write/releases/<SHA>/...`에 올린다.
9. `npm run deploy:production`으로 코드·runtime 변수·Secret을 같은 Worker 배포에 반영한다.
10. `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·세션 서명키 설정은 먼저 준비할 수 있다.
운영 API 주소와 사용자가 확정한 EGBIM 프로젝트·Q&A 채널 UUID를 운영 기본값으로 반영했다. Gitea의 `PROD_ABC_API_BASE_URL`·`PROD_ABC_PROJECT_ID`·`PROD_ABC_CHANNEL_ID`가 비어 있으면 이 값을 사용하며, 다른 값이면 업로드 전에 중단한다. 운영 프로젝트 API key는 별도 Secret이 필요하다. 실제 도메인이 `Hello World!`만 반환하면 Q&A 작성페이지 코드가 해당 도메인에서 서비스되지 않는 상태다. Worker 코드 배포와 Cloudflare custom domain의 대상 Worker를 함께 확인한다. 관리 서버의 CORS 설정만으로 작성페이지가 배포되지는 않는다.
- [x] 관리페이지 연동 `PROD_ABC_*` 4개를 제외한 운영 Secrets와 Variables 등록을 완료했다. 사용자 보고 기준이며 실제 값·권한 검증은 아직 수행하지 않았다.
- [ ] 관리페이지 서버 확정 후 `PROD_ABC_API_KEY`, `PROD_ABC_API_BASE_URL`, `PROD_ABC_PROJECT_ID`, `PROD_ABC_CHANNEL_ID`를 등록한다.
- [x] 작성페이지 저장소의 `PROD_ABC_API_KEY` 등록을 완료했다. 사용자 보고 기준이며 실제 키 검증은 workflow에서 수행한다.
- [x] 운영 EGBIM 프로젝트·Q&A 채널 UUID를 사용자에게 확인하고 운영 기본 설정에 반영했다.
- [x] 운영 SSO callback과 관련 변수 등록을 완료했다. 사용자 완료 보고 기준이다.
- [ ] ABC project/channel과 조직 API의 실제 접근 권한을 확인한다.
- [ ] 이 변경을 `main`에 반영한다.
@@ -98,7 +108,7 @@ Secret 파일은 임시 디렉터리에 접근 권한 `0600`으로 만들고 배
## 실패와 롤백
- 설정·조직 API·빌드 검증 실패는 운영 정적 파일 업로드와 Worker 배포 전에 중단된다.
- 설정·조직 API·ABC 연결·빌드 검증 실패는 운영 정적 파일 업로드와 Worker 배포 전에 중단된다.
- 정적 파일 업로드가 실패해도 기존 Worker는 이전 커밋 경로를 계속 읽는다. 다시 실행하면 같은 커밋 경로로 업로드한다.
- Worker 배포 후 HTTP 확인이 실패하면 workflow는 실패로 표시되지만 이미 반영한 배포를 자동으로 되돌리지는 않는다. 마지막 정상 Worker 버전으로 명시적으로 롤백한다.
- Cloudflare의 `baron-qa-gateway-prod` **Deployments**에서 마지막 정상 버전으로 rollback하면 해당 버전의 `QA_ASSET_PREFIX`와 정적 파일을 다시 사용한다. 이전 release 경로는 보관한다.
+12
View File
@@ -501,6 +501,18 @@
console.error('[QA] SSO session load failed:', error);
return getAuthUser();
}).then(function (user) { renderAuth(user); return user; });
const accountMenu = qs('#accountMenu');
if (accountMenu) {
document.addEventListener('click', function (event) {
if (!accountMenu.contains(event.target)) accountMenu.open = false;
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && accountMenu.open) {
accountMenu.open = false;
qs('#accountButton').focus();
}
});
}
const menuButton = qs('.menu-button');
const nav = qs('.global-nav');
if (menuButton && nav) {
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>S/W 문의 | 바론컨설턴트</title><script src="../assets/inquiry/entry.js?v=20261102-ui-1" defer></script></head><body><p>문의 화면으로 이동합니다. <a href="./">문의 목록</a></p></body></html>
<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>S/W Q&A | 바론컨설턴트</title><script src="../assets/inquiry/entry.js?v=20261102-ui-1" defer></script></head><body><p>문의 화면으로 이동합니다. <a href="./">문의 목록</a></p></body></html>
+14 -5
View File
@@ -1,9 +1,18 @@
<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="EG-BIM 소프트웨어 문의"><title>S/W 문의 | 바론컨설턴트</title><link rel="stylesheet" href="../assets/styles.css?v=20261102-ui-1"><link rel="stylesheet" href="../assets/inquiry/inquiry.css?v=20261102-ui-1"></head><body>
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="EG-BIM 소프트웨어 문의"><title>S/W Q&A | 바론컨설턴트</title><link rel="stylesheet" href="../assets/styles.css?v=20261102-ui-1"><link rel="stylesheet" href="../assets/inquiry/inquiry.css?v=20261102-ui-3"></head><body>
<main class="inquiry-page">
<section class="inquiry-hero" aria-labelledby="inquiry-title">
<details class="inquiry-account" id="accountMenu">
<summary aria-label="내 계정" id="accountButton" aria-controls="accountPanel">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/></svg>
</summary>
<div class="inquiry-account__panel" id="accountPanel">
<p data-auth-name>로그인 확인 중</p>
<a href="/auth/logout" id="logoutLink"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 4H4v16h6M14 8l4 4-4 4m-5-4h12"/></svg>로그아웃</a>
</div>
</details>
<div class="inquiry-hero__copy">
<h1 id="inquiry-title">S/W 문의</h1>
<h1 id="inquiry-title">S/W Q&A</h1>
<p>소프트웨어 관련 문의하기</p>
</div>
</section>
@@ -15,11 +24,11 @@
<span class="inquiry-tab-description">BIM 모델</span>
<picture class="inquiry-tab-logo"><source media="(max-width: 600px)" srcset="../assets/inquiry/egbim-symbol.svg" width="49" height="25" /><img src="../assets/inquiry/egbim.svg" alt="EG-BIM" width="150" height="26" /></picture>
</button>
<button type="button" role="tab" id="tab-tova" aria-controls="inquiry-panel" aria-selected="false" aria-disabled="true" disabled tabindex="-1" data-service="tova" aria-label="TOVA 문의">
<button type="button" role="tab" id="tab-tova" aria-controls="inquiry-panel" aria-selected="false" aria-disabled="true" disabled hidden tabindex="-1" data-service="tova" aria-label="TOVA 문의">
<span class="inquiry-tab-description">통합 교통 수요분석 <span class="inquiry-coming-soon">준비중</span></span>
<span class="inquiry-tab-logo"><img src="../assets/inquiry/tova.svg" alt="TOVA" width="104" height="26" /></span>
</button>
<button type="button" role="tab" id="tab-gaia" aria-controls="inquiry-panel" aria-selected="false" aria-disabled="true" disabled tabindex="-1" data-service="gaia" aria-label="GAIA 문의">
<button type="button" role="tab" id="tab-gaia" aria-controls="inquiry-panel" aria-selected="false" aria-disabled="true" disabled hidden tabindex="-1" data-service="gaia" aria-label="GAIA 문의">
<span class="inquiry-tab-description">지반조사 <span class="inquiry-coming-soon">준비중</span></span>
<span class="inquiry-tab-logo"><img src="../assets/inquiry/gaia.svg" alt="GAIA" width="82" height="33" /></span>
</button>
@@ -204,5 +213,5 @@
</div>
</section>
</template>
<div id="toast" class="toast" role="status" aria-live="polite"></div><script src="config.js?v=20260921-3"></script><script src="app.js?v=20261102-ui-1"></script>
<div id="toast" class="toast" role="status" aria-live="polite"></div><script src="config.js?v=20260921-3"></script><script src="app.js?v=20261102-ui-3"></script>
</body></html>
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>S/W 문의 | 바론컨설턴트</title><script src="../assets/inquiry/entry.js?v=20261102-ui-1" defer></script></head><body><p>문의 화면으로 이동합니다. <a href="./">문의 목록</a></p></body></html>
<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>S/W Q&A | 바론컨설턴트</title><script src="../assets/inquiry/entry.js?v=20261102-ui-1" defer></script></head><body><p>문의 화면으로 이동합니다. <a href="./">문의 목록</a></p></body></html>
+3 -2
View File
@@ -6,11 +6,12 @@
"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 && node --check assets/inquiry/entry.js && node --check scripts/verify-org-context.mjs && node --check scripts/production-settings.mjs && node --check scripts/deploy-production.mjs && node --check scripts/verify-production.mjs && bash -n scripts/upload-r2.sh",
"check": "node --check src/index.js && node --check src/org-context.js && node --check egbim/app.js && node --check assets/inquiry/entry.js && node --check scripts/verify-org-context.mjs && node --check scripts/production-settings.mjs && node --check scripts/deploy-production.mjs && node --check scripts/verify-production.mjs && bash -n scripts/upload-r2.sh && node --check scripts/verify-abc.mjs",
"test": "node --test --test-isolation=none tests/*.test.js",
"test:ui": "node tests/browser/inquiry-ui.mjs",
"org:verify": "node scripts/verify-org-context.mjs",
"production:verify": "node scripts/verify-production.mjs"
"production:verify": "node scripts/verify-production.mjs",
"abc:verify": "node scripts/verify-abc.mjs"
},
"devDependencies": {
"wrangler": "4.134.0"
+3 -1
View File
@@ -17,7 +17,9 @@ 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 }));
console.log(JSON.stringify({ worker: settings.worker, bucket: settings.bucket, origin: settings.origin,
api: settings.vars.ABC_API_BASE_URL, authorize: settings.vars.AUTH_AUTHORIZE_URL,
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);
+13 -9
View File
@@ -1,8 +1,9 @@
import { spawn } from 'node:child_process';
import { deploymentOrgSettings } from './org-context-settings.mjs';
import { stagingSettings } from './staging-settings.mjs';
const worker = 'baron-qa-gateway-test';
const dryRun = process.argv.includes('--dry-run');
const validateOnly = process.argv.includes('--validate-only');
function execute(args, input) {
return new Promise((resolve, reject) => {
const child = spawn('npx', ['wrangler', ...args], {
@@ -26,13 +27,16 @@ function execute(args, input) {
});
}
try {
const { vars, secrets } = deploymentOrgSettings(process.env);
if (!dryRun && Object.keys(secrets).length) await execute(['secret', 'bulk', '--name', worker], JSON.stringify(secrets));
const args = ['deploy', '--name', worker];
if (dryRun) args.push('--dry-run');
for (const [name, value] of Object.entries(vars)) args.push('--var', name + ':' + value);
await execute(args);
} catch {
console.error('Worker deployment failed. Check configuration and preceding Wrangler diagnostics.');
if (process.argv.slice(2).some((option) => !['--dry-run', '--validate-only'].includes(option))) throw new Error('Unknown staging deployment option');
const { vars, secrets } = stagingSettings(process.env);
if (!validateOnly) {
if (!dryRun && Object.keys(secrets).length) await execute(['secret', 'bulk', '--name', worker], JSON.stringify(secrets));
const args = ['deploy', '--config', 'wrangler.toml', '--name', worker];
if (dryRun) args.push('--dry-run');
for (const [name, value] of Object.entries(vars)) args.push('--var', name + ':' + value);
await execute(args);
}
} catch (error) {
console.error(error.message || 'Worker deployment failed. Check configuration and preceding Wrangler diagnostics.');
process.exitCode = 1;
}
+33 -5
View File
@@ -5,7 +5,12 @@ export const productionTarget = Object.freeze({
worker: 'baron-qa-gateway-prod',
config: 'wrangler.production.toml',
bucket: 'baron-qa-write',
origin: 'https://qa.baroncs.co.kr'
origin: 'https://qa.baroncs.co.kr',
apiOrigin: 'https://qa.brsw.kr',
projectId: '01a1c543-ab4f-744f-8eff-ab85f2644271',
channelId: '01a1c544-35ac-7674-9bf0-2469bfbe3e52',
authIssuer: 'https://app.brsw.kr/oidc',
orgContextApiUrl: 'https://app.brsw.kr/api/v1/integrations/org-context'
});
function required(env, name) {
@@ -23,6 +28,15 @@ function httpsBase(value, name) {
return url.origin;
}
function httpsIssuer(value) {
let url;
try { url = new URL(value); } catch { throw new Error('AUTH_ISSUER must be an HTTPS issuer URL'); }
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
throw new Error('AUTH_ISSUER must be an HTTPS issuer URL');
}
return url.origin + url.pathname.replace(/\/+$/, '');
}
export function productionSettings(env) {
// These names are mapped exclusively from PROD_* settings in the production workflow.
required(env, 'CLOUDFLARE_API_TOKEN');
@@ -34,20 +48,34 @@ export function productionSettings(env) {
};
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);
const organization = deploymentOrgSettings({
...env, ORG_CONTEXT_API_URL: env.ORG_CONTEXT_API_URL || productionTarget.orgContextApiUrl
});
orgContextConfig({ ...organization.vars, ...secrets });
const issuer = httpsIssuer(env.AUTH_ISSUER || productionTarget.authIssuer);
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')
AUTH_AUTHORIZE_URL: issuer + '/oauth2/auth',
AUTH_TOKEN_URL: issuer + '/oauth2/token',
AUTH_USERINFO_URL: issuer + '/userinfo',
ABC_API_BASE_URL: httpsBase(env.ABC_API_BASE_URL || productionTarget.apiOrigin, 'ABC_API_BASE_URL'),
ABC_PROJECT_ID: String(env.ABC_PROJECT_ID || productionTarget.projectId).trim(),
ABC_CHANNEL_ID: String(env.ABC_CHANNEL_ID || productionTarget.channelId).trim()
};
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');
}
// Fail before uploads if a PROD_* variable points at staging or another channel.
for (const [name, expected] of Object.entries({
ABC_API_BASE_URL: productionTarget.apiOrigin,
ABC_PROJECT_ID: productionTarget.projectId,
ABC_CHANNEL_ID: productionTarget.channelId
})) {
if (vars[name] !== expected) throw new Error(name + ' must match the production EGBIM Q&A target: ' + expected);
}
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;
+36
View File
@@ -0,0 +1,36 @@
import { deploymentOrgSettings } from './org-context-settings.mjs';
function required(env, name) {
const value = String(env[name] || '').trim();
if (!value) throw new Error('STG_' + name + ' is required for staging');
return value;
}
function httpsUrl(value, name, originOnly = false) {
let url;
try { url = new URL(value); } catch { throw new Error('STG_' + name + ' must be an HTTPS URL'); }
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || (originOnly && url.pathname !== '/')) {
throw new Error('STG_' + name + ' must be an HTTPS ' + (originOnly ? 'origin' : 'issuer URL'));
}
return originOnly ? url.origin : url.origin + url.pathname.replace(/\/+$/, '');
}
export function stagingSettings(env) {
const organization = deploymentOrgSettings({ ...env, ORG_CONTEXT_ENABLED: required(env, 'ORG_CONTEXT_ENABLED') });
const issuer = httpsUrl(required(env, 'AUTH_ISSUER'), 'AUTH_ISSUER');
const vars = {
...organization.vars,
AUTH_CLIENT_ID: required(env, 'AUTH_CLIENT_ID'),
AUTH_AUTHORIZE_URL: issuer + '/oauth2/auth',
AUTH_TOKEN_URL: issuer + '/oauth2/token',
AUTH_USERINFO_URL: issuer + '/userinfo',
ABC_API_BASE_URL: httpsUrl(required(env, 'ABC_API_BASE_URL'), 'ABC_API_BASE_URL', true),
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('STG_' + name + ' must be a UUID');
}
return { vars, secrets: organization.secrets };
}
+37
View File
@@ -0,0 +1,37 @@
import { pathToFileURL } from 'node:url';
import { productionSettings } from './production-settings.mjs';
export async function verifyAbc(env = process.env, fetchImpl = fetch) {
const { vars, secrets } = productionSettings(env);
const endpoint = vars.ABC_API_BASE_URL + '/api/projects/' + encodeURIComponent(vars.ABC_PROJECT_ID)
+ '/channels/' + encodeURIComponent(vars.ABC_CHANNEL_ID) + '/fields';
let response;
try {
response = await fetchImpl(endpoint, {
method: 'GET', redirect: 'error', signal: AbortSignal.timeout(15000),
headers: { accept: 'application/json', 'x-api-key': secrets.ABC_API_KEY }
});
} catch {
throw new Error('Production ABC API request failed; check PROD_ABC_API_BASE_URL, HTTPS and connectivity');
}
if (!response.ok) {
throw new Error('Production ABC API returned HTTP ' + response.status
+ '; check PROD_ABC_API_KEY, PROD_ABC_PROJECT_ID and PROD_ABC_CHANNEL_ID');
}
let body;
try { body = await response.json(); } catch { throw new Error('Production ABC fields response is not JSON'); }
const fields = Array.isArray(body) ? body : body?.items || body?.data?.items || body?.data;
if (!Array.isArray(fields)) throw new Error('Production ABC fields response is not a field list');
const keys = new Set(fields.map((field) => field.key));
const missing = ['title', 'contents', 'Category'].filter((key) => !keys.has(key));
if (missing.length) throw new Error('Production Q&A channel is missing required fields: ' + missing.join(', '));
const categories = new Set((fields.find((field) => field.key === 'Category').options || []).map((option) => option.key));
const missingCategories = ['ERROR_QNA', 'IMPROVEMENT_QNA', 'GENERAL_QNA'].filter((key) => !categories.has(key));
if (missingCategories.length) throw new Error('Production Q&A channel is missing category options: ' + missingCategories.join(', '));
console.log('Production ABC API verified: ' + vars.ABC_API_BASE_URL + ', project ' + vars.ABC_PROJECT_ID
+ ', channel ' + vars.ABC_CHANNEL_ID + '. No feedback data was changed.');
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try { await verifyAbc(); } catch (error) { console.error(error.message); process.exitCode = 1; }
}
+41 -1
View File
@@ -3,16 +3,56 @@ import { deploymentOrgSettings } from './org-context-settings.mjs';
import { setTimeout as wait } from 'node:timers/promises';
import { pathToFileURL } from 'node:url';
// Only documented machine codes and enum values may be copied from an upstream error.
const accessErrorCodes = new Set([
'credential_operation_not_available', 'credential_operation_mismatch',
'credential_scope_exceeded', 'credential_exposure_exceeded',
'integration_credential_not_allowed', 'integration_credential_required',
'invalid_integration_credential', 'integration_credential_expired', 'credential_conflict'
]);
const exposures = new Set(['none', 'structure', 'members', 'identifiers']);
async function accessResponseDiagnostic(response) {
const type = response.headers.get('content-type') || '';
const responseKind = /json/i.test(type) ? 'json' : /html/i.test(type) ? 'html' : 'other';
let body;
if (responseKind === 'json') {
try { body = await response.clone().json(); } catch {}
}
const code = body?.code || body?.error;
return {
responseKind,
upstreamCode: accessErrorCodes.has(code) ? code : 'unrecognized',
grantedExposure: exposures.has(body?.details?.grantedExposure) ? body.details.grantedExposure : undefined,
requiredExposure: exposures.has(body?.details?.requiredExposure) ? body.details.requiredExposure : undefined,
proxyChallenge: response.headers.get('cf-mitigated') === 'challenge'
};
}
export async function verifyOrganization(env = process.env, { fetchImpl = fetch, pause = wait, log = console.log } = {}) {
const { vars, secrets } = deploymentOrgSettings(env);
if (vars.ORG_CONTEXT_ENABLED !== 'true') {
log('Organization integration disabled; existing behavior retained.');
return;
}
const checkedFetch = async (url, options) => {
const response = await fetchImpl(url, options);
if (response.status === 401 || response.status === 403) {
const request = new URL(url);
log(JSON.stringify({
status: 'organization_access_denied', httpStatus: response.status,
endpoint: request.origin + request.pathname,
tenantId: request.searchParams.get('tenantId'),
operation: 'baron.org-context.read', exposure: 'identifiers',
...await accessResponseDiagnostic(response)
}));
}
return response;
};
for (let attempt = 1; attempt <= 3; attempt++) {
try {
// CI can wait for a slow upstream without extending the page request timeout.
const directory = await getOrgDirectory({ ...vars, ...secrets }, { fetchImpl, timeoutMs: 15000 });
const directory = await getOrgDirectory({ ...vars, ...secrets }, { fetchImpl: checkedFetch, timeoutMs: 15000 });
const summary = {
status: 'verified', rootCount: JSON.parse(vars.ORG_CONTEXT_TENANT_IDS).length,
tenantCount: directory.tenantCount, userCount: directory.byUser.size,
+7 -2
View File
@@ -766,6 +766,11 @@ function isUuidv7(value) {
function logout(request, env) {
const url = new URL(request.url);
const target = safeReturnUrl(env.AUTH_POST_LOGOUT_REDIRECT_URI || '/', url.origin);
return redirect(new URL(target, url.origin).toString(), { 'set-cookie': cookie(env.SESSION_COOKIE_NAME, '', 0, { delete: true }) });
let target = new URL('/', url.origin);
try {
// External destinations come only from trusted deployment config, never request parameters.
const configured = new URL(env.AUTH_POST_LOGOUT_REDIRECT_URI || '/', url.origin);
if ((configured.protocol === 'https:' || configured.origin === url.origin) && !configured.username && !configured.password) target = configured;
} catch (error) {}
return redirect(target.toString(), { 'set-cookie': cookie(env.SESSION_COOKIE_NAME, '', 0, { delete: true }) });
}
+49
View File
@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { verifyAbc } from '../scripts/verify-abc.mjs';
const env = {
CLOUDFLARE_API_TOKEN: 'fixture-cloudflare-token', SESSION_SECRET: 'fixture-session-'.repeat(3),
ABC_API_KEY: 'fixture-project-key', AUTH_CLIENT_ID: 'fixture-public-pkce-client',
ABC_PROJECT_ID: '01a1c543-ab4f-744f-8eff-ab85f2644271', ABC_CHANNEL_ID: '01a1c544-35ac-7674-9bf0-2469bfbe3e52',
ORG_CONTEXT_ENABLED: 'true', ORG_CONTEXT_TENANT_IDS: '["038326b6-954a-48a7-a85f-efd83f62b82a"]',
ORG_CONTEXT_KEY_ID: 'fixture-key-id', ORG_CONTEXT_KEY_SECRET: 'fixture-key-secret',
DEPLOY_COMMIT_SHA: 'a'.repeat(40)
};
const fields = [{ key: 'title' }, { key: 'contents' }, {
key: 'Category', options: ['ERROR_QNA', 'IMPROVEMENT_QNA', 'GENERAL_QNA'].map((key) => ({ key }))
}];
test('ABC connection verification reads the configured production channel without writing data', async () => {
await verifyAbc(env, async (url, options) => {
assert.equal(url, 'https://qa.brsw.kr/api/projects/' + env.ABC_PROJECT_ID + '/channels/' + env.ABC_CHANNEL_ID + '/fields');
assert.equal(options.method, 'GET');
assert.equal(options.body, undefined);
assert.equal(options.redirect, 'error');
assert.equal(options.headers['x-api-key'], env.ABC_API_KEY);
return Response.json(fields);
});
});
test('ABC connection verification blocks wrong production targets before sending credentials', async () => {
let requests = 0;
for (const override of [
{ ABC_API_BASE_URL: 'https://feedback.hmac.kr' },
{ ABC_CHANNEL_ID: '01900000-0000-7000-8000-000000000002' }
]) {
await assert.rejects(verifyAbc({ ...env, ...override }, async () => { requests++; }), /must match the production EGBIM Q&A target/);
}
assert.equal(requests, 0);
});
test('ABC connection verification rejects wrong keys and channel schemas without printing credentials', async () => {
for (const status of [401, 403, 404]) {
await assert.rejects(verifyAbc(env, async () => new Response('', { status })), new RegExp('HTTP ' + status));
}
await assert.rejects(verifyAbc(env, async () => Response.json([{ key: 'title' }])), /contents, Category/);
await assert.rejects(verifyAbc(env, async () => Response.json(fields.map((field) => ({ ...field, options: [] })))), /category options/);
await assert.rejects(verifyAbc(env, async () => { throw new Error(env.ABC_API_KEY); }), (error) => {
assert.ok(!error.message.includes(env.ABC_API_KEY));
return true;
});
});
+28 -1
View File
@@ -6,6 +6,7 @@ import {createRequire} from 'node:module';
import {fileURLToPath} from 'node:url';
import {join} from 'node:path';
import {tmpdir} from 'node:os';
import worker from '../../src/index.js';
const require = createRequire(import.meta.url);
const {chromium} = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const root = fileURLToPath(new URL('../../', import.meta.url)).replace(/\/$/, '');
@@ -17,7 +18,12 @@ await mkdir(images,{recursive:true});
const errors = [];
try {
const context = await browser.newContext({viewport:{width:1900,height:1000}});
await context.addCookies([{name:'fixture_session',value:'existing-session',url:'https://qa.example.test',httpOnly:true,secure:true}]);
const page = await context.newPage();page.setDefaultTimeout(8000);page.setDefaultNavigationTimeout(8000);
// Redirect continuations bypass Playwright route handlers; block the real destinations in Chromium.
const network = await context.newCDPSession(page);
await network.send('Network.enable');
await network.send('Network.setBlockedURLs',{urls:['https://test.baroncs.co.kr/*','https://sso.hmac.kr/*']});
page.on('pageerror', error => errors.push(error.message));
const records = Array.from({length:15},(_,i)=>({id:id(i+1),requester_id:OWNER,requester_name:'송대일',requester_affiliation:'총괄기획&기술개발센터',requester_department:'IS3',title:['PDF 출력 시 이미지 표시 관련 문의','EG-BIM 로그인 오류 문의','모델링 기능 개선 요청'][i%3] + (i>2?' '+(i+1):''),contents:'EG-BIM 이용 중 궁금한 사항을 문의합니다.\n\n이 영역에 문의 내용과 첨부파일, 답변이 표시됩니다.',Category:['ERROR_QNA','GENERAL_QNA','IMPROVEMENT_QNA'][i%3],created_at:'2026-11-02T01:14:00Z',feedback_status:['NEW','RECEIVED','IN_PROGRESS','PENDING','COMPLETED'][i%5],is_secret:i===0,attachments:i===0?[{url:'/fixture/manual.pdf',original_file_name:'화면 설명.pdf'}]:[]}));
const comments = new Map(records.map(r=>[r.id,[{id:id(101),author_id:OWNER,author_name:'송대일',content:'확인 부탁드립니다.',created_at:'2026-11-02T02:00:00Z'},{id:id(102),author_id:'other',author_name:'고객지원',content:'접수되었습니다.',created_at:'2026-11-02T02:15:00Z'}]]));
@@ -26,6 +32,10 @@ try {
await page.route('**/*',async route=>{
const request = route.request(); const url = new URL(request.url());
if(url.origin!=='https://qa.example.test') return route.abort();
if(url.pathname==='/auth/logout') {
const response=await worker.fetch(new Request(url),{SESSION_COOKIE_NAME:'fixture_session',AUTH_POST_LOGOUT_REDIRECT_URI:'https://test.baroncs.co.kr/ko/sw-inquiry.html'});
return route.fulfill({status:response.status,headers:Object.fromEntries(response.headers),body:''});
}
const send = (body,status=200)=>route.fulfill({status,contentType:'application/json',body:JSON.stringify(body)});
if(url.pathname==='/auth/session') return send({authenticated:true,user:{requesterId:OWNER,userUuid:OWNER,tenantId:id(555),name:'송대일',email:'fixture@example.test',company:'총괄기획&기술개발센터',department:'IS3'}});
if(url.pathname.startsWith('/api/')) {
@@ -63,7 +73,16 @@ try {
const decide = accept => page.once('dialog',d=>accept?d.accept():d.dismiss());
await page.goto('https://qa.example.test/egbim/');
await page.waitForFunction(()=>document.querySelector('#resultCount').textContent==='총 15건');
assert.equal(await page.locator('#tab-tova').isDisabled(),true);assert.equal(await page.locator('.inquiry-remote').getAttribute('href'),'https://939.co.kr/saman/');
assert.equal(await page.locator('#tab-tova').isVisible(),false);assert.equal(await page.locator('#tab-gaia').isVisible(),false);
assert.equal(await page.locator('#tab-egbim').evaluate(e=>Math.abs(e.getBoundingClientRect().x+e.getBoundingClientRect().width/2-innerWidth/2)<1),true);assert.equal(await page.locator('.inquiry-remote').getAttribute('href'),'https://939.co.kr/saman/');
await page.locator('#accountButton').click();
assert.equal(await page.locator('#logoutLink').isVisible(),true);
assert.equal(await page.locator('#accountPanel [data-auth-name]').textContent(),'송대일');
await page.keyboard.press('Escape');
assert.equal(await page.locator('#logoutLink').isVisible(),false);
assert.equal(await page.locator('#accountButton').evaluate(e=>e===document.activeElement),true);
await page.locator('#accountButton').click();await page.locator('#inquiry-title').click();
assert.equal(await page.locator('#logoutLink').isVisible(),false);
await page.screenshot({path:images+'/desktop-list.png',fullPage:true});
await page.locator('#query').fill('로그임');await page.locator('#searchForm').evaluate(f=>f.requestSubmit());assert.equal(await page.locator('#qaRows tr').count(),5);
await page.locator('#qaRows tr a').first().click();await page.waitForSelector('#detailModal[open]');await close();assert.equal(await page.locator('#query').inputValue(),'로그임');
@@ -107,6 +126,7 @@ try {
await page.locator('#writeButton').click();await page.locator('#category').selectOption('오류문의');await page.locator('#title').fill('JSON 등록');await page.locator('#content').fill('첨부 없이 등록');await page.locator('#qaForm').evaluate(f=>f.requestSubmit());await page.waitForFunction(()=>document.querySelector('[data-detail-title]')?.textContent==='JSON 등록');assert.match(mutations.at(-1).type,/application\/json/);assert.equal(mutations.filter(m=>m.url==='/api/feedbacks'&&m.method==='POST').length,3);await close();
await page.setViewportSize({width:390,height:844});await page.evaluate(()=>scrollTo(0,0));await page.screenshot({path:images+'/mobile-list.png',fullPage:true});
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);
assert.equal(await page.locator('#tab-egbim').evaluate(e=>Math.abs(e.getBoundingClientRect().x+e.getBoundingClientRect().width/2-innerWidth/2)<1),true);
await page.locator('#writeButton').click();assert.equal(await page.locator('.inquiry-form-grid').evaluate(e=>getComputedStyle(e).gridTemplateColumns.split(' ').length),1);await page.screenshot({path:images+'/mobile-write.png'});
assert.equal(await page.locator('#composeModal').evaluate(d=>d.scrollHeight>d.clientHeight),true);await close();
await page.locator('#qaRows tr a').first().evaluate(a=>a.click());await page.waitForSelector('#commentForm');await page.screenshot({path:images+'/mobile-detail.png'});assert.equal(await page.locator('#detailModal').evaluate(d=>d.getBoundingClientRect().right<=innerWidth),true);await close();
@@ -115,6 +135,13 @@ try {
assert.equal(await page.locator('#editButton').isVisible(),false);
assert.equal(await page.locator('#commentForm').isVisible(),false);
await close();
await page.locator('#accountButton').click();
assert.equal(await page.locator('#accountPanel').evaluate(e=>e.getBoundingClientRect().right<=innerWidth&&e.getBoundingClientRect().left>=0),true);
await page.screenshot({path:images+'/mobile-account.png'});
const logoutDestination = page.waitForRequest('https://test.baroncs.co.kr/ko/sw-inquiry.html');
await page.locator('#logoutLink').click({noWaitAfter:true});
await logoutDestination;
assert.equal((await context.cookies('https://qa.example.test')).some(c=>c.name==='fixture_session'),false);
assert.deepEqual(errors,[]);console.log(JSON.stringify({result:'PASS',mutations:mutations.length,screenshots:images}));await context.close();
for (const scenario of ['owner', 'other', 'same-name-no-id', 'anonymous-with-stale-user', 'session-unavailable', 'delayed-session']) {
const context = await browser.newContext();
+78
View File
@@ -84,3 +84,81 @@ test('disabled verification makes no upstream requests', async () => {
fetchImpl: async () => assert.fail('disabled lookup'), log() {}
});
});
for (const status of [401, 403]) {
test(`HTTP ${status} diagnostics identify the requested tenant without exposing credentials or upstream data`, async () => {
const env = settings();
const logs = [];
await assert.rejects(verifyOrganization(env, {
fetchImpl: async () => response({ error: 'private-upstream-message', name: 'private-user', phone: '+8201000000000' }, status),
log: line => logs.push(line), pause: async () => assert.fail('must not retry access denial')
}), new RegExp('org_context_http_' + status));
const diagnostic = JSON.parse(logs[0]);
assert.equal(diagnostic.httpStatus, status);
assert.equal(diagnostic.endpoint, 'https://sadmin.hmac.kr/api/v1/integrations/org-context');
assert.equal(diagnostic.tenantId, ROOT);
assert.equal(diagnostic.operation, 'baron.org-context.read');
assert.equal(diagnostic.exposure, 'identifiers');
for (const hidden of [env.ORG_CONTEXT_KEY_ID, env.ORG_CONTEXT_KEY_SECRET, 'private-upstream-message', 'private-user', '+8201000000000']) {
assert.ok(!JSON.stringify(logs).includes(hidden));
}
});
}
test('a partial directory fails and diagnostics point to the forbidden root instead of a permitted root', async () => {
const deniedRoot = '9951229e-eea5-4376-96f4-78989bc3a506';
const logs = [];
await assert.rejects(verifyOrganization({ ...settings(), ORG_CONTEXT_TENANT_IDS: JSON.stringify([ROOT, deniedRoot]) }, {
fetchImpl: async url => new URL(url).searchParams.get('tenantId') === deniedRoot ? response({}, 403) : response(directory()),
log: line => logs.push(line), pause: async () => assert.fail('must not retry access denial')
}), /org_context_http_403/);
assert.deepEqual(logs.map(line => JSON.parse(line).tenantId), [deniedRoot]);
});
for (const code of ['credential_operation_mismatch', 'credential_scope_exceeded', 'credential_exposure_exceeded']) {
test(`access diagnostics preserve the documented ${code} reason while excluding upstream messages`, async () => {
const logs = [];
await assert.rejects(verifyOrganization(settings(), {
fetchImpl: async () => response({ code, message: 'private-server-message', details: {
grantedExposure: 'members', requiredExposure: 'identifiers', secret: 'private-value'
} }, 403),
log: line => logs.push(line)
}), /org_context_http_403/);
const diagnostic = JSON.parse(logs[0]);
assert.equal(diagnostic.upstreamCode, code);
assert.equal(diagnostic.grantedExposure, 'members');
assert.equal(diagnostic.requiredExposure, 'identifiers');
assert.ok(!logs[0].includes('private-server-message'));
assert.ok(!logs[0].includes('private-value'));
});
}
test('unrecognized codes and invalid enum values cannot expose private upstream strings', async () => {
const logs = [];
await assert.rejects(verifyOrganization(settings(), {
fetchImpl: async () => response({ code: 'private-code', details: {
grantedExposure: 'private-granted', requiredExposure: 'private-required'
} }, 403),
log: line => logs.push(line)
}), /org_context_http_403/);
const diagnostic = JSON.parse(logs[0]);
assert.equal(diagnostic.upstreamCode, 'unrecognized');
assert.equal(diagnostic.grantedExposure, undefined);
assert.equal(diagnostic.requiredExposure, undefined);
assert.ok(!logs[0].includes('private-'));
});
test('HTML proxy challenges are distinguishable without logging HTML or bypassing the failure', async () => {
const logs = [];
await assert.rejects(verifyOrganization(settings(), {
fetchImpl: async () => new Response('<html>private-challenge-body</html>', {
status: 403, headers: { 'content-type': 'text/html', 'cf-mitigated': 'challenge' }
}),
log: line => logs.push(line)
}), /org_context_http_403/);
const diagnostic = JSON.parse(logs[0]);
assert.equal(diagnostic.responseKind, 'html');
assert.equal(diagnostic.proxyChallenge, true);
assert.equal(diagnostic.upstreamCode, 'unrecognized');
assert.ok(!logs[0].includes('private-challenge-body'));
});
+41 -4
View File
@@ -12,8 +12,8 @@ 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',
ABC_API_KEY: 'fixture-abc-key', AUTH_CLIENT_ID: 'production-client', ABC_API_BASE_URL: 'https://qa.brsw.kr/',
ABC_PROJECT_ID: '01a1c543-ab4f-744f-8eff-ab85f2644271', ABC_CHANNEL_ID: '01a1c544-35ac-7674-9bf0-2469bfbe3e52',
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
});
@@ -24,8 +24,9 @@ test('production preserves dashboard-managed domains without requesting zone rou
assert.match(config, /^workers_dev\s*=\s*false\s*$/m);
});
test('production requires its own credentials, target IDs, organization lookup and immutable release', () => {
test('production requires its own credentials, organization lookup and immutable release', () => {
for (const name of Object.keys(fixture())) {
if (['ABC_API_BASE_URL', 'ABC_PROJECT_ID', 'ABC_CHANNEL_ID'].includes(name)) continue;
const env = fixture();
delete env[name];
assert.throws(() => productionSettings(env), new RegExp(name === 'ORG_CONTEXT_TENANT_IDS' ? 'org_context_invalid_tenants' : name));
@@ -38,12 +39,48 @@ test('production requires its own credentials, target IDs, organization lookup a
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.ABC_API_BASE_URL, 'https://qa.brsw.kr');
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);
});
test('production defaults connect the writing Worker to the production API and SSO', () => {
const env = fixture();
delete env.ABC_API_BASE_URL;
delete env.ABC_PROJECT_ID;
delete env.ABC_CHANNEL_ID;
const settings = productionSettings(env);
assert.equal(settings.vars.ABC_API_BASE_URL, 'https://qa.brsw.kr');
assert.equal(settings.vars.ABC_PROJECT_ID, '01a1c543-ab4f-744f-8eff-ab85f2644271');
assert.equal(settings.vars.ABC_CHANNEL_ID, '01a1c544-35ac-7674-9bf0-2469bfbe3e52');
assert.equal(settings.vars.AUTH_AUTHORIZE_URL, 'https://app.brsw.kr/oidc/oauth2/auth');
assert.equal(settings.vars.AUTH_TOKEN_URL, 'https://app.brsw.kr/oidc/oauth2/token');
assert.equal(settings.vars.AUTH_USERINFO_URL, 'https://app.brsw.kr/oidc/userinfo');
assert.equal(settings.vars.ORG_CONTEXT_API_URL, 'https://app.brsw.kr/api/v1/integrations/org-context');
const custom = productionSettings({ ...env, AUTH_ISSUER: 'https://identity.example.test/oidc/' });
assert.equal(custom.vars.AUTH_AUTHORIZE_URL, 'https://identity.example.test/oidc/oauth2/auth');
assert.throws(() => productionSettings({ ...env, AUTH_ISSUER: 'http://identity.example.test' }), /AUTH_ISSUER/);
});
test('production rejects staging API origins and other project or channel IDs', () => {
for (const override of [
{ ABC_API_BASE_URL: 'https://feedback.hmac.kr' },
{ ABC_API_BASE_URL: 'https://qa-test.baroncs.co.kr' },
{ ABC_PROJECT_ID: '01900000-0000-7000-8000-000000000001' },
{ ABC_CHANNEL_ID: '01900000-0000-7000-8000-000000000002' }
]) {
assert.throws(() => productionSettings({ ...fixture(), ...override }), /must match the production EGBIM Q&A target/);
withFakeWrangler(({ env, capture }) => {
const result = spawnSync(process.execPath, [fileURLToPath(new URL('../scripts/deploy-production.mjs', import.meta.url))], {
env: { ...env, ...override }, encoding: 'utf8'
});
assert.equal(result.status, 1, result.stderr);
assert.equal(existsSync(capture), false);
});
}
});
function withFakeWrangler(body) {
const dir = mkdtempSync(join(tmpdir(), 'qa-deploy-test-'));
const capture = join(dir, 'commands.jsonl');
+72
View File
@@ -0,0 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, 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 { stagingSettings } from '../scripts/staging-settings.mjs';
const fixture = () => ({
AUTH_CLIENT_ID: 'staging-client', AUTH_ISSUER: 'https://identity.staging.example/oidc/',
ABC_API_BASE_URL: 'https://feedback.staging.example/',
ABC_PROJECT_ID: '01900000-0000-7000-8000-000000000001',
ABC_CHANNEL_ID: '01900000-0000-7000-8000-000000000002',
ORG_CONTEXT_ENABLED: 'false',
PROD_AUTH_CLIENT_ID: 'production-client', PROD_ABC_API_BASE_URL: 'https://production.example'
});
test('staging uses its mapped API and SSO settings independently of production values', () => {
const settings = stagingSettings(fixture());
assert.equal(settings.vars.AUTH_CLIENT_ID, 'staging-client');
assert.equal(settings.vars.AUTH_AUTHORIZE_URL, 'https://identity.staging.example/oidc/oauth2/auth');
assert.equal(settings.vars.AUTH_TOKEN_URL, 'https://identity.staging.example/oidc/oauth2/token');
assert.equal(settings.vars.AUTH_USERINFO_URL, 'https://identity.staging.example/oidc/userinfo');
assert.equal(settings.vars.ABC_API_BASE_URL, 'https://feedback.staging.example');
assert.equal(settings.vars.PROD_AUTH_CLIENT_ID, undefined);
});
test('missing staging settings cannot silently fall back to a different environment', () => {
for (const name of ['AUTH_CLIENT_ID', 'AUTH_ISSUER', 'ABC_API_BASE_URL', 'ABC_PROJECT_ID', 'ABC_CHANNEL_ID', 'ORG_CONTEXT_ENABLED']) {
const env = fixture();
delete env[name];
assert.throws(() => stagingSettings(env), new RegExp('STG_' + name));
}
assert.throws(() => stagingSettings({ ...fixture(), ABC_CHANNEL_ID: 'not-a-uuid' }), /STG_ABC_CHANNEL_ID/);
assert.throws(() => stagingSettings({ ...fixture(), AUTH_ISSUER: 'http://identity.example' }), /STG_AUTH_ISSUER/);
assert.throws(() => stagingSettings({ ...fixture(), ABC_API_BASE_URL: 'https://api.example/api' }), /STG_ABC_API_BASE_URL/);
});
test('staging organization credentials remain separate from public Worker variables', () => {
const settings = stagingSettings({ ...fixture(), ORG_CONTEXT_ENABLED: 'true',
ORG_CONTEXT_TENANT_IDS: '["038326b6-954a-48a7-a85f-efd83f62b82a"]',
ORG_CONTEXT_KEY_ID: 'fixture-key', ORG_CONTEXT_KEY_SECRET: 'fixture-secret'
});
assert.equal(settings.secrets.ORG_CONTEXT_KEY_SECRET, 'fixture-secret');
assert.equal(settings.vars.ORG_CONTEXT_KEY_SECRET, undefined);
});
test('staging validation never deploys and dry-run pins the staging Worker and config', () => {
const directory = mkdtempSync(join(tmpdir(), 'qa-staging-test-'));
const capture = join(directory, 'commands.jsonl');
writeFileSync(join(directory, 'npx'), `#!/usr/bin/env node
require('node:fs').appendFileSync(process.env.QA_TEST_CAPTURE, JSON.stringify(process.argv.slice(2)) + '\\n');
`, { mode: 0o700 });
const script = fileURLToPath(new URL('../scripts/deploy-worker.mjs', import.meta.url));
const env = { ...process.env, ...fixture(), PATH: directory + ':' + process.env.PATH, QA_TEST_CAPTURE: capture };
try {
const validation = spawnSync(process.execPath, [script, '--validate-only'], { env, encoding: 'utf8' });
assert.equal(validation.status, 0, validation.stderr);
assert.equal(existsSync(capture), false);
const dryRun = spawnSync(process.execPath, [script, '--dry-run'], { env, encoding: 'utf8' });
assert.equal(dryRun.status, 0, dryRun.stderr);
const commands = readFileSync(capture, 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(commands.length, 1);
const args = commands[0];
assert.ok(args.includes('--dry-run'));
assert.ok(args.includes('baron-qa-gateway-test'));
assert.ok(args.includes('wrangler.toml'));
assert.ok(args.includes('ABC_API_BASE_URL:https://feedback.staging.example'));
assert.ok(!args.some(value => value.includes('production.example') || value === 'secret'));
} finally { rmSync(directory, { recursive: true, force: true }); }
});
+44
View File
@@ -0,0 +1,44 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import worker from '../src/index.js';
const destination = 'https://test.baroncs.co.kr/ko/sw-inquiry.html';
for (const configFile of ['wrangler.toml', 'wrangler.production.toml']) {
test(configFile + ' logout expires its own session and redirects to the inquiry homepage', async () => {
const config = readFileSync(new URL('../' + configFile, import.meta.url), 'utf8');
const setting = name => config.match(new RegExp('^' + name + ' = "([^"]+)"', 'm'))[1];
const env = {
SESSION_COOKIE_NAME: setting('SESSION_COOKIE_NAME'),
AUTH_POST_LOGOUT_REDIRECT_URI: setting('AUTH_POST_LOGOUT_REDIRECT_URI')
};
const response = await worker.fetch(new Request('https://qa.example.test/auth/logout?return_url=https://untrusted.example/&redirect_uri=https://untrusted.example/', {
headers: { cookie: env.SESSION_COOKIE_NAME + '=existing-session' }
}), env);
assert.equal(response.status, 302);
assert.equal(response.headers.get('location'), destination);
const cookie = response.headers.get('set-cookie');
assert.ok(cookie.startsWith(env.SESSION_COOKIE_NAME + '=;'));
for (const attribute of ['Max-Age=0', 'Path=/', 'Secure', 'HttpOnly', 'SameSite=Lax', 'Expires=Thu, 01 Jan 1970 00:00:00 GMT']) assert.ok(cookie.includes(attribute));
assert.match(response.headers.get('cache-control'), /no-store/);
});
}
for (const [label, value, expected] of [
['no configured destination', undefined, '/'],
['same-origin relative destination', '/signed-out', '/signed-out'],
['invalid URL', 'https://[invalid', '/'],
['script URL', 'javascript:alert(1)', '/'],
['external insecure URL', 'http://untrusted.example/', '/'],
['URL containing credentials', 'https://user:pass@untrusted.example/', '/']
]) {
test('logout handles ' + label + ' while clearing the session', async () => {
const response = await worker.fetch(new Request('https://qa.example.test/auth/logout'), {
SESSION_COOKIE_NAME: 'fixture_session', AUTH_POST_LOGOUT_REDIRECT_URI: value
});
assert.equal(response.status, 302);
assert.equal(response.headers.get('location'), 'https://qa.example.test' + expected);
assert.match(response.headers.get('set-cookie'), /fixture_session=; Max-Age=0/);
});
}
+7 -4
View File
@@ -10,11 +10,14 @@ preview_urls = false
[vars]
AUTH_REDIRECT_URI = "https://qa.baroncs.co.kr/auth/callback"
AUTH_POST_LOGOUT_REDIRECT_URI = "https://qa.baroncs.co.kr/"
AUTH_POST_LOGOUT_REDIRECT_URI = "https://test.baroncs.co.kr/ko/sw-inquiry.html"
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"
AUTH_AUTHORIZE_URL = "https://app.brsw.kr/oidc/oauth2/auth"
AUTH_TOKEN_URL = "https://app.brsw.kr/oidc/oauth2/token"
AUTH_USERINFO_URL = "https://app.brsw.kr/oidc/userinfo"
ABC_API_BASE_URL = "https://qa.brsw.kr"
ABC_PROJECT_ID = "01a1c543-ab4f-744f-8eff-ab85f2644271"
ABC_CHANNEL_ID = "01a1c544-35ac-7674-9bf0-2469bfbe3e52"
SESSION_COOKIE_NAME = "baron_qa_prod_session_v1"
OAUTH_COOKIE_NAME = "baron_qa_prod_oauth_v1"
SESSION_TTL_SECONDS = "3600"
+4 -4
View File
@@ -2,15 +2,15 @@ name = "baron-qa-gateway-test"
main = "src/index.js"
compatibility_date = "2026-09-18"
account_id = "81fa2d48964d31dd0da9558f9ce601d1"
workers_dev = false
preview_urls = false
routes = [
{ pattern = "qa-test.baroncs.co.kr", custom_domain = true }
]
# qa-test.baroncs.co.kr is already attached in the Cloudflare dashboard.
# Omit route/routes to preserve that connection without zone routing API calls.
[vars]
AUTH_REDIRECT_URI = "https://qa-test.baroncs.co.kr/auth/callback"
AUTH_POST_LOGOUT_REDIRECT_URI = "https://qa-test.baroncs.co.kr/"
AUTH_POST_LOGOUT_REDIRECT_URI = "https://test.baroncs.co.kr/ko/sw-inquiry.html"
AUTH_SCOPE = "openid tenants profile email"
AUTH_CLIENT_ID = "e16898a7-acd2-420a-a224-cdcd89ff66cd"
AUTH_AUTHORIZE_URL = "https://sso.hmac.kr/oidc/oauth2/auth"