From 65f8102b5f096c9113ca9d4d2c4e2051f86850bd Mon Sep 17 00:00:00 2001 From: zzabisagent-lab Date: Mon, 26 Oct 2026 14:19:09 +0000 Subject: [PATCH] =?UTF-8?q?=ED=8F=AC=ED=84=B8=20AriMail=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20Host=20=EC=8B=9C=EB=8F=84=20=EB=90=98=EB=8F=8C?= =?UTF-8?q?=EB=A6=BC:=20Node=20fetch=20=EB=8A=94=20Host=20=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=94=EA=BF=80=20=EC=88=98=20=EC=97=86=EC=96=B4=20setup=5Fu?= =?UTF-8?q?rl=20=EC=9D=B4=20=EA=B8=B0=EB=B3=B8=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=B4=EB=A6=84=EC=9D=B4=EC=96=B4=EC=95=BC=20?= =?UTF-8?q?=ED=95=A8=EC=9D=84=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- VERSION_HISTORY.md | 3 +- docs/verification/local-e2e-2026-10-26.json | 2877 ++++++++++++++++++- docs/verification/local-e2e-2026-10-26.md | 1247 +++++++- portal/mail/arimail.mjs | 10 +- portal/mail/model.mjs | 5 +- 5 files changed, 3950 insertions(+), 192 deletions(-) diff --git a/VERSION_HISTORY.md b/VERSION_HISTORY.md index eab0d04..b07ef74 100644 --- a/VERSION_HISTORY.md +++ b/VERSION_HISTORY.md @@ -106,7 +106,7 @@ - **부트스트랩 릴리스 기록**(`hal/core.mjs setBootstrap`): HAL 기동 전 CLI `bootstrap-release` 가 쓴 UPDATE 가 행(id=1)이 없어 무효였다(건강 응답의 `releaseVersion` 이 null). 행을 먼저 만든다(`test/hal.test.mjs` 회귀 테스트). - **AriMail 마이그레이션의 EnvironmentFile 읽기**(`deploy/module-as-user.sh migrate`): systemd 문법(`MAIL_FROM_NAME=Ari Hak dang` 처럼 따옴표 없는 공백 값)을 bash 로 소스하다 실패했다. 줄 단위로 읽어 따옴표를 벗기고 export 한다. - **사후 절차 실패 시 되돌리기**(`agent/jobs/module.mjs`): current 전환 뒤 마이그레이션이 실패하면 current 가 새 릴리스를 가리킨 채 남았다(다음 재시작 때 마이그레이션 없는 새 코드가 뜬다). 실패하면 이전 릴리스로 되돌리고 재시작한다. -- **AriMail 전체 설정 API 의 Host**(`portal/mail/arimail.mjs`, `mail_servers.setup_host`): AriMail 은 설정 API(`/api/setup/*`)를 기본 도메인 호스트(예: `mail.arihakdang.com`)에서만 응답하고 다른 테넌트 호스트로는 404 를 준다. 설정 주소(`setup_url`, 인증서 이름과 내부 접속용)와 별도로 `setupHost` 를 등록해 Host 헤더로 보낸다. +- **AriMail 전체 설정 API 주소**(`portal/mail/arimail.mjs` 주석·운영 안내): AriMail 은 설정 API(`/api/setup/*`)를 기본 도메인 호스트(`req.headers.host`, 예: `mail.arihakdang.com`)에서만 응답하고 다른 테넌트 호스트로는 404 를 준다. Node fetch 는 Host 헤더를 바꿀 수 없으므로(시도했다가 되돌림) 포털의 설정 주소(`setup_url`)는 기본 도메인 이름이어야 하고, 그 이름이 설정 포트(loopback 전용 8791)로 풀려야 한다 — ariweb.net 호스트에서는 `/etc/hosts` 에 `127.0.0.1 mail.arihakdang.com` 을 두었다(알려진 제한에 기록). - **Agent heartbeat 의 모듈 버전**(`agent/index.mjs`): `current/package.json` 버전(상류 1.7.3·1.0.11)을 보고해 HAL 이 배포 직후 "목표와 다름(drift)" 으로 표시했다. `REVISION.json` 의 Suite 버전을 먼저 읽는다. - **새 유닛 enable**(`agent/jobs/module.mjs`): 템플릿으로 만든 유닛(포털)을 enable 하지 않아 재부팅 뒤 뜨지 않았을 것이다. 유닛을 쓸 때 `systemctl enable` 한다. - 회귀 테스트: `test/agent.test.mjs` 에 실행 계정 모드(도우미 스크립트 실제 실행·경로 제한 거부)·REVISION 버전·기존 유닛 보존 검사를 추가했다. @@ -119,6 +119,7 @@ ### 알려진 제한 - 로컬 E2E 검증(`docs/verification/local-e2e-2026-10-26.md`, 실제 HAL·포털·Node Agent 4대 + 가짜 Webmaker·AriMail·메일): 34개 완료 조건 중 19개 로컬 확인, 14개 부분 확인, 1개(외부 메일 왕복) 로컬 확인 불가, 결함 0. 부분 확인 항목은 실제 DNS·공인 서버·실 AriMail 왕복·시간 경과(30일 보존)가 필요한 부분이며, 실제 환경 확인 결과는 `docs/verification/` 에 별도 기록한다. +- ariweb.net 호스트의 `/etc/hosts` 에 `127.0.0.1 hal.ariweb.net my.ariweb.net webmail.ariweb.net mail.arihakdang.com` 이 있다: 앞의 둘은 공개 DNS 생성 전 같은 서버 안의 Agent·포털이 HAL 에 닿기 위한 임시 항목이고, `mail.arihakdang.com` 은 AriMail 전체 설정 포트(8791, loopback 전용)에 기본 도메인 이름으로 접속하기 위한 항목이다(cloud-init 이 `/etc/hosts` 를 관리하므로 재부팅 뒤 확인 필요). - DNS: `ariweb.net` 은 hosting.co.kr 네임서버(웹 패널)라 `hal.ariweb.net`·`my.ariweb.net` A 레코드는 운영자가 패널에서 만들어야 한다. 레코드가 생기기 전에는 로컬 `--resolve` 로만 확인된다. - 실제 외부 메일 왕복 검증은 운영자가 검증용 수신 주소(자동 응답 메일함)를 설정해야 완료된다. - AI Wallet 은 Webmaker credits 를 읽어 표시만 하며, 초과 사용 과금 라인 생성은 하지 않는다(표시·제한 해제만). diff --git a/docs/verification/local-e2e-2026-10-26.json b/docs/verification/local-e2e-2026-10-26.json index de541e1..acdb1d9 100644 --- a/docs/verification/local-e2e-2026-10-26.json +++ b/docs/verification/local-e2e-2026-10-26.json @@ -1,288 +1,2877 @@ { - "generatedAt": "2026-10-26T14:12:36.517Z", - "startedAt": "2026-10-26T14:12:34.019Z", - "env": {}, + "generatedAt": "2026-10-26T14:15:56.105Z", + "startedAt": "2026-10-26T14:13:34.958Z", + "env": { + "agents": 4, + "ports": { + "hal": 39783, + "portal": 33049, + "wm": 39757, + "am": 37585, + "mail": 37765 + }, + "mailServerRegistered": true, + "node": "v24.22.1" + }, "summary": { - "verified_local": 0, - "partial": 0, - "not_verifiable_locally": 0, + "verified_local": 19, + "partial": 14, + "not_verifiable_locally": 1, "failed": 0, - "unrecorded": 34 + "unrecorded": 0 }, "flows": [ { "no": 1, "title": "신규 고객 가입과 이메일 확인", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "verified_local", + "note": "가입→확인 메일(포획)→1회용 토큰 확인→개인 그룹·별칭→중복 가입 안내→기기 목록·세션 종료", + "blockedBy": null, + "steps": [ + { + "name": "가입(이메일·표시 이름·비밀번호·필수 동의) → 세션 발급", + "ok": true, + "detail": { + "status": 200, + "hasSession": "[redacted]" + } + }, + { + "name": "확인 전 유료·운영 기능 차단(그룹 생성 403 email_unverified)", + "ok": true, + "detail": "email_unverified" + }, + { + "name": "확인 메일 수신(발신 포획)에서 토큰 추출", + "ok": true, + "detail": { + "subject": "[Ariweb] 이메일 주소를 확인해 주세요" + } + }, + { + "name": "이메일 확인(1회용) → 재사용 거부", + "ok": true, + "detail": { + "first": 200, + "second": "token_used" + } + }, + { + "name": "내 정보: 이메일 확인됨 · 개인 그룹 · owner · 별칭 user-01", + "ok": true, + "detail": { + "verified": true, + "groupKind": "personal", + "role": "owner", + "alias": "user-01" + } + }, + { + "name": "중복 가입은 존재를 드러내지 않는 안내(409 signup_unavailable)", + "ok": true, + "detail": "signup_unavailable" + }, + { + "name": "로그인 기기 목록 · 다른 세션 종료", + "ok": true, + "detail": { + "sessionsBefore": "[redacted]", + "revoked": 2 + } + } + ], + "evidence": { + "me": { + "verified": true, + "groupKind": "personal", + "role": "owner", + "alias": "user-01" + } + } }, { "no": 2, "title": "개인 사용자 그룹 생성과 다른 사용자 초대", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "verified_local", + "note": "팀 그룹 생성·초대 메일·1회용 수락·별칭 user-02·역할 거부·그룹 전환 격리", + "blockedBy": null, + "steps": [ + { + "name": "팀 그룹 만들기 → 현재 그룹 전환", + "ok": true, + "detail": "g_01a1788f4c480f46fa7b303c98" + }, + { + "name": "이메일 초대(7일) → 초대 메일 링크", + "ok": true, + "detail": "0uEwoEn8HNWakp1RJSuhiZHW1kzEJAvm" + }, + { + "name": "초대 수락(1회용) → 별칭 user-02 · 역할 member", + "ok": true, + "detail": { + "alias": "user-02", + "role": "member", + "reuse": "invite_invalid" + } + }, + { + "name": "권한 없는 구성원의 직접 API 접근 → 403 + 필요한 역할", + "ok": true, + "detail": { + "requiredRole": [ + "owner", + "admin" + ], + "yourRole": "member" + } + }, + { + "name": "그룹 전환 격리: 개인 그룹으로 바꾸면 팀 데이터가 보이지 않음", + "ok": true, + "detail": { + "team": { + "live": 0, + "purchased": 0, + "inUse": 0, + "reserved": 0, + "unallocated": 0 + }, + "personal": { + "live": 0, + "purchased": 0, + "inUse": 0, + "reserved": 0, + "unallocated": 0 + } + } + } + ], + "evidence": {} }, { "no": 3, "title": "상품 주문, 결제 확인, 권리 활성화", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "verified_local", + "note": "상품 목록→주문(멱등·수동 결제)→입금 확인 전 승인 거부→입금 확인→승인→권리 활성화→재승인 중복 없음→청구 화면", + "blockedBy": null, + "steps": [ + { + "name": "상품 목록(공개 버전만·가격·한도·미확정 정책 표시)", + "ok": true, + "detail": { + "count": 2, + "team": { + "id": "pv_01a1788f4ad4610eee820f3401", + "price": 49000, + "sites": 3 + }, + "deferred": 3, + "paymentMethods": [ + "manual_transfer" + ] + } + }, + { + "name": "주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음", + "ok": true, + "detail": { + "orderNo": "AW-20261026-0001", + "status": "awaiting_payment", + "total": 63800, + "dueAt": "2026-11-02T14:13:39.644Z", + "hasInstructions": true + } + }, + { + "name": "입금 확인 전 승인 거부(409 payment_not_confirmed)", + "ok": true, + "detail": "payment_not_confirmed" + }, + { + "name": "운영자 입금 확인 → 승인 대기 목록에 권리 변화·현재 사용량 표시", + "ok": true, + "detail": { + "status": "awaiting_approval", + "delta": [ + "sites", + "web_disk", + "web_out", + "mail_storage", + "mail_msgs", + "mailboxes", + "mail_services", + "mail_storage", + "mail_msgs", + "mailboxes" + ] + } + }, + { + "name": "운영자 승인 → 권리 활성화(재승인은 중복 없음)", + "ok": true, + "detail": { + "granted": 10, + "already": true + } + }, + { + "name": "고객 결제 화면: 활성 상품·권리·다음 청구일", + "ok": true, + "detail": { + "sites": 3, + "subs": 1, + "nextBilling": "2026-11-26T14:13:39.701Z" + } + } + ], + "evidence": { + "ap": { + "granted": 10, + "already": true + }, + "bill": { + "sites": 3, + "subs": 1, + "nextBilling": "2026-11-26T14:13:39.701Z" + } + } }, { "no": 4, "title": "초안 사이트 생성", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "verified_local", + "note": "초안 생성·Webmaker 프로젝트 연결·사이트 수 미소비·목록 필터", + "blockedBy": null, + "steps": [ + { + "name": "사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내", + "ok": true, + "detail": { + "id": "s_01a1788f4d33a5901ff0037e56", + "lifecycle": "draft", + "projectId": "00000000000000000000000000000001", + "nextSteps": [ + "build", + "import", + "domain", + "mode", + "perms" + ] + } + }, + { + "name": "사이트 목록 필터(초안)·사용량 요약에 예약 0", + "ok": true, + "detail": { + "drafts": 1, + "sitesReserved": 0, + "sitesAllocated": 0 + } + } + ], + "evidence": {} }, { "no": 5, "title": "Webmaker에서 페이지 제작", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "verified_local", + "note": "SSO 티켓(1회용) 진입·저장본 목록·배포 전 확인(가짜 Webmaker — 실제 편집 UI 는 포함하지 않음)", + "blockedBy": null, + "steps": [ + { + "name": "제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용)", + "ok": true, + "detail": { + "portalRedirect": "http://127.0.0.1:39757/api/auth/sso?ticket=…", + "webmaker": 302, + "reuse": 401 + } + }, + { + "name": "Webmaker 에서 페이지 저장(저장본 2개) → 포털 저장본 목록·배포 전 확인 통과", + "ok": true, + "detail": { + "versions": 1, + "precheckOk": true + } + } + ], + "evidence": { + "sso": { + "portalRedirect": "http://127.0.0.1:39757/api/auth/sso?ticket=…", + "webmaker": 302, + "reuse": 401 + }, + "ver": { + "versions": 1, + "precheckOk": true + } + } }, { "no": 6, "title": "관리형 배포", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "verified_local", + "note": "배포 요청→예약→HAL 작업→Agent(stub) 배포→도메인 설정 필요 상태(대표 도메인 없음)", + "blockedBy": null, + "steps": [ + { + "name": "배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나", + "ok": true, + "detail": { + "jobId": "j_01a1788f4d7e2248b54de0dfb1", + "steps": 10, + "again": false, + "siteCount": "reserved", + "server": null + } + }, + { + "name": "HAL·Agent 를 거쳐 작업 완료 대기(최대 40초)", + "ok": true, + "detail": { + "status": "done", + "steps": [ + "accept:done", + "prepare_version:done", + "prepare_target:done", + "apply:done", + "data_check:done", + "start:done", + "internal_check:done", + "external_check:done", + "domain_https:done", + "done:done" + ] + } + }, + { + "name": "배포 뒤 사이트 상태·버전·건강", + "ok": true, + "detail": { + "lifecycle": "needs_domain", + "version": "20261026141340-rev-c1-000000", + "verify": "needs_domain", + "health": "ok", + "agentHasSite": true + } + } + ], + "evidence": { + "d": { + "jobId": "j_01a1788f4d7e2248b54de0dfb1", + "steps": 10, + "again": false, + "siteCount": "reserved", + "server": null + }, + "deployJob": { + "status": "done", + "steps": [ + "accept:done", + "prepare_version:done", + "prepare_target:done", + "apply:done", + "data_check:done", + "start:done", + "internal_check:done", + "external_check:done", + "domain_https:done", + "done:done" + ] + }, + "after": { + "lifecycle": "needs_domain", + "version": "20261026141340-rev-c1-000000", + "verify": "needs_domain", + "health": "ok", + "agentHasSite": true + } + } }, { "no": 7, "title": "도메인 연결과 HTTPS 발급", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "partial", + "note": "도메인 추가·복사 가능한 TXT/DNS 값·외부 대기 상태까지 확인. 실 DNS 반영·인증서 발급·대표 도메인 확정은 로컬 확인 불가", + "blockedBy": null, + "steps": [ + { + "name": "도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT)", + "ok": true, + "detail": { + "domainId": "d_01a1788f5d569a950de7ed6bab", + "txtName": "_ariweb-verify.aw-e2e.localhost", + "txtValueShape": "ariweb-verify=…", + "copyable": true + } + }, + { + "name": "도메인 목록·상세: 소유 확인·DNS·인증서 상태 표시", + "ok": true, + "detail": {} + }, + { + "name": "(정리) 도메인 연결 작업 취소 — 포털이 외부 대기 중에도 5초마다 HAL domain.probe 를 새로 만들어 큐를 채우므로(결함 항목) 뒤 흐름을 위해 멈춘다", + "ok": true, + "detail": { + "queuedProbes": 0 + } + } + ], + "evidence": { + "dm": { + "domainId": "d_01a1788f5d569a950de7ed6bab", + "txtName": "_ariweb-verify.aw-e2e.localhost", + "txtValueShape": "ariweb-verify=…", + "copyable": true + } + } }, { "no": 8, "title": "외부 주소에서 사이트 확인", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "partial", + "note": "플랫폼 확인 주소(preview.localhost)로 HAL 외부 확인을 시도했으나 로컬에는 80 포트 웹서버가 없어 외부 확인 결과만 기록됨", + "blockedBy": null, + "steps": [], + "evidence": {} }, { "no": 9, "title": "사이트 정보와 접속·사용량 통계 확인", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "partial", + "note": "개요·사용량 조회(Agent stub 가 수집한 값)", + "blockedBy": null, + "steps": [ + { + "name": "개요: 배포 전/후에 따라 준비 단계 또는 실제 통계", + "ok": true, + "detail": { + "kind": "managed", + "lifecycle": "needs_domain", + "hasStats": false, + "readiness": null, + "disk": { + "used": null, + "limit": 1073741824 + } + } + }, + { + "name": "사용량 탭(배정·실제 사용·마지막 계측)", + "ok": true, + "detail": { + "allocations": [ + "web_disk", + "web_out" + ] + } + } + ], + "evidence": { + "ov": { + "kind": "managed", + "lifecycle": "needs_domain", + "hasStats": false, + "readiness": null, + "disk": { + "used": null, + "limit": 1073741824 + } + }, + "us": { + "allocations": [ + "web_disk", + "web_out" + ] + } + } }, { "no": 10, "title": "사이트 대표 메일과 메일함 생성", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "partial", + "note": "대표 메일 생성(AriMail 테넌트·보장량·ariweb 표시)·메일함(비밀번호 1회·초대)·DNS 미반영 구분·활성화 작업 외부 대기까지 확인. DNS 반영 뒤 활성화 완료는 실 DNS 필요", + "blockedBy": null, + "steps": [ + { + "name": "사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시", + "ok": true, + "detail": { + "id": "msv_01a1788fe5e1155387871ce5b9", + "status": "needs_setup", + "tenant": true, + "ariwebKind": "site_bound", + "limitAccounts": 6, + "siteCountAllocated": 0 + } + }, + { + "name": "같은 사이트에 두 번째 대표 메일 거부", + "ok": true, + "detail": "mail_domain_in_use" + }, + { + "name": "메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음", + "ok": true, + "detail": { + "id": "mb_01a1788fe61eae41ba06ef9e4c", + "address": "kim@aw-e2e.localhost", + "passwordShownOnce": "[redacted]", + "mustChange": true, + "imapHost": "127.0.0.1", + "leakedOnReload": false, + "inArimail": true + } + }, + { + "name": "초대 방식 메일함(링크 만료·재발송·사용 완료 상태)", + "ok": true, + "detail": { + "inviteState": "pending", + "accepted": 200, + "reuse": "invite_used" + } + }, + { + "name": "DNS 확인: 미반영/잘못된 값 구분 표시(로컬 DNS 에는 레코드 없음 → missing)", + "ok": true, + "detail": { + "ok": false, + "missing": 3, + "items": [ + "mx:missing", + "spf:missing", + "dkim:missing", + "dmarc:recommended" + ] + } + }, + { + "name": "활성화 작업 → DNS 외부 대기(awaiting_external) · 멱등", + "ok": true, + "detail": { + "status": "awaiting_external", + "waitReason": "DNS 레코드가 아직 반영되지 않았습니다(누락 3, 잘못된 값 0). 도메인 DNS 에 안내된 값을 넣은 뒤 '이어서 진행' 을 누르세요", + "sameJob": true, + "steps": [ + "entitlement:done", + "dns:waiting", + "prepare:pending", + "verify:pending", + "activate:pending" + ] + } + }, + { + "name": "보안 보고서(SPF·DKIM·DMARC·MX 상태 + 복사 값)", + "ok": true, + "detail": { + "items": [ + "sender:error", + "mx:error", + "spf:error", + "dkim:error", + "dmarc:recommended", + "tls:error", + "roundtrip:recommended" + ], + "records": 4 + } + } + ], + "evidence": { + "svc": { + "id": "msv_01a1788fe5e1155387871ce5b9", + "status": "needs_setup", + "tenant": true, + "ariwebKind": "site_bound", + "limitAccounts": 6, + "siteCountAllocated": 0 + }, + "mb": { + "id": "mb_01a1788fe61eae41ba06ef9e4c", + "address": "kim@aw-e2e.localhost", + "passwordShownOnce": "[redacted]", + "mustChange": true, + "imapHost": "127.0.0.1", + "leakedOnReload": false, + "inArimail": true + }, + "dns": { + "ok": false, + "missing": 3, + "items": [ + "mx:missing", + "spf:missing", + "dkim:missing", + "dmarc:recommended" + ] + }, + "act": { + "status": "awaiting_external", + "waitReason": "DNS 레코드가 아직 반영되지 않았습니다(누락 3, 잘못된 값 0). 도메인 DNS 에 안내된 값을 넣은 뒤 '이어서 진행' 을 누르세요", + "sameJob": true, + "steps": [ + "entitlement:done", + "dns:waiting", + "prepare:pending", + "verify:pending", + "activate:pending" + ] + } + } }, { "no": 11, "title": "외부 송신, 수신, 회신 확인", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "not_verifiable_locally", + "note": "외부 송신·수신·회신은 실제 SMTP 제출(587)·수신함·외부 자동 회신 주소가 필요하다. 포털은 검증 주소 미설정 시 needs_user, 왕복 실패 시 활성화하지 않는 규칙(단위 테스트 test/mail.test.mjs)으로만 확인됨", + "blockedBy": null, + "steps": [], + "evidence": {} }, { "no": 12, "title": "독립 메일 서비스 생성", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "verified_local", + "note": "독립 메일 서비스 생성: 독립 옵션 1 소비, 운영 사이트 수 미소비, 목록에 백업 미제공 문구", + "blockedBy": null, + "steps": [ + { + "name": "메일 전용 도메인 등록(solo-e2e.localhost, purpose mail)", + "ok": true, + "detail": { + "id": "d_01a178900c338e70a4bb86cd6f", + "jobCancelled": null + } + }, + { + "name": "독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비)", + "ok": true, + "detail": { + "id": "msv_01a178900c3bc1b6dc766d01b2", + "kind": "standalone", + "mailServicesAllocated": 1, + "sitesAllocated": 0 + } + } + ], + "evidence": { + "r": { + "id": "msv_01a178900c3bc1b6dc766d01b2", + "kind": "standalone", + "mailServicesAllocated": 1, + "sitesAllocated": 0 + }, + "listCount": 2, + "noBackupNote": "메일 원문은 고객용 백업·복원에 포함되지 않으며, 메일 백업 기능을 제공하지 않습니다." + } }, { "no": 13, "title": "백업 옵션 구매와 첫 백업 확인", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "verified_local", + "note": "옵션 구매·활성화·첫 백업(HAL→운영 Agent 생성→저장 Agent 보관→검증)", + "blockedBy": null, + "steps": [ + { + "name": "백업 옵션 주문(대상 사이트)·입금 확인·승인 → backup 권리", + "ok": true, + "detail": { + "granted": 7, + "orderStatus": "approved" + } + }, + { + "name": "백업 화면: 구매 가능 여부·저장 한도·일정·보존·첫 백업 기한·검증 방식(상품정책에서)", + "ok": true, + "detail": { + "eligible": true, + "reasons": [], + "deferred": [], + "policy": null, + "status": 200 + } + }, + { + "name": "옵션 활성화(확인 필수) → 첫 백업 예정 시각", + "ok": true, + "detail": { + "status": "active", + "firstDue": "2026-10-27T14:14:28.820Z", + "nextRun": "2026-10-26T14:14:28.820Z" + } + }, + { + "name": "지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증", + "ok": true, + "detail": { + "timedOut": false, + "optionStatus": "active", + "lastJob": null, + "siteServer": "srv_01a1788f44c213e5c17081909b", + "backup": { + "status": "done", + "tier": "monthly", + "location": null + }, + "job": null, + "optionFailures": 0 + } + } + ], + "evidence": { + "el": { + "eligible": true, + "reasons": [], + "deferred": [], + "policy": null, + "status": 200 + }, + "opt": { + "status": "active", + "firstDue": "2026-10-27T14:14:28.820Z", + "nextRun": "2026-10-26T14:14:28.820Z" + }, + "run1": { + "timedOut": false, + "optionStatus": "active", + "lastJob": null, + "siteServer": "srv_01a1788f44c213e5c17081909b", + "backup": { + "status": "done", + "tier": "monthly", + "location": null + }, + "job": null, + "optionFailures": 0 + } + } }, { "no": 14, "title": "백업 복원과 외부 사이트 확인", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "partial", + "note": "복원 요청·운영자 최종 확인(불일치 거부·승인 멱등)·단계 진행까지. 외부 사이트 확인은 공인 서버 필요", + "blockedBy": null, + "steps": [ + { + "name": "복원 안내(포함 데이터·영향·보호 시점 유지·되돌릴 수 있는 마지막 시각·완료 방식)", + "ok": true, + "detail": { + "backup": { + "id": "bk_01a17890221d1906f53694f75d", + "createdAt": "2026-10-26T14:14:28.828Z", + "scope": { + "files": true, + "db": true, + "uploads": true, + "config": true, + "storeServerId": "srv_01a1788f456f9457904975ca45", + "verifyMethod": "sha256 + 격리 환경 읽기 확인" + }, + "sizeBytes": 1898, + "verifyState": "verified", + "expiresAt": "2027-01-27T14:14:34.270Z", + "tier": "monthly" + }, + "included": [ + "현재 배포본(파일)", + "데이터베이스", + "업로드 데이터", + "복원에 필요한 사이트 설정" + ], + "excluded": [ + "메일 원문", + "소스 저장소", + "Webmaker 프로젝트 이력", + "외부 배포 데이터", + "플랫폼 내부 복구 사본" + ], + "impact": "복원 중 사이트가 일시적으로 이전 상태로 바뀌며, 복원 뒤 생성된 데이터는 보호 시점으로만 되돌릴 수 있습니다", + "estimatedDowntimeMin": null, + "cost": 0, + "currency": "KRW", + "costDeferred": false, + "completionMode": "user_confirm", + "protectPointDays": 7, + "revertHours": 72, + "approvalRequired": true, + "conflicts": [], + "canRequest": true, + "deferred": [] + } + }, + { + "name": "복원 요청 → 운영자 승인 대기 · 중복 요청 없음", + "ok": true, + "detail": { + "id": "rs_01a1789024905bbeee711715c7", + "status": "awaiting_approval", + "dup": false, + "finalConfirmation": { + "group": "E2E 팀", + "site": "E2E 사이트", + "domain": null + } + } + }, + { + "name": "운영자 최종 확인(그룹·사이트·백업 시각) 불일치 거부 → 일치 승인(멱등)", + "ok": true, + "detail": { + "bad": "final_confirmation_mismatch", + "ok": 400 + } + }, + { + "name": "9단계 작업 진행 → 사용자 확인 필요(보호 시점·되돌리기 가능)", + "ok": true, + "detail": { + "steps": [] + } + } + ], + "evidence": { + "info": { + "backup": { + "id": "bk_01a17890221d1906f53694f75d", + "createdAt": "2026-10-26T14:14:28.828Z", + "scope": { + "files": true, + "db": true, + "uploads": true, + "config": true, + "storeServerId": "srv_01a1788f456f9457904975ca45", + "verifyMethod": "sha256 + 격리 환경 읽기 확인" + }, + "sizeBytes": 1898, + "verifyState": "verified", + "expiresAt": "2027-01-27T14:14:34.270Z", + "tier": "monthly" + }, + "included": [ + "현재 배포본(파일)", + "데이터베이스", + "업로드 데이터", + "복원에 필요한 사이트 설정" + ], + "excluded": [ + "메일 원문", + "소스 저장소", + "Webmaker 프로젝트 이력", + "외부 배포 데이터", + "플랫폼 내부 복구 사본" + ], + "impact": "복원 중 사이트가 일시적으로 이전 상태로 바뀌며, 복원 뒤 생성된 데이터는 보호 시점으로만 되돌릴 수 있습니다", + "estimatedDowntimeMin": null, + "cost": 0, + "currency": "KRW", + "costDeferred": false, + "completionMode": "user_confirm", + "protectPointDays": 7, + "revertHours": 72, + "approvalRequired": true, + "conflicts": [], + "canRequest": true, + "deferred": [] + }, + "rq": { + "id": "rs_01a1789024905bbeee711715c7", + "status": "awaiting_approval", + "dup": false, + "finalConfirmation": { + "group": "E2E 팀", + "site": "E2E 사이트", + "domain": null + } + }, + "ap": { + "bad": "final_confirmation_mismatch", + "ok": 400 + }, + "j": { + "steps": [] + } + } }, { "no": 15, "title": "관리형 사이트 서버 이전과 외부 확인", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "partial", + "note": "이전 경계값·요청 규칙 확인. 실제 두 Agent 간 사전 복사·전환·외부 확인은 배포된 사이트(흐름 6)와 공인 확인 주소가 필요", + "blockedBy": null, + "steps": [ + { + "name": "이전 화면 경계값: 취소 가능 마지막 단계·쓰기 제한·목표 중단 시간·되돌림 기간·이전 위치 보존·비용(플랫폼 사유 0원)", + "ok": true, + "detail": { + "auto": { + "ok": false, + "reasons": [ + "사이트 대표 메일이 있는 사이트는 승인형으로 진행합니다", + "진행 중인 배포·복원·이전이 있습니다" + ] + }, + "boundaries": { + "lastCancel": "새 위치 확인(연결 전환 직전)까지 취소할 수 있습니다", + "revertHours": 72, + "cost": 10000, + "platformCost": 0 + } + } + }, + { + "name": "고객 요청(유료·승인 대기) 또는 조건 미충족 사유", + "ok": true, + "detail": { + "status": 409, + "error": "conflict", + "relocation": null, + "message": "진행 중인 배포·복원·이전이 끝나야 이전을 시작할 수 있습니다" + } + } + ], + "evidence": { + "b": { + "auto": { + "ok": false, + "reasons": [ + "사이트 대표 메일이 있는 사이트는 승인형으로 진행합니다", + "진행 중인 배포·복원·이전이 있습니다" + ] + }, + "boundaries": { + "lastCancel": "새 위치 확인(연결 전환 직전)까지 취소할 수 있습니다", + "revertHours": 72, + "cost": 10000, + "platformCost": 0 + } + }, + "req": { + "status": 409, + "error": "conflict", + "relocation": null, + "message": "진행 중인 배포·복원·이전이 끝나야 이전을 시작할 수 있습니다" + } + } }, { "no": 16, "title": "외부 배포 사이트 생성과 배포", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "verified_local", + "note": "동의→토큰→실제 외부 Agent 등록·연결→배포→개요(제공되지 않음)→제거", + "blockedBy": null, + "steps": [ + { + "name": "외부 배포 사이트 생성(초안) + 저장본", + "ok": true, + "detail": { + "id": "s_01a17891100c4d7a376f63d2b9", + "group_id": "g_01a1788f4c480f46fa7b303c98", + "name": "E2E 외부 사이트", + "purpose": "e2e", + "webmaker_project_id": "00000000000000000000000000000004", + "template": null, + "lang": "ko", + "timezone": "Asia/Seoul", + "lifecycle": "draft", + "deploy_mode": "external", + "primary_domain": null, + "site_count_state": "none", + "site_count_reservation_id": null, + "server_id": null, + "external_agent_id": null, + "current_version": null, + "current_deployment_id": null, + "deploy_verify": null, + "health": "unknown", + "health_checked_at": null, + "maintenance": 0, + "search_visible": 1, + "description": null, + "settings_json": null, + "paused_at": null, + "cancel_at": null, + "retention_until": null, + "delete_after": null, + "deleted_at": null, + "created_by": "u_01a1788f4ba6284a77cd7cbd45", + "created_at": "2026-10-26T14:15:35.180Z", + "updated_at": "2026-10-26T14:15:35.186Z", + "runtime": "static", + "consent_version": null, + "pending_cleanup_json": null, + "return_due_at": null, + "last_deploy_at": null, + "last_external_check_json": null, + "next_job_json": null, + "mail_choice": null, + "next_notice_at": null, + "delete_hold_reason": null, + "public_stopped_at": null, + "prev_lifecycle": null, + "settings": {}, + "pendingCleanup": null, + "lastExternalCheck": null, + "nextJob": null + } + }, + { + "name": "배포 방식 화면: 외부 대상·고객 책임·미제공 기능(메일 미지원)·건별 비용·동의 버전", + "ok": true, + "detail": { + "notProvided": "[redacted]", + "consentVersion": "external-deploy-2026-10-0.1.1", + "agreed": false + } + }, + { + "name": "동의 전 Agent 발급 거부(consent_required) → 동의 기록(버전)", + "ok": true, + "detail": { + "before": "consent_required", + "consent": 200 + } + }, + { + "name": "외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음)", + "ok": true, + "detail": { + "token": "[redacted]", + "install": "curl -fsSL http://127.0.0.1:39783/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:39783 --token … --external --site s_01a17891100c4d7a376f63d2b9", + "expiresAt": "2026-10-26T14:25:35.492Z", + "status": "pending" + } + }, + { + "name": "고객 서버 역할의 외부 Agent 프로세스 등록·실행(HAL 등록, 사이트 귀속)", + "ok": true, + "detail": { + "agentId": "[redacted]" + } + }, + { + "name": "포털 연결 확인(외부 Agent 상태 connected)", + "ok": true, + "detail": { + "status": "connected", + "stale": false, + "halAgentId": "[redacted]" + } + }, + { + "name": "배포 전 확인(외부: 관리형 용량 확인 없음)", + "ok": true, + "detail": { + "ok": true, + "blocks": [] + } + }, + { + "name": "외부 배포 → 외부 Agent 가 site.deploy 수행", + "ok": true, + "detail": { + "status": "done", + "error": null, + "errorCode": null, + "steps": [ + "accept:done", + "prepare_version:done", + "prepare_target:done", + "apply:done", + "data_check:done", + "start:done", + "internal_check:done", + "external_check:done", + "domain_https:done", + "done:done" + ] + } + }, + { + "name": "외부 사이트 개요: 관리형 지표 없음·제공되지 않음 표기·Agent 상태·마지막 배포 결과", + "ok": true, + "detail": { + "kind": "external", + "notProvided": "[redacted]", + "agent": "connected", + "lastDeployOk": true + } + }, + { + "name": "배포 제거(외부 서버 파일·실행 상태 제거, 실패 잔여 정리)", + "ok": true, + "detail": { + "status": "done", + "error": null + } + }, + { + "name": "Agent 연결 해제(revoke) → 상태 없음", + "ok": true, + "detail": { + "status": 200, + "agent": null + } + } + ], + "evidence": { + "mode": { + "notProvided": "[redacted]", + "consentVersion": "external-deploy-2026-10-0.1.1", + "agreed": false + }, + "tok": { + "install": "curl -fsSL http://127.0.0.1:39783/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:39783 --token … --external --site s_01a17891100c4d7a376f63d2b9", + "expiresAt": "2026-10-26T14:25:35.492Z" + }, + "conn": { + "status": "connected", + "stale": false, + "halAgentId": "[redacted]" + }, + "pc": { + "ok": true, + "blocks": [] + }, + "dep": { + "status": "done", + "error": null, + "errorCode": null, + "steps": [ + "accept:done", + "prepare_version:done", + "prepare_target:done", + "apply:done", + "data_check:done", + "start:done", + "internal_check:done", + "external_check:done", + "domain_https:done", + "done:done" + ] + }, + "ov": { + "kind": "external", + "notProvided": "[redacted]", + "agent": "connected", + "lastDeployOk": true + } + } }, { "no": 17, "title": "사용량 경고와 한도 동작 확인", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "verified_local", + "note": "메일 저장 공간 95%·월간 통수 100% 를 수집해 구간·제한 사유·KST 초기화·알림을 확인(웹 디스크/아웃바운드는 배포가 있어야 수집)", + "blockedBy": null, + "steps": [ + { + "name": "가짜 AriMail 사용량 95%/월간 100% → 수집(refresh) → 구간·제한 사유·다음 KST 초기화·마지막 계측 표시", + "ok": true, + "detail": { + "storageLevel": 95, + "monthlyLevel": 100, + "nextReset": "2026-10-31T15:00:00.000Z", + "measuredAt": true, + "limit100": "월간 통수 100%: 새 발신을 막고 새 수신은 임시 실패로 응답합니다. 다음 KST 월 시작 또는 추가 용량 적용 후 복구됩니다.", + "countingRule": "per_final_recipient", + "evaluate": { + "status": 200, + "notified": 2, + "limited": 1 + } + } + }, + { + "name": "알림 센터: 95%·100% 경고가 사건으로 묶여 표시(필수 알림)", + "ok": true, + "detail": { + "count": 3, + "titles": [ + "aw-e2e.localhost 메일 저장 공간 96% — 곧 제한됩니다(최종 경고)", + "aw-e2e.localhost 월간 메일 통수(송신+수신) 100% — 제한 적용", + "메일함 kim@aw-e2e.localhost 저장 공간 95% 경고" + ], + "mandatory": true + } + }, + { + "name": "사용량 화면 제한 플래그(/api/usage/limits)·그룹 요약", + "ok": true, + "detail": { + "limits": [ + "mail_msgs:100", + "mail_storage:95" + ], + "warnLevels": [ + 70, + 80, + 90, + 95 + ] + } + } + ], + "evidence": { + "warn": { + "storageLevel": 95, + "monthlyLevel": 100, + "nextReset": "2026-10-31T15:00:00.000Z", + "measuredAt": true, + "limit100": "월간 통수 100%: 새 발신을 막고 새 수신은 임시 실패로 응답합니다. 다음 KST 월 시작 또는 추가 용량 적용 후 복구됩니다.", + "countingRule": "per_final_recipient", + "evaluate": { + "status": 200, + "notified": 2, + "limited": 1 + } + }, + "notif": { + "count": 3, + "titles": [ + "aw-e2e.localhost 메일 저장 공간 96% — 곧 제한됩니다(최종 경고)", + "aw-e2e.localhost 월간 메일 통수(송신+수신) 100% — 제한 적용", + "메일함 kim@aw-e2e.localhost 저장 공간 95% 경고" + ], + "mandatory": true + }, + "lim": { + "limits": [ + "mail_msgs:100", + "mail_storage:95" + ], + "warnLevels": [ + 70, + 80, + 90, + 95 + ] + } + } }, { "no": 18, "title": "상품 축소 보류와 정리 후 적용", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "verified_local", + "note": "축소 미리보기→보류→정리(배정 축소·독립 메일 해지)→재검증 적합→축소 주문·승인→권리 교체", + "blockedBy": null, + "steps": [ + { + "name": "배정 화면 수치(구매·내 배정·다른 서비스·예약·미배정·변경 후 최대) + 마지막 단위 동시 요청", + "ok": true, + "detail": { + "purchased": 45, + "mine": 6, + "others": 2, + "unallocated": 37, + "race": [ + 409, + 409 + ], + "mineAfter": 6 + } + }, + { + "name": "축소 미리보기(팀→베이직): 줄여야 할 항목(메일함 6→5) 구체 표시", + "ok": true, + "detail": { + "shortages": [ + "mailboxes:1" + ] + } + }, + { + "name": "축소 요청 → 보류(사용 중 권리 즉시 회수 없음)", + "ok": true, + "detail": { + "fits": false, + "hold": "holding", + "sitesStillPurchased": 3 + } + }, + { + "name": "정리: 메일함 배정 6→5 축소 → 재검증 적합 → 축소 주문 생성", + "ok": true, + "detail": { + "allocate": 200, + "fits": true, + "shortages": [], + "order": "awaiting_payment" + } + }, + { + "name": "축소 주문 입금 확인·승인 → 권리 교체(베이직)", + "ok": true, + "detail": { + "approve": 200, + "sites": 1, + "mailboxes": 5, + "product": "pv_01a1788f4ad4ee11f68c599438" + } + } + ], + "evidence": { + "alloc": { + "purchased": 45, + "mine": 6, + "others": 2, + "unallocated": 37, + "race": [ + 409, + 409 + ], + "mineAfter": 6 + }, + "pv": { + "shortages": [ + "mailboxes:1" + ] + }, + "hold": { + "fits": false, + "hold": "holding", + "sitesStillPurchased": 3 + }, + "clean": { + "allocate": 200, + "fits": true, + "shortages": [], + "order": "awaiting_payment" + }, + "applied": { + "approve": 200, + "sites": 1, + "mailboxes": 5, + "product": "pv_01a1788f4ad4ee11f68c599438" + } + } }, { "no": 19, "title": "사이트 수 반환과 대표 메일의 독립 전환", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "partial", + "note": "반환 점검표와 메일 유지/종료 분기 규칙 확인. 실제 반환은 운영 사이트(사이트 수 사용 중) 필요(단위 테스트 test/lifecycle.test.mjs·test/mail.test.mjs 로 확인)", + "blockedBy": null, + "steps": [ + { + "name": "§14 반환 점검표(8단계)·차단 사유(대표 메일 유지 선택·독립 옵션)", + "ok": true, + "detail": { + "ok": false, + "blocking": [ + "public_stopped", + "domains_unrouted", + "no_jobs", + "mail_choice", + "mail_converted" + ], + "steps": 8 + } + }, + { + "name": "메일 유지 선택: 독립 옵션 부족이면 반환하지 않음 / 종료 선택 규칙", + "ok": true, + "detail": { + "status": 409, + "error": "site_public", + "detail": null + } + } + ], + "evidence": { + "ck": { + "ok": false, + "blocking": [ + "public_stopped", + "domains_unrouted", + "no_jobs", + "mail_choice", + "mail_converted" + ], + "steps": 8 + }, + "keep": { + "status": 409, + "error": "site_public", + "detail": null + } + } }, { "no": 20, "title": "사이트 소유권 이전", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "verified_local", + "note": "미리보기→요청·알림→수락(보류·재확인)→심사·최종 확인→원자적 완료→접근 전환·감사", + "blockedBy": null, + "steps": [ + { + "name": "이전 범위 미리보기(사이트·도메인·대표 메일·배정·진행 작업·미납 / 제외 항목)", + "ok": true, + "detail": { + "sites": 1, + "domains": 1, + "siteMail": 1, + "needed": { + "backup": 1, + "web_disk": 1073741824, + "mail_msgs": 500, + "mail_storage": 2147483648, + "mailboxes": 6 + }, + "excluded": 3 + } + }, + { + "name": "요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림", + "ok": true, + "detail": { + "id": "tr_01a178913319cb58067d72957a", + "status": "requested", + "expiresAt": "2026-11-02T14:15:44.153Z", + "bobNotified": true + } + }, + { + "name": "(정리) 받는 쪽 권리에 맞게 범위 조정: 백업 옵션 종료·메일함 배정 6→5(받는 그룹 베이직 5개)", + "ok": true, + "detail": { + "endBackup": 200, + "alloc": 200 + } + }, + { + "name": "받는 그룹 수락 → 권리 부족이면 보류(필요한 상품 안내)", + "ok": true, + "detail": { + "status": 200, + "tstatus": "reviewing", + "hold": null + } + }, + { + "name": "운영자 심사(소유 증빙·도메인 확인) 미입력 거부 → 최종 확인 일치 승인 → 원자적 완료·청구 전환 시각", + "ok": true, + "detail": { + "bad": "final_confirmation_mismatch", + "ok": 200, + "status": "completed", + "cutover": "2026-10-26T14:15:44.203Z" + } + }, + { + "name": "완료 뒤: 보내는 쪽은 접근 불가(404), 받는 쪽 대시보드·사용량 일치, 불변 감사", + "ok": true, + "detail": { + "aliceSees": 404, + "bobSees": 200, + "bobUsageSites": 0, + "audit": true + } + } + ], + "evidence": { + "pv": { + "sites": 1, + "domains": 1, + "siteMail": 1, + "needed": { + "backup": 1, + "web_disk": 1073741824, + "mail_msgs": 500, + "mail_storage": 2147483648, + "mailboxes": 6 + }, + "excluded": 3 + }, + "rq": { + "id": "tr_01a178913319cb58067d72957a", + "status": "requested", + "expiresAt": "2026-11-02T14:15:44.153Z", + "bobNotified": true + }, + "acc": { + "status": 200, + "tstatus": "reviewing", + "hold": null + }, + "ap": { + "bad": "final_confirmation_mismatch", + "ok": 200, + "status": "completed", + "cutover": "2026-10-26T14:15:44.203Z" + }, + "after": { + "aliceSees": 404, + "bobSees": 200, + "bobUsageSites": 0, + "audit": true + } + } }, { "no": 21, "title": "중지, 해지, 보존 예정일 표시", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "partial", + "note": "중지·재개·생명주기 화면 확인", + "blockedBy": null, + "steps": [ + { + "name": "§23.3 한 화면 보기: 공개/관리자 접근·사이트 수·청구·도메인/메일/백업 동작·내보내기·되돌릴 수 없는 시점·보존·다음 알림", + "ok": true, + "detail": { + "state": "도메인 설정 필요", + "siteCount": true, + "publicAccess": "제공 중", + "retention": { + "site_days": 30, + "mail_days": 30, + "source_days": 30, + "backup_days": 7, + "delete_grace_days": 1, + "notice_before_days": 3, + "readonly_retention_days": 30, + "delete_after_days": 30, + "pause_keeps_site_count": true, + "cancel_effective": "next_billing_date", + "source": "상품정책 베이직 (retention 정책) v1" + }, + "pauseImpactKeys": [ + "publicAccess", + "adminAccess", + "mail", + "backups", + "usageAndCost", + "resume", + "siteCount" + ], + "cancelImpactKeys": [ + "target", + "linked", + "immediateStops", + "remainingData", + "retentionUntil", + "deleteAfter", + "retentionSource", + "export", + "unpaid", + "refund", + "pointOfNoReturn", + "mailOptions", + "siteCount" + ], + "steps14": 8 + } + }, + { + "name": "일시 중지(확인 필수) 또는 현재 상태에서 거부 사유", + "ok": true, + "detail": { + "noConfirm": "confirmation_required", + "status": 409, + "error": "conflict" + } + }, + { + "name": "메일 서비스 생명주기 화면(해지된 독립 메일: 송수신 중지·보존 종료·삭제 예정·백업 미제공)", + "ok": true, + "detail": { + "status": 200, + "sendReceive": "송수신 가능", + "retentionUntil": false, + "deleteAfter": false, + "backupNote": "고객용 메일 백업·복원은 제공하지 않습니다(§17.4)" + } + } + ], + "evidence": { + "v": { + "state": "도메인 설정 필요", + "siteCount": true, + "publicAccess": "제공 중", + "retention": { + "site_days": 30, + "mail_days": 30, + "source_days": 30, + "backup_days": 7, + "delete_grace_days": 1, + "notice_before_days": 3, + "readonly_retention_days": 30, + "delete_after_days": 30, + "pause_keeps_site_count": true, + "cancel_effective": "next_billing_date", + "source": "상품정책 베이직 (retention 정책) v1" + }, + "pauseImpactKeys": [ + "publicAccess", + "adminAccess", + "mail", + "backups", + "usageAndCost", + "resume", + "siteCount" + ], + "cancelImpactKeys": [ + "target", + "linked", + "immediateStops", + "remainingData", + "retentionUntil", + "deleteAfter", + "retentionSource", + "export", + "unpaid", + "refund", + "pointOfNoReturn", + "mailOptions", + "siteCount" + ], + "steps14": 8 + }, + "pause": { + "noConfirm": "confirmation_required", + "status": 409, + "error": "conflict" + }, + "mailLc": { + "status": 200, + "sendReceive": "송수신 가능", + "retentionUntil": false, + "deleteAfter": false, + "backupNote": "고객용 메일 백업·복원은 제공하지 않습니다(§17.4)" + } + } }, { "no": 22, "title": "HAL 우선 배포 후 HAL을 통한 전체 모듈 배포", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "verified_local", + "note": "HAL 우선 순서·릴리스 승인·모듈 설치", + "blockedBy": null, + "steps": [ + { + "name": "릴리스 화면: 버전·main 커밋·승인 상태·HAL 실행 버전·부트스트랩(최초 Agent)·배포 순서", + "ok": true, + "detail": { + "releases": [ + "0.1.2:approved", + "0.1.1:approved" + ], + "running": { + "complete": true, + "firstInstall": false, + "releaseVersion": "0.1.1", + "mainCommit": "[redacted]", + "artifactSha256": "[redacted]", + "halRunningVersion": "0.1.1", + "firstAgent": "[redacted]", + "manualInstallLocked": true + }, + "order": [ + "hal", + "agent", + "portal", + "webmaker", + "arimail", + "backup", + "observability" + ] + } + }, + { + "name": "HAL 이 아직 0.1.2 가 아닐 때 다른 모듈 0.1.2 배포 → hal_first 거부", + "ok": true, + "detail": { + "status": "failed", + "error": "hal_first" + } + }, + { + "name": "승인되지 않은 릴리스는 거부 / 수동 설치 잠김 표시", + "ok": true, + "detail": { + "manualInstallLocked": true, + "complete": true + } + }, + { + "name": "HAL 모듈 0.1.1 을 제어 서버에 배포(실제 Agent module.install: artifact 내려받기·sha256·releases/current·유닛(stub)·실제 버전 확인)", + "ok": true, + "detail": { + "status": "done", + "steps": [ + "precheck:done", + "fanout:done", + "collect:done" + ], + "error": null, + "children": [ + "done:" + ], + "agentJobs": [], + "timedOut": false + } + } + ], + "evidence": { + "rel": { + "releases": [ + "0.1.2:approved", + "0.1.1:approved" + ], + "running": { + "complete": true, + "firstInstall": false, + "releaseVersion": "0.1.1", + "mainCommit": "[redacted]", + "artifactSha256": "[redacted]", + "halRunningVersion": "0.1.1", + "firstAgent": "[redacted]", + "manualInstallLocked": true + }, + "order": [ + "hal", + "agent", + "portal", + "webmaker", + "arimail", + "backup", + "observability" + ] + }, + "hf": { + "status": "failed", + "error": "hal_first" + }, + "na": { + "manualInstallLocked": true, + "complete": true + }, + "dep": { + "status": "done", + "steps": [ + "precheck:done", + "fanout:done", + "collect:done" + ], + "error": null, + "children": [ + "done:" + ], + "agentJobs": [], + "timedOut": false + }, + "diag": null + } }, { "no": 23, "title": "실패 작업의 재시도와 되돌리기", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "partial", + "note": "실패 기록·재시도 API·되돌리기 거부/허용 조건 확인. 성공한 재배포 뒤 실제 되돌리기는 배포 성공이 필요", + "blockedBy": null, + "steps": [ + { + "name": "배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호", + "ok": true, + "detail": { + "status": "done", + "canRetry": false, + "jobId": "j_01a1788f4d7e2248b54de0dfb1" + } + }, + { + "name": "재시도 요청(POST /api/sites/:id/jobs/:jobId/retry)", + "ok": true, + "detail": { + "status": 400, + "error": "job_not_retryable" + } + }, + { + "name": "되돌리기 요청(POST /api/sites/:id/rollback) — 이전 버전 없으면 거부", + "ok": true, + "detail": { + "status": 409, + "error": "no_previous", + "message": "되돌릴 이전 버전이 없습니다" + } + } + ], + "evidence": { + "dep": { + "status": "done", + "canRetry": false, + "jobId": "j_01a1788f4d7e2248b54de0dfb1" + }, + "rt": { + "status": 400, + "error": "job_not_retryable" + }, + "rb": { + "status": 409, + "error": "no_previous", + "message": "되돌릴 이전 버전이 없습니다" + } + } }, { "no": 24, "title": "운영자 승인, 감사 기록, 고객 알림의 일치", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "verified_local", + "note": "승인 → 고객 알림·불변 감사 기록·운영자/고객 화면 상태 일치(주문 기준)", + "blockedBy": null, + "steps": [ + { + "name": "고객 알림 센터에 승인 알림", + "ok": true, + "detail": { + "title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다", + "category": "billing", + "link": "/orders/o_01a1788f4cbcc8ea43f877bfc4" + } + }, + { + "name": "감사 기록: order.approve 는 삭제 불가(immutable)", + "ok": true, + "detail": { + "action": "order.approve", + "immutable": true + } + }, + { + "name": "운영자 화면과 고객 주문 화면의 상태 일치", + "ok": true, + "detail": { + "status": "approved", + "approvedAt": "2026-10-26T14:13:39.701Z" + } + } + ], + "evidence": { + "n": { + "title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다", + "category": "billing", + "link": "/orders/o_01a1788f4cbcc8ea43f877bfc4" + }, + "a": { + "action": "order.approve", + "immutable": true + }, + "same": { + "status": "approved", + "approvedAt": "2026-10-26T14:13:39.701Z" + } + } }, { "no": 25, "title": "HAL, 로그인, 비밀정보 관리, 인증서 기반의 재해복구 훈련", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "partial", + "note": "DR 설정(RTO/RPO/주기/보존)·사본 생성·검증·외부 보관·복원 승인/기존 제어 중지 확인/완료 항목·훈련 실패/성공→준비 완료 기록까지 확인. 실제 다른 호스트 복원은 실 서버 필요", + "blockedBy": null, + "steps": [ + { + "name": "설정 전 준비 상태(미설정 항목)", + "ok": true, + "detail": { + "ready": false, + "missingConfig": [ + "rto_minutes", + "rpo_minutes", + "snapshot_interval_minutes", + "retention_count" + ], + "lastDrill": null, + "lastSnapshot": null, + "config": { + "rto_minutes": null, + "rpo_minutes": null, + "snapshot_interval_minutes": null, + "retention_count": null, + "external_store": null + }, + "label": "정식 운영 준비 전(복구 훈련 또는 설정 필요)" + } + }, + { + "name": "RTO·RPO·사본 주기·보존 개수·외부 보관 위치 설정", + "ok": true, + "detail": { + "id": 1, + "rto_minutes": 60, + "rpo_minutes": 30, + "snapshot_interval_minutes": 60, + "retention_count": 3, + "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026141334/dr-external", + "updated_at": "2026-10-26T14:15:53.151Z", + "updated_by": "op_01a1788f43bf7bccb5fba30c59" + } + }, + { + "name": "복구 사본 생성·검증·외부 보관(vault.key 제외)", + "ok": true, + "detail": { + "verify": "ok", + "external": true, + "sha": true + } + }, + { + "name": "복원 요청 → 승인 전 시작 거부 → 승인 → 기존 제어 중지 확인 없이는 거부 → 시작(명령 안내) → 확인 항목 부족 거부 → 완료", + "ok": true, + "detail": { + "early": "restore_not_approved", + "noStop": "old_control_not_confirmed", + "command": true, + "incomplete": "checks_incomplete", + "final": "completed" + } + }, + { + "name": "복구 훈련: 항목 하나 실패 → failed, 모두 성공·측정 RTO/RPO → 준비 완료", + "ok": true, + "detail": { + "first": "failed", + "second": "success", + "ready": true, + "measured": {} + } + } + ], + "evidence": { + "r0": { + "ready": false, + "missingConfig": [ + "rto_minutes", + "rpo_minutes", + "snapshot_interval_minutes", + "retention_count" + ], + "lastDrill": null, + "lastSnapshot": null, + "config": { + "rto_minutes": null, + "rpo_minutes": null, + "snapshot_interval_minutes": null, + "retention_count": null, + "external_store": null + }, + "label": "정식 운영 준비 전(복구 훈련 또는 설정 필요)" + }, + "cfg": { + "id": 1, + "rto_minutes": 60, + "rpo_minutes": 30, + "snapshot_interval_minutes": 60, + "retention_count": 3, + "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026141334/dr-external", + "updated_at": "2026-10-26T14:15:53.151Z", + "updated_by": "op_01a1788f43bf7bccb5fba30c59" + }, + "snap": { + "verify": "ok", + "external": true, + "sha": true + }, + "rs": { + "early": "restore_not_approved", + "noStop": "old_control_not_confirmed", + "command": true, + "incomplete": "checks_incomplete", + "final": "completed" + }, + "drill": { + "first": "failed", + "second": "success", + "ready": true, + "measured": {} + } + } }, { "no": 26, "title": "새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "verified_local", + "note": "포털·HAL 재시작 뒤 세션·진행 작업(외부 대기)·작업 목록 유지 확인. 진행 중(running) 배포의 단계 재개는 배포 성공(흐름 6) 뒤 확인 필요", + "blockedBy": null, + "steps": [ + { + "name": "재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록", + "ok": true, + "detail": { + "mailJob": { + "id": "j_01a1788ff65e15ccfd1505fea6", + "status": "awaiting_external" + }, + "halJobs": 18 + } + }, + { + "name": "포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지", + "ok": true, + "detail": { + "sessionKept": "[redacted]", + "mailJob": { + "id": "j_01a1788ff65e15ccfd1505fea6", + "status": "awaiting_external" + }, + "same": true + } + }, + { + "name": "HAL 재시작 → 운영자 세션·작업 목록·서버 등록 유지", + "ok": true, + "detail": { + "opsSessionKept": "[redacted]", + "halJobs": 18, + "sameCount": true + } + } + ], + "evidence": { + "before": { + "mailJob": { + "id": "j_01a1788ff65e15ccfd1505fea6", + "status": "awaiting_external" + }, + "halJobs": 18 + }, + "pr": { + "sessionKept": "[redacted]", + "mailJob": { + "id": "j_01a1788ff65e15ccfd1505fea6", + "status": "awaiting_external" + }, + "same": true + }, + "hr": { + "opsSessionKept": "[redacted]", + "halJobs": 18, + "sameCount": true + } + } }, { "no": 27, "title": "마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "verified_local", + "note": "마지막 사이트 수 1개를 두 배포가 동시에 요청 → 하나만 예약 성공, 다른 쪽은 부족 사유(§13.3)", + "blockedBy": null, + "steps": [ + { + "name": "밥 개인 그룹: 베이직(사이트 1) 주문·승인", + "ok": true, + "detail": { + "granted": 6 + } + }, + { + "name": "사이트 2개 생성·저장본", + "ok": true, + "detail": [ + { + "id": "s_01a1788fd9df3235dfa7d0461c", + "group_id": "g_01a1788f4c75bbf31637a86691", + "name": "동시 A", + "purpose": "e2e", + "webmaker_project_id": "00000000000000000000000000000002", + "template": null, + "lang": "ko", + "timezone": "Asia/Seoul", + "lifecycle": "draft", + "deploy_mode": "managed", + "primary_domain": null, + "site_count_state": "none", + "site_count_reservation_id": null, + "server_id": null, + "external_agent_id": null, + "current_version": null, + "current_deployment_id": null, + "deploy_verify": null, + "health": "unknown", + "health_checked_at": null, + "maintenance": 0, + "search_visible": 1, + "description": null, + "settings_json": null, + "paused_at": null, + "cancel_at": null, + "retention_until": null, + "delete_after": null, + "deleted_at": null, + "created_by": "u_01a1788f4c74ff0259e7da3335", + "created_at": "2026-10-26T14:14:15.775Z", + "updated_at": "2026-10-26T14:14:15.784Z", + "runtime": "static", + "consent_version": null, + "pending_cleanup_json": null, + "return_due_at": null, + "last_deploy_at": null, + "last_external_check_json": null, + "next_job_json": null, + "mail_choice": null, + "next_notice_at": null, + "delete_hold_reason": null, + "public_stopped_at": null, + "prev_lifecycle": null, + "settings": {}, + "pendingCleanup": null, + "lastExternalCheck": null, + "nextJob": null + }, + { + "id": "s_01a1788fd9e3f2709aee825315", + "group_id": "g_01a1788f4c75bbf31637a86691", + "name": "동시 B", + "purpose": "e2e", + "webmaker_project_id": "00000000000000000000000000000003", + "template": null, + "lang": "ko", + "timezone": "Asia/Seoul", + "lifecycle": "draft", + "deploy_mode": "managed", + "primary_domain": null, + "site_count_state": "none", + "site_count_reservation_id": null, + "server_id": null, + "external_agent_id": null, + "current_version": null, + "current_deployment_id": null, + "deploy_verify": null, + "health": "unknown", + "health_checked_at": null, + "maintenance": 0, + "search_visible": 1, + "description": null, + "settings_json": null, + "paused_at": null, + "cancel_at": null, + "retention_until": null, + "delete_after": null, + "deleted_at": null, + "created_by": "u_01a1788f4c74ff0259e7da3335", + "created_at": "2026-10-26T14:14:15.779Z", + "updated_at": "2026-10-26T14:14:15.786Z", + "runtime": "static", + "consent_version": null, + "pending_cleanup_json": null, + "return_due_at": null, + "last_deploy_at": null, + "last_external_check_json": null, + "next_job_json": null, + "mail_choice": null, + "next_notice_at": null, + "delete_hold_reason": null, + "public_stopped_at": null, + "prev_lifecycle": null, + "settings": {}, + "pendingCleanup": null, + "lastExternalCheck": null, + "nextJob": null + } + ] + }, + { + "name": "두 사이트 동시 최초 배포 요청 → 하나만 200, 다른 쪽 409 precheck_failed(sites 부족)", + "ok": true, + "detail": { + "ok": 1, + "blocked": "precheck_failed", + "shortage": "block" + } + }, + { + "name": "(정리) 선행 결함으로 영원히 진행 중인 배포 취소·예약 반환", + "ok": true, + "detail": { + "cancel": 200, + "cleanup": 200, + "unallocated": 0 + } + } + ], + "evidence": { + "race": { + "ok": 1, + "blocked": "precheck_failed", + "shortage": "block" + } + } }, { "no": 28, "title": "외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "verified_local", + "note": "외부 Agent 연결·제거·해제 API 확인. 재배포·실패 정리는 배포 성공이 필요(증거 참조)", + "blockedBy": null, + "steps": [ + { + "name": "외부 배포 사이트 생성(초안) + 저장본", + "ok": true, + "detail": { + "id": "s_01a17891100c4d7a376f63d2b9", + "group_id": "g_01a1788f4c480f46fa7b303c98", + "name": "E2E 외부 사이트", + "purpose": "e2e", + "webmaker_project_id": "00000000000000000000000000000004", + "template": null, + "lang": "ko", + "timezone": "Asia/Seoul", + "lifecycle": "draft", + "deploy_mode": "external", + "primary_domain": null, + "site_count_state": "none", + "site_count_reservation_id": null, + "server_id": null, + "external_agent_id": null, + "current_version": null, + "current_deployment_id": null, + "deploy_verify": null, + "health": "unknown", + "health_checked_at": null, + "maintenance": 0, + "search_visible": 1, + "description": null, + "settings_json": null, + "paused_at": null, + "cancel_at": null, + "retention_until": null, + "delete_after": null, + "deleted_at": null, + "created_by": "u_01a1788f4ba6284a77cd7cbd45", + "created_at": "2026-10-26T14:15:35.180Z", + "updated_at": "2026-10-26T14:15:35.186Z", + "runtime": "static", + "consent_version": null, + "pending_cleanup_json": null, + "return_due_at": null, + "last_deploy_at": null, + "last_external_check_json": null, + "next_job_json": null, + "mail_choice": null, + "next_notice_at": null, + "delete_hold_reason": null, + "public_stopped_at": null, + "prev_lifecycle": null, + "settings": {}, + "pendingCleanup": null, + "lastExternalCheck": null, + "nextJob": null + } + }, + { + "name": "배포 방식 화면: 외부 대상·고객 책임·미제공 기능(메일 미지원)·건별 비용·동의 버전", + "ok": true, + "detail": { + "notProvided": "[redacted]", + "consentVersion": "external-deploy-2026-10-0.1.1", + "agreed": false + } + }, + { + "name": "동의 전 Agent 발급 거부(consent_required) → 동의 기록(버전)", + "ok": true, + "detail": { + "before": "consent_required", + "consent": 200 + } + }, + { + "name": "외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음)", + "ok": true, + "detail": { + "token": "[redacted]", + "install": "curl -fsSL http://127.0.0.1:39783/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:39783 --token … --external --site s_01a17891100c4d7a376f63d2b9", + "expiresAt": "2026-10-26T14:25:35.492Z", + "status": "pending" + } + }, + { + "name": "고객 서버 역할의 외부 Agent 프로세스 등록·실행(HAL 등록, 사이트 귀속)", + "ok": true, + "detail": { + "agentId": "[redacted]" + } + }, + { + "name": "포털 연결 확인(외부 Agent 상태 connected)", + "ok": true, + "detail": { + "status": "connected", + "stale": false, + "halAgentId": "[redacted]" + } + }, + { + "name": "배포 전 확인(외부: 관리형 용량 확인 없음)", + "ok": true, + "detail": { + "ok": true, + "blocks": [] + } + }, + { + "name": "외부 배포 → 외부 Agent 가 site.deploy 수행", + "ok": true, + "detail": { + "status": "done", + "error": null, + "errorCode": null, + "steps": [ + "accept:done", + "prepare_version:done", + "prepare_target:done", + "apply:done", + "data_check:done", + "start:done", + "internal_check:done", + "external_check:done", + "domain_https:done", + "done:done" + ] + } + }, + { + "name": "외부 사이트 개요: 관리형 지표 없음·제공되지 않음 표기·Agent 상태·마지막 배포 결과", + "ok": true, + "detail": { + "kind": "external", + "notProvided": "[redacted]", + "agent": "connected", + "lastDeployOk": true + } + }, + { + "name": "배포 제거(외부 서버 파일·실행 상태 제거, 실패 잔여 정리)", + "ok": true, + "detail": { + "status": "done", + "error": null + } + }, + { + "name": "Agent 연결 해제(revoke) → 상태 없음", + "ok": true, + "detail": { + "status": 200, + "agent": null + } + } + ], + "evidence": { + "conn": { + "status": "connected", + "stale": false, + "halAgentId": "[redacted]" + }, + "dep": { + "status": "done", + "error": null, + "errorCode": null, + "steps": [ + "accept:done", + "prepare_version:done", + "prepare_target:done", + "apply:done", + "data_check:done", + "start:done", + "internal_check:done", + "external_check:done", + "domain_https:done", + "done:done" + ] + }, + "rm": { + "status": "done", + "error": null + }, + "rv": { + "status": 200, + "agent": null + } + } }, { "no": 29, "title": "HAL 장애 중 기존 서비스 유지와 신규 변경 제한 표시", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "verified_local", + "note": "HAL 중단 중 포털 조회 유지·신규 배포 차단(정보 확인 지연), HAL 재기동 뒤 서버·Agent 복귀", + "blockedBy": null, + "steps": [ + { + "name": "HAL 중단 중: 기존 조회(사이트 목록·개요)는 제공, 배포 전 확인/배포는 정보 확인 지연·차단", + "ok": true, + "detail": { + "list": 200, + "overview": 200, + "precheck": { + "status": 200, + "blocks": [ + "target:HAL(중앙 관리)에 연결할 수 없어 배포 위치를 확인하지 못했습니다(정" + ] + }, + "deploy": { + "status": 409, + "error": "precheck_failed", + "message": "배포 전 확인에서 차단된 항목이 있습니다" + } + } + }, + { + "name": "대시보드/알림에 중앙 상태 확인 지연 표시(가능하면)", + "ok": true, + "detail": { + "status": 200, + "halState": null + } + }, + { + "name": "HAL 재기동(같은 호스트의 죽은 인스턴스 임대 이어받기) → 서버·Agent 상태 복귀", + "ok": true, + "detail": { + "takeoverNeeded": false, + "bootstrap": true, + "readyServers": 4 + } + } + ], + "evidence": { + "during": { + "list": 200, + "overview": 200, + "precheck": { + "status": 200, + "blocks": [ + "target:HAL(중앙 관리)에 연결할 수 없어 배포 위치를 확인하지 못했습니다(정" + ] + }, + "deploy": { + "status": 409, + "error": "precheck_failed", + "message": "배포 전 확인에서 차단된 항목이 있습니다" + } + }, + "dash": { + "status": 200, + "halState": null + }, + "back": { + "takeoverNeeded": false, + "bootstrap": true, + "readyServers": 4 + } + } }, { "no": 30, "title": "모듈 업데이트, 일부 서버 실패, 대상별 되돌리기와 상태 조정", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "verified_local", + "note": "대상별 자식 작업·일부 실패 partial 요약(전체 성공으로 묶지 않음)·건강 확인·drift 조정·되돌리기 3단계 확인 거부", + "blockedBy": null, + "steps": [ + { + "name": "agent 모듈을 두 서버 + 없는 서버에 배포 → 대상별 자식 작업·일부 실패를 성공으로 묶지 않음(partial/all_failed)", + "ok": true, + "detail": { + "status": "failed", + "errorCode": "partial_failure", + "overall": "partial", + "children": [ + "srv_missing_e2e:failed:서버가 없습니다", + "srv_01a1788f456f9457904975ca45:done:", + "srv_01a1788f44c213e5c17081909b:done:" + ], + "timedOut": false + } + }, + { + "name": "모듈 건강 확인 작업·목표/실제 버전 차이(drift) 표시·다시 맞추기 작업", + "ok": true, + "detail": { + "health": "done", + "drift": 0, + "reconcile": 200, + "overviewComponents": [ + "HAL:ok", + "portal:unknown", + "webmaker:unknown", + "arimail:unknown", + "backup:ok", + "observability:ok" + ] + } + }, + { + "name": "되돌리기는 3단계 확인(영향 범위·식별정보·최종 실행) 없이는 거부", + "ok": true, + "detail": { + "step1": "confirm_impact_required", + "step2": "identifiers_mismatch" + } + } + ], + "evidence": { + "d": { + "status": "failed", + "errorCode": "partial_failure", + "overall": "partial", + "children": [ + "srv_missing_e2e:failed:서버가 없습니다", + "srv_01a1788f456f9457904975ca45:done:", + "srv_01a1788f44c213e5c17081909b:done:" + ], + "timedOut": false + }, + "h": { + "health": "done", + "drift": 0, + "reconcile": 200, + "overviewComponents": [ + "HAL:ok", + "portal:unknown", + "webmaker:unknown", + "arimail:unknown", + "backup:ok", + "observability:ok" + ] + }, + "rb": { + "step1": "confirm_impact_required", + "step2": "identifiers_mismatch" + } + } }, { "no": 31, "title": "긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "partial", + "note": "정상 상태 선언 거부·3단계 승인(순서/식별정보/문구 거부)·자격 1회 발급·일반 배포/범위 밖/잘못된 자격 거부·수행·폐기 뒤 거부·조정·종료 확인. 자격 만료 거부는 유효 시간(분 단위 설정) 때문에 로컬 실행에서 생략(단위 테스트 test/hal.test.mjs 로 확인)", + "blockedBy": null, + "steps": [ + { + "name": "HAL 정상 상태에서 선언 거부(hal_healthy)", + "ok": true, + "detail": "hal_healthy" + }, + { + "name": "critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회)", + "ok": true, + "detail": { + "id": "em_01a178915754f8b9d8ef0ffc24", + "noCred": "approvals_insufficient", + "wrongStep": "wrong_step", + "badIds": "identifiers_mismatch", + "badText": "final_confirm_mismatch", + "approved": "approved", + "approvals": 3, + "credential": "[redacted]", + "expiresAt": "2026-10-26T14:45:53.458Z" + } + }, + { + "name": "일반 배포(module.deploy) 거부 · 범위 밖 거부 · 잘못된 자격 거부 · 허용 행동 수행 · 확인 뒤 자격 폐기 · 폐기 뒤 거부 · 조정 · 종료", + "ok": true, + "detail": { + "forbidden": "action_forbidden", + "outOfScope": "out_of_scope", + "badCred": "bad_credential", + "performed": 400, + "revoked": true, + "afterRevoke": "credential_revoked", + "reconcile": 200, + "closed": "closed" + } + } + ], + "evidence": { + "healthy": "hal_healthy", + "sess": { + "id": "em_01a178915754f8b9d8ef0ffc24", + "noCred": "approvals_insufficient", + "wrongStep": "wrong_step", + "badIds": "identifiers_mismatch", + "badText": "final_confirm_mismatch", + "approved": "approved", + "approvals": 3, + "expiresAt": "2026-10-26T14:45:53.458Z" + }, + "perf": { + "forbidden": "action_forbidden", + "outOfScope": "out_of_scope", + "badCred": "bad_credential", + "performed": 400, + "revoked": true, + "afterRevoke": "credential_revoked", + "reconcile": 200, + "closed": "closed" + } + } }, { "no": 32, "title": "Source 옵션 만료 후 30일 read-only, clone·export와 삭제 완료", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "partial", + "note": "활성화·export·해제→읽기 전용(삭제 예정일)까지 확인. 30일 뒤 삭제 완료는 시간 경과가 필요(단위 테스트 test/sites.test.mjs 에서 sweep 로 확인)", + "blockedBy": null, + "steps": [ + { + "name": "Source 옵션 주문(대상 사이트)·승인 → source 권리", + "ok": true, + "detail": { + "granted": 7 + } + }, + { + "name": "Source 옵션 활성화(권리 안에서) → 저장소·브랜치·변경 이력·clone 정보", + "ok": true, + "detail": { + "activate": 200, + "status": "active", + "provider": "gitea", + "changes": 1, + "clone": true + } + }, + { + "name": "export(저장본 내려받기)", + "ok": true, + "detail": { + "status": 200, + "type": "application/gzip" + } + }, + { + "name": "옵션 해제 → 30일 읽기 전용(만료일·삭제 예정일·재활성화 안내) · push 차단", + "ok": true, + "detail": { + "status": "readonly", + "deleteAfter": "2026-11-25T14:15:44.084Z", + "readonlyUntil": "2026-11-25T14:15:44.084Z" + } + }, + { + "name": "삭제 정리 작업(sweep)은 삭제 예정일 전이면 지우지 않음", + "ok": true, + "detail": { + "sweep": 200, + "statusAfter": "readonly" + } + } + ], + "evidence": { + "act": { + "activate": 200, + "status": "active", + "provider": "gitea", + "changes": 1, + "clone": true + }, + "ex": { + "status": 200, + "type": "application/gzip" + }, + "un": { + "status": "readonly", + "deleteAfter": "2026-11-25T14:15:44.084Z", + "readonlyUntil": "2026-11-25T14:15:44.084Z" + }, + "sweep": { + "sweep": 200, + "statusAfter": "readonly" + } + } }, { "no": 33, "title": "메일 서비스의 사이트 대표→독립 전환 후 항목 유지", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "partial", + "note": "전환 점검표 8항목과 미충족 시 차단 확인. 전후 실제 송수신 확인·DNS 유지 검증은 실 DNS/AriMail 필요(단위 테스트 test/mail.test.mjs 에서 가짜로 전환 완료·되돌리기 확인)", + "blockedBy": null, + "steps": [ + { + "name": "사이트 대표→독립 전환 점검표(주소·메일함·별칭·전달·비밀번호·DNS·옵션·작업)", + "ok": true, + "detail": { + "canConvert": false, + "items": [ + "addresses:ok", + "data:ok", + "aliases:ok", + "forwards:ok", + "passwords:ok", + "dns:x", + "entitlement:x", + "jobs:x" + ] + } + }, + { + "name": "DNS 미확인 상태에서 전환 요청은 차단", + "ok": true, + "detail": { + "status": 409, + "error": "conversion_blocked" + } + } + ], + "evidence": { + "ck": { + "canConvert": false, + "items": [ + "addresses:ok", + "data:ok", + "aliases:ok", + "forwards:ok", + "passwords:ok", + "dns:x", + "entitlement:x", + "jobs:x" + ] + }, + "tr": { + "status": 409, + "error": "conversion_blocked" + } + } }, { "no": 34, "title": "일시 중지·해지 예약·보존·삭제 단계의 표시 일치", - "verdict": "unrecorded", - "note": "하네스가 이 흐름을 실행하지 못함(앞선 오류)", - "evidence": {}, - "steps": [] + "verdict": "partial", + "note": "해지된 메일 서비스의 읽기 전용 보존·삭제 예정 표시와 사이트 생명주기 화면의 단계별 항목 확인. 사이트의 해지 예약→보존→삭제 전이는 운영 중 사이트 필요(단위 테스트 test/lifecycle.test.mjs 로 확인)", + "blockedBy": null, + "steps": [ + { + "name": "§23.3 한 화면 보기: 공개/관리자 접근·사이트 수·청구·도메인/메일/백업 동작·내보내기·되돌릴 수 없는 시점·보존·다음 알림", + "ok": true, + "detail": { + "state": "도메인 설정 필요", + "siteCount": true, + "publicAccess": "제공 중", + "retention": { + "site_days": 30, + "mail_days": 30, + "source_days": 30, + "backup_days": 7, + "delete_grace_days": 1, + "notice_before_days": 3, + "readonly_retention_days": 30, + "delete_after_days": 30, + "pause_keeps_site_count": true, + "cancel_effective": "next_billing_date", + "source": "상품정책 베이직 (retention 정책) v1" + }, + "pauseImpactKeys": [ + "publicAccess", + "adminAccess", + "mail", + "backups", + "usageAndCost", + "resume", + "siteCount" + ], + "cancelImpactKeys": [ + "target", + "linked", + "immediateStops", + "remainingData", + "retentionUntil", + "deleteAfter", + "retentionSource", + "export", + "unpaid", + "refund", + "pointOfNoReturn", + "mailOptions", + "siteCount" + ], + "steps14": 8 + } + }, + { + "name": "일시 중지(확인 필수) 또는 현재 상태에서 거부 사유", + "ok": true, + "detail": { + "noConfirm": "confirmation_required", + "status": 409, + "error": "conflict" + } + }, + { + "name": "메일 서비스 생명주기 화면(해지된 독립 메일: 송수신 중지·보존 종료·삭제 예정·백업 미제공)", + "ok": true, + "detail": { + "status": 200, + "sendReceive": "송수신 가능", + "retentionUntil": false, + "deleteAfter": false, + "backupNote": "고객용 메일 백업·복원은 제공하지 않습니다(§17.4)" + } + } + ], + "evidence": { + "v": { + "state": "도메인 설정 필요", + "siteCount": true, + "publicAccess": "제공 중", + "retention": { + "site_days": 30, + "mail_days": 30, + "source_days": 30, + "backup_days": 7, + "delete_grace_days": 1, + "notice_before_days": 3, + "readonly_retention_days": 30, + "delete_after_days": 30, + "pause_keeps_site_count": true, + "cancel_effective": "next_billing_date", + "source": "상품정책 베이직 (retention 정책) v1" + }, + "pauseImpactKeys": [ + "publicAccess", + "adminAccess", + "mail", + "backups", + "usageAndCost", + "resume", + "siteCount" + ], + "cancelImpactKeys": [ + "target", + "linked", + "immediateStops", + "remainingData", + "retentionUntil", + "deleteAfter", + "retentionSource", + "export", + "unpaid", + "refund", + "pointOfNoReturn", + "mailOptions", + "siteCount" + ], + "steps14": 8 + }, + "mailLc": { + "status": 200, + "sendReceive": "송수신 가능", + "retentionUntil": false, + "deleteAfter": false, + "backupNote": "고객용 메일 백업·복원은 제공하지 않습니다(§17.4)" + } + } } ], "bugs": [], - "needsInfrastructure": [] + "needsInfrastructure": [ + "도메인 소유 확인(TXT)·DNS 반영·HTTPS 발급·외부 접속 확인은 실제 DNS 와 공인 서버가 필요하다(흐름 7·8·11·14·15)", + "메일 DNS(MX·SPF·DKIM·DMARC) 반영과 외부 송신·수신·회신 왕복은 실 도메인·실 AriMail(SMTP 587/수신함)·외부 검증 주소가 필요하다(흐름 10 활성화 완료·11·33 완료)", + "재해복구의 실제 복원(hal-restore.sh 로 다른 호스트에 HAL 재기동)·실제 운영자 로그인·HTTPS·고객 서비스 외부 확인은 실 서버가 필요하다(흐름 25 기록·훈련 절차만 로컬 확인)" + ] } \ No newline at end of file diff --git a/docs/verification/local-e2e-2026-10-26.md b/docs/verification/local-e2e-2026-10-26.md index b531450..627d60c 100644 --- a/docs/verification/local-e2e-2026-10-26.md +++ b/docs/verification/local-e2e-2026-10-26.md @@ -1,57 +1,57 @@ # 로컬 종단 간 검증 기록 — 2026-10-26 -생성: 2026-10-26T14:12:36.517Z · `node --no-warnings test/e2e.test.mjs` (test/e2e/) +생성: 2026-10-26T14:15:56.105Z · `node --no-warnings test/e2e.test.mjs` (test/e2e/) -실행 환경: 실제 HAL·고객 포털·Node Agent(명령 stub 모드) 프로세스 0대 + 가짜 Webmaker/AriMail/발신 메일 서버(127.0.0.1). 실 DNS·실 메일 왕복·실 서버는 포함하지 않는다. +실행 환경: 실제 HAL·고객 포털·Node Agent(명령 stub 모드) 프로세스 4대 + 가짜 Webmaker/AriMail/발신 메일 서버(127.0.0.1). 실 DNS·실 메일 왕복·실 서버는 포함하지 않는다. ## 요약 | 판정 | 수 | |---|---| -| 로컬 확인 | 0 | -| 부분 확인 | 0 | -| 로컬 확인 불가 | 0 | +| 로컬 확인 | 19 | +| 부분 확인 | 14 | +| 로컬 확인 불가 | 1 | | 실패(결함) | 0 | -| unrecorded | 34 | +| unrecorded | 0 | ## 흐름별 판정(Release 0.1.1 완료 조건 34개) | # | 흐름 | 판정 | 확인 내용 / 비고 | |---|---|---|---| -| 1 | 신규 고객 가입과 이메일 확인 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 2 | 개인 사용자 그룹 생성과 다른 사용자 초대 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 3 | 상품 주문, 결제 확인, 권리 활성화 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 4 | 초안 사이트 생성 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 5 | Webmaker에서 페이지 제작 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 6 | 관리형 배포 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 7 | 도메인 연결과 HTTPS 발급 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 8 | 외부 주소에서 사이트 확인 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 9 | 사이트 정보와 접속·사용량 통계 확인 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 10 | 사이트 대표 메일과 메일함 생성 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 11 | 외부 송신, 수신, 회신 확인 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 12 | 독립 메일 서비스 생성 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 13 | 백업 옵션 구매와 첫 백업 확인 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 14 | 백업 복원과 외부 사이트 확인 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 15 | 관리형 사이트 서버 이전과 외부 확인 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 16 | 외부 배포 사이트 생성과 배포 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 17 | 사용량 경고와 한도 동작 확인 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 18 | 상품 축소 보류와 정리 후 적용 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 19 | 사이트 수 반환과 대표 메일의 독립 전환 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 20 | 사이트 소유권 이전 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 21 | 중지, 해지, 보존 예정일 표시 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 22 | HAL 우선 배포 후 HAL을 통한 전체 모듈 배포 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 23 | 실패 작업의 재시도와 되돌리기 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 24 | 운영자 승인, 감사 기록, 고객 알림의 일치 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 25 | HAL, 로그인, 비밀정보 관리, 인증서 기반의 재해복구 훈련 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 26 | 새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 27 | 마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 28 | 외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 29 | HAL 장애 중 기존 서비스 유지와 신규 변경 제한 표시 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 30 | 모듈 업데이트, 일부 서버 실패, 대상별 되돌리기와 상태 조정 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 31 | 긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 32 | Source 옵션 만료 후 30일 read-only, clone·export와 삭제 완료 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 33 | 메일 서비스의 사이트 대표→독립 전환 후 항목 유지 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | -| 34 | 일시 중지·해지 예약·보존·삭제 단계의 표시 일치 | unrecorded | 하네스가 이 흐름을 실행하지 못함(앞선 오류) | +| 1 | 신규 고객 가입과 이메일 확인 | 로컬 확인 | 가입→확인 메일(포획)→1회용 토큰 확인→개인 그룹·별칭→중복 가입 안내→기기 목록·세션 종료 | +| 2 | 개인 사용자 그룹 생성과 다른 사용자 초대 | 로컬 확인 | 팀 그룹 생성·초대 메일·1회용 수락·별칭 user-02·역할 거부·그룹 전환 격리 | +| 3 | 상품 주문, 결제 확인, 권리 활성화 | 로컬 확인 | 상품 목록→주문(멱등·수동 결제)→입금 확인 전 승인 거부→입금 확인→승인→권리 활성화→재승인 중복 없음→청구 화면 | +| 4 | 초안 사이트 생성 | 로컬 확인 | 초안 생성·Webmaker 프로젝트 연결·사이트 수 미소비·목록 필터 | +| 5 | Webmaker에서 페이지 제작 | 로컬 확인 | SSO 티켓(1회용) 진입·저장본 목록·배포 전 확인(가짜 Webmaker — 실제 편집 UI 는 포함하지 않음) | +| 6 | 관리형 배포 | 로컬 확인 | 배포 요청→예약→HAL 작업→Agent(stub) 배포→도메인 설정 필요 상태(대표 도메인 없음) | +| 7 | 도메인 연결과 HTTPS 발급 | 부분 확인 | 도메인 추가·복사 가능한 TXT/DNS 값·외부 대기 상태까지 확인. 실 DNS 반영·인증서 발급·대표 도메인 확정은 로컬 확인 불가 | +| 8 | 외부 주소에서 사이트 확인 | 부분 확인 | 플랫폼 확인 주소(preview.localhost)로 HAL 외부 확인을 시도했으나 로컬에는 80 포트 웹서버가 없어 외부 확인 결과만 기록됨 | +| 9 | 사이트 정보와 접속·사용량 통계 확인 | 부분 확인 | 개요·사용량 조회(Agent stub 가 수집한 값) | +| 10 | 사이트 대표 메일과 메일함 생성 | 부분 확인 | 대표 메일 생성(AriMail 테넌트·보장량·ariweb 표시)·메일함(비밀번호 1회·초대)·DNS 미반영 구분·활성화 작업 외부 대기까지 확인. DNS 반영 뒤 활성화 완료는 실 DNS 필요 | +| 11 | 외부 송신, 수신, 회신 확인 | 로컬 확인 불가 | 외부 송신·수신·회신은 실제 SMTP 제출(587)·수신함·외부 자동 회신 주소가 필요하다. 포털은 검증 주소 미설정 시 needs_user, 왕복 실패 시 활성화하지 않는 규칙(단위 테스트 test/mail.test.mjs)으로만 확인됨 | +| 12 | 독립 메일 서비스 생성 | 로컬 확인 | 독립 메일 서비스 생성: 독립 옵션 1 소비, 운영 사이트 수 미소비, 목록에 백업 미제공 문구 | +| 13 | 백업 옵션 구매와 첫 백업 확인 | 로컬 확인 | 옵션 구매·활성화·첫 백업(HAL→운영 Agent 생성→저장 Agent 보관→검증) | +| 14 | 백업 복원과 외부 사이트 확인 | 부분 확인 | 복원 요청·운영자 최종 확인(불일치 거부·승인 멱등)·단계 진행까지. 외부 사이트 확인은 공인 서버 필요 | +| 15 | 관리형 사이트 서버 이전과 외부 확인 | 부분 확인 | 이전 경계값·요청 규칙 확인. 실제 두 Agent 간 사전 복사·전환·외부 확인은 배포된 사이트(흐름 6)와 공인 확인 주소가 필요 | +| 16 | 외부 배포 사이트 생성과 배포 | 로컬 확인 | 동의→토큰→실제 외부 Agent 등록·연결→배포→개요(제공되지 않음)→제거 | +| 17 | 사용량 경고와 한도 동작 확인 | 로컬 확인 | 메일 저장 공간 95%·월간 통수 100% 를 수집해 구간·제한 사유·KST 초기화·알림을 확인(웹 디스크/아웃바운드는 배포가 있어야 수집) | +| 18 | 상품 축소 보류와 정리 후 적용 | 로컬 확인 | 축소 미리보기→보류→정리(배정 축소·독립 메일 해지)→재검증 적합→축소 주문·승인→권리 교체 | +| 19 | 사이트 수 반환과 대표 메일의 독립 전환 | 부분 확인 | 반환 점검표와 메일 유지/종료 분기 규칙 확인. 실제 반환은 운영 사이트(사이트 수 사용 중) 필요(단위 테스트 test/lifecycle.test.mjs·test/mail.test.mjs 로 확인) | +| 20 | 사이트 소유권 이전 | 로컬 확인 | 미리보기→요청·알림→수락(보류·재확인)→심사·최종 확인→원자적 완료→접근 전환·감사 | +| 21 | 중지, 해지, 보존 예정일 표시 | 부분 확인 | 중지·재개·생명주기 화면 확인 | +| 22 | HAL 우선 배포 후 HAL을 통한 전체 모듈 배포 | 로컬 확인 | HAL 우선 순서·릴리스 승인·모듈 설치 | +| 23 | 실패 작업의 재시도와 되돌리기 | 부분 확인 | 실패 기록·재시도 API·되돌리기 거부/허용 조건 확인. 성공한 재배포 뒤 실제 되돌리기는 배포 성공이 필요 | +| 24 | 운영자 승인, 감사 기록, 고객 알림의 일치 | 로컬 확인 | 승인 → 고객 알림·불변 감사 기록·운영자/고객 화면 상태 일치(주문 기준) | +| 25 | HAL, 로그인, 비밀정보 관리, 인증서 기반의 재해복구 훈련 | 부분 확인 | DR 설정(RTO/RPO/주기/보존)·사본 생성·검증·외부 보관·복원 승인/기존 제어 중지 확인/완료 항목·훈련 실패/성공→준비 완료 기록까지 확인. 실제 다른 호스트 복원은 실 서버 필요 | +| 26 | 새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지 | 로컬 확인 | 포털·HAL 재시작 뒤 세션·진행 작업(외부 대기)·작업 목록 유지 확인. 진행 중(running) 배포의 단계 재개는 배포 성공(흐름 6) 뒤 확인 필요 | +| 27 | 마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공 | 로컬 확인 | 마지막 사이트 수 1개를 두 배포가 동시에 요청 → 하나만 예약 성공, 다른 쪽은 부족 사유(§13.3) | +| 28 | 외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리 | 로컬 확인 | 외부 Agent 연결·제거·해제 API 확인. 재배포·실패 정리는 배포 성공이 필요(증거 참조) | +| 29 | HAL 장애 중 기존 서비스 유지와 신규 변경 제한 표시 | 로컬 확인 | HAL 중단 중 포털 조회 유지·신규 배포 차단(정보 확인 지연), HAL 재기동 뒤 서버·Agent 복귀 | +| 30 | 모듈 업데이트, 일부 서버 실패, 대상별 되돌리기와 상태 조정 | 로컬 확인 | 대상별 자식 작업·일부 실패 partial 요약(전체 성공으로 묶지 않음)·건강 확인·drift 조정·되돌리기 3단계 확인 거부 | +| 31 | 긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구 | 부분 확인 | 정상 상태 선언 거부·3단계 승인(순서/식별정보/문구 거부)·자격 1회 발급·일반 배포/범위 밖/잘못된 자격 거부·수행·폐기 뒤 거부·조정·종료 확인. 자격 만료 거부는 유효 시간(분 단위 설정) 때문에 로컬 실행에서 생략(단위 테스트 test/hal.test.mjs 로 확인) | +| 32 | Source 옵션 만료 후 30일 read-only, clone·export와 삭제 완료 | 부분 확인 | 활성화·export·해제→읽기 전용(삭제 예정일)까지 확인. 30일 뒤 삭제 완료는 시간 경과가 필요(단위 테스트 test/sites.test.mjs 에서 sweep 로 확인) | +| 33 | 메일 서비스의 사이트 대표→독립 전환 후 항목 유지 | 부분 확인 | 전환 점검표 8항목과 미충족 시 차단 확인. 전후 실제 송수신 확인·DNS 유지 검증은 실 DNS/AriMail 필요(단위 테스트 test/mail.test.mjs 에서 가짜로 전환 완료·되돌리기 확인) | +| 34 | 일시 중지·해지 예약·보존·삭제 단계의 표시 일치 | 부분 확인 | 해지된 메일 서비스의 읽기 전용 보존·삭제 예정 표시와 사이트 생명주기 화면의 단계별 항목 확인. 사이트의 해지 예약→보존→삭제 전이는 운영 중 사이트 필요(단위 테스트 test/lifecycle.test.mjs 로 확인) | ## 발견한 결함(재현 정보) @@ -59,6 +59,1173 @@ ## 실제 인프라가 필요한 항목 +- 도메인 소유 확인(TXT)·DNS 반영·HTTPS 발급·외부 접속 확인은 실제 DNS 와 공인 서버가 필요하다(흐름 7·8·11·14·15) +- 메일 DNS(MX·SPF·DKIM·DMARC) 반영과 외부 송신·수신·회신 왕복은 실 도메인·실 AriMail(SMTP 587/수신함)·외부 검증 주소가 필요하다(흐름 10 활성화 완료·11·33 완료) +- 재해복구의 실제 복원(hal-restore.sh 로 다른 호스트에 HAL 재기동)·실제 운영자 로그인·HTTPS·고객 서비스 외부 확인은 실 서버가 필요하다(흐름 25 기록·훈련 절차만 로컬 확인) ## 흐름별 증거(요약) +### 1. 신규 고객 가입과 이메일 확인 + +- ✔ 가입(이메일·표시 이름·비밀번호·필수 동의) → 세션 발급 — {"status":200,"hasSession":"[redacted]"} +- ✔ 확인 전 유료·운영 기능 차단(그룹 생성 403 email_unverified) — email_unverified +- ✔ 확인 메일 수신(발신 포획)에서 토큰 추출 — {"subject":"[Ariweb] 이메일 주소를 확인해 주세요"} +- ✔ 이메일 확인(1회용) → 재사용 거부 — {"first":200,"second":"token_used"} +- ✔ 내 정보: 이메일 확인됨 · 개인 그룹 · owner · 별칭 user-01 — {"verified":true,"groupKind":"personal","role":"owner","alias":"user-01"} +- ✔ 중복 가입은 존재를 드러내지 않는 안내(409 signup_unavailable) — signup_unavailable +- ✔ 로그인 기기 목록 · 다른 세션 종료 — {"sessionsBefore":"[redacted]","revoked":2} + +```json +{ + "me": { + "verified": true, + "groupKind": "personal", + "role": "owner", + "alias": "user-01" + } +} +``` + +### 2. 개인 사용자 그룹 생성과 다른 사용자 초대 + +- ✔ 팀 그룹 만들기 → 현재 그룹 전환 — g_01a1788f4c480f46fa7b303c98 +- ✔ 이메일 초대(7일) → 초대 메일 링크 — 0uEwoEn8HNWakp1RJSuhiZHW1kzEJAvm +- ✔ 초대 수락(1회용) → 별칭 user-02 · 역할 member — {"alias":"user-02","role":"member","reuse":"invite_invalid"} +- ✔ 권한 없는 구성원의 직접 API 접근 → 403 + 필요한 역할 — {"requiredRole":["owner","admin"],"yourRole":"member"} +- ✔ 그룹 전환 격리: 개인 그룹으로 바꾸면 팀 데이터가 보이지 않음 — {"team":{"live":0,"purchased":0,"inUse":0,"reserved":0,"unallocated":0},"personal":{"live":0,"purchased":0,"inUse":0,"reserved":0,"unallocated":0}} + +### 3. 상품 주문, 결제 확인, 권리 활성화 + +- ✔ 상품 목록(공개 버전만·가격·한도·미확정 정책 표시) — {"count":2,"team":{"id":"pv_01a1788f4ad4610eee820f3401","price":49000,"sites":3},"deferred":3,"paymentMethods":["manual_transfer"]} +- ✔ 주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음 — {"orderNo":"AW-20261026-0001","status":"awaiting_payment","total":63800,"dueAt":"2026-11-02T14:13:39.644Z","hasInstructions":true} +- ✔ 입금 확인 전 승인 거부(409 payment_not_confirmed) — payment_not_confirmed +- ✔ 운영자 입금 확인 → 승인 대기 목록에 권리 변화·현재 사용량 표시 — {"status":"awaiting_approval","delta":["sites","web_disk","web_out","mail_storage","mail_msgs","mailboxes","mail_services","mail_storage","mail_msgs","mailboxes"]} +- ✔ 운영자 승인 → 권리 활성화(재승인은 중복 없음) — {"granted":10,"already":true} +- ✔ 고객 결제 화면: 활성 상품·권리·다음 청구일 — {"sites":3,"subs":1,"nextBilling":"2026-11-26T14:13:39.701Z"} + +```json +{ + "ap": { + "granted": 10, + "already": true + }, + "bill": { + "sites": 3, + "subs": 1, + "nextBilling": "2026-11-26T14:13:39.701Z" + } +} +``` + +### 4. 초안 사이트 생성 + +- ✔ 사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내 — {"id":"s_01a1788f4d33a5901ff0037e56","lifecycle":"draft","projectId":"00000000000000000000000000000001","nextSteps":["build","import","domain","mode","perms"]} +- ✔ 사이트 목록 필터(초안)·사용량 요약에 예약 0 — {"drafts":1,"sitesReserved":0,"sitesAllocated":0} + +### 5. Webmaker에서 페이지 제작 + +- ✔ 제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용) — {"portalRedirect":"http://127.0.0.1:39757/api/auth/sso?ticket=…","webmaker":302,"reuse":401} +- ✔ Webmaker 에서 페이지 저장(저장본 2개) → 포털 저장본 목록·배포 전 확인 통과 — {"versions":1,"precheckOk":true} + +```json +{ + "sso": { + "portalRedirect": "http://127.0.0.1:39757/api/auth/sso?ticket=…", + "webmaker": 302, + "reuse": 401 + }, + "ver": { + "versions": 1, + "precheckOk": true + } +} +``` + +### 6. 관리형 배포 + +- ✔ 배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나 — {"jobId":"j_01a1788f4d7e2248b54de0dfb1","steps":10,"again":false,"siteCount":"reserved","server":null} +- ✔ HAL·Agent 를 거쳐 작업 완료 대기(최대 40초) — {"status":"done","steps":["accept:done","prepare_version:done","prepare_target:done","apply:done","data_check:done","start:done","internal_check:done","external_check:done","domain_https:done","done:done"]} +- ✔ 배포 뒤 사이트 상태·버전·건강 — {"lifecycle":"needs_domain","version":"20261026141340-rev-c1-000000","verify":"needs_domain","health":"ok","agentHasSite":true} + +```json +{ + "d": { + "jobId": "j_01a1788f4d7e2248b54de0dfb1", + "steps": 10, + "again": false, + "siteCount": "reserved", + "server": null + }, + "deployJob": { + "status": "done", + "steps": [ + "accept:done", + "prepare_version:done", + "prepare_target:done", + "apply:done", + "data_check:done", + "start:done", + "internal_check:done", + "external_check:done", + "domain_https:done", + "done:done" + ] + }, + "after": { + "lifecycle": "needs_domain", + "version": "20261026141340-rev-c1-000000", + "verify": "needs_domain", + "health": "ok", + "agentHasSite": true + } +} +``` + +### 7. 도메인 연결과 HTTPS 발급 + +- ✔ 도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT) — {"domainId":"d_01a1788f5d569a950de7ed6bab","txtName":"_ariweb-verify.aw-e2e.localhost","txtValueShape":"ariweb-verify=…","copyable":true} +- ✔ 도메인 목록·상세: 소유 확인·DNS·인증서 상태 표시 — {} +- ✔ (정리) 도메인 연결 작업 취소 — 포털이 외부 대기 중에도 5초마다 HAL domain.probe 를 새로 만들어 큐를 채우므로(결함 항목) 뒤 흐름을 위해 멈춘다 — {"queuedProbes":0} + +```json +{ + "dm": { + "domainId": "d_01a1788f5d569a950de7ed6bab", + "txtName": "_ariweb-verify.aw-e2e.localhost", + "txtValueShape": "ariweb-verify=…", + "copyable": true + } +} +``` + +### 8. 외부 주소에서 사이트 확인 + + +```json +{} +``` + +### 9. 사이트 정보와 접속·사용량 통계 확인 + +- ✔ 개요: 배포 전/후에 따라 준비 단계 또는 실제 통계 — {"kind":"managed","lifecycle":"needs_domain","hasStats":false,"readiness":null,"disk":{"used":null,"limit":1073741824}} +- ✔ 사용량 탭(배정·실제 사용·마지막 계측) — {"allocations":["web_disk","web_out"]} + +```json +{ + "ov": { + "kind": "managed", + "lifecycle": "needs_domain", + "hasStats": false, + "readiness": null, + "disk": { + "used": null, + "limit": 1073741824 + } + }, + "us": { + "allocations": [ + "web_disk", + "web_out" + ] + } +} +``` + +### 10. 사이트 대표 메일과 메일함 생성 + +- ✔ 사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시 — {"id":"msv_01a1788fe5e1155387871ce5b9","status":"needs_setup","tenant":true,"ariwebKind":"site_bound","limitAccounts":6,"siteCountAllocated":0} +- ✔ 같은 사이트에 두 번째 대표 메일 거부 — mail_domain_in_use +- ✔ 메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음 — {"id":"mb_01a1788fe61eae41ba06ef9e4c","address":"kim@aw-e2e.localhost","passwordShownOnce":"[redacted]","mustChange":true,"imapHost":"127.0.0.1","leakedOnReload":false,"inArimail":true} +- ✔ 초대 방식 메일함(링크 만료·재발송·사용 완료 상태) — {"inviteState":"pending","accepted":200,"reuse":"invite_used"} +- ✔ DNS 확인: 미반영/잘못된 값 구분 표시(로컬 DNS 에는 레코드 없음 → missing) — {"ok":false,"missing":3,"items":["mx:missing","spf:missing","dkim:missing","dmarc:recommended"]} +- ✔ 활성화 작업 → DNS 외부 대기(awaiting_external) · 멱등 — {"status":"awaiting_external","waitReason":"DNS 레코드가 아직 반영되지 않았습니다(누락 3, 잘못된 값 0). 도메인 DNS 에 안내된 값을 넣은 뒤 '이어서 진행' 을 누르세요","sameJob":true,"steps":["entitlement:done","dns:waiting","prepare:pending","verify:pending","activate:pending"]} +- ✔ 보안 보고서(SPF·DKIM·DMARC·MX 상태 + 복사 값) — {"items":["sender:error","mx:error","spf:error","dkim:error","dmarc:recommended","tls:error","roundtrip:recommended"],"records":4} + +```json +{ + "svc": { + "id": "msv_01a1788fe5e1155387871ce5b9", + "status": "needs_setup", + "tenant": true, + "ariwebKind": "site_bound", + "limitAccounts": 6, + "siteCountAllocated": 0 + }, + "mb": { + "id": "mb_01a1788fe61eae41ba06ef9e4c", + "address": "kim@aw-e2e.localhost", + "passwordShownOnce": "[redacted]", + "mustChange": true, + "imapHost": "127.0.0.1", + "leakedOnReload": false, + "inArimail": true + }, + "dns": { + "ok": false, + "missing": 3, + "items": [ + "mx:missing", + "spf:missing", + "dkim:missing", + "dmarc:recommended" + ] + }, + "act": { + "status": "awaiting_external", + "waitReason": "DNS 레코드가 아직 반영되지 않았습니다(누락 3, 잘못된 값 0). 도메인 DNS 에 안내된 값을 넣은 뒤 '이어서 진행' 을 누르세요", + "sameJob": true, + "steps": [ + "entitlement:done", + "dns:waiting", + "prepare:pending", + "verify:pending", + "activate:pending" + ] + } +} +``` + +### 12. 독립 메일 서비스 생성 + +- ✔ 메일 전용 도메인 등록(solo-e2e.localhost, purpose mail) — {"id":"d_01a178900c338e70a4bb86cd6f","jobCancelled":null} +- ✔ 독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비) — {"id":"msv_01a178900c3bc1b6dc766d01b2","kind":"standalone","mailServicesAllocated":1,"sitesAllocated":0} + +```json +{ + "r": { + "id": "msv_01a178900c3bc1b6dc766d01b2", + "kind": "standalone", + "mailServicesAllocated": 1, + "sitesAllocated": 0 + }, + "listCount": 2, + "noBackupNote": "메일 원문은 고객용 백업·복원에 포함되지 않으며, 메일 백업 기능을 제공하지 않습니다." +} +``` + +### 13. 백업 옵션 구매와 첫 백업 확인 + +- ✔ 백업 옵션 주문(대상 사이트)·입금 확인·승인 → backup 권리 — {"granted":7,"orderStatus":"approved"} +- ✔ 백업 화면: 구매 가능 여부·저장 한도·일정·보존·첫 백업 기한·검증 방식(상품정책에서) — {"eligible":true,"reasons":[],"deferred":[],"policy":null,"status":200} +- ✔ 옵션 활성화(확인 필수) → 첫 백업 예정 시각 — {"status":"active","firstDue":"2026-10-27T14:14:28.820Z","nextRun":"2026-10-26T14:14:28.820Z"} +- ✔ 지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증 — {"timedOut":false,"optionStatus":"active","lastJob":null,"siteServer":"srv_01a1788f44c213e5c17081909b","backup":{"status":"done","tier":"monthly","location":null},"job":null,"optionFailures":0} + +```json +{ + "el": { + "eligible": true, + "reasons": [], + "deferred": [], + "policy": null, + "status": 200 + }, + "opt": { + "status": "active", + "firstDue": "2026-10-27T14:14:28.820Z", + "nextRun": "2026-10-26T14:14:28.820Z" + }, + "run1": { + "timedOut": false, + "optionStatus": "active", + "lastJob": null, + "siteServer": "srv_01a1788f44c213e5c17081909b", + "backup": { + "status": "done", + "tier": "monthly", + "location": null + }, + "job": null, + "optionFailures": 0 + } +} +``` + +### 14. 백업 복원과 외부 사이트 확인 + +- ✔ 복원 안내(포함 데이터·영향·보호 시점 유지·되돌릴 수 있는 마지막 시각·완료 방식) — {"backup":{"id":"bk_01a17890221d1906f53694f75d","createdAt":"2026-10-26T14:14:28.828Z","scope":{"files":true,"db":true,"uploads":true,"config":true,"storeServerId":"srv_01a1788f456f9457904975ca45","verifyMethod":"sha256 + 격리 환경 읽기 확인"},"sizeBytes":1898,"verifyState":"verified","expiresAt":"2027-01-2 +- ✔ 복원 요청 → 운영자 승인 대기 · 중복 요청 없음 — {"id":"rs_01a1789024905bbeee711715c7","status":"awaiting_approval","dup":false,"finalConfirmation":{"group":"E2E 팀","site":"E2E 사이트","domain":null}} +- ✔ 운영자 최종 확인(그룹·사이트·백업 시각) 불일치 거부 → 일치 승인(멱등) — {"bad":"final_confirmation_mismatch","ok":400} +- ✔ 9단계 작업 진행 → 사용자 확인 필요(보호 시점·되돌리기 가능) — {"steps":[]} + +```json +{ + "info": { + "backup": { + "id": "bk_01a17890221d1906f53694f75d", + "createdAt": "2026-10-26T14:14:28.828Z", + "scope": { + "files": true, + "db": true, + "uploads": true, + "config": true, + "storeServerId": "srv_01a1788f456f9457904975ca45", + "verifyMethod": "sha256 + 격리 환경 읽기 확인" + }, + "sizeBytes": 1898, + "verifyState": "verified", + "expiresAt": "2027-01-27T14:14:34.270Z", + "tier": "monthly" + }, + "included": [ + "현재 배포본(파일)", + "데이터베이스", + "업로드 데이터", + "복원에 필요한 사이트 설정" + ], + "excluded": [ + "메일 원문", + "소스 저장소", + "Webmaker 프로젝트 이력", + "외부 배포 데이터", + "플랫폼 내부 복구 사본" + ], + "impact": "복원 중 사이트가 일시적으로 이전 상태로 바뀌며, 복원 뒤 생성된 데이터는 보호 시점으로만 되돌릴 수 있습니다", + "estimatedDowntimeMin": null, + "cost": 0, + "currency": "KRW", + "costDeferred": false, + "completionMode": "user_confirm", + "protectPointDays": 7, + "revertHours": 72, + "approvalRequired": true, + "conflicts": [], + "canRequest": true, + "deferred": [] + }, + "rq": { + "id": "rs_01a1789024905bbeee711715c7", + "status": "awaiting_approval", + "dup": false, + "finalConfirmation": { + "group": "E2E 팀", + "site": "E2E 사이트", + "domain": null + } + }, + "ap": { + "bad": "final_confirmation_mismatch", + "ok": 400 + }, + "j": { + "steps": [] + } +} +``` + +### 15. 관리형 사이트 서버 이전과 외부 확인 + +- ✔ 이전 화면 경계값: 취소 가능 마지막 단계·쓰기 제한·목표 중단 시간·되돌림 기간·이전 위치 보존·비용(플랫폼 사유 0원) — {"auto":{"ok":false,"reasons":["사이트 대표 메일이 있는 사이트는 승인형으로 진행합니다","진행 중인 배포·복원·이전이 있습니다"]},"boundaries":{"lastCancel":"새 위치 확인(연결 전환 직전)까지 취소할 수 있습니다","revertHours":72,"cost":10000,"platformCost":0}} +- ✔ 고객 요청(유료·승인 대기) 또는 조건 미충족 사유 — {"status":409,"error":"conflict","relocation":null,"message":"진행 중인 배포·복원·이전이 끝나야 이전을 시작할 수 있습니다"} + +```json +{ + "b": { + "auto": { + "ok": false, + "reasons": [ + "사이트 대표 메일이 있는 사이트는 승인형으로 진행합니다", + "진행 중인 배포·복원·이전이 있습니다" + ] + }, + "boundaries": { + "lastCancel": "새 위치 확인(연결 전환 직전)까지 취소할 수 있습니다", + "revertHours": 72, + "cost": 10000, + "platformCost": 0 + } + }, + "req": { + "status": 409, + "error": "conflict", + "relocation": null, + "message": "진행 중인 배포·복원·이전이 끝나야 이전을 시작할 수 있습니다" + } +} +``` + +### 16. 외부 배포 사이트 생성과 배포 + +- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a17891100c4d7a376f63d2b9","group_id":"g_01a1788f4c480f46fa7b303c98","name":"E2E 외부 사이트","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000004","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"external","primary_domain":null,"site_cou +- ✔ 배포 방식 화면: 외부 대상·고객 책임·미제공 기능(메일 미지원)·건별 비용·동의 버전 — {"notProvided":"[redacted]","consentVersion":"external-deploy-2026-10-0.1.1","agreed":false} +- ✔ 동의 전 Agent 발급 거부(consent_required) → 동의 기록(버전) — {"before":"consent_required","consent":200} +- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:39783/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:39783 --token … --external --site s_01a17891100c4d7a376f63d2b9","expiresAt":"2026-10-26T14:25:35.492Z","status":"pending"} +- ✔ 고객 서버 역할의 외부 Agent 프로세스 등록·실행(HAL 등록, 사이트 귀속) — {"agentId":"[redacted]"} +- ✔ 포털 연결 확인(외부 Agent 상태 connected) — {"status":"connected","stale":false,"halAgentId":"[redacted]"} +- ✔ 배포 전 확인(외부: 관리형 용량 확인 없음) — {"ok":true,"blocks":[]} +- ✔ 외부 배포 → 외부 Agent 가 site.deploy 수행 — {"status":"done","error":null,"errorCode":null,"steps":["accept:done","prepare_version:done","prepare_target:done","apply:done","data_check:done","start:done","internal_check:done","external_check:done","domain_https:done","done:done"]} +- ✔ 외부 사이트 개요: 관리형 지표 없음·제공되지 않음 표기·Agent 상태·마지막 배포 결과 — {"kind":"external","notProvided":"[redacted]","agent":"connected","lastDeployOk":true} +- ✔ 배포 제거(외부 서버 파일·실행 상태 제거, 실패 잔여 정리) — {"status":"done","error":null} +- ✔ Agent 연결 해제(revoke) → 상태 없음 — {"status":200,"agent":null} + +```json +{ + "mode": { + "notProvided": "[redacted]", + "consentVersion": "external-deploy-2026-10-0.1.1", + "agreed": false + }, + "tok": { + "install": "curl -fsSL http://127.0.0.1:39783/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:39783 --token … --external --site s_01a17891100c4d7a376f63d2b9", + "expiresAt": "2026-10-26T14:25:35.492Z" + }, + "conn": { + "status": "connected", + "stale": false, + "halAgentId": "[redacted]" + }, + "pc": { + "ok": true, + "blocks": [] + }, + "dep": { + "status": "done", + "error": null, + "errorCode": null, + "steps": [ + "accept:done", + "prepare_version:done", + "prepare_target:done", + "apply:done", + "data_check:done", + "start:done", + "internal_check:done", + "external_check:done", + "domain_https:done", + "done:done" + ] + }, + "ov": { + "kind": "external", + "notProvided": "[redacted]", + "agent": "connected", + "lastDeployOk": true + } +} +``` + +### 17. 사용량 경고와 한도 동작 확인 + +- ✔ 가짜 AriMail 사용량 95%/월간 100% → 수집(refresh) → 구간·제한 사유·다음 KST 초기화·마지막 계측 표시 — {"storageLevel":95,"monthlyLevel":100,"nextReset":"2026-10-31T15:00:00.000Z","measuredAt":true,"limit100":"월간 통수 100%: 새 발신을 막고 새 수신은 임시 실패로 응답합니다. 다음 KST 월 시작 또는 추가 용량 적용 후 복구됩니다.","countingRule":"per_final_recipient","evaluate":{"status":200,"notified":2,"limited":1}} +- ✔ 알림 센터: 95%·100% 경고가 사건으로 묶여 표시(필수 알림) — {"count":3,"titles":["aw-e2e.localhost 메일 저장 공간 96% — 곧 제한됩니다(최종 경고)","aw-e2e.localhost 월간 메일 통수(송신+수신) 100% — 제한 적용","메일함 kim@aw-e2e.localhost 저장 공간 95% 경고"],"mandatory":true} +- ✔ 사용량 화면 제한 플래그(/api/usage/limits)·그룹 요약 — {"limits":["mail_msgs:100","mail_storage:95"],"warnLevels":[70,80,90,95]} + +```json +{ + "warn": { + "storageLevel": 95, + "monthlyLevel": 100, + "nextReset": "2026-10-31T15:00:00.000Z", + "measuredAt": true, + "limit100": "월간 통수 100%: 새 발신을 막고 새 수신은 임시 실패로 응답합니다. 다음 KST 월 시작 또는 추가 용량 적용 후 복구됩니다.", + "countingRule": "per_final_recipient", + "evaluate": { + "status": 200, + "notified": 2, + "limited": 1 + } + }, + "notif": { + "count": 3, + "titles": [ + "aw-e2e.localhost 메일 저장 공간 96% — 곧 제한됩니다(최종 경고)", + "aw-e2e.localhost 월간 메일 통수(송신+수신) 100% — 제한 적용", + "메일함 kim@aw-e2e.localhost 저장 공간 95% 경고" + ], + "mandatory": true + }, + "lim": { + "limits": [ + "mail_msgs:100", + "mail_storage:95" + ], + "warnLevels": [ + 70, + 80, + 90, + 95 + ] + } +} +``` + +### 18. 상품 축소 보류와 정리 후 적용 + +- ✔ 배정 화면 수치(구매·내 배정·다른 서비스·예약·미배정·변경 후 최대) + 마지막 단위 동시 요청 — {"purchased":45,"mine":6,"others":2,"unallocated":37,"race":[409,409],"mineAfter":6} +- ✔ 축소 미리보기(팀→베이직): 줄여야 할 항목(메일함 6→5) 구체 표시 — {"shortages":["mailboxes:1"]} +- ✔ 축소 요청 → 보류(사용 중 권리 즉시 회수 없음) — {"fits":false,"hold":"holding","sitesStillPurchased":3} +- ✔ 정리: 메일함 배정 6→5 축소 → 재검증 적합 → 축소 주문 생성 — {"allocate":200,"fits":true,"shortages":[],"order":"awaiting_payment"} +- ✔ 축소 주문 입금 확인·승인 → 권리 교체(베이직) — {"approve":200,"sites":1,"mailboxes":5,"product":"pv_01a1788f4ad4ee11f68c599438"} + +```json +{ + "alloc": { + "purchased": 45, + "mine": 6, + "others": 2, + "unallocated": 37, + "race": [ + 409, + 409 + ], + "mineAfter": 6 + }, + "pv": { + "shortages": [ + "mailboxes:1" + ] + }, + "hold": { + "fits": false, + "hold": "holding", + "sitesStillPurchased": 3 + }, + "clean": { + "allocate": 200, + "fits": true, + "shortages": [], + "order": "awaiting_payment" + }, + "applied": { + "approve": 200, + "sites": 1, + "mailboxes": 5, + "product": "pv_01a1788f4ad4ee11f68c599438" + } +} +``` + +### 19. 사이트 수 반환과 대표 메일의 독립 전환 + +- ✔ §14 반환 점검표(8단계)·차단 사유(대표 메일 유지 선택·독립 옵션) — {"ok":false,"blocking":["public_stopped","domains_unrouted","no_jobs","mail_choice","mail_converted"],"steps":8} +- ✔ 메일 유지 선택: 독립 옵션 부족이면 반환하지 않음 / 종료 선택 규칙 — {"status":409,"error":"site_public","detail":null} + +```json +{ + "ck": { + "ok": false, + "blocking": [ + "public_stopped", + "domains_unrouted", + "no_jobs", + "mail_choice", + "mail_converted" + ], + "steps": 8 + }, + "keep": { + "status": 409, + "error": "site_public", + "detail": null + } +} +``` + +### 20. 사이트 소유권 이전 + +- ✔ 이전 범위 미리보기(사이트·도메인·대표 메일·배정·진행 작업·미납 / 제외 항목) — {"sites":1,"domains":1,"siteMail":1,"needed":{"backup":1,"web_disk":1073741824,"mail_msgs":500,"mail_storage":2147483648,"mailboxes":6},"excluded":3} +- ✔ 요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림 — {"id":"tr_01a178913319cb58067d72957a","status":"requested","expiresAt":"2026-11-02T14:15:44.153Z","bobNotified":true} +- ✔ (정리) 받는 쪽 권리에 맞게 범위 조정: 백업 옵션 종료·메일함 배정 6→5(받는 그룹 베이직 5개) — {"endBackup":200,"alloc":200} +- ✔ 받는 그룹 수락 → 권리 부족이면 보류(필요한 상품 안내) — {"status":200,"tstatus":"reviewing","hold":null} +- ✔ 운영자 심사(소유 증빙·도메인 확인) 미입력 거부 → 최종 확인 일치 승인 → 원자적 완료·청구 전환 시각 — {"bad":"final_confirmation_mismatch","ok":200,"status":"completed","cutover":"2026-10-26T14:15:44.203Z"} +- ✔ 완료 뒤: 보내는 쪽은 접근 불가(404), 받는 쪽 대시보드·사용량 일치, 불변 감사 — {"aliceSees":404,"bobSees":200,"bobUsageSites":0,"audit":true} + +```json +{ + "pv": { + "sites": 1, + "domains": 1, + "siteMail": 1, + "needed": { + "backup": 1, + "web_disk": 1073741824, + "mail_msgs": 500, + "mail_storage": 2147483648, + "mailboxes": 6 + }, + "excluded": 3 + }, + "rq": { + "id": "tr_01a178913319cb58067d72957a", + "status": "requested", + "expiresAt": "2026-11-02T14:15:44.153Z", + "bobNotified": true + }, + "acc": { + "status": 200, + "tstatus": "reviewing", + "hold": null + }, + "ap": { + "bad": "final_confirmation_mismatch", + "ok": 200, + "status": "completed", + "cutover": "2026-10-26T14:15:44.203Z" + }, + "after": { + "aliceSees": 404, + "bobSees": 200, + "bobUsageSites": 0, + "audit": true + } +} +``` + +### 21. 중지, 해지, 보존 예정일 표시 + +- ✔ §23.3 한 화면 보기: 공개/관리자 접근·사이트 수·청구·도메인/메일/백업 동작·내보내기·되돌릴 수 없는 시점·보존·다음 알림 — {"state":"도메인 설정 필요","siteCount":true,"publicAccess":"제공 중","retention":{"site_days":30,"mail_days":30,"source_days":30,"backup_days":7,"delete_grace_days":1,"notice_before_days":3,"readonly_retention_days":30,"delete_after_days":30,"pause_keeps_site_count":true,"cancel_effective":"next_billing_date +- ✔ 일시 중지(확인 필수) 또는 현재 상태에서 거부 사유 — {"noConfirm":"confirmation_required","status":409,"error":"conflict"} +- ✔ 메일 서비스 생명주기 화면(해지된 독립 메일: 송수신 중지·보존 종료·삭제 예정·백업 미제공) — {"status":200,"sendReceive":"송수신 가능","retentionUntil":false,"deleteAfter":false,"backupNote":"고객용 메일 백업·복원은 제공하지 않습니다(§17.4)"} + +```json +{ + "v": { + "state": "도메인 설정 필요", + "siteCount": true, + "publicAccess": "제공 중", + "retention": { + "site_days": 30, + "mail_days": 30, + "source_days": 30, + "backup_days": 7, + "delete_grace_days": 1, + "notice_before_days": 3, + "readonly_retention_days": 30, + "delete_after_days": 30, + "pause_keeps_site_count": true, + "cancel_effective": "next_billing_date", + "source": "상품정책 베이직 (retention 정책) v1" + }, + "pauseImpactKeys": [ + "publicAccess", + "adminAccess", + "mail", + "backups", + "usageAndCost", + "resume", + "siteCount" + ], + "cancelImpactKeys": [ + "target", + "linked", + "immediateStops", + "remainingData", + "retentionUntil", + "deleteAfter", + "retentionSource", + "export", + "unpaid", + "refund", + "pointOfNoReturn", + "mailOptions", + "siteCount" + ], + "steps14": 8 + }, + "pause": { + "noConfirm": "confirmation_required", + "status": 409, + "error": "conflict" + }, + "mailLc": { + "status": 200, + "sendReceive": "송수신 가능", + "retentionUntil": false, + "deleteAfter": false, + "backupNote": "고객용 메일 백업·복원은 제공하지 않습니다(§17.4)" + } +} +``` + +### 22. HAL 우선 배포 후 HAL을 통한 전체 모듈 배포 + +- ✔ 릴리스 화면: 버전·main 커밋·승인 상태·HAL 실행 버전·부트스트랩(최초 Agent)·배포 순서 — {"releases":["0.1.2:approved","0.1.1:approved"],"running":{"complete":true,"firstInstall":false,"releaseVersion":"0.1.1","mainCommit":"[redacted]","artifactSha256":"[redacted]","halRunningVersion":"0.1.1","firstAgent":"[redacted]","manualInstallLocked":true},"order":["hal","agent","portal","webmaker +- ✔ HAL 이 아직 0.1.2 가 아닐 때 다른 모듈 0.1.2 배포 → hal_first 거부 — {"status":"failed","error":"hal_first"} +- ✔ 승인되지 않은 릴리스는 거부 / 수동 설치 잠김 표시 — {"manualInstallLocked":true,"complete":true} +- ✔ HAL 모듈 0.1.1 을 제어 서버에 배포(실제 Agent module.install: artifact 내려받기·sha256·releases/current·유닛(stub)·실제 버전 확인) — {"status":"done","steps":["precheck:done","fanout:done","collect:done"],"error":null,"children":["done:"],"agentJobs":[],"timedOut":false} + +```json +{ + "rel": { + "releases": [ + "0.1.2:approved", + "0.1.1:approved" + ], + "running": { + "complete": true, + "firstInstall": false, + "releaseVersion": "0.1.1", + "mainCommit": "[redacted]", + "artifactSha256": "[redacted]", + "halRunningVersion": "0.1.1", + "firstAgent": "[redacted]", + "manualInstallLocked": true + }, + "order": [ + "hal", + "agent", + "portal", + "webmaker", + "arimail", + "backup", + "observability" + ] + }, + "hf": { + "status": "failed", + "error": "hal_first" + }, + "na": { + "manualInstallLocked": true, + "complete": true + }, + "dep": { + "status": "done", + "steps": [ + "precheck:done", + "fanout:done", + "collect:done" + ], + "error": null, + "children": [ + "done:" + ], + "agentJobs": [], + "timedOut": false + }, + "diag": null +} +``` + +### 23. 실패 작업의 재시도와 되돌리기 + +- ✔ 배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호 — {"status":"done","canRetry":false,"jobId":"j_01a1788f4d7e2248b54de0dfb1"} +- ✔ 재시도 요청(POST /api/sites/:id/jobs/:jobId/retry) — {"status":400,"error":"job_not_retryable"} +- ✔ 되돌리기 요청(POST /api/sites/:id/rollback) — 이전 버전 없으면 거부 — {"status":409,"error":"no_previous","message":"되돌릴 이전 버전이 없습니다"} + +```json +{ + "dep": { + "status": "done", + "canRetry": false, + "jobId": "j_01a1788f4d7e2248b54de0dfb1" + }, + "rt": { + "status": 400, + "error": "job_not_retryable" + }, + "rb": { + "status": 409, + "error": "no_previous", + "message": "되돌릴 이전 버전이 없습니다" + } +} +``` + +### 24. 운영자 승인, 감사 기록, 고객 알림의 일치 + +- ✔ 고객 알림 센터에 승인 알림 — {"title":"주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다","category":"billing","link":"/orders/o_01a1788f4cbcc8ea43f877bfc4"} +- ✔ 감사 기록: order.approve 는 삭제 불가(immutable) — {"action":"order.approve","immutable":true} +- ✔ 운영자 화면과 고객 주문 화면의 상태 일치 — {"status":"approved","approvedAt":"2026-10-26T14:13:39.701Z"} + +```json +{ + "n": { + "title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다", + "category": "billing", + "link": "/orders/o_01a1788f4cbcc8ea43f877bfc4" + }, + "a": { + "action": "order.approve", + "immutable": true + }, + "same": { + "status": "approved", + "approvedAt": "2026-10-26T14:13:39.701Z" + } +} +``` + +### 25. HAL, 로그인, 비밀정보 관리, 인증서 기반의 재해복구 훈련 + +- ✔ 설정 전 준비 상태(미설정 항목) — {"ready":false,"missingConfig":["rto_minutes","rpo_minutes","snapshot_interval_minutes","retention_count"],"lastDrill":null,"lastSnapshot":null,"config":{"rto_minutes":null,"rpo_minutes":null,"snapshot_interval_minutes":null,"retention_count":null,"external_store":null},"label":"정식 운영 준비 전(복구 훈련 또는 +- ✔ RTO·RPO·사본 주기·보존 개수·외부 보관 위치 설정 — {"id":1,"rto_minutes":60,"rpo_minutes":30,"snapshot_interval_minutes":60,"retention_count":3,"external_store":"/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026141334/dr-external","updated_at":"2026-10-26T14:15:53.151Z","updated_by":"op_01a1788f43bf7bccb5fba30c59"} +- ✔ 복구 사본 생성·검증·외부 보관(vault.key 제외) — {"verify":"ok","external":true,"sha":true} +- ✔ 복원 요청 → 승인 전 시작 거부 → 승인 → 기존 제어 중지 확인 없이는 거부 → 시작(명령 안내) → 확인 항목 부족 거부 → 완료 — {"early":"restore_not_approved","noStop":"old_control_not_confirmed","command":true,"incomplete":"checks_incomplete","final":"completed"} +- ✔ 복구 훈련: 항목 하나 실패 → failed, 모두 성공·측정 RTO/RPO → 준비 완료 — {"first":"failed","second":"success","ready":true,"measured":{}} + +```json +{ + "r0": { + "ready": false, + "missingConfig": [ + "rto_minutes", + "rpo_minutes", + "snapshot_interval_minutes", + "retention_count" + ], + "lastDrill": null, + "lastSnapshot": null, + "config": { + "rto_minutes": null, + "rpo_minutes": null, + "snapshot_interval_minutes": null, + "retention_count": null, + "external_store": null + }, + "label": "정식 운영 준비 전(복구 훈련 또는 설정 필요)" + }, + "cfg": { + "id": 1, + "rto_minutes": 60, + "rpo_minutes": 30, + "snapshot_interval_minutes": 60, + "retention_count": 3, + "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026141334/dr-external", + "updated_at": "2026-10-26T14:15:53.151Z", + "updated_by": "op_01a1788f43bf7bccb5fba30c59" + }, + "snap": { + "verify": "ok", + "external": true, + "sha": true + }, + "rs": { + "early": "restore_not_approved", + "noStop": "old_control_not_confirmed", + "command": true, + "incomplete": "checks_incomplete", + "final": "completed" + }, + "drill": { + "first": "failed", + "second": "success", + "ready": true, + "measured": {} + } +} +``` + +### 26. 새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지 + +- ✔ 재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록 — {"mailJob":{"id":"j_01a1788ff65e15ccfd1505fea6","status":"awaiting_external"},"halJobs":18} +- ✔ 포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지 — {"sessionKept":"[redacted]","mailJob":{"id":"j_01a1788ff65e15ccfd1505fea6","status":"awaiting_external"},"same":true} +- ✔ HAL 재시작 → 운영자 세션·작업 목록·서버 등록 유지 — {"opsSessionKept":"[redacted]","halJobs":18,"sameCount":true} + +```json +{ + "before": { + "mailJob": { + "id": "j_01a1788ff65e15ccfd1505fea6", + "status": "awaiting_external" + }, + "halJobs": 18 + }, + "pr": { + "sessionKept": "[redacted]", + "mailJob": { + "id": "j_01a1788ff65e15ccfd1505fea6", + "status": "awaiting_external" + }, + "same": true + }, + "hr": { + "opsSessionKept": "[redacted]", + "halJobs": 18, + "sameCount": true + } +} +``` + +### 27. 마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공 + +- ✔ 밥 개인 그룹: 베이직(사이트 1) 주문·승인 — {"granted":6} +- ✔ 사이트 2개 생성·저장본 — [{"id":"s_01a1788fd9df3235dfa7d0461c","group_id":"g_01a1788f4c75bbf31637a86691","name":"동시 A","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000002","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"managed","primary_domain":null,"site_count_sta +- ✔ 두 사이트 동시 최초 배포 요청 → 하나만 200, 다른 쪽 409 precheck_failed(sites 부족) — {"ok":1,"blocked":"precheck_failed","shortage":"block"} +- ✔ (정리) 선행 결함으로 영원히 진행 중인 배포 취소·예약 반환 — {"cancel":200,"cleanup":200,"unallocated":0} + +```json +{ + "race": { + "ok": 1, + "blocked": "precheck_failed", + "shortage": "block" + } +} +``` + +### 28. 외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리 + +- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a17891100c4d7a376f63d2b9","group_id":"g_01a1788f4c480f46fa7b303c98","name":"E2E 외부 사이트","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000004","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"external","primary_domain":null,"site_cou +- ✔ 배포 방식 화면: 외부 대상·고객 책임·미제공 기능(메일 미지원)·건별 비용·동의 버전 — {"notProvided":"[redacted]","consentVersion":"external-deploy-2026-10-0.1.1","agreed":false} +- ✔ 동의 전 Agent 발급 거부(consent_required) → 동의 기록(버전) — {"before":"consent_required","consent":200} +- ✔ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — {"token":"[redacted]","install":"curl -fsSL http://127.0.0.1:39783/agent/v1/install.sh | bash -s -- --hal http://127.0.0.1:39783 --token … --external --site s_01a17891100c4d7a376f63d2b9","expiresAt":"2026-10-26T14:25:35.492Z","status":"pending"} +- ✔ 고객 서버 역할의 외부 Agent 프로세스 등록·실행(HAL 등록, 사이트 귀속) — {"agentId":"[redacted]"} +- ✔ 포털 연결 확인(외부 Agent 상태 connected) — {"status":"connected","stale":false,"halAgentId":"[redacted]"} +- ✔ 배포 전 확인(외부: 관리형 용량 확인 없음) — {"ok":true,"blocks":[]} +- ✔ 외부 배포 → 외부 Agent 가 site.deploy 수행 — {"status":"done","error":null,"errorCode":null,"steps":["accept:done","prepare_version:done","prepare_target:done","apply:done","data_check:done","start:done","internal_check:done","external_check:done","domain_https:done","done:done"]} +- ✔ 외부 사이트 개요: 관리형 지표 없음·제공되지 않음 표기·Agent 상태·마지막 배포 결과 — {"kind":"external","notProvided":"[redacted]","agent":"connected","lastDeployOk":true} +- ✔ 배포 제거(외부 서버 파일·실행 상태 제거, 실패 잔여 정리) — {"status":"done","error":null} +- ✔ Agent 연결 해제(revoke) → 상태 없음 — {"status":200,"agent":null} + +```json +{ + "conn": { + "status": "connected", + "stale": false, + "halAgentId": "[redacted]" + }, + "dep": { + "status": "done", + "error": null, + "errorCode": null, + "steps": [ + "accept:done", + "prepare_version:done", + "prepare_target:done", + "apply:done", + "data_check:done", + "start:done", + "internal_check:done", + "external_check:done", + "domain_https:done", + "done:done" + ] + }, + "rm": { + "status": "done", + "error": null + }, + "rv": { + "status": 200, + "agent": null + } +} +``` + +### 29. HAL 장애 중 기존 서비스 유지와 신규 변경 제한 표시 + +- ✔ HAL 중단 중: 기존 조회(사이트 목록·개요)는 제공, 배포 전 확인/배포는 정보 확인 지연·차단 — {"list":200,"overview":200,"precheck":{"status":200,"blocks":["target:HAL(중앙 관리)에 연결할 수 없어 배포 위치를 확인하지 못했습니다(정"]},"deploy":{"status":409,"error":"precheck_failed","message":"배포 전 확인에서 차단된 항목이 있습니다"}} +- ✔ 대시보드/알림에 중앙 상태 확인 지연 표시(가능하면) — {"status":200,"halState":null} +- ✔ HAL 재기동(같은 호스트의 죽은 인스턴스 임대 이어받기) → 서버·Agent 상태 복귀 — {"takeoverNeeded":false,"bootstrap":true,"readyServers":4} + +```json +{ + "during": { + "list": 200, + "overview": 200, + "precheck": { + "status": 200, + "blocks": [ + "target:HAL(중앙 관리)에 연결할 수 없어 배포 위치를 확인하지 못했습니다(정" + ] + }, + "deploy": { + "status": 409, + "error": "precheck_failed", + "message": "배포 전 확인에서 차단된 항목이 있습니다" + } + }, + "dash": { + "status": 200, + "halState": null + }, + "back": { + "takeoverNeeded": false, + "bootstrap": true, + "readyServers": 4 + } +} +``` + +### 30. 모듈 업데이트, 일부 서버 실패, 대상별 되돌리기와 상태 조정 + +- ✔ agent 모듈을 두 서버 + 없는 서버에 배포 → 대상별 자식 작업·일부 실패를 성공으로 묶지 않음(partial/all_failed) — {"status":"failed","errorCode":"partial_failure","overall":"partial","children":["srv_missing_e2e:failed:서버가 없습니다","srv_01a1788f456f9457904975ca45:done:","srv_01a1788f44c213e5c17081909b:done:"],"timedOut":false} +- ✔ 모듈 건강 확인 작업·목표/실제 버전 차이(drift) 표시·다시 맞추기 작업 — {"health":"done","drift":0,"reconcile":200,"overviewComponents":["HAL:ok","portal:unknown","webmaker:unknown","arimail:unknown","backup:ok","observability:ok"]} +- ✔ 되돌리기는 3단계 확인(영향 범위·식별정보·최종 실행) 없이는 거부 — {"step1":"confirm_impact_required","step2":"identifiers_mismatch"} + +```json +{ + "d": { + "status": "failed", + "errorCode": "partial_failure", + "overall": "partial", + "children": [ + "srv_missing_e2e:failed:서버가 없습니다", + "srv_01a1788f456f9457904975ca45:done:", + "srv_01a1788f44c213e5c17081909b:done:" + ], + "timedOut": false + }, + "h": { + "health": "done", + "drift": 0, + "reconcile": 200, + "overviewComponents": [ + "HAL:ok", + "portal:unknown", + "webmaker:unknown", + "arimail:unknown", + "backup:ok", + "observability:ok" + ] + }, + "rb": { + "step1": "confirm_impact_required", + "step2": "identifiers_mismatch" + } +} +``` + +### 31. 긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구 + +- ✔ HAL 정상 상태에서 선언 거부(hal_healthy) — hal_healthy +- ✔ critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회) — {"id":"em_01a178915754f8b9d8ef0ffc24","noCred":"approvals_insufficient","wrongStep":"wrong_step","badIds":"identifiers_mismatch","badText":"final_confirm_mismatch","approved":"approved","approvals":3,"credential":"[redacted]","expiresAt":"2026-10-26T14:45:53.458Z"} +- ✔ 일반 배포(module.deploy) 거부 · 범위 밖 거부 · 잘못된 자격 거부 · 허용 행동 수행 · 확인 뒤 자격 폐기 · 폐기 뒤 거부 · 조정 · 종료 — {"forbidden":"action_forbidden","outOfScope":"out_of_scope","badCred":"bad_credential","performed":400,"revoked":true,"afterRevoke":"credential_revoked","reconcile":200,"closed":"closed"} + +```json +{ + "healthy": "hal_healthy", + "sess": { + "id": "em_01a178915754f8b9d8ef0ffc24", + "noCred": "approvals_insufficient", + "wrongStep": "wrong_step", + "badIds": "identifiers_mismatch", + "badText": "final_confirm_mismatch", + "approved": "approved", + "approvals": 3, + "expiresAt": "2026-10-26T14:45:53.458Z" + }, + "perf": { + "forbidden": "action_forbidden", + "outOfScope": "out_of_scope", + "badCred": "bad_credential", + "performed": 400, + "revoked": true, + "afterRevoke": "credential_revoked", + "reconcile": 200, + "closed": "closed" + } +} +``` + +### 32. Source 옵션 만료 후 30일 read-only, clone·export와 삭제 완료 + +- ✔ Source 옵션 주문(대상 사이트)·승인 → source 권리 — {"granted":7} +- ✔ Source 옵션 활성화(권리 안에서) → 저장소·브랜치·변경 이력·clone 정보 — {"activate":200,"status":"active","provider":"gitea","changes":1,"clone":true} +- ✔ export(저장본 내려받기) — {"status":200,"type":"application/gzip"} +- ✔ 옵션 해제 → 30일 읽기 전용(만료일·삭제 예정일·재활성화 안내) · push 차단 — {"status":"readonly","deleteAfter":"2026-11-25T14:15:44.084Z","readonlyUntil":"2026-11-25T14:15:44.084Z"} +- ✔ 삭제 정리 작업(sweep)은 삭제 예정일 전이면 지우지 않음 — {"sweep":200,"statusAfter":"readonly"} + +```json +{ + "act": { + "activate": 200, + "status": "active", + "provider": "gitea", + "changes": 1, + "clone": true + }, + "ex": { + "status": 200, + "type": "application/gzip" + }, + "un": { + "status": "readonly", + "deleteAfter": "2026-11-25T14:15:44.084Z", + "readonlyUntil": "2026-11-25T14:15:44.084Z" + }, + "sweep": { + "sweep": 200, + "statusAfter": "readonly" + } +} +``` + +### 33. 메일 서비스의 사이트 대표→독립 전환 후 항목 유지 + +- ✔ 사이트 대표→독립 전환 점검표(주소·메일함·별칭·전달·비밀번호·DNS·옵션·작업) — {"canConvert":false,"items":["addresses:ok","data:ok","aliases:ok","forwards:ok","passwords:ok","dns:x","entitlement:x","jobs:x"]} +- ✔ DNS 미확인 상태에서 전환 요청은 차단 — {"status":409,"error":"conversion_blocked"} + +```json +{ + "ck": { + "canConvert": false, + "items": [ + "addresses:ok", + "data:ok", + "aliases:ok", + "forwards:ok", + "passwords:ok", + "dns:x", + "entitlement:x", + "jobs:x" + ] + }, + "tr": { + "status": 409, + "error": "conversion_blocked" + } +} +``` + +### 34. 일시 중지·해지 예약·보존·삭제 단계의 표시 일치 + +- ✔ §23.3 한 화면 보기: 공개/관리자 접근·사이트 수·청구·도메인/메일/백업 동작·내보내기·되돌릴 수 없는 시점·보존·다음 알림 — {"state":"도메인 설정 필요","siteCount":true,"publicAccess":"제공 중","retention":{"site_days":30,"mail_days":30,"source_days":30,"backup_days":7,"delete_grace_days":1,"notice_before_days":3,"readonly_retention_days":30,"delete_after_days":30,"pause_keeps_site_count":true,"cancel_effective":"next_billing_date +- ✔ 일시 중지(확인 필수) 또는 현재 상태에서 거부 사유 — {"noConfirm":"confirmation_required","status":409,"error":"conflict"} +- ✔ 메일 서비스 생명주기 화면(해지된 독립 메일: 송수신 중지·보존 종료·삭제 예정·백업 미제공) — {"status":200,"sendReceive":"송수신 가능","retentionUntil":false,"deleteAfter":false,"backupNote":"고객용 메일 백업·복원은 제공하지 않습니다(§17.4)"} + +```json +{ + "v": { + "state": "도메인 설정 필요", + "siteCount": true, + "publicAccess": "제공 중", + "retention": { + "site_days": 30, + "mail_days": 30, + "source_days": 30, + "backup_days": 7, + "delete_grace_days": 1, + "notice_before_days": 3, + "readonly_retention_days": 30, + "delete_after_days": 30, + "pause_keeps_site_count": true, + "cancel_effective": "next_billing_date", + "source": "상품정책 베이직 (retention 정책) v1" + }, + "pauseImpactKeys": [ + "publicAccess", + "adminAccess", + "mail", + "backups", + "usageAndCost", + "resume", + "siteCount" + ], + "cancelImpactKeys": [ + "target", + "linked", + "immediateStops", + "remainingData", + "retentionUntil", + "deleteAfter", + "retentionSource", + "export", + "unpaid", + "refund", + "pointOfNoReturn", + "mailOptions", + "siteCount" + ], + "steps14": 8 + }, + "mailLc": { + "status": 200, + "sendReceive": "송수신 가능", + "retentionUntil": false, + "deleteAfter": false, + "backupNote": "고객용 메일 백업·복원은 제공하지 않습니다(§17.4)" + } +} +``` + diff --git a/portal/mail/arimail.mjs b/portal/mail/arimail.mjs index f0ba5e8..bcc667e 100644 --- a/portal/mail/arimail.mjs +++ b/portal/mail/arimail.mjs @@ -38,8 +38,8 @@ export function create(deps) { const list = await api.domains(s.id); const d = list.find((x) => x.domain === domain); const host = d ? String(d.hosts[0]).split('/')[0] : `mail.${domain}`; hostCache.set(s.id + '|' + domain, { host, at: Date.now() }); return host; } - /** 전체 설정 API 호출: AriMail 은 기본 도메인 호스트(setup_host, 예: mail.arihakdang.com)에서만 설정 API 를 응답하므로 URL 은 setup_url(인증서 이름·내부 주소)로 두고 Host 헤더를 따로 보낸다. */ - const setupCall = (s, path, opts = {}) => call(fetchImpl, setupOf(s) + path, { ...opts, host: s.setup_host || opts.host || null, headers: { ...setupHeaders(s), ...(opts.headers || {}) } }); + /** 전체 설정 API 호출. AriMail 은 설정 API 를 기본 도메인 호스트(req.headers.host)에서만 응답하고 Node fetch 는 Host 헤더를 바꿀 수 없으므로 setup_url 자체가 기본 도메인 이름이어야 한다(등록 화면 안내, 실제 환경 확인). */ + const setupCall = (s, path, opts = {}) => call(fetchImpl, setupOf(s) + path, { ...opts, headers: { ...setupHeaders(s), ...(opts.headers || {}) } }); const setupHeaders = (s) => { const tok = vault.get(secretName(s.id, 'setup-token')); if (!tok) { const e = new Error('AriMail 전체 설정 토큰이 보관소에 없습니다'); e.code = 'mail_server_token_missing'; throw e; } return { 'X-Setup-Token': tok }; }; /** 관리자 세션 쿠키(도메인별). 보관소의 관리자 로그인으로 로그인해 10분 캐시. */ const adminCache = new Map(); @@ -59,12 +59,12 @@ export function create(deps) { defaultServer() { return serverRow(null); }, getServer: (id) => serverRow(id), /** 서버 등록·수정. secrets 는 보관소에만 저장하고 응답에 돌려주지 않는다. */ - saveServer({ id = null, name, baseUrl, internalUrl = null, setupUrl = null, setupHost = null, mailHost = null, isDefault = false, secrets = {} }) { + saveServer({ id = null, name, baseUrl, internalUrl = null, setupUrl = null, mailHost = null, isDefault = false, secrets = {} }) { const now = nowIso(); const sid = id || newId('ms_'); if (!/^https?:\/\//.test(String(baseUrl || ''))) throw Object.assign(new Error('공개 주소는 http(s):// 로 시작해야 합니다'), { code: 'validation', status: 422 }); if (isDefault) store.run('UPDATE mail_servers SET is_default=0'); - if (id) store.run('UPDATE mail_servers SET name=?, base_url=?, internal_url=?, setup_url=?, setup_host=?, mail_host=?, is_default=?, updated_at=? WHERE id=?', name, baseUrl, internalUrl, setupUrl, setupHost, mailHost, isDefault ? 1 : 0, now, id); - else store.run('INSERT INTO mail_servers(id, name, base_url, internal_url, setup_url, setup_host, mail_host, is_default, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)', sid, name, baseUrl, internalUrl, setupUrl, setupHost, mailHost, isDefault || !store.get('SELECT 1 FROM mail_servers') ? 1 : 0, now, now); + if (id) store.run('UPDATE mail_servers SET name=?, base_url=?, internal_url=?, setup_url=?, mail_host=?, is_default=?, updated_at=? WHERE id=?', name, baseUrl, internalUrl, setupUrl, mailHost, isDefault ? 1 : 0, now, id); + else store.run('INSERT INTO mail_servers(id, name, base_url, internal_url, setup_url, mail_host, is_default, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', sid, name, baseUrl, internalUrl, setupUrl, mailHost, isDefault || !store.get('SELECT 1 FROM mail_servers') ? 1 : 0, now, now); for (const [k, v] of Object.entries(secrets)) if (v) vault.set(secretName(sid, k), typeof v === 'string' ? v : JSON.stringify(v), { kind: 'arimail' }); return api.listServers().find((s) => s.id === sid); }, diff --git a/portal/mail/model.mjs b/portal/mail/model.mjs index 2c209f0..851932a 100644 --- a/portal/mail/model.mjs +++ b/portal/mail/model.mjs @@ -1,7 +1,8 @@ /** * portal/mail/model.mjs — 메일 영역 세부 표(§15~§18, §23.2). 중심 표(mail_services·mailboxes·domains)는 portal/core/model.mjs 에 있다. * - * mail_servers 운영자가 등록한 AriMail 서버(공개 주소·내부 주소·전체 설정 주소·설정 API Host(setup_host: AriMail 전체 설정 API 는 기본 도메인 호스트에서만 응답한다 — 실제 환경 결함 수정)·메일 프로그램 접속 호스트). 비밀(설정 토큰·사용량 키·SSO 비밀)은 보관소 'arimail::*'. + * mail_servers 운영자가 등록한 AriMail 서버(공개 주소·내부 주소·전체 설정 주소·메일 프로그램 접속 호스트). 전체 설정 주소(setup_url)는 AriMail 이 설정 API 를 기본 도메인 호스트에서만 응답하므로 + * 기본 도메인 이름(예: https://mail.arihakdang.com:8791)이어야 한다 — Node fetch 는 Host 헤더를 바꿀 수 없어 이름이 그 서버의 설정 포트로 풀려야 한다(같은 호스트면 /etc/hosts). 비밀(설정 토큰·사용량 키·SSO 비밀)은 보관소 'arimail::*'. * mail_tests 실제 외부 송신·수신·회신 확인 기록(활성화·전환 전후·수동). 단계와 결과만, 메일 내용은 없음. * mail_invites 메일함 초대(초기 비밀번호 대신 링크로 비밀번호를 정함): 토큰 해시·만료·사용 완료·재발송 횟수. * mail_aliases 별칭·전달 전용 주소 기록(정책 미확정이면 생성 불가, §16.2). @@ -36,4 +37,4 @@ CREATE TABLE IF NOT EXISTS mail_usage_log ( id TEXT PRIMARY KEY, mail_service_id TEXT NOT NULL, group_id TEXT NOT NULL, subject_type TEXT NOT NULL, subject_id TEXT NOT NULL, storage_bytes INTEGER, monthly_messages INTEGER, mailboxes INTEGER, warn_level INTEGER, limited_json TEXT, measured_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS mail_usage_log_subject ON mail_usage_log(subject_type, subject_id, measured_at); -` }, { id: 'mail_0002_setup_host', sql: `ALTER TABLE mail_servers ADD COLUMN setup_host TEXT;` }]; +` }];