#!/usr/bin/env python3 """Studio 저장 어댑터. 승인된 최종본만 넣고, 저장한 뒤 되읽어 대조한다. **게시하지 않는다.** 이 파일에 게시 요청을 만드는 코드가 없고, 만들어 낸 요청이 게시 경로를 가리키면 `_reject_forbidden_paths` 가 막는다. 스킬 문서의 「게시 버튼을 누르지 않는다」는 문장 하나로 막던 것을 코드로 옮긴 것이다. **무인 저장은 꺼져 있다.** `UNATTENDED_SAVE_ENABLED` 가 `False` 다. 서버 권한이 `studio:read`·`studio:write` 둘뿐이라 저장할 수 있는 계정은 게시도 할 수 있고, 그래서 사람이 안 보는 사이에 저장을 돌리지 않는다. 이 값을 `True` 로 바꾸는 것은 서버에 `studio:publish` 가 갈라져 들어온 뒤의 일이다. **자격증명을 이 저장소에 적지 않는다.** 세션 쿠키와 CSRF 토큰은 환경 변수로만 받는다. 서버 계약 (tech-log-backend @ a000f87 의 src/config/openapi/studio-v1.yaml 과 소스에서 읽었다) | 무엇 | 값 | |---|---| | 만들기 | `POST /api/v1/studio/documents` → 201 | | 읽기 | `GET /api/v1/studio/documents/{id}` | | 저장 | `PUT /api/v1/studio/documents/{id}` → 200 | | 멱등 키 | `Idempotency-Key` 헤더 **필수**, 200자 이하 (`StudioIdempotency.java:34,77-88`) | | 재생 여부 | 응답 헤더 `Idempotency-Replayed` | | 낙관적 락 | `SaveDocumentCommand.expectedVersion` **필수 · minimum 1** | | 충돌 | `VERSION_CONFLICT` 409, `details.latestDocument` 에 현재 문서 전체 | | 인증 | 쿠키 `TECHLOG_SESSION` + 변경 요청에 `X-CSRF-TOKEN` | python3 scripts/studio-save.py --record <기록.md> --package python3 scripts/studio-save.py --record <기록.md> --package -o """ from __future__ import annotations import argparse import hashlib import json import os import re import sys ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # 서버에 studio:publish 가 갈라져 들어오기 전에는 무인 저장을 켜지 않는다. # 켜는 조건은 사람의 판단이지 이 파일의 기본값이 아니다 (A-studio-change-requests.md CR-001). UNATTENDED_SAVE_ENABLED = False UNATTENDED_SAVE_BLOCKED_BY = ( "CR-001 — 서버 권한이 studio:read·studio:write 둘뿐이라 저장 계정이 게시도 할 수 있다. " "studio:publish 가 갈라지기 전에는 사람이 보는 앞에서만 저장한다" ) BASE_PATH = "/api/v1/studio" FORBIDDEN_PATH = re.compile(r"/(publish|unpublish)(/|$)") MAX_KEY_LENGTH = 200 # StudioIdempotency.java:34 IDEMPOTENCY_HEADER = "Idempotency-Key" REPLAYED_HEADER = "Idempotency-Replayed" # 기록의 `##` 이름 → Studio 입력 칸. studio-form-map.md 가 정본이다 FIELD_MAP = { "CASE": {"문제": "problem", "결론": "conclusion", "검증 환경": "environment", "재현 조건": "reproduction", "본문": "bodyMarkdown"}, "CONCEPT": {"본문": "bodyMarkdown"}, "REFERENCE": {"목적": "purpose", "규칙": "rules", "적용 조건": "appliesWhen", "예외": "exceptions", "예시": "examples"}, "QUESTION": {"사실": "facts", "가정": "assumptions", "미지수": "unknowns", "제약": "constraints", "선택지": "options", "다음 검증": "nextVerification"}, "PROJECT_DECISION": {"근거": "basis", "결정문": "decision", "판단 이유": "rationale", "영향": "impact"}, } BODY_START, BODY_END = "", "" # SVG 에서 **문자열로 판정되는 것**. 사람 판단이 필요 없으므로 error 로 막는다. # `plan/01` §8 이 「SVG 의 스크립트·외부 리소스는 저장 단계의 허용 정책으로 제한한다」고 # 적었는데 그 정책이 코드로 어디에도 없었다. 화살표 방향은 사람이 봐야 하지만 # 스크립트가 들어 있는지는 안 그렇다 — 그림 쪽에서 기계가 할 수 있는 유일한 일이다 SVG_FORBIDDEN = ( (re.compile(r"<\s*script\b", re.I), "스크립트가 들어 있다"), (re.compile(r"<\s*foreignObject\b", re.I), "foreignObject 는 임의의 HTML 을 싣는다"), (re.compile(r"\son[a-z]+\s*=", re.I), "이벤트 처리기 속성이 있다"), # 따옴표는 \x22 \x27 로 적는다. 정규식 안에 두 종류를 함께 쓰면 파이썬 문자열이 먼저 닫힌다 (re.compile(r"(?:xlink:)?href\s*=\s*[\x22\x27](?:https?:)?//", re.I), "바깥 주소를 가리킨다"), (re.compile(r"<\s*(?:image|use)\b[^>]*(?:xlink:)?href\s*=\s*[\x22\x27](?!#|data:)", re.I), "바깥 리소스를 불러온다"), (re.compile(r"@import\b", re.I), "바깥 스타일을 불러온다"), ) # 그림이 **실제 흐름**을 그린 것인지 **개념 설명**인지. `plan/01` §8 이 표시하라고 적었다. # 없으면 error 가 아니라 warning 이다 — 지금 저장소의 그림 272장에 이 표시가 없고, # 없다고 전부 막으면 정상을 막는 쪽으로 넘어간다 FIGURE_KIND = re.compile( r"data-figure-kind\s*=\s*[\x22\x27](evidence|illustrative)[\x22\x27]", re.I) class Refused(Exception): """어댑터가 스스로 거절한 것. 서버 오류가 아니다.""" def inspect_svg(path: str) -> tuple[list[str], str | None]: """(막아야 할 것, 그림 종류). 종류를 못 읽으면 None.""" try: text = open(path, encoding="utf-8", errors="replace").read() except OSError as e: return [f"열지 못했다: {e}"], None hits = [why for pat, why in SVG_FORBIDDEN if pat.search(text)] m = FIGURE_KIND.search(text) return hits, (m.group(1).lower() if m else None) def inspect_assets(package: dict) -> tuple[list[dict], list[dict]]: """그림을 본다. 막는 것과 읽어야 할 것을 나눠 낸다. **바뀐 것이 무엇인지는 기계가 못 말한다. 바뀌었으니 보라는 말할 수 있다.** 화살표를 뒤집어도 `gates` 는 전부 0 이고 `warnings` 도 비어 자동 통과했다. 문장 쪽에는 다리를 놨는데 그림 쪽에는 안 놨던 자리다. """ errors, warnings = [], [] for asset in package.get("assets") or []: rel = asset.get("path", "") full = os.path.join(ROOT, rel) if not asset.get("exists") or not rel.endswith(".svg"): continue hits, kind = inspect_svg(full) for why in hits: errors.append({"id": "그림 허용 정책", "asset": rel, "detail": why}) now = _sha256_file(full) if asset.get("sha256") and now != asset["sha256"]: warnings.append({ "id": "그림이 검토 뒤에 바뀌었다", "asset": rel, "detail": f"검토 시점 {asset['sha256'][:12]} → 지금 {now[:12]}", "note": "무엇이 바뀌었는지는 이 어댑터가 말하지 못한다. " "화살표 방향·주체는 사람이 그림을 열어 봐야 안다", }) if kind is None: warnings.append({ "id": "그림 종류가 표시돼 있지 않다", "asset": rel, "detail": 'data-figure-kind="evidence" 또는 "illustrative" 가 없다', "note": "실제 흐름을 그린 것인지 개념 설명인지 못 가린다. " "설명용을 증거로 읽는 것을 막을 수 없다", }) elif kind == "illustrative": warnings.append({ "id": "설명용 그림", "asset": rel, "detail": "data-figure-kind=\"illustrative\" — 실제 흐름이 아니라 개념 설명이다", "note": "막지 않는다. 밝힌 대로 읽히는지만 검토가 본다", }) return errors, warnings def _sha256_file(path: str) -> str: with open(path, "rb") as fh: return hashlib.sha256(fh.read()).hexdigest() def _sha256_text(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() def idempotency_key(op: str, record_rel: str, payload: dict | None = None) -> str: """기록 경로에서 유도한 안정 키. 프런트는 호출마다 `crypto.randomUUID()` 로 새 키를 만든다(`local-id.ts:8`). 그래서 「만들다 타임아웃 → 다시 시도」가 문서를 둘 만든다. 서버의 멱등 기능이 정확히 막으라고 만들어진 상황을 키 정책이 빠져나간다. - **만들기**는 경로만으로 키를 만든다. 몇 번을 다시 시도해도 같은 키라 문서가 하나다. - **저장**은 경로와 보낼 내용을 함께 넣는다. 같은 내용의 재시도는 서버가 첫 결과를 재생하고, 내용이 바뀌면 새 저장이 된다. 경로만 쓰면 두 번째 저장이 첫 결과로 조용히 재생돼 버린다. """ base = _sha256_text(record_rel)[:32] key = f"studio-{op}-{base}" if payload is not None: key += "-" + _sha256_text(json.dumps(payload, ensure_ascii=False, sort_keys=True))[:32] if len(key) > MAX_KEY_LENGTH: # 서버가 200자 초과를 거절한다 raise Refused(f"멱등 키가 {len(key)}자다. 서버 상한은 {MAX_KEY_LENGTH}자") return key def _front_matter(text: str) -> dict: if not text.startswith("---"): return {} end = text.find("\n---", 3) out: dict[str, str] = {} for line in text[3:end].splitlines(): m = re.match(r"^([a-zA-Z_]+):\s*(.*)$", line) if m: out[m.group(1)] = m.group(2).strip().strip('"') return out def _sections(text: str) -> dict[str, str]: a, b = text.find(BODY_START), text.find(BODY_END) marks = [] for m in re.finditer(r"^##\s+(.+)$", text, re.M): if a >= 0 <= b and a < m.start() < b: continue marks.append((m.group(1).strip(), m.end())) out: dict[str, str] = {} for i, (name, start) in enumerate(marks): stop = marks[i + 1][1] - len(f"## {marks[i + 1][0]}") if i + 1 < len(marks) else len(text) chunk = text[start:stop] if name == "본문": chunk = chunk.replace(BODY_START, "").replace(BODY_END, "") out[name] = chunk.strip() return out def _summary(text: str) -> str: m = re.search(r"^#\s+.+$", text, re.M) if not m: return "" after = re.split(r"^##\s", text[m.end():], maxsplit=1, flags=re.M)[0] for para in (p.strip() for p in after.split("\n\n")): if para and not para.startswith("