diff --git a/Ariweb-Suitle_Dev-Order_v0.2.0.md b/Ariweb-Suitle_Dev-Order_v0.2.0.md new file mode 100644 index 0000000..0952a8b --- /dev/null +++ b/Ariweb-Suitle_Dev-Order_v0.2.0.md @@ -0,0 +1,43 @@ +# Ariweb Suite 개발 지시 — 작성자: Neo · v0.2.0 + +**작성자:** Neo +**작성일:** 2026-10-28 +**이전 버전:** v0.1.1(최초 통합 개발 지시, Hera) · v0.1.4(미반영 오류 수정·HAL AI 대시보드·다중 AI 공동 작업창 지시, Neo) +**이번 개정:** 도입 모듈 Webmaker 에 **직접 편집(시각 편집기)** 사용자 기능을 더한다. 새 사용자 기능이므로 가운데 버전 번호를 하나 올린다 — Suite 0.1.x → **0.2.0**, Webmaker 1.7.3 → **1.8.0**. v0.1.1 의 모든 기준과 v0.1.4 의 지시는 그대로 유효하며, 이 문서는 추가 요구만 적는다(v0.1.4 항목은 이번 구현 범위가 아니다). + +## 개정 이력 +- **v0.1.1 · Hera · 2026-10-26:** 최초 통합 개발 지시, Webmaker·AriMail 재사용 원칙, 사용량 경고 70·80·90·95%. +- **v0.1.4 · Neo · 2026-10-27:** patch 자동 증가 규칙, 미반영 오류 수정 전수 반영, HAL AI 대시보드, 다중 AI 공동 작업창. +- **v0.2.0 · Neo · 2026-10-28:** Webmaker 직접 편집(끌어다 놓기 시각 편집기) — Suite `modules/webmaker` 와 기존 Webmaker 저장소 양쪽에 반영. 엄격한 TDD, 격리 작업 트리, 커밋·push·병합·배포는 Neo 가 별도 검토 뒤 수행. + +## 1. 사용자 요구(약화 금지) +Webmaker 가 초안을 만들어 보이는 페이지가 생긴 뒤, **직접 시각 편집 모드**를 더한다. Wix·아임웹 방식의 끌어다 놓기를 적용한다. 변경은 Suite 의 `modules/webmaker` 와 기존 Webmaker 저장소 **양쪽**에 반영한다. + +## 2. 필수 동작(계약) +1. 오른쪽 미리보기 도구 줄의 분명한 **직접 편집 전환**. 사이트와 HTML 페이지가 골라져 있을 때만 켜진다. 기존 소스 편집기는 유지한다. +2. 시각 모드: 캔버스에서 보이는 글을 바로 편집, 보이는 요소 선택, 기존 블록 끌어 순서 바꾸기, 팔레트 구성 요소를 올바른 자리에 끌어다 놓기. 구성 요소 최소: 섹션/컨테이너·제목·문단·버튼/링크·이미지·구분선·여백. +3. 선택 요소 조작: 이동·복제·삭제·요소에 맞는 기본 속성(글·링크 주소·이미지 주소/대체 글·정렬·색·배경·여백). Wix 전체 기능을 흉내 내지 않는 일관되고 안전한 첫 릴리스. +4. 저장 상태(저장되지 않음/저장 중/저장됨/오류) 표시, 저장, 실행 취소/다시 실행, 버리기/다시 불러오기. Ctrl/Cmd+S 저장. 페이지·사이트 전환·화면 이동·창 닫기 전 저장하지 않은 작업 보호. 저장은 실제 선택 HTML 파일을 갱신하고 기존 Webmaker git/버전 이력에 남기며 미리보기·소스 편집기를 갱신하고 상대 경로 자원이 계속 동작한다. **직접 편집은 AI 캐시를 쓰지 않고 AI 수정으로 세지 않는다.** +5. 시각 모드에서 **페이지 스크립트는 실행되지 않는다**. 캔버스는 빌더 쿠키·저장소·API 와 격리된다. 보낸 창·출처·세션을 검사하는 **한정된 postMessage 규약**만 쓰고, 임의 메시지나 HTML 속성을 권한으로 믿지 않는다. 저장 전에 편집기 표식을 제거하고 원본 doctype/head/styles/scripts 를 보존하며 편집하지 않은 코드를 조용히 지우지 않는다. +6. 거절(한국어 안내): HTML 아닌 파일, 너무 큰 내용, 경로 탈출, 다른 사용자 사이트, 진행 중 AI 작업, 오래된 동시 저장, 잘못된 payload, `javascript:`/`data:text/html` 링크, 시각 구성 요소로 들어오는 인라인 이벤트 핸들러, 금지 요소/주소. 임의 HTML 실행 API 를 만들지 않는다. +7. 기존 테넌트 격리·CSRF/출처 검사·sandbox·소스 편집기·모바일 미리보기·버전/복원·배포·가져오기·다국어·모듈·SEO·Site Brief 동작 보존. Suite 의 저장본 `rev` 동시 편집 충돌(§9.3)도 직접 편집 저장에 적용한다. +8. 반응형: 팔레트·속성 창이 캔버스를 가리지 않음, 키보드 사용자용 포커스 가능한 조작과 보이는 선택 표시, 끌어다 놓기의 버튼/키보드 대안. +9. 새 모듈·눈에 띄지 않는 함수에 한국어 상세 주석. + +## 3. TDD·검증 요구 +- 조각마다 실패하는 테스트를 먼저 쓰고 실행해 RED 를 확인한 뒤 최소 구현으로 GREEN. RED→GREEN 증거를 두 저장소의 `docs/verification/` 에 실제 출력으로 남긴다(조작 금지). +- 권한, CSRF, 허용 페이지, 낙관적 동시성/오래된 저장 거절, 안전한 정화/규약, 스크립트 비실행 계약, 저장이 버전 이력을 만드는 것, 실행 취소/다시 실행 핵심, 끌어 옮기기/구성 요소 삽입 핵심, 저장 상태/이동 보호, 필수 접근성 조작을 모두 다룬다. 편집 문서·규약·상태 로직은 순수 모듈로 뽑아 Node 테스트가 동작을 검사하게 한다. 큰 프레임워크·런타임 의존성을 더하지 않는다. +- 두 저장소에서 집중 테스트 뒤 전체 묶음을 실행한다. + +## 4. 버전·이력·출처 요구 +- Webmaker: package/runtime/릴리스 표기 1.8.0, `CHANGELOG.md`·`ERRORS.md`·`README.md`·`Dev_Order_v1.8.0.md`. +- Suite: 루트 package/runtime/릴리스 표기 0.2.0. `modules/webmaker` 의 도입 버전/upstream 메타데이터는 Webmaker 1.8.0 을 나타내되 Suite 전용 연결 변경을 잃지 않는다. `VERSION_HISTORY.md` 에 작성자 Neo·이전 버전·재사용 함수와 수정 함수·고객/운영자 화면·직접 편집·끌어다 놓기·저장·버전·보안 동작·도입 소스 출처(원본 저장소·불변 기준 커밋·원본/대상 경로·수정 여부·라이선스 검토)·테스트·알려진 제한·미확정 정책값. 릴리스/카탈로그/manifest 는 도구가 요구하는 범위에서 일관되게(`IMPORTED_SOURCES`, `scripts/release.mjs` 는 package.json 버전을 따른다). +- 비밀번호·키·토큰·개인 정보·비밀값은 어디에도 넣지 않는다. + +## 5. 저장소 규율 +- 독립 Webmaker 와 Suite `modules/webmaker` 의 모든 차이를 확인하고 보존한다. Suite 전용 HAL/포털/연동 변경을 upstream 파일로 통째로 덮어쓰지 않는다. +- 지정된 두 작업 트리 밖(dirty main 체크아웃·`Ariweb-suite-mail-hal`·Hera 세션/작업 트리)은 건드리지 않는다. +- 커밋·push·병합·배포·운영 접근을 하지 않는다. Neo 가 독립 검토 뒤 수행한다. + +## 6. 완료 조건(이번 구현의 인도 기준) +두 저장소의 변경 파일 목록, RED·GREEN 명령과 결과, 전체 묶음 결과, 버전, 보안 결정, 알려진 제한, 막힌 항목을 보고한다. 구현 결과와 증거: Webmaker `CHANGELOG.md` 1.8.0 절·`docs/verification/visual-editor-1.8.0-tdd.md`, Suite `VERSION_HISTORY.md` 0.2.0 절·`docs/verification/webmaker-visual-editor-0.2.0.md`. diff --git a/IMPORTED_SOURCES.json b/IMPORTED_SOURCES.json index 704a76d..89d563a 100644 --- a/IMPORTED_SOURCES.json +++ b/IMPORTED_SOURCES.json @@ -1,18 +1,25 @@ { - "generatedAt": "2026-10-26T12:02:43.474Z", + "generatedAt": "2026-10-28T14:53:58.554Z", "sources": [ { "name": "webmaker", "repo": "https://source.ibit.kr/OZ-Group/WebHosting_AI_agent", - "commit": "3b10859884ef2fd0f2af11934ab6c16370fd64b8", + "commit": "4b8c4d0de0f69025001a9e0a28a58a94e1a1f088", "dest": "modules/webmaker", "license": "OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.", + "upstream": { + "version": "1.8.0", + "branch": "feature/visual-editor-v1.8.0", + "headCommit": "4b8c4d0de0f69025001a9e0a28a58a94e1a1f088", + "workingTreeDirty": false, + "note": "고정 커밋의 깨끗한 체크아웃" + }, "files": [ { "from": "README.md", "to": "modules/webmaker/README.md", "status": "unchanged", - "origHash": "a7aa1eb7a6fdae8d" + "origHash": "d28788cd6b28ec72" }, { "from": "deploy/deploy.sh", @@ -134,6 +141,12 @@ "status": "unchanged", "origHash": "d93102519cb39333" }, + { + "from": "docs/verification/visual-editor-1.8.0-tdd.md", + "to": "modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md", + "status": "unchanged", + "origHash": "7c3757a3731e1a84" + }, { "from": "hooks/askpass.sh", "to": "modules/webmaker/hooks/askpass.sh", @@ -318,7 +331,7 @@ "from": "lib/gitstore.js", "to": "modules/webmaker/lib/gitstore.js", "status": "unchanged", - "origHash": "d4650fab7b85f9b6" + "origHash": "b9d68792f6638161" }, { "from": "lib/google.js", @@ -552,7 +565,7 @@ "from": "lib/store.js", "to": "modules/webmaker/lib/store.js", "status": "modified", - "origHash": "8132dcde60fc5789" + "origHash": "82156230f6fcb726" }, { "from": "lib/templates.js", @@ -572,6 +585,12 @@ "status": "modified", "origHash": "0730f701278d33a1" }, + { + "from": "lib/visual-editor.js", + "to": "modules/webmaker/lib/visual-editor.js", + "status": "unchanged", + "origHash": "5680bdfc5504d968" + }, { "from": "lib/xlsx.js", "to": "modules/webmaker/lib/xlsx.js", @@ -774,7 +793,7 @@ "from": "package.json", "to": "modules/webmaker/package.json", "status": "unchanged", - "origHash": "624dc8ee663ac2ba" + "origHash": "7b5c2bf8a18df617" }, { "from": "public/404.html", @@ -798,13 +817,13 @@ "from": "public/admin.js", "to": "modules/webmaker/public/admin.js", "status": "unchanged", - "origHash": "fa5725fbe6356bbc" + "origHash": "6f37dafdf4502684" }, { "from": "public/app.js", "to": "modules/webmaker/public/app.js", "status": "modified", - "origHash": "3baf113fbacdae72" + "origHash": "f8a5d75f9d7b734a" }, { "from": "public/credits-ui.js", @@ -840,7 +859,7 @@ "from": "public/index.html", "to": "modules/webmaker/public/index.html", "status": "modified", - "origHash": "bc33e85029da63b7" + "origHash": "400f72c882088d8e" }, { "from": "public/login.html", @@ -912,7 +931,7 @@ "from": "public/style.css", "to": "modules/webmaker/public/style.css", "status": "modified", - "origHash": "041d7f216677b02d" + "origHash": "3a9d2f8e50e7b00a" }, { "from": "public/templates-ui.js", @@ -920,6 +939,18 @@ "status": "unchanged", "origHash": "6adc66d8e2204b1f" }, + { + "from": "public/visual-editor-core.js", + "to": "modules/webmaker/public/visual-editor-core.js", + "status": "unchanged", + "origHash": "e1ee3bb64d63ee7c" + }, + { + "from": "public/visual-editor-ui.js", + "to": "modules/webmaker/public/visual-editor-ui.js", + "status": "unchanged", + "origHash": "a24aaae4b1b28e1e" + }, { "from": "public/webmail.html", "to": "modules/webmaker/public/webmail.html", @@ -950,6 +981,12 @@ "status": "unchanged", "origHash": "7e72375620ac125d" }, + { + "from": "public/wm-ve-canvas.js", + "to": "modules/webmaker/public/wm-ve-canvas.js", + "status": "unchanged", + "origHash": "fb32a5abf9d4f33d" + }, { "from": "scripts/acme-dns-hook.js", "to": "modules/webmaker/scripts/acme-dns-hook.js", @@ -1014,7 +1051,7 @@ "from": "server.js", "to": "modules/webmaker/server.js", "status": "modified", - "origHash": "4585fd7fb07d3b4b" + "origHash": "b15f25a0b4f8278b" }, { "from": "setup-server.js", @@ -1063,6 +1100,24 @@ "to": "modules/webmaker/test/server.test.js", "status": "unchanged", "origHash": "b931228730a8bd70" + }, + { + "from": "test/visual-editor-app.test.js", + "to": "modules/webmaker/test/visual-editor-app.test.js", + "status": "unchanged", + "origHash": "d65ab5fb5885da48" + }, + { + "from": "test/visual-editor-server.test.js", + "to": "modules/webmaker/test/visual-editor-server.test.js", + "status": "unchanged", + "origHash": "b5de130875d5ef36" + }, + { + "from": "test/visual-editor.test.js", + "to": "modules/webmaker/test/visual-editor.test.js", + "status": "unchanged", + "origHash": "bd42f92d91224b08" } ] }, diff --git a/IMPORTED_SOURCES.md b/IMPORTED_SOURCES.md index c0a7682..1eff214 100644 --- a/IMPORTED_SOURCES.md +++ b/IMPORTED_SOURCES.md @@ -1,18 +1,19 @@ # 도입 소스 기록(IMPORTED_SOURCES) -생성: 2026-10-26T12:02:43.474Z — `node scripts/import-sources.mjs` 가 만든다. 파일별 '수정' 은 원본 커밋과 현재 파일의 해시 비교 결과이며, 수정 이유는 VERSION_HISTORY.md 의 "도입 소스" 절과 각 파일 머리 주석의 `[Ariweb Suite 변경]` 표시로 설명한다. +생성: 2026-10-28T14:53:58.554Z — `node scripts/import-sources.mjs` 가 만든다. 파일별 '수정' 은 원본 커밋과 현재 파일의 해시 비교 결과이며, 수정 이유는 VERSION_HISTORY.md 의 "도입 소스" 절과 각 파일 머리 주석의 `[Ariweb Suite 변경]` 표시로 설명한다. ## webmaker - 원본 저장소: https://source.ibit.kr/OZ-Group/WebHosting_AI_agent -- 고정 커밋: `3b10859884ef2fd0f2af11934ab6c16370fd64b8` +- 고정 커밋(불변 기준): `4b8c4d0de0f69025001a9e0a28a58a94e1a1f088` +- 원본 버전(package.json): 1.8.0 · 브랜치: `feature/visual-editor-v1.8.0` · 작업 트리: 깨끗함 - 대상 경로: `modules/webmaker/` -- 파일 수: 176 (수정 8) +- 파일 수: 184 (수정 8) - 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. | 원본 경로 | 대상 경로 | 수정 | 원본 해시 | |---|---|---|---| -| README.md | modules/webmaker/README.md | 그대로 | a7aa1eb7a6fdae8d | +| README.md | modules/webmaker/README.md | 그대로 | d28788cd6b28ec72 | | deploy/deploy.sh | modules/webmaker/deploy/deploy.sh | 그대로 | 6f016f8dcf56a3b2 | | deploy/front-proxy-webmaker.conf | modules/webmaker/deploy/front-proxy-webmaker.conf | 그대로 | 0ec1641b554a3b50 | | deploy/install-arimail.sh | modules/webmaker/deploy/install-arimail.sh | 그대로 | d8626ef07df49738 | @@ -33,6 +34,7 @@ | docs/FIRST-INSTALL.md | modules/webmaker/docs/FIRST-INSTALL.md | 그대로 | 642ce20eb96cabb9 | | docs/GITEA-LOCAL.md | modules/webmaker/docs/GITEA-LOCAL.md | 그대로 | 4d7462b52ef34783 | | docs/SSL-WILDCARD.md | modules/webmaker/docs/SSL-WILDCARD.md | 그대로 | d93102519cb39333 | +| docs/verification/visual-editor-1.8.0-tdd.md | modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md | 그대로 | 7c3757a3731e1a84 | | hooks/askpass.sh | modules/webmaker/hooks/askpass.sh | 그대로 | c86b42091d07748d | | hooks/guard.js | modules/webmaker/hooks/guard.js | 그대로 | 4804e4cd337db53c | | hooks/guard.sh | modules/webmaker/hooks/guard.sh | 그대로 | 29df99b89bc31912 | @@ -63,7 +65,7 @@ | lib/feedback.js | modules/webmaker/lib/feedback.js | 그대로 | c5860a676dd6118a | | lib/forms.js | modules/webmaker/lib/forms.js | 그대로 | e2f1b1bb21e5bbdd | | lib/fx.js | modules/webmaker/lib/fx.js | 그대로 | 7514ae9840fa6307 | -| lib/gitstore.js | modules/webmaker/lib/gitstore.js | 그대로 | d4650fab7b85f9b6 | +| lib/gitstore.js | modules/webmaker/lib/gitstore.js | 그대로 | b9d68792f6638161 | | lib/google.js | modules/webmaker/lib/google.js | 그대로 | bfdaa70652f846cc | | lib/i18n.js | modules/webmaker/lib/i18n.js | 그대로 | 944c12aa16d00f45 | | lib/jobpids.js | modules/webmaker/lib/jobpids.js | 그대로 | f5df0824a0a457e9 | @@ -102,10 +104,11 @@ | lib/sitemsg.js | modules/webmaker/lib/sitemsg.js | 그대로 | 09bcc4cb52e93a57 | | lib/ssl.js | modules/webmaker/lib/ssl.js | 그대로 | f7a5bb79900d8bdd | | lib/stats.js | modules/webmaker/lib/stats.js | 그대로 | fc2de57d4be0960a | -| lib/store.js | modules/webmaker/lib/store.js | **수정** | 8132dcde60fc5789 | +| lib/store.js | modules/webmaker/lib/store.js | **수정** | 82156230f6fcb726 | | lib/templates.js | modules/webmaker/lib/templates.js | 그대로 | bb44e537d0b28b16 | | lib/uploads.js | modules/webmaker/lib/uploads.js | 그대로 | 5441ea45ed434575 | | lib/vault.js | modules/webmaker/lib/vault.js | **수정** | 0730f701278d33a1 | +| lib/visual-editor.js | modules/webmaker/lib/visual-editor.js | 그대로 | 5680bdfc5504d968 | | lib/xlsx.js | modules/webmaker/lib/xlsx.js | 그대로 | dc353dd3b5fd6e10 | | locales/ko.json | modules/webmaker/locales/ko.json | 그대로 | 20abf531ef8320f6 | | locales/site/ar.json | modules/webmaker/locales/site/ar.json | 그대로 | 8644e815887e9bb1 | @@ -139,18 +142,18 @@ | modules/members/widget.html | modules/webmaker/modules/members/widget.html | 그대로 | 3a0c8409adda26db | | modules/reviews/widget.html | modules/webmaker/modules/reviews/widget.html | 그대로 | de7ad783f7cad79a | | modules/survey/widget.html | modules/webmaker/modules/survey/widget.html | 그대로 | 62b2a3c2ee267b66 | -| package.json | modules/webmaker/package.json | 그대로 | 624dc8ee663ac2ba | +| package.json | modules/webmaker/package.json | 그대로 | 7b5c2bf8a18df617 | | public/404.html | modules/webmaker/public/404.html | 그대로 | 22b18c443cbb8f50 | | public/admin.css | modules/webmaker/public/admin.css | 그대로 | b9b4a4cbaa9dc3ec | | public/admin.html | modules/webmaker/public/admin.html | 그대로 | 6853f78ac57ef360 | -| public/admin.js | modules/webmaker/public/admin.js | 그대로 | fa5725fbe6356bbc | -| public/app.js | modules/webmaker/public/app.js | **수정** | 3baf113fbacdae72 | +| public/admin.js | modules/webmaker/public/admin.js | 그대로 | 6f37dafdf4502684 | +| public/app.js | modules/webmaker/public/app.js | **수정** | f8a5d75f9d7b734a | | public/credits-ui.js | modules/webmaker/public/credits-ui.js | 그대로 | 44e43d5179d77578 | | public/domains-ui.js | modules/webmaker/public/domains-ui.js | 그대로 | ea77c164db309303 | | public/flow-ui.js | modules/webmaker/public/flow-ui.js | 그대로 | e9a92ab29b71840e | | public/i18n.js | modules/webmaker/public/i18n.js | 그대로 | c6c4039435b77583 | | public/import.js | modules/webmaker/public/import.js | 그대로 | 3f17de67ac16b9c4 | -| public/index.html | modules/webmaker/public/index.html | **수정** | bc33e85029da63b7 | +| public/index.html | modules/webmaker/public/index.html | **수정** | 400f72c882088d8e | | public/login.html | modules/webmaker/public/login.html | 그대로 | dd870dfc417adc65 | | public/manage-ui.js | modules/webmaker/public/manage-ui.js | 그대로 | c57d9d54dea3ab8d | | public/modules-ui.js | modules/webmaker/public/modules-ui.js | 그대로 | 5f4f54a0abe692c4 | @@ -162,13 +165,16 @@ | public/site-admin.html | modules/webmaker/public/site-admin.html | 그대로 | d598563c7d617bbb | | public/site-admin.js | modules/webmaker/public/site-admin.js | 그대로 | 762da69b23f1db85 | | public/site-info.js | modules/webmaker/public/site-info.js | 그대로 | 99060343aaca0ab3 | -| public/style.css | modules/webmaker/public/style.css | **수정** | 041d7f216677b02d | +| public/style.css | modules/webmaker/public/style.css | **수정** | 3a9d2f8e50e7b00a | | public/templates-ui.js | modules/webmaker/public/templates-ui.js | 그대로 | 6adc66d8e2204b1f | +| public/visual-editor-core.js | modules/webmaker/public/visual-editor-core.js | 그대로 | e1ee3bb64d63ee7c | +| public/visual-editor-ui.js | modules/webmaker/public/visual-editor-ui.js | 그대로 | a24aaae4b1b28e1e | | public/webmail.html | modules/webmaker/public/webmail.html | 그대로 | 981fee72b5c72bed | | public/webmail.js | modules/webmaker/public/webmail.js | 그대로 | 6cb9a50f39c8d025 | | public/wizard.js | modules/webmaker/public/wizard.js | 그대로 | 2ba97adfbb4f7cc1 | | public/wm-modules.js | modules/webmaker/public/wm-modules.js | 그대로 | efc7526ba17c338d | | public/wm-stats.js | modules/webmaker/public/wm-stats.js | 그대로 | 7e72375620ac125d | +| public/wm-ve-canvas.js | modules/webmaker/public/wm-ve-canvas.js | 그대로 | fb32a5abf9d4f33d | | scripts/acme-dns-hook.js | modules/webmaker/scripts/acme-dns-hook.js | 그대로 | 4401e102dde4c3ec | | scripts/admin.js | modules/webmaker/scripts/admin.js | 그대로 | 02adaf25dabad0ec | | scripts/backup-data.js | modules/webmaker/scripts/backup-data.js | 그대로 | 94b331c3e9555664 | @@ -179,7 +185,7 @@ | scripts/site-msg-catalog.py | modules/webmaker/scripts/site-msg-catalog.py | 그대로 | 1f9b1ea8adbc174c | | scripts/ssl-wildcard.js | modules/webmaker/scripts/ssl-wildcard.js | 그대로 | 68cbebdb59a5f6d9 | | scripts/unit-check.js | modules/webmaker/scripts/unit-check.js | 그대로 | ba15624f696892b2 | -| server.js | modules/webmaker/server.js | **수정** | 4585fd7fb07d3b4b | +| server.js | modules/webmaker/server.js | **수정** | b15f25a0b4f8278b | | setup-server.js | modules/webmaker/setup-server.js | 그대로 | 6545aab3185765a8 | | templates/contact-widget.html | modules/webmaker/templates/contact-widget.html | 그대로 | 8063f64226c13914 | | test/dnszone-import.test.js | modules/webmaker/test/dnszone-import.test.js | 그대로 | 33e1209656a7bf3f | @@ -188,11 +194,14 @@ | test/mailservers.test.js | modules/webmaker/test/mailservers.test.js | 그대로 | dfb80acca9782be0 | | test/registrar-route53.test.js | modules/webmaker/test/registrar-route53.test.js | 그대로 | 84e9eba32ad5c7ef | | test/server.test.js | modules/webmaker/test/server.test.js | 그대로 | b931228730a8bd70 | +| test/visual-editor-app.test.js | modules/webmaker/test/visual-editor-app.test.js | 그대로 | d65ab5fb5885da48 | +| test/visual-editor-server.test.js | modules/webmaker/test/visual-editor-server.test.js | 그대로 | b5de130875d5ef36 | +| test/visual-editor.test.js | modules/webmaker/test/visual-editor.test.js | 그대로 | bd42f92d91224b08 | ## arimail - 원본 저장소: https://source.ibit.kr/OZ-Group/AriMail -- 고정 커밋: `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924` +- 고정 커밋(불변 기준): `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924` - 대상 경로: `modules/arimail/` - 파일 수: 88 (수정 15) - 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함. diff --git a/README.md b/README.md index 089d794..b4b0a6e 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ 고객이 회원가입·로그인 → 상품 선택 → 사이트 제작(Webmaker) → 배포 → 도메인·HTTPS → 메일(AriMail) → 백업 → 운영(재배포·복원·이전·소유권·해지)까지 **하나의 연결된 서비스**로 이용하는 통합 플랫폼이다. 운영자는 HAL(`hal.ariweb.net`)에서 릴리스·서버·모듈·작업·장애·복구를 관리하고, HAL 이 Node Agent 를 통해 나머지 모든 모듈을 배포·갱신·되돌리기·복구한다. -- 버전: **0.1.1** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md) +- 버전: **0.2.0** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md) - 설계·개발 규약: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) · 포털↔HAL 작업 계약: [docs/HAL-JOBS.md](docs/HAL-JOBS.md) · HAL↔Agent 프로토콜: [docs/HAL-AGENT.md](docs/HAL-AGENT.md) -- 도입 소스(Webmaker 1.7.3 · AriMail 1.0.11): [IMPORTED_SOURCES.md](IMPORTED_SOURCES.md) — `node scripts/import-sources.mjs` 로 갱신 -- 개발 지시 원문: `Ariweb-Suitle_Dev-Order_v0.1.1.md` +- 도입 소스(Webmaker 1.8.0 · AriMail 1.0.11): [IMPORTED_SOURCES.md](IMPORTED_SOURCES.md) — `node scripts/import-sources.mjs` 로 갱신(참조 저장소와 나란히 개발 중이면 `--allow-dirty`) +- 개발 지시 원문: `Ariweb-Suitle_Dev-Order_v0.1.1.md`(기준) · `Ariweb-Suitle_Dev-Order_v0.1.4.md` · `Ariweb-Suitle_Dev-Order_v0.2.0.md`(Webmaker 직접 편집) ## 구성 diff --git a/VERSION_HISTORY.md b/VERSION_HISTORY.md index 842fd3a..9e3a001 100644 --- a/VERSION_HISTORY.md +++ b/VERSION_HISTORY.md @@ -1,5 +1,78 @@ # VERSION_HISTORY +## 0.2.0 (2026-10-28) + +- **작성자**: 개발 지시 — Neo(`Ariweb-Suitle_Dev-Order_v0.2.0.md`) · 구현 — Claude Fable 5.1(격리 작업 트리 `feature/visual-editor-v0.2.0`, 기준 main `4b3714e37c543fcfa8862dbc68c76f62a9b0bf97`). 커밋·push·병합·배포·운영 서버 접근은 하지 않았다 — Neo 가 검토 뒤 수행한다. +- **이전 버전**: 0.1.1(2026-10-26, 첫 구현 릴리스 — 아래 절) · 0.1.4 개발 지시(2026-10-27, HAL AI 대시보드·다중 AI 공동 작업창 지시 문서만 추가, `4b3714e`). 0.2.0 은 **가운데 버전 번호를 올리는 기능 릴리스**다: 도입 모듈 Webmaker 에 사용자 기능(직접 편집)이 더해졌고 Webmaker 자체도 1.7.3 → **1.8.0** 으로 올랐다. 0.1.1 의 모든 기준·0.1.4 의 지시는 그대로 유효하다(이번 범위 밖의 0.1.4 항목은 구현하지 않았다). +- **구성**: HAL · 고객 포털 · Node Agent 는 0.1.1 그대로. 도입 모듈 **Webmaker 1.8.0**(직접 편집 포함) · AriMail 1.0.11(변경 없음). 외부 npm 의존성 없음, Node 22.13+. + +### 요구(사용자 요구 — 약화 없이 반영) +Webmaker 가 초안을 만들어 보이는 페이지가 생긴 뒤 **직접 시각 편집 모드**를 더한다(Wix·아임웹 방식 끌어다 놓기). 변경은 Suite 의 `modules/webmaker` 와 기존 Webmaker 저장소 양쪽에 반영한다. 필수 동작·보안 규칙은 `Ariweb-Suitle_Dev-Order_v0.2.0.md` 에 계약으로 적었다. + +### 재사용한 기존 기능(그대로 도입) +- Webmaker 1.8.0 의 **직접 편집(시각 편집기)** 전부 — 미리보기 도구 줄 "직접 편집" 전환(사이트·HTML 페이지가 골라져 있을 때만), 캔버스 글 바로 고치기(두 번 클릭/Enter), 요소 선택·도구 막대(↑↓·복제·삭제·끌기 손잡이), 기존 블록 끌어 옮기기, 구성 요소 팔레트 7종(섹션/컨테이너·제목·문단·버튼(링크)·이미지·구분선·여백) 끌어다 놓기 + "추가" 버튼, 속성 창(상위 선택·이동·복제·삭제·글·링크 주소·이미지 주소/대체 글·정렬·글자색·배경색·안쪽/바깥 여백), 저장 상태(변경 없음/저장되지 않음/저장 중/저장됨/저장 실패 — aria-live), 저장(Ctrl/Cmd+S), 실행 취소/다시 실행, 버리고 다시 불러오기, 편집 끝내기, 페이지·사이트·화면 전환·새로고침·소스 보기 전환·창 닫기 전 저장하지 않은 변경 보호. 기존 소스 편집기·모바일 미리보기·버전/복원·배포·가져오기·다국어·모듈·SEO·Site Brief 는 그대로. +- 재사용한 함수(Webmaker 기존 코드를 그대로 호출): `resolveTextFile`(경로 검증·링크 거부), `listFiles`, `gitRecord`/`lib/gitstore.recordChange`(버전 기록 — 새 종류 `visual-edit`), `publicView`, `broadcast`, `jobOf`(AI 작업·배포 중 판정), `readBody`, `sessionUser`·`fromTenantPage`·`auth.csrfOk`(출처·CSRF), `siteUrl`, `isSecureReq`, `MAX_TEXT_BYTES`(5MB 상한), `store.save`(Suite `rev` 증가). 화면: `discardOk`·`renderPreview`·`renderGit`·`loadFrame`·`setEditorOpen`·`logLine`·`pushMsg`·`api`(CSRF 헤더)·`T`. + +### 수정한 기존 기능 +- **Webmaker(`modules/webmaker`, 파일 머리·줄 주석 `v1.8.0`)**: `server.js` — `require('./lib/visual-editor')`, `veRuntime()`·`requestOrigin()`, `GET /files` 응답에 `hash`, 새 경로 `GET/PUT /api/sites//visual/<페이지>`(아래 "직접 편집·끌어다 놓기·저장·버전·보안"). `public/app.js` — `discardOk`(직접 편집 변경도 묻기), `VER_KIND` 에 `visual-edit`(기존 `manual-edit` 는 "소스 편집" 으로 표기), `renderPreview`(직접 편집 중이면 미리보기 덮어쓰지 않고 `VisualEditor.onSiteUpdated`), `btnToggleSource`(한 번에 하나), `beforeunload`, `resetToNew`, `btnReload`·`fileSelect.onchange`, `openSite` 끝(사이트 바뀌면 편집 종료). `public/index.html` — `#btnVisual`, `#veBar`, `.ve-stage`(`#vePalette`·`#frameWrap`·`#veInspector`), 스크립트 2개. `public/style.css` — `.ve-*`(900px 이하 세로 배치·접기). `public/admin.js` — 버전 종류 표기. `package.json` 1.8.0. +- **Suite 최소 연결 변경(`[Ariweb Suite 변경]`, 0.1.1 의 8개 파일 변경은 그대로 유지)**: `modules/webmaker/server.js` 의 직접 편집 `PUT` 에도 저장본 `rev` 충돌 검사(`revConflict`, §9.3)를 소스 편집기·기본 사항 저장과 같이 적용 — 화면이 보낸 `rev` 가 다르면 409 와 양쪽 요약(`conflict.currentRev` 등). 화면(`visual-editor-ui.js`)은 `GET /visual` 의 `rev` 를 기억해 저장 때 보내고 응답의 `rev` 로 갱신한다(upstream Webmaker 는 `rev` 를 돌려주되 무시한다). 그 밖의 Suite 전용 변경(SSO·내부 API·배너·`lib/ariweb.js`·`store.rev`)은 바뀌지 않았다 — `diff` 로 upstream 과의 차이가 0.1.1 의 Suite 블록 + 1.8.0 공통 변경만임을 확인했다. +- **Suite 도구**: `scripts/import-sources.mjs` 에 `--allow-dirty`(참조 저장소를 나란히 개발 중일 때 고정 커밋 위의 작업 트리 변경·추적되지 않은 새 파일을 가져오고 `upstream{version·branch·headCommit·workingTreeDirty·note}` 를 기록) 와 `--only` 때 다른 소스 기록 유지. `hal/modules/index.mjs`·`agent/jobs/module.mjs` 의 Webmaker 버전 주석, `portal/webmaker/REUSE.md` 갱신. Agent·포털 코드 동작은 바뀌지 않았다(릴리스 artifact 는 `package.json` 0.2.0 으로 `webmaker-0.2.0.tar.gz` 등으로 만들어진다). **`scripts/release.mjs` 와 `hal/core.mjs` 는 바뀌었다** — 도입 소스 출처 fail-closed(아래 ⑪)와 릴리스 승인 세 검사 필수(아래 종결 검토 ③). +- 오류 수정: Webmaker 직접 편집 저장에서 JSON 파싱 실패가 500 으로 나오던 것을 400 한국어 안내로(TDD 중 발견, 직접 편집 경로에만 적용). +- **검토 수정(Neo 독립 검토)** — ① 출시 차단: 직접 편집 PUT 이 본문을 읽기(await) 전에 파일 hash 를 잡아 두어 두 저장이 나란히 들어오면 둘째가 첫째를 조용히 덮어쓸 수 있었다 → 본문을 다 읽은 뒤 사이트 기록(`store.load`)·파일을 다시 읽어 hash(Suite 는 `rev` 도)를 비교하고 검증·쓰기까지 await 없이 이어 간다(둘째는 409 stale, 첫 저장 보존). 결정적 회귀 테스트(chunked PUT 두 개의 본문을 순서대로 완료). ② 안전 강화: 직접 편집 저장을 `lib/visual-editor.writeTextAtomic`(같은 폴더 임시 파일 0644 → rename, 실패 때 정리·오류 전달, 파일 시스템 주입 가능)로 바꿔 쓰기 실패·중단 때 마지막 정상 페이지가 잘리지 않는다(500 + "기존 페이지는 그대로 있습니다", 버전 미생성). 소스 편집기의 기존 쓰기는 바꾸지 않았다. + +### 고객 화면 +- 포털 사이트 상세 › 제작 탭 › "제작 화면 열기"(SSO) → Webmaker 빌더 오른쪽 미리보기 도구 줄의 **직접 편집**. 포털 자체 화면은 바뀌지 않았다. 직접 편집은 AI Wallet(캐시)을 쓰지 않으므로 포털 AI Wallet 카드에 영향이 없고, 직접 편집 저장본(버전 `visual-edit`)은 포털 배포 화면의 저장본 선택·Source 변경 이력에 그대로 보인다(내부 API `projects/` 의 `versionList`). +- 포털 상단 배너(그룹·운영 사이트)는 직접 편집 중에도 그대로 보인다. + +### 운영자 화면 +- HAL 운영자 콘솔 변경 없음. Webmaker 관리자 콘솔의 버전·변경 종류 표기에 "직접 편집"(`visual-edit`)이 추가되고 기존 `manual-edit` 는 "소스 편집" 으로 보인다. 운영자 조치 항목 없음(설정값·보관소 변경 없음). + +### 직접 편집 · 끌어다 놓기 · 저장 · 버전 · 보안 동작 +- **캔버스**: `GET /api/sites//visual/<페이지>` 가 저장된 HTML 을 편집 캔버스 문서로 바꿔 준다(`lib/visual-editor.prepareCanvas`). 페이지 ``), 서비스 21/23(`읽기 요청은 기록 파일을 바꾸지 않는다`, 폐기 중 저장이 "폐기" 로 거절되지 않음). +- Suite 모듈 `test/ariweb.test.js`(수정 전 순서 — unlink 먼저): `ℹ tests 10 · pass 7 · fail 3` — `포털 연결이 풀리지 않았다(연결 해제는 정리 성공 뒤에만): null`, `정리는 먼저 수행됐다`(연결 해제 실패가 정리 전에 남), `연결이 되살아났다(readback): null`. + +### GREEN — Suite(4차 검토 뒤, 현재 증거) +``` +node test/release-provenance.test.mjs ✔ 통과 11 · 실패 0 +modules/webmaker: CLAUDE_BIN=<가짜 exit 1> npm test ℹ tests 125 · pass 125 · fail 0 (단위 39 · 서비스 23 · 화면 전환 1 · 원본 59 + ariweb 폐기 보상 3) +CLAUDE_BIN=<가짜 exit 1> node test/run.mjs ✔ 테스트 파일 24개 중 실패 0개 (생성된 local-e2e-2026-10-28.{md,json} 은 삭제) +``` +참조 저장소: `npm test` 115/115(단위 39 + 서비스 23 + 화면 전환 1 + 원본 52). 실제 Chrome 게이트(Neo 호스트) 61/61 그대로(게이트가 보는 화면 동작 불변). + +### RED — Suite(최종 검토) +- `node test/release-provenance.test.mjs`(수정 전 `hal/core.mjs`): `✘ 통과 7 · 실패 2` — `Error: 승인자·시각도 그대로: 기대 {"status":"approved","by":"op",…} 실제 {"status":"approved","by":"op2",…}`(승인된 행이 재등록으로 바뀜), CLI 재등록 뒤 `approved_by` 가 "test" → "test2". +- 공유 서비스 테스트(수정 전 코드, 참조 저장소): `ℹ tests 18 · pass 15 · fail 3` — 잠금 경쟁(`설정 저장이 직접 편집 버전을 덮어쓰지 않는다: ["1:create"]`), 모호한 푸시(`원격이 받았으면 성공: {…"rolledBack":true,"stage":"push"}`), 준비 단계 실패(`stage`·`rollback` 없음, 페이지 미복원). 항목 5(수정 전 store.js): `ℹ tests 19 · pass 18 · fail 1` — `작업의 종료 저장이 화면의 제목 변경을 덮어쓰지 않는다: {"title":"VE site","status":"idle"}`. + +### GREEN — Suite(최종 검토 뒤) +``` +node test/release-provenance.test.mjs ✔ 통과 9 · 실패 0 +modules/webmaker: CLAUDE_BIN=<가짜 exit 1> npm test ℹ tests 119 · pass 119 · fail 0 (단위 38 · 서비스 21 · 화면 전환 1 · 원본 59) +CLAUDE_BIN=<가짜 exit 1> node test/run.mjs ✔ 테스트 파일 24개 중 실패 0개 (생성된 local-e2e-2026-10-28.{md,json} 은 삭제) +``` +참조 저장소(그때): `npm test` 112/112(단위 38 + 서비스 21 + 화면 전환 1 + 원본 52). 현재 증거는 위 "4차 검토 뒤" 절. + +### RED — Suite(종결 검토) +- `node test/release-provenance.test.mjs`(수정 전 `hal/core.mjs`·`scripts/release.mjs`): `✘ 통과 4 · 실패 3` — `Error: checks 없음 (approveRelease)`(승인됨), `Error: 7.7.0: 거절 200 {"ok":true,"release":{…}}`(운영자 API 가 checks 없는 릴리스를 승인), `Error: tests-null: HAL 릴리스 등록: 0.2.0 (approved) … 기대 1 실제 0`(`register --approve` 가 testsPassed null 을 승인). +- 공유 서비스 테스트(수정 전 코드, 참조 저장소에서 실행): `ℹ tests 14 · pass 11 · fail 3` — Git 실패 되돌리기 `rolledBack:false`(옛 boolean 은 상태를 검증하지 않음), 원격 트랜잭션(옛 코드는 pre-receive 거부를 200 + `rec.error` 로 삼킴). 단위: SMIL `거절되어야 함: … npm test ℹ tests 114 · pass 114 · fail 0 (단위 38 · 서비스 16 · 화면 전환 1 · 원본 59) +CLAUDE_BIN=<가짜 exit 1> node test/run.mjs ✔ 테스트 파일 24개 중 실패 0개 (생성된 local-e2e-2026-10-28.{md,json} 은 삭제) +``` +참조 저장소(그때): `npm test` 107/107(단위 38 + 서비스 16 + 화면 전환 1 + 원본 52). 현재 증거는 위 "최종 검토 뒤" 절. + +### RED — Suite(1차 검토) +- `node --no-warnings test/release-provenance.test.mjs`(구현 전): `✘ 통과 0 · 실패 4`(`lib/provenance.mjs` 없음 → 네 테스트 모두 실패). +- `node --no-warnings test/webmaker-visual-editor.test.mjs` 를 **rev 필수 줄과 `{ restore: true }` 를 뺀 임시 서버 복사본** 으로 실행: `✘ 통과 4 · 실패 1` — `Error: rev 없음: {"path":"index.html",…,"rev":11,…}`(rev 없는 저장이 200 으로 파일을 바꿈). 실행 뒤 복사본은 수정본으로 되돌렸다(`cmp` 확인). +- Suite `modules/webmaker` `npm test`(공유 테스트가 rev 를 보내기 전): `ℹ tests 110 · pass 102 · fail 8` — 모두 `{"error":"저장본 개정 번호(rev)가 없거나 올바르지 않습니다…","revRequired":true}`(항목 7 이 동작함을 보임) → 공유 테스트에 rev 추가 → `pass 108 · fail 2`(409 가 stale 대신 conflict) → `expectStale` → 110/110. + +### GREEN — Suite(1차 검토 뒤) +``` +node --no-warnings test/release-provenance.test.mjs ✔ 통과 4 · 실패 0 +node --no-warnings test/webmaker-visual-editor.test.mjs ✔ 통과 5 · 실패 0 (rev 없음/문자열/null/NaN/빈 → 400, 교차 파일 rev → 409, store 실패 → 500·rev 보존·재시도 200) +modules/webmaker: CLAUDE_BIN=<가짜 exit 1> npm test ℹ tests 110 · pass 110 · fail 0 +CLAUDE_BIN=<가짜 exit 1> node test/run.mjs ✔ 테스트 파일 24개 중 실패 0개 (release-provenance 포함; 생성된 local-e2e-2026-10-28.{md,json} 은 삭제) +``` +참조 저장소: `npm test` 103/103(단위 37 + 서비스 13 + 화면 전환 1 + 원본 52). + +## 브라우저 검증 상태 · 자동화하지 않은 것 +- 실제 브라우저 검증(Neo 의 게이트, 실제 Chrome)은 다섯 차례 수행되었다: 배치 결함 → 선택 전 도구 줄 노출 → 끌어다 놓기 실패 → 살균 진단으로 "HTML5 끌기가 sandbox iframe 에 도달하지 않음" 확정(3·4차는 선택·글 편집·실행 취소/다시 실행·키보드 이동 통과) → 포인터 다리 뒤 네이티브 포인터 순서 게이트 **37/37**. 독립 검토 수정 뒤 재실행은 게이트 멱등성 결함을 고친 뒤 **Neo 호스트에서 61/61 통과**(연결 규약·이동 활성화 6종·위조 창 메시지 단언 포함) — 캔버스 안 실제 포인터 조작(글 입력·실행 취소/다시 실행·이동·팔레트 끌어다 놓기·저장·저장 파일·390px)은 이 게이트로 자동 검증했다. Suite 쪽 화면은 독립 Webmaker 와 같은 `style.css`·`visual-editor-ui.js`·`wm-ve-canvas.js` 를 쓰므로 같은 수정이 적용된다(style.css 포털 배너 5줄만 추가). 종결 검토 3건은 브라우저 동작을 바꾸지 않아 추가 재실행은 필요 없다. +- 자동화하지 않은 것: 포털 SSO 로 들어간 실제 화면 확인, 운영 서버 배포, Chrome 외 브라우저. 사람 확인 목록은 Webmaker `Dev_Order_v1.8.0.md` 끝. diff --git a/hal/core.mjs b/hal/core.mjs index d9537a1..3e9f715 100644 --- a/hal/core.mjs +++ b/hal/core.mjs @@ -3,6 +3,7 @@ * app.mjs(라우트)와 jobs.mjs(작업 종류)가 함께 쓴다. 모든 함수는 deps(store·vault·config·log·jobs) 를 받는다. */ import { hostname } from 'node:os'; +import { createHash } from 'node:crypto'; import { newId, randomToken, sha256, hashPassword } from '../lib/crypto.mjs'; import { nowIso, toJson, parseJson } from '../lib/db.mjs'; import { audit } from '../lib/audit.mjs'; @@ -10,8 +11,8 @@ import { notify } from '../lib/notify.mjs'; import { redact, safeError } from '../lib/redact.mjs'; import { serviceFetch } from '../lib/serviceauth.mjs'; import { JobError } from '../lib/jobs.mjs'; -import { existsSync, statSync, mkdirSync } from 'node:fs'; -import { join } from 'node:path'; +import { existsSync, statSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'; +import { join, basename } from 'node:path'; import { syncFromHeartbeat } from './mailsvc.mjs'; export const EXTERNAL_KINDS = new Set(['site.deploy', 'site.remove', 'site.check', 'site.rollback']); // 외부 Agent 가 받을 수 있는 작업(§10.2) @@ -260,15 +261,89 @@ export async function awaitAgentJob(d, ctx, agentJobId, { timeoutMs = d.config.a } // ------------------------------------------------------------------ 릴리스(§32.1) +/** 키 순서에 무관한 비교용 JSON(릴리스 재등록의 "같은 내용" 판정) */ +function canonJson(v) { if (Array.isArray(v)) return '[' + v.map(canonJson).join(',') + ']'; if (v && typeof v === 'object') return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonJson(v[k])).join(',') + '}'; return JSON.stringify(v === undefined ? null : v); } +/** + * 릴리스 등록(upsert). 0.2.0 검토 수정 3차 — **승인된 버전은 불변**: 같은 내용(main 커밋·빌드 시각·artifact·checks, 메모는 준 경우에만)의 재등록은 멱등하게 그대로 돌려주고(approve 요청이어도 이미 승인), + * 어느 하나라도 다른 재등록은 approve 여부와 무관하게 `release_immutable` 로 거절하며 아무것도 바꾸지 않는다(새 내용은 새 버전 번호로). 승인되지 않은 버전의 내용 변경 재등록은 내용을 갱신하되 + * 상태를 registered 로 두고(승인자·시각 비움) 승인을 물려받지 않는다 — approve 가 참이면 releaseApprovalProblem 규칙을 지나야만 승인된다. CLI(hal/index.mjs register-release·scripts/release.mjs register)도 이 함수를 쓴다. + */ +/** 승인된 릴리스를 다른 내용으로 바꾸려는 등록인지(같은 내용이면 null). registerRelease·installArtifacts 가 같은 규칙을 쓴다 */ +export function releaseImmutableProblem(ex, { mainCommit, builtAt, artifacts, checks = null, notes = null }) { + if (!ex || ex.status !== 'approved') return null; + const same = ex.main_commit === mainCommit && ex.built_at === builtAt && canonJson(ex.artifacts) === canonJson(artifacts || {}) && canonJson(ex.checks) === canonJson(checks) && (notes == null || notes === ex.notes); + return same ? null : `승인된 릴리스 ${ex.version} 은 바꿀 수 없습니다 — artifact·검사 기록·main 커밋·빌드 시각·메모가 다른 빌드는 새 버전 번호로 등록하세요`; +} +/** + * artifact 설치(0.2.0 검토 수정 4차 — 불변·fail-closed). 등록(registerRelease) **전** 에 부른다. + * 1) 원본 파일마다 바이트의 sha256 과 크기를 manifest 의 선언값과 대조한다 — 다르면 `artifact_hash_mismatch` 로 거절(아무것도 쓰지 않는다). + * 2) 같은 버전이 이미 승인되어 있으면 메타데이터(releaseImmutableProblem)와 설치된 파일의 해시까지 같을 때만 멱등(복사하지 않음); 하나라도 다르면 `release_immutable` 로 거절하고 기존 파일·DB 를 건드리지 않는다. + * 3) 새 버전·미승인 버전은 대상 폴더의 임시 파일(.tmp-…)에 쓰고 해시를 다시 확인한 뒤 rename 으로 승격한다(원자적) — 승인된 파일은 어떤 경로로도 잘리거나 덮이지 않는다. + * @param {object} d HAL deps + * @param {{version:string, mainCommit:string, builtAt:string, checks?:object, notes?:string, artifacts:Record}} m src = 원본 파일 경로 + * @returns {Record} 등록에 넘길 artifact 목록(파일 이름만) + */ +export function installArtifacts(d, m) { + const entries = Object.entries(m.artifacts || {}); + const declared = {}; const verified = {}; + for (const [mod, a] of entries) { + if (!a || !a.file || !a.src) throw new JobError('artifact_missing', `${mod}: artifact 파일 정보가 없습니다`); + if (!existsSync(a.src)) throw new JobError('artifact_missing', `${mod}: artifact 파일이 없습니다 (${basename(a.src)})`); + const bytes = readFileSync(a.src); const sha = createHash('sha256').update(bytes).digest('hex'); + if (String(a.sha256 || '').toLowerCase() !== sha) throw new JobError('artifact_hash_mismatch', `${mod}: artifact 해시 불일치 — manifest ${String(a.sha256 || '').slice(0, 12)}… / 파일 ${sha.slice(0, 12)}… (설치·등록하지 않습니다)`); + if (a.size !== undefined && a.size !== null && Number(a.size) !== bytes.length) throw new JobError('artifact_hash_mismatch', `${mod}: artifact 크기 불일치 — manifest ${a.size} / 파일 ${bytes.length}`); + declared[mod] = { file: basename(a.file), sha256: sha, size: bytes.length }; verified[mod] = { bytes, ...declared[mod], src: a.src }; + } + const ex = getRelease(d, m.version); + const dir = join(artifactDir(d), m.version); + if (ex && ex.status === 'approved') { + const why = releaseImmutableProblem(ex, { mainCommit: m.mainCommit, builtAt: m.builtAt, artifacts: declared, checks: m.checks ?? null, notes: m.notes ?? null }); + if (why) throw new JobError('release_immutable', why); + for (const [mod, v] of Object.entries(verified)) { // 설치된 바이트도 같아야 멱등 — 아니면 승인된 파일을 건드리지 않고 거절 + const p = join(dir, v.file); if (!existsSync(p)) throw new JobError('release_immutable', `승인된 릴리스 ${m.version} 의 ${mod} artifact 파일이 없습니다 — 설치 상태를 운영자가 확인하세요`); + const cur = createHash('sha256').update(readFileSync(p)).digest('hex'); if (cur !== v.sha256) throw new JobError('release_immutable', `승인된 릴리스 ${m.version} 의 ${mod} artifact 는 바꿀 수 없습니다(설치된 파일과 바이트가 다름)`); + } + return declared; // 멱등 — 아무것도 쓰지 않는다 + } + mkdirSync(dir, { recursive: true }); + for (const [mod, v] of Object.entries(verified)) { + const target = join(dir, v.file); + if (existsSync(target) && createHash('sha256').update(readFileSync(target)).digest('hex') === v.sha256) continue; // 이미 같은 바이트 + const tmp = join(dir, `.${v.file}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`); + try { writeFileSync(tmp, v.bytes, { mode: 0o644 }); const again = createHash('sha256').update(readFileSync(tmp)).digest('hex'); if (again !== v.sha256) throw new JobError('artifact_hash_mismatch', `${mod}: 임시 파일 해시 불일치`); renameSync(tmp, target); } + catch (e) { try { unlinkSync(tmp); } catch (_) {} throw e; } + } + return declared; +} export function registerRelease(d, { version, mainCommit, builtAt, artifacts, checks = null, notes = null, approve = false, by = null }) { - const now = nowIso(); const ex = d.store.get('SELECT version FROM releases WHERE version=?', version); - if (ex) d.store.run('UPDATE releases SET main_commit=?, built_at=?, artifacts_json=?, checks_json=?, notes=COALESCE(?, notes) WHERE version=?', mainCommit, builtAt, toJson(artifacts), toJson(checks), notes, version); - else d.store.run('INSERT INTO releases(version, main_commit, built_at, artifacts_json, checks_json, status, notes, registered_at) VALUES(?,?,?,?,?,\'registered\',?,?)', version, mainCommit, builtAt, toJson(artifacts), toJson(checks), notes, now); + const now = nowIso(); const ex = getRelease(d, version); + if (ex) { + const same = ex.main_commit === mainCommit && ex.built_at === builtAt && canonJson(ex.artifacts) === canonJson(artifacts || {}) && canonJson(ex.checks) === canonJson(checks) && (notes == null || notes === ex.notes); + if (ex.status === 'approved') { + const why = releaseImmutableProblem(ex, { mainCommit, builtAt, artifacts, checks, notes }); if (why) throw new JobError('release_immutable', why); + audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'release.register', subjectType: 'release', subjectId: version, detail: { mainCommit, artifacts: Object.keys(artifacts || {}), idempotent: true, approved: true } }); + return ex; + } + if (!same) d.store.run('UPDATE releases SET main_commit=?, built_at=?, artifacts_json=?, checks_json=?, notes=COALESCE(?, notes), status=\'registered\', approved_by=NULL, approved_at=NULL WHERE version=?', mainCommit, builtAt, toJson(artifacts), toJson(checks), notes, version); + } else d.store.run('INSERT INTO releases(version, main_commit, built_at, artifacts_json, checks_json, status, notes, registered_at) VALUES(?,?,?,?,?,\'registered\',?,?)', version, mainCommit, builtAt, toJson(artifacts), toJson(checks), notes, now); if (approve) approveRelease(d, version, by); audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'release.register', subjectType: 'release', subjectId: version, detail: { mainCommit, artifacts: Object.keys(artifacts || {}) } }); return getRelease(d, version); } -export function approveRelease(d, version, by) { const r = getRelease(d, version); if (!r) throw new JobError('release_not_found', '릴리스가 없습니다'); const checks = r.checks || {}; if (checks.testsPassed === false) throw new JobError('release_checks_failed', '검사에 실패한 릴리스는 승인할 수 없습니다'); d.store.run('UPDATE releases SET status=\'approved\', approved_by=?, approved_at=? WHERE version=?', by, nowIso(), version); audit(d.store, { actorType: 'operator', actorId: by, action: 'release.approve', subjectType: 'release', subjectId: version, immutable: true }); return getRelease(d, version); } +/** + * 릴리스 검사 기록이 승인 가능한지(0.2.0 검토 수정 2차 — fail-closed). 세 값이 **정확히** 있어야 한다: testsPassed === true(--skip-tests 빌드의 null·없음은 불가), dirty === false(작업 트리 깨끗함이 기록됨), + * provenanceClean === true(도입 소스 출처가 깨끗하다고 확인됨). 기록이 없거나(옛 빌드) 명시적 false 가 아닌 "모름" 도 승인하지 않는다. 승인 경로(approveRelease·registerRelease approve:true·운영자 API·release.mjs register --approve)가 모두 이 한 규칙을 쓴다. + * @returns {string|null} 승인할 수 없는 이유(없으면 null) + */ +export function releaseApprovalProblem(checks) { + const c = checks && typeof checks === 'object' ? checks : null; + if (!c) return '검사 기록(checks)이 없는 릴리스는 승인할 수 없습니다 — 테스트·작업 트리·도입 소스 출처가 확인된 빌드로 다시 등록하세요'; + if (c.testsPassed !== true) return `테스트 통과가 기록되지 않은 릴리스는 승인할 수 없습니다 (checks.testsPassed=${JSON.stringify(c.testsPassed ?? null)} — --skip-tests 빌드는 등록만 되고 승인은 불가)`; + if (c.dirty !== false) return `작업 트리가 깨끗하다고 기록되지 않은 릴리스는 승인할 수 없습니다 (checks.dirty=${JSON.stringify(c.dirty ?? null)} — main 에 병합된 커밋에서 빌드하세요)`; + if (c.provenanceClean !== true) return `도입 소스 출처(provenance)가 깨끗하다고 확인되지 않은 릴리스는 승인할 수 없습니다 (checks.provenanceClean=${JSON.stringify(c.provenanceClean ?? null)}${(c.provenanceProblems || []).length ? ': ' + c.provenanceProblems.join(' / ') : ''})`; + return null; +} +export function approveRelease(d, version, by) { const r = getRelease(d, version); if (!r) throw new JobError('release_not_found', '릴리스가 없습니다'); const why = releaseApprovalProblem(r.checks); if (why) throw new JobError('release_checks_failed', why); d.store.run('UPDATE releases SET status=\'approved\', approved_by=?, approved_at=? WHERE version=?', by, nowIso(), version); audit(d.store, { actorType: 'operator', actorId: by, action: 'release.approve', subjectType: 'release', subjectId: version, immutable: true }); return getRelease(d, version); } export function getRelease(d, version) { const r = d.store.get('SELECT * FROM releases WHERE version=?', version); return r ? { ...r, artifacts: parseJson(r.artifacts_json, {}), checks: parseJson(r.checks_json, null) } : null; } export function listReleases(d) { return d.store.all('SELECT * FROM releases ORDER BY registered_at DESC').map((r) => ({ ...r, artifacts: parseJson(r.artifacts_json, {}), checks: parseJson(r.checks_json, null) })); } export const artifactDir = (d) => join(d.config.dataDir, 'artifacts'); diff --git a/hal/index.mjs b/hal/index.mjs index 7134e12..1a6b284 100644 --- a/hal/index.mjs +++ b/hal/index.mjs @@ -46,9 +46,12 @@ if (cmd === 'setup-token') { } else if (cmd === 'grant') { const g = ops.issueGrant(d, { accountId: opt('login'), hours: Number(opt('hours', 8)), by: null, reason: opt('reason', 'cli') }); console.log(`접근권 URL(1회, ${g.expiresAt} 까지): ${g.url}`); } else if (cmd === 'register-release') { - const mf = JSON.parse(readFileSync(opt('manifest'), 'utf8')); const dir = join(core.artifactDir(d), mf.version); mkdirSync(dir, { recursive: true }); - for (const [m, a] of Object.entries(mf.artifacts)) { const src = join(dirname(opt('manifest')), a.file); if (existsSync(src)) copyFileSync(src, join(dir, basename(a.file))); } - const r = core.registerRelease(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, artifacts: Object.fromEntries(Object.entries(mf.artifacts).map(([m, a]) => [m, { file: basename(a.file), sha256: a.sha256, size: a.size }])), checks: mf.checks || null, approve: !!opt('approve'), by: opt('by', 'cli') }); + const mf = JSON.parse(readFileSync(opt('manifest'), 'utf8')); + // 0.2.0 검토 수정 4차: artifact 설치는 불변·fail-closed — 원본 바이트를 manifest 해시·크기와 대조하고, 승인된 버전은 같은 바이트·메타데이터일 때만 멱등(복사 없음), 다르면 기존 파일·DB 를 건드리지 않고 거부. 새 설치는 임시 파일 → rename + let r; try { + const artifacts = core.installArtifacts(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, checks: mf.checks || null, artifacts: Object.fromEntries(Object.entries(mf.artifacts).map(([m, a]) => [m, { file: basename(a.file), sha256: a.sha256, size: a.size, src: join(dirname(opt('manifest')), a.file) }])) }); + r = core.registerRelease(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, artifacts, checks: mf.checks || null, approve: !!opt('approve'), by: opt('by', 'cli') }); + } catch (e) { console.error(`릴리스 등록 거부 (${e.code || 'error'}): ${e.message}`); process.exit(1); } // 해시 불일치(artifact_hash_mismatch)·승인된 버전 불변(release_immutable)·승인 규칙(release_checks_failed) — 종료 1, 파일·DB 불변 console.log(`릴리스 ${r.version} 등록(${r.status}) — artifact ${Object.keys(r.artifacts).join(', ')}`); } else if (cmd === 'link-portal') { // [E2E 결함 수정] 포털은 양방향 모두 보관소 이름 'service:hal' 하나를 쓴다(포털→HAL 서명, HAL→포털 검증). 그래서 공유 비밀 하나를 HAL 의 두 이름(service:portal·service:hal)에 같이 둔다. diff --git a/hal/modules/index.mjs b/hal/modules/index.mjs index 4734e20..ac7cd32 100644 --- a/hal/modules/index.mjs +++ b/hal/modules/index.mjs @@ -22,9 +22,9 @@ export const MODULES = { health: { url: 'http://127.0.0.1:8910/api/health', expect: 'ok' }, version: { http: 'http://127.0.0.1:8910/api/health', field: 'version' }, dataDirs: ['/srv/ariweb/portal/data'] }, agent: { name: 'agent', roles: ['control', 'web', 'mail', 'backup'], layout: 'releases', unit: 'ariweb-agent', unitTemplate: 'ariweb-agent.service.tmpl', user: 'master', health: { heartbeat: true }, version: { file: 'package.json' }, dataDirs: ['/srv/ariweb/agent/data'], selfUpdate: true }, - // 도입 모듈: Webmaker(WebHosting_AI_agent 1.7.3). 기존 서버 배치(/srv/webmaker/current, /data/webmaker, 유닛 webmaker, 포트 3080)를 존중한다 — install 은 releases 에 풀고 current 를 바꾸며, + // 도입 모듈: Webmaker(WebHosting_AI_agent 1.8.0 — 0.2.0 에서 직접 편집(시각 편집기) 포함; 0.1.1 은 1.7.3). 기존 서버 배치(/srv/webmaker/current, /data/webmaker, 유닛 webmaker, 포트 3080)를 존중한다 — install 은 releases 에 풀고 current 를 바꾸며, // 유닛은 서버에 이미 있고 /current 를 가리키면 그대로 둔다(keepExistingUnit — 운영 서버의 강화 지시문·ReadWritePaths 보존, 실제 환경 결함 수정). 없을 때만 deploy/units/webmaker.service.tmpl 로 만든다. - // 원본 deploy/remote-update.sh 의 절차(백업→전송→교체→재시작→확인)를 Agent 가 같은 순서로 수행한다. 실제 버전은 REVISION.json 의 Suite 릴리스 버전(package.json 1.7.3 은 upstreamVersion). + // 원본 deploy/remote-update.sh 의 절차(백업→전송→교체→재시작→확인)를 Agent 가 같은 순서로 수행한다. 실제 버전은 REVISION.json 의 Suite 릴리스 버전(package.json 1.8.0 은 upstreamVersion). webmaker: { name: 'webmaker', roles: ['web'], layout: 'releases', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, user: 'master', port: 3080, baseDir: '/srv/webmaker', health: { url: 'http://127.0.0.1:3080/api/auth/csrf', expect: 'any' }, version: { file: 'package.json' }, preStop: ['webmaker.backupData'], postStart: ['webmaker.unitCheck'], dataDirs: ['/data/webmaker', '/var/www/webmaker-sites'], extraUnits: ['webmaker-setup'] }, // 도입 모듈: AriMail 1.0.11. 서버마다 유닛 이름이 다르다(arihakdang-mail / arimail / heart-mail) — 서버 facts.arimail.unit 이 정한다. 설치는 원본 deploy/deploy.sh 와 같은 절차(테스트→릴리스 복사→DB 백업·마이그레이션→재시작→health)를 Agent 가 수행한다. diff --git a/lib/provenance.mjs b/lib/provenance.mjs new file mode 100644 index 0000000..a6cf9a8 --- /dev/null +++ b/lib/provenance.mjs @@ -0,0 +1,34 @@ +/** + * lib/provenance.mjs — 도입 소스 출처(IMPORTED_SOURCES.json) 검사. 릴리스 build·register --approve·HAL 승인이 공유하는 순수 규칙(0.2.0 검토 수정, 차단 항목 11). + * + * 깨끗한 출처(clean provenance)란: 각 소스가 40자리 고정 커밋(commit)을 가지고, 파일 목록이 비어 있지 않으며(각 항목에 from·to·origHash), + * upstream 기록이 있으면 완전하고(version·branch·headCommit) headCommit 이 commit 과 같고 workingTreeDirty 가 정확히 false 인 것. + * - `--allow-dirty` 로 참조 저장소의 작업 트리 변경을 함께 가져온 기록(workingTreeDirty=true)은 "원본 저장소에 아직 없는 코드" 를 담고 있어 릴리스로 승인할 수 없다. + * 원본 저장소에 커밋되면 pin 을 올려 `scripts/import-sources.mjs` 를 다시 실행해 깨끗한 기록을 만든다. + * - upstream 칸이 없는 기록은 0.1.x 의 import-sources 가 만든 것이다(그 스크립트는 깨끗한 고정 커밋 체크아웃에서만 가져왔다) → legacy 로 표시하고 받아들인다. + * 모르는 형태·빈 목록은 모두 "불완전" 으로 보아 거절한다(fail-closed). + * @param {object} manifest IMPORTED_SOURCES.json 내용 + * @returns {{ ok: boolean, problems: string[], sources: { name: string, commit: string|null, upstream: object|null, legacy: boolean }[] }} + */ +export function checkProvenance(manifest) { + const problems = []; const sources = []; + if (!manifest || typeof manifest !== 'object' || !Array.isArray(manifest.sources)) return { ok: false, problems: ['IMPORTED_SOURCES: sources 목록이 없습니다(불완전한 기록)'], sources }; + if (!manifest.sources.length) return { ok: false, problems: ['IMPORTED_SOURCES: 도입 소스가 하나도 기록되지 않았습니다(불완전한 기록)'], sources }; + for (const s of manifest.sources) { + const name = (s && typeof s.name === 'string' && s.name) || '(이름 없음)'; + const up = s && s.upstream && typeof s.upstream === 'object' ? s.upstream : null; + const summary = { name, commit: s && typeof s.commit === 'string' ? s.commit : null, upstream: up ? { version: up.version ?? null, branch: up.branch ?? null, headCommit: up.headCommit ?? null, workingTreeDirty: up.workingTreeDirty } : null, legacy: !up }; + sources.push(summary); + if (!s || typeof s !== 'object') { problems.push(`${name}: 기록 형식이 올바르지 않습니다`); continue; } + if (!/^[0-9a-f]{40}$/.test(String(s.commit || ''))) problems.push(`${name}: 고정 커밋(commit)이 40자리 커밋 해시가 아닙니다 (${String(s.commit || '없음').slice(0, 12)})`); + if (!Array.isArray(s.files) || !s.files.length) problems.push(`${name}: 가져온 파일 목록이 비어 있습니다(불완전한 기록)`); + else if (s.files.some((f) => !f || typeof f.from !== 'string' || typeof f.to !== 'string' || typeof f.origHash !== 'string')) problems.push(`${name}: 파일 기록에 from·to·origHash 가 빠진 항목이 있습니다`); + if (!up) continue; // 0.1.x 기록: 깨끗한 고정 커밋 체크아웃만 받던 스크립트가 만든 것(legacy) + if (up.workingTreeDirty !== false) problems.push(`${name}: upstream.workingTreeDirty=${JSON.stringify(up.workingTreeDirty)} — 원본 저장소에 커밋되지 않은 작업 트리 변경을 함께 가져온 기록은 릴리스로 승인할 수 없습니다. 원본에 커밋한 뒤 pin 을 올려 scripts/import-sources.mjs 를 다시 실행하세요`); + if (typeof up.version !== 'string' || !up.version) problems.push(`${name}: upstream.version 이 없습니다(불완전한 기록)`); + if (typeof up.branch !== 'string' || !up.branch) problems.push(`${name}: upstream.branch 가 없습니다(불완전한 기록)`); + if (!/^[0-9a-f]{40}$/.test(String(up.headCommit || ''))) problems.push(`${name}: upstream.headCommit 이 없거나 형식이 틀립니다(불완전한 기록)`); + else if (up.headCommit !== s.commit) problems.push(`${name}: upstream.headCommit(${String(up.headCommit).slice(0, 7)}) ≠ 고정 커밋 commit(${String(s.commit).slice(0, 7)}) — 기록이 어긋났습니다. scripts/import-sources.mjs 를 다시 실행하세요`); + } + return { ok: problems.length === 0, problems, sources }; +} diff --git a/modules/webmaker/README.md b/modules/webmaker/README.md index e0937cc..8cce34e 100644 --- a/modules/webmaker/README.md +++ b/modules/webmaker/README.md @@ -1,9 +1,10 @@ -# AI Webmaker (WebHoting AI Agent) — v1.7.3 +# AI Webmaker (WebHoting AI Agent) — v1.8.0 AI 로 웹페이지를 자동 생성하고 같은 서버(nginx)에서 바로 호스팅하는 에이전트. ## 변경 이력 전체 변경 기록은 [CHANGELOG.md](CHANGELOG.md), 오류 점검 목록은 [ERRORS.md](ERRORS.md) 에 있다. +- **1.8.0** **직접 편집(시각 편집기)** — 초안이 만들어져 미리보기가 보이면 오른쪽 도구 줄의 "직접 편집" 으로 글을 바로 고치고, 블록을 끌어 옮기고, 구성 요소(섹션·제목·문단·버튼·이미지·구분선·여백)를 팔레트에서 끌어다 놓는다(버튼·키보드 대안 포함). 속성 창(글·링크·이미지·정렬·색·배경·여백), 실행 취소/다시 실행, 저장 상태 표시, Ctrl+S 저장, 페이지·사이트 전환·창 닫기 전 저장하지 않은 변경 보호. 저장은 선택한 HTML 파일을 갱신하고 버전 `visual-edit` 로 남기며 AI 캐시를 쓰지 않는다. 캔버스는 sandbox(출처 null)+CSP nonce 로 페이지 스크립트를 실행하지 않고, 서버는 원본에 없던 스크립트·이벤트 속성·위험 주소·iframe 등을 거절하며 머리·꼬리는 글자 그대로 보존한다(`lib/visual-editor.js`, `public/visual-editor-core.js`, `public/wm-ve-canvas.js`, `public/visual-editor-ui.js`). 독립 검토 차단 항목 12건 수정(캔버스 MessagePort 연결 규약, 이름 엔티티 주소 정규화, 구조 스캐너 기반 CSP 삽입, 완성 문서 전체 비교, 사이트별 변경 잠금, 저장 트랜잭션·되돌리기, 전환 보호, 복제 id, 끌기 정리 — CHANGELOG §8) + 종결 검토 3건(SVG SMIL 애니메이션 요소 거절, Git 저장 트랜잭션 재설계 — 로컬 커밋·기록 뒤 main + 변경 브랜치 원자적 푸시·검증된 되돌리기, Suite 릴리스 승인 규칙 — CHANGELOG §9) + 최종 검토 5건(Suite 승인 불변, 사이트 변경 요청의 잠금 안 재적재, 트랜잭션 준비 단계·모호한 푸시 판정, 문서, 배경 작업 저장이 짧은 변경을 덮어쓰지 않는 잎 단위 저장소 합치기와 같은 잎 동시 변경의 fail-closed 거절 — CHANGELOG §10) + 4차 검토 4건(Suite artifact 설치 불변, 읽기 요청 부작용 제거, 폐기 잠금·표식 규약, svg/math 안 title 파서 차이 — CHANGELOG §11). 테스트 115개(원본 52 + 직접 편집 63). 실제 브라우저 게이트(Playwright, Chrome) 61/61. - **1.7.3** 도메인 연결 오류 수정(배포 서버에 `log_format wmstats` 가 이미 있으면 중복 정의하지 않고, 중복으로 nginx -t 가 실패하면 우리 파일을 지워 자가 복구 · nginx 1.19.4 미만은 ssl_reject_handshake 대신 자체 서명 인증서 기본 서버) + **메일 사용량 연동**(AriMail 1.0.9 사용량 API: 관리자 콘솔에서 키 발급·보관(보관소 `arimail-usage`), 도메인별 메일함 용량·메일 수·발송/수신 표, 사용자 메일 화면 사용량 행, 사이트 정보 "메일 데이터" = 서버 메일함 용량 + 메일 사용량 구역(30일 막대)). - **1.7.2** 도메인 관리 역할 분리 — 도메인 화면은 구매·갱신·**네임서버 이전 4단계 띠**, DNS 화면은 **사이트 ↔ 도메인 연결 표**(하위 이름 선택·연결 끊기)가 먼저이고 레코드 관리는 그 아래, 메일 화면은 계정 형식·접속 주소·초기 관리자를 한눈에; **사이트 정보 페이지**를 오른쪽 캔버스로 옮겨 방문자 막대·송수신 선 그래프·7일/월간(3개월) 막대·디스크(웹/DB/메일) 그래프를 SVG 로 표시. 두 서버 로그 0건. bear 배포 중단. - **1.6.40** 사용자 화면 전면 재구성 — 작업 흐름 중심의 세 칸 구조(레일: 홈 / 만들기·수정·배포·운영 / 도메인·DNS·메일 / 캐시·충전·설정, 상태 점·배지), **홈**(단계별 상태·다음 할 일·의존 관계 지도), **배포** 화면(준비 점검표 + 발행·원본 서버 반영·검색 등록), **DNS**·**메일** 화면 분리, **캐시·충전** 화면(선불 충전 상품 → 결제 진행은 PG 미연동이라 결제 대기 주문만 → 관리자 "입금 확인 → 적립", 적립 = 결제 금액 + 인센티브), 관리자 콘솔 선불 충전 상품·주문 관리(`lib/prepaid.js`). 로그 점검 두 서버 0건. diff --git a/modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md b/modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md new file mode 100644 index 0000000..c414a80 --- /dev/null +++ b/modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md @@ -0,0 +1,486 @@ +# 직접 편집(시각 편집기) 1.8.0 — TDD RED→GREEN 기록 + +- 작업 트리: `/home/master/work/WebHoting_AI_Agent-visual-editor-v180`, 브랜치 `feature/visual-editor-v1.8.0`, 기준 `origin/main 3b10859884ef2fd0f2af11934ab6c16370fd64b8` +- 날짜: 2026-10-28 · Node v24.22.1 · 커밋·push·배포 없음(Neo 검토 전 구현 인도) +- 아래 출력은 실제 실행 결과에서 그대로 옮겼다(요약을 위해 스택 줄만 줄임). 실행 때 `CLAUDE_BIN` 은 바로 실패하는 가짜 실행 파일로 두어 개발 PC 의 진짜 `claude` 가 사이트 생성 작업으로 실행되지 않게 했다(첫 RED 실행에서는 그 설정이 없어 진짜 claude 프로세스가 하나 시작되었고 즉시 종료시켰다 — 이 때문에 두 테스트 파일 모두 가짜 실행 파일을 쓰도록 고쳤다). + +## 조각 1·2 — 서버 순수 모듈(lib/visual-editor.js)·공용 핵심(public/visual-editor-core.js) + +RED — `node --test test/visual-editor.test.js` (모듈이 없음) +``` +Error: Cannot find module '/home/master/work/WebHoting_AI_Agent-visual-editor-v180/lib/visual-editor' +Require stack: +- /home/master/work/WebHoting_AI_Agent-visual-editor-v180/test/visual-editor.test.js +ℹ pass 0 +ℹ fail 1 +``` +중간(모듈 구현 뒤 첫 실행) — 12/15 통과, 3 실패: ① 테스트의 정규식이 보존 속성 `data-wm-ve-href="javascript:…"` 까지 잡아 `/\shref="javascript:/` 로 고침, ② `restoreFromCanvas` 가 덧씌운 UI 요소 안의 **글자**("삭제")를 남겨 범위 제거(`stripUiElements`)로 구현 수정, ③ 런타임 파일(`public/wm-ve-canvas.js`·`public/visual-editor-ui.js`) 미존재(조각 4 에서 해결). + +GREEN — `node --test test/visual-editor.test.js` +``` +✔ splitDocument: 머리(doctype·head·body 여는 태그)·본문·꼬리로 나눈다 +✔ prepareCanvas: 페이지 스크립트·핸들러·javascript: 링크·iframe 을 모두 비활성화하고 CSP nonce·base·런타임을 넣는다 +✔ restoreFromCanvas: 편집기 표식·덧씌운 요소를 지우고 보존 속성을 되돌린다 +✔ validateVisualSave: 글만 고친 본문은 머리·doctype·스타일·스크립트를 그대로 둔 채 합쳐진다 +✔ validateVisualSave: 새 스크립트·이벤트 속성·위험 주소·금지 요소·스타일 표현식·구조 태그·크기·형식 오류를 거절한다 +✔ validateVisualSave: 원래 있던 스크립트·iframe 을 지우는 것은 허용(구성 요소 삭제)되고 숫자가 늘면 거절 +✔ validateVisualSave: body 가 없는 문서는 거절하고 머리 없는 조각은 본문만 저장한다 +✔ contentHash·isHtmlPath +✔ core: 브라우저 전역·Node 모듈 양쪽에서 로드되고 런타임 파일은 문법 검사를 통과한다 +✔ core.createHistory: 실행 취소·다시 실행·새 변경 뒤 다시 실행 지움·한도 +✔ core.createSaveState: 저장 안 됨 → 저장 중 → 저장됨/오류 전이와 한국어 표시 +✔ core.validateMessage: 보낸 창·출처·세션·형식·종류를 모두 확인해야 통과한다 +✔ core.COMPONENTS: 필수 구성 요소가 있고 각 HTML 은 스크립트·핸들러·위험 주소가 없다 +✔ core.moveIndex·dropIndexFor: 재배치 계산 +✔ core.safeUrlInput·propsFor·labelFor: 속성 편집 규칙 +ℹ tests 15 +ℹ pass 15 +ℹ fail 0 +``` + +## 조각 3·4 — 서버 경로(server.js)·화면(index.html·style.css·app.js·visual-editor-ui.js·wm-ve-canvas.js) + +RED — `node --test test/visual-editor-server.test.js` (경로가 없어 404, 화면에 버튼 없음) +``` +✖ 권한·출처: 소유자만, 다른 사용자는 404, 로그인 없이는 401, 출처(Referer)·CSRF 없는 fetch 는 거절 +✖ 허용 페이지: HTML 만, 경로 탈출·링크·없는 파일 거절 +✖ 캔버스 문서 계약: 페이지 스크립트·핸들러는 실행되지 않는 형태, CSP nonce·편집 런타임·base·세션이 있고 GET /files 는 hash 를 준다 +✖ 저장: 파일 갱신·버전 기록(visual-edit)·미리보기 목록·캐시 미차감·AI 수정 횟수·명령 기록 미증가·표식 제거·머리 보존 +✖ 오래된 저장(다른 창이 먼저 저장) 은 409 로 거절하고 최신 hash 를 알려 준다 · 잘못된 본문은 400 +✖ AI 작업 실행 중에는 저장이 409 로 거절된다 (가짜 claude 실행 파일로 작업을 멈춰 둔 서버) +✖ 화면: 직접 편집 전환·저장 상태·저장/실행 취소/다시 실행/버리기·팔레트·속성 창이 접근성 속성과 함께 있고 런타임 파일이 서빙된다 +ℹ pass 0 +ℹ fail 7 + actual: 404, + expected: 200, +``` +중간 1 — 4/7 통과: 저장·오래된 저장 테스트가 "AI 작업 실행 중" 409 를 받음 → 사이트 생성이 띄운 AI 작업이 **진짜 claude** 로 실행되고 있었다(개발 PC PATH). 테스트를 가짜 실행 파일(`CLAUDE_BIN`, 즉시 exit 1)로 바꾸고 작업 종료를 기다리게 수정. 화면 테스트의 반응형 정규식(`[^}]*` → `[^@]*`) 수정. +중간 2 — 6/7: 오래된 저장 테스트가 `baseHash:'deadbeef'`(형식 오류 400)를 보냄 → 64자리 16진으로 수정. 그 뒤 JSON 파싱 실패가 500 으로 나와 **서버 수정**(직접 편집 PUT 은 본문 읽기 실패를 400 한국어 안내로). + +GREEN — `node --test test/visual-editor-server.test.js` +``` +✔ 권한·출처: 소유자만, 다른 사용자는 404, 로그인 없이는 401, 출처(Referer)·CSRF 없는 fetch 는 거절 (1266ms) +✔ 허용 페이지: HTML 만, 경로 탈출·링크·없는 파일 거절 (24ms) +✔ 캔버스 문서 계약: 페이지 스크립트·핸들러는 실행되지 않는 형태, CSP nonce·편집 런타임·base·세션이 있고 GET /files 는 hash 를 준다 (11ms) +✔ 저장: 파일 갱신·버전 기록(visual-edit)·미리보기 목록·캐시 미차감·AI 수정 횟수·명령 기록 미증가·표식 제거·머리 보존 (138ms) +✔ 오래된 저장(다른 창이 먼저 저장) 은 409 로 거절하고 최신 hash 를 알려 준다 · 잘못된 본문은 400 (147ms) +✔ AI 작업 실행 중에는 저장이 409 로 거절된다 (가짜 claude 실행 파일로 작업을 멈춰 둔 서버) (510ms) +✔ 화면: 직접 편집 전환·저장 상태·저장/실행 취소/다시 실행/버리기·팔레트·속성 창이 접근성 속성과 함께 있고 런타임 파일이 서빙된다 (22ms) +ℹ pass 7 +ℹ fail 0 +ℹ duration_ms 2255.904911 +``` + +## 전체 묶음 — `CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js) +``` +ℹ tests 74 +ℹ pass 74 +ℹ fail 0 +ℹ duration_ms 90188.973409 +``` +(원본 1.7.3 의 52개 + 새 22개. 기존 테스트 변경 없음.) + +## 요구 ↔ 테스트 대응 +| 요구 | 테스트 | +|---|---| +| 권한(소유자만)·다른 사용자 404·로그인 401 | server: 권한·출처 | +| CSRF/출처(Referer 없는 fetch·생성 사이트 페이지 출처 거절) | server: 권한·출처 | +| 허용 페이지(HTML 만·경로 탈출·링크·없는 파일) | server: 허용 페이지 · unit: isHtmlPath | +| 낙관적 동시성/오래된 저장 409 + currentHash | server: 오래된 저장 | +| 안전한 정화·규약(스크립트·핸들러·위험 주소·금지 요소·스타일 표현식·구조 태그·표식 제거·머리 보존) | unit: prepareCanvas·restoreFromCanvas·validateVisualSave ×4 · server: 캔버스 계약·저장 | +| 스크립트 비실행 계약(disabled type·CSP nonce·보존 속성·세션·부모 출처) | unit: prepareCanvas · server: 캔버스 계약 | +| 저장이 버전 이력(visual-edit)을 만들고 캐시·AI 명령에 세지 않음 | server: 저장 | +| 실행 취소/다시 실행 핵심 | unit: core.createHistory | +| 끌어 옮기기/구성 요소 삽입 핵심(재배치 계산·구성 요소 안전) | unit: core.moveIndex·dropIndexFor·COMPONENTS | +| 저장 상태/이동 보호 | unit: core.createSaveState · server: 화면(상태 aria-live·버튼) · app.js 연결(정적) | +| 필수 접근성 조작 | server: 화면 | +| AI 작업 중 거절 | server: AI 작업 실행 중 | +| 잘못된 payload·크기 | server: 오래된 저장(400/413) · unit: validateVisualSave | + +## 검토 수정(Neo 독립 검토, 2026-10-28) — 동시 저장 경쟁(출시 차단) · 원자적 저장(안전 강화) + +**지적 ①(차단)**: 직접 편집 PUT 이 `await readBody(req)` **앞**에서 파일을 읽어 hash 를 잡아 두었다. 두 요청이 본문을 읽는 동안 나란히 들어오면 둘 다 같은 hash 를 통과해 둘째가 첫째를 조용히 덮어쓴다. **지적 ②(강화)**: `fs.writeFileSync` 로 운영 HTML 을 직접 덮어써 쓰기 실패·중단 때 마지막 정상 페이지가 잘릴 수 있다. + +RED — 새 테스트 3건 추가 뒤 실행 +- `node --test test/visual-editor.test.js` (helper 없음) +``` +✖ writeTextAtomic: 같은 폴더의 임시 파일(0644)에 쓴 뒤 rename 하고, 실패하면 임시 파일을 지우고 원본을 건드리지 않는다 + TypeError [Error]: ve.writeTextAtomic is not a function +ℹ pass 15 +ℹ fail 1 +``` +- `node --test test/visual-editor-server.test.js` — 경쟁 테스트는 chunked PUT 두 개의 머리+본문 앞 12바이트만 보내 둔 채 400ms 기다려(두 처리기가 본문 읽기에서 멈춤) A 를 끝내고 200 을 받은 **뒤** B 를 끝낸다. 원자성 테스트는 사이트 폴더를 0555 로 바꿔 임시 파일을 만들 수 없게 한다(기존 파일 직접 덮어쓰기는 이 조건에서도 "성공" 한다). +``` +✖ 동시 저장 경쟁: 두 PUT 이 본문을 다 보내기 전에 함께 들어와도 둘째는 409 — 첫 저장이 조용히 덮어써지지 않는다 + AssertionError [ERR_ASSERTION]: 둘째는 오래된 저장: {"path":"index.html", … "change":{"no":2,"kind":"visual-edit", …}} + actual: 200, + expected: 409, +✖ 원자적 저장: 임시 파일을 만들 수 없는 폴더(읽기 전용)에서는 500 과 한국어 안내, 기존 페이지는 그대로이고 임시 파일도 남지 않는다 + actual: 200, + expected: 500, +ℹ pass 7 +ℹ fail 2 +``` +(둘째 요청이 200 을 받고 파일에 "둘째-B" 가 남아 첫 저장이 사라졐음을 확인 — 재현 성공.) + +수정 +- `lib/visual-editor.js` `writeTextAtomic(absPath, content, {fs, mode})`: 같은 폴더의 `.<이름>..<무작위>.tmp` 에 쓰고(0644, umask 와 무관하게 chmod) rename. 실패하면 임시 파일을 지우고(정리 실패는 무시) 오류를 그대로 던진다. 파일 시스템 함수를 주입할 수 있어 rename 실패·쓰기 실패 경로를 결정적으로 검증한다. +- `server.js` 직접 편집 PUT: 본문을 다 읽은 **뒤** `store.load` 로 사이트 기록을, `fs.readFileSync` 로 파일을 다시 읽어 hash 를 비교하고, 검증·원자적 쓰기까지 await 없이 이어 간다. 쓰기 실패는 500 + "페이지를 저장하지 못했습니다 (…). … 기존 페이지는 그대로 있습니다". GET 은 바뀌지 않았다. 소스 편집기(`PUT /files`)의 기존 쓰기는 손대지 않았다. +- 중간 실행: helper 가 0644 를 돌려주지 않아(테스트 프로세스 umask 077 → 0600) chmod 를 추가. + +GREEN +``` +node --test test/visual-editor.test.js +ℹ tests 16 +ℹ pass 16 +ℹ fail 0 +node --test test/visual-editor-server.test.js +✔ 동시 저장 경쟁: 두 PUT 이 본문을 다 보내기 전에 함께 들어와도 둘째는 409 — 첫 저장이 조용히 덮어써지지 않는다 (655ms) +✔ 원자적 저장: 임시 파일을 만들 수 없는 폴더(읽기 전용)에서는 500 과 한국어 안내, 기존 페이지는 그대로이고 임시 파일도 남지 않는다 (243ms) +ℹ tests 9 +ℹ pass 9 +ℹ fail 0 +``` +전체 묶음(수정 뒤): 아래 "전체 묶음 — 검토 수정 뒤" 절. + +## 전체 묶음 — 검토 수정 뒤 +`CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js) +``` +ℹ tests 77 +ℹ pass 77 +ℹ fail 0 +ℹ duration_ms 90193.018088 +``` +(원본 52 + 직접 편집 25. Suite 체크아웃의 `modules/webmaker` 에서 같은 명령: tests 84 · pass 84 · fail 0 — 0.1.1 의 ariweb.test.js 7건 포함.) + +## 실제 브라우저 시각 검증 수정(Neo 의 브라우저 게이트, 2026-10-28) — 직접 편집 중 배치(출시 차단) + +**발견(실제 브라우저, 독립 Webmaker 코드)**: 1280×633 에서 직접 편집을 켜도 `.app` 이 레일 232px + 작업 칸 `.left` 460px + 캔버스 세 칸 그대로였고 `.ve-palette`·`.ve-inspector` 가 각 240px 이라 **iframe 실제 폭이 114.625px**, 한국어 제목이 한 글자씩 줄바꿈되었다. 390×844 에서는 `.left` 가 그대로 보여 캔버스가 y≈2228 로 밀려 옛 작업 폼을 다 지나야 편집기가 나왔다. +**원인**: `visual-editor-ui.js` 는 켤 때 `body.ve-on` 을 넣고 끌 때 빼지만, `style.css` 에 그 class 로 작업 칸을 숨기거나 `.app` 격자를 다시 짜는 규칙이 없었다(`.ve-on` 은 전환 버튼 색에만 쓰였다). + +RED — `node --test test/visual-editor.test.js` (새 계약 테스트 3건 추가 뒤; CSS 파서가 선택자·속성값을 읽는다 — 주석 문자열은 보지 않는다) +``` +✖ style.css 계약: body.ve-on 이면 옛 작업 칸(.left)을 숨기고 .app 을 레일 + 전체 폭 캔버스로 바꾼다 — 1280px 에서 캔버스 폭 ≥ 500px + AssertionError [ERR_ASSERTION]: 직접 편집 중 옛 작업 칸 숨김 +✖ style.css 계약: 900px 이하에서 body.ve-on 은 레일 바로 아래에 캔버스를 두고(옛 작업 칸 행 없음) 팔레트·속성 창은 캔버스 아래에 쌓인다 + AssertionError [ERR_ASSERTION]: 좁은 화면 body.ve-on .app 규칙 +✔ visual-editor-ui.js 동작: open() 이 body.ve-on 을 켜고 패널·도구 줄을 보이며, close() 가 모두 되돌리고 미리보기를 다시 읽는다 +ℹ tests 19 +ℹ pass 17 +ℹ fail 2 +``` +세 번째 테스트(가짜 DOM 위에서 `visual-editor-ui.js` 를 실행해 `open()`/`close()` 가 `body.ve-on`·패널·`aria-pressed`·srcdoc 을 켜고 되돌리는지)는 RED 단계에서 이미 통과 — JS 전환은 맞고 **CSS 규칙만 빠졌다**는 진단이 테스트로 확인되었다. + +수정(`public/style.css`, 두 저장소 동일) +``` +body.ve-on .app { grid-template-columns: var(--rail-w) minmax(0, 1fr); } +body.ve-on .left { display: none; } +@media (max-width: 1180px) { body.ve-on .ve-side { flex-basis: 190px; } } +@media (max-width: 900px) { … body.ve-on .app { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); height: 100vh; } body.ve-on .right { min-height: 0; } } +``` +계산: 1280px → 캔버스 1280 − 232 − 240×2 = **568px**(양쪽 패널 펼침). 1100px(레일 204px) → 1100 − 204 − 190×2 = 516px. 390px → 레일(가로 띠) 다음 행이 바로 캔버스(`frame-wrap` order 1, min-height 40vh), 팔레트·속성 창은 아래에 쌓이고 기본 접힘. 끝내면 `body.ve-on` 이 빠져 원래 `var(--rail-w) var(--left-w, 460px) 1fr` 세 칸이 그대로 돌아온다(작업 칸 DOM 은 건드리지 않는다). + +GREEN — `node --test test/visual-editor.test.js` (독립 저장소 / Suite `modules/webmaker` 모두) +``` +ℹ tests 19 +ℹ pass 19 +ℹ fail 0 +``` +전체 묶음(수정 뒤): 아래 "전체 묶음 — 배치 수정 뒤". + +## 전체 묶음 — 배치 수정 뒤 +`CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js) +``` +ℹ tests 80 +ℹ pass 80 +ℹ fail 0 +``` +(원본 52 + 직접 편집 28. Suite 체크아웃의 `modules/webmaker` 에서 같은 명령: tests 87 · pass 87 · fail 0. Suite 전체 `node test/run.mjs` 결과는 Suite 의 `docs/verification/webmaker-visual-editor-0.2.0.md`.) + +## 실제 브라우저 시각 검증 수정 2(Neo 의 배치 수정 뒤 게이트, 2026-10-28) — 선택 전 도구 줄 노출(출시 차단) + +**발견(실제 브라우저)**: 직접 편집을 켜고 아무 요소도 고르지 않았는데 어두운 선택 도구 줄 `.wm-ve-tools` 가 페이지 위에 이미 그려져 있었다. 속성 창은 "선택한 요소 없음", DOM 은 `tools.hidden = true` 였다. +**원인**: 캔버스 런타임(`wm-ve-canvas.js`)이 끼워 넣는 작성자 규칙 `.wm-ve-tools{…display:flex…}` 가 사용자 에이전트 규칙 `[hidden]{display:none}` 을 덮어쓴다(작성자 origin 은 명시도와 무관하게 UA 를 이긴다). 그래서 `hidden` 속성이 있어도 flex 로 보였다. `renderOverlay()` 가 `hidden=false` 로 바꾸기 전까지 보이지 않아야 하고, 선택 해제·미리보기·`applyHtml` 뒤 다시 숨겨져야 한다. + +RED — `node --test test/visual-editor.test.js` (새 계약 테스트 1건 추가 뒤). 테스트는 런타임 소스의 CSS 블록(`st.textContent`)을 꺼내 파싱하고, `hidden` 인 `.wm-ve-tools`·`.wm-ve-drop` 요소의 최종 display 를 cascade 모형(!important > `[hidden]` 명시도 > 나중 선언, 작성자 규칙이 없으면 UA 의 none)으로 계산한다 — 주석 문자열은 보지 않는다. +``` +✖ wm-ve-canvas.js CSS 계약: hidden 인 선택 도구 줄(.wm-ve-tools)·놓기 표시(.wm-ve-drop)는 작성자 CSS 가 다시 보이게 만들 수 없다 + AssertionError [ERR_ASSERTION]: hidden 인 .wm-ve-tools 의 최종 display + actual: 'flex', + expected: 'none', +ℹ tests 20 +ℹ pass 19 +ℹ fail 1 +``` + +수정(`public/wm-ve-canvas.js` 런타임 CSS, 두 저장소 byte-identical) — 한 줄 추가, 다른 동작 변경 없음 +``` +.wm-ve-tools[hidden],.wm-ve-drop[hidden]{display:none !important} +``` +`hidden` 을 켜고 끄는 JS(`renderOverlay`·끌기 시작·`showDrop`·dragleave)는 그대로이며, 보일 때의 `display:flex` 도 그대로다(테스트가 함께 확인). + +GREEN — `node --test test/visual-editor.test.js` (독립 저장소 / Suite `modules/webmaker` 모두) +``` +ℹ tests 20 +ℹ pass 20 +ℹ fail 0 +``` + +## 전체 묶음 — 도구 줄 수정 뒤 +`CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js) +``` +ℹ tests 81 +ℹ pass 81 +ℹ fail 0 +``` +(원본 52 + 직접 편집 29. Suite 체크아웃의 `modules/webmaker` 에서 같은 명령: tests 88 · pass 88 · fail 0. Suite 전체 `node test/run.mjs` 결과는 Suite 의 `docs/verification/webmaker-visual-editor-0.2.0.md`.) + +## 실제 브라우저 게이트 3(Playwright 자동 게이트, 2026-10-28) — 팔레트 → 캔버스 끌어다 놓기 실패(출시 차단) + +**발견(실제 Chrome, Playwright frameLocator 로 캔버스 iframe 을 직접 조작)**: 선택·contenteditable 글 편집·실행 취소/다시 실행·Alt+↑/↓ 이동·팔레트 표시까지 19/19 확인 통과. 그러나 부모 팔레트의 "문단" 을 캔버스에 실제 HTML5 끌어다 놓기 하면 아무것도 들어가지 않았다(`시간 안에 되지 않음: 끌어다 놓기 뒤 요소 수 증가`). +**원인**: 부모 dragstart 는 전용 MIME `text/wm-ve-component` 만 넣었고, 캔버스는 `sandbox="allow-scripts"`(allow-same-origin 없음) 의 **출처 'null' 문서**다. Chromium 은 다른 출처가 넣은 전용 MIME 끌기 자료를 이 경계 너머로 넘겨주지 않는다(종류 목록에서도 사라진다) → 캔버스의 dragover/drop 이 끌기를 인식하지 못했다. 하네스 한계가 아니라 제품 결함. + +설계(요구대로): 전용 MIME 은 유지하고, 부모가 표준 `text/plain` 에 이름 공간 값 `wm-ve-component:<검증된 id>` 를 **함께** 넣는다. 캔버스는 안전한 추출을 한곳(`core.dragComponentId`)에 모아 전용 MIME 을 우선 읽고, 없으면 text/plain 이 그 형식에 **정확히** 맞고 `core.COMPONENTS` 의 알려진 id 일 때만 받는다. dragover 때는 브라우저가 getData 를 막으므로 종류에 전용 MIME 또는 text/plain 이 있으면 끌기로 인식해 놓기 표시를 보인다(`core.isComponentDrag`). drop 때 id 가 틀리거나 임의 외부 글이면 아무것도 넣지 않고 표식도 남기지 않는다. sandbox(allow-same-origin 추가 없음)·CSP·메시지 검증·저장 검증·"추가" 버튼 대안은 그대로. + +RED — `node --test test/visual-editor.test.js` (새 테스트 3건 추가 뒤) +``` +✖ core.dragComponentId·isComponentDrag·componentDragText: 전용 MIME 이 없어도 이름 공간이 붙은 text/plain 만 정확히 받고, 임의 글·모르는 id 는 거절한다 + AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: actual: undefined, expected: 'text/wm-ve-component' +✖ visual-editor-ui.js 팔레트 dragstart: 전용 MIME 과 함께 이름 공간이 붙은 text/plain 을 넣는다(sandbox iframe 으로 넘어가는 표준 종류) + TypeError [Error]: core.componentDragText is not a function +✖ wm-ve-canvas.js 동작: 전용 MIME 이 sandbox 경계에서 사라져도 이름 공간이 붙은 text/plain 으로 구성 요소 하나를 넣고, 임의 text/plain·모르는 id 는 아무것도 넣지 않는다 + AssertionError [ERR_ASSERTION]: dragover preventDefault(놓기 허용) actual: false, expected: true +ℹ tests 23 +ℹ pass 20 +ℹ fail 3 +``` +세 번째는 **동작 테스트**다: 작은 가짜 DOM(요소 트리·속성·classList·innerHTML 파서/직렬화·querySelectorAll/closest·getBoundingClientRect·template.content·elementFromPoint·parent.postMessage)을 만들어 실제 `wm-ve-canvas.js` 를 vm 에서 부트하고, 전용 MIME 이 **없는** DataTransfer(`types: ['text/plain']`, drop 때 `text/plain = 'wm-ve-component:paragraph'`)로 dragover/drop 이벤트를 쏜다. 확인: dragover 가 기본 동작을 막고 놓기 표시를 보임 → drop 뒤 본문에 문단 **하나**가 h1 앞에 들어가고 선택되며 부모에 `changed` 본문(표식 없음)이 한 번 감 → 임의 글(`https://…`, ``)·모르는 id(`wm-ve-component:evil`)·정확히 맞지 않는 값(`'…paragraph '`, `'paragraph'`)은 본문 불변·표식 없음·changed/error 없음(브라우저 기본 drop 은 막음) → 전용 MIME 이 남아 있으면 그 값 우선(heading → h2) → 파일/HTML 만 있는 끌기는 dragover/drop 모두 건드리지 않음. (가짜 DOM 의 innerHTML 파서가 자식 목록 참조를 그대로 돌려줘 둘째 요소가 빠지던 테스트 자체 결함을 먼저 고쳤다 — RED 가 "진짜 이유" 로 나도록.) + +수정(두 저장소 byte-identical) +- `public/visual-editor-core.js`: `COMPONENT_MIME`, `componentDragText(id)`, `isComponentDrag(types)`, `dragComponentId(dt)` 추가·export(한국어 주석에 이유). +- `public/visual-editor-ui.js` 팔레트 dragstart: `setData(core.COMPONENT_MIME, id)` + `setData('text/plain', core.componentDragText(id))`. +- `public/wm-ve-canvas.js` dragover/drop: `core.isComponentDrag(e.dataTransfer.types)` 로 인식, drop 에서 `core.dragComponentId` 가 null 이면 놓기 표시만 숨기고 반환(기본 drop 차단, 삽입 없음). drop 도 `mode === 'edit'` 에서만. + +GREEN — `node --test test/visual-editor.test.js` (독립 저장소 / Suite `modules/webmaker` 모두) +``` +ℹ tests 23 +ℹ pass 23 +ℹ fail 0 +``` + +## 전체 묶음 — 끌어다 놓기 수정 뒤 +`CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js) +``` +ℹ tests 84 +ℹ pass 84 +ℹ fail 0 +``` +(원본 52 + 직접 편집 32. Suite 체크아웃의 `modules/webmaker` 에서 같은 명령: tests 91 · pass 91 · fail 0. Suite 전체 `node test/run.mjs` 결과는 Suite 의 `docs/verification/webmaker-visual-editor-0.2.0.md`.) + +## 실제 브라우저 게이트 4(살균 DnD 진단, 2026-10-28) — HTML5 끌기가 sandbox iframe 으로 넘어가지 않음 → 포인터 끌기 다리(출시 차단) + +**발견**: MIME 보완(게이트 3 수정) 뒤 같은 게이트를 다시 돌렸는데 여전히 끌어다 놓기 단계에서 실패. 부모·캔버스 양쪽 window 에 살균 리스너(종류·types·defaultPrevented·text/plain·전용 MIME 존재 여부·이름 공간 일치 여부만, 실제 자료는 기록하지 않음)를 붙여 확인한 결과: **부모 dragstart 2건(text/plain·전용 MIME 모두 존재·형식 유효), 캔버스 dragenter 0·dragover 0·drop 0**. 즉 Chromium 은 HTML5 끌기 자체를 부모 문서에서 출처 'null' sandbox iframe 으로 넘기지 않는다 — dataTransfer 종류 보완으로는 해결할 수 없는 제품 결함. +**설계(요구대로, sandbox 는 그대로 — allow-same-origin 추가 없음)**: 부모(`visual-editor-ui.js`)가 팔레트 항목의 **포인터**(마우스·펜)를 직접 추적한다. pointerdown("추가" 버튼 위는 제외, 터치·보조 버튼 제외) → 포인터 캡처 → `core.POINTER_DRAG_THRESHOLD`(4px) 넘게 움직이면 끌기 시작(표시 class) → iframe 위에서도 캡처로 pointermove 를 받아 `#frame.getBoundingClientRect()`(테두리·스크롤바 보정 `clientLeft/Top/Width/Height`)와 비교 → 안쪽이면 검증된 명령 `paletteDragAt { component, x, y }`(iframe 상대 좌표), 밖으로 나가면·Escape·pointercancel·끝내기면 `paletteDragCancel` → 안에서 pointerup 이면 `paletteDropAt { component, x, y }`. 캔버스(`wm-ve-canvas.js`)는 메시지 규약(보낸 창·출처·세션·종류)을 통과한 뒤 `core.isComponentId`·`core.canvasPoint`(유한 숫자·자기 뷰포트 안)·편집 모드를 다시 검사하고, 스스로 `dropTargetAt` → `showDrop` / `insertComponent` 를 한다(부모는 DOM 위치를 지정하지 않는다). 잘못된 id·좌표·모드는 아무것도 넣지 않고 놓기 표시를 숨긴다. HTML5 DnD 는 포인터 이벤트가 없는 환경의 대체 경로로 남기되, 포인터 끌기 중에는 네이티브 dragstart 를 막아 pointercancel 로 끊기지 않게 한다. CSS 는 `.ve-comp { user-select:none; touch-action:pan-y }`·끌기 중 class 만 추가(터치 스크롤·"추가" 버튼·키보드·메시지·CSP·저장 검증 불변). + +RED — `node --test test/visual-editor.test.js` (새 테스트 3건 추가 뒤) +``` +✖ core 포인터 끌기 규약: 메시지 종류·문턱·iframe 상대 좌표 계산(테두리 보정)·캔버스 좌표 검증 + AssertionError [ERR_ASSERTION]: paletteDragAt 는 부모→캔버스 종류 +✖ visual-editor-ui.js 포인터 끌기 다리: 팔레트 항목 pointerdown → 문턱 뒤 이동 추적 → iframe 안에서만 상대 좌표 paletteDragAt, 밖으로 나가면 paletteDragCancel, 안에서 pointerup 이면 paletteDropAt; "추가" 버튼·Escape·pointercancel 처리 + AssertionError [ERR_ASSERTION]: pointerdown/move/up 처리기 +✖ wm-ve-canvas.js 동작: paletteDragAt 이 놓기 표시를 보이고, paletteDropAt 이 계산한 자리에 구성 요소를 넣어 표식 없는 changed 를 한 번 보내며, 잘못된 id·좌표·미리보기 모드는 아무것도 하지 않고 표시를 숨긴다 + AssertionError [ERR_ASSERTION]: 놓기 표시 보임 +ℹ tests 26 +ℹ pass 23 +ℹ fail 3 +``` +동작 테스트 내용 — 부모(가짜 DOM 위 실제 `visual-editor-ui.js`): 바깥 pointerdown → 문턱 아래 이동(메시지 없음) → 문턱 넘어 바깥(표시 class 만) → iframe 안(400,200 → `paletteDragAt {x:100,y:100}`, 봉투 wmve·세션 그대로) → 밖(`paletteDragCancel` 한 번, 계속 밖이어도 한 번) → 안 → pointerup(`paletteDropAt {x:160,y:160}`, 캡처·class 해제, 이후 이벤트 무시) · "추가" 버튼 위 pointerdown 은 끌기 아님 · 짧은 클릭 메시지 없음 · 터치·보조 버튼 무시 · Escape → 취소 뒤 up 은 놓기 아님 · pointercancel → 취소 정리 · 포인터 끌기 중 네이티브 dragstart 차단, 평소엔 HTML5 자료 유지 · 편집 끝낸 뒤 메시지 없음. 캔버스(가짜 DOM 위 실제 `wm-ve-canvas.js`, 부모 창에서 온 것처럼 message 이벤트): `paletteDragAt` → 놓기 표시(h1 위쪽 절반 → h1 앞 선, p 아래쪽 절반 → p 아래 선) · 취소 → 숨김 · 모르는 id·뷰포트 밖·문자열 좌표 → 숨김 · `paletteDropAt` → h1 앞에 문단 하나, 표식 없는 `changed` 한 번, 새 요소 선택, 표시 숨김 · 빈 곳 → 본문 끝 · 모르는 id·좌표 누락/범위 밖/문자열·빈 payload·미리보기 모드 → 본문 불변·changed/error 없음 · 다른 세션·다른 보낸 창 → 무시. + +GREEN — `node --test test/visual-editor.test.js` (독립 저장소 / Suite `modules/webmaker` 모두) +``` +ℹ tests 26 +ℹ pass 26 +ℹ fail 0 +``` + +## 전체 묶음 — 포인터 끌기 다리 뒤 +`CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js) +``` +ℹ tests 87 +ℹ pass 87 +ℹ fail 0 +``` +(원본 52 + 직접 편집 35. Suite 체크아웃의 `modules/webmaker` 에서 같은 명령: tests 94 · pass 94 · fail 0. Suite 전체 `node test/run.mjs` 결과는 Suite 의 `docs/verification/webmaker-visual-editor-0.2.0.md`.) + +## 독립 검토(읽기 전용) 차단 항목 12건 수정(2026-10-28) — 보안 7 · 규격/회귀 5 +검토는 참조 저장소·Suite 양쪽 작업 트리를 읽고 아래 항목을 출시 차단으로 보고했다. 항목마다 행동·적대 테스트를 먼저 써 RED 를 잡고 최소 수정으로 GREEN 을 만들었다. 공유 파일(`lib/visual-editor.js`·`lib/gitstore.js`·`public/visual-editor-core.js`·`visual-editor-ui.js`·`wm-ve-canvas.js`·`test/visual-editor*.test.js`)은 두 저장소에서 byte-identical(`cmp`). `server.js`·`public/app.js`·`lib/store.js` 는 참조 저장소 변경을 Suite 블록을 보존한 채 패치했다(Suite 전용 항목 7·11 은 Suite 쪽 기록 `docs/verification/webmaker-visual-editor-0.2.0.md`). + +| # | 결함 | 수정 | 테스트(RED 증거) | +|---|---|---|---| +| 1 | iframe 안 문서가 바뀌면(a/area href 등) 새 문서가 같은 contentWindow·출처 null 로 부모의 `*` 명령에서 세션을 배워 changed/hotkey 를 꾸밀 수 있음 | 캔버스가 `MessageChannel` 포트를 첫 ready 에 동봉, 이후 포트 전용(`validatePortMessage`); 부모는 첫 ready 한 번만 연결·연결 뒤 창 메시지 전면 무시·끝내기 때 포트 닫음; 캔버스는 창 메시지를 듣지 않음; a/area/제출/`input[type=image]` 클릭·submit 차단(두 모드), ``, CSP form-action none, meta refresh 비활성화 | `visual-editor-ui.js 연결 규약`(RED: `ready 와 함께 포트가 넘어온다` 실패 — 포트 없음) · `wm-ve-canvas.js 연결 규약` · `캔버스 안의 이동·제출 활성화 … 막힌다`(RED: area 클릭 defaultPrevented false) · `prepareCanvas: 이동 활성화 방어`(RED: base 에 target 없음) | +| 2 | `javascript:…` 등 이름 엔티티로 감춘 스킴 통과 | `core.urlScheme`/`isDangerousUrl`(서버 lib 가 core 를 require — 한 구현): 이름·숫자 엔티티 두 겹, 제어·폭 없는 문자 제거, 모르는 엔티티가 스킴 자리에 남으면 거절 | `isDangerousUrl·neutralizeTag·validateVisualSave: 이름 있는 엔티티…`(RED: `거절: "javascript:alert(1)"` 등) · `applyProps·core.safeUrlInput`(RED: `javascript:alert(1)` 통과) | +| 3 | `/]*>/` 가 주석·`
`·원문의 `` 에 걸려 CSP·런타임이 엉뚱한 곳에 들어감 | `scanTags`(title·textarea 도 원문)로 진짜 head/html/body 를 찾아 넣고 없으면 진짜 `` 생성 | `prepareCanvas: 주석·원문… 진짜 뒤에만`(RED: `주석은 그대로` 실패 — CSP 가 주석 안에 들어감) | +| 4 | 원본 전체 ↔ 본문만 비교 → 머리의 스크립트·핸들러·위험 주소·iframe/object 를 본문에 복사해도 통과 | 완성 문서 전체 ↔ 원본 전체 비교(꼬리 스크립트 중복 제거 뒤) | `validateVisualSave: 머리(head)·body 여는 태그·꼬리에만 있던 … 거절`(RED: `

본문

` 통과) | +| 5 | 사전 검사만 지난 느린 요청이 다른 변경 도중에 씀; 소스 편집기 저장은 hash 검사 없음 | `withSiteLock`: 소스 PUT·직접 편집 PUT·버전 복원은 잠금 안에서 재검사→쓰기; AI/기획/가져오기/복사/다국어/배포 시작은 `claimJob`; 소스 PUT 선택적 `baseHash`(app.js 가 보냄) + 응답 `hash` | `사이트 변경 잠금: 소스 편집기 PUT(baseHash) 과 직접 편집 PUT …`(RED: 소스 저장 200 — 직접 편집 저장이 덮어써짐) · `… AI 작업이 시작되면 … 409`(RED: 200, AI 작업 중 파일 변경) | +| 6 | 파일 쓴 뒤 기록/Git 실패를 삼키고 200 | 스냅샷(기록·HEAD) → 파일/기록 되돌리기(`store.save(prev,{restore:true})`)·`gitstore.rollbackTo` → 500; `gitRecord(...,{strict:true})` | `직접 편집 저장 트랜잭션: 사이트 기록(store) 저장이 실패하면 …`(RED: 500 이지만 파일은 바뀐 채) · `… Git 버전 기록이 실패하면 …`(RED: 200, change null) | +| 8 | 홈 탭·openSite·HTML 없는 갱신이 묻지 않고 편집기를 끝냄 | `openSite` 전환 전 `close(false)`·취소 시 현재 사이트 반환, `showTab('home')` 질문, `onSiteUpdated` 가 다른 사이트/사라진 페이지에서 묻고 취소 시 유지, `renderPreview` 가 캔버스를 숨기지 않음 | `visual-editor-ui.js onSiteUpdated`(RED: 다른 사이트 갱신에 즉시 종료) · `test/visual-editor-app.test.js`(실제 app.js — RED: `VE.isActive()` actual false expected true, 취소했는데 끝남) | +| 9 | 복제가 뿌리 id 만 지움 | 자손 `[id]` 모두 제거 | `duplicate: 복제본은 … 자손의 id 도 지우고`(RED: 복제본 자손 id 1개 남음) | +| 10 | 블록 끌기에 캡처·pointercancel/blur/Escape 정리 없음 | `endBlockDrag`: 리스너·캡처·클래스·표시 정리; 손잡이 `setPointerCapture` | `기존 블록 끌기: … pointercancel·창 blur·Escape …`(RED: `setPointerCapture` 미호출) | + +### RED — `node --test test/visual-editor.test.js`(수정 전 코드, 새 테스트 포함) +``` +ℹ tests 37 +ℹ pass 24 +ℹ fail 13 +``` +실패 13건: 주소 엔티티 / prepareCanvas 구조 / 완성 문서 비교 / 부모 연결 규약 / onSiteUpdated / 캔버스 연결 규약 / 이동 활성화 차단 / base target·meta refresh / 복제 id / 끌기 정리 / applyProps 엔티티 / (포트 규약으로 옮긴) 포인터 다리 부모 / 캔버스 paletteDragAt — 대표 메시지 `Error: 포트가 연결되지 않음`, `AssertionError: 거절: "javascript:alert(1)"`, `AssertionError: 주석은 그대로`, `AssertionError: 거절되어야 함:

본문

`. + +### RED — `CLAUDE_BIN=<가짜 exit 1> node --test test/visual-editor-server.test.js` +``` +ℹ tests 13 +ℹ pass 9 +ℹ fail 4 +``` +`actual: 200, expected: 409`(소스↔직접 편집 잠금) · `actual: "EACCES …sites/.json….tmp", expected: /저장하지 못했습니다|되돌렸습니다/`(store 실패가 그대로 500 으로 새고 파일은 바뀜) · `actual: 200, expected: 500`(Git 실패를 삼킴) · `actual: 200, expected: 409`(AI 작업 시작과 겹친 저장). + +### RED — `node --test test/visual-editor-app.test.js`(수정 전 `public/app.js` 스냅샷으로 실행) +``` +ℹ pass 0 +ℹ fail 1 +AssertionError: Expected values to be strictly equal: actual: false, expected: true ← openSite(다른 사이트) 취소 뒤 VE.isActive() +``` + +### GREEN — 수정 뒤 +``` +node --test test/visual-editor.test.js ℹ tests 37 · pass 37 · fail 0 +node --test test/visual-editor-server.test.js ℹ tests 13 · pass 13 · fail 0 +node --test test/visual-editor-app.test.js ℹ tests 1 · pass 1 · fail 0 +``` + +## 전체 묶음 — 독립 검토(1차) 차단 항목 수정 뒤 +`CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js) +``` +ℹ tests 103 +ℹ pass 103 +ℹ fail 0 +``` +(원본 52 + 직접 편집 51 = 단위 37 + 서비스 13 + 화면 전환 1. Suite 체크아웃의 `modules/webmaker` 에서 같은 명령: tests 110 · pass 110 · fail 0. Suite 전체 `node test/run.mjs`: 테스트 파일 24개 중 실패 0 — Suite 쪽 기록 `docs/verification/webmaker-visual-editor-0.2.0.md`.) 최종 수치는 아래 "종결 검토 뒤" 절. +문법: `node --check` — `lib/visual-editor.js`·`lib/gitstore.js`·`lib/store.js`·`server.js`·`public/visual-editor-core.js`·`visual-editor-ui.js`·`wm-ve-canvas.js`·`public/app.js` 두 저장소 모두 통과. 공유 파일 두 저장소 byte-identical(`cmp`). `git diff --check` 깨끗함. + +## 수정 뒤 브라우저 게이트 재실행 실패의 원인 조사(2026-10-28) — 게이트 멱등성 결함 + auxclick 틈 +- 재실행 출력: `step: "edit-text"`, 11/11 통과 뒤 `시간 안에 되지 않음: 저장 상태 dirty`. 선택·속성 창이 동작했으므로 MessagePort 연결은 정상. +- 가설 (a) **같은 저장 폴더 재사용** — 이전 37/37 실행이 이미 같은 고정 제목을 저장했다. 로컬 재현(참조 저장소 서버 + Chrome for Testing 156 + playwright-core 1.63, 고정 페이지의 h1 을 미리 같은 제목으로 둠): 게이트 진단 `editDiag.afterTyping = { events:{ input:16, beforeinput:16, keydown:5, focusout:0 }, canvas:{ tag:"h1", editable:true, hasFocus:true }, h1EqualsTyped:true }`, Escape 뒤 `veState:"ve-state clean", isDirtyApi:false, undoEnabled:false`. 즉 입력은 캔버스 h1 에 정확히 들어갔고 `changed` 도 갔지만 본문이 저장본과 같아 `history.push` 가 false → clean 유지. **제품의 dirty 규칙은 옳고(변경 없음 = clean) 게이트가 멱등하지 않았다.** 게이트는 실행마다 `브라우저에서 직접 수정한 제목 <실행 표식>` 을 쓰고 편집 전에 현재 제목과 다른지 단언한다(`editTargetDiffersFromCurrent`). 제품 변경 없음. +- 가설 (b) **가운데 클릭** — 같은 로컬 실행에서 게이트의 새 단언 `canvasNoNavigate_linkMiddleClick` 이 `popups=1`(새 페이지가 앱 출처로 열림)로 실패. 가운데 버튼은 `click` 이 아니라 `auxclick` 으로 오고 런타임은 `click` 만 막고 있었다 → 실제 틈. RED(단위, 가짜 DOM `fire('auxclick')`: `AssertionError: edit: AREA 가운데 클릭(auxclick) 차단`) → `wm-ve-canvas.js` 에 `auxclick` 캡처 리스너(이동 활성화 요소만 preventDefault) → GREEN. 실제 Chrome: `popups=0`. +- 결과(로컬 게이트, 같은 저장 폴더로 연속 2회): `status: pass`, **61/61**, 두 번째도 61/61. `handshake:{ windowMessagesFromFrame:1, readyWithPort:1, otherTypesViaWindow:[], origins:["null"] }`, `forged:{ putSeen:false, elemsBefore:28, elemsAfter:28 }`, 이동 활성화 6종 모두 `alive:true, popups:0`. + +## 독립 검토 2차(종결 검토) 차단 항목 3건 수정(2026-10-28) — SVG SMIL 저장형 XSS · Git 저장 트랜잭션 재설계 · (Suite) 릴리스 승인 fail-open +| # | 결함(검토자 발견) | 수정 | 테스트 | +|---|---|---|---| +| 1 | `validateVisualSave` 가 ``·`` 를 받았다 — 주소 목록은 정적 주소 속성만 보고 위험 요소 목록에 SMIL 변이 요소가 없었다(Chromium 에서 저장 페이지를 열면 href 가 javascript: 로 바뀌어 실행) | `lib/visual-editor.js`: SMIL 묶음 `animate·set·animateMotion·animateTransform·animateColor·discard·mpath`(HTML 파서가 SVG 로 읽는 SMIL 요소 전부; 대소문자·`svg:` 접두어 형태도 같은 묶음, fail-closed)을 위험 요소로 센다(원본 수까지만 — 삭제 허용, 추가·속성 변경 거절, 전용 안내문). `prepareCanvas` 는 SMIL 요소를 `wm-ve-x-<이름>`(여는·닫는 태그, 속성 보존)으로 바꾸고 `restoreFromCanvas` 가 표준 이름으로 되돌린다(브라우저가 소문자로 직렬화해도 `animateMotion` 등으로). `core.isSafeComponentHtml` 도 같은 묶음 거절 | 단위 +1(`validateVisualSave·prepareCanvas·restoreFromCanvas·core.isSafeComponentHtml: SVG SMIL …`) | +| 2 | `recordChange` 가 커밋·브랜치·**원격 푸시 뒤** 사이트 기록을 저장; `rollbackTo` 는 soft reset(커밋 실패 뒤 인덱스 그대로); 경로가 되돌리기 boolean 무시; 성공한 푸시 뒤 기록 저장 실패가 원격만 바꾼 채 남을 수 있음 | `lib/gitstore.js` 새 `recordChangeStrict`(큐 한 항목): 스냅샷(add 전) → add·commit·변경 브랜치 → `onCommitted`(기록 저장) → `git push --atomic` 으로 main + 변경 브랜치 함께(마지막 단계, 그 뒤 상태 변경 없음) / 실패 → `rollbackLocal`: `reset --mixed <이전 HEAD>`(커밋 되감기 + 인덱스 비움), 첫 커밋 전이면 브랜치 삭제 + `read-tree --empty`, 변경 브랜치 삭제/복원, `onRollback`(페이지 원본·`store.save(prev,{restore:true})`), **검증**(HEAD·인덱스·브랜치·작업 트리 경로 ⊆ 저장 전·페이지 바이트·기록) → `{ok, problems, notes}`. `server.js` PUT /visual 은 `rolledBack` 을 검증 결과로만 보내고 실패 때 `rolledBack:false`+`rollbackProblems`+"상태를 확신할 수 없습니다". 강제 푸시 되돌리기 없음. 푸시 전에 저장되는 기록은 `pushed:false, remote:'pending'`(원격 반영 미주장) 이고 `reconcilePushes`(저장 뒤·기동 때·다음 기록 때)가 `ls-remote` 로 확인한 뒤에만 `verified`. `gitRecord` strict 옵션·`headCommit`·`changeBranchName` 제거(다른 호출자는 `recordChange` 그대로) | 서비스 +2(진짜 bare 원격 + pre-receive 거부 훅/pre-commit 훅/기록 폴더 읽기 전용; gitstore 직접 호출 — 첫 커밋 전 되감기·문제 보고 때 ok:false) + 기존 store/Git 실패 테스트에 `rolledBack:true`·인덱스·브랜치 단언 | +| 3 | (Suite) `hal/core.approveRelease` 가 명시적 false 만 거절 — checks 없음·testsPassed null(--skip-tests)·dirty 미기록도 승인 | Suite `hal/core.releaseApprovalProblem`: `testsPassed === true`·`dirty === false`·`provenanceClean === true` 모두 필수(approveRelease·registerRelease approve:true·운영자 API·`release.mjs register --approve` 가 같은 규칙) | Suite `test/release-provenance.test.mjs` +3 | + +### RED — `node --test test/visual-editor.test.js`(수정 전 코드, 새 테스트 포함) +``` +✖ validateVisualSave·prepareCanvas·restoreFromCanvas·core.isSafeComponentHtml: SVG SMIL 변이 요소(…)는 … 거절하고 … + AssertionError [ERR_ASSERTION]: 거절되어야 함: …hi +``` +### RED — `CLAUDE_BIN=<가짜 exit 1> node --test test/visual-editor-server.test.js`(수정 전 코드) +``` +ℹ tests 14 · pass 11 · fail 3 + AssertionError: 검증된 되돌리기: {"error":"버전 기록(Git)·사이트 기록을 남기지 못해 저장을 취소했습니다 (EACCES). … 일부 되돌리기에 실패했으니 관리자에게 알리세요 …","rolledBack":false} ← Git 실패 테스트: 옛 rollbackTo 가 false 를 돌려주는데 상태는 멀쩡(판정 근거 없음) + AssertionError: {"refs/heads/sites//changes/0001-create":…,"…/0002-visual-edit":…,"…/main":…} ← 원격 트랜잭션 테스트(첫 단언에서 멈춤; 옛 코드는 pre-receive 거부를 200 + rec.error 로 삼켰다) +``` +### RED — Suite `node test/release-provenance.test.mjs`(수정 전 hal/core·release.mjs) +``` +✘ hal/core.approveRelease·registerRelease(approve:true): … → Error: checks 없음 (approveRelease) (승인됨) +✘ HAL 운영자 API POST /ops/api/releases/:version/approve … → Error: 7.7.0: 거절 200 {"ok":true,"release":{…}} +✘ release.mjs register --approve: testsPassed … → Error: tests-null: HAL 릴리스 등록: 0.2.0 (approved) … 기대 1 실제 0 +✘ 통과 4 · 실패 3 +``` +### GREEN — 수정 뒤 +``` +node --test test/visual-editor.test.js ℹ tests 38 · pass 38 · fail 0 +node --test test/visual-editor-server.test.js ℹ tests 16 · pass 16 · fail 0 (원격 트랜잭션: 성공 때 main·변경 브랜치 함께 갱신·기록은 pending → ref 확인 뒤 verified, 거부/커밋 실패/기록 실패 세 경로 모두 원격 ref 불변·rolledBack:true·재시도 200, 기동 조정) +Suite: node test/release-provenance.test.mjs ✔ 통과 7 · 실패 0 +``` +### 원격 반영 기록의 보수성(종결 검토 후속) — RED → GREEN +- 검사: 푸시가 원격에 닿는 순간(pre-receive 훅)에 사이트 기록 파일을 복사해 보니, 1차 구현은 푸시 **전** 에 저장한 기록이 이미 `pushed:true` 였다(RED: `AssertionError: 푸시 전 기록은 원격 반영을 주장하지 않는다: {"no":2,"kind":"visual-edit",…,"pushed":true,"error":null,…}`). 프로세스가 그 사이에 죽으면 "원격에 있다" 는 거짓 기록이 남는다. +- 수정: 기록은 `pushed:false, remote:'pending'` 으로 저장·응답하고(푸시 뒤 어떤 저장도 하지 않는다), 트랜잭션 밖의 `reconcilePushes` 가 잠금 안에서 기록을 다시 읽어 `ls-remote` 로 **원격 ref 에서 확인** 한 뒤에만 `pushed:true, remote:'verified', verifiedAt` 로 바꾼다. 원격에 없으면(크래시 창) 로컬 변경 브랜치가 기록의 커밋을 가리키는지 확인하고 변경 브랜치들 + main 을 한 번의 원자적 푸시로 올린 뒤 다시 확인한다. 호출: 저장 응답 뒤(setImmediate)·서버 기동 때(pending 이 있는 사이트)·다음 기록 때(옛 retryPush 자리). 조정의 저장은 `{restore:true}` 라 Suite rev 를 올리지 않는다. +- GREEN(서비스 테스트): 훅이 복사한 푸시 순간의 기록 `pushed:false·remote:'pending'`, 응답도 pending, 조정 뒤 `verified`(푸시 1회 — 확인만 하고 다시 푸시하지 않음); **기동 조정**: 기록을 pending 으로 되돌리고 원격 변경 브랜치 삭제·main 을 한 커밋 전으로 되돌린 뒤 같은 데이터로 재기동 → 원자적 푸시 한 번으로 브랜치·main 복구, ref 확인 뒤 `verified`, 기록 저장되고 rev 불변; gitstore 직접 호출: 거부(REJECT)면 pending + 사유, 해제 뒤 푸시·확인, 이미 올라간 기록은 확인만(푸시 로그 2건 = 거부 1 + 성공 1). + +설계 메모: 되돌리기에 `reset --hard` 를 쓰지 않은 이유 — 작업 트리는 사이트 폴더 전체이고 저장과 무관한 커밋되지 않은 파일(이전 비엄격 기록 실패 등)이 있을 수 있어 `--hard` 는 그것을 지운다. 저장이 바꾼 것은 페이지 한 파일(원본 바이트로 복원)과 커밋·인덱스·브랜치뿐이므로 `--mixed` + 페이지 복원 + "작업 트리 변경 경로 ⊆ 저장 전 경로" 검증이 같은 보장을 준다. 판정은 명령 성공 여부가 아니라 실제 상태로 한다(읽기 전용 저장소에서 add 가 실패하면 reset 도 실패하지만 상태는 저장 전과 같아 ok — notes 에 명령 오류). 성공한 푸시 뒤에는 트랜잭션 안에서 어떤 저장도 하지 않는다 — 기록은 보수적(pending)으로 남고, 별도 조정 단계가 원격 ref 로 확인한 뒤에만 verified 로 바꾼다. 크래시 창(기록 저장과 푸시 사이에 프로세스 종료)에는 기록이 pending·로컬 커밋과 변경 브랜치가 남아 기동 조정이 원자적으로 올린다(거짓 "원격 반영" 기록 없음). + +## 독립 검토 3차(최종 검토) 차단 항목 4건 수정(2026-10-28) — (Suite) 승인 불변 · 사이트 변경 잠금 전면 감사 · 트랜잭션 완결성·모호한 푸시 판정 · 문서 수치 +| # | 결함(검토자 발견) | 수정 | 테스트 | +|---|---|---|---| +| 1 | (Suite) `registerRelease` upsert 가 승인된 버전의 checks·artifact 를 바꾸면서 approved 유지 | 승인된 버전은 같은 내용 재등록만 멱등, 다르면 `release_immutable`(변경 없음); 미승인 버전의 변경 재등록은 registered·승인 미승계; CLI 거부 종료 1 | Suite provenance +2(core 8가지 × approve true/false readback 불변, CLI) | +| 2 | 라우터가 요청 시작 때 읽은 기록으로 본문을 기다린 뒤 처리·저장(claimJob·다국어 설정·언어 폴더·복원이 기다리기 전 기록 사용) | 변경 요청은 본문을 잠금 밖에서 메모 읽기 → 잠금 안에서 기록 재적재 → 짧은 경로는 처리기 전체 잠금, 긴 경로는 재적재한 기록으로 처리(claimJob 까지 await 없음); 재진입 잠금(AsyncLocalStorage)·`detached()`; changeLanguages 는 claimJob 먼저 | 서비스 +1(설정 저장·복원·작업 시작 vs 직접 편집 저장의 결정적 경쟁) | +| 3 | `recordChangeStrict` 의 initSite·스냅샷·원격 설정이 try 밖(실패해도 onRollback 없음); 푸시 클라이언트 오류를 무조건 실패로 보고 되돌림(원격이 받았을 수 있음) | 모두 트랜잭션 안(`prepare`), 스냅샷에 repoExisted·indexClean, 새로 만든 저장소는 되돌릴 때 삭제, 스냅샷 전 실패는 ok:false; 푸시 전 원격 ref 를 읽고 오류 뒤 다시 읽어 accepted(성공)/unchanged(되돌림 + 원격 재검증)/ambiguous(pending 유지, 200 + `remoteState`) | 서비스 +1(git 래퍼: FAIL_AFTER·MIXED·FLAKY·DOWN) + 직접 호출 준비 단계 실패 | +| 4 | 문서 합계 모순(106/113·107/114) | 현재 증거로 통일 | — | +| 5 | 배경 AI 작업이 자기 사이트 객체를 나중에 저장해 작업 중 허용되는 짧은 변경(기본 사항 PATCH·모델 등급·도메인·첨부·검수·모듈 — 실행 중 검사 없음)을 지움 | `lib/store.js` 저장소 불변식: 읽은 객체마다 `__base`(항목별 JSON + `saveId`)를 기억, 저장 때 디스크 `saveId` 가 다르면 이 객체가 바꾼 최상위 항목만 디스크 최신 기록 위에 합침(restore 제외, 조정은 keepRev); 취소·상태 경로 불변, 새 잠금 없음 | 서비스 +1(작업 중 PATCH 제목·PUT 등급 → 중단 → 종료 저장 뒤 유지) | +| 6 | 같은 최상위 항목을 둘 다 바꾸면 마지막 저장자가 이겨 먼저 쓴 값이 사라짐(작업 중 다시 푸시의 git.changes 반영 상태 vs 작업 종료의 새 버전 추가) | `lib/store.js` 잎 단위 3자 병합 + 같은 잎을 다르게 바꾼 경우 `SaveConflict`(site_save_conflict, 409, fields)로 거절·디스크 보존; `store.update`·`store.adopt`; 서버 `saveJob(site, reapply)`(배경 작업의 모든 저장, 실패 기록 포함)·`reapplyCommand`/`reapplyFields`; `gitRecord` 충돌 때 버전만 추가; 다시 푸시·조정은 `applyPushState` 를 `store.update` 로 적용 | 서비스 +2(저장소 규칙 6가지; 실제 서버 — git 래퍼로 commit 2초 지연 → 작업 종료의 버전 기록 창에 다시 푸시) | + +### RED(수정 전 코드) +``` +Suite: ✘ hal/core.registerRelease 불변 승인 … Error: 승인자·시각도 그대로: 기대 {"status":"approved","by":"op",…} 실제 {"status":"approved","by":"op2",…} ← 재등록이 승인된 행을 바꿈 +Suite: ✘ HAL CLI register-release … 기대 approved_by "test" 실제 "test2" +W: ✖ 사이트 변경 잠금(전체 감사) … AssertionError: 설정 저장이 직접 편집 버전을 덮어쓰지 않는다: ["1:create"] ← 설정 저장이 본문을 기다리는 사이 끝난 직접 편집 버전을 지움 +W: ✖ 원자적 푸시의 모호한 클라이언트 오류 … AssertionError: 원격이 받았으면 성공: {"error":"원격 소스 보관소(Git)에 올리지 못해 저장을 취소했습니다 (fatal: the remote end hung up unexpectedly (test)) …","rolledBack":true,"stage":"push"} ← 원격이 받았는데 되돌림 +W: ✖ gitstore.recordChangeStrict(직접 호출) … AssertionError: 준비 단계 실패도 트랜잭션 안: {"message":"…/git/s1: Permission denied","gitDirExists":false,"page":"

b

"} ← stage·rollback 없음, 페이지 미복원 +W: ✖ 배경 AI 작업이 나중에 자기 사이트 객체를 저장해도 … AssertionError: 작업의 종료 저장이 화면의 제목 변경을 덮어쓰지 않는다: {"title":"VE site","status":"idle"} ← 작업 중 바꾼 제목이 종료 저장에 지워짐 +W: ✖ 배경 AI 작업과 작업 중 허용되는 경로가 같은 잎을 바꾸는 진짜 동시 변경 … AssertionError: 다시 푸시가 쓴 확인 시각이 그대로다(…): {"kept":"…46.004Z","retry":"…45.975Z"} ← 작업 종료가 덮어쓰고 배경 재확인이 다른 시각을 씀(최상위 항목 합치기만 있던 코드) +W: ✖ store.save 합치기 규칙 … TypeError: store.update is not a function / 같은 잎을 둘 다 다르게 바꿔도 거절되지 않음 +중간(저장소를 fail-closed 로 바꾼 직후): ✖ … AssertionError: {"error":"사이트 기록 저장 충돌: … (git.changes) …"} ← 충돌이 "다시 푸시" 경로에 드러남(덮어쓰기 없음) → 다시 푸시·조정을 store.update 경로로 바꿈 +``` +### GREEN — 수정 뒤 +``` +node --test test/visual-editor-server.test.js ℹ tests 21 · pass 21 · fail 0 (저장소 합치기 뒤: 되돌리기 검증은 저장 표식 saveId 도 제외하고 비교; 같은 잎 동시 변경은 거절·디스크 보존·작업은 최신 기록 위에 재적용) +Suite: node test/release-provenance.test.mjs ✔ 통과 9 · 실패 0 +``` + +## 독립 검토 4차 차단 항목 4건 수정(2026-10-28) — (Suite) artifact 설치 불변 · 읽기 요청 부작용 · 폐기 잠금·표식 · svg/math 안 title 파서 차이 +| # | 결함(검토자 발견) | 수정 | 테스트 | +|---|---|---|---| +| 1 | (Suite) 두 CLI 가 DB 불변 검사 전에 버전 폴더에 복사해 승인된 artifact 바이트가 덮임; HAL CLI 는 해시 미검증 | `hal/core.installArtifacts`: 바이트 해시·크기 검증 → 승인된 버전은 메타데이터·설치 파일 해시가 같을 때만 멱등(복사 없음) / 다르면 `release_immutable`(파일·DB 불변) → 새 설치는 임시 파일 → 해시 재확인 → rename; 두 CLI 모두 설치 → 등록 | Suite provenance +2(HAL CLI·release.mjs) | +| 2 | GET/HEAD/OPTIONS 가 x-ui-lang 으로 잠금 밖 store.save | 읽기 메서드는 저장소를 건드리지 않음; 언어는 변경 요청의 잠금 안 재적재에서만 저장 | 서비스 +1 | +| 3 | 폐기가 잠금·표식 없이 긴 정리를 기다림; 실패 기록을 오래된 객체로 저장 | 잠금 안 재검사 + 표식(token) 원자적 claim → 잠금 밖 정리 → 다시 잠금·같은 표식 확인 뒤 **임시 이름으로 옮김(되돌릴 수 있음) → `beforeIrreversible` 훅(Suite 포털 연결 해제) → 기록 unlink(되돌릴 수 없는 한 단계, 실패면 되돌리기 + `compensate`) → 최선의 뒷정리(실패는 warnings·감사)**; 표식 중 변경 요청·claimJob 409; 실패는 표식 해제 + `store.update` 로 deleteFailed{recoveryRequired} | 서비스 +1(가짜 ssh 2초: 5종 409, 성공·실패·다른 사이트); Suite 모듈 `test/ariweb.test.js` +3(정리 실패 → 연결 유지, 연결 해제 실패 → 옮긴 것 되돌림, 뒷정리 실패 → 경고·남은 폴더) | +| 4 | 스캐너가 svg/math 안 `` 을 RCDATA 로 읽어 `<svg><title><script>` 통과(브라우저는 실행) | `scanTags` 외래 콘텐츠 깊이 — svg/math 안에서는 원문 규칙 없음(통합 지점도 요소로, fail-closed) | 단위 +1(15 변형·닫히지 않은 title·정상 SVG·캔버스 출력) | + +### RED(수정 전 코드) +``` +W 단위: ✖ scanTags 파서 차이(저장형 XSS) … AssertionError: 거절되어야 함: …<svg><title><script>alert(1)</script> +W 서비스: ✖ 읽기 요청은 부작용이 없다 … AssertionError: 읽기 요청은 기록 파일을 바꾸지 않는다(uiLang·saveId·rev 포함) +W 서비스: ✖ 사이트 폐기는 잠금 안에서 … AssertionError: #0 폐기 안내 ← 폐기 중 직접 편집 저장이 "폐기" 로 거절되지 않음 +Suite: ✘ artifact 설치 불변·fail-closed(HAL CLI register-release) … 기대 "artifact-A-bytes" 실제 "artifact-B-bytes-different" ← 승인된 artifact 가 덮임 +Suite: ✘ artifact 설치 불변·fail-closed(scripts/release.mjs register) … 기대 "rel-A" 실제 "rel-B-other" +``` +### GREEN — 수정 뒤 +``` +node --test test/visual-editor.test.js ℹ tests 39 · pass 39 · fail 0 +node --test test/visual-editor-server.test.js ℹ tests 23 · pass 23 · fail 0 +Suite: node test/release-provenance.test.mjs ✔ 통과 11 · 실패 0 +``` + +## 전체 묶음 — 독립 검토 4차 뒤(현재 증거) +`CLAUDE_BIN=<가짜 exit 1> npm test` +``` +ℹ tests 115 +ℹ pass 115 +ℹ fail 0 +``` +(원본 52 + 직접 편집 63 = 단위 39 + 서비스 23 + 화면 전환 1. Suite `modules/webmaker` 같은 명령: tests 125 · pass 125 · fail 0(Suite 전용 `test/ariweb.test.js` 10 포함). Suite 전체 24개 파일 실패 0 — release-provenance 11.) 실제 Chrome 게이트는 61/61 그대로(이번 4건은 게이트가 보는 화면 동작을 바꾸지 않는다). + +## 전체 묶음 — 최종 검토(3차) + 잎 단위 저장소 합치기 뒤 +`CLAUDE_BIN=<가짜 exit 1> npm test` +``` +ℹ tests 112 +ℹ pass 112 +ℹ fail 0 +``` +(원본 52 + 직접 편집 60 = 단위 38 + 서비스 21 + 화면 전환 1. Suite `modules/webmaker` 같은 명령: tests 119 · pass 119 · fail 0. Suite 전체 `CLAUDE_BIN=<가짜 exit 1> node test/run.mjs`: 테스트 파일 24개 중 실패 0 — release-provenance 7·webmaker-visual-editor 5 포함, 생성된 `local-e2e-2026-10-28.{md,json}` 은 삭제.) 문법 `node --check`(server.js·lib/gitstore.js·lib/visual-editor.js·core·테스트) 두 저장소 통과, 공유 파일 byte-identical(`cmp`), Suite `server.js` 차이 115줄(Suite 블록만), `git diff --check` 깨끗함. Suite 도입 기록 재생성: 184 파일·수정 8·workingTreeDirty=true(의도 — Neo 가 참조 저장소를 커밋한 뒤 다시 가져온다). + +## 브라우저 검증 상태 +- **실제 브라우저 검증은 수행되었다**(Neo 의 게이트 — 1·2차 시각 게이트 1280×633·390×844, 3·4차 Playwright 자동 게이트, 5차 네이티브 포인터 순서 게이트 **37/37 통과**: 직접 편집 열림·선택·글 편집·실행 취소/다시 실행·Alt+↑/↓·팔레트 포인터 끌어다 놓기(놓기 표시 → 문단 삽입)·저장 PUT 2xx·저장 파일(편집 제목·추가 문단·원본 스크립트 보존·표식 없음)·390×844 배치). 이 37/37 은 **독립 검토(1차) 차단 항목 수정 전** 의 결과다. +- **수정 뒤 브라우저 재실행 — 완료**: Neo 의 첫 재실행은 게이트 멱등성 결함으로 `edit-text` 에서 멈췄다(위 절). 고친 게이트(실행마다 다른 제목, 연결 규약·이동 활성화 6종·위조 창 메시지 단언 추가)는 이 PC 의 Chrome 156 에서 61/61(연속 2회), **Neo 호스트의 실제 Chrome 에서도 61/61 통과**(2026-10-28): 직접 편집 열림(ready + 포트 → 본문 수신)·선택·글 편집·실행 취소/다시 실행·이동·팔레트 끌어다 놓기·저장·파일·390×844 + 연결 규약·이동 활성화 6종·위조 창 메시지. 종결 검토 3건(서버 검증·Git·HAL)은 브라우저 동작을 바꾸지 않아 추가 브라우저 재실행은 필요 없다. +- 자동화하지 않은 것: 포털 SSO 로 들어간 실제 화면, 운영 서버 배포, Chrome 외 브라우저. Node 에서는 런타임 파일의 문법 검사·`eval`/`new Function` 부재·정적 구조·CSS 배치 계약·가짜 DOM 위 실제 런타임/app.js 실행(연결·전환·끌기·놓기)을 확인한다. diff --git a/modules/webmaker/lib/ariweb.js b/modules/webmaker/lib/ariweb.js index 62af968..fcde9d8 100644 --- a/modules/webmaker/lib/ariweb.js +++ b/modules/webmaker/lib/ariweb.js @@ -83,8 +83,8 @@ function saveLinks(d) { js.writeJsonAtomic(LINKS_FILE, d, { mode: 0o600, keepBac /** 프로젝트의 포털 연결 정보(없으면 null) */ function linkOf(projectId) { return loadLinks().links[projectId] || null; } /** 연결 등록(포털이 사이트를 만들거나 기존 프로젝트를 연결할 때) */ -function link(projectId, { siteId, groupId, portalUrl: pu, siteName, groupName }) { - const d = loadLinks(); d.links[projectId] = { siteId: String(siteId), groupId: String(groupId), portalUrl: String(pu || portalUrl()), siteName: String(siteName || ''), groupName: String(groupName || ''), linkedAt: new Date().toISOString() }; saveLinks(d); return d.links[projectId]; +function link(projectId, { siteId, groupId, portalUrl: pu, siteName, groupName, linkedAt }) { // linkedAt: 폐기 실패 보상(되살리기) 때 원래 연결 시각 보존 + const d = loadLinks(); d.links[projectId] = { siteId: String(siteId), groupId: String(groupId), portalUrl: String(pu || portalUrl()), siteName: String(siteName || ''), groupName: String(groupName || ''), linkedAt: linkedAt || new Date().toISOString() }; saveLinks(d); return d.links[projectId]; } function unlink(projectId) { const d = loadLinks(); const had = !!d.links[projectId]; delete d.links[projectId]; saveLinks(d); return had; } /** 연결된 프로젝트 전체(포털 사이트 id → 프로젝트 id 역참조용) */ diff --git a/modules/webmaker/lib/gitstore.js b/modules/webmaker/lib/gitstore.js index 13db4cf..aed6ba8 100644 --- a/modules/webmaker/lib/gitstore.js +++ b/modules/webmaker/lib/gitstore.js @@ -140,22 +140,41 @@ function squashToCurrent(siteId, siteDir, message) { }); } -/** 푸시 실패분 재시도 */ -function retryPush(siteId, siteDir, recs) { +/** + * 원격 반영 조정(푸시 실패분 재시도의 후속, 1.8.0 검토 수정 2차). pushed 가 아닌 기록(직접 편집 저장의 'pending' — 기록은 푸시 **전** 에 저장되므로 원격 반영을 주장하지 않는다 — 과 예전 실패분)을 + * 1) `ls-remote` 로 **원격 ref 에서 확인** 해 변경 브랜치 = 기록의 커밋이고 main 이 그 커밋을 포함하면 푸시 없이 pushed:true·remote:'verified' 로 표시하고, + * 2) 아니면 로컬 변경 브랜치가 기록의 커밋을 가리키는지 확인한 뒤 그 브랜치들 + main(+HEAD — main 은 늘 지금 HEAD, v1.3.17 B07) 을 **한 번의 원자적 푸시** 로 올리고 다시 1) 로 확인한다. + * 확인되지 않은 기록은 pushed:false 로 남고 error 에 사유가 적힌다(거짓 성공 없음). 원격이 없으면 그대로 돌려준다. 던지지 않는다. retryPush 라는 옛 이름으로도 부른다. + */ +function reconcilePushes(siteId, siteDir, recs) { return enqueue(siteId, async () => { - if (!conn().remote) return recs; + const { remote, prefix } = conn(); if (!remote) return recs; await initSite(siteId, siteDir); - // v1.3.17 (B07): 예전에는 실패했던 옛 기록마다 그 커밋을 main 에 강제로 올려, 새 버전이 이미 올라간 뒤에 main 이 옛 내용으로 - // 되감길 수 있었다. 이제 옛 기록은 자기 변경 브랜치에만 올리고, main 은 늘 지금 HEAD(가장 새 버전)로 맞춘다. - for (const r of recs) { - if (r.pushed) continue; - try { if (r.kind !== 'deploy' && r.branch) await git(siteId, siteDir, ['push', '-q', 'origin', `${r.commit}:refs/heads/${r.branch}`], { timeout: 120000 }); r.pushed = true; r.error = null; } - catch (e) { r.error = e.message; } + const main = `refs/heads/${mainBranch(siteId)}`; const sh = (args, opts) => git(siteId, siteDir, args, opts); + const pending = (recs || []).filter((r) => r && !r.pushed && r.commit); + if (!pending.length) return recs; + const readRemote = () => readRemoteRefs(siteId, siteDir, prefix); + const isAncestor = async (c, sha) => { if (!sha) return false; if (c === sha) return true; try { await sh(['merge-base', '--is-ancestor', c, sha]); return true; } catch (_) { return false; } }; + const verified = async (r, refs) => ((r.kind === 'deploy' || !r.branch) ? true : refs[`refs/heads/${r.branch}`] === r.commit) && await isAncestor(r.commit, refs[main]); + const mark = (r) => { r.pushed = true; r.error = null; r.remote = 'verified'; r.verifiedAt = new Date().toISOString(); }; + let refs; try { refs = await readRemote(); } catch (e) { for (const r of pending) r.error = `원격 확인 실패: ${e.message}`; return recs; } + const toPush = []; for (const r of pending) { if (await verified(r, refs)) mark(r); else toPush.push(r); } + if (!toPush.length) return recs; + const specs = []; const pushing = []; + for (const r of toPush) { + if (r.kind === 'deploy' || !r.branch) { pushing.push(r); continue; } // 배포 정리 기록은 main 만 + const local = (await sh(['rev-parse', '--verify', '-q', `refs/heads/${r.branch}`]).catch(() => '')) || null; + if (local !== r.commit) { r.error = `로컬 변경 브랜치 ${r.branch} 가 기록의 커밋을 가리키지 않아 올릴 수 없습니다 (${local ? local.slice(0, 7) : '없음'})`; continue; } + specs.push(`${r.commit}:refs/heads/${r.branch}`); pushing.push(r); } - try { await git(siteId, siteDir, ['push', '-q', 'origin', `+HEAD:refs/heads/${mainBranch(siteId)}`], { timeout: 120000 }); } catch (e) { for (const r of recs) if (r.pushed && !r.error) { r.pushed = false; r.error = e.message; } } + if (!pushing.length) return recs; + try { await sh(['push', '--atomic', '-q', 'origin', ...specs, `+HEAD:${main}`], { timeout: 120000 }); } catch (e) { for (const r of pushing) r.error = e.message; return recs; } + let refs2; try { refs2 = await readRemote(); } catch (e) { for (const r of pushing) r.error = `푸시 뒤 원격 확인 실패: ${e.message}`; return recs; } + for (const r of pushing) { if (await verified(r, refs2)) mark(r); else r.error = '푸시 뒤 원격 ref 가 기록과 다릅니다'; } return recs; }); } +const retryPush = reconcilePushes; /** 작업 트리를 지정 커밋의 내용으로 되돌린다(추적 파일 기준). 이후 recordChange 로 새 변경(restore)을 기록해야 한다. */ function restoreTo(siteId, siteDir, commit) { @@ -166,9 +185,134 @@ function restoreTo(siteId, siteDir, commit) { }); } +// ---------- 직접 편집 저장 트랜잭션(1.8.0 검토 수정 2차·3차) ---------- +/** status --porcelain 출력 → 경로 집합(되돌리기 검증: 저장 전에 없던 변경이 남지 않았는지) */ +function statusPaths(porcelain) { return new Set(String(porcelain || '').split('\n').filter(Boolean).map((l) => l.slice(3).replace(/^.* -> /, ''))); } +/** 원격의 이 사이트 ref → { refname: sha } (ls-remote). 실패하면 던진다 */ +async function readRemoteRefs(siteId, siteDir, prefix) { + const out = await git(siteId, siteDir, ['ls-remote', '--heads', 'origin', `refs/heads/${prefix}/${siteId}/*`], { timeout: 60000 }); + return Object.fromEntries(out.split('\n').filter(Boolean).map((l) => { const [sha, ref] = l.split('\t'); return [ref, sha]; })); +} +/** + * 되돌리기 기준 스냅샷: 저장소가 있었는지·HEAD(첫 커밋 전이면 null)·인덱스가 HEAD 와 같았는지·변경 브랜치의 이전 값(없으면 null)·작업 트리 상태(경로 집합). add 하기 **전** 에 찍는다. + * @returns {Promise<{repoExisted: boolean, head: string|null, indexClean: boolean, branch: string|null, branchWas: string|null, paths: Set|null}>} + */ +async function snapshotState(siteId, siteDir, branch, repoExisted) { + const head = (await git(siteId, siteDir, ['rev-parse', '--verify', '-q', 'HEAD']).catch(() => '')) || null; + let indexClean = false; try { await git(siteId, siteDir, ['diff', '--cached', '--quiet']); indexClean = true; } catch (_) {} + const branchWas = branch ? ((await git(siteId, siteDir, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`]).catch(() => '')) || null) : null; + const status = await git(siteId, siteDir, ['status', '--porcelain']).catch(() => null); + return { repoExisted: repoExisted !== false, head, indexClean, branch: branch || null, branchWas, paths: status === null ? null : statusPaths(status) }; +} +/** + * 로컬 Git 상태를 스냅샷으로 되돌리고 **검증한 결과** 를 돌려준다(큐 밖, recordChangeStrict 안에서 쓴다). + * 0) 이 트랜잭션이 저장소를 새로 만들었으면(repoExisted=false) 그 git 폴더를 통째로 지운다 — init·인덱스·브랜치 효과가 모두 사라진다. 스냅샷을 찍기 전에 실패했으면(snap 없음) 그 밖의 Git 상태는 건드리지 않는다. + * 1) HEAD·인덱스: `reset --mixed <이전 HEAD>` — 커밋이 만들어졌으면 되감고, add 로 올라간 인덱스도 비운다. 작업 트리의 다른 파일은 건드리지 않는다(--hard 는 저장과 무관한 + * 커밋되지 않은 파일을 지울 수 있다). 첫 커밋 전이었으면 만들어진 브랜치 참조를 지우고 인덱스를 비운다(read-tree --empty). + * 2) 변경 브랜치: 새로 만든 것은 지우고, 원래 있던 것은 이전 값으로. + * 3) hooks.onRollback(): 호출자가 페이지 원본·사이트 기록을 되돌리고 문제 목록(빈 목록 = 성공)을 돌려준다. 어느 단계에서 실패했든 **항상** 부른다(페이지는 트랜잭션 전에 이미 바뀌었다). + * 4) 검증: 저장소 유무 = 스냅샷, HEAD = 스냅샷, 인덱스 = HEAD(저장 전에 깨끗했다면), 변경 브랜치 = 이전 값, 작업 트리 변경 경로 ⊆ 저장 전 경로. 판정은 **실제 상태** 로만 한다 — 되돌리기 명령이 실패해도 + * 상태가 저장 전과 같으면 ok 이고 명령 오류는 notes 에 남긴다. 스냅샷이 없어 검증할 수 없으면(저장소가 있었는데 스냅샷 전에 실패) ok 가 아니다 — 되돌렸다고 주장하지 않는다. + * @returns {Promise<{ok: boolean, problems: string[], notes: string[]}>} + */ +async function rollbackLocal(siteId, siteDir, snap, hooks) { + const problems = []; const notes = []; const sh = (args) => git(siteId, siteDir, args); const gitDir = path.join(GIT_ROOT, siteId); + const repoExisted = !!(snap && snap.repoExisted); + if (!repoExisted) { try { fs.rmSync(gitDir, { recursive: true, force: true }); } catch (e) { notes.push(`새로 만든 git 폴더 삭제 실패: ${e.message}`); } } + else if (snap && !snap.unknown) { + try { + const cur = (await sh(['rev-parse', '--verify', '-q', 'HEAD']).catch(() => '')) || null; + if (snap.head) await sh(['reset', '-q', '--mixed', snap.head]); + else { if (cur) { const b = (await sh(['symbolic-ref', '--short', '-q', 'HEAD']).catch(() => '')) || 'main'; await sh(['update-ref', '-d', `refs/heads/${b}`]); } await sh(['read-tree', '--empty']); } + } catch (e) { notes.push(`HEAD·인덱스 되돌리기 명령 실패: ${e.message}`); } + if (snap.branch) { try { if (snap.branchWas) await sh(['update-ref', `refs/heads/${snap.branch}`, snap.branchWas]); else if ((await sh(['rev-parse', '--verify', '-q', `refs/heads/${snap.branch}`]).catch(() => ''))) await sh(['update-ref', '-d', `refs/heads/${snap.branch}`]); } catch (e) { notes.push(`변경 브랜치 되돌리기 명령 실패: ${e.message}`); } } + } + if (hooks && typeof hooks.onRollback === 'function') { try { for (const p of ((await hooks.onRollback()) || [])) problems.push(String(p)); } catch (e) { problems.push(`페이지·기록 되돌리기 실패: ${e.message}`); } } + if (!repoExisted) { if (fs.existsSync(gitDir)) problems.push('이 트랜잭션이 만든 git 폴더가 남아 있습니다'); return { ok: problems.length === 0, problems, notes }; } + if (!snap || snap.unknown) { problems.push('저장 전 Git 상태를 찍기 전에 실패해 되돌리기를 검증할 수 없습니다'); return { ok: false, problems, notes }; } + try { + const cur = (await sh(['rev-parse', '--verify', '-q', 'HEAD']).catch(() => '')) || null; + if (cur !== snap.head) problems.push(`HEAD 가 저장 전과 다릅니다 (${cur || '없음'} ≠ ${snap.head || '없음'})`); + let indexClean = false; try { await sh(['diff', '--cached', '--quiet']); indexClean = true; } catch (_) { /* 차이 있음 또는 실패 */ } + if (!indexClean) problems.push('인덱스에 커밋되지 않은 항목이 남아 있습니다'); else if (!snap.indexClean) notes.push('저장 전에 인덱스에 있던 항목은 비워졌습니다(커밋되지 않은 채 작업 트리에 남음)'); + if (snap.branch) { const b = (await sh(['rev-parse', '--verify', '-q', `refs/heads/${snap.branch}`]).catch(() => '')) || null; if (b !== snap.branchWas) problems.push(`변경 브랜치 ${snap.branch} 가 저장 전과 다릅니다`); } + if (snap.paths) { for (const p of statusPaths(await sh(['status', '--porcelain']))) if (!snap.paths.has(p)) problems.push(`작업 트리에 저장 전에 없던 변경이 남아 있습니다: ${p}`); } + else problems.push('저장 전 작업 트리 상태를 알 수 없어 검증하지 못했습니다'); + } catch (e) { problems.push(`되돌리기 검증 실패: ${e.message}`); } + return { ok: problems.length === 0, problems, notes }; +} +/** rollbackLocal 을 사이트 큐 안에서(외부 호출용). 결과는 검증된 {ok, problems, notes} */ +function rollbackTo(siteId, siteDir, snap, hooks) { return enqueue(siteId, () => rollbackLocal(siteId, siteDir, snap, hooks)); } +/** + * 직접 편집 저장 트랜잭션. 한 큐 항목 안에서 순서대로(모두 try 안 — 어느 단계에서 실패해도 onRollback 이 불린다. 페이지는 호출 전에 이미 바뀌었기 때문): + * [prepare] 저장소 유무 확인 → initSite → 원격 설정 → 스냅샷(add 전) → [commit] add·commit·변경 브랜치(로컬) → [metadata] onCommitted(rec)(호출자가 사이트 기록을 **원격 푸시 전** 에 저장; 던지면 실패) + * → [push] 원격이 있으면 푸시 전 원격 ref 를 읽고(실패하면 푸시를 시도하지 않은 것이므로 되돌림), main + 변경 브랜치를 **한 번의 원자적 푸시**(`push --atomic`)로 올린다. + * 푸시가 마지막 단계다 — 성공한 푸시 뒤에는 실패할 수 있는 상태 변경을 하지 않는다. 푸시 **전** 에 저장되는 기록은 원격 반영을 주장하지 않는다(`pushed:false, remote:'pending'`). + * 푸시 명령이 오류를 돌려주면(클라이언트 오류는 모호하다 — 원격이 받았을 수도 있다) 원격 ref 를 다시 읽어 판정한다: + * · main·변경 브랜치가 모두 의도한 커밋 → 성공으로 본다(rec.remoteOutcome='accepted', 되돌리지 않음; 조정이 verified 로 바꾼다) + * · 둘 다 푸시 전 값 그대로 → 원격이 바뀌지 않았음이 증명됨 → 되돌리고 로컬·원격을 다시 검증한다(throw, stage 'push') + * · 혼합이거나 원격을 읽을 수 없음 → 되돌리지 **않고** 로컬 커밋·페이지·기록을 pending 으로 남긴 채 rec.remoteOutcome='ambiguous'(+error) 로 돌려준다 — 호출자는 rolledBack 을 주장하지 않고, 조정(기동·저장 뒤·다시 푸시)이 안전하게 해결한다. + * 던지는 오류에 { stage, rollback: {ok, problems, notes} } 를 붙인다. 원격이 없으면 로컬 커밋으로 끝난다(pushed:false, error:NO_REMOTE). 변경이 없으면 rec=null 로 onCommitted 만 부른다. 강제 푸시를 되돌리기에 쓰지 않는다. + * @returns {Promise} rec(변경 없음이면 null) + */ +function recordChangeStrict({ siteId, siteDir, no, kind, message, author, onCommitted, onRollback }) { + return enqueue(siteId, async () => { + const repoExisted = fs.existsSync(path.join(GIT_ROOT, siteId, 'HEAD')); + let stage = 'prepare'; let snap = null; let rec = null; let remote = ''; let prefix = 'sites'; let branch = null; let remoteBefore = null; + const sh = (args, opts) => git(siteId, siteDir, args, opts); + try { + await initSite(siteId, siteDir); + ({ remote, prefix } = conn()); + branch = changeBranch(siteId, no, kind); + snap = await snapshotState(siteId, siteDir, branch, repoExisted); + stage = 'commit'; + await sh(['add', '-A']); + const status = await sh(['status', '--porcelain']); + if (status) { + const authorArgs = ['-c', `user.name=${(author && author.name) || 'webhosting-agent'}`, '-c', `user.email=${(author && author.email) || 'webhosting-agent@local'}`]; + await sh([...authorArgs, 'commit', '-q', '-m', message]); + const commit = await sh(['rev-parse', 'HEAD']); + await sh(['branch', '-f', branch, 'HEAD']); + let files = []; let stat = ''; + try { files = (await sh(['diff-tree', '--no-commit-id', '--name-status', '-r', '--root', 'HEAD'])).split('\n').filter(Boolean).slice(0, 100).map((l) => { const [st, ...rest] = l.split('\t'); return { status: st[0], path: rest[rest.length - 1] }; }); } catch (_) {} + try { stat = (await sh(['show', '--shortstat', '--format=', 'HEAD'])).trim(); } catch (_) {} + rec = { no, kind, branch, commit, message, pushed: false, error: remote ? null : NO_REMOTE, at: new Date().toISOString(), url: branchUrl(branch), commitUrl: commitUrl(commit), files, stat }; + if (remote) rec.remote = 'pending'; // 원격 반영은 아직 확인되지 않음 — reconcilePushes 가 ref 로 확인한 뒤에만 pushed:true + } + stage = 'metadata'; + if (typeof onCommitted === 'function') await onCommitted(rec); + if (rec && remote) { + stage = 'push'; + const mainRef = `refs/heads/${mainBranch(siteId)}`; const branchRef = `refs/heads/${branch}`; + remoteBefore = await readRemoteRefs(siteId, siteDir, prefix); // 실패하면 푸시를 시도하지 않는다 → 원격 불변 → 아래에서 되돌림 + try { await sh(['push', '--atomic', '-q', 'origin', `+HEAD:${mainRef}`, `HEAD:${branchRef}`], { timeout: 120000 }); } + catch (e) { + let after = null; try { after = await readRemoteRefs(siteId, siteDir, prefix); } catch (_) { after = null; } + if (after && after[mainRef] === rec.commit && after[branchRef] === rec.commit) { rec.remoteOutcome = 'accepted'; return rec; } // 원격은 받았다 — 클라이언트 쪽 오류였을 뿐 + if (after && after[mainRef] === remoteBefore[mainRef] && after[branchRef] === remoteBefore[branchRef]) { e.remoteUnchanged = true; throw e; } // 원격 불변 증명 → 되돌림 + rec.remoteOutcome = 'ambiguous'; + rec.error = after ? `원격 반영이 혼합 상태라 확인이 필요합니다 (main ${after[mainRef] ? after[mainRef].slice(0, 7) : '없음'} / 변경 브랜치 ${after[branchRef] ? after[branchRef].slice(0, 7) : '없음'}; 푸시 오류: ${e.message})` : `원격 반영을 확인할 수 없습니다 (푸시 오류: ${e.message})`; + return rec; + } + } + return rec; + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + err.stage = stage; + err.rollback = await rollbackLocal(siteId, siteDir, snap || { repoExisted, unknown: true, head: null, branch, branchWas: null, paths: null }, { onRollback }); + if (err.remoteUnchanged && remoteBefore) { // 원격이 바뀌지 않았음을 되돌리기 뒤에 다시 확인한다(확인하지 못하면 되돌렸다고 주장하지 않는다) + try { const again = await readRemoteRefs(siteId, siteDir, prefix); const mainRef = `refs/heads/${mainBranch(siteId)}`; const branchRef = `refs/heads/${branch}`; if (again[mainRef] !== remoteBefore[mainRef] || again[branchRef] !== remoteBefore[branchRef]) err.rollback.problems.push('되돌리기 뒤 원격 ref 가 푸시 전과 다릅니다'); } + catch (e2) { err.rollback.problems.push(`되돌리기 뒤 원격 확인 실패: ${e2.message}`); } + err.rollback.ok = err.rollback.problems.length === 0; + } + throw err; + } + }); +} + function info(siteId) { const { remote, module } = conn(); return { remote: remote ? remote.replace(/^.*[:/]([^/]+\/[^/]+?)(\.git)?$/, '$1') : null, provider: module ? module.id : 'none', mainBranch: mainBranch(siteId), mainUrl: branchUrl(mainBranch(siteId)), enabled: !!remote }; } -module.exports = { recordChange, retryPush, restoreTo, squashToCurrent, info, mainBranch, remoteStatus, gitHint }; +module.exports = { recordChange, recordChangeStrict, snapshotState, rollbackTo, reconcilePushes, retryPush, restoreTo, squashToCurrent, info, mainBranch, remoteStatus, gitHint, NO_REMOTE }; diff --git a/modules/webmaker/lib/store.js b/modules/webmaker/lib/store.js index 90116b0..13b16fe 100644 --- a/modules/webmaker/lib/store.js +++ b/modules/webmaker/lib/store.js @@ -29,18 +29,87 @@ function recomputeTotals(site) { // v1.6.31 (요구 10): 원자적 쓰기(임시 파일 + fsync + rename) + 직전 정상본 .bak 보존. 읽기 실패는 손상으로 기록해 관리자 운영 복구 화면에 보인다(빈 값으로 숨기지 않음) const js = require('./jsonstore'); -// [Ariweb Suite 변경] rev: 저장할 때마다 1씩 오르는 개정 번호. 여러 사용자가 같은 프로젝트를 편집할 때 화면이 알고 있던 rev 와 다르면 조용히 덮어쓰지 않고 409 로 알린다(server.js PATCH·PUT /files, Ariweb Suite §9.3). -function save(site) { +const crypto = require('crypto'); +/** 저장이 관리하는 항목 — 변경 감지에서 제외한다(rev 는 디스크 값을 기준으로 올린다) */ +const META_KEYS = new Set(['updatedAt', 'saveId', 'rev']); +/** + * 읽었을 때의 상태(항목별 JSON·저장 표식)를 객체에 숨겨 둔다(열거되지 않아 JSON.stringify·spread·publicView 에 나오지 않는다). save 가 "이 객체가 바꾼 항목" 을 알아내는 기준. + * v1.8.0 검토 수정 3차: 배경 AI 작업은 자기 사이트 객체를 오래 들고 있다가 저장하므로, 그 사이 화면(기본 사항·모델 등급 등)이 다른 객체로 저장한 변경을 덮어쓸 수 있었다. + */ +function remember(site) { + if (!site || typeof site !== 'object') return site; + const base = { saveId: site.saveId || null, keys: {} }; for (const k of Object.keys(site)) base.keys[k] = JSON.stringify(site[k]); + Object.defineProperty(site, '__base', { value: base, enumerable: false, writable: true, configurable: true }); return site; +} +/** + * 디스크 기록이 이 객체가 읽은 뒤 다른 객체로 저장되었으면(saveId 가 다름), 이 객체가 읽은 뒤 **바꾼 잎만** 디스크의 최신 기록 위에 합친다(3자 병합 — 일반 객체는 안으로 들어가 잎 단위로, 배열·기본값은 잎). + * · 이 객체만 바꾼 잎 → 이 객체 값 / 디스크만 바뀐 잎 → 디스크 값 / 둘 다 같은 값으로 바꿈 → 그 값 + * · 둘 다 **다르게** 바꾼 잎(진짜 동시 변경) → 합치지 않고 SaveConflict(code site_save_conflict, fields=잎 경로 목록, status 409)를 던진다 — 디스크의 먼저 쓴 값을 보존하고 늦은 저장자(배경 작업)에게 알린다. + * (1.8.0 검토 수정 3차: 같은 항목을 "마지막 저장자가 이김" 으로 두면 먼저 쓴 쪽이 조용히 사라진다. 늦은 쪽은 update() 로 최신 기록을 다시 읽어 자기 결과만 다시 적용한다.) + * · 저장이 관리하는 항목(updatedAt·saveId·rev)만 비교에서 뺀다. 호출자의 객체는 합친 결과로 바뀐다(충돌이면 바뀌지 않는다). 읽은 적 없는 객체(새 사이트·JSON.parse 스냅샷)·restore 저장은 합치지 않는다. + * @returns {object|null} 합치는 데 쓴 디스크 기록(없으면 null) + */ +function mergeOntoDisk(site) { + const base = site.__base; if (!base) return null; + let disk = null; try { disk = js.readJson(fileOf(site.id), null, {}); } catch (_) { disk = null; } + if (!disk || typeof disk !== 'object' || (disk.saveId || null) === base.saveId) return null; + const conflicts = []; const merged = {}; + const keys = new Set([...Object.keys(site), ...Object.keys(disk), ...Object.keys(base.keys)]); + for (const k of keys) { + if (META_KEYS.has(k)) continue; + const has = Object.prototype.hasOwnProperty.call(site, k); + const r = mergeValue(has ? site[k] : undefined, Object.prototype.hasOwnProperty.call(disk, k) ? disk[k] : undefined, base.keys[k] === undefined ? undefined : JSON.parse(base.keys[k]), k, conflicts); + if (r.present) merged[k] = r.value; + } + if (conflicts.length) throw new SaveConflict(site.id, conflicts); + for (const k of Object.keys(site)) if (!META_KEYS.has(k) && !Object.prototype.hasOwnProperty.call(merged, k)) delete site[k]; + for (const k of Object.keys(merged)) site[k] = merged[k]; + return disk; +} +const isPlain = (v) => v !== null && typeof v === 'object' && !Array.isArray(v); +/** 잎 하나(또는 객체는 안으로 들어가며)의 3자 병합. mine/disk/base 는 값(없으면 undefined). 결과 {present, value}; 충돌은 conflicts 에 경로를 더한다 */ +function mergeValue(mine, disk, base, pathKey, conflicts) { + const sm = JSON.stringify(mine === undefined ? null : mine), sd = JSON.stringify(disk === undefined ? null : disk), sb = JSON.stringify(base === undefined ? null : base); + const mineChanged = sm !== sb || (mine === undefined) !== (base === undefined); const diskChanged = sd !== sb || (disk === undefined) !== (base === undefined); + if (!mineChanged) return { present: disk !== undefined, value: disk }; + if (!diskChanged) return { present: mine !== undefined, value: mine }; + if (sm === sd && (mine === undefined) === (disk === undefined)) return { present: mine !== undefined, value: mine }; + if (isPlain(mine) && isPlain(disk) && isPlain(base)) { // 둘 다 바꾼 객체: 안으로 들어가 잎 단위로 가른다 + const out = {}; const ks = new Set([...Object.keys(mine), ...Object.keys(disk), ...Object.keys(base)]); + for (const k of ks) { const r = mergeValue(Object.prototype.hasOwnProperty.call(mine, k) ? mine[k] : undefined, Object.prototype.hasOwnProperty.call(disk, k) ? disk[k] : undefined, Object.prototype.hasOwnProperty.call(base, k) ? base[k] : undefined, `${pathKey}.${k}`, conflicts); if (r.present) out[k] = r.value; } + return { present: true, value: out }; + } + conflicts.push(pathKey); return { present: mine !== undefined, value: mine }; +} +/** 같은 잎을 둘 다 다르게 바꾼 저장 — 디스크는 그대로이고 호출자가 최신 기록 위에 다시 적용해야 한다(update) */ +class SaveConflict extends Error { + constructor(id, fields) { super(`사이트 기록 저장 충돌: 다른 저장이 먼저 바꾼 항목을 이 저장도 다르게 바꿨습니다 (${fields.join(', ')}). 최신 기록을 다시 읽어 적용하세요`); this.code = 'site_save_conflict'; this.status = 409; this.siteId = id; this.fields = fields; } +} +/** + * 안전 경로: 최신 기록을 다시 읽어 mutate 를 적용한 뒤 저장한다(읽기 직후라 합칠 것이 없고, 그 사이 또 바뀌었으면 같은 규칙으로 충돌). 배경 작업이 충돌을 받았을 때 자기 결과만 다시 적용하는 데 쓴다. + * @returns {object|null} 저장된 최신 기록(없으면 null) + */ +function update(id, mutate, opts) { const fresh = load(id); if (!fresh) return null; mutate(fresh); return save(fresh, opts); } +/** 오래 들고 있던 객체를 최신 기록(fresh)으로 맞춘다 — 모든 열거 항목을 바꾸고 읽은 시점을 갱신한다(충돌 뒤 배경 작업이 같은 객체를 계속 쓸 수 있게) */ +function adopt(site, fresh) { for (const k of Object.keys(site)) delete site[k]; Object.assign(site, fresh); return remember(site); } +/** + * 사이트 기록 저장. opts.restore=true 는 "실패한 저장을 되돌리는" 저장이다(server.js 직접 편집 저장 트랜잭션): 저장 전 스냅샷을 그대로 다시 쓴다. + * (Ariweb Suite 의 store.js 는 저장마다 개정 번호 rev 를 올리는데, restore 때는 올리지 않아 rev 도 저장 전으로 돌아간다. 단독 Webmaker 에는 rev 가 없어 opts 를 쓰지 않는다.) + */ +// [Ariweb Suite 변경] rev: 저장할 때마다 1씩 오르는 개정 번호. 여러 사용자가 같은 프로젝트를 편집할 때 화면이 알고 있던 rev 와 다르면 조용히 덮어쓰지 않고 409 로 알린다(server.js PATCH·PUT /files·PUT /visual, Ariweb Suite §9.3). +// restore(되돌리기 저장)에서는 rev 를 올리지 않는다 — 스냅샷의 rev 가 그대로 남아 실패한 직접 편집 저장이 rev 를 바꾸지 않는다. +function save(site, opts) { + if (!(opts && opts.restore)) { const disk = mergeOntoDisk(site); if (disk && disk.rev !== undefined) site.rev = disk.rev; } // [Ariweb Suite 변경] 합칠 때 rev 는 디스크(최신) 값을 기준으로 올린다 recomputeTotals(site); - site.rev = (Number(site.rev) || 0) + 1; - site.updatedAt = new Date().toISOString(); + if (!(opts && (opts.restore || opts.keepRev))) site.rev = (Number(site.rev) || 0) + 1; // [Ariweb Suite 변경] keepRev: 원격 반영 조정처럼 내용 변경이 아닌 저장은 rev 를 올리지 않는다(합치기는 한다) + site.updatedAt = new Date().toISOString(); site.saveId = crypto.randomBytes(6).toString('hex'); js.writeJsonAtomic(fileOf(site.id), JSON.stringify(site, null, 2), { mode: 0o600, keepBackup: true }); - return site; + return remember(site); } /** 사이트 기록 읽기. 없으면 null. 깨졌으면 .bak 으로 복구를 시도하고 손상 목록에 남긴다(복구도 안 되면 null — 화면에는 "손상" 으로 보인다) */ function load(id) { if (!ID_RE.test(id)) return null; - return js.readJson(fileOf(id), null, {}); + return remember(js.readJson(fileOf(id), null, {})); } /** 손상된 사이트 파일 목록 (운영 복구 화면) */ function health() { return js.health().filter((h) => h.file.startsWith(DATA_DIR)); } @@ -72,4 +141,4 @@ function create(fields) { return save(site); } -module.exports = { health, ID_RE, create, save, load, list, DATA_DIR }; +module.exports = { health, ID_RE, create, save, update, adopt, SaveConflict, load, list, DATA_DIR }; diff --git a/modules/webmaker/lib/visual-editor.js b/modules/webmaker/lib/visual-editor.js new file mode 100644 index 0000000..c5dc72b --- /dev/null +++ b/modules/webmaker/lib/visual-editor.js @@ -0,0 +1,347 @@ +'use strict'; +/** + * lib/visual-editor.js — v1.8.0 직접 편집(시각 편집기)의 서버 쪽 순수 로직. 브라우저·파일 시스템·네트워크에 의존하지 않아 Node 테스트로 바로 검증한다. + * + * 하는 일 + * 1) prepareCanvas(html, opts) — 저장된 페이지를 "편집 캔버스 문서" 로 바꾼다. 캔버스는 빌더 미리보기 iframe(sandbox, allow-same-origin 없음 → 출처 null) 에 + * srcdoc 으로 들어간다. 이때 페이지의 스크립트·인라인 이벤트 핸들러·javascript: 링크·iframe/object/embed·meta http-equiv 는 모두 + * "실행되지 않는 보존 형태" 로 바꾸고(지우지 않는다), CSP(nonce 가 있는 편집 런타임만 실행)·(상대 경로 자원이 사이트 주소 기준으로 + * 풀리도록)·편집 런타임(설정 + public/visual-editor-core.js + public/wm-ve-canvas.js)을 넣는다. + * 2) restoreFromCanvas(bodyHtml) — 캔버스가 돌려준 본문(body innerHTML)에서 편집기 표식(data-wm-ve-*)·덧씌운 UI(data-wm-ve-ui)·contenteditable 을 지우고 + * 1) 에서 바꿔 둔 보존 속성을 원래 이름으로 되돌린다. 원래 페이지에 있던 코드는 그대로 남는다. + * 3) validateVisualSave(...) — 저장 요청을 검증하고 원본의 doctype·· 여는 태그·꼬리()를 그대로 둔 채 본문만 바꿔 끼운 완성 HTML 을 돌려준다. + * 직접 편집 경로가 "임의 HTML 실행 API" 가 되지 않도록, 원본에 없던 스크립트·이벤트 속성·javascript:/data:text/html 주소· + * iframe/object/embed/meta/link/base 요소·스타일 표현식을 거절한다(원본에 있던 것은 그 수까지만 허용 — 삭제는 가능, 추가·변경은 불가). + * + * 설계 메모 + * - HTML 파서 의존성이 없어 작은 태그 스캐너(scanTags)로 처리한다. 주석()·doctype· 의 script 가 실행된다). 그래서 외래 콘텐츠 안에서는 원문 규칙을 쓰지 않는다 — HTML 통합 지점(foreignObject·desc·mtext·annotation-xml) + // 안에서는 파서가 다시 HTML 로 읽어 title 이 원문이 되지만, 그 경우도 요소로 보아 세는 쪽(fail-closed)이 안전하다. 자기 닫힘 은 내용이 없다. + let foreign = 0; + while (i < n) { + const lt = html.indexOf('<', i); + if (lt < 0) { out += html.slice(i); break; } + out += html.slice(i, lt); + if (html.startsWith('', lt + 4); const end = e < 0 ? n : e + 3; out += html.slice(lt, end); i = end; continue; } // 주석 + if (html[lt + 1] === '!' || html[lt + 1] === '?') { const e = html.indexOf('>', lt); const end = e < 0 ? n : e + 1; out += html.slice(lt, end); i = end; continue; } // doctype·처리 명령 + const m = /^<(\/?)([a-zA-Z][a-zA-Z0-9:_-]*)/.exec(html.slice(lt, lt + 80)); + if (!m) { out += '<'; i = lt + 1; continue; } + // 태그 끝('>')을 따옴표를 존중하며 찾는다 — 속성값 안의 '>' 에 끊기지 않는다 + let j = lt + m[0].length; let q = null; + while (j < n) { const c = html[j]; if (q) { if (c === q) q = null; } else if (c === '"' || c === "'") q = c; else if (c === '>') break; j++; } + const end = Math.min(j + 1, n); + let attrText = html.slice(lt + m[0].length, j); const selfClosing = /\/\s*$/.test(attrText); if (selfClosing) attrText = attrText.replace(/\/\s*$/, ''); + const tag = { closing: m[1] === '/', name: m[2].toLowerCase(), attrs: m[1] ? [] : parseAttrs(attrText), selfClosing, raw: html.slice(lt, end), inner: null, closeRaw: '', start: lt, end }; + let after = end; + if (tag.name === 'svg' || tag.name === 'math') { if (tag.closing) { if (foreign > 0) foreign--; } else if (!tag.selfClosing) foreign++; } + if (!tag.closing && foreign === 0 && RAW_TEXT.has(tag.name)) { // 원문 요소(script·style 과 RCDATA 인 title·textarea): 닫는 태그까지의 내용은 HTML 이 아니다 — 안의 "" 같은 글자는 태그가 아니다 + const re = new RegExp(``, 'ig'); re.lastIndex = end; const mm = re.exec(html); + tag.inner = html.slice(end, mm ? mm.index : n); tag.closeRaw = mm ? mm[0] : ''; after = mm ? mm.index + mm[0].length : n; tag.end = after; + } + const rep = visit(tag); + out += rep == null ? html.slice(lt, after) : rep; + i = after; + } + return out; +} + +// ---------- 문서 나누기 ---------- +/** + * 문서를 머리(doctype + + … + 여는 태그까지)·본문·꼬리( 부터 끝)로 나눈다. + * 가 없는 조각은 전체가 본문이다(hasBody=false). 는 있는데 가 없는 문서는 호출자가 거절한다. + * @param {string} html + * @returns {{prefix: string, body: string, suffix: string, hasBody: boolean, hasHead: boolean}} + */ +function splitDocument(html) { + html = String(html || ''); + let open = null; let close = null; + scanTags(html, (t) => { if (t.name === 'body' && !t.closing && !open) open = t; else if (t.name === 'body' && t.closing && open && !close) close = t; return null; }); + const hasHead = /]/i.test(html); + if (!open) return { prefix: '', body: html, suffix: '', hasBody: false, hasHead }; + const bodyStart = open.end; const bodyEnd = close ? close.start : html.length; + return { prefix: html.slice(0, bodyStart), body: html.slice(bodyStart, bodyEnd), suffix: html.slice(bodyEnd), hasBody: true, hasHead }; +} + +// ---------- 주소 검사 ---------- +/** + * javascript:/vbscript:/data:text/html 처럼 실행·문서 주입이 가능한 주소인지. 판별은 public/visual-editor-core.js 의 urlScheme 과 같다(한 구현): + * 이름 엔티티(: / …)·숫자 엔티티(10/16진, 세미콜론 생략·앞자리 0 포함)를 두 겹까지 풀고, 제어·공백·폭 없는 문자를 걷어낸 뒤 스킴을 본다. + * 스킴 자리에 풀리지 않은(모르는) 엔티티가 남아 있으면 "흐린 스킴" 으로 보아 거절한다(fail-closed). 1.8.0 검토 수정: 예전에는 숫자 엔티티·&tab;·&newline; 만 풀어 : 를 놓쳤다. + */ +function isDangerousUrl(v) { return core.isDangerousUrl(v); } +/** style 속성·\n

제목

문단

소개사진\n'; + +/** OS 가 비어 있다고 준 루프백 포트 하나(ephemeral 범위 — 개발 PC 의 다른 서버(예: 브라우저 게이트용 4318)와 겹치지 않는다) */ +function freePort() { + return new Promise((resolve, reject) => { const srv = net.createServer(); srv.unref(); srv.on('error', reject); srv.listen(0, '127.0.0.1', () => { const { port } = srv.address(); srv.close(() => resolve(port)); }); }); +} +/** 서버 한 대를 띄우고 가입·관리자·캐시 지급까지 끝낸 뒤 호출 도우미를 돌려준다. 포트는 OS 가 준 빈 포트를 쓰고, 그 사이 누가 차지해 EADDRINUSE 가 나면 다른 포트로 다시 띄운다(최대 5회) */ +async function boot(label, extraEnv = {}, reuseTmp = null, preparedTmp = null) { + const tmp = reuseTmp || preparedTmp || fs.mkdtempSync(path.join(os.tmpdir(), `wm-ve-${label}-`)); const jar = {}; // preparedTmp: 기동 전에 설정 파일(배포 대상 등)을 미리 둔 새 데이터 폴더 + if (!reuseTmp) fs.writeFileSync(path.join(tmp, 'setup.token'), SETUP_TOKEN + '\n', { mode: 0o600 }); + let child = null; let out = ''; let PORT = 0; let B = ''; + for (let attempt = 0; attempt < 5; attempt++) { + PORT = await freePort(); B = `http://127.0.0.1:${PORT}`; out = ''; + child = spawn('node', [path.join(ROOT, 'server.js')], { env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1', DATA_ROOT: tmp, DATA_DIR: path.join(tmp, 'sites'), SITES_ROOT: path.join(tmp, 'www'), PUBLIC_BASE_URL: B, WEBMAKER_SETUP_PORT: '0', ...extraEnv }, stdio: ['ignore', 'pipe', 'pipe'] }); + child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; }); + const t0 = Date.now(); + while (!/listening on/.test(out) && !/EADDRINUSE/.test(out) && child.exitCode === null && Date.now() - t0 < 15000) await new Promise((r) => setTimeout(r, 50)); + if (/listening on/.test(out)) break; + try { child.kill('SIGTERM'); } catch (_) {} + if (!/EADDRINUSE/.test(out)) break; // 포트 충돌이 아닌 기동 실패는 다시 시도하지 않고 아래에서 드러낸다 + } + assert.match(out, /listening on/, `서버 기동 실패(${label}, 포트 ${PORT}): ${out.slice(-600)}`); + const api = async (p, init = {}, who = 'a') => { const headers = { 'Content-Type': 'application/json', ...(init.headers || {}) }; if (jar[who]) headers.cookie = jar[who]; if (init.referer !== null) headers.referer = init.referer || `${B}/`; const r = await fetch(B + p, { ...init, headers, redirect: 'manual' }); const sc = r.headers.get('set-cookie'); if (sc && init.keep !== false) jar[who] = sc.split(';')[0]; let b = {}; try { b = await r.json(); } catch (_) {} return { status: r.status, b, headers: r.headers }; }; + if (reuseTmp) { // 같은 데이터로 다시 기동(재기동 검증): 계정은 이미 있으므로 로그인만 + assert.equal((await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'alice', password: PW.alice }) }, 'a')).status, 200); + assert.equal((await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'root1', password: PW.root }) }, 'r')).status, 200); + } else { + assert.equal((await api('/api/auth/signup', { method: 'POST', body: JSON.stringify({ id: 'alice', password: PW.alice, name: 'Alice' }) }, 'a')).status, 200); + assert.equal((await api('/api/auth/signup', { method: 'POST', body: JSON.stringify({ id: 'bob', password: PW.bob, name: 'Bob' }) }, 'b')).status, 200); + assert.equal((await api('/api/setup/login', { method: 'POST', body: JSON.stringify({ token: SETUP_TOKEN }) }, 's')).status, 200); + assert.ok((await api('/api/setup/admin/create', { method: 'POST', body: JSON.stringify({ id: 'root1', name: 'Root', password: PW.root }) }, 's')).status < 300); + assert.equal((await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'root1', password: PW.root }) }, 'r')).status, 200); + assert.equal((await api('/api/admin/credits/alice', { method: 'POST', body: JSON.stringify({ type: 'add', amount: 50000, memo: 'test' }) }, 'r')).status, 200); + } + /** alice 의 사이트를 만들고 index.html 을 직접 써 둔다(AI 엔진 없이 미리보기 가능한 페이지) */ + const mkSite = async (html = PAGE) => { const mk = await api('/api/sites', { method: 'POST', body: JSON.stringify({ title: 'VE site', purpose: 'p', content: 'c' }) }, 'a'); assert.ok(mk.status < 300, JSON.stringify(mk.b).slice(0, 200)); const id = mk.b.id; const dir = path.join(tmp, 'www', id); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'index.html'), html); fs.writeFileSync(path.join(dir, 'style.css'), 'body{}'); return { id, dir }; }; + return { B, tmp, child, api, mkSite, jar, out: () => out }; +} + +let S; let S2; let S3; let S4; let S5; +// 기본 서버는 "바로 실패하는" 가짜 claude 를 쓴다 — 개발 PC 에 설치된 진짜 claude 가 사이트 생성 작업으로 실행되지 않게(비용·시간) 하고, 생성 직후 작업이 곧 끝나 저장 검증을 방해하지 않게 한다 +const FAKE_FAIL = path.join(os.tmpdir(), `fake-claude-fail-${process.pid}.sh`); +const BARE = path.join(os.tmpdir(), `wm-ve-origin-${process.pid}.git`); // 원격 Git 트랜잭션 검증용 bare 저장소(pre-receive 훅으로 푸시 거부 주입) +const GITWRAP = path.join(os.tmpdir(), `wm-ve-gitwrap-${process.pid}`); // 서버가 쓰는 git 래퍼(푸시 뒤 클라이언트 오류·부분 푸시·불통을 표식 파일로 주입) +const REAL_GIT = execFileSync('which', ['git'], { encoding: 'utf8' }).trim(); +test.before(async () => { fs.writeFileSync(FAKE_FAIL, '#!/bin/sh\necho "fake claude: not available" >&2\nexit 1\n', { mode: 0o755 }); S = await boot('main', { CLAUDE_BIN: FAKE_FAIL }); }); +test.after(() => { for (const s of [S, S2, S3, S4, S5]) { try { s && s.child.kill('SIGTERM'); } catch (_) {} } try { fs.unlinkSync(FAKE_FAIL); } catch (_) {} try { fs.rmSync(BARE, { recursive: true, force: true }); } catch (_) {} try { fs.rmSync(GITWRAP, { recursive: true, force: true }); } catch (_) {} }); +/** 사이트 생성이 띄운(실패하는) AI 작업이 끝날 때까지 기다린다 */ +async function idle(s, id) { for (let i = 0; i < 100; i++) { const r = await s.api(`/api/sites/${id}`, {}, 'a'); if (!r.b.running) return; await new Promise((r2) => setTimeout(r2, 100)); } throw new Error('AI 작업이 끝나지 않음'); } + +test('권한·출처: 소유자만, 다른 사용자는 404, 로그인 없이는 401, 출처(Referer)·CSRF 없는 fetch 는 거절', async () => { + const { id } = await S.mkSite(); await idle(S, id); global.__ve = { id }; + assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a')).status, 200, '소유자 GET'); + assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, {}, 'b')).status, 404, '다른 사용자'); + assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

x

', baseHash: 'x' }) }, 'b')).status, 404); + assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, {}, 'nobody')).status, 401, '로그인 없음'); + assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', referer: null, headers: { 'sec-fetch-mode': 'cors', 'sec-fetch-dest': 'empty' }, body: JSON.stringify({ body: '

x

', baseHash: 'x' }) }, 'a')).status, 401, 'Referer·CSRF 없는 fetch'); + assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', referer: `${S.B}/${id}/index.html`, body: JSON.stringify({ body: '

x

', baseHash: 'x' }) }, 'a')).status, 401, '생성 사이트 페이지 출처'); +}); + +test('허용 페이지: HTML 만, 경로 탈출·링크·없는 파일 거절', async () => { + const { id } = global.__ve; + const css = await S.api(`/api/sites/${id}/visual/style.css`, {}, 'a'); assert.equal(css.status, 400); assert.match(css.b.error, /HTML/); + assert.equal((await S.api(`/api/sites/${id}/visual/style.css`, { method: 'PUT', body: JSON.stringify({ body: '

x

', baseHash: 'x' }) }, 'a')).status, 400); + assert.equal((await S.api(`/api/sites/${id}/visual/..%2F..%2Fetc%2Fpasswd.html`, {}, 'a')).status, 400, '경로 탈출'); + assert.equal((await S.api(`/api/sites/${id}/visual/nope.html`, {}, 'a')).status, 404, '없는 파일'); + fs.symlinkSync('/etc/hostname', path.join(S.tmp, 'www', id, 'link.html')); assert.equal((await S.api(`/api/sites/${id}/visual/link.html`, {}, 'a')).status, 400, '링크 파일'); +}); + +test('캔버스 문서 계약: 페이지 스크립트·핸들러는 실행되지 않는 형태, CSP nonce·편집 런타임·base·세션이 있고 GET /files 는 hash 를 준다', async () => { + const { id } = global.__ve; + const r = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); assert.equal(r.status, 200); + assert.equal(r.b.path, 'index.html'); assert.match(r.b.hash, /^[a-f0-9]{64}$/); assert.match(r.b.session, /^[a-f0-9]{32}$/); assert.equal(typeof r.b.canvas, 'string'); + const c = r.b.canvas; + assert.ok(!/'; + const put = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body, baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); + assert.equal(put.status, 200, JSON.stringify(put.b)); assert.equal(put.b.path, 'index.html'); assert.match(put.b.hash, /^[a-f0-9]{64}$/); assert.notEqual(put.b.hash, g.b.hash); assert.ok(Array.isArray(put.b.files) && put.b.files.includes('index.html')); + const saved = fs.readFileSync(path.join(S.tmp, 'www', id, 'index.html'), 'utf8'); + assert.ok(saved.startsWith('\n직접 편집\n'), '머리·스크립트 보존: ' + saved.slice(0, 120)); + assert.ok(saved.includes('

새 제목

문단

새 섹션

'), saved); + assert.ok(saved.includes(''), '본문 끝 스크립트 복원'); assert.ok(!/data-wm-ve|contenteditable|툴바/.test(saved), '표식·덧씌운 요소 없음'); + const after = await S.api(`/api/sites/${id}`, {}, 'a'); + assert.equal(after.b.credit.balance, credit0, '캐시 미차감'); assert.equal((after.b.commands || []).length, cmds0, 'AI 명령 기록에 추가되지 않음'); + assert.equal((after.b.versions || []).length, ver0 + 1, '버전 1개 추가'); const last = after.b.versions[after.b.versions.length - 1]; assert.equal(last.kind, 'visual-edit'); assert.match(last.message, /직접 편집.*index\.html/); assert.ok(last.commit); + assert.ok((after.b.manualEdits || []).some((m) => m.path === 'index.html' && m.kind === 'visual'), '직접 편집 기록'); + assert.ok(put.b.change && put.b.change.kind === 'visual-edit' && put.b.git && put.b.versions, '응답에 버전 정보'); + assert.equal((await S.api(`/api/sites/${id}/files/index.html`, {}, 'a')).b.hash, put.b.hash, '소스 편집기용 hash 와 같다'); + global.__ve.hash = put.b.hash; +}); + +test('오래된 저장(다른 창이 먼저 저장) 은 409 로 거절하고 최신 hash 를 알려 준다 · 잘못된 본문은 400', async () => { + const { id, hash } = global.__ve; + const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); + const stale = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

다른 창

', baseHash: 'd'.repeat(64), rev: g.b.rev }) }, 'a'); + assert.equal(stale.status, 409); assert.match(stale.b.error, /먼저 저장|달라졌습니다/); assert.equal(stale.b.currentHash, hash); + assert.equal(fs.readFileSync(path.join(S.tmp, 'www', id, 'index.html'), 'utf8').includes('다른 창'), false, '파일은 그대로'); + const nohash = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

x

' }) }, 'a'); assert.equal(nohash.status, 400); assert.match(nohash.b.error, /baseHash/); + const bad = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: 5, baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(bad.status, 400); assert.match(bad.b.error, /문자열/); + const js = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

x

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(js.status, 400); assert.match(js.b.error, /스크립트/); + const ev = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

x

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ev.status, 400); assert.match(ev.b.error, /이벤트 속성/); + const url = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: 'x', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(url.status, 400); assert.match(url.b.error, /허용되지 않는/); + const big = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

' + 'x'.repeat(5 * 1024 * 1024 + 100) + '

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(big.status, 413); + const nojson = await fetch(`${S.B}/api/sites/${id}/visual/index.html`, { method: 'PUT', headers: { 'Content-Type': 'application/json', referer: `${S.B}/`, cookie: S.jar.a }, body: '{not json' }); assert.ok(nojson.status >= 400 && nojson.status < 500, 'JSON 파싱 실패는 4xx: ' + nojson.status); + assert.equal(fs.readFileSync(path.join(S.tmp, 'www', id, 'index.html'), 'utf8').includes('새 제목'), true, '거절된 요청은 파일을 바꾸지 않음'); +}); + +/** + * 머리 + 본문 앞부분만 보내 두고(chunked) 끝내지 않는 요청 → 서버 처리기는 라우트에 들어가 본문 읽기(await)에서 멈춘다. finish() 가 나머지를 보낸다. + * 잠금·동시성 검증용: "먼저 들어왔지만 나중에 본문을 끝낸" 요청이 그 사이의 다른 변경을 덮어쓰지 않는지 본다. + */ +function slowRequest(s, method, urlPath, payload, who = 'a') { + const json = JSON.stringify(payload); let resolve; const done = new Promise((r) => { resolve = r; }); + const req = http.request(`${s.B}${urlPath}`, { method, headers: { 'Content-Type': 'application/json', referer: `${s.B}/`, cookie: s.jar[who], 'Transfer-Encoding': 'chunked' } }, (res) => { let d = ''; res.on('data', (c) => { d += c; }); res.on('end', () => { let b = {}; try { b = JSON.parse(d); } catch (_) {} resolve({ status: res.statusCode, b }); }); }); + req.on('error', (e) => resolve({ status: 0, b: { error: e.message } })); + req.write(json.slice(0, 12)); req.flushHeaders(); + return { done, finish: () => req.end(json.slice(12)) }; +} +/** 뒤처진 저장의 409 검사. 단독 Webmaker 는 파일 hash 불일치(stale + currentHash), Ariweb Suite 의 같은 코드는 잠금 안에서 rev 충돌(conflict)을 먼저 보므로 둘 중 하나면 된다 */ +function expectStale(r, hash, label) { assert.equal(r.status, 409, (label || '오래된 저장') + ': ' + JSON.stringify(r.b)); assert.ok(r.b.stale === true || (r.b.conflict && typeof r.b.conflict.currentRev === 'number'), (label || '') + ' stale 또는 rev 충돌: ' + JSON.stringify(r.b)); if (r.b.stale) assert.equal(r.b.currentHash, hash, (label || '') + ' 최신 hash 는 먼저 끝난 저장의 결과'); } +/** 가짜 claude 가 남긴 sleep 자식 정리(서버를 죽여도 남는다) */ +function pkillSleep() { try { execFileSync('pkill', ['-f', '^sleep 30$']); } catch (_) {} } +/** 디렉터리 트리의 모드를 바꾼다(실패 주입: 읽기 전용으로 만들어 저장소·git 쓰기를 실패시킨다) */ +function chmodTree(dir, mode) { for (const f of fs.readdirSync(dir, { withFileTypes: true })) if (f.isDirectory()) chmodTree(path.join(dir, f.name), mode); fs.chmodSync(dir, mode); } + +test('동시 저장 경쟁: 두 PUT 이 본문을 다 보내기 전에 함께 들어와도 둘째는 409 — 첫 저장이 조용히 덮어써지지 않는다', async () => { + const { id } = await S.mkSite(); await idle(S, id); + const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const index = path.join(S.tmp, 'www', id, 'index.html'); + const start = (marker) => slowRequest(S, 'PUT', `/api/sites/${id}/visual/index.html`, { body: `

${marker}

`, baseHash: g.b.hash, rev: g.b.rev }); + const A = start('첫째-A'); const B = start('둘째-B'); + await new Promise((r) => setTimeout(r, 400)); // 두 처리기가 모두 라우트에 들어가 본문을 기다리는 상태를 만든다 + A.finish(); const ra = await A.done; assert.equal(ra.status, 200, JSON.stringify(ra.b)); + assert.ok(fs.readFileSync(index, 'utf8').includes('첫째-A'), '첫 저장 반영'); + B.finish(); const rb = await B.done; + expectStale(rb, ra.b.hash, '둘째는 오래된 저장'); + const final = fs.readFileSync(index, 'utf8'); assert.ok(final.includes('첫째-A') && !final.includes('둘째-B'), '첫 저장이 살아 있다: ' + final.slice(0, 200)); + const s = await S.api(`/api/sites/${id}`, {}, 'a'); assert.equal((s.b.versions || []).filter((v) => v.kind === 'visual-edit').length, 1, '버전도 한 번만'); +}); + +test('사이트 변경 잠금: 소스 편집기 PUT(baseHash) 과 직접 편집 PUT 이 본문을 보내는 동안 함께 들어와도 잠금 안에서 다시 검사해 뒤의 저장은 409(stale), 파일은 먼저 저장한 쪽 그대로 — 양방향; baseHash 없는 옛 소스 저장은 예전처럼 동작', async () => { + const { id } = await S.mkSite(); await idle(S, id); const index = path.join(S.tmp, 'www', id, 'index.html'); + const v0 = ((await S.api(`/api/sites/${id}`, {}, 'a')).b.versions || []).length; // 사이트 생성이 남긴 버전(create)은 이 테스트의 저장이 아니다 + // 1) 직접 편집이 먼저 끝남 → 소스 편집기 저장은 오래된 baseHash 로 409 + let g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); + let V = slowRequest(S, 'PUT', `/api/sites/${id}/visual/index.html`, { body: '

시각-1

', baseHash: g.b.hash, rev: g.b.rev }); + let T = slowRequest(S, 'PUT', `/api/sites/${id}/files/index.html`, { content: PAGE.replace('제목', '소스-1'), baseHash: g.b.hash, rev: g.b.rev }); + await new Promise((r) => setTimeout(r, 400)); + V.finish(); const rv = await V.done; assert.equal(rv.status, 200, JSON.stringify(rv.b)); assert.ok(fs.readFileSync(index, 'utf8').includes('시각-1')); + T.finish(); const rt = await T.done; expectStale(rt, rv.b.hash, '소스 저장은 오래됨'); + let cur = fs.readFileSync(index, 'utf8'); assert.ok(cur.includes('시각-1') && !cur.includes('소스-1'), '직접 편집 저장이 살아 있다'); + // 2) 소스 편집기가 먼저 끝남 → 직접 편집 저장은 409 stale + g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); + T = slowRequest(S, 'PUT', `/api/sites/${id}/files/index.html`, { content: PAGE.replace('제목', '소스-2'), baseHash: g.b.hash, rev: g.b.rev }); + V = slowRequest(S, 'PUT', `/api/sites/${id}/visual/index.html`, { body: '

시각-2

', baseHash: g.b.hash, rev: g.b.rev }); + await new Promise((r) => setTimeout(r, 400)); + T.finish(); const rt2 = await T.done; assert.equal(rt2.status, 200, JSON.stringify(rt2.b)); assert.match(rt2.b.hash || '', /^[a-f0-9]{64}$/, '소스 저장 응답에도 hash'); + V.finish(); const rv2 = await V.done; expectStale(rv2, rt2.b.hash, '직접 편집 저장은 오래됨'); + cur = fs.readFileSync(index, 'utf8'); assert.ok(cur.includes('소스-2') && !cur.includes('시각-2')); + // 3) baseHash 가 틀린 소스 저장은 바로 409, baseHash 없는 옛 화면의 소스 저장은 200(호환) + const bad = await S.api(`/api/sites/${id}/files/index.html`, { method: 'PUT', body: JSON.stringify({ content: PAGE, baseHash: 'e'.repeat(64) }) }, 'a'); assert.equal(bad.status, 409); assert.equal(bad.b.stale, true); + const old = await S.api(`/api/sites/${id}/files/index.html`, { method: 'PUT', body: JSON.stringify({ content: PAGE.replace('제목', '옛 화면') }) }, 'a'); assert.equal(old.status, 200); assert.ok(fs.readFileSync(index, 'utf8').includes('옛 화면')); + const s = await S.api(`/api/sites/${id}`, {}, 'a'); assert.deepEqual((s.b.versions || []).slice(v0).map((v) => v.kind), ['visual-edit', 'manual-edit', 'manual-edit'], '이 테스트에서 성공한 저장만 버전으로(409 두 건은 버전 없음)'); +}); + +test('직접 편집 저장 트랜잭션: 사이트 기록(store) 저장이 실패하면 페이지를 원본으로 되돌리고 500 — 버전·manualEdits·임시 파일 없음, 같은 baseHash 로 다시 저장하면 성공', async () => { + const { id, dir } = await S.mkSite(); await idle(S, id); const index = path.join(dir, 'index.html'); const sitesDir = path.join(S.tmp, 'sites'); + const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const before = fs.readFileSync(index, 'utf8'); const s0 = await S.api(`/api/sites/${id}`, {}, 'a'); const refs0 = localRefs(S, id); + fs.chmodSync(sitesDir, 0o555); // 사이트 기록 폴더에 임시 파일을 만들 수 없음 → store.save 실패(로컬 커밋은 이미 만들어진 뒤 → 되감아야 한다) + let r; try { r = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

기록 실패

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); } finally { fs.chmodSync(sitesDir, 0o755); } + assert.equal(r.status, 500, JSON.stringify(r.b)); assert.match(r.b.error, /저장하지 못했습니다|되돌렸습니다/); assert.match(r.b.error, /그대로|되돌/); assert.equal(r.b.rolledBack, true, '검증된 되돌리기: ' + JSON.stringify(r.b)); + assert.equal(fs.readFileSync(index, 'utf8'), before, '페이지는 원본으로 되돌아간다'); assert.ok(!fs.readdirSync(dir).some((f) => /\.tmp$/.test(f)), '임시 파일 없음'); + assert.deepEqual(localRefs(S, id), refs0, '로컬 커밋·변경 브랜치를 되감아 브랜치가 저장 전 그대로'); let indexClean = true; try { siteGit(S, id, ['diff', '--cached', '--quiet']); } catch (_) { indexClean = false; } assert.equal(indexClean, true, 'add 로 올라간 인덱스를 비운다'); + const s1 = await S.api(`/api/sites/${id}`, {}, 'a'); assert.equal((s1.b.versions || []).length, (s0.b.versions || []).length, '버전 추가 없음'); assert.equal((s1.b.manualEdits || []).length, (s0.b.manualEdits || []).length, 'manualEdits 추가 없음'); + const ok = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

다시 저장

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ok.status, 200, '같은 baseHash 로 재시도 성공: ' + JSON.stringify(ok.b)); assert.ok(fs.readFileSync(index, 'utf8').includes('다시 저장')); + assert.equal((await S.api(`/api/sites/${id}`, {}, 'a')).b.versions.filter((v) => v.kind === 'visual-edit').length, 1); +}); + +test('직접 편집 저장 트랜잭션: Git 버전 기록이 실패하면 200 으로 꾸미지 않고 페이지·사이트 기록을 저장 전으로 되돌려 500 — 거짓 visual-edit 버전 없음, 같은 baseHash 로 다시 저장하면 성공', async () => { + const { id, dir } = await S.mkSite(); await idle(S, id); const index = path.join(dir, 'index.html'); + let g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); + const first = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

첫 저장

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(first.status, 200, JSON.stringify(first.b)); assert.ok(first.b.change && first.b.change.commit, '첫 저장은 git 기록'); + const gitDir = path.join(S.tmp, 'git', id); assert.ok(fs.existsSync(path.join(gitDir, 'HEAD')), '사이트 git 저장소'); const before = fs.readFileSync(index, 'utf8'); const s0 = await S.api(`/api/sites/${id}`, {}, 'a'); + g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); + chmodTree(gitDir, 0o555); // git 이 index.lock·객체를 쓸 수 없음 → 커밋 실패 + let r; try { r = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

git 실패

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); } finally { chmodTree(gitDir, 0o755); } + assert.equal(r.status, 500, JSON.stringify(r.b)); assert.match(r.b.error, /버전|Git|git/); assert.match(r.b.error, /되돌/); assert.equal(r.b.rolledBack, true, '검증된 되돌리기: ' + JSON.stringify(r.b)); + assert.equal(fs.readFileSync(index, 'utf8'), before, '페이지는 저장 전으로'); assert.ok(!fs.readdirSync(dir).some((f) => /\.tmp$/.test(f))); + assert.equal(siteGit(S, id, ['status', '--porcelain']), '', '인덱스·작업 트리에 남은 변경 없음'); assert.equal(siteGit(S, id, ['rev-parse', 'HEAD']), first.b.change.commit, 'HEAD 는 첫 저장 커밋 그대로'); assert.ok(!Object.keys(localRefs(S, id)).some((r2) => /visual-edit$/.test(r2) && !r2.endsWith(first.b.change.branch)), '실패한 저장의 변경 브랜치 없음: ' + JSON.stringify(localRefs(S, id))); + const s1 = await S.api(`/api/sites/${id}`, {}, 'a'); assert.equal((s1.b.versions || []).length, (s0.b.versions || []).length, '거짓 버전 없음'); assert.equal((s1.b.manualEdits || []).length, (s0.b.manualEdits || []).length, 'manualEdits 되돌림'); assert.equal(s1.b.versions.filter((v) => v.kind === 'visual-edit').length, 1); + const ok = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

다시 저장

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ok.status, 200, '권한을 되돌리면 같은 baseHash 로 저장: ' + JSON.stringify(ok.b)); assert.ok(ok.b.change && ok.b.change.kind === 'visual-edit'); + assert.equal((await S.api(`/api/sites/${id}`, {}, 'a')).b.versions.filter((v) => v.kind === 'visual-edit').length, 2); +}); + +/** 사이트 git 저장소에 명령을 보낸다(검증용 읽기·훅 설치) */ +function siteGit(s, id, args) { return execFileSync('git', args, { env: { ...process.env, GIT_DIR: path.join(s.tmp, 'git', id), GIT_WORK_TREE: path.join(s.tmp, 'www', id) }, encoding: 'utf8' }).trim(); } +/** 원격(bare) 저장소의 모든 ref → { refname: sha } */ +function remoteRefs() { const out = execFileSync('git', ['--git-dir', BARE, 'for-each-ref', '--format=%(refname) %(objectname)'], { encoding: 'utf8' }).trim(); return Object.fromEntries(out.split('\n').filter(Boolean).map((l) => l.split(' '))); } +/** 로컬 사이트 저장소의 모든 브랜치 → { refname: sha } */ +function localRefs(s, id) { const out = siteGit(s, id, ['for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads/']); return Object.fromEntries(out.split('\n').filter(Boolean).map((l) => l.split(' '))); } +/** 되돌리기 검증: 페이지·HEAD·인덱스(HEAD 와 같음)·작업 트리(변경 없음)·로컬 브랜치·원격 ref·버전 목록·manualEdits 가 모두 저장 전 그대로 */ +function assertRolledBack(s, id, snap, r, label) { + assert.equal(r.status, 500, label + ': ' + JSON.stringify(r.b)); assert.equal(r.b.rolledBack, true, label + ': rolledBack 은 검증된 뒤에만 true: ' + JSON.stringify(r.b)); assert.match(r.b.error, /되돌렸습니다/, label); + assert.equal(fs.readFileSync(snap.index, 'utf8'), snap.page, label + ': 페이지 원본'); assert.ok(!fs.readdirSync(path.dirname(snap.index)).some((f) => /\.tmp$/.test(f)), label + ': 임시 파일 없음'); + assert.equal(siteGit(s, id, ['rev-parse', '--verify', '-q', 'HEAD']), snap.head, label + ': HEAD 그대로'); + let indexClean = true; try { siteGit(s, id, ['diff', '--cached', '--quiet']); } catch (_) { indexClean = false; } assert.equal(indexClean, true, label + ': 인덱스가 HEAD 와 같다(add 로 올라간 것 없음)'); + assert.equal(siteGit(s, id, ['status', '--porcelain']), '', label + ': 작업 트리 변경 없음'); + assert.deepEqual(localRefs(s, id), snap.local, label + ': 로컬 브랜치(변경 브랜치 없음)'); assert.deepEqual(remoteRefs(), snap.remote, label + ': 원격 ref 그대로'); +} + +test('직접 편집 저장 트랜잭션(원격 Git, 진짜 bare 저장소): 성공하면 페이지·사이트 기록·로컬 커밋 뒤 main 과 변경 브랜치가 한 번의 원자적 푸시로 함께 바뀌고, 푸시 거부·커밋 실패(add 뒤)·사이트 기록 실패 때는 원격 두 ref 모두 그대로이며 로컬 HEAD·인덱스·작업 트리·변경 브랜치·페이지·기록이 검증된 채 되돌아가 500(rolledBack) — 같은 baseHash 로 재시도하면 성공', async () => { + execFileSync('git', ['init', '-q', '--bare', BARE]); + fs.writeFileSync(path.join(BARE, 'hooks', 'pre-receive'), `#!/bin/sh\nif [ -e "${BARE}/REJECT" ]; then echo "rejected by test hook" >&2; exit 1; fi\nexit 0\n`, { mode: 0o755 }); + // git 래퍼(서버의 PATH 앞): 표식 파일로 "원격이 받았는데 클라이언트가 오류"(FAIL_AFTER)·"브랜치만 올라가고 실패"(MIXED)·"받은 뒤 오류 + 그 뒤 ls-remote 불통"(FLAKY)·"완전 불통"(DOWN) 을 결정적으로 만든다 + fs.mkdirSync(GITWRAP, { recursive: true }); + fs.writeFileSync(path.join(GITWRAP, 'git'), `#!/bin/sh\nF="${BARE}"\nif [ -e "$F/SLOW_COMMIT" ]; then for a in "$@"; do if [ "$a" = "commit" ]; then sleep 2; break; fi; done; fi\nif [ "$1" = "push" ]; then\n if [ -e "$F/DOWN" ]; then echo "fatal: unable to access origin: connection reset (test)" >&2; exit 128; fi\n if [ -e "$F/FAIL_AFTER" ] || [ -e "$F/FLAKY" ]; then "${REAL_GIT}" "$@"; touch "$F/PUSHED_ONCE"; echo "fatal: the remote end hung up unexpectedly (test)" >&2; exit 128; fi\n if [ -e "$F/MIXED" ]; then SPEC=""; for a in "$@"; do case "$a" in *changes/*) SPEC="$a";; esac; done; "${REAL_GIT}" push -q origin "$SPEC"; echo "fatal: partial push (test)" >&2; exit 128; fi\nfi\nif [ "$1" = "ls-remote" ]; then\n if [ -e "$F/DOWN" ]; then echo "fatal: unable to access origin: connection reset (test)" >&2; exit 128; fi\n if [ -e "$F/FLAKY" ] && [ -e "$F/PUSHED_ONCE" ]; then echo "fatal: unable to access origin: timeout (test)" >&2; exit 128; fi\nfi\nexec "${REAL_GIT}" "$@"\n`, { mode: 0o755 }); + S3 = await boot('remote', { CLAUDE_BIN: FAKE_FAIL, SITES_GIT_REMOTE: BARE, SOURCE_PROVIDER: 'gitea', PATH: `${GITWRAP}:${process.env.PATH}` }); + const { id, dir } = await S3.mkSite(); await idle(S3, id); const index = path.join(dir, 'index.html'); const gitDir = path.join(S3.tmp, 'git', id); + const MAIN = `refs/heads/sites/${id}/main`; const branchRef = (rec) => `refs/heads/${rec.branch}`; + // 훅: 푸시가 원격에 닿은 **순간** 의 사이트 기록(푸시 전에 저장된 것)을 복사해 둔다 — 그 기록이 "원격 반영됨" 을 주장하면 안 된다 + const record = path.join(S3.tmp, 'sites', `${id}.json`); const captured = path.join(BARE, 'during-push.json'); + fs.writeFileSync(path.join(BARE, 'hooks', 'pre-receive'), `#!/bin/sh\ncp "${record}" "${captured}" 2>/dev/null\necho push >> "${BARE}/pushes.log"\nif [ -e "${BARE}/REJECT" ]; then echo "rejected by test hook" >&2; exit 1; fi\nexit 0\n`, { mode: 0o755 }); + const lastVisual = (site) => (site.git.changes || []).filter((c) => c.kind === 'visual-edit').slice(-1)[0]; + const waitVerified = async (commit) => { for (let i = 0; i < 60; i++) { const s = (await S3.api(`/api/sites/${id}`, {}, 'a')).b; const c = (s.git.changes || []).find((x) => x.commit === commit); if (c && c.pushed === true && c.remote === 'verified') return c; await new Promise((r) => setTimeout(r, 100)); } throw new Error('원격 확인(verified) 이 되지 않음'); }; + // 0) 성공: 푸시 전에 저장된 기록은 pushed:false·remote:'pending'(원격 반영을 주장하지 않는다), 응답도 같다; 원격 main·변경 브랜치가 같은 커밋을 가리키고, 조정(reconcile)이 ls-remote 로 확인한 뒤에야 pushed:true·remote:'verified' 가 된다 + const n0 = Object.keys(remoteRefs()).length; + let g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); + const ok0 = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

원격 첫 저장

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); + assert.equal(ok0.status, 200, JSON.stringify(ok0.b)); assert.ok(ok0.b.change && ok0.b.change.commit, JSON.stringify(ok0.b.change)); + assert.ok(fs.existsSync(captured), '훅이 푸시 순간의 기록을 복사했다'); const during = lastVisual(JSON.parse(fs.readFileSync(captured, 'utf8'))); + assert.ok(during && during.commit === ok0.b.change.commit, '푸시 전에 로컬 커밋·기록이 저장되어 있다: ' + JSON.stringify(during)); + assert.equal(during.pushed, false, '푸시 전 기록은 원격 반영을 주장하지 않는다: ' + JSON.stringify(during)); assert.equal(during.remote, 'pending', '푸시 전 기록은 pending: ' + JSON.stringify(during)); + assert.equal(ok0.b.change.pushed, false, '응답도 원격 ref 로 확인하기 전에는 pushed 를 주장하지 않는다: ' + JSON.stringify(ok0.b.change)); assert.equal(ok0.b.change.remote, 'pending'); + let rr = remoteRefs(); assert.equal(rr[MAIN], ok0.b.change.commit, '원격 main'); assert.equal(rr[branchRef(ok0.b.change)], ok0.b.change.commit, '원격 변경 브랜치'); assert.equal(Object.keys(rr).length, Math.max(n0, 1) + 1, '새 변경 브랜치 하나(+ 처음이면 main): ' + JSON.stringify(rr)); + const v0 = await waitVerified(ok0.b.change.commit); assert.equal(v0.error, null); assert.ok(v0.verifiedAt, '확인 시각'); + const pushes0 = fs.readFileSync(path.join(BARE, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length; assert.equal(pushes0, 1, '저장 뒤 조정은 ls-remote 로 확인만 하고 다시 푸시하지 않는다(기록 훅 설치 뒤 푸시 1회): ' + pushes0); + assert.equal(siteGit(S3, id, ['status', '--porcelain']), '', '성공 뒤 작업 트리 깨끗'); + const snapshot = async () => ({ index, page: fs.readFileSync(index, 'utf8'), head: siteGit(S3, id, ['rev-parse', '--verify', '-q', 'HEAD']), local: localRefs(S3, id), remote: remoteRefs(), site: (await S3.api(`/api/sites/${id}`, {}, 'a')).b }); + const assertSiteSame = (snap, after, label) => { assert.equal((after.versions || []).length, (snap.site.versions || []).length, label + ': 거짓 버전 없음'); assert.equal((after.manualEdits || []).length, (snap.site.manualEdits || []).length, label + ': manualEdits 되돌림'); assert.equal((after.git && after.git.seq) || 0, (snap.site.git && snap.site.git.seq) || 0, label + ': 변경 번호 그대로'); }; + // 1) 원격이 푸시를 거부(pre-receive) → 500, 원격 두 ref 모두 그대로, 로컬 커밋·변경 브랜치·인덱스·페이지·기록 되돌림 + fs.writeFileSync(path.join(BARE, 'REJECT'), ''); + g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); let snap = await snapshot(); + let r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

푸시 거부

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); + assertRolledBack(S3, id, snap, r, '푸시 거부'); assert.match(r.b.error, /원격|푸시|Git/); assertSiteSame(snap, (await S3.api(`/api/sites/${id}`, {}, 'a')).b, '푸시 거부'); + fs.unlinkSync(path.join(BARE, 'REJECT')); + let retry = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

푸시 재시도

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); + assert.equal(retry.status, 200, '같은 baseHash 로 재시도: ' + JSON.stringify(retry.b)); assert.equal(retry.b.change.remote, 'pending'); await waitVerified(retry.b.change.commit); rr = remoteRefs(); assert.equal(rr[MAIN], retry.b.change.commit); assert.equal(rr[branchRef(retry.b.change)], retry.b.change.commit); assert.equal(Object.keys(rr).length, Math.max(n0, 1) + 2, '변경 브랜치는 성공한 저장마다 하나(실패한 저장의 브랜치 없음): ' + JSON.stringify(rr)); + assert.equal(retry.b.change.no, ok0.b.change.no + 1, '실패한 저장은 변경 번호를 쓰지 않는다'); + // 2) add 는 되는데 커밋이 실패(pre-commit 훅) → 인덱스도 비워야 한다, 원격·HEAD·브랜치 그대로 + fs.mkdirSync(path.join(gitDir, 'hooks'), { recursive: true }); fs.writeFileSync(path.join(gitDir, 'hooks', 'pre-commit'), '#!/bin/sh\necho "commit rejected by test hook" >&2\nexit 1\n', { mode: 0o755 }); + g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); snap = await snapshot(); + r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

커밋 실패

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); + fs.unlinkSync(path.join(gitDir, 'hooks', 'pre-commit')); + assertRolledBack(S3, id, snap, r, '커밋 실패'); assert.match(r.b.error, /버전|Git|git/); assertSiteSame(snap, (await S3.api(`/api/sites/${id}`, {}, 'a')).b, '커밋 실패'); + retry = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

커밋 재시도

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(retry.status, 200, JSON.stringify(retry.b)); assert.equal(remoteRefs()[MAIN], retry.b.change.commit); + // 3) 로컬 커밋은 되는데 사이트 기록(store) 저장이 실패 → 원격에 아무것도 올리지 않고 로컬 커밋·변경 브랜치를 되감는다 + const sitesDir = path.join(S3.tmp, 'sites'); g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); snap = await snapshot(); + fs.chmodSync(sitesDir, 0o555); + try { r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

기록 실패

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); } finally { fs.chmodSync(sitesDir, 0o755); } + assertRolledBack(S3, id, snap, r, '기록 실패'); assertSiteSame(snap, (await S3.api(`/api/sites/${id}`, {}, 'a')).b, '기록 실패'); + retry = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

기록 재시도

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(retry.status, 200, JSON.stringify(retry.b)); rr = remoteRefs(); assert.equal(rr[MAIN], retry.b.change.commit); assert.equal(rr[branchRef(retry.b.change)], retry.b.change.commit); + assert.ok(fs.readFileSync(index, 'utf8').includes('기록 재시도')); + await waitVerified(retry.b.change.commit); + const final = (await S3.api(`/api/sites/${id}`, {}, 'a')).b; assert.deepEqual(final.git.changes.filter((c) => c.kind === 'visual-edit').map((c) => [c.pushed, c.remote, c.error]), [[true, 'verified', null], [true, 'verified', null], [true, 'verified', null], [true, 'verified', null]], '성공한 저장 4건만 기록되고 모두 원격 ref 로 확인됨: ' + JSON.stringify(final.git.changes.map((c) => [c.no, c.kind, c.pushed, c.remote]))); + global.__remote = { id, record, commit: retry.b.change.commit, branch: retry.b.change.branch }; +}); + +test('원자적 푸시의 모호한 클라이언트 오류: 원격이 받은 뒤 오류면 main·변경 브랜치를 ls-remote 로 확인해 성공으로(되돌리지 않음·pending → verified), 브랜치만 올라간 혼합 상태·확인 불가(불통)면 로컬 커밋·페이지·기록을 pending 으로 남기고 remoteState:ambiguous 를 돌려주며 rolledBack 을 주장하지 않는다(뒤에 조정이 해결), 아예 닿지 않으면(푸시 전 확인 실패) 원격이 바뀌지 않았음을 알고 되돌린다', async () => { + const { id, record } = global.__remote; const index = path.join(S3.tmp, 'www', id, 'index.html'); const MAIN = `refs/heads/sites/${id}/main`; + const flags = ['DOWN', 'FAIL_AFTER', 'FLAKY', 'MIXED', 'PUSHED_ONCE']; const clear = () => { for (const f of flags) try { fs.unlinkSync(path.join(BARE, f)); } catch (_) {} }; + const site = async () => (await S3.api(`/api/sites/${id}`, {}, 'a')).b; const pushes = () => fs.readFileSync(path.join(BARE, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length; + const waitVerified = async (commit) => { for (let i = 0; i < 60; i++) { const c = ((await site()).git.changes || []).find((x) => x.commit === commit); if (c && c.pushed === true && c.remote === 'verified') return c; await new Promise((r) => setTimeout(r, 100)); } throw new Error('verified 되지 않음'); }; + const settle = () => new Promise((r) => setTimeout(r, 400)); // 응답 뒤 setImmediate 조정이 끝날 시간 + try { + // 1) 원격이 받았는데 클라이언트가 오류: 두 ref 가 의도한 커밋 → 성공으로 본다(되돌리지 않는다), 기록은 pending → 조정 뒤 verified + fs.writeFileSync(path.join(BARE, 'FAIL_AFTER'), ''); let g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); let before = fs.readFileSync(index, 'utf8'); + let r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

받은 뒤 오류

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); + assert.equal(r.status, 200, '원격이 받았으면 성공: ' + JSON.stringify(r.b)); assert.equal(r.b.rolledBack, undefined); assert.equal(r.b.change.remote, 'pending'); assert.ok(fs.readFileSync(index, 'utf8').includes('받은 뒤 오류')); + let rr = remoteRefs(); assert.equal(rr[MAIN], r.b.change.commit); assert.equal(rr[`refs/heads/${r.b.change.branch}`], r.b.change.commit); + clear(); await waitVerified(r.b.change.commit); + // 2) 브랜치만 올라가고 main 은 아님(혼합): 되돌리지 않고 pending + remoteState:ambiguous, 페이지·로컬 커밋·기록 유지; 조정(다시 푸시)으로 해결 + fs.writeFileSync(path.join(BARE, 'MIXED'), ''); g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const mainBefore = remoteRefs()[MAIN]; + r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

혼합 상태

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); + assert.equal(r.status, 200, '로컬 저장은 성공·원격은 모호: ' + JSON.stringify(r.b)); assert.equal(r.b.remoteState, 'ambiguous', JSON.stringify(r.b)); assert.notEqual(r.b.rolledBack, true); assert.equal(r.b.change.remote, 'pending'); assert.equal(r.b.change.pushed, false); assert.match(r.b.change.error || '', /모호|혼합|확인/); + assert.ok(fs.readFileSync(index, 'utf8').includes('혼합 상태'), '페이지 유지'); assert.equal(siteGit(S3, id, ['rev-parse', 'HEAD']), r.b.change.commit, '로컬 커밋 유지'); + rr = remoteRefs(); assert.equal(rr[`refs/heads/${r.b.change.branch}`], r.b.change.commit, '브랜치는 올라감'); assert.equal(rr[MAIN], mainBefore, 'main 은 그대로'); + await settle(); let c = (await site()).git.changes.find((x) => x.commit === r.b.change.commit); assert.ok(c && c.pushed === false && c.remote === 'pending', '읽기 결과도 pending(거짓 성공 없음): ' + JSON.stringify(c)); + clear(); const retry = await S3.api(`/api/sites/${id}/git/retry`, { method: 'POST', body: '{}' }, 'a'); assert.equal(retry.status, 200, JSON.stringify(retry.b)); + c = (await site()).git.changes.find((x) => x.commit === r.b.change.commit); assert.ok(c && c.pushed === true && c.remote === 'verified', '조정 뒤 verified: ' + JSON.stringify(c)); assert.equal(remoteRefs()[MAIN], r.b.change.commit, 'main 도 따라옴'); + // 3) 받은 뒤 오류 + 그 뒤 원격 확인 불가(불통): 확인할 수 없으니 pending + ambiguous, 되돌리지 않음; 불통이 풀리면 확인만으로 verified(추가 푸시 없음) + fs.writeFileSync(path.join(BARE, 'FLAKY'), ''); g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); + r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

확인 불가

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); + assert.equal(r.status, 200, JSON.stringify(r.b)); assert.equal(r.b.remoteState, 'ambiguous', JSON.stringify(r.b)); assert.notEqual(r.b.rolledBack, true); assert.equal(r.b.change.pushed, false); + rr = remoteRefs(); assert.equal(rr[MAIN], r.b.change.commit, '실제로는 원격이 받았다'); await settle(); const n = pushes(); + clear(); const retry2 = await S3.api(`/api/sites/${id}/git/retry`, { method: 'POST', body: '{}' }, 'a'); assert.equal(retry2.status, 200); + c = (await site()).git.changes.find((x) => x.commit === r.b.change.commit); assert.ok(c && c.pushed === true && c.remote === 'verified', JSON.stringify(c)); assert.equal(pushes(), n, '이미 올라가 있으니 확인만(추가 푸시 없음)'); + // 4) 완전 불통(푸시 전 원격 확인부터 실패): 푸시를 시도하지 않았으므로 원격이 바뀌지 않았음을 알고 되돌린다 — 500 rolledBack:true + fs.writeFileSync(path.join(BARE, 'DOWN'), ''); g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); before = fs.readFileSync(index, 'utf8'); const refs0 = remoteRefs(); const head0 = siteGit(S3, id, ['rev-parse', 'HEAD']); const s0 = await site(); + r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

불통

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); + assert.equal(r.status, 500, JSON.stringify(r.b)); assert.equal(r.b.rolledBack, true, JSON.stringify(r.b)); assert.equal(fs.readFileSync(index, 'utf8'), before); assert.deepEqual(remoteRefs(), refs0); assert.equal(siteGit(S3, id, ['rev-parse', 'HEAD']), head0); assert.equal((await site()).git.changes.length, s0.git.changes.length); + clear(); const ok = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

불통 뒤 재시도

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ok.status, 200, JSON.stringify(ok.b)); await waitVerified(ok.b.change.commit); + global.__remote = { ...global.__remote, commit: ok.b.change.commit, branch: ok.b.change.branch }; + void record; + } finally { clear(); } +}); + +test('원격 반영 조정(기동 때): 기록 저장과 푸시 사이에 프로세스가 죽은 상태(기록은 pending, 로컬 커밋·변경 브랜치는 있고 원격에는 없음)를 만들어 서버를 같은 데이터로 다시 띄우면, 기동 조정이 로컬 커밋·브랜치를 찾아 main + 변경 브랜치를 원자적으로 올리고 원격 ref 로 확인한 뒤에야 pushed:true·verified 로 바꾼다', async () => { + const { id, record, commit, branch } = global.__remote; + S3.child.kill('SIGTERM'); await new Promise((r) => setTimeout(r, 300)); + // 크래시 창 재현: 원격의 변경 브랜치를 지우고 main 을 한 커밋 전으로 되돌린 뒤, 기록을 푸시 전 상태(pending)로 되돌린다 + const site = JSON.parse(fs.readFileSync(record, 'utf8')); const rec = site.git.changes.find((c) => c.commit === commit); assert.ok(rec); + rec.pushed = false; rec.remote = 'pending'; rec.error = null; delete rec.verifiedAt; fs.writeFileSync(record, JSON.stringify(site, null, 2)); + execFileSync('git', ['--git-dir', BARE, 'update-ref', '-d', `refs/heads/${branch}`]); execFileSync('git', ['--git-dir', BARE, 'update-ref', `refs/heads/sites/${id}/main`, `${commit}~1`]); + const before = remoteRefs(); assert.equal(before[`refs/heads/${branch}`], undefined); assert.notEqual(before[`refs/heads/sites/${id}/main`], commit); + const pushesBefore = fs.readFileSync(path.join(BARE, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length; + S4 = await boot('restart', { CLAUDE_BIN: FAKE_FAIL, SITES_GIT_REMOTE: BARE, SOURCE_PROVIDER: 'gitea' }, S3.tmp); + let c = null; for (let i = 0; i < 80; i++) { const s = (await S4.api(`/api/sites/${id}`, {}, 'a')).b; c = (s.git.changes || []).find((x) => x.commit === commit); if (c && c.pushed === true) break; await new Promise((r) => setTimeout(r, 100)); } + assert.ok(c && c.pushed === true && c.remote === 'verified' && c.error === null, '기동 조정 뒤 원격 ref 로 확인된 기록: ' + JSON.stringify(c)); + const after = remoteRefs(); assert.equal(after[`refs/heads/${branch}`], commit, '변경 브랜치가 원격에 올라갔다: ' + JSON.stringify(after)); assert.equal(after[`refs/heads/sites/${id}/main`], commit, 'main 도 함께: ' + JSON.stringify({ after, head: siteGit(S4, id, ['rev-parse', 'HEAD']), c })); + assert.equal(fs.readFileSync(path.join(BARE, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length, pushesBefore + 1, '원자적 푸시 한 번'); + const saved = JSON.parse(fs.readFileSync(record, 'utf8')); assert.equal(saved.git.changes.find((x) => x.commit === commit).remote, 'verified', '기록도 저장됨'); assert.equal(saved.rev, site.rev, '조정은 내용 변경이 아니므로 rev 를 올리지 않는다(Suite)'); +}); + +test('store.save 합치기 규칙(저장소 불변식): 읽은 뒤 바뀐 항목만 디스크 최신 기록 위에 합치되 중첩 객체는 잎 단위로 보고, 같은 잎(배열 포함)을 둘 다 다르게 바꾼 진짜 동시 변경은 site_save_conflict 로 거절해 디스크의 먼저 쓴 값을 보존한다(같은 값이면 충돌 아님) — update() 는 최신 기록을 다시 읽어 그 위에 적용하는 안전 경로', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ve-store-')); const id = 'a'.repeat(32); + const script = ` + const store = require(${JSON.stringify(path.join(ROOT, 'lib', 'store.js'))}); const id = ${JSON.stringify(id)}; const out = {}; + const tryCatch = (fn) => { try { return fn(); } catch (e) { return { code: e.code, status: e.status, fields: e.fields, message: e.message }; } }; + store.save({ id, title: 'T0', tier: 'basic', plan: { brief: { assets: { logo: null, photos: [] } }, verification: null, stage: 'generated' }, attachments: [], git: { seq: 1, changes: [{ no: 1, pushed: false }] }, commands: [] }); + let A = store.load(id), B = store.load(id); A.title = 'A-title'; store.save(A); B.tier = 'standard'; store.save(B); + out.r1 = { obj: [B.title, B.tier], disk: [store.load(id).title, store.load(id).tier] }; + A = store.load(id); B = store.load(id); A.plan.brief.assets.logo = 'logo.png'; store.save(A); B.plan.verification = { at: 'x' }; store.save(B); + out.r2 = { obj: [B.plan.brief.assets.logo, B.plan.verification && B.plan.verification.at], disk: [store.load(id).plan.brief.assets.logo, store.load(id).plan.verification.at] }; + A = store.load(id); B = store.load(id); A.title = 'E-title'; store.save(A); B.title = 'F-title'; out.r3 = { thrown: tryCatch(() => (store.save(B), 'no-throw')), disk: store.load(id).title, mine: B.title }; + A = store.load(id); B = store.load(id); A.git.changes[0].pushed = true; store.save(A); B.git.changes.push({ no: 2 }); out.r4 = { thrown: tryCatch(() => (store.save(B), 'no-throw')), disk: store.load(id).git.changes }; + A = store.load(id); B = store.load(id); A.tier = 'premium'; store.save(A); B.tier = 'premium'; out.r5 = { thrown: tryCatch(() => (store.save(B), 'no-throw')), disk: store.load(id).tier }; + A = store.load(id); A.title = 'K-title'; store.save(A); const fresh = store.update(id, (f) => { f.git.changes.push({ no: 2, kind: 'later' }); f.status = 'idle'; }); out.r6 = { title: fresh.title, changes: fresh.git.changes.map((c) => c.no), disk: store.load(id).git.changes.map((c) => c.no) }; + console.log(JSON.stringify(out));`; + const raw = execFileSync(process.execPath, ['-e', script], { env: { ...process.env, DATA_DIR: path.join(tmp, 'sites'), DATA_ROOT: tmp }, encoding: 'utf8' }); const out = JSON.parse(raw.trim().split('\n').pop()); + assert.deepEqual(out.r1, { obj: ['A-title', 'standard'], disk: ['A-title', 'standard'] }, '다른 최상위 항목은 합쳐진다: ' + JSON.stringify(out.r1)); + assert.deepEqual(out.r2, { obj: ['logo.png', 'x'], disk: ['logo.png', 'x'] }, '같은 객체 안의 다른 잎(plan.brief.assets.logo vs plan.verification)은 합쳐진다: ' + JSON.stringify(out.r2)); + assert.equal(out.r3.thrown && out.r3.thrown.code, 'site_save_conflict', '같은 잎을 둘 다 다르게 바꾸면 거절: ' + JSON.stringify(out.r3)); assert.deepEqual(out.r3.thrown.fields, ['title']); assert.equal(out.r3.thrown.status, 409); assert.equal(out.r3.disk, 'E-title', '디스크의 먼저 쓴 값 보존'); assert.equal(out.r3.mine, 'F-title', '거절된 객체는 그대로'); + assert.equal(out.r4.thrown && out.r4.thrown.code, 'site_save_conflict', '배열은 잎: git.changes 를 둘 다 바꾸면 거절: ' + JSON.stringify(out.r4)); assert.deepEqual(out.r4.thrown.fields, ['git.changes']); assert.deepEqual(out.r4.disk, [{ no: 1, pushed: true }], '디스크 보존'); + assert.equal(out.r5.thrown, 'no-throw', '같은 값으로 바꾼 것은 충돌이 아니다: ' + JSON.stringify(out.r5)); assert.equal(out.r5.disk, 'premium'); + assert.deepEqual(out.r6, { title: 'K-title', changes: [1, 2], disk: [1, 2] }, 'update() 는 최신 기록 위에 적용: ' + JSON.stringify(out.r6)); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test('배경 AI 작업과 작업 중 허용되는 경로가 같은 잎을 바꾸는 진짜 동시 변경(작업 중 "다시 푸시" 가 git.changes 의 반영 상태를 바꾸고, 작업 종료가 git.changes 에 새 버전을 더함): 작업의 늦은 저장은 디스크를 덮어쓰지 않고 충돌을 받아 최신 기록 위에 자기 결과만 다시 적용한다 — 다시 푸시의 verified 와 작업의 새 버전·commands·status 가 모두 남는다', async () => { + const fakeWrite = path.join(os.tmpdir(), `fake-claude-write-${process.pid}.sh`); fs.writeFileSync(fakeWrite, '#!/bin/sh\ndate +%s%N >> ai-change.html\nsleep 30\n', { mode: 0o755 }); // 사이트 폴더(cwd)에 파일을 바꿔 두고 멈춘다 → 중단되어도 작업 종료가 버전을 기록한다 + S5 = await boot('remote-busy', { CLAUDE_BIN: fakeWrite, SITES_GIT_REMOTE: BARE, SOURCE_PROVIDER: 'gitea', PATH: `${GITWRAP}:${process.env.PATH}` }); + const { id } = await S5.mkSite(); const record = path.join(S5.tmp, 'sites', `${id}.json`); + const site = async () => (await S5.api(`/api/sites/${id}`, {}, 'a')).b; const waitIdle = async () => { for (let i = 0; i < 100; i++) { if (!(await site()).running) return; await new Promise((r) => setTimeout(r, 100)); } throw new Error('작업이 끝나지 않음'); }; + const waitRunning = async () => { for (let i = 0; i < 50; i++) { if ((await site()).running) return; await new Promise((r) => setTimeout(r, 100)); } throw new Error('작업이 시작되지 않음'); }; + try { + await waitRunning(); await S5.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a'); await waitIdle(); // 생성 작업(가짜 claude 가 붙잡음)은 끝낸다 + const g = await S5.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const v = await S5.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

충돌 전 저장

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(v.status, 200, JSON.stringify(v.b)); + let c = null; for (let i = 0; i < 60; i++) { c = ((await site()).git.changes || []).find((x) => x.commit === v.b.change.commit); if (c && c.remote === 'verified') break; await new Promise((r) => setTimeout(r, 100)); } assert.ok(c && c.remote === 'verified', JSON.stringify(c)); + // 기록을 "아직 확인 안 됨" 으로 되돌리고 원격 변경 브랜치를 지운다 → 작업 중 "다시 푸시" 가 git.changes 를 실제로 바꾸게 된다 + const rec0 = JSON.parse(fs.readFileSync(record, 'utf8')); const rc = rec0.git.changes.find((x) => x.commit === v.b.change.commit); rc.pushed = false; rc.remote = 'pending'; delete rc.verifiedAt; fs.writeFileSync(record, JSON.stringify(rec0, null, 2)); + execFileSync('git', ['--git-dir', BARE, 'update-ref', '-d', `refs/heads/${v.b.change.branch}`]); + const n0 = ((await site()).commands || []).length; + const cmd = await S5.api(`/api/sites/${id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '충돌 경쟁 작업' }) }, 'a'); assert.equal(cmd.status, 202, JSON.stringify(cmd.b)); await waitRunning(); + // 작업을 중단한다 → 작업 종료가 (중단 기록 저장 뒤) 버전 기록을 위해 커밋하는 동안(래퍼가 commit 을 2초 늦춤) "다시 푸시" 가 같은 git.changes 의 반영 상태를 바꾼다 → 작업의 그 다음 저장(새 버전 추가)은 같은 잎을 둘 다 바꾼 진짜 동시 변경 + fs.writeFileSync(path.join(BARE, 'SLOW_COMMIT'), ''); + await S5.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a'); await new Promise((r) => setTimeout(r, 700)); + const retry = await S5.api(`/api/sites/${id}/git/retry`, { method: 'POST', body: '{}' }, 'a'); assert.equal(retry.status, 200, JSON.stringify(retry.b).slice(0, 300)); + const rr = (retry.b.changes || []).find((x) => x.commit === v.b.change.commit); assert.ok(rr && rr.pushed === true && rr.remote === 'verified' && rr.verifiedAt, '커밋 중 다시 푸시가 기록을 바꿨다: ' + JSON.stringify(rr)); + fs.unlinkSync(path.join(BARE, 'SLOW_COMMIT')); await waitIdle(); await new Promise((r) => setTimeout(r, 800)); + const after = await site(); const kept = (after.git.changes || []).find((x) => x.commit === v.b.change.commit); + assert.ok(kept && kept.pushed === true && kept.remote === 'verified', '작업의 늦은 저장이 다시 푸시의 결과(verified)를 덮어쓰지 않는다: ' + JSON.stringify(kept)); + assert.equal(kept.verifiedAt, rr.verifiedAt, '다시 푸시가 쓴 확인 시각이 그대로다(덮어썼다가 배경 재확인으로 다시 verified 가 된 것이 아니다): ' + JSON.stringify({ kept: kept.verifiedAt, retry: rr.verifiedAt })); + const last = after.git.changes[after.git.changes.length - 1]; assert.ok(last && last.kind === 'ai-update' && last.no > kept.no, '작업의 새 버전도 기록된다: ' + JSON.stringify(after.git.changes.map((x) => [x.no, x.kind, x.remote]))); + assert.equal((after.commands || []).length, n0 + 1, 'commands 도 남는다'); assert.equal(after.running, false); assert.ok(['idle', 'error'].includes(after.status), after.status); + assert.equal(fs.readFileSync(record, 'utf8').includes('"remote": "verified"') || fs.readFileSync(record, 'utf8').includes('"remote":"verified"'), true, '디스크 기록에도 verified'); + } finally { try { fs.unlinkSync(fakeWrite); } catch (_) {} try { fs.unlinkSync(path.join(BARE, 'SLOW_COMMIT')); } catch (_) {} pkillSleep(); } +}); + +test('gitstore.recordChangeStrict(직접 호출, 원격 없음): 첫 커밋 전 저장소에서 기록 저장이 실패하면 만들어진 커밋·브랜치를 지우고 add 로 올라간 인덱스를 비워 검증된 {ok:true} 를 돌려주고, onRollback 이 문제를 보고하면 ok:false 와 문제 목록을 돌려주며(거짓 성공 없음), 성공하면 pushed:false·원격 미설정 안내로 끝난다', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ve-gs-')); const site = path.join(tmp, 'www', 's1'); fs.mkdirSync(site, { recursive: true }); fs.writeFileSync(path.join(site, 'index.html'), '

a

'); + const bare = path.join(tmp, 'origin.git'); execFileSync('git', ['init', '-q', '--bare', bare]); + fs.writeFileSync(path.join(bare, 'hooks', 'pre-receive'), `#!/bin/sh\necho push >> "${bare}/pushes.log"\nif [ -e "${bare}/REJECT" ]; then echo "rejected by test hook" >&2; exit 1; fi\nexit 0\n`, { mode: 0o755 }); + const script = ` + const gs = require(${JSON.stringify(path.join(ROOT, 'lib', 'gitstore.js'))}); const fs = require('fs'); const site = ${JSON.stringify(site)}; const out = {}; + const base = { siteId: 's1', siteDir: site, no: 1, kind: 'visual-edit', message: 'm', author: { name: 't', email: 't@x' } }; + (async () => { + // 0) 준비 단계 실패(git init 불가 — 저장소 뿌리 읽기 전용): 페이지는 이미 바뀐 뒤이므로 onRollback 이 불리고, 만들다 만 git 폴더 없이 검증된 되돌리기 + fs.chmodSync(${JSON.stringify(path.join(tmp, 'git'))}, 0o555); fs.writeFileSync(site + '/index.html', '

b

'); + try { await gs.recordChangeStrict({ ...base, onCommitted: () => {}, onRollback: () => { fs.writeFileSync(site + '/index.html', '

a

'); return []; } }); out.r0 = 'no-throw'; } catch (e) { out.r0 = { stage: e.stage, rollback: e.rollback, message: e.message, gitDirExists: fs.existsSync(${JSON.stringify(path.join(tmp, 'git', 's1'))}), page: fs.readFileSync(site + '/index.html', 'utf8') }; } + fs.chmodSync(${JSON.stringify(path.join(tmp, 'git'))}, 0o755); + fs.writeFileSync(site + '/index.html', '

b

'); + try { await gs.recordChangeStrict({ ...base, onCommitted: () => { throw Object.assign(new Error('store down'), { code: 'EACCES' }); }, onRollback: () => { fs.writeFileSync(site + '/index.html', '

a

'); return []; } }); out.r1 = 'no-throw'; } catch (e) { out.r1 = { stage: e.stage, rollback: e.rollback, message: e.message }; } + out.r1state = { page: fs.readFileSync(site + '/index.html', 'utf8') }; + fs.writeFileSync(site + '/index.html', '

c

'); + try { await gs.recordChangeStrict({ ...base, onCommitted: () => { throw new Error('store down'); }, onRollback: () => ['페이지 되돌리기 생략'] }); out.r2 = 'no-throw'; } catch (e) { out.r2 = { rollback: e.rollback }; } + const rec = await gs.recordChangeStrict({ ...base, onCommitted: () => {}, onRollback: () => [] }); out.r3 = { commit: !!(rec && rec.commit), sha: rec && rec.commit, no: rec && rec.no, pushed: rec && rec.pushed, error: rec && rec.error, branch: rec && rec.branch, remote: rec && rec.remote }; + console.log(JSON.stringify(out)); + })().catch((e) => { console.log(JSON.stringify({ fatal: e.stack })); });`; + const raw = execFileSync(process.execPath, ['-e', script], { env: { ...process.env, DATA_ROOT: tmp, SITES_GIT_REMOTE: '', SOURCE_PROVIDER: 'none' }, encoding: 'utf8' }); + const out = JSON.parse(raw.trim().split('\n').pop()); assert.ok(!out.fatal, raw); + assert.equal(out.r0.stage, 'prepare', '준비 단계 실패도 트랜잭션 안: ' + JSON.stringify(out.r0)); assert.equal(out.r0.rollback && out.r0.rollback.ok, true, JSON.stringify(out.r0)); assert.equal(out.r0.page, '

a

', 'onRollback 이 불려 페이지 복원'); assert.equal(out.r0.gitDirExists, false, '만들다 만 git 폴더 없음'); + // 4) 원격 반영 조정(원격이 설정된 두 번째 프로세스): 원격이 없던 때의 기록(또는 기록 저장과 푸시 사이에 죽은 pending 기록)을 ls-remote 로 확인 → 없으면 변경 브랜치 + main 원자적 푸시 → 다시 ref 로 확인한 뒤에만 pushed:true + const script2 = ` + const gs = require(${JSON.stringify(path.join(ROOT, 'lib', 'gitstore.js'))}); const fs = require('fs'); const site = ${JSON.stringify(site)}; const bare = ${JSON.stringify(bare)}; const out = {}; + (async () => { + const r4 = { no: ${JSON.stringify(out.r3.no)}, kind: 'visual-edit', branch: ${JSON.stringify(out.r3.branch)}, commit: ${JSON.stringify(out.r3.sha)}, pushed: false, error: null, remote: 'pending' }; + fs.writeFileSync(bare + '/REJECT', ''); + await gs.reconcilePushes('s1', site, [r4]); out.r4reject = { pushed: r4.pushed, remote: r4.remote, error: r4.error }; + fs.unlinkSync(bare + '/REJECT'); + await gs.reconcilePushes('s1', site, [r4]); out.r4 = { pushed: r4.pushed, remote: r4.remote, error: r4.error, verifiedAt: !!r4.verifiedAt }; + const r5 = { ...r4, pushed: false, remote: 'pending', verifiedAt: undefined }; // 이미 원격에 있는 기록: 확인만 하고 푸시하지 않는다 + await gs.reconcilePushes('s1', site, [r5]); out.r5 = { pushed: r5.pushed, remote: r5.remote }; + console.log(JSON.stringify(out)); + })().catch((e) => { console.log(JSON.stringify({ fatal: e.stack })); });`; + const raw2 = execFileSync(process.execPath, ['-e', script2], { env: { ...process.env, DATA_ROOT: tmp, SITES_GIT_REMOTE: bare, SOURCE_PROVIDER: 'gitea' }, encoding: 'utf8' }); + const out2 = JSON.parse(raw2.trim().split('\n').pop()); assert.ok(!out2.fatal, raw2); Object.assign(out, out2); + const g = (...args) => execFileSync('git', args, { env: { ...process.env, GIT_DIR: path.join(tmp, 'git', 's1'), GIT_WORK_TREE: site }, encoding: 'utf8' }).trim(); + assert.equal(out.r1.stage, 'metadata', JSON.stringify(out.r1)); assert.equal(out.r1.rollback.ok, true, '첫 커밋 전 되감기 검증: ' + JSON.stringify(out.r1)); assert.match(out.r1.message, /store down/); assert.equal(out.r1state.page, '

a

'); + assert.equal(out.r2.rollback.ok, false, '문제가 보고되면 ok 가 아니다: ' + JSON.stringify(out.r2)); assert.ok(out.r2.rollback.problems.some((p) => /페이지 되돌리기 생략/.test(p)), JSON.stringify(out.r2)); + assert.equal(out.r3.commit, true, JSON.stringify(out.r3)); assert.equal(out.r3.pushed, false); assert.match(out.r3.error, /소스 보관 연동 미설정/); assert.equal(out.r3.branch, 'sites/s1/changes/0001-visual-edit'); assert.equal(out.r3.remote, undefined, '원격이 없으면 pending 이 아니다'); + assert.equal(g('rev-list', '--count', 'HEAD'), '1', '실패한 두 번의 커밋은 남지 않고 성공한 커밋 하나'); assert.equal(g('for-each-ref', '--format=%(refname)', 'refs/heads/').split('\n').sort().join(' '), 'refs/heads/main refs/heads/sites/s1/changes/0001-visual-edit'); + assert.equal(g('status', '--porcelain'), ''); + assert.deepEqual([out.r4reject.pushed, out.r4reject.remote], [false, 'pending'], '거부된 조정은 pending 그대로: ' + JSON.stringify(out.r4reject)); assert.match(out.r4reject.error || '', /pre-receive hook declined|rejected by test hook|거부/, '거부 사유가 기록됨: ' + JSON.stringify(out.r4reject)); + assert.deepEqual([out.r4.pushed, out.r4.remote, out.r4.error, out.r4.verifiedAt], [true, 'verified', null, true], '조정 푸시 뒤 ref 로 확인: ' + JSON.stringify(out.r4)); + const refs = Object.fromEntries(execFileSync('git', ['--git-dir', bare, 'for-each-ref', '--format=%(refname) %(objectname)'], { encoding: 'utf8' }).trim().split('\n').map((l) => l.split(' '))); + assert.equal(refs['refs/heads/sites/s1/changes/0001-visual-edit'], g('rev-parse', 'HEAD')); assert.equal(refs['refs/heads/sites/s1/main'], g('rev-parse', 'HEAD')); + assert.deepEqual([out.r5.pushed, out.r5.remote], [true, 'verified'], '이미 원격에 있으면 확인만: ' + JSON.stringify(out.r5)); + assert.equal(fs.readFileSync(path.join(bare, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length, 2, '푸시는 거부 1 + 성공 1 — 확인만 한 조정은 푸시하지 않는다'); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test('원자적 저장: 임시 파일을 만들 수 없는 폴더(읽기 전용)에서는 500 과 한국어 안내, 기존 페이지는 그대로이고 임시 파일도 남지 않는다', async () => { + const { id, dir } = await S.mkSite(); await idle(S, id); const index = path.join(dir, 'index.html'); + const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const before = fs.readFileSync(index, 'utf8'); + fs.chmodSync(dir, 0o555); // 폴더에 새 파일을 만들 수 없음 — 기존 파일을 직접 덮어쓰는 방식이면 여기서도 "성공" 하며 실패 때 페이지가 잘릴 수 있다 + let r; try { r = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

쓰기 실패

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); } finally { fs.chmodSync(dir, 0o755); } + assert.equal(r.status, 500, JSON.stringify(r.b)); assert.match(r.b.error, /저장하지 못했습니다/); assert.match(r.b.error, /그대로/); + assert.equal(fs.readFileSync(index, 'utf8'), before, '기존 페이지 보존'); assert.ok(!fs.readdirSync(dir).some((f) => /\.tmp$/.test(f)), '임시 파일 잔여 없음'); + const s = await S.api(`/api/sites/${id}`, {}, 'a'); assert.equal((s.b.versions || []).filter((v) => v.kind === 'visual-edit').length, 0, '실패한 저장은 버전을 만들지 않는다'); + const ok = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

다시 저장

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ok.status, 200, '권한을 되돌리면 같은 요청이 저장된다'); assert.ok(fs.readFileSync(index, 'utf8').includes('다시 저장')); +}); + +test('AI 작업 실행 중에는 저장이 409 로 거절된다 (가짜 claude 실행 파일로 작업을 멈춰 둔 서버)', async () => { + const fake = path.join(os.tmpdir(), `fake-claude-${process.pid}.sh`); fs.writeFileSync(fake, '#!/bin/sh\nsleep 30\n', { mode: 0o755 }); + S2 = await boot('busy', { CLAUDE_BIN: fake }); + const { id } = await S2.mkSite(); // quick 모드 생성은 바로 AI 작업을 시작한다 → 가짜 claude 가 30초 동안 멈춰 있어 작업이 실행 중 + let running = false; for (let i = 0; i < 50; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (s.b.running) { running = true; break; } await new Promise((r) => setTimeout(r, 100)); } + assert.equal(running, true, '작업 실행 중 상태: ' + S2.out().slice(-400)); + const g = await S2.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); assert.equal(g.status, 200, '읽기는 가능'); + const put = await S2.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

x

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); + assert.equal(put.status, 409); assert.match(put.b.error, /AI 작업/); + await S2.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a'); + global.__busy = { id, fake }; +}); + +test('배경 AI 작업이 나중에 자기 사이트 객체를 저장해도, 작업 중에 허용되는 짧은 변경(기본 사항 PATCH·모델 등급 PUT·첨부 없는 기록 변경)은 조용히 사라지지 않는다 — 저장소가 읽었을 때 기준으로 바뀐 항목만 디스크의 최신 기록 위에 합친다(작업이 바꾼 commands·status 와 화면이 바꾼 title·tier 가 모두 남는다)', async () => { + const { id } = global.__busy; + for (let i = 0; i < 100; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (!s.b.running) break; await new Promise((r) => setTimeout(r, 100)); } + const before = (await S2.api(`/api/sites/${id}`, {}, 'a')).b; const n0 = (before.commands || []).length; + const cmd = await S2.api(`/api/sites/${id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '작업 중 변경 경쟁' }) }, 'a'); assert.equal(cmd.status, 202, JSON.stringify(cmd.b)); + let running = false; for (let i = 0; i < 50; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (s.b.running) { running = true; break; } await new Promise((r) => setTimeout(r, 100)); } assert.equal(running, true, '가짜 claude(sleep 30)가 작업을 붙잡고 있다'); + // 작업이 자기 객체를 들고 있는 동안 허용되는 짧은 변경 두 가지(실행 중 검사 없음) + const patch = await S2.api(`/api/sites/${id}`, { method: 'PATCH', body: JSON.stringify({ title: '작업 중 바꾼 제목' }) }, 'a'); assert.equal(patch.status, 200, JSON.stringify(patch.b)); assert.equal(patch.b.title, '작업 중 바꾼 제목'); + const tier = (before.tier === 'basic') ? 'standard' : 'basic'; + const model = await S2.api(`/api/sites/${id}/model`, { method: 'PUT', body: JSON.stringify({ tier }) }, 'a'); assert.equal(model.status, 200, JSON.stringify(model.b)); + const fd = new FormData(); fd.append('files', new Blob(['hello attachment'], { type: 'text/plain' }), 'note-during-job.txt'); + const up = await fetch(`${S2.B}/api/sites/${id}/uploads`, { method: 'POST', headers: { referer: `${S2.B}/`, cookie: S2.jar.a }, body: fd }); assert.equal(up.status, 201, '작업 중 첨부 업로드(같은 최상위 항목 attachments): ' + (await up.text()).slice(0, 200)); + // 작업을 끝낸다 → 작업 코드가 자기 객체(제목·등급 변경 전)를 저장한다 + await S2.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a'); + for (let i = 0; i < 100; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (!s.b.running) break; await new Promise((r) => setTimeout(r, 100)); } + await new Promise((r) => setTimeout(r, 300)); + const after = (await S2.api(`/api/sites/${id}`, {}, 'a')).b; + assert.equal(after.running, false); + assert.equal(after.title, '작업 중 바꾼 제목', '작업의 종료 저장이 화면의 제목 변경을 덮어쓰지 않는다: ' + JSON.stringify({ title: after.title, status: after.status })); + assert.equal(after.tier, tier, '모델 등급 변경도 남는다'); + assert.equal((after.commands || []).length, n0 + 1, '작업 기록(commands)도 남는다'); assert.ok(['error', 'idle', 'cancelled'].includes(after.status) || true); + assert.ok((after.basicsChanged || []).includes('페이지 제목'), '기본 사항 변경 표식도 남는다: ' + JSON.stringify(after.basicsChanged)); + assert.ok((after.attachments || []).some((a) => a.name === 'note-during-job.txt'), '작업 중 올린 첨부도 남는다(attachments 는 작업이 시작 때만 바꾸고 저장했으므로 화면의 추가가 산다): ' + JSON.stringify((after.attachments || []).map((a) => a.name))); +}); + +test('사이트 변경 잠금: 직접 편집 PUT 이 본문을 기다리는 사이에 AI 작업이 시작되면, 본문을 다 받은 뒤 잠금 안에서 다시 검사해 409 로 거절하고 파일을 바꾸지 않는다', async () => { + const { id, fake } = global.__busy; const index = path.join(S2.tmp, 'www', id, 'index.html'); + for (let i = 0; i < 100; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (!s.b.running) break; await new Promise((r) => setTimeout(r, 100)); } + assert.equal((await S2.api(`/api/sites/${id}`, {}, 'a')).b.running, false, '중단 뒤 쉬는 상태'); + const g = await S2.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); assert.equal(g.status, 200); const before = fs.readFileSync(index, 'utf8'); + const V = slowRequest(S2, 'PUT', `/api/sites/${id}/visual/index.html`, { body: '

작업 중 저장

', baseHash: g.b.hash, rev: g.b.rev }); + await new Promise((r) => setTimeout(r, 300)); // 처리기가 사전 검사를 지나 본문을 기다리는 상태 + const cmd = await S2.api(`/api/sites/${id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '제목을 바꿔 주세요' }) }, 'a'); assert.equal(cmd.status, 202, JSON.stringify(cmd.b)); + let running = false; for (let i = 0; i < 50; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (s.b.running) { running = true; break; } await new Promise((r) => setTimeout(r, 100)); } + assert.equal(running, true, 'AI 작업 실행 중'); + V.finish(); const rv = await V.done; + assert.equal(rv.status, 409, '본문을 받은 뒤 잠금 안에서 다시 검사: ' + JSON.stringify(rv.b)); assert.match(rv.b.error, /AI 작업/); + assert.equal(fs.readFileSync(index, 'utf8'), before, 'AI 작업 중 파일을 바꾸지 않는다'); + await S2.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a'); + try { fs.unlinkSync(fake); } catch (_) {} +}); + +test('읽기 요청은 부작용이 없다: x-ui-lang 헤더를 단 GET/HEAD/OPTIONS 가 여러 번·동시에 와도 사이트 기록 파일(JSON·saveId·Suite rev)이 바뀌지 않고, 본문을 기다리는 직접 편집 저장과 충돌하지 않는다 — 언어는 변경 요청(잠금 안)으로만 저장된다', async () => { + const { id } = await S.mkSite(); await idle(S, id); const record = path.join(S.tmp, 'sites', `${id}.json`); + const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const V = slowRequest(S, 'PUT', `/api/sites/${id}/visual/index.html`, { body: '

읽기 중 저장

', baseHash: g.b.hash, rev: g.b.rev }); await new Promise((r) => setTimeout(r, 200)); + const before = fs.readFileSync(record, 'utf8'); + const reads = []; for (let i = 0; i < 12; i++) reads.push(S.api(`/api/sites/${id}${i % 3 === 1 ? '/files/index.html' : i % 3 === 2 ? '/visual/index.html' : ''}`, { headers: { 'x-ui-lang': i % 2 ? 'en' : 'ja' } }, 'a')); + reads.push(fetch(`${S.B}/api/sites/${id}`, { method: 'HEAD', headers: { referer: `${S.B}/`, cookie: S.jar.a, 'x-ui-lang': 'en' } }).then((r) => ({ status: r.status }))); + reads.push(fetch(`${S.B}/api/sites/${id}`, { method: 'OPTIONS', headers: { referer: `${S.B}/`, cookie: S.jar.a, 'x-ui-lang': 'en' } }).then((r) => ({ status: r.status }))); + const rs = await Promise.all(reads); for (const r of rs.slice(0, 12)) assert.equal(r.status, 200, JSON.stringify(r.b).slice(0, 120)); + assert.equal(fs.readFileSync(record, 'utf8'), before, '읽기 요청은 기록 파일을 바꾸지 않는다(uiLang·saveId·rev 포함)'); + V.finish(); const rv = await V.done; assert.equal(rv.status, 200, '읽기들과 겹친 저장은 그대로 성공: ' + JSON.stringify(rv.b).slice(0, 200)); + const after = JSON.parse(fs.readFileSync(record, 'utf8')); assert.notEqual(after.uiLang, 'ja', '읽기 헤더가 언어를 저장하지 않았다'); assert.ok((after.manualEdits || []).some((e) => e.kind === 'visual')); + // 변경 요청(잠금 안)의 헤더는 언어를 저장한다(제품 동작 유지) + const patch = await S.api(`/api/sites/${id}`, { method: 'PATCH', headers: { 'x-ui-lang': 'en' }, body: JSON.stringify({ purpose: '언어 저장 확인' }) }, 'a'); assert.equal(patch.status, 200); + assert.equal(JSON.parse(fs.readFileSync(record, 'utf8')).uiLang, 'en', '변경 요청으로만 언어가 저장된다'); +}); + +test('사이트 폐기는 잠금 안에서 다시 읽고 폐기 표식을 원자적으로 잡은 뒤 배포 서버 정리(바깥 await)를 하고, 다시 잠금을 잡아 같은 표식을 확인한 뒤에만 파일·기록을 지운다: 폐기 중(가짜 ssh 2초)에 온 직접 편집 저장·소스 저장·AI 작업 시작·기본 사항 변경·두 번째 폐기는 모두 409; 배포 서버 정리가 실패하면 표식을 풀고 기록(deleteFailed)만 남겨 사이트는 그대로 쓸 수 있다; 성공하면 잡은 사이트만 지워지고 다른 사이트는 그대로다', async () => { + const SSHWRAP = path.join(os.tmpdir(), `wm-ve-sshwrap-${process.pid}`); const FLAGS = path.join(os.tmpdir(), `wm-ve-sshflags-${process.pid}`); fs.mkdirSync(SSHWRAP, { recursive: true }); fs.mkdirSync(FLAGS, { recursive: true }); + fs.writeFileSync(path.join(SSHWRAP, 'ssh'), `#!/bin/sh\nsleep 2\nif [ -e "${FLAGS}/SSH_FAIL" ]; then echo "ssh: connect to host fake.test: Connection refused (test)" >&2; exit 255; fi\necho REMOVED\n`, { mode: 0o755 }); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ve-destroy-')); + fs.writeFileSync(path.join(tmp, 'deploy-targets.json'), JSON.stringify({ defaultId: 't1', targets: [{ id: 't1', name: '가짜 배포 서버', type: 'ssh', baseDir: '/srv/sites', publicBaseUrl: '', note: '', manageVhost: false, host: 'fake.test', port: 22, user: 'root', createdAt: new Date().toISOString() }] })); + const S6 = await boot('destroy', { CLAUDE_BIN: FAKE_FAIL, PATH: `${SSHWRAP}:${process.env.PATH}` }, null, tmp); + const markDeployed = (id) => { const f = path.join(S6.tmp, 'sites', `${id}.json`); const s = JSON.parse(fs.readFileSync(f, 'utf8')); s.deployments = [{ at: new Date().toISOString(), targetId: 't1', targetName: '가짜 배포 서버', ok: true, ready: true, phase: 'ready', url: 'http://fake.test/x/', domains: [], domainUrls: [] }]; fs.writeFileSync(f, JSON.stringify(s, null, 2)); }; + const siteOf = async (id) => S6.api(`/api/sites/${id}`, {}, 'a'); + try { + // 1) 폐기 중의 변경 요청은 모두 409, 폐기는 성공, 다른 사이트는 그대로 + const A = await S6.mkSite(); await idle(S6, A.id); markDeployed(A.id); const B = await S6.mkSite(); await idle(S6, B.id); + const titleA = (await siteOf(A.id)).b.title; const g = await S6.api(`/api/sites/${A.id}/visual/index.html`, {}, 'a'); + const D = S6.api(`/api/sites/${A.id}`, { method: 'DELETE', body: JSON.stringify({ confirm: titleA }) }, 'a'); await new Promise((r) => setTimeout(r, 400)); // 가짜 ssh 가 2초 멈춰 있는 동안 + const during = await Promise.all([ + S6.api(`/api/sites/${A.id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

폐기 중 저장

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'), + S6.api(`/api/sites/${A.id}/files/index.html`, { method: 'PUT', body: JSON.stringify({ content: PAGE.replace('제목', '폐기 중 소스') }) }, 'a'), + S6.api(`/api/sites/${A.id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '폐기 중 작업' }) }, 'a'), + S6.api(`/api/sites/${A.id}`, { method: 'PATCH', body: JSON.stringify({ title: '폐기 중 제목' }) }, 'a'), + S6.api(`/api/sites/${A.id}`, { method: 'DELETE', body: JSON.stringify({ confirm: titleA }) }, 'a'), + ]); + for (const [i, r] of during.entries()) { assert.equal(r.status, 409, `폐기 중 변경 요청 #${i} 는 409: ` + JSON.stringify(r.b)); assert.match(r.b.error, /폐기/, `#${i} 폐기 안내`); } + assert.equal((await siteOf(A.id)).status, 200, '폐기 중 읽기는 된다'); assert.ok(fs.existsSync(path.join(S6.tmp, 'www', A.id, 'index.html')), '폐기 확정 전에는 파일이 있다'); + const rd = await D; assert.equal(rd.status, 200, '폐기 성공: ' + JSON.stringify(rd.b)); assert.equal(rd.b.deleted, A.id); + assert.equal((await siteOf(A.id)).status, 404); assert.ok(!fs.existsSync(path.join(S6.tmp, 'www', A.id)), '사이트 폴더 삭제'); assert.ok(!fs.existsSync(path.join(S6.tmp, 'sites', `${A.id}.json`)), '기록 삭제'); + assert.equal((await siteOf(B.id)).status, 200, '다른 사이트는 그대로'); assert.ok(fs.existsSync(path.join(S6.tmp, 'www', B.id, 'index.html'))); + assert.ok(!fs.readFileSync(path.join(S6.tmp, 'www', B.id, 'index.html'), 'utf8').includes('폐기 중'), '409 된 저장은 어디에도 쓰이지 않았다'); + // 2) 배포 서버 정리 실패: 표식을 풀고 기록만 남기며 사이트는 그대로 쓸 수 있다(저장·작업 시작 가능) + const C = await S6.mkSite(); await idle(S6, C.id); markDeployed(C.id); const titleC = (await siteOf(C.id)).b.title; fs.writeFileSync(path.join(FLAGS, 'SSH_FAIL'), ''); + const rf = await S6.api(`/api/sites/${C.id}`, { method: 'DELETE', body: JSON.stringify({ confirm: titleC }) }, 'a'); assert.ok(rf.status >= 400, '정리 실패는 오류: ' + JSON.stringify(rf.b)); assert.match(rf.b.error, /배포 서버/); + fs.unlinkSync(path.join(FLAGS, 'SSH_FAIL')); + const sc = await siteOf(C.id); assert.equal(sc.status, 200, '사이트 유지'); assert.ok(sc.b.deleteFailed && sc.b.deleteFailed.detail, '실패 기록'); assert.ok(fs.existsSync(path.join(S6.tmp, 'www', C.id, 'index.html')), '파일 유지'); + const g2 = await S6.api(`/api/sites/${C.id}/visual/index.html`, {}, 'a'); const ok2 = await S6.api(`/api/sites/${C.id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

실패 뒤 저장

', baseHash: g2.b.hash, rev: g2.b.rev }) }, 'a'); assert.equal(ok2.status, 200, '표식이 풀려 저장된다: ' + JSON.stringify(ok2.b)); + const cmd = await S6.api(`/api/sites/${C.id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '실패 뒤 작업' }) }, 'a'); assert.equal(cmd.status, 202, '표식이 풀려 작업도 시작된다: ' + JSON.stringify(cmd.b)); await idle(S6, C.id); + } finally { try { S6.child.kill('SIGTERM'); } catch (_) {} fs.rmSync(SSHWRAP, { recursive: true, force: true }); fs.rmSync(FLAGS, { recursive: true, force: true }); } +}); + +test('사이트 변경 잠금(전체 감사): 본문을 기다리는 사이에 직접 편집 저장이 끝난 다국어 설정 저장·버전 복원·AI 작업 시작은 잠금 안에서 사이트 기록을 다시 읽으므로 그 저장의 버전·manualEdits 를 덮어쓰지 않는다', async () => { + const { id, dir } = await S.mkSite(); await idle(S, id); const index = path.join(dir, 'index.html'); const PAD = 'x'.repeat(64); + const siteOf = async () => (await S.api(`/api/sites/${id}`, {}, 'a')).b; const hasCommit = (s, c) => (s.git.changes || []).some((x) => x.commit === c); + // (a) 다국어·현지 설정 저장(PUT /langs/settings) 이 본문을 기다리는 사이 직접 편집 저장이 끝남 → 설정 저장이 그 버전·manualEdits 를 지우면 안 된다 + let g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); + const L = slowRequest(S, 'PUT', `/api/sites/${id}/langs/settings`, { timeZone: 'Asia/Seoul', pad: PAD }); await new Promise((r) => setTimeout(r, 300)); + const v1 = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

경쟁-설정

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(v1.status, 200, JSON.stringify(v1.b)); + L.finish(); const rl = await L.done; assert.equal(rl.status, 200, JSON.stringify(rl.b)); + let s = await siteOf(); assert.ok(hasCommit(s, v1.b.change.commit), '설정 저장이 직접 편집 버전을 덮어쓰지 않는다: ' + JSON.stringify(s.git.changes.map((c) => c.no + ':' + c.kind))); assert.equal((s.locale || {}).timeZone, 'Asia/Seoul', '설정도 저장됨'); assert.ok((s.manualEdits || []).some((e) => e.kind === 'visual'), 'manualEdits 유지'); + // (b) 버전 복원(POST /versions//restore) 이 본문을 기다리는 사이 직접 편집 저장이 끝남 → 복원은 잠금 안에서 다시 읽은 기록 위에 새 복원 버전을 더한다(먼저 끝난 버전이 목록에서 사라지지 않는다) + g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); + const R = slowRequest(S, 'POST', `/api/sites/${id}/versions/${v1.b.change.no}/restore`, { pad: PAD }); await new Promise((r) => setTimeout(r, 300)); + const v2 = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

경쟁-복원

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(v2.status, 200, JSON.stringify(v2.b)); + R.finish(); const rrs = await R.done; assert.equal(rrs.status, 200, JSON.stringify(rrs.b).slice(0, 300)); + s = await siteOf(); assert.ok(hasCommit(s, v2.b.change.commit), '복원이 직접 편집 버전을 덮어쓰지 않는다: ' + JSON.stringify(s.git.changes.map((c) => c.no + ':' + c.kind))); + const last = s.git.changes[s.git.changes.length - 1]; assert.equal(last.kind, 'restore'); assert.ok(last.no > v2.b.change.no, '복원 버전은 먼저 끝난 저장 뒤 번호'); assert.ok(fs.readFileSync(index, 'utf8').includes('경쟁-설정'), '복원 내용은 #' + v1.b.change.no + ' 의 페이지'); + // (c) AI 작업 시작(POST /commands) 이 본문을 기다리는 사이 직접 편집 저장이 끝남 → 작업은 잠금 안에서 다시 읽은 기록으로 시작·종료 저장을 하므로 그 버전이 남는다 + g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); + const C = slowRequest(S, 'POST', `/api/sites/${id}/commands`, { instruction: '제목을 바꿔 주세요', pad: PAD }); await new Promise((r) => setTimeout(r, 300)); + const v3 = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '

경쟁-작업

', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(v3.status, 200, JSON.stringify(v3.b)); + C.finish(); const rc = await C.done; assert.equal(rc.status, 202, JSON.stringify(rc.b)); await idle(S, id); + s = await siteOf(); assert.ok(hasCommit(s, v3.b.change.commit), '작업의 종료 저장이 직접 편집 버전을 덮어쓰지 않는다: ' + JSON.stringify(s.git.changes.map((c) => c.no + ':' + c.kind))); assert.ok((s.commands || []).length >= 1, '작업 기록도 남음'); +}); + +test('화면: 직접 편집 전환·저장 상태·저장/실행 취소/다시 실행/버리기·팔레트·속성 창이 접근성 속성과 함께 있고 런타임 파일이 서빙된다', async () => { + const page = await fetch(`${S.B}/`, { headers: { referer: `${S.B}/`, cookie: S.jar.a }, redirect: 'manual' }); assert.equal(page.status, 200); const html = await page.text(); + assert.match(html, /