58 Commits
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 2420fcee9f fix: 공개 문서가 실제로 그리는 주제 링크도 탐색 필터로 돌린다
주제 링크를 고쳤는데 화면은 그대로 `/topics/:slug` 로 갔다. 공개 문서의 머리말을 그리는
것은 `PublicDocumentHeader` 의 breadcrumb 이 아니라 렌더 모델의 `topic.publicPath` 이고,
같은 파일 안에서 두 자리가 같은 경로를 만들고 있었다. 한 자리만 고쳤으니 배포하고 눌러
보기 전까지는 고친 것처럼 보였다.

테스트도 이 링크를 묻지 않고 있었다. 머리말 검사가 `Case/JPA/Backend Skeleton` 이라는
글자만 확인해서, 그 글자가 어디로 가는지는 아무도 보지 않았다. 되돌려 보면 공개 문서
여섯 개가 모두 빨개진다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 23:55:19 +09:00
DongHyeonkaandClaude Opus 5 2632850322 fix: 최근 기록에 Open Question 을 싣고, 주제 링크를 탐색 필터로 돌린다
게시한 질문이 홈 최근 기록에 나오지 않았다. 계약의 `LatestEntry.entryType` 이 네 값만
허용해 백엔드가 담을 수 없었다 — design-package ef49d3a 에서 `QUESTION` 을 더했고 여기서
계약을 다시 생성한다. 포트의 `LatestRecordEntry.entryType` 도 같이 넓히고, 홈의 표기는
목록의 다른 이름들과 같은 규칙(대문자에 공백)을 따라 `OPEN QUESTION` 으로 적는다.

문서 머리말의 주제 링크는 `/topics/:slug` 로 가고 있었는데, 그 화면은 `jpa`/`authentication`
/`redis` 세 개를 하드코딩해 두고 있어 실제 주제는 무엇이든 404 가 됐다. 게시한 모든 문서의
주제 링크가 거기로 갔다.

주제 페이지를 채우는 대신 링크를 탐색 필터로 돌린다. 지금 그 페이지만 줄 수 있는 것 —
설명, 범위, 선별한 대표 기록 — 이 전부 비어 있고, Studio 의 주제 만들기는 `{name, slug}` 만
보내므로 설명을 쓸 칸조차 없다. 독자가 거기서 기대하는 것은 같은 주제의 기록 목록이고,
그것은 `/explore?topic=` 이 그대로 준다. 주제가 여러 개가 되고 설명을 쓸 수 있게 되면 그때
페이지를 만드는 것이 순서다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 23:44:20 +09:00
DongHyeonkaandClaude Opus 5 ad8f322b43 fix: 질문 머리말이 관계에 담긴 프로젝트를 읽는다
질문 상세는 프로젝트를 `question` 이 아니라 `relations.primaryProject` 에 담는다 —
Case/Reference 와 다른 자리다. `question` 에서 찾고 있었으므로 머리말의 프로젝트 칸이 늘
비어 있었다.

그 자리의 값은 `RelatedEntry` 라 `title`/`path` 를 쓴다. 머리말이 기다리는 것은
`name`/`slug` 이므로 옮겨 준다 — slug 는 경로의 마지막 마디다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 22:56:21 +09:00
DongHyeonkaandClaude Opus 5 ab4d822956 fix: 게시된 Open Question 의 공개 상세가 열리게 한다
게시한 질문의 공개 상세가 「요청을 처리하지 못했습니다」만 띄웠다. 게이트웨이가 `points`
를 `{group, items}` 배열로 읽고 `.filter` 를 불렀는데, 계약의 `QuestionPointGroup` 은
`facts`/`assumptions`/`unknowns`/`constraints` 를 키로 갖는 객체다. 객체에는 `.filter` 가
없으니 매핑이 통째로 터졌다.

목록은 이 칸들을 빈 배열로 두고 만들기 때문에 탐색에서는 멀쩡히 보였다. 그래서 "게시했는데
public 에 안 뜬다" 로만 드러났고 어느 층이 깨졌는지는 보이지 않았다. `as` 캐스트가 그
어긋남을 타입 검사에서 가렸다 — 이제 계약의 타입을 그대로 써서 모양이 바뀌면 컴파일이
먼저 막는다.

관계도 같은 종류로 어긋나 있었다. 계약이 주는 이름은 `resultCase`/`producedDecision`/
`derivedReferences` 인데 매퍼는 `derivedCases`/`projectDecisions`/`relatedQuestions` 를
찾고 있었고, 하나도 맞지 않아 이유 자리에 영문 키가 그대로 나왔다. `primaryProject` 는
관계가 아니라 이 질문이 속한 프로젝트이므로 관계 목록에서 뺀다 — 머리말이 이미 보여 준다.

이 사고가 지나간 이유는 HTTP 게이트웨이의 질문 상세 매핑을 지나는 테스트가 없었기
때문이다. 화면 테스트는 정적 픽스처 어댑터를 쓰므로 계약 모양을 한 번도 통과시키지
않는다. 계약 모양 그대로의 응답을 진짜 게이트웨이에 넣고 네 칸이 채워져 나오는지 묻는
테스트를 넣는다 — 되돌려 보면 운영에서 난 것과 같은 `points.filter is not a function`
으로 실패한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 22:50:14 +09:00
DongHyeonkaandClaude Opus 5 344a163d84 fix: 탐색 주제 필터가 slug 를 보내고, 편집기를 넓혀 두 칸이 함께 스크롤한다
주제 필터를 걸면 「조건에 맞는 공개 기록이 없습니다」만 남았다. 선택지가
`<option>{이름}</option>` 이라 값이 없어 이름이 그대로 나갔고 — `topic=OAuth/OIDC 인증
경계` — API 는 slug 로 거르므로 0건을 돌려줬다. 프로젝트 선택지는 처음부터
`value={slug}` 였고, 그래서 프로젝트만 멀쩡했다. 주제도 같은 모양으로 맞춘다.

테스트가 이 결함을 통과시킨 이유는 픽스처의 주제 이름이 `JPA`, `Authentication` 처럼
slug 와 구분되지 않는 값이어서다. 이름과 slug 가 다른 값을 쓰는 운영에서만 드러났다.
선택지의 값이 slug 인지 직접 묻는 단언을 넣는다.

`RecordFilters.topic` 은 어댑터마다 뜻이 달랐다. 정적 어댑터는 이름으로, HTTP 어댑터는
그 값을 그대로 API 에 넘겨 slug 로 걸렀다. 프로젝트가 이미 slug/제목 둘 다 받는 것과
같이 주제도 둘 다 받게 해서 두 어댑터가 같은 값을 이해하게 한다. 주제 페이지도 이름
대신 경로의 slug 로 묻는다.

「전체」를 고른 칸은 조건이 아니다. 빈 값까지 실어 보내고 있었고, URL 이 지저분해질 뿐
아니라 이 값을 그대로 API 에 넘기는 화면에서는 `topic=` 이 "slug 가 빈 문자열인 주제"로
해석되어 0건이 된다.

편집기는 미리보기를 붙박이로 두고 자체 스크롤을 줬다. 편집기를 내려도 미리보기는
제자리였고, 보려면 그 안을 따로 굴려야 했다 — 나란히 둔 이유가 둘을 같이 보는 것인데
움직임이 갈라지면 그 이점이 없다. 둘 다 페이지 스크롤을 그대로 타게 한다.

폭도 넓힌다. `.studio-main` 은 모든 Studio 화면이 1180px 를 함께 쓰는데, 본문 두 벌이
들어가야 하는 이 화면에서는 한 칸이 566px 였다. 편집기가 놓인 경우에만 1600px 로
넓히고 헤더도 같이 넓혀 좌우 끝을 맞춘다. 다른 화면은 그대로다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 17:30:11 +09:00
DongHyeonkaandClaude Opus 5 60c8c82097 fix: 편집과 미리보기를 한 화면에서 보고, 저장·게시를 아래에 고정한다
편집기는 탭이었다. 고친 것이 어떻게 보이는지 확인하려면 편집하던 자리를 화면에서
치워야 했고, 돌아오면 스크롤 위치도 잃었다. 두 패널을 나란히 두면 그 왕복이 통째로
없어진다.

작업 상태와 저장·게시는 오른쪽 세로 rail 이었다. 그 자리는 편집기와 미리보기가 함께
쓸 가로 폭을 가져갔고, 편집 칸이 길어질수록 rail 은 위에 붙은 채 본문만 멀어졌다.
화면 아래에 고정하면 폭을 돌려주면서 스크롤 위치와 무관하게 손이 닿는다.

탭을 없앴으므로 각 패널이 스스로 이름을 가져야 한다. `aria-labelledby` 로 제목을
가리켜 landmark 로 만든다 — 탭 목록이 하던 "여기는 편집, 저기는 미리보기" 안내를
대신한다. `aside` 와 「작업 상태」라는 이름은 그대로 둔다. 자리가 바뀐 것이지 이
묶음이 무엇인지가 바뀐 것은 아니다.

미리보기가 늘 떠 있게 되면서 Picker 테스트의 단언도 함께 고쳤다. 「Picker 가
좁아졌는가」를 화면 전체에 묻고 있었는데, 이제는 미리보기 쪽의 같은 Asset 까지
세게 된다. 그 김에 `.studio-asset-panel` 의 `aria-labelledby` 가 role 없는 div 에서
아무 이름도 만들지 못하던 것도 `role="group"` 으로 실효화했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 15:49:18 +09:00
DongHyeonkaandClaude Opus 5 b3119952d5 fix: 제목 아래에 문서의 요약을 그린다
Reference 머리말에 「이 기준을 쓰는 이유」와 같은 글이 나오고 있었다. Case 도 마찬가지로
바로 아래 「문제」와 같은 글을 두 번 말했다.

문서가 스스로 밝히는 한 줄 요약이 공개 응답에 없어서, 화면이 유형별 요약
(`problemSummary` / `scopeSummary`)을 대신 쓰고 있었다. Question 에는 이미 `summary` 가
있었으므로 셋이 같은 자리를 갖게 한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-25 21:01:52 +09:00
DongHyeonkaandClaude Opus 5 7211dd1a92 fix: 공개 Reference 가 계약이 주는 이름을 읽게 한다
Reference 를 공개했는데 Studio 에서는 다 보이고 공개 화면만 비어 있었다.

게이트웨이가 읽던 이름이 계약에 없는 것들이었다 — `purposeSummary`,
`applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown`. 계약이 주는 이름은
`scopeSummary`, `appliesTo`, `excludedScope` 다. 전부 undefined 로 떨어졌고, `as string`
단언 때문에 타입 검사는 아무 말도 하지 않았다.

규칙은 `content` 마크다운을 잘라 만들고 있었다. Reference 의 본문은 마크다운 한 덩어리가
아니라 제목이 붙은 규칙의 목록이고, Studio 의 편집기가 그렇게 받아 `body_markdown` 은
비워 둔다 — 자를 것이 없으니 언제나 빈 목록이었다. 계약이 구조로 주는 것을 그대로 쓴다.

값이 아니라 이름을 지키는 테스트를 둔다. 계약에서 그 칸이 사라지면 `satisfies` 가 먼저
깨진다 — 이번 결함은 값을 검사해서는 잡히지 않았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-25 19:08:37 +09:00
DongHyeonkaandClaude Opus 5 3036b8d788 feat: Ctrl+S 로 저장한다
브라우저의 Ctrl+S 는 "페이지를 파일로 저장"이다. 글을 쓰다가 그 손버릇이 나오면 저장
대화상자가 뜨고 편집한 내용은 그대로 남는다 — 저장한 줄 알고 창을 닫으면 잃는다.

문서·프로젝트·릴리즈 세 편집기에서 기본 동작을 막고 그 화면의 저장으로 돌린다.

저장할 것이 없을 때도 기본 동작은 막는다. 막지 않으면 "저장할 것이 없다"는 화면 상태와
브라우저의 저장 대화상자가 동시에 나와, 무엇이 일어났는지 알 수 없다.

Ctrl+Shift+S 와 Alt 조합은 가로채지 않는다 — 다른 뜻으로 쓰는 곳이 있다.

버튼에 단축키를 적어 둔다. 단축키는 알려 주지 않으면 없는 것과 같다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-25 17:05:14 +09:00
DongHyeonkaandClaude Opus 5 fd73bc88a1 fix: Decision 미리보기의 결정일 요구를 풀고, 화면 테스트가 실제 동작을 다시 말하게 한다
Decision 은 결정일이 없으면 미리보기가 열리지 않았다. 검증은 그것을 경고로만 다루므로
날짜 없이 게시할 수 있는데 렌더 모델이 필수로 요구했다 — 작성자는 "경고라면서 왜 안
되냐"를 만난다. 계약을 nullable 로 열고 화면이 "결정일 미정"이라고 말하게 한다.

한 칸의 실패가 화면을 통째로 날리지 않게 한다. `Promise.all([gateway.foo()])` 은 foo 가
거절하는 것만 잡는다 — 호출이 동기적으로 던지면 배열을 만드는 중에 터져 rejection
handler 를 지나지 못하고, 그러면 홈 focus 한 칸 때문에 대시보드 전체가 빈 화면이 된다.
프로젝트 편집도 같은 모양이라 함께 고친다.

`IntersectionObserver` 가 없는 환경을 견딘다. 목차는 픽스처 Case 하나에서만 쓰여 그런
환경을 만난 적이 없었는데, 모든 Case 가 목차를 받게 되면서 jsdom 에서 문서가 통째로
깨졌다. 없으면 "지금 읽는 절" 표시만 못 할 뿐이다.

픽스처의 최근 기록에서 릴리스를 뺀다. 서버의 `latestEntries` 는 공개 투영에서 고르므로
릴리스가 없고, 홈이 릴리스를 따로 읽어 합친다 — 픽스처가 넣으면 같은 릴리스가 두 번
나온다.

화면 테스트는 `test:unit` 이 아니라 `test:tech-log` 가 돌린다. 그것을 돌리지 않아 위
두 결함과, 라우트 두 개·`--body-copy`·Case 배치 통합·활동 링크 제거처럼 의도한 변경에
고정돼 있던 단언들이 23건 빨간 채로 여러 커밋을 지나갔다. 단언을 실제 동작으로 옮긴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 18:03:11 +09:00
DongHyeonkaandClaude Opus 5 e5770dfbc8 fix: Decision 미리보기 오류가 할 일을 말하게 한다
"PROJECT public path is required" 는 사실이지만 작성자가 무엇을 해야 하는지 말하지
않는다. Decision 의 공개 주소는 프로젝트 주소 아래에 있으므로, 프로젝트를 먼저
게시해야 한다는 것과 그 화면이 어디인지 적는다.

프로젝트를 고르지 않았을 때의 문구도 같이 바꾼다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 17:35:46 +09:00
DongHyeonkaandClaude Opus 5 a7fe069a6d feat: 프로젝트 활동을 게시가 남기는 로그로 바꾼다
활동을 Studio 에서 손으로 적어야 했다. 그러면 "언제 무엇을 올렸는가" 가 실제로 올린
사실과 따로 관리되고, 적기를 잊으면 타임라인에 구멍이 남는다. 게시가 곧 사건이므로
게시가 기록한다.

공개 화면에서는 링크를 없앤다. 각 줄에 그 기록으로 가는 링크가 있으면 같은 글에 닿는
길이 둘이 되고, 읽는 사람은 "기록"과 "활동"이 어떻게 다른지 매번 다시 판단해야 한다.
활동은 날짜와 무엇을 올렸는지만 말하고, 글을 읽는 자리는 기록 화면 하나로 둔다.

Studio 의 활동 칸도 읽기 전용 로그가 된다 — 만들기·고치기·지우기 폼을 걷어낸다.

맥락에서 경계와 시스템 개요 칸을 뺀다. 저장은 되지만 공개 화면 어디에도 나오지 않는다
— 프로젝트 화면이 그리는 것은 목적 하나다. 쓰는 사람에게는 어딘가 실릴 것처럼 보이는
칸이었고 실제로는 아무도 읽지 않았다. 열의 데이터는 남겨 두고 서버 값을 그대로
왕복시킨다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 15:33:43 +09:00
DongHyeonkaandClaude Opus 5 ab67eb912c fix: 릴리즈 편집 하단 버튼이 두 벌 나오던 것을 하나로 되돌린다
저장·공개·공개에서 내리기가 각각 두 개씩 나왔다.

편집 폼을 목록에서 떼어 낼 때 잘라 온 조각에 이미 그 푸터가 들어 있었는데, 새 화면을
조립하면서 같은 푸터를 한 번 더 붙였다. 둘 다 같은 핸들러를 부르므로 어느 쪽을 눌러도
동작은 했고, 그래서 화면을 열어 개수를 세기 전까지는 드러나지 않았다.

자식이 하나뿐이라 감쌀 이유가 없어진 프래그먼트도 함께 걷어낸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 02:32:12 +09:00
DongHyeonkaandClaude Opus 5 e9b866184a fix: 릴리즈 목록에서 편집 흔적을 걷어내고, 타입 검사가 실제로 돌게 한다
앞 커밋에서 편집 폼만 떼어 내고 그것을 움직이던 상태와 핸들러를 목록에 남겨 두었다.
`GuardedStudioLink` import 는 빠졌고 `navigate` 는 아예 정의된 적이 없다. 운영에서
릴리즈 목록이 ReferenceError 로 비었다.

`npx tsc --noEmit` 이 통과했기 때문에 이것을 못 봤다. 루트 tsconfig 는 `"files": []` 에
project references 만 나열하므로 그 명령은 한 파일도 검사하지 않고 성공한다 — 실제 검사는
`npm run check:types` 가 여섯 개 프로젝트를 돌며 한다. 그 명령으로 돌리자 이 저장소에
남아 있던 다른 오류도 함께 드러났다:

- `CatalogEntry` 는 `contracts/studio/contract.ts` 가 export 하지 않는다 (project-editor,
  home-focus-editor). 문서 편집기처럼 generated 에서 지역 타입으로 뽑는다.
- 라우트 파라미터는 `unknown` 으로 들어온다. `params.id` 를 그대로 문자열 자리에 넘기고
  있었다.
- `message("route.auth.returnTo", …)` 는 파라미터를 받는 키로 등록되지 않았다.
- `ReleaseIndexItem` 에는 `summary` 가 없다 — 목록 행은 마크다운을 싣지 않는다. 대신
  공개 경로를 적는다.

목록의 남은 로직도 정리한다. 새 릴리즈를 만들면 그 행을 `aria-current` 로 짚어 준다 —
목록이 길면 어느 것이 새것인지 알기 어렵다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 01:47:41 +09:00
DongHyeonkaandClaude Opus 5 84d72c4f60 feat: 릴리즈 편집을 자기 주소로 옮긴다
편집 폼이 릴리즈 목록 아래에 열렸다. 릴리즈가 열 개 스무 개로 늘면 편집하려고 목록
전체를 지나 내려가야 하고, 저장 버튼은 그보다 더 아래에 있다. 목록이 길어질수록 편집이
멀어지는 구조다.

`/studio/releases/:id` 를 연다 — 문서와 프로젝트가 각자 편집 주소를 갖는 것과 같은
이유다. 목록의 "편집" 은 토글이 아니라 링크가 되고, 새 릴리즈를 만들면 곧바로 그 화면으로
간다(만들자마자 편집할 것이 분명하다).

라우트가 하나 늘어 CI 게이트가 함께 움직였다 — 아티팩트 기준선 133→134, 증거 개수
112→113, 게이트 형태 다이제스트 재계산(f9e7e521… 을 이전 gates.json 에서 먼저 재현해
계산 방법을 확인했다), 서빙 패턴 하나 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 01:41:04 +09:00
DongHyeonkaandClaude Opus 5 6b9dc3f0a2 fix: 릴리즈 하단 버튼을 한 줄에 세우고, 로그인을 화면으로 만든다
릴리즈 편집 맨 아래에서 저장과 공개가 서로 다른 높이에 서 있었다.
`.studio-secondary-button` 는 목록 행에서 쓰려고 전역으로 `margin-top: 24px` 를 갖는데
하단 푸터에서 primary 만 그 여백을 풀어 주고 있었다 — 그래서 저장은 위에, 공개와
공개에서 내리기는 24px 아래에 앉았다.

로그인은 화면이 없었다. `ui-page` 위에 제목 한 줄과 버튼 하나가 전부라, 보호된 주소를
열었을 때 페이지처럼 보이지 않았다. 카드 하나로 세우고 eyebrow·제목·설명·행동을 갖춘
뒤, 로그인하면 어디로 돌아오는지까지 적는다 — 그것이 이 화면이 답해야 할 질문이다.

오류 화면과 다른 모양이어야 한다. 같은 모양이면 작성자는 무언가 고장 난 줄로 읽는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 00:56:16 +09:00
DongHyeonkaandClaude Opus 5 f9d20e8946 fix: setState 업데이터 안에서 event.currentTarget 을 읽지 않는다
프로젝트 편집 화면에서 활동 유형이나 제목을 한 글자 치면 화면이 통째로 죽었다 —
"Cannot read properties of null (reading 'value')".

setState 업데이터는 핸들러가 끝난 뒤 다음 렌더에 실행된다. 그때 React 는 이미
`event.currentTarget` 을 null 로 되돌려 놓았으므로, 업데이터 안에서 그것을 읽으면
반드시 터진다. 타입 검사도 lint 도 이것을 잡지 못했고, 첫 입력에서야 드러났다.

값은 핸들러가 도는 동안 지역 변수로 꺼내 두고 업데이터에는 그 값을 넘긴다.

같은 실수를 다시 못 하도록 lint 규칙을 세운다: `setXxx(...)` 에 곧바로 넘기는 화살표
함수 안에서는 `currentTarget` 을 읽을 수 없다. 처음 쓴 선택자는 동기 핸들러까지 잡아
(map 콜백, 즉시 호출되는 update 등 아홉 자리) 너무 넓었으므로, 실제로 위험한 setter
업데이터만 겨냥하도록 좁혔다. 결함을 되돌려 규칙이 잡는 것을 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 21:50:47 +09:00
DongHyeonkaandClaude Opus 5 014f21b9e1 feat: 프로젝트 주제와 활동 연결을 열고, Case 목차를 되살리고, Studio 화면을 기존 디테일에 맞춘다
주요 주제 — 프로젝트 화면은 처음부터 `project.topics` 를 읽고 있었는데 공개 계약에
그 자리가 없었다. `project_topic` 은 테이블도 있고 조인할 수도 있었지만 응답에 실을
곳이 없었고, 고를 화면도 없었다. 계약에 자리를 만들고, 편집 화면에 주제 선택을 붙이고,
공개 조회가 ACTIVE 인 주제만 고른 순서대로 내보내게 한다.

활동 위치 열기 — 그 링크는 활동의 관련 기록을 따라간다. 백엔드는 처음부터 그 경로를
유도하고 있었지만, 활동을 만들 때 기록을 고를 방법이 없어 모든 줄이 관련 기록 없이
저장됐다. 활동 추가 폼에 유형·일시·제목·요약·연결할 기록·노출을 모두 둔다.

Case 목차 — 오른쪽 목차가 어떤 문서에서도 나오지 않았다. Case 렌더러가 둘이었고
어느 쪽을 쓸지 `publicPath === "/cases/collection-fetch-join-pagination"` 이라는 슬러그
비교가 정했다. 설계 픽스처 문서 하나만 breadcrumb·목차가 있는 완성된 배치를 받고, 실제로
작성한 Case 는 전부 축약본으로 떨어졌다. 배치를 하나로 합치고, 목차는 본문에 제목이
있을 때만 그린다.

Studio UI — 새로 만든 화면들이 기존 디테일을 따라가지 않고 있었다. `<label htmlFor>` +
별도 컨트롤 대신 편집기가 쓰는 `<label><span>` 묶음을, 임시로 만든 폼 클래스 대신
`studio-editor-section`·`studio-field-grid` 를 쓴다. 릴리즈 목록 행에는 유형 라벨이
빠져 격자 첫 열이 비어 있었고(제목이 당겨져 다른 목록과 줄이 맞지 않았다) 버튼 둘이
묶이지 않은 채였다. 프로젝트 행에는 단계·공개 범위를 한국어로 적고 "내용 편집" 을
다음 행동으로 둔다.

로그인 화면 — "인증 연동 지점을 확인하기 위한 보호 라우트" 는 이 뼈대를 만들던 사람에게
하는 말이었다. 이 화면을 보는 사람은 글을 쓰러 온 작성자다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 21:22:58 +09:00
DongHyeonkaandClaude Opus 5 16e5b9f4ec feat: 프로젝트를 편집하고 활동을 남길 수 있게 한다
홈의 집중 카드가 제목만 보여 주고 "현재 목표"·"다음 작업" 칸이 비어 있었다.
프로젝트 페이지의 "활동" 도 늘 비어 있었다.

프로젝트를 만들 수는 있었지만 고칠 화면이 없었다. 그래서 이름과 slug 말고는 아무
값도 가질 수 없었고, 그 값을 읽는 공개 화면들은 빈칸을 그렸다 — 백엔드의
`updateProject` 는 처음부터 구현돼 있었고 채울 화면만 없었다.

`/studio/projects/:id` 를 연다. 프로젝트 필드 전부와 활동 목록을 한 화면에 둔다 —
프로젝트 밖의 활동은 존재하지 않고, 무엇이 공개 타임라인에 실리는지를 프로젝트 필드와
같은 자리에서 보는 편이 낫다.

이미 공개된 프로젝트는 저장한 뒤 투영도 함께 갱신한다. 투영은 게시할 때 세워지므로,
저장만 하면 공개 화면에는 예전 값이 남는다.

단계와 활동 유형의 선택지는 DB CHECK 제약과 같은 목록이다. 화면이 더 많은 값을
보여 주면 저장이 제약에서 터지고, 작성자는 왜 안 되는지 알 수 없다.

라우트가 하나 늘어 CI 게이트가 함께 움직였다 — FE-GATE-009 는 설치된 라우트마다 수동
접근성 증거를 하나씩 요구하고 그 집합이 정확히 일치하지 않으면 거절한다. 아티팩트
기준선 132→133, 증거 개수 111→112, 게이트 형태 다이제스트 재계산(이전 상수 187dbd96…
을 이전 gates.json 에서 먼저 재현해 계산 방법을 확인했다), 서빙 패턴 하나 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 19:45:00 +09:00
DongHyeonkaandClaude Opus 5 0eb3c86839 fix: 새 목록 항목의 id 가 계약을 건너갈 수 있게 한다
관계를 고르고 저장하면 400 이 돌아왔고, 편집기에는 "saveStudioDocument broke its
contract." 한 줄만 남았다. 저장은 통째로 실패했다.

새 관계·규칙·선택지·문장의 id 를 `createLocalId` 로 만들고 있었다. 그 함수는 접두사를
붙여 `relation-<uuid>` 를 돌려준다 — React key 나 Idempotency-Key 로는 맞지만 계약이
그 자리에 요구하는 것은 uuid 다. 서버는 파싱조차 못 하고 InvalidFormatException 으로
거절했다.

목록에 고를 대상이 하나도 없던 동안에는(catalog RELATION/EVIDENCE 가 스텁이었다) 아무도
이 경로를 지나지 않아 드러나지 않았다. 두 결함이 서로를 가리고 있었다.

Case 는 목록 항목이 없어 무사했다. Reference 의 규칙, Question 의 선택지, Decision 의
consequences 는 모두 같은 이유로 저장되지 않았을 것이다.

`createNewItemId` 로 나눈다. 서버는 자기가 소유하지 않은 id 를 어차피 새로 부여하므로
(`StudioRelationStore.replace`) 이 값은 "이 줄은 새것"이라는 표시일 뿐이다 — 지켜야 할
것은 형식뿐이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 19:03:27 +09:00
DongHyeonkaandClaude Opus 5 c87e0a2338 fix: home focus 경로를 계약과 맞춘다
계약이 `/api/v1/studio/home-focus` 에서 `/api/v1/studio/home/focus` 로 옮겼다 —
케밥 세그먼트가 백엔드의 AIP-122 규칙을 위반했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 18:20:32 +09:00
DongHyeonkaandClaude Opus 5 b3aa304975 feat: 프로젝트를 공개할 수 있게 하고, 홈이 무엇을 앞에 둘지 고를 수 있게 한다
공개 화면 다섯 곳이 조용히 비어 있었다. 원인은 하나씩 달랐지만 모두 "값을 채울
방법이 없었다"는 같은 모양이었다.

홈의 "지금 집중하는 것" — `home_focus_config` 는 마이그레이션이 빈 행 하나만
넣어 두었고, 계약에 선언된 `getHomeFocus`/`updateHomeFocus` 는 구현이 없었다.
세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않으므로, 운영에서는 한 번도
나타난 적이 없다. Studio 대시보드에 고르는 화면을 둔다.

홈의 "최근 기록" — 화면이 공개된 프로젝트를 하나씩 돌며 타임라인을 조립했다.
그래서 게시한 문서라도 그 프로젝트가 공개되어 있지 않으면 목록에서 통째로
빠졌고, 실제로 릴리스 한 줄만 남았다. 무엇이 최근인지는 공개 투영이 이미 알고
있으므로 그것을 그대로 읽는다. 프로젝트마다 요청을 보내던 N+1 도 사라진다.

프로젝트 공개 — 프로젝트는 `RecordKind` 에 없어 문서 게시 파이프라인을 타지
못하는데, 공개 화면들(프로젝트 목록·프로필의 "현재 프로젝트"·홈 focus)은 전부
`public_resource_projection` 의 PROJECT 행을 가시성 관문으로 쓴다. 그 행을
세우는 경로가 없었으므로 프로젝트는 영원히 비공개였다. 계약에 이미 있던
`publishProject`/`unpublishProject` 를 구현하고 주제·프로젝트 화면에 버튼을 둔다.

문서 사이 관계 연결 — `JdbcCatalogQueryAdapter` 의 RELATION/EVIDENCE 가
`List.of()` 스텁이라 어떤 기록도 연결 대상 목록을 채울 수 없었다. RELATION 은
작성 중에 고르는 것이므로 작업본까지 포함하고, EVIDENCE 는 읽는 사람이 따라갈
수 있어야 하므로 공개된 것만 포함한다.

본문 너비 — 문서 한 편이 세 폭으로 갈라져 있었다. 머리말 920px, 유형·프로젝트
줄은 shell 전체 1180px, 본문은 672px 를 가운데 정렬. 셋을 같은 폭·같은 왼쪽
끝에 세우고 읽는 단을 56rem 으로 넓힌다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 17:38:49 +09:00
DongHyeonka c03b0c77b8 fix: 오류 수정 2026-08-22 14:40:49 +09:00
DongHyeonka 1801414592 fix: show the reason the server gave for a failed action
Every failure in the management screens printed a guess. Deleting a working
copy said "it may be published, or something may reference it, or someone may
have edited it first" — three maybes, while the server had answered with
exactly one: "이 기록을 참조하는 곳이 있어 삭제할 수 없습니다". A version
conflict read as "in use" because the same sentence covered both, and an author
watching some deletions succeed and others fail had no way to tell them apart.

The gateway now carries the server's client-safe message and the screens show
it. The canned sentences remain only as a fallback for a failure that never
reached the server.

The topic status label said "사용 중" for every active topic, including one
created seconds earlier that nothing references. Next to a refusal about
records that use a topic, the two read as the same statement. It says "활성"
now, which is what the status means.

Publishing also stops demanding a finished document — the mock validator moves
with the real one, so what an author sees against fixtures matches production.
2026-08-21 19:29:04 +09:00
DongHyeonka 7345500ef3 feat: show the way to publish, and what is blocking it, in the editor
Publishing was reachable only by walking the whole chain blind. The editor
offered one link — "저장본 검증" — and the word "게시" appeared nowhere until
three screens later, so an author with a finished draft could not tell how to
publish it. The working-copy list already named the next step, but the name was
plain text with nowhere to go.

The editor now shows the whole path: 검증 → 미리보기 → 게시, each a link. The
list's next step is a link to that step. Neither weakens the gates — an
unvalidated document is still refused at preview, an unpreviewed one at
publish. What changes is that the order stops being a secret.

What is blocking publication now appears where it gets fixed. The validation
report lived on its own screen, so an author read the list, navigated back, and
had to remember which field each item meant. The editor shows the same issues
above the fields, in red, and says plainly when they describe an older saved
version rather than the current one.

The clock is read during render, not captured as an effect dependency. It is a
new function on every render of the provider, so depending on it refetched the
document endlessly — the editor never settled, and a tab click did not even
register. A value you are asking about now does not belong in a dependency
array.
2026-08-21 18:43:10 +09:00
DongHyeonka fb478f951b fix: say which version a stale validation judged, before showing its errors
The validation screen presented a report from an earlier version with the same
weight as a current one — same status badge, same full error list — and marked
the difference with two small words. An author read a version 2 report on a
version 5 document and understood those errors as the document's present state.
Every error in that list had already been fixed.

The report now leads with what it is: which version it judged, that the
document has changed since, and where to re-run it. The badge stops claiming a
verdict and the issue list recedes, because a judgement about an older version
is not a verdict about this one.
2026-08-21 18:10:51 +09:00
DongHyeonka 7289ce97bb fix: stop the smoke sweep reporting an expected 404 as a failure
A document with no preview yet answers 404 when the screen asks for its current
one, and the screen turns that into "make a preview". The sweep counted it as a
failure, so every run ended with the same red line under a healthy deployment.

A check that cries wolf on every run stops being read, and a real failure would
have sat unnoticed beside it. The session probe's 401 before sign-in is the
same kind of expected answer and is excluded on the same terms; every other
4xx and 5xx still fails the sweep.
2026-08-21 18:00:14 +09:00
DongHyeonka 348420618d fix: stop claiming a 1x1 size for an image whose dimensions are unknown
An uploaded figure never appeared, and no request for it was ever made. The
resolver substituted 1x1 when an asset carried no dimensions, and a 1x1 box
with `loading="lazy"` never enters the viewport — so the browser had no reason
to fetch it. The image was not failing to load; it was never asked for.

Unknown dimensions now say so. The figure omits the attributes and loads
eagerly, letting the browser size the image from the file, and reserves layout
space only when the size is actually known. Guessing a number to fill an
attribute is what turned a missing measurement into a missing picture.
2026-08-21 17:57:10 +09:00
DongHyeonka 89a73c13c6 feat: delete a decision, manage assets while writing, and sweep before deploying
Four things an author could not do, and the check that should have caught them.

A Decision could not be deleted. Case, Reference and Question all could, so an
author who opened a decision draft had no way to close it. The contract gained
the operation and the list now offers it for every kind. Its path carries the
project because a decision belongs to one; a row with no project says so rather
than failing.

Assets could only be managed by leaving the document. The picker now deletes
one in place — the server still refuses an asset a document uses — so a
mistaken upload does not cost the author their editing session.

Zoom was decided for the author and could not be changed: only a DIAGRAM got
it, so a screenshot uploaded as an image or attachment went in with zoom off
and no way to turn it on. It now defaults on for images and the picker offers
the choice. The toggle is a picker control, not a document field, and carries
its own class — wearing the field class put it in the editor's field list.

`scripts/smoke/production-sweep.ts` walks every public and Studio screen and
the document flow, reporting console errors, failed API calls and error text.
It exists because verifying only the screen I had just changed is what let
broken screens reach production repeatedly; this runs before a deploy, not
after a report.
2026-08-21 17:20:42 +09:00
DongHyeonka 21f8425f1e fix: keep line breaks in the fields that are not Markdown
The earlier fix covered the Markdown body and stopped there, so the preview
still ran lines together — which is exactly what it looked like from the
outside: nothing had changed.

Summary, problem, conclusion, environment and the rest are plain text. They
never pass through the Markdown parser, so their newlines sit in a text node
and HTML collapses them, and the renderer that now emits <br> for the body was
never asked about them.

They render through the same rule now. One helper, one behaviour: a line the
author broke stays broken, wherever they typed it.
2026-08-21 15:54:28 +09:00
DongHyeonka 7093d84ab5 fix: give a document a slug, and say so when saving fails
Validation reported a slug the author could see on screen as missing. Both
halves of that were the editor's fault.

The document slug must match `^[a-z0-9]+(?:-[a-z0-9]+)*$`, which the editor
never said and never helped with. A Korean slug was rejected by the server with
a 422 carrying no details — and the editor answered that only through
`setRequestAnnouncement`, which is an aria-live region and shows a sighted
author nothing at all. The value stayed in the field, so it looked saved. Then
validation, which reads the saved version by design, correctly reported no
slug, and the author read that as the tool contradicting itself.

An empty slug is now derived from the title, romanizing Hangul the same way
topic slugs do, so a Korean title produces a valid slug and the author never
has to learn the rule. A slug that cannot work is refused before the request,
naming the rule instead of letting the server answer with an unexplained 422.
Every save failure now renders where the author is looking, not only where a
screen reader would hear it.

The rail shows one message rather than two: a conflict already says what to do,
so it outranks the server's wording, and everything else shows the server's
reason.
2026-08-21 15:35:23 +09:00
DongHyeonka d2c289c650 fix: keep the line breaks an author typed
Text written across several lines rendered as one run-on line. Markdown reads a
single newline as a space that joins a paragraph, the parser leaves that
newline inside the text node, and HTML then collapses it — so the break the
author pressed Enter for disappeared at the last step.

This was never a preview artifact: the Studio preview and the public page go
through the same renderer, so a published record ran its lines together too.

Newlines inside a paragraph now render as <br>. Paragraphs separated by a blank
line are already two paragraphs by the time they reach here, so this only
affects the breaks an author put inside one.
2026-08-21 15:10:27 +09:00
DongHyeonka 5cffe30200 fix: derive a topic slug that survives a Korean name
Creating a topic failed intermittently — "a topic with that slug already
exists" — and worked when the author retried with a different name. The rule
was never intermittent, only invisible: the slug kept `[a-z0-9]` and dropped
everything else, so a Korean name contributed nothing. Whatever Latin word or
number happened to be in it became the entire slug.

Two ways that goes wrong, and the author hit both. `인증` reduced to an empty
string, which the form refused before a request was ever sent. `Redis 캐시` and
`Redis 클러스터` both reduced to `redis`, so the second one collided with the
first — a real conflict, reported honestly, about a slug the author never chose
and could not see.

Hangul is now romanized rather than discarded. Syllables decompose
arithmetically into initial, medial and final jamo, so this needs no table and
is deterministic: `백엔드 아키텍처` becomes `baekendeu-akitekcheo`. Only the
jamo mapping from Revised Romanization is applied — the sound-change rules are
deliberately left out, because a slug is read, not pronounced, and those rules
would make one name produce different slugs in different contexts.

The output keeps the shape document slugs already use
(`^[a-z0-9]+(?:-[a-z0-9]+)*$`), so the repository has one slug rule rather than
two, and the tests assert exactly that.
2026-08-21 14:56:03 +09:00
DongHyeonka 197b2c7e72 chore: pick up the regenerated Question input contract
`resolution` leaves the input's required list, so the generated type makes it
optional. The editor already sent null for an unresolved question; nothing in
the frontend changes behaviour.
2026-08-21 14:32:14 +09:00
DongHyeonka c5e8735041 feat: read the profile's topics from Studio, and add working-copy deletion
Two things an author could not control from Studio.

The profile's "주요 관심 주제" was four strings in the JSX. Creating or removing
a topic in Studio changed nothing, and correcting the list meant a rebuild and
a redeploy. It now renders the published topic list. The old literal opened
with "Backend Architecture", which no record in the catalogue actually carries
— the profile was advertising a topic that did not exist, and nothing could
have caught that while the list lived in the markup.

The working-copy list gained a delete control. It routes by kind because the
contract and the storage both do: Case and Reference share one table split by
type, Question is its own. Decision has no delete — its lifecycle is accept,
reject, supersede, which records what happened rather than erasing it — so the
control does not appear for it.

The list summary carries no version, so deletion reads the working copy first
and uses the version it finds. A stale version from a list left open should
fail as a conflict, not delete whatever is there now.
2026-08-21 13:30:37 +09:00
DongHyeonka ab8c6c14db fix: derive the Studio serving patterns from the route contract
`/studio/releases` answered a plain-text 404 from nginx. The route existed, the
chunk was built, and the SPA could reach the screen by client-side navigation —
but a hard load or a reload never got that far, because the web server had
never been told the path exists.

The public half of the serving contract derives its patterns from the route
registry. The Studio half was a hand-maintained array, and it failed the way
hand-maintained arrays fail: the comment above `^/studio/assets$` records that
exact bug being fixed once already, and adding a route repeated it immediately.
Both halves now come from the same source, so a Studio route that exists is
served without anyone having to remember.

Deriving them yields one pattern per route rather than the old alternation that
folded the four document sub-screens together. Same matched set, and it no
longer needs a human to keep the grouping honest.
2026-08-21 03:29:47 +09:00
DongHyeonka 3754269118 feat: add the Studio release editor and point the footer at the changelog
The public site shipped a Releases page and a footer link to a release, and
neither could ever have content: the read path existed, the write path did not.
This adds the seven release operations to the contract contribution and the
gateway, and a Studio screen that can actually write one.

The editor is six markdown fields rather than one, because that is what the
contract models and what a release note is — why, what, what a reader notices,
what it leaves in the code, how it was verified, what is still open. Publishing
is separate from saving: a draft saves in any state, but the public query keys
on PUBLISHED alone, so publish is where completeness is demanded.

No new CSS. The screen reuses the document editor's field classes and the
working-copy list's row classes, so it inherits Studio's spacing and type
instead of introducing a second look.

The footer previously linked `/releases/0.1.0` — a version that did not exist,
so the link 404'd, and one that would have gone stale at 0.2.0 anyway. It now
points at the changelog index, which is the only place that knows what the
latest release is and which reads correctly when there are none.

Route inventory, navigation order, message catalog, manual accessibility
evidence, artifact baseline, and the pinned gate-shape digest all move with the
new route. The digest was recomputed by first reproducing the previous constant
from the previous gates.json, so the computation is known to be the one it was
pinned under.
2026-08-21 03:08:41 +09:00
DongHyeonka 760071156d fix: give each API surface its own error-code enum
The public site answered every screen with the terminal error surface. Three
defects stacked, and each one hid the next.

The first refused the request outright: `attachCredentials` asks the Studio
helper, which returns null for a profile it does not own, and the fallback
below read the session and rejected anything not authenticated. Public reads
declare the ANONYMOUS profile, so a signed-out visitor — the public site's
entire audience — never got a request out of the browser. An anonymous profile
carries no credentials by definition and must never consult the session.

With requests flowing, the second surfaced: `envelopeError()` pinned
`ApiError.code` to the Studio enum and all three surfaces shared it. Public and
Management each declare their own enum in their own contract, so every error
they returned failed validation and arrived as a CONTRACT_VIOLATION — an
unclassifiable transport fault — rather than the domain error it was. A strict
enum checked against the wrong surface's contract still looks strict, which is
why no gate caught it. Each surface now passes its own contract's codes.

The third was the not-found path: it read `status` and `code` off the problem
body, but the envelope has no `status` and names the code for its surface
(PUBLIC_RESOURCE_NOT_FOUND, not NOT_FOUND). The HTTP status from the transport
is the authoritative signal and the only one that holds across both shapes.

The regression test composes the real runtime adapters against the deployed
backend's actual 404 body. Neither the gateway tests (which stub the executor)
nor the screen tests (which stub the gateway) cover this seam, and the whole
outage lived in it.

Two page-level fixes came out of the same investigation: the profile page asked
for two project slugs that only ever existed in the static fixture, and the
index pages held their fixed header copy behind a request that had nothing to
do with it. Headers now paint immediately; only the sections that are actually
waiting show a fallback, and an empty list says so instead of rendering blank.
2026-08-21 01:14:20 +09:00
DongHyeonka 03986da3d6 fix: let a signed-out visitor read the public site
Every public screen rendered its terminal error surface, and the network log
explained why: no request to /api/v1/public ever left the browser.

The credential collaborator asks the Studio helper first, which returns null
for any profile it does not own — "not mine, use your own logic". Below that,
the fallback reads the session and refuses anything that is not authenticated.
The public operations declare the ANONYMOUS profile, so they fell into that
fallback, and a signed-out visitor is exactly who the public site is for.

An anonymous profile carries no credentials by definition — the registry
refuses to install one that even allows a credential header — so it must never
consult the session. It now short-circuits with an empty credential patch,
keyed on the profile's transport rather than a profile id, so any anonymous
operation is covered rather than one named surface.

This could only appear once the public source became HTTP; until this week
that path had never run in a browser. The suites did not catch it because they
exercise the gateway and the screens, not the composition root's credential
decision — that seam has no test, and this is what it costs.
2026-08-21 00:43:54 +09:00
DongHyeonka 31dca00857 fix: keep the public screens usable on an empty site, and centre the dialogs
The published site showed one line of text — "요청한 문구를 표시할 수 없습니다."
— instead of any UI. Two independent faults produced it.

The screens ask for the whole catalogue by calling searchPublicContent(""). The
fixture answered that with everything it had, and four screens lean on it: the
home timeline, the project index, the release index, and the explore filter.
The contract has no such meaning — `q` is required, and an empty one answers
400 — so all four turned into error surfaces the moment the source became HTTP.
The adapter now assembles that catalogue from the list endpoints the contract
does provide, and only sends a real query to the search endpoint. Filtering
client-side instead would have been the other option, and it would silently
lose every result past the first page.

The message that surfaced was missing too. Twenty-two of the thirty-eight
failure kinds had no copy, so `errorMessage` fell through to
`common.unavailable` — which says nothing about what failed or what to do.
That is a systemic gap, not one absent key, so all twenty-two are written, in
both catalogues. They are phrased for the reader: what did not happen and what
to try, not the internal classification.

The Studio dialogs opened against the top-left corner. A modal dialog centres
through the UA's `margin: auto`, which is not surviving in this build; the
public `.search-dialog` already states `position/inset/margin` explicitly for
the same reason. The three that did not — unsaved-changes, asset upload, and
the publication flow — now follow it, with a max-height so a long dialog
scrolls rather than running off the screen.
2026-08-21 00:25:51 +09:00
DongHyeonka 11c2713139 feat: let Studio create the topics and projects publishing requires
Publishing needs a topic and nothing could create one. The backend now owns
that surface; this is its consumer — the management contract vendored, a
gateway over its nine operations, and one Studio screen that lists, creates,
and deletes topics and projects.

The screen adds no CSS. It reuses the classes the working-copy list already
uses, so it inherits Studio's spacing, type, and colour rather than growing a
second visual vocabulary beside them. Scope stops at list/create/delete:
renaming, phase changes, and visibility are implemented in the backend and
declared in the contract, but their screens are a separate design.

Two real defects surfaced while making the public port async, and both would
have shipped:

The search page and the header search dialog shared a query key. With an empty
query, `["tech-log","search",""]` was identical for both, so react-query
handed one surface the other's cache — different shapes — and the page died
reading a field that was not there. Keys now name the surface.

The explore filter's selects are uncontrolled and read `defaultValue`, which
React applies once. Their options arrive later now, so the first render had
nothing to match and the value stayed empty: a topic in the URL no longer
showed as selected. The form key includes whether the catalog has arrived, so
it remounts with the options present. Controlled inputs would be the other
answer, but this form submits to build a URL — the URL owns the value.

The route brought its own bookkeeping: a build chunk, a manual accessibility
evidence file, and the CI artifact baseline that counts them. The gate pins a
digest of its own shape precisely so a new route cannot slip in without that
count being reviewed.

Test harnesses that render public screens now assemble the query providers and
await the settled paint, because the screens they render became async.
2026-08-20 23:40:15 +09:00
DongHyeonka 4b62bf3b1f chore: re-vendor both contracts from the merged design package
Only the recorded source revision moves (55a9599 -> b98eaf9). The vendored
specs and the generated types are byte-identical, which is the useful part:
the envelope redefinition landed on a merge commit, not a schema change, so
nothing downstream has to move with it.
2026-08-20 18:42:04 +09:00
DongHyeonka 6784eb1ce6 fix: serve the public routes the router declares, not the slugs the build saw
The serving contract enumerated every public path the bundled fixture happened
to contain, and the generated nginx published exactly those as `location =`
blocks. A record published after the build — the entire point of having a
backend — answered 404 at the edge before the SPA was ever asked, and no amount
of correct routing inside the bundle could recover it. Twenty-seven frozen
paths, and any twenty-eighth was unreachable.

The route contract already declares which paths exist; the catalog only decides
which of them currently resolve, and that is the SPA's call rather than the web
server's. So the contract now emits one regex per registered Public route,
derived from the router, the way the Studio half has always worked.

A parameter matches one segment and never a slash, so /cases/a/b stays a 404
instead of quietly rendering a case page. The catch-all route is dropped rather
than translated: serving index.html for every unmatched URL would turn an edge
404 into a soft 200 and hide broken links from crawlers and from us.

Verified against a built image: /cases/a-brand-new-slug now answers 200 while
/nope and /cases/a/b still answer 404.

schemaVersion goes to 2 because the field changed shape, not just contents —
a consumer reading publicSpaPaths would otherwise see an absent key rather than
a version it can refuse.
2026-08-20 17:48:39 +09:00
DongHyeonka 24c01aedf2 feat: give the public surface an HTTP adapter, and a switch to reach it
The public read port had one implementation and no way to add another. This is
the second one: the 18 operations of the public contract, mapped to the nine
methods the screens call.

The contract and the screens disagree about shape, and translating here is what
keeps the presentation components untouched. The server speaks in what it
stores — timestamps, one markdown body, relations grouped by why they relate.
The screens were built against a catalog that spoke in what a page renders —
formatted labels, titled sections, one flat relation list whose group name is
the reason. Neither is wrong. Where the contract has no counterpart the value is
left empty and the gap is named where it happens rather than guessed at: a
Case's verification line, a decision's consequences, a question's options.

Sections are split from markdown here rather than through the Studio parser.
That parser produces the canonical render-block union the editor needs — inline
marks, evidence directives, tables — which is a richer tree than RecordSection
can hold, so reusing it would mean flattening away exactly the blocks that made
it worth using.

A 404 is unwrapped, not thrown. A slug that is not published is an answer the
port already has a shape for, and throwing would put a terminal-error surface on
a page whose real state is "this does not exist".

`listRecords` is one method over two endpoints, because the contract pages and
filters knowledge separately from questions. Only the unfiltered call fans out:
asking for one kind must not pay for the other.

The operations declare the ANONYMOUS auth profile, which forbids credentials
outright. That is the point — a later change that starts sending the session
cookie on a public read fails the profile check instead of quietly making a
cache-friendly surface user-specific.
2026-08-20 17:36:18 +09:00
DongHyeonka 4566f2d7a8 refactor: make the public read port async so a network adapter can implement it
`PublicContentQueries` returned arrays, not promises. That signature is only
implementable by something already in memory, so the port could hold exactly
one adapter — the bundled fixture — and no amount of configuration could put
the public site on the backend. Turning it async is the change that makes a
second adapter possible; the adapter itself follows.

The markup is untouched. Every page reads a value and hands it to a
presentational component, so the shape those components receive is mapped at
the adapter boundary and nothing below the page changes.

Screens load through one query, not one per read. Several pages read in a loop
— the home timeline walks every project for its activity, the explore filter
walks search results to resolve titles — and a hook per read would mean a
variable number of hooks per render, which React forbids. `usePublicContent`
takes the whole screen's reads as one loader, where a loop is a loop and
`Promise.all` is available; the loops that used to be N sequential lookups now
issue together.

Two places deliberately do not show the loading surface. The explore filter
sits inside a page that already renders one, so a second skeleton would move
the layout under it — it keeps its structure and fills its options in when they
arrive. The search dialog is a type-ahead: re-querying per keystroke would
replace the results with a skeleton on every key, so it loads the catalog once
and applies the same predicate locally.

`usePublicContent` requires an object because `undefined` is how the query
layer says "no result yet". A loader returning the record itself would make a
missing slug indistinguishable from a request in flight, and the page would sit
on a skeleton instead of rendering its not-found route.

Studio's `resolvePublishedLabel` stays synchronous. It is called from inside
the public renderer, so making it async would push awaits through the render
tree; the shell loads the catalog once and the callback remains a lookup.

The component tests now assemble the query providers the running app assembles.
Without them the render throws "No QueryClient set" — not a harness quirk, but
the same failure the app would produce if it were mounted without its query
layer.
2026-08-20 16:53:51 +09:00
DongHyeonka c362ec6100 feat: vendor the public read contract, and give it its own source switch
The public surface — 17 of the 28 registered routes — reads from a 29KB
TypeScript fixture and never touches the backend. `TECH_LOG_STUDIO_SOURCE`
only ever switched the Studio gateways; `publicContent` was wired to the
static adapter unconditionally, so no configuration could make the public
site show published content. This is the first half of closing that: the
contract and the switch, with the adapter still to come.

The generator now vendors both canonical contracts instead of one. They are
independent — different services on different schedules — so each carries
its own digest and operation list, and updating one leaves the other's drift
gate quiet.

`TECH_LOG_PUBLIC_SOURCE` is deliberately a second flag rather than a rename
of the Studio one. The combination that matters right now is exactly the one
a single flag cannot express: the authoring backend is live while the public
read API does not exist yet. production stays on MOCK for that reason —
pointing it at HTTP today would empty the live site — and moves when the
backend serves /api/v1/public.

Also records the compatibility evidence the registry gate wanted for the
Studio access change in fff5e6f. That gate has been failing since, which is
on me: the change was real and breaking, and it shipped without the note
explaining that route ids and schemas are untouched and only the access
classification moves.
2026-08-20 16:19:53 +09:00
DongHyeonka 83409bef7a feat: give the frontend a deployment artifact, and show its logo
The repository had no container image and no production-shaped serving
configuration. `dist/server.mjs` is a preview server that applies neither
the security headers nor the cache policy `config/hosting/` declares, so a
deployment had nothing correct to run.

`scripts/generate-nginx-config.ts` derives the server block from
`dist/tech-log-serving-contract.json` plus the two hosting policy files, so
the served headers and cache lifetimes cannot drift from what the contract
declares. It emits no TLS and no proxy blocks: the edge terminates TLS and
routes /api, and baking a backend address into the image would tie the
bundle to one deployment. Static surfaces use `alias` because a base-path
build serves /dev/assets/... out of dist/assets/..., which `root` plus URI
would look for one directory too deep.

The image copies that config next to the bundle and normalises permissions:
the build writes config.json 0600, which nginx cannot read, so the container
came up healthy and answered 403 for the one file the SPA needs to boot.

index.html never referenced public/favicon.svg. The file shipped and nginx
served it, but browsers asked for /favicon.ico, got a 404, and fell back to
the default icon. `%BASE_URL%` rather than an absolute path so a prefixed
deployment points at its own copy.

development.json moves to the HTTP Studio source; the mock source has no
backend to authenticate against, which is the whole point of that profile.
2026-08-20 16:14:09 +09:00
DongHyeonkaandClaude Opus 5 5e2b1a5586 fix: remove the footer layout shift, and hide the Studio chrome from signed-out visitors
CLS was 0.192 on every public route, and one element accounted for all of
it: the footer moved at t≈482ms, right when the lazily loaded route chunk
arrived. The site frame laid the footer out in normal flow, so before the
content existed it sat at the bottom edge of the viewport — visible — and
then dropped out of view when the page grew to 2400px.

The frame is a flex column now with the footer pinned by `margin-top:
auto`, and the content slot holds a viewport of height so the footer
starts below the fold and only ever moves further out of sight. The slot
is `main` once the route renders and `section.ui-page` while Suspense is
pending; covering only the first left the shift in place, which is what
the intermediate measurements showed.

  /  /explore  /projects  /releases  /search  /profile   0.1924 → 0.0001
  360 / 768 / 1440 across four routes: no horizontal overflow

Separately, the route gate stopped the Studio page but not the Studio
shell, so a signed-out visitor who typed /studio still got the whole
workspace navigation — 작업본, 게시 기록, 새 문서, by name. No data crosses
an href, but "비로그인 사용자가 Studio 화면을 볼 수 없다" is not satisfied by
hiding the contents of a screen while showing the screen. The header is
drawn only for an authenticated session; `children` is already the
router's sign-in surface, which is the whole of what such a visitor gets.

  signed out   h1 "세션이 필요합니다."   0 nav, 0 studio links
  signed in    h1 "작업 흐름"           2 nav, 8 links, sign-out present

Three test updates follow from behaviour that changed rather than broke:
the frozen route inventory now derives `access` from each route's own
layoutGroup instead of asserting "public" for all 28 — so a Studio route
added without a gate fails that table too — and the router and shell
harnesses supply the session the Studio surface now reads. The router
suite also gains a test that a signed-out visitor gets neither the Studio
heading nor its navigation, which is the regression the gate exists for.

test:all is 1811 passed; the eight remaining failures are the three
load-dependent flake families (ci-artifact-contract,
provider-guardian-transaction, security-followup), all of which pass in
isolation and reference none of the changed files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 11:01:29 +09:00
DongHyeonkaandClaude Opus 5 f1498feee5 feat: let the SPA see the backend session, and give it a way out
Two gaps that only showed up once the backend's BFF login worked.

The SPA could not tell it was signed in. `AUTH_MODE: "external"` delegates
the session to whoever hosts the bundle, via
`window.__CA_FRONTEND_AUTH_OWNER__`; nothing installed one, so the runtime
fell back to `createUnavailableSessionAdapter` and a browser holding a
valid TECHLOG_SESSION cookie still saw "로그인 연동이 필요합니다".

Tech Log's host is its own backend. The session is an httpOnly cookie the
SPA cannot read, so the only way to observe it is to ask — which is what
`getStudioSession` already is, the contract's bootstrap operation that
issues the CSRF token. The owner probes it on creation and publishes the
result: 200 authenticated, 401/403 unauthenticated, anything else
integration-failed (claiming "signed out" on a 5xx would push the user
through a login they do not need). It starts at `recovery-pending` so a
signed-in user does not get a sign-in flash on every reload, and signs in
by navigating the browser to the authorization endpoint — the code flow is
a redirect chain an XHR cannot follow.

Installed from create-runtime-composition, before the adapters resolve it,
and only for AUTH_MODE=external with the HTTP Studio: MOCK has no backend
to ask and demo keeps its own adapter.

There was also no way to sign out. `signOut` is wired all the way to the
port and the label exists in both catalogs, but the button lives in the
template's AppShell, which TechLog never renders — it supplies its own
public and studio shells. The Studio header now carries it, drawn only
when authenticated so it does not duplicate the sign-in the auth gate
already offers.

Sign-out sends the CSRF header it caches from the session probe, and
reports failure instead of swallowing it. The first attempt did neither:
`/logout` is a mutation, answered 403 without the header, and the owner
published `unauthenticated` from a `finally` — so the cookie survived
while the UI claimed the user was out. That is the one failure someone on
a shared machine would never think to check, so a sign-out that did not
happen now throws and leaves the state alone.

Verified against the running backend with a production-profile build:

  /studio signed out   401 probe → sign-in surface → Keycloak
  after login          session 200, dashboard 200, real data rendered
  sign out             204, TECHLOG_SESSION cleared, /studio/documents
                       back to the sign-in surface

check:types, lint, check:architecture, check:tech-log-contract,
check:dev-release-manifest and check:browser-security all pass. test:all
is 1817 passed with two load-dependent flakes that pass in isolation
(provider-guardian-transaction, security-followup) and reference none of
the changed files — security-followup kills process groups, which is also
what was killing the Gradle daemon when both suites ran at once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 02:24:09 +09:00
DongHyeonkaandClaude Opus 5 5fe355483e docs: complete the locally-verifiable half of the release checklist
Second pass. The first pass called several sections impossible; most were
not. The Studio mock implements all 18 contract operations with real
semantics (optimistic locking, validation staleness, preview expiry,
warning acknowledgement, idempotency), so the whole authoring flow is
exercisable without a backend, and the Public surface's UI behaviour is
testable against its static content.

Corrections to the first pass:
  - prod refusing to boot on the committed .env is the design working,
    not a defect: five startup validators reject development values, two
    of which were observed firing in order. The real gap is that no
    production value set exists anywhere yet.
  - ddl-auto=validate failing is a constraint, not a blocker -- prod
    accepts none as well, which is how this run booted.
  - two first-pass findings were false positives: the "Studio exposure"
    hits were release-note body text (zero /studio links on any public
    page), and the missing code block was a test artifact (no static
    document contains one; injecting one renders correctly).

New defects found:
  - no way to log out: the session button lives in the template's
    AppShell, which TechLog never renders -- it supplies its own shells.
  - duplicate relations are not prevented, at the contract level, so a
    backend implementation would inherit the same hole.
  - CLS 0.192, from a single footer shift at t=538ms.
  - no index on navigation_path: 236ms seq scan over 20k rows for the
    slug lookup the checklist names as a query pattern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 17:28:05 +09:00
DongHyeonkaandClaude Opus 5 44caa477e3 docs: record the release-gate verification run against a live backend
Runs both repositories locally -- backend on PostgreSQL 16 behind a real
Keycloak realm, frontend as a production-profile build -- and records what
each checklist section actually did, with the command output behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 16:50:42 +09:00
DongHyeonkaandClaude Opus 5 fff5e6f59e fix: gate Studio routes behind a session and repair the broken main build
Three defects found while running the release checklist against a live
backend, all on main.

1. Every TechLog route registered `access: "public"`, including the whole
   Studio surface. `decideRouteAccessForDefinition` was therefore a no-op
   for Studio: a signed-out visitor who typed /studio, /studio/documents,
   or /studio/assets got the Studio shell rendered, and the page went on
   to issue Studio API calls. Access is now derived from the spec's own
   `layoutGroup`, so a newly added Studio route is gated by construction
   rather than by remembering to restate it.

   Verified against a production-profile build: /studio* now renders the
   sign-in surface, /  and /explore are unchanged, and after signing in
   the router returns to the originally requested Studio screen.

2. `public/release-manifest.json` still declared the contract set at
   2.0.0 while the vendored contract had moved to 3.0.0 (eb86708). Boot
   verification fails closed on that mismatch, so `pnpm dev` served a
   blank screen. Regenerated from the same producer `dist/` uses.

3. `release-manifest.test.ts` asserted the same stale 2.0.0. The literal
   is deliberately independent of `EXPECTED_CONTRACT_SET_PACKAGES` (see
   the comment above it), so it is updated in place, not derived.

Also drops a dead `= null` initializer that failed `no-useless-assignment`.

check:types, lint, check:architecture, check:tech-log-contract and
check:dev-release-manifest all pass. test:all is 1818 passed with one
pre-existing load-dependent flake (provider-guardian-transaction, passes
in isolation, untouched by this change).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 16:34:31 +09:00
DongHyeonkaandClaude Opus 5 eb86708076 merge: feature/studio-response-envelope — Studio 응답 봉투를 전송 경계에서 언랩
계약 v3.0.0(ADR-006)에 맞춰 재생성하고, envelopeData/envelopeError가
{success,data,meta}를 언랩한다. 앱·도메인 계층과 StudioGateway 포트는 무변경 —
언랩이 전송 경계에서 끝난다.

검증: check:tech-log-contract / test:tech-log 337건 / tsc --noEmit 전부 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:16:10 +09:00
DongHyeonkaandClaude Opus 5 3e2406349a contract: studio 계약의 VALIDATION_FAILED 개명(DOCUMENT_VALIDATION_FAILED) 반영
canonical studio-v1.yaml(tech-log-design-package b20d7a2)이 VALIDATION_FAILED를
DOCUMENT_VALIDATION_FAILED로 개명했다 — 스켈레톤 전역 OperationalError.
VALIDATION_FAILED(400)와 code 문자열이 충돌해 같은 code가 두 HTTP status를
갖던 문제를 해소한다.

- generate:tech-log-contract로 vendor된 계약·생성 타입·canonical-source.json 재생성
- STUDIO_ERROR_CODES(손수 유지되는 계약 미러)의 해당 항목 개명

계약 밖 코드는 STUDIO_UNAVAILABLE로 접히므로 이 배열이 계약과 어긋나면 안 된다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 00:13:38 +09:00
DongHyeonkaandClaude Opus 5 aaaf0ac343 contract: ResponseMeta.page 스키마 확장 반영해 studio 계약 재생성
design-package 6a85d81이 ResponseMeta.page를 순수 null 타입에서
[object, null]로 넓혔다(openapi-generator가 순수 null 타입을 다루지
못해 생긴 계약 결함 수정, wire 의미는 그대로 — page는 여전히 항상
null). page의 생성 타입이 `null`에서 `{ [key: string]: unknown } | null`로
넓어졌다 — 의도된 변경이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:39:52 +09:00
DongHyeonkaandClaude Opus 5 9244f5c15d contract: discriminator enum 정정 반영해 studio 계약 재생성
design-package d170392이 discriminator 판별 필드를 const에서 단일값
enum으로 바꿨다(wire 의미 동일, openapi-generator 7.18.0의
discriminator+const NPE를 피하려는 정정). vendor된 계약과 생성 타입을
다시 맞춘다. 실질적인 리터럴 타입은 그대로다 — JSDoc 태그만
@constant에서 @enum {string}으로 바뀌었다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:32:32 +09:00
DongHyeonkaandClaude Opus 5 d23a18f659 fix: ProblemDetails를 전송 계층이 실제로 만드는 모양 하나로 합친다
Task 3 리뷰 finding(Important): contract.ts의 손수 유지되는 ProblemDetails가
tech-log-studio-contract-contribution.ts의 envelopeError()가 실제로
반환하는 모양과 별도로 정의돼 있었다. envelopeError()는 ApiError의
type/title/status/detail/code/retryable/category/details만 채우므로
ProblemDetails가 갖고 있던 옛 평면 필드(instance/traceId/fieldErrors/
latestDocument/latestPublication/conflictingFields)는 production에서
항상 undefined였다 — mock만 채워서 mock이 아무것도 검증하지 못하는
상태였다.

- contract.ts: ProblemDetails에서 옛 평면 필드를 제거하고 details를
  wire와 같은 union 타입(ValidationErrorDetails | VersionConflictDetails
  | PublicationConflictDetails | null)으로 정확히 준다. 세 타입을 이제
  개별 export한다.
- tech-log-studio-contract-contribution.ts: envelopeError()가
  contract.ts의 ProblemDetails를 그대로 반환 타입으로 쓴다(로컬
  StudioProblemShape 제거) — 이제 한 곳에만 정의가 있다.
- mock-studio-gateway.ts / cursor.ts: fieldErrors/latestDocument/
  conflictingFields/latestPublication을 wire와 같은 자리(details 안)로
  옮긴다.
- mock-studio-gateway.test.ts: 위 이동에 맞춰 details를 캐스트로 좁혀
  읽도록 갱신.

retryable은 optional로 유지했다 — 여러 테스트가 생략하고 만들며, 이번
finding과 무관해 required로 좁히면 관련 없는 파일들이 깨진다.

리뷰가 보류한 2건(asset-upload-transport.ts의 CODES.has 중복 검사,
apiErrorSchema.category가 enum이 아닌 것)은 손대지 않았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:13:33 +09:00
DongHyeonkaandClaude Opus 5 25a6b63d27 feat: Studio 응답 봉투를 전송 경계에서 언랩한다
studio-v1.yaml v3.0.0(ADR-006)에 맞춰 계약을 재생성하고, 성공은
{success,data,meta}, 실패는 {success,error,meta} 봉투를 전송 경계에서
언랩하는 envelopeData/envelopeError validator를 도입한다. 앱·도메인
계층은 기존과 같은 payload/ProblemDetails 모양을 계속 받고,
StudioGateway 포트 시그니처는 무변경이다.

- tech-log-studio-contract-contribution.ts: envelopeData/envelopeError
  도입, 18개 operation의 outputValidator를 passthrough에서 envelopeData로
  교체
- studio-error-mapping.ts: 봉투 오류의 status(항상 0)를
  outcome.metadata.status로 덮는다. SafeResponseMetadata.status가
  실제 필드명이며(httpStatus 아님) PROBLEM outcome에서 필수 필드다
- contract.ts: 삭제된 ProblemDetails 생성 스키마를 손으로 유지 — 앱
  계층·mock 게이트웨이가 그 모양을 계속 소비한다
- asset-upload-transport.ts: multipart 업로드는 일반 계약 런타임을
  거치지 않는 별도 seam이지만 같은 wire 봉투를 쓴다 — envelopeData/
  envelopeError를 재사용해 이 경로도 언랩한다 (브리프 파일 목록 밖의
  발견, report에 기록)
- 테스트: 신규 studio-envelope-unwrap.test.ts(TDD) + 봉투 뼈대를 직접
  만드는 기존 테스트(asset-upload-transport, studio-csrf-composition,
  contract-generation)를 봉투 형태로 갱신

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 21:55:45 +09:00
189 changed files with 29778 additions and 1294 deletions
+1
View File
@@ -29,3 +29,4 @@ artifacts/tests/visual/
# Git worktrees created inside the repository. A worktree is a checkout, not
# source: committing one would nest a second working copy inside this one.
.worktrees/
.playwright-mcp/
+96
View File
@@ -0,0 +1,96 @@
# syntax=docker/dockerfile:1
#
# The frontend deployment artifact. The repository had none — `dist/server.mjs`
# is a preview server that applies neither the security headers nor the cache
# policy `config/hosting/` declares — so a deployment had nothing to run.
#
# Two stages: the build produces `dist/` and, from the serving contract, the
# nginx configuration that matches it; the runtime is nginx with both.
# ---------------------------------------------------------------------------
# build
# ---------------------------------------------------------------------------
# Pinned by digest: the release-provenance gate requires an immutable runner
# identity, and a floating tag cannot give one.
FROM node@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 AS build
WORKDIR /src
# The profile is baked at build time (scripts/generate-runtime-config.ts), so it
# has to be chosen here rather than at `docker run`. `dist/config.json` stays a
# separate file in the image, which is what makes build-once/promote possible:
# a deployment can replace just that file without rebuilding the bundle.
ARG APP_PROFILE=production
ENV APP_PROFILE=${APP_PROFILE}
# The bundle and the nginx locations must agree on the prefix the deployment
# serves this under: "/" at a domain root, "/dev/" behind a path prefix.
ARG VITE_ROUTER_BASE_PATH=/
ENV VITE_ROUTER_BASE_PATH=${VITE_ROUTER_BASE_PATH}
# `CI=true` turns on the release-provenance gate (scripts/lib/build-environment.ts),
# which refuses to build without an identity for the artifact. That is the point:
# a deployed bundle that cannot say which commit it came from is not traceable,
# and the checklist asks exactly that. Supplied as build args so the caller —
# a pipeline or the deploy script — owns the values.
ENV CI=true
ARG VITE_BUILD_ID
ARG VITE_COMMIT_SHA
ARG RELEASE_ID
ARG CI_RUNNER_IMAGE
ARG SOURCE_DATE_EPOCH
ENV VITE_BUILD_ID=${VITE_BUILD_ID}
ENV VITE_COMMIT_SHA=${VITE_COMMIT_SHA}
ENV RELEASE_ID=${RELEASE_ID}
ENV CI_RUNNER_IMAGE=${CI_RUNNER_IMAGE}
ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}
# The two values a deployment is allowed to supply (scripts/generate-runtime-
# config.ts OVERRIDES); everything else is fixed by the profile. API_BASE_URL
# has to be absolute — the runtime canonicalises it with `new URL(value)` — so
# even a same-origin deployment names its own origin here. The committed
# production profile ships a placeholder (https://api.example.com/), which is
# what a deployment that forgets this would silently serve.
ARG RUNTIME_API_BASE_URL
ARG RUNTIME_TELEMETRY_ENDPOINT
ENV RUNTIME_API_BASE_URL=${RUNTIME_API_BASE_URL}
ENV RUNTIME_TELEMETRY_ENDPOINT=${RUNTIME_TELEMETRY_ENDPOINT}
RUN corepack enable
# Dependencies first so a source-only change does not re-resolve them.
COPY package.json pnpm-lock.yaml ./
RUN corepack pnpm install --frozen-lockfile --ignore-scripts
COPY . .
RUN corepack pnpm build \
&& node scripts/generate-nginx-config.ts
# ---------------------------------------------------------------------------
# runtime
# ---------------------------------------------------------------------------
FROM nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10 AS runtime
# Replaces the packaged default server block; the generated file is the whole
# server definition, including the BFF proxy locations.
RUN rm /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/nginx.conf /etc/nginx/conf.d/tech-log.conf
COPY --from=build /src/dist/ /usr/share/nginx/html/
# The generated config is served from /usr/share/nginx/html as root, so the two
# copies above would also publish nginx.conf itself. It is not secret, but it is
# not a page either.
RUN rm -f /usr/share/nginx/html/nginx.conf /usr/share/nginx/html/server.mjs \
&& rm -rf /usr/share/nginx/html/.vite \
# The build writes config.json 0600, which nginx (running as `nginx`) cannot
# read — the container came up healthy and answered 403 for the one file the
# SPA needs before it can boot. Normalise what is served to world-readable.
&& chmod -R a+rX /usr/share/nginx/html
EXPOSE 80
# No `nginx -t` here: proxy_pass names are resolved when the config loads, and
# `backend`/`keycloak` only exist on the compose network. The container's own
# startup is the check, and it fails loudly.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1${VITE_ROUTER_BASE_PATH:-/}config.json || exit 1
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_PROJECT_EDIT accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_PROJECT_EDIT
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_RELEASES accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_RELEASES
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_RELEASE_EDIT accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_RELEASE_EDIT
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_TAXONOMY accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_TAXONOMY
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

+28
View File
@@ -1196,6 +1196,30 @@
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_TAXONOMY.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PROJECT-EDIT-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_PROJECT_EDIT.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASE-EDIT-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASE_EDIT.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md",
@@ -1949,6 +1973,10 @@
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATIONS-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATION-PREVIEW-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-ASSETS-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PROJECT-EDIT-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASE-EDIT-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
"artifact-artifacts-tests-a11y-manual-NOT-FOUND-md",
"artifact-artifacts-tests-a11y-manual-report-json"
@@ -280,6 +280,86 @@
"compatibilityWindow": "Existing valid envelopes continue to decode; invalid values fail closed.",
"rollback": "Remove the required codec field and runtime codec dispatch together.",
"owner": "frontend-platform"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_HOME:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENTS:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_NEW:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_EDIT:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_VALIDATION:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_PREVIEW:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_PUBLISH:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_PUBLICATIONS:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_PUBLICATION_PREVIEW:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
},
{
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_NOT_FOUND:access:field-changed",
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
"owner": "tech-log-frontend"
}
]
}
+2 -1
View File
@@ -13,7 +13,8 @@
"SERVICE_WORKER": "DEFAULT",
"OFFLINE_COMMANDS": "DEFAULT"
},
"TECH_LOG_STUDIO_SOURCE": "MOCK",
"TECH_LOG_STUDIO_SOURCE": "HTTP",
"TECH_LOG_PUBLIC_SOURCE": "HTTP",
"FEATURE_OVERRIDES": {
"reference-feature": "DEFAULT"
}
+1
View File
@@ -14,6 +14,7 @@
"OFFLINE_COMMANDS": "DEFAULT"
},
"TECH_LOG_STUDIO_SOURCE": "MOCK",
"TECH_LOG_PUBLIC_SOURCE": "MOCK",
"FEATURE_OVERRIDES": {
"reference-feature": "DEFAULT"
}
+1
View File
@@ -15,6 +15,7 @@
"OFFLINE_COMMANDS": "DEFAULT"
},
"TECH_LOG_STUDIO_SOURCE": "HTTP",
"TECH_LOG_PUBLIC_SOURCE": "HTTP",
"FEATURE_OVERRIDES": {
"reference-feature": "DEFAULT"
}
+1
View File
@@ -15,6 +15,7 @@
"OFFLINE_COMMANDS": "DEFAULT"
},
"TECH_LOG_STUDIO_SOURCE": "HTTP",
"TECH_LOG_PUBLIC_SOURCE": "HTTP",
"FEATURE_OVERRIDES": {
"reference-feature": "DEFAULT"
}
+24
View File
@@ -0,0 +1,24 @@
# Keycloak realm
`tech-log-realm.json` is imported by the `keycloak` service at start
(`--import-realm`). It exists because the realm was previously created by hand,
which meant §27 of the release checklist — "Keycloak Realm 설정을 복원할 수
있다" — had no answer: nothing in either repository described the realm.
What it declares, and why each part is load-bearing:
- **`studio-author` realm role.** `StudioAuthzEnvironmentPostProcessor` maps this
name to `studio:read` and `studio:write`. The name is configurable through
`APP_STUDIO_AUTHOR_ROLE`; if you change it here, change it there too.
- **`tech-log-bff` confidential client.** The Authorization Code flow belongs to
the backend, not the browser — the SPA never holds a token. `redirectUris` is
relative so the same realm works on any origin the deployment is served from.
- **`realm-roles` protocol mapper.** Without it the roles never reach the token,
the registry resolves zero permissions, and every Studio call answers 403.
## Values that must be replaced
`CHANGE_ME_BFF_SECRET` and `CHANGE_ME_STUDIO_PASSWORD` are placeholders, and the
deploy script substitutes them from the environment before import. They are left
visible rather than pre-filled so a realm file committed with a real secret is an
obvious mistake rather than a quiet one.
+57
View File
@@ -0,0 +1,57 @@
{
"realm": "tech-log",
"enabled": true,
"sslRequired": "none",
"registrationAllowed": false,
"loginTheme": "keycloak",
"accessTokenLifespan": 300,
"ssoSessionIdleTimeout": 28800,
"ssoSessionMaxLifespan": 86400,
"roles": {
"realm": [
{ "name": "studio-author", "description": "Tech Log Studio 편집 권한 (studio:read + studio:write)" }
]
},
"clients": [
{
"clientId": "tech-log-bff",
"name": "Tech Log BFF",
"description": "백엔드가 소유하는 Authorization Code 클라이언트. SPA 는 토큰을 직접 들지 않는다.",
"enabled": true,
"publicClient": false,
"secret": "CHANGE_ME_BFF_SECRET",
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"redirectUris": ["/login/oauth2/code/*"],
"webOrigins": ["+"],
"protocolMappers": [
{
"name": "realm-roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"config": {
"claim.name": "realm_access.roles",
"jsonType.label": "String",
"multivalued": "true",
"access.token.claim": "true",
"id.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
}
],
"users": [
{
"username": "studio",
"enabled": true,
"emailVerified": true,
"email": "studio@tech-log.local",
"firstName": "Studio",
"lastName": "Author",
"credentials": [{ "type": "password", "value": "CHANGE_ME_STUDIO_PASSWORD", "temporary": false }],
"realmRoles": ["default-roles-tech-log", "studio-author"]
}
]
}
+180
View File
@@ -0,0 +1,180 @@
# The Tech Log dev stack: one origin, five services.
#
# nginx is the only published port. Everything the browser touches — the SPA,
# /api, the OIDC redirect chain, and Keycloak under /auth — arrives on the same
# origin, which is what lets the session be a plain first-party httpOnly cookie
# instead of a cross-site one needing SameSite=None.
#
# browser ──> frontend(nginx) ──┬─> / SPA bundle
# ├─> /api backend
# ├─> /oauth2 /login /logout backend (BFF)
# └─> /auth keycloak
#
# Secrets here are development values and are meant to be replaced by the
# deployment; they are named in .env so nothing is baked into an image.
name: tech-log
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-tech_log}
POSTGRES_USER: ${POSTGRES_USER:-tech_log}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
TZ: UTC
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tech_log} -d ${POSTGRES_DB:-tech_log}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped
networks: [tech-log]
redis:
# Holds the Studio session. Losing it signs everyone out; it holds nothing
# else, so it is not backed by a volume on purpose.
image: redis:7-alpine
command: ["redis-server", "--save", "", "--appendonly", "no"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
restart: unless-stopped
networks: [tech-log]
keycloak:
image: quay.io/keycloak/keycloak:26.7.0
command: ["start-dev", "--import-realm", "--http-relative-path=/auth"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?set KEYCLOAK_ADMIN_PASSWORD}
KC_HTTP_ENABLED: "true"
# Behind nginx: Keycloak must build its URLs from the forwarded host, or
# the redirect back from the login page points at the container.
KC_HOSTNAME: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN}/auth
KC_HOSTNAME_STRICT: "false"
KC_PROXY_HEADERS: xforwarded
KC_HEALTH_ENABLED: "true"
volumes:
- ${KEYCLOAK_IMPORT_DIR:-./deploy/keycloak}:/opt/keycloak/data/import:ro
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /auth/health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'"]
interval: 15s
timeout: 5s
retries: 20
start_period: 40s
restart: unless-stopped
networks: [tech-log]
backend:
image: ${BACKEND_IMAGE:-tech-log-backend:local}
volumes:
- tls-public:/tls-public:ro
# The image entrypoint is `java -jar /app/app.jar`; this wraps it so the
# frontend's certificate lands in the JVM truststore first. Without it the
# OIDC metadata fetch fails PKIX validation and the process crash-loops.
entrypoint:
- /bin/sh
- -c
- |
until [ -f /tls-public/server.crt ]; do sleep 1; done
# The image runs as a non-root user, so the JVM's own cacerts is not
# writable — importing there silently did nothing and the metadata fetch
# kept failing PKIX. Copy it somewhere writable, add the edge
# certificate, and point the JVM at that.
cp "/opt/java/openjdk/lib/security/cacerts" /tmp/truststore.jks
keytool -importcert -noprompt -trustcacerts -alias tech-log-edge \
-file /tls-public/server.crt \
-keystore /tmp/truststore.jks -storepass changeit
exec java \
-Djavax.net.ssl.trustStore=/tmp/truststore.jks \
-Djavax.net.ssl.trustStorePassword=changeit \
-jar /app/app.jar
environment:
SPRING_PROFILES_ACTIVE: local
# Persistence
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-tech_log}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-tech_log}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
SPRING_DATASOURCE_DRIVER_CLASS_NAME: org.postgresql.Driver
SPRING_FLYWAY_ENABLED: "true"
SPRING_JPA_HIBERNATE_DDL_AUTO: none
CA_SKELETON_PERSISTENCE_VENDOR: postgresql
# BFF session
CA_SKELETON_SECURITY_AUTH_MODE: redis-session
CA_SKELETON_SECURITY_SESSION_COOKIE_NAME: TECHLOG_SESSION
APP_REDIS_ENABLED: "true"
APP_REDIS_AUTHENTICATION_ANONYMOUS_ACCESS_ACCEPTED: "true"
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: "6379"
# OIDC. The issuer is the browser-facing URL because the tokens carry it
# and the browser is redirected there; the container reaches the same
# Keycloak through nginx on the compose network.
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_ID: ${OIDC_CLIENT_ID:-tech-log-bff}
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?set OIDC_CLIENT_SECRET}
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_SCOPE: openid,profile,email
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_AUTHORIZATION_GRANT_TYPE: authorization_code
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_REDIRECT_URI: "${PUBLIC_ORIGIN}/login/oauth2/code/keycloak"
SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_ISSUER_URI: ${PUBLIC_ORIGIN}/auth/realms/${KEYCLOAK_REALM:-tech-log}
APP_STUDIO_AUTHOR_ROLE: ${STUDIO_AUTHOR_ROLE:-studio-author}
APP_STUDIO_POST_LOGIN_REDIRECT: "${PUBLIC_ORIGIN}/studio"
# Behind a proxy: trust the forwarded headers nginx sets, so redirect URLs
# and client IPs are the browser's, not the container's.
APP_SERVER_FORWARD_HEADERS_STRATEGY: framework
TZ: UTC
# The issuer in a token is the browser-facing URL, and the backend has to
# both validate that exact string and fetch the realm's metadata from it.
# Inside the container that host does not resolve, so discovery failed and
# the process crash-looped. Mapping the public host to the docker gateway
# makes one URL work from both sides — the browser reaches nginx directly,
# the backend reaches the same nginx through the published port.
extra_hosts:
- "${PUBLIC_HOST:?set PUBLIC_HOST}:host-gateway"
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
keycloak: { condition: service_healthy }
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/healthcheck"]
interval: 15s
timeout: 5s
retries: 10
start_period: 45s
restart: unless-stopped
networks: [tech-log]
frontend:
image: ${FRONTEND_IMAGE:-tech-log-frontend:local}
# The backend fetches the realm metadata from the same HTTPS origin the
# browser uses, so it has to trust this certificate. Publishing it to a
# shared volume keeps one certificate for both sides; a deployment that
# mounts a CA-issued certificate over /etc/nginx/tls needs neither this nor
# the backend's import step.
volumes:
- tls-public:/tls-public
command:
- /bin/sh
- -c
- "cp /etc/nginx/tls/server.crt /tls-public/server.crt && exec nginx -g 'daemon off;'"
ports:
- "${PUBLIC_HTTP_PORT:-8088}:80"
- "${PUBLIC_PORT:-8443}:443"
depends_on:
backend: { condition: service_started }
keycloak: { condition: service_started }
restart: unless-stopped
networks: [tech-log]
networks:
tech-log:
driver: bridge
volumes:
postgres-data:
tls-public:
@@ -0,0 +1,394 @@
# Tech Log 운영 출시 전 체크리스트 — 실측 검증 보고서
**검증일** 2026-08-19 · **방식** 두 저장소를 로컬에서 실제 기동해 엔드포인트·브라우저 단위로 실측
| 대상 | 위치 | 리비전 |
|---|---|---|
| Frontend | `tech-log-frontend` | `main` eb86708 → `fix/release-gate-frontend` fff5e6f |
| Backend | `tech-log-backend` | `develop` ab0447a |
| Keycloak | 로컬 컨테이너 `local-keycloak` | 26.7.0 (`:18080`) |
| PostgreSQL | 로컬 컨테이너 `techlog-pg` | 16.15 (`:5433`) |
---
## 요약 판정: **출시 보류 (P0 미충족)**
체크리스트 §31의 P0 항목 중 **인증 우회 불가 · 인가 우회 불가 · Studio 주요 기능 정상 · Publish 정상**이
현재 충족되지 않는다. 아래 근거는 전부 실행 결과다.
### 가장 중요한 구조적 사실
백엔드는 계약(`studio-v1.yaml`)이 선언한 **18개 오퍼레이션 중 2개**만 구현되어 있다.
| 상태 | 오퍼레이션 |
|---|---|
| 구현됨 (2) | `getStudioSession`, `listStudioCatalog` |
| 미구현 (16) | `getStudioDashboard`, `listStudioDocuments`, `createStudioDocument`, `getStudioDocument`, `saveStudioDocument`, `validateStudioDocument`, `getCurrentStudioPreview`, `createStudioPreview`, `publishStudioDocument`, `listStudioPublications`, `unpublishStudioPublication`, `getStudioPublicationSnapshot`, `listStudioAssets`, `uploadStudioAsset`, `getStudioAsset`, `updateStudioAsset`, `deleteStudioAsset` |
또한 **Public 읽기 엔드포인트는 계약에 아예 없다.** `studio-v1.yaml`은 Studio 전용이고,
프론트엔드의 Public 화면(`/`, `/explore`, `/projects`, `/releases`, 문서 상세)은
`src/features/tech-log/adapters/static/public-content.ts`의 **번들에 컴파일된 정적 콘텐츠**를 읽는다.
따라서 체크리스트의 다음 절은 검증 대상 자체가 존재하지 않는다:
§2(탐색·검색·프로젝트·변경기록의 백엔드 연동), §3.1~3.3(문서 작성·관계·Publish),
§12(Public/Private 데이터 경계), §17(파일/Object Storage), §29(E2E 시나리오).
---
## P0 — 출시 차단 결함
### P0-1. Studio 라우트가 인증을 검사하지 않았다 — **수정 완료**
`TECH_LOG_ROUTE_REGISTRY`가 모든 TechLog 라우트를 `access: "public"`으로 등록하고 있었다.
라우터에 `decideRouteAccessForDefinition` 가드가 존재하지만 Studio에 대해 무력화된 상태였다.
운영 프로파일 빌드(`AUTH_MODE=external`)로 실측한 수정 전:
```
/studio http=200 h1="작업 흐름" ← 비로그인 상태에서 Studio UI 렌더링
/studio/documents http=200 h1="작업본"
/studio/assets http=200 h1="Asset"
```
`spec.layoutGroup === "STUDIO"`에서 `access`를 유도하도록 수정한 뒤:
```
/studio http=200 h1="로그인 연동이 필요합니다."
/studio/documents http=200 h1="로그인 연동이 필요합니다."
/ , /explore 변화 없음
```
로그인 후 원래 요청 화면으로 복귀하는 것도 확인했다(`/studio/documents` → 로그인 → `작업본`).
커밋 `fff5e6f`.
### P0-2. 백엔드 Studio API에 인가 검사가 없다 — **미해결**
`SecurityConfig``anyRequest().authenticated()`로 끝나고, Studio 컨트롤러에
`@RequiresPermission` 계열 애노테이션이 **하나도 없다**.
Keycloak에 Studio 권한이 없는 사용자(`plain`, realm role `plain-user`)를 만들어 확인:
```
GET /api/v1/studio/catalog?type=TOPIC
studio 사용자 (studio-author) → HTTP 200
plain 사용자 (권한 없음) → HTTP 200 ← 인가 우회
```
체크리스트 §11 "인증된 사용자라고 해서 무조건 Studio API를 호출할 수 있지 않다",
§31 P0 "인가 우회 불가" 미충족.
### P0-3. 모든 Studio 경로가 `/api/api/v1/...`에 매핑된다 (Double Prefix) — **미해결**
`PresentationWebConfig``configurer.addPathPrefix("/api", c -> true)`로 전 컨트롤러에
`/api`를 붙이는데, Studio 컨트롤러는 `@GetMapping("/api/v1/studio/...")`로 이미 `/api`를 포함해 선언한다.
```
GET /api/v1/studio/catalog → 404 ROUTE_NOT_FOUND
GET /api/api/v1/studio/catalog → 200
GET /api/v1/studio/session → 404 ROUTE_NOT_FOUND
GET /api/api/v1/studio/session → 503
```
프론트엔드는 계약대로 `/api/v1/studio/...`를 호출하므로 **현재 상태로는 단 한 건도 연결되지 않는다.**
체크리스트 §25 "`/api` Prefix 처리에서 Double Prefix가 발생하지 않는다" 미충족.
### P0-4. `getStudioSession`이 항상 503을 반환한다 — **미해결**
`auth-mode: jwt`(저장소 기본값, `src/.env:115`)에서 `SecurityConfig``csrf.disable()`
`CsrfFilter`를 제거하므로 `CsrfToken` 파라미터가 항상 `null`이고, 컨트롤러는 이를
`STUDIO_UNAVAILABLE`(503)로 정직하게 보고한다.
```
GET /api/api/v1/studio/session (유효한 studio 토큰)
→ 503 {"code":"STUDIO_UNAVAILABLE","category":"TRANSIENT_DEPENDENCY","retryable":true}
로그: "CSRF token unavailable: CSRF protection is disabled for the active auth-mode"
```
프론트엔드 HTTP 모드는 `getStudioSession`으로 CSRF 토큰을 받아 부트스트랩하므로,
**이 한 건 때문에 Studio HTTP 경로 전체가 시작조차 못 한다.**
`auth-mode: redis-session`에 필요한 세션 빈이 저장소에 없다는 점은 백엔드 HANDOFF.md도 명시하고 있다.
### P0-5. `main` 브랜치의 dev 부팅이 깨져 있었다 — **수정 완료**
`eb86708`(계약 3.0.0 머지) 이후 `public/release-manifest.json`이 2.0.0으로 남아
부팅 시 contract-set 검증이 fail-closed → **빈 화면**. 이전에 한 번 겪은 것과 같은 실패 양식이다.
```
setDigest drift: manifest sha256:e0da7765…, build sha256:261ac630…
package drift: manifest 2.0.0 / ce2e748 vs build 3.0.0 / b20d7a2
```
`generate:dev-release-manifest`로 재생성하고, 같은 값을 하드코딩하던
`tests/runtime-schema/release-manifest.test.ts`도 함께 갱신했다. 커밋 `fff5e6f`.
### P0-6. 커밋된 `.env`로는 prod 프로파일이 부팅하지 않는다 — **미해결**
`src/.env:140``APP_DATASOURCE_DDL_AUTO=update`인데, `application-prod.yml`이 문서화한
`JpaSchemaSafetyValidator`는 prod에서 `none|validate`만 허용하고 위반 시 exit 71로 종료한다.
### P0-7. `ddl-auto=validate`로는 PostgreSQL에서 부팅하지 않는다 — **미해결**
```
SchemaManagementException: Schema-validation: missing table [fs_cleanup_item]
```
`PostgreSqlPersistenceConfig`가 Flyway 위치를 `classpath:db/migration/postgresql`로 고정해
`db/migration/jpa/fileserver` 트리가 **한 번도 적용되지 않는데**, 해당 JPA 엔티티는 스캔된다.
`ddl-auto=update`가 이 사실을 가려 온 것이고, prod가 요구하는 `validate`로 바꾸는 순간 드러난다.
(본 검증은 `ddl-auto=none`으로 우회해 진행했다.)
---
## P1 — 출시 전 해결 권장
| # | 항목 | 실측 근거 |
|---|---|---|
| P1-1 | Keycloak realm 구성이 두 저장소 어디에도 없다 | compose에 keycloak 서비스 없음, realm export 파일 없음. 검증을 위해 `ca-skeleton` realm·클라이언트·audience 매퍼·테스트 사용자를 수기로 생성해야 했다. §27 "Keycloak Realm 설정을 복원할 수 있다" 미충족 |
| P1-2 | 프론트엔드에 로그인 구현이 없다 | OIDC/Keycloak 클라이언트 코드 0건. `AUTH_MODE=external`은 호스팅 페이지가 `window.__CA_FRONTEND_AUTH_OWNER__`를 주입하기를 기대하며, 없으면 `createUnavailableSessionAdapter`가 "로그인 연동이 필요합니다"를 띄운다. §1.4 인증 항목 전부 검증 불가 |
| P1-3 | production 런타임 설정이 플레이스홀더 | `API_BASE_URL: https://api.example.com/`, `TELEMETRY_ENDPOINT: https://telemetry.example.com/v1/events` |
| P1-4 | Rate Limit 비활성 | `APP_RATE_LIMIT_ENABLED=false`, `APP_RATE_LIMIT_PROVIDER=disabled`. 60회 연속 호출 전부 200 |
| P1-5 | 보안 헤더를 적용하는 주체가 없다 | `config/hosting/security-headers.json`에 CSP·HSTS·X-Frame-Options 등이 정의돼 있으나 `dist/server.mjs`**하나도 적용하지 않는다**. `verify:hosting-headers`는 기본적으로 fixture 모드로 동작해 실 서버를 검사하지 않는다 |
| P1-6 | 캐시 정책도 미적용 | `cache-policy.json``/assets/*``public, max-age=31536000, immutable`을 요구하나 실제 응답은 전부 `no-cache` |
| P1-7 | 프론트엔드 배포 아티팩트 부재 | Dockerfile·nginx conf·compose 없음. `dist/server.mjs`는 프리뷰용이지 운영 파일 서버가 아니다 |
| P1-8 | robots.txt / sitemap.xml 없음 | **Studio 경로가 검색 엔진에 차단되지 않는다.** §7 미충족 |
| P1-9 | Open Graph·canonical 메타데이터 없음 | `dist/index.html``og:*`·canonical 없음. `<title>`은 라우트별로 정상 동작하나 **런타임에 설정**되므로 JS를 실행하지 않는 공유 미리보기 크롤러에는 "Tech Log" 고정값만 노출된다 |
| P1-10 | DB 타임아웃 30초 | `APP_DATASOURCE_CONNECTION_TIMEOUT=30000`. `application.yml`이 문서화한 D2 fail-fast 의도(기본 5s)와 어긋난다. 프론트엔드 `REQUEST_TIMEOUT_MS=10000`이므로 DB 장애 시 프론트가 항상 먼저 끊겨 `DB_UNAVAILABLE` 503을 보지 못한다 |
| P1-11 | Tech Log Asset의 Object Storage 배선 없음 | objectstorage 어댑터는 템플릿 자산으로 존재하나 techlog 참조 0건, MinIO/S3 환경변수 0건, `uploadStudioAsset` 엔드포인트 미구현 |
---
## 검증되어 통과한 항목
### Frontend
| 항목 | 결과 |
|---|---|
| Production Build | PASS (local·production 프로파일 모두) |
| TypeScript compile | PASS (`check:types` 6개 프로젝트) |
| ESLint | PASS (수정 후 0 error) |
| 전체 테스트 | 1,818 passed / 16 skipped / **1 기존 flake** (`provider-guardian-transaction` — 단독 실행 2회 모두 PASS, 부하 의존) |
| architecture / contract / dev-release-manifest / browser-security 게이트 | PASS |
| Production 번들에 dev·localhost URL 없음 | PASS (`localhost`·`127.0.0.1` 0건, `.local` 매치는 전부 `locale`/`localeCompare`) |
| Production 번들에 Mock API 미포함 | PASS (`createMockStudioGateway` 0건) |
| Source Map 비공개 | PASS (`.map` 0개) |
| Route 단위 Lazy Loading | PASS (30 청크, 총 954 KB / 최대 569 KB) |
| SPA 라우팅·새로고침 | PASS (열거형 allowlist 방식. 존재하지 않는 문서 경로는 의도적으로 404) |
| Route별 `<title>` | PASS (`탐색 · Tech Log`, `프로젝트 · Tech Log` …) |
| 반응형 | PASS — 360/414/768/1440 × 6개 Public 라우트 **24개 조합 전부 가로 스크롤 없음** |
| 접근성 | PASS — axe(wcag2a/2aa/21a/21aa) **serious+critical 0건** (Public 6 + Studio 4 라우트). h1 정확히 1개, heading 건너뜀 없음, alt 누락 0, 레이블 없는 icon button 0 |
| 로그인 흐름 | PASS (게이트 → 로그인 → 원래 화면 복귀) |
### Backend
| 항목 | 결과 |
|---|---|
| Production Profile Build | PASS — `:app-bootstrap:bootJar` 성공 |
| 전체 테스트 | PASS — **3,530 tests / 0 failures / 7 skipped** (BUILD SUCCESSFUL 8m 9s). app-bootstrap 797 · application-core 568 · cache-redis 423 · fileserver 398 · inbound-web 341 · httpclient 283 · shared-contract 224 · objectstorage 140 · persistence-jpa 122 · 그 외 |
| Docker Image Build | PASS — 623MB. `BUILD_VERSION`/`GIT_SHA`/`SOURCE_URL` build-arg를 강제하는 provenance 게이트가 있어 인자 없이는 의도적으로 실패한다 |
| Production Image 실제 실행 | PASS — 컨테이너에서 14.5초 기동, `healthcheck` 200 · `readiness` 200 · `catalog` 200(실데이터 2건) |
| Flyway 마이그레이션 (신규 DB, 처음부터) | PASS — 6개 적용, V7 techlog core 포함, 테이블 33개 생성 |
| 응답 봉투 일관성 | PASS — `{success,data,error,meta}` 전 경로 동일 |
| HTTP 상태 코드 | PASS — 401 / 404 / 405 / 422 / 500 / 503 모두 적절 |
| 인증 오류 코드 분리 | PASS — `AUTH_TOKEN_MISSING` / `AUTH_TOKEN_MALFORMED` / `AUTH_TOKEN_INVALID_SIGNATURE` / `AUTH_TOKEN_EXPIRED` |
| Validation | PASS — 잘못된 enum·필수 누락은 422 + `fieldErrors`, `limit` 상·하한 강제 |
| SQL Injection | PASS — `' OR 1=1--` 파라미터 바인딩되어 빈 결과 |
| Visibility 필터 | PASS — `ARCHIVED` 토픽이 catalog 결과에서 제외됨 |
| CORS | PASS — 허용 origin 200 + `Allow-Credentials: true`, 미허용 origin 403, 와일드카드 없음 |
| 보안 헤더 | PASS — `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Cache-Control: no-store` |
| 오류 정보 노출 | PASS — Stack trace·SQL·내부 클래스명 모두 미노출 (`details: null`) |
| 로그 위생 | PASS — 토큰·Authorization·쿠키·비밀번호 **0건**. `user=`는 가명화 해시 |
| 추적성 | PASS — 모든 요청 로그에 `req=`·`trace=`, `http_request method= uri_template= status= duration_ms=` |
| Metrics | PASS — Prometheus 127개 메트릭 패밀리 (`http_server_requests_seconds_bucket`, `jvm_gc_*`, `jvm_memory_*`, `hikaricp_connections_*`) |
| Liveness / Readiness 분리 | PASS — DB 중단 시 readiness 503 DOWN, liveness 200 UP 유지 |
| 의존성 장애 대응 | PASS(동작) — DB 중단 시 무한 대기 없이 **503 `DB_UNAVAILABLE` (retryable)** 반환, DB 복구 후 27ms/정상 데이터로 자동 회복. 단 응답까지 30초 소요(P1-10) |
---
## 다음 단계 (권장 순서)
1. **Double Prefix 해소** (P0-3) — 컨트롤러 매핑에서 `/api`를 제거하거나 `addPathPrefix` 대상에서 제외. 이걸 고치기 전에는 프론트-백엔드가 한 건도 연결되지 않으므로 최우선.
2. **세션 인프라** (P0-4) — `redis-session` 배선. 백엔드 HANDOFF.md도 Plan 02보다 앞선 선행 작업으로 지목하고 있다.
3. **Studio 인가** (P0-2) — Studio 컨트롤러에 권한 검사 추가 + 권한 없는 사용자 403 회귀 테스트.
4. **prod 부팅 설정** (P0-6, P0-7) — `.env``ddl-auto`, fileserver 마이그레이션 위치.
5. **나머지 16개 오퍼레이션** — 백엔드 HANDOFF.md가 지적한 생성 union 5종의 Jackson 파손 전략 결정이 선행.
6. 배포 레이어 (P1-5·6·7) — nginx/CDN에 보안 헤더·캐시 정책 적용, 프론트엔드 이미지.
7. robots.txt로 Studio 차단 (P1-8).
---
# 2차 검증 — 로컬에서 가능한 항목 완주 (2026-08-19)
1차에서 "물리적으로 불가능"이라 분류했던 항목 중 상당수가 실제로는 검증 가능했다.
Studio는 mock 게이트웨이가 18개 오퍼레이션을 **전부** 구현하고 있고(낙관적 락·검증
staleness·미리보기 만료·경고 승인·멱등성 포함), Public은 정적 콘텐츠지만 UI 동작
항목은 그대로 검증된다. 아래는 그 재검증 결과다.
## 1차 판정 정정
### 정정 1 — P0-6 은 결함이 아니다
`prod` 프로파일이 커밋된 `src/.env`로 부팅하지 않는 것은 **의도된 설계**다.
`application-prod.yml`이 문서화한 5개 startup validator가 개발용 값을 거부한다:
```
JpaSchemaSafetyValidator ddl-auto must be none|validate (exit 71)
FlywayProdSafetyValidator baseline-on-migrate / out-of-order / clean 비활성
StartupSafetyValidator error-detail 노출 · body-capture 로깅 off
PostgreSqlTransportSecurityValidator pgJDBC sslmode=verify-full
PersistenceVendorProdSafetyValidator vendor·URL 모두 H2 금지
```
실측으로 2개가 순서대로 발화하는 것을 확인했다:
```
exit=71 error.code=PROFILE_MISMATCH
"prod profile requires APP_DATASOURCE_DDL_AUTO ... to be none or validate,
but was update; Flyway is the production schema writer"
ddl-auto=none 으로 넘긴 뒤:
"prod PostgreSQL transport requires pgJDBC sslmode=verify-full"
```
**§9 "운영 환경에서 개발용 설정이 활성화되지 않는다"는 PASS**다.
남는 진짜 갭은 별개다 — **운영 값 세트가 저장소에도 배포 시스템에도 아직 없다**(P1로 이동).
### 정정 2 — P0-7 의 심각도 하향
`ddl-auto=validate``fs_cleanup_item` 누락으로 실패하는 것은 사실이나,
prod는 `none|validate` **둘 다** 허용하므로 `none`으로 부팅할 수 있다(실제로 그렇게 기동해 검증했다).
따라서 출시 차단은 아니고, **스키마 검증을 포기해야 한다는 제약**으로 남는다 → P1.
### 정정 3 — 1차의 오탐 2건
- **"Public UI에 Studio 노출"** — 오탐. 매칭된 "Studio"는 전부 게시된 릴리스 노트의 본문
텍스트였다("TechLog Public·Studio 경계를 확정했습니다"). 실제 `a[href^="/studio"]`
모든 Public 화면에서 **0건**. §1.3 PASS.
- **"코드 블록 미표시"** — 오탐. `code-block.tsx``<figure class="code-block">` +
`<pre role="region" tabindex=0>`을 렌더하고 CSS가 `overflow-x:auto`·`max-width:100%`
준다. 정적 공개 문서에 CODE_BLOCK이 0건이라 발견하지 못한 것이며, Studio 편집기에
직접 넣어 확인하니 정상 렌더되고 페이지 가로 오버플로도 없었다. §4 PASS.
## 새로 발견한 결함
| # | 항목 | 근거 |
|---|---|---|
| **N-1** | **로그아웃할 방법이 없다** | `signOut` 포트와 `app-shell.tsx`의 세션 버튼(`action.signOut`="로그아웃")은 존재하지만, **TechLog는 자체 셸(`public-shell.tsx` + Studio 셸)을 쓰고 `AppShell`을 렌더하지 않는다.** 로그인 후 Public·Studio 어느 화면에서도 로그아웃 버튼이 없다. §1.4 "로그아웃", "로그아웃 후 보호된 데이터가 UI 상태에 남지 않는다" 미충족 |
| **N-2** | **중복 관계 생성이 방지되지 않는다** | 같은 대상을 두 번 연결해 저장해도 경고가 없다. 계약에 `uniqueItems` 제약이 없고(`relations: maxItems 20`뿐), `validate-working-copy.ts`도 slug 중복만 검사한다(`SLUG_DUPLICATE`). **백엔드를 구현해도 계약이 허용하므로 같은 결과가 난다.** §3.2 미충족 |
| **N-3** | **CLS 0.192 (기준 0.1)** | 원인 단일: `FOOTER.site-footer`가 t=538ms에 0.1922 이동. 나머지 shift는 0.0001. 세 라우트 모두 동일 값 → 앱 셸 마운트 시점의 footer 점프. §4 "주요 화면의 Layout Shift가 없다" 미충족 |
| **N-4** | **`navigation_path`(slug 조회)에 인덱스가 없다** | 20,000행 기준 `Seq Scan`, `Rows Removed by Filter: 19999`, **236ms**. `enable_seqscan=off`로도 인덱스를 못 쓴다 → 존재하지 않는다. 체크리스트가 명시한 "Slug 조회" 쿼리 패턴 |
| **N-5** | 검색 trgm 인덱스가 플래너에 선택되지 않음 | GIN trgm 인덱스는 존재하고 강제하면 3.96ms로 동작하나, 20k 규모에서 플래너가 Seq Scan(10.3ms)을 고른다. 운영 규모에서 재확인 필요 |
| **N-6** | `/api/v3/api-docs`가 500 | `/swagger-ui`·`/v3/api-docs`는 404로 미배포(정상)인데, path prefix가 붙은 `/api/v3/api-docs`만 500 INTERNAL_ERROR |
## 검증 결과 — 절별
### §1.2 Routing · §1.3 경계 · §2 기능 — 27/27 PASS
```
§1.2 존재하지 않는 Case / 잘못된 explore kind / 없는 프로젝트 / 없는 릴리스
→ 전부 "페이지를 찾을 수 없습니다."
§1.2 Not Found 화면, Back/Forward (/explore→/projects→back→forward) 정상
§1.3 Public 6개 화면에 studio 링크 0건, Draft 표식 0건
§2.1 탐색 목록 6건 · 중복 0 · 필터 적용 6→2건
§2.2 검색창 열림 / Focus 이동 / Overlay 겹침 없음 / 입력 중 과요청 0
결과 없음 UI / ESC 닫기 / 빈 검색어 정책 / 결과 클릭 → 상세 이동
§2.3 프로젝트 목록 2건 · 상세("Backend Skeleton") · 포함 문서 6건
§2.4 변경 기록 목록·상세, 연결 문서 7건, 시간순 정렬 일관
```
### §3 Studio — 20/22 PASS (mock 기준)
```
§3.1 새 문서(유형 4종) → 편집 진입 → 저장 → 상태 전달 PASS
§3.1 저장 버튼 3연타 → 문서 수 8→9 (증가 1) PASS ← 멱등성 실동작
§3.1 미저장 변경 이동 경고 [머무르기/변경 버리기/저장 후 이동] PASS
§3.1 머무르기 후 입력값 보존 PASS
§3.1 검증 화면("저장본 검증") / 게시 화면("게시 준비") PASS
§3.2 관계 추가·순서 이동·삭제, 대상 카탈로그 4건 PASS
§3.2 중복 관계 방지 FAIL (N-2)
§3.3 즉시 미리보기 렌더 / Public Preview 화면 PASS
§3.3 게시 기록 8건 · 게시 취소 버튼 3개 PASS
§20 저장 충돌(409) 사용자 안내 PASS
콘솔 오류 0건
```
문서 **삭제**는 계약에 오퍼레이션 자체가 없다(`deleteStudioAsset`만 존재). §3.1의 "삭제"는 설계 범위 밖.
### §4 UX/UI · §5 접근성 · §6 성능 — 15/18 PASS
```
§4 Layout Shift FAIL CLS=0.1924 (N-3)
§4 Header가 콘텐츠를 가리지 않음 PASS
§4 긴 제목(150자)/긴 본문/긴 URL PASS scrollWidth==clientWidth 1440
§4 코드 블록 (pre overflow-x:auto) PASS
§5 Modal Focus 이동 / role=dialog / Focus Trap / 닫은 뒤 복귀 PASS
§5 키보드 순회 19개 요소 · Focus 표시 전부 존재 PASS
§6 긴 문서 렌더링 296ms PASS
§6 이미지 lazy loading · width/height 명시 PASS
§6 동일 요청 중복 0 · 2초간 DOM 변경 0건(render loop 없음) PASS
§6 검색 21자 입력+반영 847ms PASS
```
### §14 데이터베이스
```
Constraint PK 33 · FK 36 · UNIQUE 18 · CHECK 89 · NOT NULL 267 · PK 없는 테이블 0 PASS
Index 실행계획 (20,000행 기준)
Public 목록(최신순) Index Scan idx_public_latest 0.113ms PASS
유형별 조회 Index Scan idx_public_type 0.129ms PASS
Topic별 조회 Bitmap Index Scan idx_public_topic 0.229ms PASS
검색(trgm) Seq Scan (인덱스 미선택) 10.3ms 주의 (N-5)
slug 조회 Seq Scan (인덱스 부재) 236ms FAIL (N-4)
```
`public_resource_projection`의 인덱스들이 `WHERE publication_state='ACTIVE' AND
visibility='PUBLIC'` 부분 인덱스로 정의되어 있다 — Public/Private 경계를 인덱스 수준에서
강제하는 좋은 설계다(§12를 구현할 때 그대로 활용 가능).
### §19 악용 방지 · §28 Swagger
```
pagination 최대 크기 (limit=1000) 422 REQUEST_VALIDATION_FAILED PASS
q 길이 제한 (500자) 422 REQUEST_VALIDATION_FAILED PASS
Rate Limit APP_RATE_LIMIT_ENABLED=false 미적용
대용량 Body 쓰기 엔드포인트 부재로 검증 불가
/swagger-ui, /v3/api-docs 404 (미배포) PASS
/api/v3/api-docs 500 주의 (N-6)
```
### §26 의존성 장애
```
PostgreSQL Down catalog 503 DB_UNAVAILABLE(retryable) 30s · readiness 503 DOWN
liveness 200 UP 유지 · 복구 후 27ms 정상 PASS
Keycloak Down JWKS 캐시로 기존 토큰 32ms/200 · 잘못된 서명 21ms/401
readiness 200 UP 유지(외부 IdP를 readiness에 걸지 않음)
복구 후 정상 PASS
Backend 단절 Public 화면 정상 유지(정적 소스) PASS
MinIO / Redis 해당 없음(미배선)
```
### §0 · §9 설정
```
src/.env 가 git에 커밋되어 있다 — 값은 local 프로파일용이지만 .gitignore에 .env가 없어
구조적으로 막혀 있지 않다. Redis HMAC은 secret://environment/... 간접 참조를 쓴다(좋은 패턴).
prod 5개 validator 실동작 확인 (정정 1)
show-sql=false · 로그에 토큰/쿠키/비밀번호 0건 · user= 는 가명화 해시
```
## 남은 것 — 로컬에서 불가능
| 절 | 이유 |
|---|---|
| §12 Public/Private 경계 | Public 엔드포인트·문서 엔드포인트 부재 |
| §15 N+1 / JPA Query | Tech Log에 JPA 리포지토리 0건 (catalog는 raw JDBC 단일 쿼리) |
| §16 Transaction | 쓰기 유스케이스 부재 |
| §17 파일/Object Storage | 업로드 엔드포인트·스토리지 배선 부재 |
| §18 HTTPS/HSTS/Redirect | TLS 종단 필요 |
| §22 Grafana·Loki 대시보드 | 관측 스택 필요 (수집 측 127개 메트릭은 확인 완료) |
| §24 Kubernetes | 매니페스트·오케스트레이터 부재 |
| §25 Ingress 라우팅 · X-Forwarded-* | 리버스 프록시 필요 |
| §27 Backup / Restore | 실제 볼륨·운영 DB 필요 |
| §30 Production Smoke Test | 운영 환경 부재 |
| §1.4 세션 만료 · 토큰 만료 후 프론트 동작 | demo 어댑터에 만료 개념이 없음 (외부 IdP 연동 필요) |
+14
View File
@@ -594,6 +594,20 @@ const commonSecurityRules = {
"CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']",
message: "Runtime script construction is prohibited by FE-OC-019.",
},
{
/*
setState 업데이터는 핸들러가 끝난 뒤, 다음 렌더에 실행된다. 그때 React 는 이미
`event.currentTarget` 을 null 로 되돌려 놓았으므로 업데이터 안에서 그것을 읽으면
"Cannot read properties of null" 로 화면이 통째로 죽는다.
타입 검사도 lint 도 잡지 못했고, 첫 입력에서야 드러났다 — 값은 핸들러가 도는 동안
지역 변수로 꺼내 두고 업데이터에는 그 값을 넘긴다.
*/
selector:
"CallExpression[callee.name=/^set[A-Z]/] > ArrowFunctionExpression MemberExpression[property.name='currentTarget']",
message:
"Read event.currentTarget before the setState updater runs — it is null by the time the updater is called.",
},
],
};
+5
View File
@@ -5,6 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Tech Log frontend" />
<title>Tech Log</title>
<!-- public/favicon.svg 는 빌드가 dist 루트로 복사하고 nginx 도 서빙하지만,
참조가 없어 브라우저는 /favicon.ico 를 찾다가 404 를 받고 기본 아이콘을
띄우고 있었다. %BASE_URL% 은 Vite 가 base 로 치환한다 — 경로 프리픽스
배포(/dev/)에서도 같은 파일을 가리키게 하려면 절대경로여선 안 된다. -->
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
</head>
<body>
<div id="root"></div>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+19 -5
View File
@@ -38,14 +38,28 @@
},
"contractSet": {
"setAlgorithm": "CA_CONTRACT_SET_V1",
"setDigest": "sha256:e0da77655f51592ece583826d5fc6b092f57dd2bf63307e45e7e77283e6bf437",
"setDigest": "sha256:cdcfb628a502d71596f1162726eb395aad0f5f92cf05fd77d304f8e51c81b2fc",
"packages": [
{
"packageId": "@tech-log/studio-contract",
"version": "2.0.0",
"digest": "sha256:99f54f56ea0c582eafdbdf9be5653e3384bef0a1b08bff67f3147ee0292019ea",
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
"runtimeProtocolVersion": 1,
"sourceRevision": "ce2e748"
"sourceRevision": "ef49d3a"
},
{
"packageId": "@tech-log/public-contract",
"version": "2.1.0",
"digest": "sha256:7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e",
"runtimeProtocolVersion": 1,
"sourceRevision": "ef49d3a"
},
{
"packageId": "@tech-log/studio-contract",
"version": "3.1.0",
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
"runtimeProtocolVersion": 1,
"sourceRevision": "ef49d3a"
}
]
}
+24 -3
View File
@@ -459,7 +459,21 @@ const CANONICAL_GATE_SHAPE_SHA256 =
// Dev release manifest drift fix, item 2: recomputed again after FE-GATE-010
// gained `check-dev-release-manifest`. Same method — 98d19911… was first
// reproduced from the previous gates.json before this value was hashed.
"b40962448e617883060e09eb7183837cfea6833519f435f11713efd083210dbe";
// Taxonomy route: recomputed again after FE-GATE-009 gained the
// TECH_LOG_STUDIO_TAXONOMY manual accessibility evidence artifact. The gate
// lists one evidence artifact per installed route and refuses a set that does
// not match the route scope exactly, so adding a route necessarily moves this
// digest — that is the point of pinning it.
// Release authoring: recomputed again after FE-GATE-009 gained the
// TECH_LOG_STUDIO_RELEASES evidence artifact, by the same method — 8c73d447…
// was first reproduced from the previous gates.json, so the computation that
// produced this value is known to be the one the constant was pinned under.
// 프로젝트 편집 화면: FE-GATE-009 가 TECH_LOG_STUDIO_PROJECT_EDIT 증거 아티팩트를
// 얻어 다시 계산했다. 같은 방법이다 — 187dbd96… 을 이전 gates.json 에서 먼저 재현해,
// 이 값을 만든 계산이 상수가 고정될 때 쓰인 그 계산임을 확인했다.
// 릴리즈 편집 화면: 같은 방법으로 다시 계산했다. f9e7e521… 을 이전 gates.json 에서 먼저
// 재현했다.
"fb138e7c51fdf969f755cd8ff32cf627f1750b966d212c33ee996c8c578db0e3";
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
const normalized = gates.map(
@@ -512,8 +526,15 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
// Template merge. 126 product artifacts plus the two the template added.
// Task 11 added one more: the TECH_LOG_STUDIO_ASSETS manual a11y evidence file.
// Alignment follow-up, item 2 added the TechLog junit report.
if (contract.artifacts.length !== 130) {
failures.push(`artifact authority baseline must contain exactly 130 artifacts; received ${contract.artifacts.length}`);
// The taxonomy route added its own manual a11y evidence file — every installed
// route carries one, and the gate checks that the two sets match exactly.
// The project edit route did the same: it is what finally lets a project carry
// a purpose, a current objective, and a next step, so the public screens that
// read those fields stop rendering blanks.
// The release edit route followed: the editor used to open below the release
// list, so editing meant scrolling past every release to reach it.
if (contract.artifacts.length !== 134) {
failures.push(`artifact authority baseline must contain exactly 134 artifacts; received ${contract.artifacts.length}`);
}
if (contract.stages.length !== 5) {
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
+231
View File
@@ -0,0 +1,231 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
/**
* Emits the nginx configuration the deployed frontend is served with.
*
* Generated rather than hand-written because three files already decide what it
* must say, and a copy of them would drift: `dist/tech-log-serving-contract.json`
* (which paths are SPA routes and what a miss answers with),
* `config/hosting/security-headers.json`, and `config/hosting/cache-policy.json`.
* The repository had no frontend deployment artifact at all — no Dockerfile, no
* server config — so those two hosting files described a contract nothing
* fulfilled: `dist/server.mjs` applies neither, answering `no-cache` for hashed
* assets and sending no security headers.
*
* This serves static files only. TLS and the BFF paths belong to the edge: the
* deployment's own nginx terminates HTTPS and sends `/api`, the OIDC redirect
* chain and the identity provider to the backend directly (in Kubernetes,
* Traefik does). A second proxy hop here would only add a place for the two
* routing tables to disagree.
*
* The base path comes from `VITE_ROUTER_BASE_PATH`, the same value the bundle is
* built with: served under a prefix, every route and asset lives under it too.
*/
const DIST = "dist";
const OUT = path.join(DIST, "nginx.conf");
type ServingContract = Readonly<{
publicSpaPathPatterns: readonly string[];
studioPathPrefix: string;
studioSpaPathPatterns: readonly string[];
notFound: Readonly<{ status: number; contentType: string; body: string }>;
}>;
type HostingHeaders = Readonly<{ headers: Readonly<Record<string, string>> }>;
type CachePolicy = Readonly<{
surfaces: Readonly<
Record<
string,
Readonly<{
path?: string;
pathPattern?: string;
cacheControl?: string;
securityHeaders?: boolean;
}>
>
>;
}>;
async function readJson<T>(file: string): Promise<T> {
return JSON.parse(await readFile(file, "utf8")) as T;
}
/** nginx location matching is not regex-escaped for us; only `=` exact paths are literal. */
function exactLocation(pathname: string): string {
return pathname;
}
/**
* A JS regex from the contract translated for nginx. Both use PCRE-ish syntax
* for what the contract uses (`^`, `$`, `[^/]+`, alternation), so the pattern
* carries over unchanged — asserted rather than assumed, because a pattern that
* silently failed to translate would open a Studio route to the 404 branch.
*/
function studioRegex(pattern: string): string {
// nginx uses PCRE, so anchors, character classes, alternation and plain groups
// carry over as written. Lookaround and backreferences do not translate the
// same way and would silently change which paths match, so they are refused.
if (/\(\?[=!<]|\\[1-9]/.test(pattern)) {
throw new Error(
`studio SPA pattern uses a construct this generator does not translate: ${pattern}`,
);
}
return pattern;
}
function headerDirectives(
headers: Readonly<Record<string, string>>,
indent: string,
): string {
return Object.entries(headers)
.map(([name, value]) => `${indent}add_header ${name} "${value}" always;`)
.join("\n");
}
async function main(): Promise<void> {
const contract = await readJson<ServingContract>(
path.join(DIST, "tech-log-serving-contract.json"),
);
const security = await readJson<HostingHeaders>(
"config/hosting/security-headers.json",
);
const cache = await readJson<CachePolicy>("config/hosting/cache-policy.json");
const surfaces = cache.surfaces;
const indexCache = surfaces["index"]?.cacheControl ?? "no-cache";
const configCache = surfaces["runtimeConfig"]?.cacheControl ?? "no-store";
const manifestCache = surfaces["releaseManifest"]?.cacheControl ?? "no-store";
const assetCache = surfaces["hashedAsset"]?.cacheControl ?? "no-cache";
const secure = headerDirectives(security.headers, " ");
// The bundle's own base path. `/` for a deployment at the domain root, `/dev/`
// for one served under a prefix — the routes below have to carry it or nginx
// matches paths the browser never asks for.
const rawBase = process.env["VITE_ROUTER_BASE_PATH"] ?? "/";
const basePath = rawBase.endsWith("/") ? rawBase.slice(0, -1) : rawBase;
const [notFoundType, notFoundCharsetParam] = contract.notFound.contentType
.split(";")
.map((part) => part.trim());
const notFoundCharset = (notFoundCharsetParam ?? "charset=utf-8")
.replace(/^charset=/i, "")
.toLowerCase();
// Regex locations now, matching the Studio half: the contract declares which
// paths the router serves, not which ones the fixture happened to contain, so
// a record published after this build is served instead of 404ed at the edge.
const publicLocations = contract.publicSpaPathPatterns
.map(
(pattern: string) => ` location ~ ^${basePath}${studioRegex(pattern).slice(1)} {
${secure}
add_header Cache-Control "${indexCache}" always;
try_files /index.html =404;
}`,
)
.join("\n\n");
const studioLocations = contract.studioSpaPathPatterns
.map(
(pattern) => ` location ~ ^${basePath}${studioRegex(pattern).slice(1)} {
${secure}
add_header Cache-Control "${indexCache}" always;
try_files /index.html =404;
}`,
)
.join("\n\n");
const conf = `# Generated by scripts/generate-nginx-config.ts — do not edit.
# Sources: dist/tech-log-serving-contract.json, config/hosting/security-headers.json,
# config/hosting/cache-policy.json
#
# Plain HTTP on purpose: the edge terminates TLS and this container is only ever
# reached from inside the deployment network.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# The bundle is small and already compressed at rest by the build; gzip here
# covers the JSON surfaces and index.html.
gzip on;
gzip_types text/css application/javascript text/javascript application/json;
gzip_min_length 1024;
# Static surfaces, per config/hosting/cache-policy.json
# ---------------------------------------------------------------------------
location = ${basePath}/config.json {
alias /usr/share/nginx/html/config.json;
${secure}
add_header Cache-Control "${configCache}" always;
}
location = ${basePath}/release-manifest.json {
alias /usr/share/nginx/html/release-manifest.json;
${secure}
add_header Cache-Control "${manifestCache}" always;
}
# Content-hashed filenames, so the long TTL is safe and revalidation is waste.
location ${basePath}/assets/ {
# alias, not root + URI: under a base path the request is /dev/assets/x.js
# while the file is dist/assets/x.js, so root would look for
# dist/dev/assets/x.js and answer 404 for every script on the page.
alias /usr/share/nginx/html/assets/;
add_header Cache-Control "${assetCache}" always;
}
# Source maps are not published (cache-policy sourceMap.public = false).
location ~ \\.map$ {
return 404;
}
# ---------------------------------------------------------------------------
# SPA routes. Enumerated from the serving contract rather than a catch-all:
# a path that is not a real route answers 404 instead of a 200 shell, which is
# what tells a crawler the difference.
# ---------------------------------------------------------------------------
${publicLocations}
${studioLocations}
location = ${basePath}/favicon.svg {
alias /usr/share/nginx/html/favicon.svg;
add_header Cache-Control "${assetCache}" always;
}
location = ${basePath}/media/ {
return 404;
}
location ${basePath}/media/ {
alias /usr/share/nginx/html/media/;
add_header Cache-Control "${assetCache}" always;
}
# Anything else is not a route this deployment serves.
location / {
# The contract states the content type with its charset attached
# (text/plain;charset=UTF-8), but nginx takes the two separately —
# default_type rejects a parameter outright.
default_type ${notFoundType};
charset ${notFoundCharset};
return ${contract.notFound.status} "${contract.notFound.body}";
}
}
`;
await writeFile(OUT, conf, "utf8");
process.stdout.write(
`nginx config: ${OUT} (${contract.publicSpaPathPatterns.length} public routes, ` +
`${contract.studioSpaPathPatterns.length} studio patterns)\n`,
);
}
await main();
+111 -60
View File
@@ -1,5 +1,5 @@
/**
* canonical studio-v1.yaml을 vendor하고 타입을 생성한다.
* canonical 계약(studio-v1, public-v1)을 vendor하고 타입을 생성한다.
*
* 생성기는 저장소 의존성에 넣지 않는다. `openapi-typescript`는 TypeScript 5의
* classic compiler API를 요구하는데 이 저장소는 TypeScript 7.0.2를 고정하고
@@ -12,15 +12,53 @@
*/
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { argv, env, exit } from "node:process";
const CANONICAL_ROOT =
env.TECH_LOG_DESIGN_PACKAGE ?? "/home/donghyeon/workspace/tech-log-design-package";
const CANONICAL_YAML = `${CANONICAL_ROOT}/contracts/openapi/studio-v1.yaml`;
const VENDOR_YAML = "src/features/tech-log/contracts/studio/studio-api.openapi.yaml";
const GENERATED = "src/features/tech-log/contracts/studio/generated.ts";
const SOURCE_RECORD = "src/features/tech-log/contracts/studio/canonical-source.json";
/**
* 계약은 둘이고 서로 독립이다. Studio는 인증된 작성 표면이고, Public은 인증
* 없는 조회 표면이다. 각자 자기 canonical yaml에서 나오고 자기 digest를 들고
* 다니므로, 한쪽이 갱신돼도 다른 쪽 drift 게이트는 조용하다.
*/
type ContractTarget = Readonly<{
name: string;
packageId: string;
canonicalYaml: string;
vendorYaml: string;
generated: string;
sourceRecord: string;
}>;
const CONTRACTS: readonly ContractTarget[] = Object.freeze([
Object.freeze({
name: "studio",
packageId: "@tech-log/studio-contract",
canonicalYaml: `${CANONICAL_ROOT}/contracts/openapi/studio-v1.yaml`,
vendorYaml: "src/features/tech-log/contracts/studio/studio-api.openapi.yaml",
generated: "src/features/tech-log/contracts/studio/generated.ts",
sourceRecord: "src/features/tech-log/contracts/studio/canonical-source.json",
}),
Object.freeze({
name: "public",
packageId: "@tech-log/public-contract",
canonicalYaml: `${CANONICAL_ROOT}/contracts/openapi/public-v1.yaml`,
vendorYaml: "src/features/tech-log/contracts/public/public-api.openapi.yaml",
generated: "src/features/tech-log/contracts/public/generated.ts",
sourceRecord: "src/features/tech-log/contracts/public/canonical-source.json",
}),
Object.freeze({
name: "management",
packageId: "@tech-log/management-contract",
canonicalYaml: `${CANONICAL_ROOT}/contracts/openapi/studio-management-v1.yaml`,
vendorYaml: "src/features/tech-log/contracts/management/management-api.openapi.yaml",
generated: "src/features/tech-log/contracts/management/generated.ts",
sourceRecord: "src/features/tech-log/contracts/management/canonical-source.json",
}),
]);
const OPENAPI_TYPESCRIPT = "openapi-typescript@7.9.1";
const GENERATOR_TYPESCRIPT = "typescript@5.9.3";
@@ -56,71 +94,84 @@ function fail(problems: readonly string[]): never {
}
if (check) {
const vendored = readFileSync(VENDOR_YAML, "utf8");
const generated = readFileSync(GENERATED, "utf8");
const record = JSON.parse(readFileSync(SOURCE_RECORD, "utf8")) as CanonicalRecord;
const problems: string[] = [];
const summaries: string[] = [];
if (digestOf(readFileSync(VENDOR_YAML)) !== record.digest) {
problems.push(`${VENDOR_YAML} does not hash to the recorded digest`);
}
const vendoredOperations = operationIdsOf(vendored);
if (vendoredOperations.join(" ") !== [...record.operationIds].join(" ")) {
problems.push(`${SOURCE_RECORD} operationIds differ from ${VENDOR_YAML}`);
}
if (specVersionOf(vendored) !== record.version) {
problems.push(`${SOURCE_RECORD} version differs from ${VENDOR_YAML}`);
}
// 생성물은 operationId로 키가 매겨진 `operations` 인터페이스를 노출한다.
for (const operationId of record.operationIds) {
if (!new RegExp(`^\\s{4}${operationId}:`, "mu").test(generated)) {
problems.push(`${GENERATED} is missing operation ${operationId}`);
for (const target of CONTRACTS) {
const vendored = readFileSync(target.vendorYaml, "utf8");
const generated = readFileSync(target.generated, "utf8");
const record = JSON.parse(readFileSync(target.sourceRecord, "utf8")) as CanonicalRecord;
if (digestOf(readFileSync(target.vendorYaml)) !== record.digest) {
problems.push(`${target.vendorYaml} does not hash to the recorded digest`);
}
if (operationIdsOf(vendored).join(" ") !== [...record.operationIds].join(" ")) {
problems.push(`${target.sourceRecord} operationIds differ from ${target.vendorYaml}`);
}
if (specVersionOf(vendored) !== record.version) {
problems.push(`${target.sourceRecord} version differs from ${target.vendorYaml}`);
}
if (record.packageId !== target.packageId) {
problems.push(`${target.sourceRecord} packageId is not ${target.packageId}`);
}
// 생성물은 operationId로 키가 매겨진 `operations` 인터페이스를 노출한다.
for (const operationId of record.operationIds) {
if (!new RegExp(`^\\s{4}${operationId}:`, "mu").test(generated)) {
problems.push(`${target.generated} is missing operation ${operationId}`);
}
}
summaries.push(
`${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations`,
);
}
if (problems.length > 0) fail(problems);
console.log(
`tech-log contract is in sync: ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
);
console.log(`tech-log contracts are in sync:\n- ${summaries.join("\n- ")}`);
exit(0);
}
const canonicalBytes = readFileSync(CANONICAL_YAML);
const canonicalText = canonicalBytes.toString("utf8");
const sourceRevision = execFileSync(
"git",
["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"],
{ encoding: "utf8" },
).trim();
const record: CanonicalRecord = {
packageId: "@tech-log/studio-contract",
version: specVersionOf(canonicalText),
digest: digestOf(canonicalBytes),
sourceRevision: execFileSync(
"git",
["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"],
{ encoding: "utf8" },
).trim(),
operationIds: operationIdsOf(canonicalText),
};
for (const target of CONTRACTS) {
const canonicalBytes = readFileSync(target.canonicalYaml);
const canonicalText = canonicalBytes.toString("utf8");
// 격리 실행. 저장소의 node_modules와 lockfile은 그대로다.
const generated = execFileSync(
"corepack",
[
"pnpm",
"dlx",
"--package",
GENERATOR_TYPESCRIPT,
"--package",
OPENAPI_TYPESCRIPT,
"openapi-typescript",
CANONICAL_YAML,
],
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
);
const record: CanonicalRecord = {
packageId: target.packageId,
version: specVersionOf(canonicalText),
digest: digestOf(canonicalBytes),
sourceRevision,
operationIds: operationIdsOf(canonicalText),
};
writeFileSync(VENDOR_YAML, canonicalText);
writeFileSync(GENERATED, generated);
writeFileSync(SOURCE_RECORD, `${JSON.stringify(record, null, 2)}\n`);
console.log(
`Generated from ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
);
// 격리 실행. 저장소의 node_modules와 lockfile은 그대로다.
const generated = execFileSync(
"corepack",
[
"pnpm",
"dlx",
"--package",
GENERATOR_TYPESCRIPT,
"--package",
OPENAPI_TYPESCRIPT,
"openapi-typescript",
target.canonicalYaml,
],
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
);
mkdirSync(dirname(target.vendorYaml), { recursive: true });
writeFileSync(target.vendorYaml, canonicalText);
writeFileSync(target.generated, generated);
writeFileSync(target.sourceRecord, `${JSON.stringify(record, null, 2)}\n`);
console.log(
`Generated from ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
);
}
// 재생성은 매번 package digest를 바꾼다. `pnpm dev`가 그대로 서빙하는
// `public/release-manifest.json`은 build가 컴파일한 contract set을 그대로
+13 -10
View File
@@ -1,15 +1,18 @@
import {
projects,
publicRecords,
releases,
} from "../src/features/tech-log/adapters/static/public-content.ts";
import { TECH_LOG_ROUTE_REGISTRY } from "../src/features/tech-log/contracts/tech-log-route-contract.ts";
import { writeTechLogServingArtifact } from "./lib/tech-log-serving-artifact.ts";
import { createTechLogServingContract } from "./lib/tech-log-serving-contract.ts";
const contract = createTechLogServingContract({
projects,
publicRecords,
releases,
});
// The router owns which public paths exist. Reading them from the catalog
// instead — as this did — pinned the served set to whatever the bundled fixture
// contained on the day of the build.
const publicRoutePaths = Object.values(TECH_LOG_ROUTE_REGISTRY)
.filter((route) => route.layoutGroup === "PUBLIC")
.map((route) => route.path);
const studioRoutePaths = Object.values(TECH_LOG_ROUTE_REGISTRY)
.filter((route) => route.layoutGroup === "STUDIO")
.map((route) => route.path);
const contract = createTechLogServingContract({ publicRoutePaths, studioRoutePaths });
await writeTechLogServingArtifact({ distRoot: "dist", contract });
+4 -2
View File
@@ -45,7 +45,9 @@ export function createTechLogProductionServer({
contract,
}) {
const absoluteRoot = path.resolve(root);
const publicSpaPaths = new Set(contract.publicSpaPaths);
const publicSpaPathPatterns = contract.publicSpaPathPatterns.map(
(pattern) => new RegExp(pattern),
);
const studioSpaPathPatterns = contract.studioSpaPathPatterns.map(
(pattern) => new RegExp(pattern),
);
@@ -69,7 +71,7 @@ export function createTechLogProductionServer({
}
}
if (
publicSpaPaths.has(pathname) ||
publicSpaPathPatterns.some((pattern) => pattern.test(pathname)) ||
studioSpaPathPatterns.some((pattern) => pattern.test(pathname))
) {
await sendFile(path.join(absoluteRoot, "index.html"), request.method, response);
+65 -52
View File
@@ -1,6 +1,20 @@
export type TechLogServingContract = Readonly<{
schemaVersion: 1;
publicSpaPaths: readonly string[];
schemaVersion: 2;
/**
* Patterns, not an enumeration.
*
* This used to list every public path the bundled fixture happened to
* contain, and the generated nginx served exactly those. A record published
* after the build — which is the entire point of a backend — answered 404 at
* the edge before the SPA was ever asked, and no amount of correct routing
* inside the bundle could recover it.
*
* The route contract already declares which paths exist; the catalog only
* decides which of them currently resolve, and that is the SPA's call, not
* the web server's. Studio has been pattern-based all along — this brings the
* public half to the same footing.
*/
publicSpaPathPatterns: readonly string[];
studioPathPrefix: "/studio";
studioSpaPathPatterns: readonly string[];
notFound: Readonly<{
@@ -11,68 +25,67 @@ export type TechLogServingContract = Readonly<{
}>;
type ServingContractInput = Readonly<{
publicRecords: readonly Readonly<{
path: string;
topicSlug: string;
}>[];
projects: readonly Readonly<{ slug: string }>[];
releases: readonly Readonly<{ path: string }>[];
/**
* The public route templates the router registers, in route-contract form
* (`/cases/:slug`). Passed in rather than imported so this module stays a
* pure transform the tests can drive directly.
*/
publicRoutePaths: readonly string[];
/** The Studio route templates, same form and same reason. */
studioRoutePaths: readonly string[];
}>;
const staticPublicPaths = Object.freeze([
"/",
"/explore",
"/explore/cases",
"/explore/questions",
"/explore/references",
"/profile",
"/projects",
"/releases",
"/search",
]);
/**
* `/cases/:slug` -> `^/cases/[^/]+$`. A parameter matches one segment and never
* a slash, which is what keeps `/cases/a/b` a 404 instead of a case page.
*/
function patternOf(routePath: string): string {
const escaped = routePath
.split("/")
.map((segment) =>
segment.startsWith(":")
? "[^/]+"
: segment.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"),
)
.join("/");
return `^${escaped === "" ? "/" : escaped}$`;
}
const studioSpaPathPatterns = Object.freeze([
"^/studio$",
// The Asset Library is a first-class Studio route (TECH_LOG_STUDIO_ASSETS in
// the route contract) but was never listed here, so a hard navigation or a
// reload of /studio/assets was served the in-shell Studio 404 -- the screen
// was only reachable by client-side navigation from another Studio page.
"^/studio/assets$",
"^/studio/documents$",
"^/studio/documents/new$",
"^/studio/documents/[^/]+/(edit|validation|preview|publish)$",
"^/studio/publications$",
"^/studio/publications/[^/]+/preview$",
]);
function asciiCompare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
export function createTechLogServingContract({
publicRecords,
projects,
releases,
}: ServingContractInput): TechLogServingContract {
const publicSpaPaths = new Set(staticPublicPaths);
for (const record of publicRecords) {
publicSpaPaths.add(record.path);
publicSpaPaths.add(`/topics/${record.topicSlug}`);
/**
* The catch-all is the SPA's own not-found screen; serving index.html for every
* unmatched URL would turn the edge 404 into a soft 200 and hide broken links
* from crawlers and from us.
*/
function patternsFor(routePaths: readonly string[]): readonly string[] {
const patterns = new Set<string>();
for (const routePath of routePaths) {
if (routePath === "*" || routePath.includes("*")) continue;
patterns.add(patternOf(routePath));
}
for (const project of projects) {
const projectPath = `/projects/${project.slug}`;
publicSpaPaths.add(projectPath);
publicSpaPaths.add(`${projectPath}/activity`);
publicSpaPaths.add(`${projectPath}/decisions`);
publicSpaPaths.add(`${projectPath}/records`);
}
for (const release of releases) publicSpaPaths.add(release.path);
return Object.freeze([...patterns].sort(asciiCompare));
}
export function createTechLogServingContract({
publicRoutePaths,
studioRoutePaths,
}: ServingContractInput): TechLogServingContract {
return Object.freeze({
schemaVersion: 1,
publicSpaPaths: Object.freeze([...publicSpaPaths].sort(asciiCompare)),
schemaVersion: 2,
publicSpaPathPatterns: patternsFor(publicRoutePaths),
studioPathPrefix: "/studio",
studioSpaPathPatterns,
// Derived, not listed. This was a hand-maintained array, and it went stale
// exactly the way a hand-maintained array does: /studio/assets was missing
// for its whole life, and /studio/releases repeated the mistake the moment
// it was added — the route worked by client-side navigation and 404'd on
// reload, because nginx had never heard of it. The route contract already
// knows which Studio paths exist, so ask it.
studioSpaPathPatterns: patternsFor(studioRoutePaths),
notFound: Object.freeze({
status: 404,
contentType: "text/plain;charset=UTF-8",
+111
View File
@@ -0,0 +1,111 @@
/**
* 배포본 전수 확인.
*
* 이 파일은 절차 실패에서 나왔다 — 고친 화면만 확인하고 배포해서, 나머지가 깨진 것은 매번
* 사용자가 먼저 발견했다. 운영 환경이므로 배포 전에 모든 화면을 한 번씩 열어 보는 것이 맞다.
*
* 각 화면에서 보는 것: main 이 그려졌는지, 콘솔 오류, 4xx/5xx API 응답, 그리고 화면에 뜬
* 오류 문구. 하나라도 있으면 그 화면을 실패로 적고 끝까지 진행한다.
*
* SPW=<비밀번호> node scripts/smoke/production-sweep.mjs [origin]
*/
import process from "node:process";
import { chromium, type Page } from "@playwright/test";
const ORIGIN = process.argv[2] ?? "https://hyeonworks.com";
const PW = process.env.SPW;
const ERROR_TEXT =
/요청을 처리하지 못했습니다|지원 정보 확인|표시할 수 없습니다|불러오지 못했습니다|화면을 찾을 수 없습니다|Not Found/;
const results: { label: string; path: string; problems: string[] }[] = [];
async function visit(
page: Page,
label: string,
path: string,
{ expectMain = true }: { expectMain?: boolean } = {},
) {
const problems: string[] = [];
const onConsole = (m: { type(): string; text(): string }) => { if (m.type() === "error" && !/401/.test(m.text())) problems.push(`console: ${m.text().slice(0, 120)}`); };
const onResponse = (r: {
url(): string;
status(): number;
request(): { method(): string };
}) => {
const u = new URL(r.url()).pathname;
if (r.status() < 400 || !u.startsWith("/api")) return;
// 두 가지는 화면이 다루는 정상 상태다: 로그인 전 세션 탐침의 401, 그리고 아직 미리보기를
// 만들지 않은 문서의 404. 이것들을 실패로 세면 매번 같은 줄이 뜨고, 진짜 실패가 그 사이에
// 묻힌다 — 늑대가 왔다고 매번 외치는 점검은 아무도 읽지 않는다.
if (u.includes("/studio/session")) return;
if (r.status() === 404 && r.request().method() === "GET" && u.endsWith("/preview")) return;
problems.push(`${r.status()} ${r.request().method()} ${u}`);
};
page.on("console", onConsole);
page.on("response", onResponse);
try {
await page.goto(ORIGIN + path, { waitUntil: "domcontentloaded", timeout: 45000 });
await page.waitForTimeout(3000);
const main = await page.locator("main").count();
const body = (await page.locator("body").innerText().catch(() => "")).replace(/\s+/g, " ");
if (expectMain && main === 0) problems.push("main 없음");
const shown = body.match(ERROR_TEXT);
if (shown) problems.push(`화면 문구: ${shown[0]}`);
} catch (error) {
problems.push(`이동 실패: ${String(error).slice(0, 90)}`);
} finally {
page.off("console", onConsole);
page.off("response", onResponse);
}
results.push({ label, path, problems });
console.log(`${problems.length ? "✗" : "✓"} ${label.padEnd(22)} ${path}`);
for (const p of problems) console.log(` ${p}`);
}
const browser = await chromium.launch();
const page = await (await browser.newContext()).newPage();
console.log("=== 공개 ===");
for (const [label, path] of [
["홈", "/"], ["탐색", "/explore"], ["Case 목록", "/explore/cases"],
["프로젝트", "/projects"], ["변경 기록", "/releases"], ["릴리즈 상세", "/releases/0.1.0"],
["검색", "/search"], ["프로필", "/profile"],
]) await visit(page, label, path);
if (!PW) { console.log("\n(SPW 없음 — Studio 생략)"); await browser.close(); process.exit(0); }
console.log("\n=== 로그인 ===");
await page.goto(ORIGIN + "/studio", { waitUntil: "domcontentloaded", timeout: 60000 });
await page.waitForTimeout(2500);
const start = page.getByRole("button", { name: /로그인 시작/ }).or(page.getByRole("link", { name: /로그인 시작/ }));
if (await start.count()) { await start.first().click(); await page.waitForTimeout(5000); }
await page.fill("#username", "hyeonworks");
await page.fill("#password", PW);
await page.click("#kc-login, input[type=submit], button[type=submit]");
await page.waitForTimeout(6000);
console.log(" 로그인 후:", page.url().replace(ORIGIN, "") || "/");
console.log("\n=== Studio ===");
for (const [label, path] of [
["대시보드", "/studio"], ["작업본", "/studio/documents"], ["새 문서", "/studio/documents/new"],
["게시 기록", "/studio/publications"], ["Asset", "/studio/assets"],
["주제·프로젝트", "/studio/taxonomy"], ["릴리즈", "/studio/releases"],
]) await visit(page, label, path);
// 작업본 하나를 골라 편집·검증·미리보기까지 연다
await page.goto(ORIGIN + "/studio/documents", { waitUntil: "domcontentloaded" });
await page.waitForTimeout(3000);
const href = await page.locator("a[href*='/studio/documents/'][href$='/edit']").first().getAttribute("href").catch(() => null);
if (href) {
const id = href.split("/")[3];
console.log("\n=== 문서 흐름 ===", id);
for (const [label, suffix] of [["편집", "/edit"], ["검증", "/validation"], ["미리보기", "/preview"], ["게시", "/publish"]])
await visit(page, label, `/studio/documents/${id}${suffix}`);
} else console.log("\n(편집 링크를 찾지 못해 문서 흐름 생략)");
await browser.close();
const failed = results.filter((r) => r.problems.length);
console.log(`\n=== 결과 === ${results.length - failed.length}/${results.length} 통과`);
for (const r of failed) console.log(`${r.label} (${r.path}): ${r.problems.join(" | ").slice(0, 160)}`);
process.exit(failed.length ? 1 : 0);
+22 -3
View File
@@ -1,4 +1,5 @@
import { resolveRuntimeCapabilities } from "../contracts/runtime-capabilities.ts";
import { installBffSessionOwner } from "../features/tech-log/adapters/http/bff-session-owner.ts";
import { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts";
import { createCompositionRoot } from "./composition-root.ts";
import { loadReleaseManifest } from "./load-release-manifest.ts";
@@ -27,13 +28,31 @@ export async function createRuntimeComposition(
loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }),
loadRelease: (runtime) =>
loadReleaseManifest(runtime, { fetcher: dependencies.fetcher }),
createAdapters: ({ config: runtime, release }) =>
createRuntimeAdapters({
createAdapters: ({ config: runtime, release }) => {
// `AUTH_MODE: "external"` delegates the session to whoever hosts this
// bundle. For Tech Log that host is its own backend — the session is an
// httpOnly cookie the SPA cannot read — so the owner is installed here,
// before the adapters resolve it. Only for the HTTP Studio: the MOCK
// source has no backend to ask, and `demo` keeps its own adapter.
const host =
dependencies.host ?? (globalThis as unknown as Record<string, unknown>);
if (
runtime.config.AUTH_MODE === "external" &&
runtime.config.TECH_LOG_STUDIO_SOURCE === "HTTP"
) {
installBffSessionOwner(
host,
runtime.config.API_BASE_URL,
dependencies.fetcher ?? fetch,
);
}
return createRuntimeAdapters({
runtime,
release,
fetcher: dependencies.fetcher,
host: dependencies.host,
}),
});
},
});
const capabilities = resolveRuntimeCapabilities(
+13
View File
@@ -513,6 +513,18 @@ export async function createRuntimeAdapters(
techLogCsrf,
);
if (studioOutcome) return studioOutcome;
// An anonymous profile carries no credentials by definition — the
// registry refuses to install one that allows any credential header. It
// must therefore never consult the session: a signed-out visitor's state
// is `unauthenticated`, and falling through below refused every public
// read before it left the browser. The public site rendered its terminal
// error surface on every screen with no request in the network log.
//
// Keyed on the profile's transport rather than a profile id, so any
// anonymous operation is covered rather than one named surface.
if (INSTALLED_REST_AUTH_PROFILES.get(operation.authProfileId)?.transport === "ANONYMOUS") {
return Object.freeze({ kind: "READY" as const, headers: Object.freeze({}) });
}
const state = authSession.getState();
if (state === "integration-failed") {
return Object.freeze({ kind: "UNAVAILABLE" as const });
@@ -599,6 +611,7 @@ export async function createRuntimeAdapters(
const featureInputs = createInstalledFeatureInputs({
contractOperations,
studioSource: config.TECH_LOG_STUDIO_SOURCE,
publicSource: config.TECH_LOG_PUBLIC_SOURCE,
apiBaseUrl: config.API_BASE_URL,
requestTimeoutMs: config.REQUEST_TIMEOUT_MS,
csrf: techLogCsrf,
+10
View File
@@ -56,6 +56,12 @@ export type RuntimeConfig = Readonly<{
* MOCK.
*/
TECH_LOG_STUDIO_SOURCE: "MOCK" | "HTTP";
/**
* §3.5-adjacent runtime switch: which public-read adapter this build talks
* to. Independent of the Studio switch — the two surfaces are separate
* services. A V1 document predates the key and normalizes to MOCK.
*/
TECH_LOG_PUBLIC_SOURCE: "MOCK" | "HTTP";
/** Present only while a V1 document is still accepted. */
LEGACY_API_CONTRACT_VERSION?: string;
}>;
@@ -143,6 +149,10 @@ export function validateRuntimeConfig(value: unknown): RuntimeConfigValidation {
TECH_LOG_STUDIO_SOURCE: isV2
? (parsed as RuntimeConfigV2).TECH_LOG_STUDIO_SOURCE
: "MOCK",
// Same treatment for the public-read switch.
TECH_LOG_PUBLIC_SOURCE: isV2
? (parsed as RuntimeConfigV2).TECH_LOG_PUBLIC_SOURCE
: "MOCK",
...(isV2
? {}
: {
+5
View File
@@ -52,6 +52,11 @@ export const ENV_REGISTRY = Object.freeze({
// TechLog Studio gateway adapter selection. Defaults to MOCK while the
// backend does not exist yet.
TECH_LOG_STUDIO_SOURCE: runtime("public", false, "MOCK"),
// TechLog public-read adapter selection. Separate from the Studio switch on
// purpose: the two surfaces are different services on different schedules,
// and the combination that matters right now — an authoring backend that is
// live while the public read API is not — is unreachable with one flag.
TECH_LOG_PUBLIC_SOURCE: runtime("public", false, "MOCK"),
// §3.5: build-time narrowing of the product manifest. A feature left out
// here is not imported by any registry and never reaches the bundle.
VITE_PRODUCT_FEATURES: build("compile-time", false, null),
+4
View File
@@ -159,6 +159,10 @@ export const runtimeConfigV2ArtifactSchema = z
// TechLog Studio adapter selection. Backend is not live yet, so the
// default is the in-memory mock; a document may opt a build into HTTP.
TECH_LOG_STUDIO_SOURCE: z.enum(["MOCK", "HTTP"]).default("MOCK"),
// TechLog public-read adapter selection, defaulted the same way and for
// the same reason. Held apart from the Studio switch so one surface can
// move to HTTP without dragging the other with it.
TECH_LOG_PUBLIC_SOURCE: z.enum(["MOCK", "HTTP"]).default("MOCK"),
})
.strict()
.superRefine(runtimeConfigArtifactInvariants);
@@ -6,6 +6,8 @@ import {
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/contracts/reference-feature-contract-contribution.ts";
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
import { TECH_LOG_MANAGEMENT_CONTRIBUTION } from "./tech-log/contracts/tech-log-management-contract-contribution.ts";
import { TECH_LOG_PUBLIC_CONTRIBUTION } from "./tech-log/contracts/tech-log-public-contract-contribution.ts";
import { TECH_LOG_STUDIO_CONTRIBUTION } from "./tech-log/contracts/tech-log-studio-contract-contribution.ts";
/**
@@ -18,8 +20,17 @@ import { TECH_LOG_STUDIO_CONTRIBUTION } from "./tech-log/contracts/tech-log-stud
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
Object.freeze(
INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)
? [REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION, TECH_LOG_STUDIO_CONTRIBUTION]
: [TECH_LOG_STUDIO_CONTRIBUTION],
? [
REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION,
TECH_LOG_STUDIO_CONTRIBUTION,
TECH_LOG_PUBLIC_CONTRIBUTION,
TECH_LOG_MANAGEMENT_CONTRIBUTION,
]
: [
TECH_LOG_STUDIO_CONTRIBUTION,
TECH_LOG_PUBLIC_CONTRIBUTION,
TECH_LOG_MANAGEMENT_CONTRIBUTION,
],
);
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
@@ -23,6 +23,7 @@ export function createInstalledFeatureInputs(
context: Parameters<typeof createReferenceFeatureInstalledInput>[0] &
Readonly<{
studioSource: "MOCK" | "HTTP";
publicSource: "MOCK" | "HTTP";
apiBaseUrl: string;
requestTimeoutMs: number;
csrf: CsrfTokenProvider;
@@ -12,6 +12,8 @@ import {
import type { CsrfTokenProvider } from "./http/studio-session-csrf.ts";
import { createMockStudioAssetGateway } from "./mock/mock-studio-asset-gateway.ts";
import { createMockStudioGateway } from "./mock/mock-studio-gateway.ts";
import { createHttpManagementGateway } from "./http/http-management-gateway.ts";
import { createHttpPublicContentGateway } from "./http/http-public-content-gateway.ts";
import { publicContentQueries } from "./static/public-query.ts";
/**
@@ -27,6 +29,7 @@ import { publicContentQueries } from "./static/public-query.ts";
*/
export type TechLogInstallContext = Readonly<{
studioSource: "MOCK" | "HTTP";
publicSource: "MOCK" | "HTTP";
contractOperations: StudioOperationExecutor;
apiBaseUrl: string;
requestTimeoutMs: number;
@@ -66,8 +69,20 @@ export function createTechLogFeatureInstalledInput(
? createMockStudioGateway({ assets: mockAssets })
: createHttpStudioGateway({ operations: context.contractOperations });
// The public read source switches independently of Studio: the two are
// different services, and the combination that matters today is an authoring
// backend that is live while the public read API is not.
const publicContent =
context.publicSource === "MOCK"
? publicContentQueries
: createHttpPublicContentGateway({ operations: context.contractOperations });
const createManagementGateway = () =>
createHttpManagementGateway({ operations: context.contractOperations });
const input: TechLogFeatureInput = Object.freeze({
publicContent: publicContentQueries,
publicContent,
createManagementGateway,
createStudioGateway,
createStudioAssetGateway,
});
@@ -1,11 +1,21 @@
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
import type { UploadAssetForm } from "../../application/ports/studio-asset-gateway.ts";
import type { Asset, ProblemDetails } from "../../contracts/studio/contract.ts";
import { envelopeData, envelopeError } from "../../contracts/tech-log-studio-contract-contribution.ts";
import type { StudioAssetUploadTransport } from "./http-studio-asset-gateway.ts";
import { STUDIO_ERROR_CODES } from "./studio-error-mapping.ts";
const CODES = new Set<string>(STUDIO_ERROR_CODES);
/**
* canonical `uploadStudioAsset`도 다른 18개 operation과 같은 봉투(ADR-006)를
* 쓴다 — 이 seam만 일반 계약 런타임을 안 거칠 뿐이지 wire format은 같다.
* 그래서 `tech-log-studio-contract-contribution.ts`의 언랩 validator를 그대로
* 재사용한다: 봉투 뼈대 검증 로직이 두 곳에서 따로 드리프트하는 것을 막는다.
*/
const UPLOAD_DATA = envelopeData<Asset>("uploadStudioAssetOutput");
const UPLOAD_PROBLEM = envelopeError();
/**
* The contract runtime can only express `requestBody: "NONE" | "JSON"` and the
* low-level client always serializes the body as JSON (see
@@ -81,8 +91,9 @@ export function createAssetUploadTransport(
}
if (response.status === 201 || response.status === 200) {
let body: unknown;
try {
return (await response.json()) as Asset;
body = await response.json();
} catch {
// M1 (fix round 1). This port's contract is `StudioGatewayError`;
// a malformed success body must not throw a raw `SyntaxError` out
@@ -91,16 +102,33 @@ export function createAssetUploadTransport(
`Upload returned status ${response.status} with a body that could not be parsed as JSON.`,
);
}
const parsed = UPLOAD_DATA.safeParse(body);
if (!parsed.success) {
// 봉투 뼈대(`{success:true, data, meta}`)가 아니다 — payload는
// 통과시키되 봉투 자체는 반드시 검증한다(다른 18개 operation과 동일
// 원칙, ADR-006).
throw unavailable(
`Upload returned status ${response.status} with a body that did not match the response envelope.`,
);
}
return parsed.data;
}
let problem: ProblemDetails | null;
let problemBody: unknown;
try {
problem = (await response.json()) as ProblemDetails;
problemBody = await response.json();
} catch {
problem = null;
problemBody = null;
}
if (problem && typeof problem.code === "string" && CODES.has(problem.code)) {
throw new StudioGatewayError(problem);
const parsedProblem = problemBody === null ? null : UPLOAD_PROBLEM.safeParse(problemBody);
if (parsedProblem && parsedProblem.success && CODES.has(parsedProblem.data.code)) {
// `studio-error-mapping.ts`의 PROBLEM 분기와 같은 캐스트: 봉투는 이미
// `code`를 `apiErrorSchema`의 enum으로 검증했으므로(`CODES.has` 확인도
// 통과) `ProblemDetails["code"]`로 좁혀도 안전하다. envelope에는 HTTP
// status가 없다 (`envelopeError`가 0으로 둔다) — 이 seam은 실제 status를
// 이미 들고 있으므로 바로 덮는다.
const problem = parsedProblem.data as unknown as ProblemDetails;
throw new StudioGatewayError({ ...problem, status: response.status });
}
// Fix round 2, item 2. The real status is passed through (not the
// hardcoded 503 default) so `http-studio-asset-gateway.ts`'s
@@ -0,0 +1,180 @@
import type { SessionState } from "../../../../application/ports/auth-session-port.ts";
/**
* The host-installed session owner for the BFF deployment.
*
* `AUTH_MODE: "external"` means the page hosting this bundle owns the session
* and publishes it on `window.__CA_FRONTEND_AUTH_OWNER__`; with no owner
* present the runtime falls back to `createUnavailableSessionAdapter`, which is
* why a signed-in browser still saw "로그인 연동이 필요합니다". Tech Log's host
* *is* its backend: the session lives in an httpOnly `TECHLOG_SESSION` cookie
* the SPA cannot read, so the only way to observe it is to ask the backend.
*
* That is what this owner does. It is not a second authentication mechanism —
* `getStudioSession` is already the contract's bootstrap operation (the one
* that issues the CSRF token), so reading session state from it adds no
* round-trip the Studio would not make anyway.
*/
const SESSION_PATH = "api/v1/studio/session";
const SIGN_IN_PATH = "oauth2/authorization/keycloak";
const SIGN_OUT_PATH = "logout";
type Listener = () => void;
function endpoint(apiBaseUrl: string, path: string): string {
return new URL(path, apiBaseUrl).toString();
}
/**
* Starts at `recovery-pending` rather than `unauthenticated`. The state cannot
* be known synchronously and the router already models exactly this: a
* session-required route in that state renders the recovering surface and calls
* `recoverSession()`, which is where the probe belongs. Starting at
* `unauthenticated` would flash a sign-in prompt at a signed-in user on every
* reload.
*/
export function createBffSessionOwner(
apiBaseUrl: string,
fetcher: typeof fetch = fetch,
) {
let state: SessionState = "recovery-pending";
// The session response carries the CSRF token; `/logout` is a mutation and the
// backend rejects it without one. Kept here so sign-out does not need a second
// round-trip on the happy path.
let csrf: Readonly<{ token: string; header: string }> | null = null;
const listeners = new Set<Listener>();
const publish = (next: SessionState) => {
if (state === next) return;
state = next;
for (const listener of listeners) listener();
};
async function probe(): Promise<"restored" | "no-session"> {
try {
const response = await fetcher(endpoint(apiBaseUrl, SESSION_PATH), {
method: "GET",
credentials: "include",
headers: { accept: "application/json" },
});
if (response.ok) {
csrf = await readCsrf(response);
publish("authenticated");
return "restored";
}
csrf = null;
// 401/403 are answers, not faults: the caller simply has no session.
if (response.status === 401 || response.status === 403) {
publish("unauthenticated");
return "no-session";
}
// 5xx means the backend could not say. Claiming "signed out" would send
// the user through a login they do not need, so report the integration
// as unavailable and let the shell surface that instead.
publish("integration-failed");
return "no-session";
} catch {
publish("integration-failed");
return "no-session";
}
}
/**
* Total: a session that parses is still a session. A body we cannot read only
* costs sign-out its cached token, and `signOut` re-probes for one.
*/
async function readCsrf(
response: Response,
): Promise<Readonly<{ token: string; header: string }> | null> {
try {
const body = (await response.clone().json()) as {
data?: { csrfToken?: unknown; csrfHeaderName?: unknown };
};
const token = body?.data?.csrfToken;
const header = body?.data?.csrfHeaderName;
return typeof token === "string" && typeof header === "string"
? Object.freeze({ token, header })
: null;
} catch {
return null;
}
}
// Probe immediately instead of waiting for the router's recovery button. The
// session is knowable without asking the user to do anything, and the button
// exists for owners that genuinely need a user gesture (a popup-based flow,
// say). Subscribers are notified when this settles, so a route that mounted
// during `recovery-pending` re-renders on its own. `recoverSession` remains
// wired for the manual path and for a retry after `integration-failed`.
void probe();
return Object.freeze({
readState: () => state,
subscribe(listener: Listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
/**
* A full-page navigation, not a fetch: the authorization-code flow is a
* browser redirect chain through the identity provider, and an XHR cannot
* follow it. The backend sends the browser back to the SPA once the session
* cookie is set.
*/
async beginSignIn(): Promise<void> {
globalThis.location.assign(endpoint(apiBaseUrl, SIGN_IN_PATH));
},
/**
* Only reports signed-out when the backend actually ended the session.
*
* The first version published `unauthenticated` in a `finally`, which read
* as defensive but was the opposite: `/logout` is a mutation and answered
* 403 without the CSRF header, so the cookie survived while the UI claimed
* the user was out — the exact failure someone on a shared machine would
* never think to check. A sign-out that did not happen has to look like a
* sign-out that did not happen.
*/
async signOut(): Promise<void> {
if (csrf === null) {
// No cached token (never probed, or the probe body was unreadable).
// Ask again rather than sending a request that is certain to 403.
await probe();
}
const headers: Record<string, string> = csrf
? { [csrf.header]: csrf.token }
: {};
const response = await fetcher(endpoint(apiBaseUrl, SIGN_OUT_PATH), {
method: "POST",
credentials: "include",
headers,
});
if (!response.ok) {
throw new Error(
`sign-out failed with status ${response.status}; the session is still active`,
);
}
csrf = null;
publish("unauthenticated");
},
/** Cookies travel on their own; the CSRF header comes from its own collaborator. */
async attachCredential() {
return Object.freeze({ headers: Object.freeze({}) });
},
recoverSession: probe,
notifyUnauthenticated() {
publish("unauthenticated");
},
});
}
/**
* Publishes the owner on the host global the runtime reads. Called before
* `createRuntimeAdapters`, which resolves the owner once and keeps it.
*/
export function installBffSessionOwner(
host: Record<string, unknown>,
apiBaseUrl: string,
fetcher: typeof fetch = fetch,
): void {
host["__CA_FRONTEND_AUTH_OWNER__"] = createBffSessionOwner(apiBaseUrl, fetcher);
}
@@ -0,0 +1,139 @@
import type {
CreateDraftResponse,
HomeFocusRequest,
HomeFocusResponse,
ProjectActivityRequest,
ProjectActivityResponse,
UpdateProjectActivityRequest,
ProjectEditResponse,
ProjectIndexPage,
ProjectUpdateRequest,
PublishResponse,
ReleaseEditResponse,
ReleaseIndexPage,
ReleaseUpdateRequest,
TopicEdit,
} from "../../contracts/management/contract.ts";
import type { ManagementGateway } from "../../application/ports/management-gateway.ts";
import { ManagementGatewayError } from "../../application/ports/management-gateway-error.ts";
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
export type { ManagementGateway };
const ROUTE_ID = "TECH_LOG_STUDIO";
/**
* 주제·프로젝트 관리 게이트웨이.
*
* <p>Studio 게이트웨이와 같은 실패 규약을 쓴다 — 실패는 던지고, 화면은 `usePublicContent` 가 아니라
* Studio 쪽 상태 처리를 그대로 쓴다. 여기서 Result 로 감싸면 이 표면만 다른 규약이 된다.
*/
export {
ManagementGatewayError,
managementFailureMessage,
} from "../../application/ports/management-gateway-error.ts";
export function createHttpManagementGateway(
deps: Readonly<{ operations: StudioOperationExecutor }>,
): ManagementGateway {
async function run<T>(operationId: string, input: unknown): Promise<T> {
const outcome = await deps.operations.execute(operationId, input, { routeId: ROUTE_ID });
if (outcome.kind === "SUCCESS") return outcome.value as T;
if (outcome.kind === "PROBLEM") {
// The management surface answers with the ADR-006 envelope, which nests
// the code under `error` — reading `problem.code` found nothing and every
// failure surfaced as the literal "PROBLEM", matching no i18n key.
const body = outcome.problem as
| Readonly<{
code?: unknown;
detail?: unknown;
error?: Readonly<{ code?: unknown; message?: unknown }>;
}>
| null;
const code =
typeof body?.code === "string"
? body.code
: typeof body?.error?.code === "string"
? body.error.code
: "PROBLEM";
const detail =
typeof body?.error?.message === "string"
? body.error.message
: typeof body?.detail === "string"
? body.detail
: "";
throw new ManagementGatewayError(operationId, code, detail);
}
throw new ManagementGatewayError(operationId, outcome.kind, "");
}
return Object.freeze({
listTopics: () => run<TopicEdit[]>("listStudioTopics", {}),
createTopic: (input: TopicEdit) => run<TopicEdit>("createTopic", input),
updateTopic: (id: string, body: TopicEdit) => run<TopicEdit>("updateTopic", { id, body }),
deleteTopic: async (id: string, expectedVersion: number) => {
await run<void>("deleteTopic", { id, expectedVersion });
},
listProjects: (page?: number, size?: number) =>
run<ProjectIndexPage>("listStudioProjects", { ...(page !== undefined ? { page } : {}), ...(size !== undefined ? { size } : {}) }),
getProject: (id: string) => run<ProjectEditResponse>("getProjectForEdit", { id }),
createProject: (title: string) => run<CreateDraftResponse>("createProject", { title }),
updateProject: (id: string, body: ProjectUpdateRequest) =>
run<ProjectEditResponse>("updateProject", { id, body }),
listReleases: (page?: number, size?: number) =>
run<ReleaseIndexPage>("listStudioReleases", { ...(page !== undefined ? { page } : {}), ...(size !== undefined ? { size } : {}) }),
getRelease: (id: string) => run<ReleaseEditResponse>("getReleaseForEdit", { id }),
createRelease: (title: string) => run<CreateDraftResponse>("createRelease", { title }),
updateRelease: (id: string, body: ReleaseUpdateRequest) =>
run<ReleaseEditResponse>("updateRelease", { id, body }),
deleteRelease: async (id: string, expectedVersion: number) => {
await run<void>("deleteRelease", { id, expectedVersion });
},
listProjectActivities: (id: string) =>
run<ProjectActivityResponse[]>("listStudioProjectActivities", { id }),
createProjectActivity: (id: string, body: ProjectActivityRequest) =>
run<ProjectActivityResponse>("createProjectActivity", { id, body }),
updateProjectActivity: (
id: string,
activityId: string,
body: UpdateProjectActivityRequest,
) => run<ProjectActivityResponse>("updateProjectActivity", { id, activityId, body }),
deleteProjectActivity: async (id: string, activityId: string, expectedVersion: number) => {
await run<void>("deleteProjectActivity", { id, activityId, expectedVersion });
},
publishProject: (
id: string,
expectedVersion: number,
visibility: "PUBLIC" | "UNLISTED" = "PUBLIC",
) => run<PublishResponse>("publishProject", { id, expectedVersion, visibility }),
unpublishProject: (id: string, expectedVersion: number) =>
run<ProjectEditResponse>("unpublishProject", { id, expectedVersion }),
getHomeFocus: () => run<HomeFocusResponse>("getHomeFocus", {}),
updateHomeFocus: (body: HomeFocusRequest) =>
run<HomeFocusResponse>("updateHomeFocus", body),
publishRelease: (id: string, expectedVersion: number) =>
run<PublishResponse>("publishRelease", { id, expectedVersion }),
archiveRelease: (id: string, expectedVersion: number) =>
run<ReleaseEditResponse>("archiveRelease", { id, expectedVersion }),
deleteDocument: async (
kind: "CASE" | "REFERENCE" | "QUESTION",
id: string,
expectedVersion: number,
) => {
const operationId =
kind === "CASE"
? "deleteCaseDraft"
: kind === "REFERENCE"
? "deleteReferenceDraft"
: "deleteQuestion";
await run<void>(operationId, { id, expectedVersion });
},
deleteDecision: async (projectId: string, decisionId: string, expectedVersion: number) => {
await run<void>("deleteProjectDecision", { id: projectId, decisionId, expectedVersion });
},
deleteProject: async (id: string, expectedVersion: number) => {
await run<void>("deleteProject", { id, expectedVersion });
},
});
}
@@ -0,0 +1,545 @@
import type {
HomeFocusItem,
LatestRecordEntry,
ProjectActivity,
ProjectDecision,
Project,
PublicContentQueries,
PublicRecord,
PublicTopic,
QuestionRecord,
RecordFilters,
RecordKind,
Release,
SearchablePublicEntity,
} from "../../application/ports/public-content-queries.ts";
import {
activityItemToActivity,
baseOf,
dateLabel,
decisionItemToDecision,
flattenRelations,
knowledgeListItemToRecord,
markdownSections,
questionListItemToRecord,
releaseDetailToRelease,
searchItemToEntity,
} from "./public-content-mapping.ts";
import type { components } from "../../contracts/public/generated.ts";
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
const ROUTE_ID = "TECH_LOG_PUBLIC";
/**
* A missing slug is an answer, not a failure.
*
* The port returns `undefined` for a record that is not published, and the
* screens turn that into their not-found route. So a 404 is unwrapped here
* rather than thrown — throwing would put the terminal-error surface on a page
* whose real state is "this does not exist".
*/
const NOT_FOUND = Symbol("not-found");
export type PublicContentGatewayError = Error & { readonly failure?: unknown };
function gatewayError(operationId: string, detail: string): PublicContentGatewayError {
const error = new Error(`${operationId}: ${detail}`) as PublicContentGatewayError;
error.name = "PublicContentGatewayError";
return error;
}
/**
* Reads the backend's error code out of either response shape — RFC7807 puts it
* at the top level, the ADR-006 envelope nests it under `error`. Without this
* every failure was reported as the literal "PROBLEM", which told a reader
* nothing and matched no i18n key.
*/
function problemCode(problem: unknown): string {
if (!problem || typeof problem !== "object") return "PROBLEM";
const body = problem as Readonly<{ code?: unknown; error?: Readonly<{ code?: unknown }> }>;
if (typeof body.code === "string") return body.code;
if (typeof body.error?.code === "string") return body.error.code;
return "PROBLEM";
}
export function createHttpPublicContentGateway(
deps: Readonly<{ operations: StudioOperationExecutor }>,
): PublicContentQueries {
async function read<T>(operationId: string, input: unknown): Promise<T | typeof NOT_FOUND> {
const outcome = await deps.operations.execute(operationId, input, { routeId: ROUTE_ID });
if (outcome.kind === "SUCCESS") return outcome.value as T;
if (outcome.kind === "PROBLEM") {
// The HTTP status is the authoritative signal, and the only one that
// holds across both shapes this surface answers with. RFC7807 carries
// `status` in the body; the ADR-006 envelope does not — it puts the
// reason in `error.category` and a backend-specific string in
// `error.code` (PUBLIC_RESOURCE_NOT_FOUND, not NOT_FOUND). The old
// body-only check matched neither, so every 404 raised the terminal
// error surface on a page whose real state was "this does not exist".
if (outcome.metadata.status === 404) return NOT_FOUND;
throw gatewayError(operationId, problemCode(outcome.problem));
}
throw gatewayError(operationId, outcome.kind);
}
async function readOrThrow<T>(operationId: string, input: unknown): Promise<T> {
const value = await read<T>(operationId, input);
if (value === NOT_FOUND) throw gatewayError(operationId, "NOT_FOUND");
return value;
}
type Page = Readonly<{ items?: readonly Readonly<Record<string, unknown>>[] }>;
/**
* `listRecords` is one port method over two endpoints: the contract splits
* knowledge (Case, Reference) from questions because they page and filter
* differently. A caller that asks for one kind must not pay for the other, so
* the unfiltered call is the only one that fans out.
*/
async function listRecords(filters: RecordFilters = {}): Promise<PublicRecord[]> {
const wantsQuestions = !filters.kind || filters.kind === "QUESTION";
const wantsKnowledge = !filters.kind || filters.kind !== "QUESTION";
const query = {
...(filters.topic ? { topic: filters.topic } : {}),
...(filters.project ? { project: filters.project } : {}),
};
const [knowledge, questions] = await Promise.all([
wantsKnowledge
? readOrThrow<Page>("exploreKnowledge", {
...query,
...(filters.kind && filters.kind !== "QUESTION" ? { type: filters.kind } : {}),
})
: Promise.resolve({ items: [] } as Page),
wantsQuestions
? readOrThrow<Page>("exploreQuestions", {
...query,
...(filters.openQuestionsOnly ? { status: "OPEN" } : {}),
})
: Promise.resolve({ items: [] } as Page),
]);
const records = [
...(knowledge.items ?? []).map(knowledgeListItemToRecord).filter((r): r is PublicRecord => r !== null),
...(questions.items ?? []).map(questionListItemToRecord),
];
return records.sort((left, right) => right.publishedAt.localeCompare(left.publishedAt));
}
/**
* The cast at each return is not laziness. `kind` is a generic parameter, so
* narrowing it inside the body does not narrow `Extract<PublicRecord, {kind: K}>`
* with it — the compiler cannot know the branch it took corresponds to the K it
* was given. The discriminant on each object is a literal, so the shape is
* checked; only the tie back to K is asserted.
*/
async function getRecord<K extends RecordKind>(
kind: K,
slug: string,
): Promise<Extract<PublicRecord, { kind: K }> | undefined> {
const operationId =
kind === "CASE" ? "getPublicCase" : kind === "REFERENCE" ? "getPublicReference" : "getPublicQuestion";
const detail = await read<Readonly<Record<string, unknown>>>(operationId, { slug });
if (detail === NOT_FOUND) return undefined;
const canonicalPath = String(detail.canonicalPath ?? "");
const groups = (detail.relations as Readonly<Record<string, never>>) ?? {};
const projectOf = (entry: Readonly<{ title?: string; path?: string }> | undefined) =>
entry?.path
? { name: entry.title ?? "", slug: entry.path.split("/").filter(Boolean).pop() ?? "", path: entry.path }
: undefined;
if (kind === "CASE") {
const body = (detail.case as Readonly<Record<string, unknown>>) ?? {};
return Object.freeze({
...baseOf("CASE", slug, {
title: body.title as string,
// 제목 바로 아래에 오는 것은 문서의 요약이다. 유형별 요약(문제/범위)을 쓰면 바로 아래
// 블록과 같은 글을 두 번 말한다.
summary: (body.summary as string) ?? "",
path: canonicalPath,
primaryTopic: body.primaryTopic as never,
primaryProject: body.primaryProject as never,
publishedAt: body.publishedAt as string,
relations: flattenRelations(groups, {
originQuestion: "이 기록이 시작된 질문",
projectDecisions: "이 기록이 뒷받침하는 결정",
derivedReferences: "이 기록에서 정리된 기준",
relatedCases: "관련 기록",
}),
}),
kind: "CASE",
problem: (body.problemSummary as string) ?? "",
conclusion: (body.conclusionSummary as string) ?? "",
// `environmentSummary` 는 검증 환경과 재현 조건을 그 순서로 담는다 — 서버가 비어 있지
// 않은 것만 순서대로 넣는다. 예전에는 둘을 쉼표로 이어 붙여 한 칸에 넣고 재현 조건 칸은
// "계약에 없다"며 비워 두었는데, 계약에는 있었고 채우는 쪽이 없었을 뿐이다.
environment: ((body.environmentSummary as readonly string[]) ?? [])[0] ?? "",
verification: ((body.environmentSummary as readonly string[]) ?? [])[1] ?? "",
lastVerifiedLabel: dateLabel(body.lastVerifiedAt as string),
content: (body.content as string) ?? "",
bodyAssets: Object.freeze(
((body.bodyAssets as readonly Readonly<Record<string, unknown>>[]) ?? []).map((asset) =>
Object.freeze({
assetKey: asset.assetKey as string,
assetId: asset.assetId as string,
url: asset.url as string,
contentType: asset.contentType as string,
altText: (asset.altText as string) ?? "",
width: (asset.width as number) ?? null,
height: (asset.height as number) ?? null,
decorative: Boolean(asset.decorative),
}),
),
),
sections: markdownSections(body.content as string),
}) as unknown as Extract<PublicRecord, { kind: K }>;
}
if (kind === "REFERENCE") {
const body = (detail.reference as Readonly<Record<string, unknown>>) ?? {};
return Object.freeze({
...baseOf("REFERENCE", slug, {
title: body.title as string,
summary: (body.summary as string) ?? "",
path: canonicalPath,
primaryTopic: body.primaryTopic as never,
primaryProject: body.primaryProject as never,
publishedAt: body.publishedAt as string,
relations: flattenRelations(groups, {
originCases: "이 기준이 나온 기록",
projectDecisions: "이 기준을 따르는 결정",
relatedReferences: "관련 기준",
}),
}),
kind: "REFERENCE",
/*
여기서 읽는 이름은 계약이 실제로 주는 이름이어야 한다. 한때 `purposeSummary`,
`applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown` 을 읽었는데 계약에는 그런
칸이 없다 — 전부 undefined 로 떨어져 공개 Reference 화면이 통째로 비었다. Studio 에서는
같은 글이 다 보이므로 "공개 쪽만 안 나온다" 로 드러났다.
규칙과 예시는 `content` 마크다운을 잘라 만드는 것이 아니라 계약이 구조로 준다. Studio 의
편집기가 제목과 본문을 따로 받기 때문이다.
*/
purpose: (body.scopeSummary as string) ?? "",
rules: Object.freeze(
((body.rules as readonly Readonly<Record<string, unknown>>[] | undefined) ?? []).map(
(rule) => ({
title: String(rule.title ?? ""),
body: String(rule.body ?? ""),
}),
),
),
applyWhen: Object.freeze(((body.appliesTo as readonly string[] | undefined) ?? []).map(String)),
exceptions: Object.freeze(
((body.excludedScope as readonly string[] | undefined) ?? []).map(String),
),
examples: Object.freeze(((body.examples as readonly string[] | undefined) ?? []).map(String)),
verifiedAt: dateLabel(body.lastVerifiedAt as string),
}) as unknown as Extract<PublicRecord, { kind: K }>;
}
const body = (detail.question as Readonly<Record<string, unknown>>) ?? {};
/*
`points` 는 그룹 이름을 키로 갖는 객체다 — 계약의 `QuestionPointGroup`. 여기서는
`{group, items}` 배열로 읽으면서 `.filter` 를 불렀고, 객체에는 그런 것이 없으니 상세
화면이 통째로 「요청을 처리하지 못했습니다」가 됐다. 목록은 이 칸을 비워 두고 만들기
때문에 탐색에서는 멀쩡히 보였고, 그래서 "게시했는데 안 뜬다" 로만 드러났다.
`as` 캐스트가 그 어긋남을 타입 검사에서 가렸다. 계약의 타입을 그대로 쓰면 다음에 모양이
바뀔 때 컴파일이 먼저 막는다.
*/
type QuestionPoints = components["schemas"]["QuestionPointGroup"];
const points = body.points as QuestionPoints | undefined;
const pointsOf = (group: keyof QuestionPoints) =>
Object.freeze([...(points?.[group] ?? [])].map(String));
return Object.freeze({
...baseOf("QUESTION", slug, {
title: body.question as string,
summary: body.summary as string,
path: canonicalPath,
primaryTopic: body.primaryTopic as never,
/*
질문 상세는 프로젝트를 `question` 이 아니라 `relations.primaryProject` 에 담는다 —
Case/Reference 와 다른 자리다. `question` 에서 찾고 있었으므로 머리말의 프로젝트
칸이 늘 비어 있었다.
그 자리의 값은 `RelatedEntry` 라 `title`/`path` 를 쓴다. 머리말이 기다리는 것은
`name`/`slug` 이므로 여기서 옮겨 준다 — slug 는 경로의 마지막 마디다.
*/
primaryProject: projectOf(groups.primaryProject) as never,
publishedAt: body.updatedAt as string,
/*
계약이 주는 이름은 `resultCase` / `producedDecision` / `derivedReferences` 다.
여기서는 `derivedCases` / `projectDecisions` / `relatedQuestions` 를 찾고 있었고,
하나도 맞지 않아 이유 자리에 영문 키가 그대로 나왔다.
`primaryProject` 는 관계가 아니라 이 질문이 속한 프로젝트다 — 머리말이 이미
보여 주므로 관계 목록에 넣지 않는다.
*/
relations: flattenRelations(
{
resultCase: groups.resultCase,
producedDecision: groups.producedDecision,
derivedReferences: groups.derivedReferences,
},
{
resultCase: "이 질문에서 나온 기록",
producedDecision: "이 질문이 이끈 결정",
derivedReferences: "이 질문에서 정리된 기준",
},
),
}),
kind: "QUESTION",
questionStatus: (body.status as QuestionRecord["questionStatus"]) ?? "OPEN",
facts: pointsOf("facts"),
assumptions: pointsOf("assumptions"),
unknowns: pointsOf("unknowns"),
constraints: pointsOf("constraints"),
options: Object.freeze([]),
nextValidation: (body.nextVerification as string) ?? "",
}) as unknown as Extract<PublicRecord, { kind: K }>;
}
async function getProject(slug: string): Promise<Project | undefined> {
const detail = await read<Readonly<Record<string, unknown>>>("getPublicProject", { slug });
if (detail === NOT_FOUND) return undefined;
const body = (detail.project as Readonly<Record<string, unknown>>) ?? {};
const [decisions, activity] = await Promise.all([
getProjectDecisions(slug),
getProjectActivity(slug),
]);
return Object.freeze({
slug,
title: String(body.name ?? ""),
summary: String(body.oneLinePurpose ?? ""),
thesis: String(body.purpose ?? body.oneLinePurpose ?? ""),
stage: body.phase === "VALIDATION" ? "VALIDATION" : "DESIGN",
currentGoal: String(body.currentObjective ?? ""),
nextStep: String(body.nextStep ?? ""),
topics: Object.freeze(
((body.topics as readonly Readonly<{ name?: string }>[] | undefined) ?? [])
.map((topic) => topic.name ?? "")
.filter((name) => name.length > 0),
),
decisions: Object.freeze(decisions),
activity: Object.freeze(activity),
});
}
async function getProjectDecisions(projectSlug: string): Promise<ProjectDecision[]> {
const page = await read<Page>("listPublicProjectDecisions", { slug: projectSlug });
if (page === NOT_FOUND) return [];
return (page.items ?? []).map(decisionItemToDecision);
}
async function getProjectActivity(projectSlug: string): Promise<ProjectActivity[]> {
const page = await read<Page>("listPublicProjectActivities", { slug: projectSlug });
if (page === NOT_FOUND) return [];
return (page.items ?? []).map(activityItemToActivity);
}
async function getProjectRecords(projectSlug: string): Promise<PublicRecord[]> {
const page = await read<Page>("listPublicProjectRecords", { slug: projectSlug });
if (page === NOT_FOUND) return [];
return (page.items ?? [])
.map(knowledgeListItemToRecord)
.filter((record): record is PublicRecord => record !== null);
}
async function getRelease(version: string): Promise<Release | undefined> {
const detail = await read<Readonly<Record<string, unknown>>>("getPublicRelease", { version });
if (detail === NOT_FOUND) return undefined;
return releaseDetailToRelease(detail, version);
}
/**
* The home screen shows up to three focus cards. The contract returns them as
* one object with a named slot per kind rather than a list, because each slot
* has its own shape; the order below is the order the screen renders them in.
*/
async function listTopics(): Promise<PublicTopic[]> {
const page = await read<Page>("listPublicTopics", {});
if (page === NOT_FOUND) return [];
return (page.items ?? []).map((item) =>
Object.freeze({
name: String(item.name ?? ""),
slug: String(item.slug ?? ""),
recordCount: Number(item.recordCount ?? 0),
}),
);
}
/**
* 공개 투영이 고른 최근 기록. `entryType` 은 계약이 네 값만 허용하므로 그대로 믿고 쓴다 —
* 서버가 이미 걸러 보낸다.
*/
async function getLatestEntries(): Promise<LatestRecordEntry[]> {
const home = await read<Readonly<{ latestEntries?: readonly Readonly<Record<string, unknown>>[] }>>(
"getPublicHome",
{},
);
if (home === NOT_FOUND) return [];
return (home.latestEntries ?? []).map((entry) => {
const topic = entry.primaryTopic as Readonly<Record<string, unknown>> | null | undefined;
const project = entry.primaryProject as Readonly<Record<string, unknown>> | null | undefined;
const path = String(entry.path ?? "");
return Object.freeze({
id: `${String(entry.entryType ?? "")}:${path}`,
entryType: String(entry.entryType ?? "CASE") as LatestRecordEntry["entryType"],
title: String(entry.title ?? ""),
summary: String(entry.summary ?? ""),
path,
publishedAt: String(entry.publishedAt ?? ""),
topic: topic ? String(topic.name ?? "") : "",
project: project ? String(project.name ?? "") : "",
});
});
}
async function getHomeFocusItems(): Promise<HomeFocusItem[]> {
const home = await read<Readonly<{ focus?: Readonly<Record<string, never>> }>>(
"getPublicHome",
{},
);
if (home === NOT_FOUND) return [];
const focus = (home.focus ?? {}) as Readonly<Record<string, Readonly<Record<string, unknown>>>>;
const items: HomeFocusItem[] = [];
const work = focus.currentWork;
if (work) {
items.push(
Object.freeze({
key: "current",
label: "지금 하는 일",
title: String(work.projectName ?? ""),
summary: String(work.purpose ?? ""),
details: Object.freeze([
{ label: "단계", value: String(work.phase ?? "") },
{ label: "현재 목표", value: String(work.currentObjective ?? "") },
{ label: "다음 작업", value: String(work.nextStep ?? "") },
]),
targetPath: String(work.projectPath ?? "/projects"),
}),
);
}
const question = focus.openQuestion;
if (question) {
items.push(
Object.freeze({
key: "question",
label: "열린 질문",
title: String(question.question ?? ""),
summary: String(question.summary ?? ""),
details: Object.freeze([
{ label: "확인한 사실", value: ((question.knownFacts as readonly string[]) ?? []).join(" · ") },
{ label: "미해결", value: ((question.unresolvedPoints as readonly string[]) ?? []).join(" · ") },
{ label: "다음 검증", value: String(question.nextVerification ?? "") },
]),
targetPath: String(question.questionPath ?? "/explore/questions"),
}),
);
}
const decision = focus.recentDecision;
if (decision) {
items.push(
Object.freeze({
key: "decision",
label: "최근 결정",
title: String(decision.statement ?? ""),
summary: String(decision.rationale ?? ""),
details: Object.freeze([
{ label: "결정일", value: dateLabel(decision.decidedAt as string) },
{ label: "영향", value: ((decision.consequences as readonly string[]) ?? []).join(" · ") },
]),
targetPath: String(decision.decisionPath ?? "/projects"),
}),
);
}
return items;
}
/**
* 빈 검색어는 검색이 아니라 "카탈로그 전부"라는 뜻이다.
*
* 픽스처가 그렇게 동작했고 화면들이 그 의미에 기대어 쓰고 있다 — 홈 타임라인,
* 프로젝트 목록, 릴리즈 목록, 탐색 필터가 전부 `searchPublicContent("")` 로 카탈로그를
* 받아 간다. 계약에는 그런 의미가 없고 `q` 는 필수라, 그대로 보내면 400
* (`PUBLIC_REQUEST_INVALID`) 이 오고 홈을 포함한 네 화면이 통째로 오류 화면이 된다.
*
* 그래서 빈 검색어는 검색 엔드포인트로 보내지 않고, 계약이 이미 가진 목록
* 엔드포인트에서 조립한다. 검색어가 있으면 그때는 서버 검색을 쓴다 — 클라이언트에서
* 거르면 페이지 밖의 결과를 영영 못 찾는다.
*/
async function searchPublicContent(query: string): Promise<SearchablePublicEntity[]> {
const trimmed = query.trim();
if (trimmed.length > 0) {
const page = await read<Page>("searchPublicResources", { q: trimmed });
if (page === NOT_FOUND) return [];
return (page.items ?? []).map(searchItemToEntity);
}
const [knowledge, questions, projects, releases] = await Promise.all([
read<Page>("exploreKnowledge", {}),
read<Page>("exploreQuestions", {}),
read<Page>("listPublicProjects", {}),
read<Page>("listPublicReleases", {}),
]);
const items = (page: Page | typeof NOT_FOUND) =>
page === NOT_FOUND ? [] : (page.items ?? []);
const entities: SearchablePublicEntity[] = [];
for (const item of items(knowledge)) {
const record = knowledgeListItemToRecord(item);
if (record) entities.push(recordToEntity(record));
}
for (const item of items(questions)) {
entities.push(recordToEntity(questionListItemToRecord(item)));
}
for (const item of items(projects)) {
entities.push(
Object.freeze({
contentType: "PROJECT",
title: String(item.name ?? ""),
summary: String(item.oneLinePurpose ?? ""),
path: String(item.path ?? `/projects/${String(item.slug ?? "")}`),
}),
);
}
for (const item of items(releases)) {
entities.push(
Object.freeze({
contentType: "RELEASE",
title: String(item.title ?? ""),
summary: String(item.summary ?? ""),
path: String(item.path ?? `/releases/${String(item.version ?? "")}`),
}),
);
}
return entities;
}
function recordToEntity(record: PublicRecord): SearchablePublicEntity {
return Object.freeze({
contentType: record.kind,
title: record.title,
summary: record.summary,
path: record.path,
...(record.topic ? { topic: record.topic } : {}),
...(record.projectTitle ? { project: record.projectTitle } : {}),
...(record.publishedAt ? { publishedAt: record.publishedAt } : {}),
});
}
return Object.freeze({
listRecords,
getRecord,
getProject,
getRelease,
listTopics,
getProjectRecords,
getProjectDecisions,
getProjectActivity,
getHomeFocusItems,
getLatestEntries,
searchPublicContent,
});
}
@@ -0,0 +1,316 @@
import type {
CaseRecord,
ProjectActivity,
ProjectDecision,
PublicRecord,
QuestionRecord,
RecordSection,
ReferenceRecord,
Release,
SearchablePublicEntity,
} from "../../application/ports/public-content-queries.ts";
/**
* The contract and the screens disagree about shape, on purpose.
*
* The contract speaks in what the server stores — timestamps, one markdown body,
* relations grouped by their kind. The screens were built against a catalog that
* spoke in what a page renders — formatted labels, sections, one flat relation
* list. Neither is wrong, and translating here rather than at either end is what
* keeps the presentation components untouched by this migration.
*
* Where the contract has no counterpart the value is empty rather than invented,
* and the gap is named at the call site.
*/
const DATE_LABEL = new Intl.DateTimeFormat("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
timeZone: "UTC",
});
/** `2026. 08. 20.` → `2026.08.20`, the form the fixture used. */
export function dateLabel(value: string | null | undefined): string {
if (!value) return "";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "";
return DATE_LABEL.format(parsed).replaceAll(" ", "").replace(/\.$/u, "");
}
export function isoDate(value: string | null | undefined): string {
if (!value) return "";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? "" : parsed.toISOString();
}
/**
* The contract carries one markdown body; the document components render an
* ordered list of titled sections. Splitting on `##` reproduces that structure
* without a full renderer: everything before the first heading is the lead, and
* each heading opens a section whose bullets are its `-`/`*` lines.
*
* This is deliberately not the Studio parser (`parseCaseContent`). That one
* produces the canonical render-block union the editor needs — inline marks,
* evidence directives, tables — which is a richer tree than `RecordSection` can
* hold. Reusing it would mean flattening its output back down to this shape, and
* flattening loses exactly the blocks that made it worth using.
*/
export function markdownSections(body: string | null | undefined): RecordSection[] {
if (!body) return [];
const sections: RecordSection[] = [];
let current: { id: string; title: string; paragraphs: string[]; bullets: string[] } | null = null;
const flush = () => {
if (!current) return;
sections.push(
Object.freeze({
id: current.id,
title: current.title,
paragraphs: Object.freeze([...current.paragraphs]),
...(current.bullets.length > 0 ? { bullets: Object.freeze([...current.bullets]) } : {}),
}),
);
};
for (const rawLine of body.split(/\r?\n/u)) {
const line = rawLine.trim();
const heading = /^#{2,3}\s+(.*)$/u.exec(line);
if (heading) {
flush();
const title = heading[1]!.trim();
current = { id: slugOf(title, sections.length), title, paragraphs: [], bullets: [] };
continue;
}
if (!current) {
if (line.length === 0) continue;
current = { id: "lead", title: "", paragraphs: [], bullets: [] };
}
if (line.length === 0) continue;
const bullet = /^[-*]\s+(.*)$/u.exec(line);
if (bullet) current.bullets.push(bullet[1]!.trim());
else current.paragraphs.push(line);
}
flush();
return sections;
}
/** Markdown that is really a list — the release document's four bodies are. */
export function markdownLines(body: string | null | undefined): string[] {
if (!body) return [];
return body
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => line.replace(/^[-*]\s+/u, ""));
}
function slugOf(title: string, index: number): string {
const normalized = title
.toLocaleLowerCase("ko-KR")
.replace(/[^\p{Letter}\p{Number}]+/gu, "-")
.replace(/^-+|-+$/gu, "");
return normalized.length > 0 ? normalized : `section-${index + 1}`;
}
type Related = Readonly<{ type?: string; title?: string; summary?: string; path?: string }>;
/**
* The contract groups relations by why they relate (origin question, derived
* references, related cases); the renderer takes one list where the reason is a
* label. Flattening keeps the group name as that label.
*/
export function flattenRelations(
groups: Readonly<Record<string, Related | readonly Related[] | undefined>>,
labels: Readonly<Record<string, string>>,
): ReadonlyArray<{ reason: string; title: string; path: string }> {
const flat: { reason: string; title: string; path: string }[] = [];
for (const [group, value] of Object.entries(groups)) {
if (!value) continue;
const reason = labels[group] ?? group;
for (const entry of Array.isArray(value) ? value : [value as Related]) {
if (!entry?.path || !entry.title) continue;
flat.push({ reason, title: entry.title, path: entry.path });
}
}
return Object.freeze(flat);
}
type Summary = Readonly<{ name?: string; slug?: string; path?: string }> | undefined;
export function baseOf(
kind: PublicRecord["kind"],
slug: string,
fields: Readonly<{
title?: string;
summary?: string;
path?: string;
primaryTopic?: Summary;
primaryProject?: Summary;
publishedAt?: string | null;
relations?: ReadonlyArray<{ reason: string; title: string; path: string }>;
}>,
) {
return {
kind,
slug,
title: fields.title ?? "",
summary: fields.summary ?? "",
path: fields.path ?? "",
topic: fields.primaryTopic?.name ?? "",
topicSlug: fields.primaryTopic?.slug ?? "",
projectSlug: fields.primaryProject?.slug ?? "",
projectTitle: fields.primaryProject?.name ?? "",
publishedAt: isoDate(fields.publishedAt),
publishedLabel: dateLabel(fields.publishedAt),
visibility: "PUBLIC" as const,
relations: fields.relations ?? Object.freeze([]),
};
}
/**
* A list endpoint answers with what a list row needs, not with a whole document.
* The port's type is the full record, so the detail fields are filled empty here
* and the detail screens fetch by slug. That is the same trip the fixture made
* for free; it is a real extra request now, and the alternative — widening the
* list response — would send every body to render a title.
*/
export function knowledgeListItemToRecord(item: Readonly<Record<string, unknown>>): PublicRecord | null {
const type = String(item.type ?? "");
const path = String(item.path ?? "");
const slug = path.split("/").filter(Boolean).pop() ?? "";
const base = baseOf(type === "REFERENCE" ? "REFERENCE" : "CASE", slug, {
title: item.title as string,
summary: (item.primarySummary as string) ?? "",
path,
primaryTopic: item.primaryTopic as Summary,
primaryProject: item.primaryProject as Summary,
publishedAt: item.publishedAt as string,
});
if (type === "CASE") {
return Object.freeze({
...base,
kind: "CASE",
problem: (item.primarySummary as string) ?? "",
conclusion: (item.secondarySummary as string) ?? "",
environment: "",
verification: "",
lastVerifiedLabel: dateLabel(item.lastVerifiedAt as string),
// 목록 항목은 본문을 담지 않는다 — 본문은 상세 조회에서만 온다.
content: "",
bodyAssets: Object.freeze([]),
sections: Object.freeze([]),
}) as CaseRecord;
}
if (type === "REFERENCE") {
return Object.freeze({
...base,
kind: "REFERENCE",
purpose: (item.primarySummary as string) ?? "",
rules: Object.freeze([]),
applyWhen: Object.freeze([]),
exceptions: Object.freeze([]),
examples: Object.freeze([]),
verifiedAt: dateLabel(item.lastVerifiedAt as string),
}) as ReferenceRecord;
}
return null;
}
export function questionListItemToRecord(item: Readonly<Record<string, unknown>>): QuestionRecord {
const path = String(item.path ?? "");
const slug = path.split("/").filter(Boolean).pop() ?? "";
return Object.freeze({
...baseOf("QUESTION", slug, {
title: item.question as string,
summary: (item.summary as string) ?? "",
path,
primaryProject: item.primaryProject as Summary,
publishedAt: item.updatedAt as string,
}),
kind: "QUESTION",
questionStatus: (item.status as QuestionRecord["questionStatus"]) ?? "OPEN",
facts: Object.freeze([]),
assumptions: Object.freeze([]),
unknowns: Object.freeze([]),
constraints: Object.freeze([]),
options: Object.freeze([]),
nextValidation: (item.nextVerification as string) ?? "",
}) as QuestionRecord;
}
export function decisionItemToDecision(item: Readonly<Record<string, unknown>>): ProjectDecision {
const sources = [item.sourceQuestion, item.sourceCase]
.filter((entry): entry is Related => Boolean(entry))
.map((entry) => ({ title: entry.title ?? "", path: entry.path ?? "" }));
return Object.freeze({
id: String(item.id ?? ""),
status: (item.status as ProjectDecision["status"]) ?? "PROPOSED",
date: dateLabel(item.decidedAt as string),
title: String(item.statement ?? ""),
statement: String(item.statement ?? ""),
rationale: String(item.rationaleSummary ?? ""),
// The list response carries a rationale summary, not the consequence list the
// decision screen renders; the contract has no field for it here.
consequences: Object.freeze([]),
evidence: Object.freeze(sources),
});
}
export function activityItemToActivity(
item: Readonly<Record<string, unknown>>,
index: number,
): ProjectActivity {
const occurredAt = item.occurredAt as string;
const relatedPath = (item.relatedPath as string) ?? "";
return Object.freeze({
id: `activity-${index + 1}`,
date: dateLabel(occurredAt),
dateTime: isoDate(occurredAt),
type: (item.type as ProjectActivity["type"]) ?? "PROJECT UPDATE",
title: String(item.title ?? ""),
summary: String(item.summary ?? ""),
path: relatedPath,
...(relatedPath ? { recordPath: relatedPath } : {}),
});
}
export function releaseDetailToRelease(
detail: Readonly<Record<string, unknown>>,
version: string,
): Release {
const related = (detail.relatedRecords as readonly Related[] | undefined) ?? [];
return Object.freeze({
version: String(detail.version ?? version),
path: `/releases/${String(detail.version ?? version)}`,
title: String(detail.title ?? ""),
summary: String(detail.summary ?? ""),
publishedAt: isoDate(detail.releasedOn as string),
publishedLabel: dateLabel(detail.releasedOn as string),
changes: Object.freeze(markdownLines(detail.changesMarkdown as string)),
reasons: Object.freeze(markdownLines(detail.reasonMarkdown as string)),
impacts: Object.freeze([
...markdownLines(detail.userImpactMarkdown as string),
...markdownLines(detail.implementationImpactMarkdown as string),
]),
related: Object.freeze(
related
.filter((entry) => entry.path && entry.title)
.map((entry) => ({ title: entry.title!, path: entry.path! })),
),
});
}
export function searchItemToEntity(
item: Readonly<Record<string, unknown>>,
): SearchablePublicEntity {
const topic = (item.primaryTopic as Summary)?.name;
const project = (item.primaryProject as Summary)?.name;
return Object.freeze({
contentType: (item.contentType as SearchablePublicEntity["contentType"]) ?? "CASE",
title: String(item.title ?? ""),
summary: String(item.snippet ?? ""),
path: String(item.path ?? ""),
...(topic ? { topic } : {}),
...(project ? { project } : {}),
...(item.publishedAt ? { publishedAt: isoDate(item.publishedAt as string) } : {}),
});
}
@@ -9,7 +9,7 @@ export const STUDIO_ERROR_CODES = Object.freeze([
"DOCUMENT_NOT_FOUND",
"VERSION_CONFLICT",
"REQUEST_VALIDATION_FAILED",
"VALIDATION_FAILED",
"DOCUMENT_VALIDATION_FAILED",
"VALIDATION_STALE",
"PREVIEW_NOT_FOUND",
"PREVIEW_STALE",
@@ -57,13 +57,20 @@ export function toStudioGatewayError(
): StudioGatewayError {
switch (outcome.kind) {
case "PROBLEM": {
const problem = outcome.problem as ProblemDetails;
// 봉투 오류는 wire에 HTTP status를 싣지 않는다 (`envelopeError`가 `status`를
// 0으로 둔다) — 실제 status는 전송 계층이 `outcome.metadata.status`로
// 이미 들고 있으므로 여기서 덮는다. `SafeResponseMetadata.status`는
// `PROBLEM` outcome에서 필수 필드다 (`http-execution-v3.ts`).
// `problem`이 falsy이거나 status가 0(봉투의 sentinel)이면 metadata로
// 덮는다 — 원래 코드처럼 `problem`을 안전하지 않게 역참조하지 않는다.
const problem = outcome.problem as ProblemDetails | undefined;
const status = problem?.status || outcome.metadata.status;
if (problem && typeof problem.code === "string" && CODES.has(problem.code)) {
return new StudioGatewayError(problem);
return new StudioGatewayError({ ...problem, status });
}
return synthetic(
"STUDIO_UNAVAILABLE",
outcome.metadata.status,
status,
`${operationId} returned an uncontracted problem code.`,
false,
);
@@ -9,7 +9,9 @@ function invalid(detail: string) {
const problem: ProblemDetails = {
type: "https://techlog.local/problems/request-validation-failed", title: "Request validation failed",
status: 422, detail, code: "REQUEST_VALIDATION_FAILED", retryable: false,
fieldErrors: [{ path: "/cursor", message: detail }],
// wire와 같은 자리: `details`가 `ValidationErrorDetails` 모양이다 (Task 3
// fix round 1 — 예전엔 `fieldErrors`가 최상위 필드였다).
details: { fieldErrors: [{ path: "/cursor", message: detail }] },
};
return new StudioGatewayError(problem);
}
@@ -60,7 +60,11 @@ function gatewayProblem(status: number, code: ProblemDetails["code"], detail: st
}
function requestError(fieldErrors: components["schemas"]["FieldError"][]) {
return gatewayProblem(422, "REQUEST_VALIDATION_FAILED", "Request fields are invalid.", { fieldErrors });
// wire와 같은 자리: `details`가 `ValidationErrorDetails` 모양이다 (Task 3
// fix round 1 — 예전엔 `fieldErrors`가 최상위 필드였다).
return gatewayProblem(422, "REQUEST_VALIDATION_FAILED", "Request fields are invalid.", {
details: { fieldErrors },
});
}
function inputOf(document: WorkingCopy): WorkingCopyInput {
@@ -169,7 +173,7 @@ export function createMockStudioGateway(supplied: Partial<MockStudioDependencies
const nextAction = deriveDocumentState({ document: base.document, validation: base.currentValidation, preview: base.latestPreview, publication: base.currentPublication, dependencyRevision: base.dependencyRevision, now: dependencies.clock.now() }).nextAction;
return { ...base, nextAction };
};
const version = (value: WorkingCopy, expected: number) => { if (value.version !== expected) throw gatewayProblem(409, "VERSION_CONFLICT", `Expected ${expected}; current ${value.version}.`, { latestDocument: clone(detail(value.id)), conflictingFields: [] }); };
const version = (value: WorkingCopy, expected: number) => { if (value.version !== expected) throw gatewayProblem(409, "VERSION_CONFLICT", `Expected ${expected}; current ${value.version}.`, { details: { latestDocument: clone(detail(value.id)), conflictingFields: [] } }); };
const structure = (input: WorkingCopyInput) => { const errors = validateWorkingCopyInputStructure(input); if (errors.length) throw requestError(errors); };
const materialize = (id: string, value: number, input: WorkingCopyInput): WorkingCopy => ({ ...clone(input), id, version: value, updatedAt: dependencies.clock.now().toISOString(), relations: input.relations.map((relation) => ({ ...relation, id: relation.id ?? dependencies.idGenerator.next(), targetId: relation.targetId! })) }) as WorkingCopy;
const summary = (value: WorkingCopy): components["schemas"]["DocumentSummary"] => {
@@ -197,7 +201,7 @@ export function createMockStudioGateway(supplied: Partial<MockStudioDependencies
getDocument(documentId, options) { return read(options, () => { uuid(documentId, "/documentId"); return detail(documentId); }); },
saveDocument(documentId, command, options) { return idempotent("save", documentId, () => command, options, () => {
uuid(documentId, "/documentId"); structure(command.document); const current = document(documentId); version(current, command.expectedVersion);
if (state.pendingConflicts.delete(documentId)) { const latest = materialize(documentId, current.version + 1, { ...inputOf(current), title: "서버에서 먼저 수정된 제목", summary: "서버 최신 요약" }); state.documents.set(documentId, latest); state.validations.delete(documentId); throw gatewayProblem(409, "VERSION_CONFLICT", "The server document changed.", { latestDocument: clone(detail(documentId)), conflictingFields: ["/title", "/summary"] }); }
if (state.pendingConflicts.delete(documentId)) { const latest = materialize(documentId, current.version + 1, { ...inputOf(current), title: "서버에서 먼저 수정된 제목", summary: "서버 최신 요약" }); state.documents.set(documentId, latest); state.validations.delete(documentId); throw gatewayProblem(409, "VERSION_CONFLICT", "The server document changed.", { details: { latestDocument: clone(detail(documentId)), conflictingFields: ["/title", "/summary"] } }); }
const saved = materialize(documentId, current.version + 1, command.document); state.documents.set(documentId, saved); state.validations.delete(documentId); return detail(documentId);
}); },
validateDocument(documentId, command, options) { return idempotent("validate", documentId, () => command, options, () => { uuid(documentId, "/documentId"); const value = document(documentId); version(value, command.expectedVersion); const report = validateWorkingCopy(value, { now: dependencies.clock.now(), validationId: dependencies.idGenerator.next(), dependencyRevision: dependencies.dependencyRevision.current(), catalog: state.catalog, documents: [...state.documents.values()], assets: [...dependencies.assets.values()] }); state.validations.set(documentId, report); return report; }); },
@@ -215,7 +219,7 @@ export function createMockStudioGateway(supplied: Partial<MockStudioDependencies
const warnings = validation.issues.filter((issue) => issue.severity === "WARNING").map((issue) => issue.code); if (!exactSet(warnings, command.acknowledgedWarningCodes)) throw requestError([{ path: "/acknowledgedWarningCodes", message: "Acknowledge all current warnings." }]);
const eventId = dependencies.idGenerator.next(); const publicationId = existing?.publicationId ?? dependencies.idGenerator.next(); const event: PublicationEvent = { publicationEventId: eventId, publicationId, documentId, type: existing ? "REPUBLISHED" : "PUBLISHED", occurredAt: now.toISOString(), publishedVersion: value.version, sourcePublishedEventId: null, snapshotAvailable: true }; const publication: PublicationAggregate = { publicationId, documentId, status: "PUBLISHED", publishedVersion: value.version, publicationRevision: (existing?.publicationRevision ?? 0) + 1, latestEventId: eventId, publicPath: preview.renderModel.publicPath, updatedAt: now.toISOString() }; state.events.set(eventId, event); state.publications.set(documentId, publication); state.snapshots.set(eventId, { event: clone(event), renderModel: clone(preview.renderModel), contentFormatVersion: CONTENT_FORMAT_VERSION, rendererContractVersion: RENDERER_CONTRACT_VERSION }); return { publication, event } satisfies PublishResult;
}); },
unpublishPublication(publicationId, command, options) { return idempotent("unpublish", publicationId, () => command, options, () => { uuid(publicationId, "/publicationId"); const current = [...state.publications.values()].find((item) => item.publicationId === publicationId); if (!current) throw gatewayProblem(404, "PUBLICATION_NOT_FOUND", "Publication not found."); if (current.publicationRevision !== command.expectedPublicationRevision) throw gatewayProblem(409, "PUBLICATION_CONFLICT", "Publication revision changed.", { latestPublication: clone(current) }); if (current.status === "UNPUBLISHED") return { publication: current, event: state.events.get(current.latestEventId)! }; const event: PublicationEvent = { publicationEventId: dependencies.idGenerator.next(), publicationId, documentId: current.documentId, type: "UNPUBLISHED", occurredAt: dependencies.clock.now().toISOString(), publishedVersion: current.publishedVersion, sourcePublishedEventId: current.latestEventId, snapshotAvailable: false }; const publication: PublicationAggregate = { ...current, status: "UNPUBLISHED", publicationRevision: current.publicationRevision + 1, latestEventId: event.publicationEventId, updatedAt: event.occurredAt }; state.events.set(event.publicationEventId, event); state.publications.set(current.documentId, publication); return { publication, event }; }); },
unpublishPublication(publicationId, command, options) { return idempotent("unpublish", publicationId, () => command, options, () => { uuid(publicationId, "/publicationId"); const current = [...state.publications.values()].find((item) => item.publicationId === publicationId); if (!current) throw gatewayProblem(404, "PUBLICATION_NOT_FOUND", "Publication not found."); if (current.publicationRevision !== command.expectedPublicationRevision) throw gatewayProblem(409, "PUBLICATION_CONFLICT", "Publication revision changed.", { details: { latestPublication: clone(current) } }); if (current.status === "UNPUBLISHED") return { publication: current, event: state.events.get(current.latestEventId)! }; const event: PublicationEvent = { publicationEventId: dependencies.idGenerator.next(), publicationId, documentId: current.documentId, type: "UNPUBLISHED", occurredAt: dependencies.clock.now().toISOString(), publishedVersion: current.publishedVersion, sourcePublishedEventId: current.latestEventId, snapshotAvailable: false }; const publication: PublicationAggregate = { ...current, status: "UNPUBLISHED", publicationRevision: current.publicationRevision + 1, latestEventId: event.publicationEventId, updatedAt: event.occurredAt }; state.events.set(event.publicationEventId, event); state.publications.set(current.documentId, publication); return { publication, event }; }); },
listPublications(query, options) { return read(options, () => { queryText(query.q); const limit = limitOf(query.limit); const normalized = { q: normalizeQ(query.q), type: query.type ?? null, sort: "OCCURRED_DESC" }; const binding = cursorBinding(normalized); const cursor = query.cursor ? decodeCursor(query.cursor, binding) : null; const all = [...state.events.values()].filter((event) => (!normalized.type || event.type === normalized.type) && (!normalized.q || `${document(event.documentId).title} ${document(event.documentId).summary}`.toLocaleLowerCase("ko-KR").includes(normalized.q))).sort((a, b) => b.occurredAt.localeCompare(a.occurredAt) || a.publicationEventId.localeCompare(b.publicationEventId)); const source = cursor ? all.filter((event) => event.occurredAt < cursor.lastValue || (event.occurredAt === cursor.lastValue && event.publicationEventId > cursor.lastId)) : all; const selected = source.slice(0, limit); const last = selected.at(-1); return { items: selected.map(publicationRow), nextCursor: selected.length < source.length && last ? encodeCursor({ binding, lastValue: last.occurredAt, lastId: last.publicationEventId }) : null } satisfies PublicationPage; }); },
getPublicationSnapshot(publicationEventId, options) { return read(options, () => { uuid(publicationEventId, "/publicationEventId"); if (!state.events.has(publicationEventId)) throw gatewayProblem(404, "PUBLICATION_EVENT_NOT_FOUND", "Publication event not found."); const snapshot = state.snapshots.get(publicationEventId); if (!snapshot) throw gatewayProblem(404, "PUBLICATION_SNAPSHOT_NOT_FOUND", "Publication snapshot not found."); return snapshot; }); },
getCatalog(query, options) { return read(options, () => { if (!query.type) throw requestError([{ path: "/type", message: "type is required." }]); queryText(query.q); const limit = limitOf(query.limit); const normalized = { type: query.type, q: normalizeQ(query.q), sort: "LABEL_ASC" }; const binding = cursorBinding(normalized); const cursor = query.cursor ? decodeCursor(query.cursor, binding) : null; const all = state.catalog.filter((item) => item.type === query.type && (!normalized.q || item.label.toLocaleLowerCase("ko-KR").includes(normalized.q))).sort((a, b) => a.label.localeCompare(b.label, "ko") || a.id.localeCompare(b.id)); const source = cursor ? all.filter((item) => item.label.localeCompare(cursor.lastValue, "ko") > 0 || (item.label === cursor.lastValue && item.id > cursor.lastId)) : all; const selected = source.slice(0, limit); const last = selected.at(-1); return { items: selected, nextCursor: selected.length < source.length && last ? encodeCursor({ binding, lastValue: last.label, lastId: last.id }) : null } satisfies CatalogPage; }); },
@@ -170,16 +170,16 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat
const has = (id: string | null, type: CatalogEntry["type"]) => Boolean(id && dependencies.catalog.some((entry) => entry.id === id && entry.type === type));
if (blank(document.title)) error("TITLE_REQUIRED", "/title", "제목을 입력하세요.");
if (blank(document.slug)) error("SLUG_REQUIRED", "/slug", "slug를 입력하세요."); else if (dependencies.documents.some((item) => item.id !== document.id && item.slug === document.slug)) error("SLUG_DUPLICATE", "/slug", "중복 slug입니다.");
if (blank(document.summary)) error("SUMMARY_REQUIRED", "/summary", "요약을 입력하세요.");
if (!has(document.topicId, "TOPIC")) error("TOPIC_REQUIRED", "/topicId", "Topic을 선택하세요.");
if (blank(document.summary)) warning("SUMMARY_REQUIRED", "/summary", "요약을 입력하세요.");
if (!has(document.topicId, "TOPIC")) warning("TOPIC_REQUIRED", "/topicId", "Topic을 선택하세요.");
if (!document.projectId) {
if (document.kind === "PROJECT_DECISION") error("DECISION_PROJECT_REQUIRED", "/projectId", "Decision에는 Project가 필요합니다.");
if (document.kind === "PROJECT_DECISION") warning("DECISION_PROJECT_REQUIRED", "/projectId", "Decision에는 Project가 필요합니다.");
else warning("PROJECT_MISSING", "/projectId", "Project 연결을 권장합니다.");
} else if (!has(document.projectId, "PROJECT")) error("PROJECT_NOT_FOUND", "/projectId", "Project를 찾을 수 없습니다.");
document.relations.forEach((relation, index) => { if (!has(relation.targetId, "RELATION")) error("RELATION_TARGET_NOT_FOUND", `/relations/${index}/targetId`, "관계 대상을 찾을 수 없습니다."); });
if (document.kind === "CASE") {
if (blank(document.problem)) error("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) error("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요.");
if (blank(document.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
if (blank(document.problem)) warning("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) warning("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요.");
if (blank(document.bodyMarkdown)) warning("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
else try {
const evidenceCatalog = [...dependencies.catalog, ...evidenceCatalogEntriesFromAssets(dependencies.assets)];
// The key gate is the preview projection's gate, verbatim: a resolvable
@@ -202,20 +202,20 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat
}
}
} catch { error("CONTENT_FORMAT_INVALID", "/bodyMarkdown", "지원하는 문법을 사용하세요."); }
if (!document.lastVerifiedOn) error("LAST_VERIFIED_ON_REQUIRED", "/lastVerifiedOn", "검증일을 입력하세요."); else if (dependencies.now.getTime() - Date.parse(`${document.lastVerifiedOn}T00:00:00Z`) > 30 * 86_400_000) warning("VERIFICATION_OLDER_THAN_30_DAYS", "/lastVerifiedOn", "30일이 지났습니다.");
if (!document.lastVerifiedOn) warning("LAST_VERIFIED_ON_REQUIRED", "/lastVerifiedOn", "검증일을 입력하세요."); else if (dependencies.now.getTime() - Date.parse(`${document.lastVerifiedOn}T00:00:00Z`) > 30 * 86_400_000) warning("VERIFICATION_OLDER_THAN_30_DAYS", "/lastVerifiedOn", "30일이 지났습니다.");
} else if (document.kind === "REFERENCE") {
if (blank(document.purpose)) error("REFERENCE_PURPOSE_REQUIRED", "/purpose", "목적을 입력하세요."); if (!document.rules.length) error("REFERENCE_RULE_REQUIRED", "/rules", "규칙이 필요합니다."); if (!document.applyWhen.length) error("REFERENCE_APPLY_WHEN_REQUIRED", "/applyWhen", "적용 조건이 필요합니다."); if (!document.verifiedOn) error("VERIFIED_ON_REQUIRED", "/verifiedOn", "검증일이 필요합니다."); if (!document.examples.length) warning("REFERENCE_EXAMPLE_MISSING", "/examples", "예시를 권장합니다.");
if (blank(document.purpose)) warning("REFERENCE_PURPOSE_REQUIRED", "/purpose", "목적을 입력하세요."); if (!document.rules.length) warning("REFERENCE_RULE_REQUIRED", "/rules", "규칙이 필요합니다."); if (!document.applyWhen.length) warning("REFERENCE_APPLY_WHEN_REQUIRED", "/applyWhen", "적용 조건이 필요합니다."); if (!document.verifiedOn) warning("VERIFIED_ON_REQUIRED", "/verifiedOn", "검증일이 필요합니다."); if (!document.examples.length) warning("REFERENCE_EXAMPLE_MISSING", "/examples", "예시를 권장합니다.");
} else if (document.kind === "QUESTION") {
if (!document.questionStatus) error("QUESTION_STATUS_REQUIRED", "/questionStatus", "상태가 필요합니다."); if (blank(document.nextValidation)) error("NEXT_VALIDATION_REQUIRED", "/nextValidation", "다음 검증이 필요합니다."); if (!document.facts.length) error("QUESTION_FACT_REQUIRED", "/facts", "사실이 필요합니다.");
if (!document.questionStatus) warning("QUESTION_STATUS_REQUIRED", "/questionStatus", "상태가 필요합니다."); if (blank(document.nextValidation)) warning("NEXT_VALIDATION_REQUIRED", "/nextValidation", "다음 검증이 필요합니다."); if (!document.facts.length) warning("QUESTION_FACT_REQUIRED", "/facts", "사실이 필요합니다.");
if (document.questionStatus === "OPEN") { if (!document.unknowns.length) error("QUESTION_UNKNOWN_REQUIRED", "/unknowns", "미확인 사항이 필요합니다."); if (document.resolution) error("OPEN_QUESTION_RESOLUTION_FORBIDDEN", "/resolution", "열린 질문에는 결론을 둘 수 없습니다."); }
if (document.questionStatus === "RESOLVED") { if (!document.resolution) error("QUESTION_RESOLUTION_REQUIRED", "/resolution", "해결 내용이 필요합니다."); else { if (blank(document.resolution.summary)) error("RESOLUTION_SUMMARY_REQUIRED", "/resolution/summary", "요약이 필요합니다."); if (!has(document.resolution.evidenceTargetId, "EVIDENCE")) error("RESOLUTION_EVIDENCE_REQUIRED", "/resolution/evidenceTargetId", "근거가 필요합니다."); if (blank(document.resolution.linkLabel)) error("RESOLUTION_LINK_LABEL_REQUIRED", "/resolution/linkLabel", "링크 문구가 필요합니다."); } }
if (document.questionStatus === "RESOLVED") { if (!document.resolution) warning("QUESTION_RESOLUTION_REQUIRED", "/resolution", "해결 내용이 필요합니다."); else { if (blank(document.resolution.summary)) warning("RESOLUTION_SUMMARY_REQUIRED", "/resolution/summary", "요약이 필요합니다."); if (!has(document.resolution.evidenceTargetId, "EVIDENCE")) error("RESOLUTION_EVIDENCE_REQUIRED", "/resolution/evidenceTargetId", "근거가 필요합니다."); if (blank(document.resolution.linkLabel)) warning("RESOLUTION_LINK_LABEL_REQUIRED", "/resolution/linkLabel", "링크 문구가 필요합니다."); } }
if (document.options.length < 2) warning("QUESTION_OPTIONS_FEWER_THAN_TWO", "/options", "선택지 두 개를 권장합니다.");
} else {
if (!document.decisionStatus) error("DECISION_STATUS_REQUIRED", "/decisionStatus", "결정 상태가 필요합니다.");
if (!document.decidedOn) error("DECIDED_ON_REQUIRED", "/decidedOn", "결정일이 필요합니다.");
if (blank(document.statement)) error("DECISION_STATEMENT_REQUIRED", "/statement", "결정문이 필요합니다.");
if (blank(document.rationale)) error("DECISION_RATIONALE_REQUIRED", "/rationale", "판단 이유가 필요합니다.");
if (!document.consequences.length) error("DECISION_CONSEQUENCE_REQUIRED", "/consequences", "영향이 하나 이상 필요합니다.");
if (!document.decisionStatus) warning("DECISION_STATUS_REQUIRED", "/decisionStatus", "결정 상태가 필요합니다.");
if (!document.decidedOn) warning("DECIDED_ON_REQUIRED", "/decidedOn", "결정일이 필요합니다.");
if (blank(document.statement)) warning("DECISION_STATEMENT_REQUIRED", "/statement", "결정문이 필요합니다.");
if (blank(document.rationale)) warning("DECISION_RATIONALE_REQUIRED", "/rationale", "판단 이유가 필요합니다.");
if (!document.consequences.length) warning("DECISION_CONSEQUENCE_REQUIRED", "/consequences", "영향이 하나 이상 필요합니다.");
if (!document.relations.length) error("DECISION_EVIDENCE_REQUIRED", "/relations", "근거 기록이 하나 이상 필요합니다.");
}
const validatedAt = dependencies.now.toISOString();
@@ -35,6 +35,18 @@ type PublicRecordBase = {
relations: ReadonlyArray<PublicRelation>;
};
/** 본문이 `:::evidence key="..."` 로 가리키는 Asset. 계약의 `BodyAsset` 과 같은 모양이다. */
export type PublicBodyAsset = {
assetKey: string;
assetId: string;
url: string;
contentType: string;
altText: string;
width: number | null;
height: number | null;
decorative: boolean;
};
export type CaseRecord = PublicRecordBase & {
kind: "CASE";
problem: string;
@@ -42,6 +54,9 @@ export type CaseRecord = PublicRecordBase & {
environment: string;
verification: string;
lastVerifiedLabel: string;
/** 본문 Markdown 원문. 정적 기록은 문서 화면이 자체 본문을 쓰므로 비어 있다. */
content: string;
bodyAssets: ReadonlyArray<PublicBodyAsset>;
sections: ReadonlyArray<RecordSection>;
};
@@ -173,6 +188,8 @@ export const publicRecords: ReadonlyArray<PublicRecord> = [
environment: "PostgreSQL 16 · Hibernate 6 · Spring Data JPA",
verification: "FeedItem 100개, Zipf 편중 Highlight/Mention",
lastVerifiedLabel: "2026.08.11",
content: "",
bodyAssets: [],
sections: [
{
id: "fix-the-problem",
@@ -256,6 +273,8 @@ export const publicRecords: ReadonlyArray<PublicRecord> = [
environment: "Spring Boot · Redis · Testcontainers",
verification: "동일한 Port 계약으로 In-memory와 Redis Adapter 계약 테스트 실행",
lastVerifiedLabel: "2026.08.07",
content: "",
bodyAssets: [],
sections: [
{
id: "ownership",
@@ -10,7 +10,11 @@ import {
type Release,
type HomeFocusItem,
} from "./public-content.ts";
import type { PublicContentQueries } from "../../application/ports/public-content-queries.ts";
import type {
LatestRecordEntry,
PublicContentQueries,
PublicTopic,
} from "../../application/ports/public-content-queries.ts";
export type RecordFilters = {
kind?: RecordKind;
@@ -50,6 +54,7 @@ export function listRecords(filters: RecordFilters = {}): PublicRecord[] {
.filter(
(record) =>
!hasTopicFilter ||
record.topicSlug.toLocaleLowerCase("ko-KR") === requestedTopic ||
record.topic.toLocaleLowerCase("ko-KR") === requestedTopic,
)
.filter(
@@ -98,6 +103,56 @@ export function getProjectActivity(projectSlug: string): ProjectActivity[] {
return [...(getProject(projectSlug)?.activity ?? [])];
}
/**
* 정적 카탈로그에는 주제 테이블이 없다 — 기록마다 붙은 주제 이름이 있을 뿐이다. 그것을 모아
* 세면 백엔드의 `listPublicTopics` 와 같은 모양이 되고, 이 어댑터의 목적(백엔드 없이 화면을
* 그린다)에도 맞는다.
*/
export function listTopics(): PublicTopic[] {
const counts = new Map<string, { name: string; slug: string; recordCount: number }>();
for (const record of listRecords()) {
if (!record.topic) continue;
const existing = counts.get(record.topicSlug);
if (existing) existing.recordCount += 1;
else counts.set(record.topicSlug, { name: record.topic, slug: record.topicSlug, recordCount: 1 });
}
return [...counts.values()]
.sort((left, right) => right.recordCount - left.recordCount || (left.name < right.name ? -1 : 1))
.map((entry) => Object.freeze(entry));
}
/**
* 픽스처판 "최근 기록". HTTP 어댑터가 서버에서 읽어 오는 것과 같은 의미를 픽스처에서 만든다 —
* 프로젝트 활동과 릴리스가 원천이다.
*/
export function getLatestEntries(): LatestRecordEntry[] {
const recordByPath = new Map(publicRecords.map((record) => [record.path, record]));
const activities = projects.flatMap((project) =>
project.activity.map((activity) => {
const record = recordByPath.get(activity.recordPath ?? activity.path);
const published = activity.type === "PUBLICATION" && record;
return {
id: activity.id,
entryType: (published ? record.kind : "PROJECT_ACTIVITY") as LatestRecordEntry["entryType"],
title: published ? record.title : activity.title,
summary: activity.summary,
path: activity.path,
publishedAt: activity.dateTime,
topic: record?.topic ?? project.topics[0] ?? "",
project: project.title,
};
}),
);
/*
릴리스는 넣지 않는다. 이 목록은 서버의 `latestEntries` 와 같은 의미여야 하고, 그쪽은 공개
투영에서 고르므로 릴리스가 없다 — 릴리스는 Publication 파이프라인을 거치지 않는다. 홈 화면이
릴리스를 따로 읽어 합치므로, 여기서도 넣으면 같은 릴리스가 두 번 나온다.
*/
return [...activities].sort((left, right) =>
right.publishedAt.localeCompare(left.publishedAt),
);
}
export function getHomeFocusItems(): HomeFocusItem[] {
const project = getProject("backend-skeleton");
const question = getRecord("QUESTION", "validate-edge-token-again");
@@ -208,14 +263,48 @@ export function searchPublicContent(query: string): SearchablePublicEntity[] {
);
}
/**
* The MOCK source. The functions above stay synchronous — they filter arrays
* that are already in the bundle, and making them async would only add a
* microtask to every fixture test — so the port's async shape is applied here,
* at the adapter boundary, rather than pushed into the query implementations.
*
* `async` rather than `Promise.resolve(...)` so a throw from one of these
* becomes a rejected promise like the HTTP adapter's would, instead of
* escaping synchronously past the caller's await.
*/
export const publicContentQueries = Object.freeze({
listRecords,
getRecord,
getProject,
getRelease,
getProjectRecords,
getProjectDecisions,
getProjectActivity,
getHomeFocusItems,
searchPublicContent,
async listRecords(filters?: RecordFilters) {
return listRecords(filters);
},
async getRecord<K extends RecordKind>(kind: K, slug: string) {
return getRecord(kind, slug);
},
async getProject(slug: string) {
return getProject(slug);
},
async getRelease(version: string) {
return getRelease(version);
},
async getProjectRecords(projectSlug: string) {
return getProjectRecords(projectSlug);
},
async getProjectDecisions(projectSlug: string) {
return getProjectDecisions(projectSlug);
},
async getProjectActivity(projectSlug: string) {
return getProjectActivity(projectSlug);
},
async listTopics() {
return listTopics();
},
async getLatestEntries() {
return getLatestEntries();
},
async getHomeFocusItems() {
return getHomeFocusItems();
},
async searchPublicContent(query: string) {
return searchPublicContent(query);
},
}) satisfies PublicContentQueries;
@@ -0,0 +1,32 @@
/**
* 관리 표면의 실패.
*
* <p>어댑터가 아니라 포트 계층에 두는 이유는 화면이 이것을 읽어야 하기 때문이다 — `presentation`
* 은 `adapters` 를 보지 않는다 (`feature-presentation-does-not-know-outbound-adapters`).
* Studio 쪽 `studio-gateway-error.ts` 가 같은 이유로 같은 자리에 있다.
*/
export class ManagementGatewayError extends Error {
readonly operationId: string;
readonly code: string;
/**
* 서버가 준, 사람이 읽을 수 있는 이유.
*
* <p>이것이 없어서 화면은 실패할 때마다 자기가 지어낸 문구를 보여 줬다 — "게시 중이거나,
* 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다" 같은 추측 셋. 서버는 정확히
* 무엇인지 알고 그것을 보내 주는데도 그랬고, 그래서 버전 충돌도 "사용 중" 으로 읽혔다.
*/
readonly detail: string;
constructor(operationId: string, code: string, detail: string) {
super(`${operationId}: ${code}`);
this.name = "ManagementGatewayError";
this.operationId = operationId;
this.code = code;
this.detail = detail;
}
}
/** 실패에서 서버가 준 문구를 꺼낸다. 없으면 부른 쪽이 준 기본값을 쓴다. */
export function managementFailureMessage(error: unknown, fallback: string): string {
return error instanceof ManagementGatewayError && error.detail ? error.detail : fallback;
}
@@ -0,0 +1,80 @@
import type {
CreateDraftResponse,
HomeFocusRequest,
HomeFocusResponse,
ProjectActivityRequest,
ProjectActivityResponse,
UpdateProjectActivityRequest,
ProjectEditResponse,
ProjectIndexPage,
ProjectUpdateRequest,
PublishResponse,
ReleaseEditResponse,
ReleaseIndexPage,
ReleaseUpdateRequest,
TopicEdit,
} from "../../contracts/management/contract.ts";
/**
* 주제·프로젝트·릴리즈·작업본 삭제 관리 표면.
*
* <p>Studio 게이트웨이와 같은 실패 규약이다 — 실패는 던지고, 화면이 잡는다. MOCK 대응물을 두지
* 않는 것도 의도다: 이 표면은 백엔드가 없으면 존재할 이유가 없고, 픽스처를 만들면 실제로는 만들
* 수 없는 주제를 화면이 보여주게 된다.
*/
export type ManagementGateway = Readonly<{
listTopics(): Promise<TopicEdit[]>;
createTopic(input: TopicEdit): Promise<TopicEdit>;
updateTopic(id: string, body: TopicEdit): Promise<TopicEdit>;
deleteTopic(id: string, expectedVersion: number): Promise<void>;
listProjects(page?: number, size?: number): Promise<ProjectIndexPage>;
getProject(id: string): Promise<ProjectEditResponse>;
createProject(title: string): Promise<CreateDraftResponse>;
updateProject(id: string, body: ProjectUpdateRequest): Promise<ProjectEditResponse>;
deleteProject(id: string, expectedVersion: number): Promise<void>;
/**
* 프로젝트 게시. 프로젝트는 Studio 문서가 아니라 게시 파이프라인 밖에 있고, 그래서 문서를
* 게시해도 그 문서가 속한 프로젝트는 비공개로 남는다 — 공개 화면(프로젝트 목록·프로필의
* "현재 프로젝트"·홈의 focus)은 모두 게시된 프로젝트만 읽으므로, 이 호출 없이는 어디에도
* 나타나지 않는다.
*/
publishProject(
id: string,
expectedVersion: number,
visibility?: "PUBLIC" | "UNLISTED",
): Promise<PublishResponse>;
unpublishProject(id: string, expectedVersion: number): Promise<ProjectEditResponse>;
/**
* 프로젝트 활동. 공개 프로젝트 화면의 "활동" 은 이 목록을 투영 없이 직접 읽으므로, 여기서
* 만든 줄이 곧 그 화면이다.
*/
listProjectActivities(id: string): Promise<ProjectActivityResponse[]>;
createProjectActivity(id: string, body: ProjectActivityRequest): Promise<ProjectActivityResponse>;
updateProjectActivity(
id: string,
activityId: string,
body: UpdateProjectActivityRequest,
): Promise<ProjectActivityResponse>;
deleteProjectActivity(id: string, activityId: string, expectedVersion: number): Promise<void>;
/** 공개 홈이 무엇을 앞에 둘지. 세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않는다. */
getHomeFocus(): Promise<HomeFocusResponse>;
updateHomeFocus(body: HomeFocusRequest): Promise<HomeFocusResponse>;
listReleases(page?: number, size?: number): Promise<ReleaseIndexPage>;
getRelease(id: string): Promise<ReleaseEditResponse>;
createRelease(title: string): Promise<CreateDraftResponse>;
updateRelease(id: string, body: ReleaseUpdateRequest): Promise<ReleaseEditResponse>;
deleteRelease(id: string, expectedVersion: number): Promise<void>;
publishRelease(id: string, expectedVersion: number): Promise<PublishResponse>;
archiveRelease(id: string, expectedVersion: number): Promise<ReleaseEditResponse>;
/**
* 작업본 삭제. 종류마다 다른 endpoint 인 것은 계약의 모양이자 저장 구조다 — Case 와 Reference
* 는 한 테이블을 나눠 쓰고 Question 은 다른 테이블이다. Decision 은 계약에 삭제가 없다:
* 그쪽 수명주기는 수락·기각·대체이고, 그건 지우는 것이 아니라 무슨 일이 있었는지 남기는 것이다.
*/
deleteDocument(kind: "CASE" | "REFERENCE" | "QUESTION", id: string, expectedVersion: number): Promise<void>;
/**
* Decision 은 프로젝트에 속하므로 경로가 둘을 요구한다. 다른 종류처럼 한 번에 묶지 않는 이유는
* 계약이 그렇게 선언했고, 실제로도 프로젝트 밖의 Decision 은 존재하지 않기 때문이다.
*/
deleteDecision(projectId: string, decisionId: string, expectedVersion: number): Promise<void>;
}>;
@@ -36,6 +36,23 @@ type PublicRecordBase = {
relations: ReadonlyArray<PublicRelation>;
};
/**
* 본문이 `:::evidence key="..."` 로 가리키는 Asset.
*
* <p>본문에는 key 만 있고 `/media/{assetId}` 는 UUID 로만 서빙하므로 — 주소가 추측 불가능한 것이
* 의도된 성질이다 — 공개 화면이 key 를 주소로 바꾸려면 이 대응이 함께 와야 한다.
*/
export type PublicBodyAsset = {
assetKey: string;
assetId: string;
url: string;
contentType: string;
altText: string;
width: number | null;
height: number | null;
decorative: boolean;
};
export type CaseRecord = PublicRecordBase & {
kind: "CASE";
problem: string;
@@ -43,6 +60,14 @@ export type CaseRecord = PublicRecordBase & {
environment: string;
verification: string;
lastVerifiedLabel: string;
/**
* 본문 Markdown 원문.
*
* <p>`sections` 는 이것을 제목·문단·불릿으로만 줄인 것이라 표·코드·callout·evidence 가 사라진다.
* 문서 화면은 원문을 직접 파싱한다.
*/
content: string;
bodyAssets: ReadonlyArray<PublicBodyAsset>;
sections: ReadonlyArray<RecordSection>;
};
@@ -132,6 +157,31 @@ export type Release = {
related: ReadonlyArray<{ title: string; path: string }>;
};
/** 계약 `TopicSummary`. 목록에 필요한 만큼만 옮긴다. */
export type PublicTopic = {
name: string;
slug: string;
recordCount: number;
};
/**
* 홈의 "최근 기록" 한 줄. 서버가 공개 투영에서 직접 고른다.
*
* 화면이 프로젝트를 하나씩 돌며 조립하던 때에는, 게시된 문서라도 그 문서가 매달린 프로젝트가
* 공개되어 있지 않으면 목록에서 통째로 빠졌다 — 실제로 게시한 Case 는 안 보이고 릴리스만
* 남았다. 무엇이 최근인지는 공개 투영 하나가 알고 있으므로 거기서 그대로 읽는다.
*/
export type LatestRecordEntry = {
id: string;
entryType: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT_ACTIVITY" | "RELEASE";
title: string;
summary: string;
path: string;
publishedAt: string;
topic: string;
project: string;
};
export type FocusKey = "current" | "question" | "decision";
export type HomeFocusItem = {
@@ -165,17 +215,37 @@ export type SearchablePublicEntity = {
* The application-facing boundary for the immutable source Public catalog.
* Method signatures intentionally retain the source query argument and return shapes.
*/
/**
* The public read surface.
*
* Every method is async because one of the two adapters behind this port is a
* network client. The other reads a bundled fixture and could answer
* synchronously, but a port has one shape: if the fixture adapter kept the
* synchronous signature, the HTTP adapter could not implement the same port
* and callers written against the fixture would not compile against the
* network.
*
* Failures throw rather than resolving to a Result. That matches the Studio
* gateways, and it lets `useApplicationQuery` classify a rejection once at the
* boundary instead of every caller unwrapping.
*/
export type PublicContentQueries = Readonly<{
listRecords(filters?: RecordFilters): PublicRecord[];
listRecords(filters?: RecordFilters): Promise<PublicRecord[]>;
getRecord<K extends RecordKind>(
kind: K,
slug: string,
): Extract<PublicRecord, { kind: K }> | undefined;
getProject(slug: string): Project | undefined;
getRelease(version: string): Release | undefined;
getProjectRecords(projectSlug: string): PublicRecord[];
getProjectDecisions(projectSlug: string): ProjectDecision[];
getProjectActivity(projectSlug: string): ProjectActivity[];
getHomeFocusItems(): HomeFocusItem[];
searchPublicContent(query: string): SearchablePublicEntity[];
): Promise<Extract<PublicRecord, { kind: K }> | undefined>;
getProject(slug: string): Promise<Project | undefined>;
getRelease(version: string): Promise<Release | undefined>;
getProjectRecords(projectSlug: string): Promise<PublicRecord[]>;
getProjectDecisions(projectSlug: string): Promise<ProjectDecision[]>;
getProjectActivity(projectSlug: string): Promise<ProjectActivity[]>;
/**
* 공개된 주제 목록. 프로필의 "주요 관심 주제"가 이 값을 그린다 — 그 목록은 코드에 박혀
* 있었고, Studio 에서 주제를 만들어도 바뀌지 않았다.
*/
listTopics(): Promise<PublicTopic[]>;
getLatestEntries(): Promise<LatestRecordEntry[]>;
getHomeFocusItems(): Promise<HomeFocusItem[]>;
searchPublicContent(query: string): Promise<SearchablePublicEntity[]>;
}>;
@@ -1,5 +1,6 @@
import type { PublicContentQueries } from "./ports/public-content-queries.ts";
import type { StudioAssetGateway } from "./ports/studio-asset-gateway.ts";
import type { ManagementGateway } from "./ports/management-gateway.ts";
import type { StudioGateway } from "./ports/studio-gateway.ts";
export const TECH_LOG_FEATURE_ID = "tech-log" as const;
@@ -8,6 +9,9 @@ export type TechLogFeatureInput = Readonly<{
publicContent: PublicContentQueries;
createStudioGateway(): StudioGateway;
createStudioAssetGateway(): StudioAssetGateway;
// 주제·프로젝트 관리. MOCK 대응물이 없다 — 이 표면은 백엔드가 없으면 존재할 이유가 없고,
// 픽스처를 만들면 실제로는 못 만드는 주제를 화면이 보여주게 된다.
createManagementGateway(): ManagementGateway;
}>;
declare module "../../../application/ports/in/application-api.ts" {
@@ -0,0 +1,89 @@
{
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
"sourceRevision": "ef49d3a",
"operationIds": [
"createCaseDraft",
"getCaseForEdit",
"updateCaseDraft",
"deleteCaseDraft",
"createReferenceDraft",
"getReferenceForEdit",
"updateReferenceDraft",
"deleteReferenceDraft",
"validateCase",
"submitReviewCase",
"returnToDraftCase",
"unpublishCase",
"archiveCase",
"restoreCase",
"publishCase",
"validateReference",
"submitReviewReference",
"returnToDraftReference",
"unpublishReference",
"archiveReference",
"restoreReference",
"publishReference",
"createQuestion",
"listStudioQuestions",
"getQuestionForEdit",
"updateQuestion",
"deleteQuestion",
"addQuestionUpdate",
"updateQuestionUpdate",
"deleteQuestionUpdate",
"resolveQuestion",
"startQuestionInvestigation",
"pauseQuestion",
"resumeQuestion",
"reopenQuestion",
"archiveQuestion",
"publishQuestion",
"unpublishQuestion",
"createProject",
"listStudioProjects",
"getProjectForEdit",
"updateProject",
"deleteProject",
"changeProjectPhase",
"publishProject",
"unpublishProject",
"createProjectDecision",
"listStudioProjectDecisions",
"getProjectDecision",
"updateProjectDecision",
"deleteProjectDecision",
"acceptProjectDecision",
"rejectProjectDecision",
"supersedeProjectDecision",
"createRelease",
"listStudioReleases",
"getReleaseForEdit",
"updateRelease",
"deleteRelease",
"publishRelease",
"archiveRelease",
"listStudioTopics",
"createTopic",
"updateTopic",
"deleteTopic",
"listStudioTags",
"createTag",
"updateTag",
"deleteTag",
"getStudioSite",
"updateStudioSite",
"getStudioProfile",
"updateStudioProfile",
"publishProfile",
"unpublishProfile",
"getHomeFocus",
"updateHomeFocus",
"listStudioProjectActivities",
"createProjectActivity",
"updateProjectActivity",
"deleteProjectActivity"
]
}
@@ -0,0 +1,22 @@
import type { components } from "./generated.ts";
type Schemas = components["schemas"];
export type TopicEdit = Schemas["TopicEdit"];
export type ProjectEditResponse = Schemas["ProjectEditResponse"];
export type ProjectIndexItem = Schemas["ProjectIndexItem"];
export type ProjectIndexPage = Schemas["ProjectIndexPage"];
export type ProjectUpdateRequest = Schemas["ProjectUpdateRequest"];
export type CreateDraftRequest = Schemas["CreateDraftRequest"];
export type CreateDraftResponse = Schemas["CreateDraftResponse"];
export type ExpectedVersionRequest = Schemas["ExpectedVersionRequest"];
export type ReleaseEditResponse = Schemas["ReleaseEditResponse"];
export type ReleaseIndexItem = Schemas["ReleaseIndexItem"];
export type ReleaseIndexPage = Schemas["ReleaseIndexPage"];
export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"];
export type PublishResponse = Schemas["PublishResponse"];
export type HomeFocusRequest = Schemas["HomeFocusRequest"];
export type HomeFocusResponse = Schemas["HomeFocusResponse"];
export type ProjectActivityRequest = Schemas["ProjectActivityRequest"];
export type UpdateProjectActivityRequest = Schemas["UpdateProjectActivityRequest"];
export type ProjectActivityResponse = Schemas["ProjectActivityResponse"];
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -4,5 +4,8 @@ export const publicSiteConfig = Object.freeze({
operator: "동현",
contactLabel: "프로필",
contactPath: "/profile",
latestRelease: "/releases/0.1.0",
// 변경 기록 목록. 이전에는 특정 버전(`/releases/0.1.0`)을 박아 두었는데, 그 릴리즈가 아직
// 없어서 푸터 링크가 404 였고, 있었더라도 다음 버전이 나오면 다시 낡는 자리였다. 목록은 어떤
// 버전이 최신인지 아는 유일한 곳이고 릴리즈가 하나도 없어도 성립한다.
releasesPath: "/releases",
});
@@ -0,0 +1,27 @@
{
"packageId": "@tech-log/public-contract",
"version": "2.1.0",
"digest": "sha256:7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e",
"sourceRevision": "ef49d3a",
"operationIds": [
"getPublicSite",
"getPublicHome",
"exploreKnowledge",
"exploreQuestions",
"listPublicTopics",
"getPublicTopic",
"getPublicCase",
"getPublicReference",
"getPublicQuestion",
"listPublicProjects",
"getPublicProject",
"listPublicProjectDecisions",
"listPublicProjectRecords",
"listPublicProjectActivities",
"listPublicReleases",
"getPublicRelease",
"getPublicProfile",
"searchPublicResources",
"getPublicMedia"
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,8 @@
{
"packageId": "@tech-log/studio-contract",
"version": "2.0.0",
"digest": "sha256:99f54f56ea0c582eafdbdf9be5653e3384bef0a1b08bff67f3147ee0292019ea",
"sourceRevision": "ce2e748",
"version": "3.1.0",
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
"sourceRevision": "ef49d3a",
"operationIds": [
"getStudioSession",
"getStudioDashboard",
@@ -23,7 +23,48 @@ export type PublicationListItem = Schemas["PublicationListItem"];
export type PublicationPage = Schemas["PublicationPage"];
export type PublicationSnapshot = Schemas["PublicationSnapshot"];
export type CatalogPage = Schemas["CatalogPage"];
export type ProblemDetails = Schemas["ProblemDetails"];
/**
* ADR-006으로 canonical 계약의 오류가 봉투(`ErrorEnvelope`/`ApiError`)로
* 바뀌면서 `ProblemDetails` 스키마 자체는 canonical에서 삭제됐다. 더 이상
* 생성된 스키마에서 뽑지 않고 여기서 손으로 유지하되, 이 모양은 전송 경계
* (`tech-log-studio-contract-contribution.ts`의 `envelopeError`)가 실제로
* 만드는 모양과 **동일해야 한다** — 그게 이 값이 production에서 채워지는
* 유일한 경로다. `ApiError`가 옮겨주는 필드(`type/title/status/detail/code/
* category/retryable/details`)만 갖는다.
*
* (Task 3 fix round 1) 이전에는 ADR-006 이전 평면 wire 모양에서 넘어온
* `instance`/`traceId`/`fieldErrors`/`latestDocument`/`latestPublication`/
* `conflictingFields`를 최상위 필드로 따로 두고 있었다. `envelopeError`는
* 그 필드들을 채우지 않으므로(옮길 대상이 없음) production 값에서는 항상
* `undefined`였고, mock 게이트웨이만 채웠다 — 타입은 있는데 mock에 대고
* 짜면 통과하고 실제 HTTP 경로에서는 조용히 비는, 봉투 검증 설계가 막으려던
* 함정이었다. 그 데이터는 이제 wire와 동일하게 `details` 안에 둔다 — mock도
* 여기 채운다(`mock-studio-gateway.ts`, `cursor.ts`).
*/
export type ValidationErrorDetails = Schemas["ValidationErrorDetails"];
export type VersionConflictDetails = Schemas["VersionConflictDetails"];
export type PublicationConflictDetails = Schemas["PublicationConflictDetails"];
export type ProblemDetailsPayload =
| ValidationErrorDetails
| VersionConflictDetails
| PublicationConflictDetails
| null;
export type ProblemDetails = Readonly<{
/** Format: uri-reference */
type: string;
title: string;
status: number;
detail: string;
code: Schemas["ApiError"]["code"];
category?: Schemas["ApiError"]["category"];
// optional 유지: 기존 호출부(테스트의 `new StudioGatewayError({...})` 리터럴
// 다수, `synthetic()`의 일부 경로)가 `retryable`을 생략한다. 이번 fix
// round의 finding은 `details`/평면 필드 문제이지 이 필드의 필수 여부가
// 아니다 — required로 좁히면 무관한 파일들이 깨진다.
retryable?: boolean;
details?: ProblemDetailsPayload;
}>;
export type PublicRenderModel = Schemas["PublicRenderModel"];
export type Asset = Schemas["Asset"];
export type AssetDetail = Schemas["AssetDetail"];
@@ -89,8 +89,8 @@ export interface paths {
* @description 편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain
* Action이 담당한다. 저장은 Public Projection을 변경하지 않는다.
*
* `expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `ProblemDetails`
* `latestDocument`로 현재 상태를 함께 제공한다.
* `expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `error.details`
* (`VersionConflictDetails`)의 `latestDocument`로 현재 상태를 함께 제공한다.
*
*/
put: operations["saveStudioDocument"];
@@ -382,6 +382,130 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
ResponseMeta: {
requestId: string;
traceId: string;
correlationId?: string | null;
/** @description Studio는 body 안 cursor 페이지네이션을 쓰므로 항상 null이다. 백엔드 템플릿의 ResponseMeta record가 이 필드를 직렬화한다. */
page?: {
[key: string]: unknown;
} | null;
};
ApiError: {
/** @enum {string} */
code: "AUTHENTICATION_REQUIRED" | "STUDIO_ACCESS_DENIED" | "DOCUMENT_NOT_FOUND" | "VERSION_CONFLICT" | "REQUEST_VALIDATION_FAILED" | "DOCUMENT_VALIDATION_FAILED" | "VALIDATION_STALE" | "PREVIEW_NOT_FOUND" | "PREVIEW_STALE" | "PREVIEW_EXPIRED" | "PUBLICATION_NOT_FOUND" | "PUBLICATION_CONFLICT" | "PUBLICATION_EVENT_NOT_FOUND" | "PUBLICATION_SNAPSHOT_NOT_FOUND" | "WARNING_ACKNOWLEDGEMENT_REQUIRED" | "IDEMPOTENCY_KEY_REUSED" | "ASSET_NOT_FOUND" | "ASSET_NOT_READY" | "ASSET_IN_USE" | "ASSET_QUARANTINED" | "PAYLOAD_TOO_LARGE" | "UNSUPPORTED_MEDIA_TYPE" | "STUDIO_UNAVAILABLE";
/** @enum {string} */
category: "VALIDATION" | "AUTH" | "AUTHZ" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMIT" | "TRANSIENT_DEPENDENCY" | "PERMANENT_DEPENDENCY" | "DATA_INTEGRITY" | "INTERNAL";
message: string;
retryable: boolean;
details?: components["schemas"]["ValidationErrorDetails"] | components["schemas"]["VersionConflictDetails"] | components["schemas"]["PublicationConflictDetails"] | null;
};
ErrorEnvelope: {
/** @constant */
success: false;
error: components["schemas"]["ApiError"];
meta: components["schemas"]["ResponseMeta"];
};
ValidationErrorDetails: {
fieldErrors: components["schemas"]["FieldError"][];
};
VersionConflictDetails: {
latestDocument: components["schemas"]["WorkingCopyDetail"];
conflictingFields?: string[];
};
PublicationConflictDetails: {
latestPublication: components["schemas"]["PublicationAggregate"];
};
StudioSessionEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["StudioSession"];
meta: components["schemas"]["ResponseMeta"];
};
StudioDashboardEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["StudioDashboard"];
meta: components["schemas"]["ResponseMeta"];
};
DocumentPageEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["DocumentPage"];
meta: components["schemas"]["ResponseMeta"];
};
WorkingCopyDetailEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["WorkingCopyDetail"];
meta: components["schemas"]["ResponseMeta"];
};
WorkingCopyEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["WorkingCopy"];
meta: components["schemas"]["ResponseMeta"];
};
ValidationReportEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["ValidationReport"];
meta: components["schemas"]["ResponseMeta"];
};
PreviewDetailEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["PreviewDetail"];
meta: components["schemas"]["ResponseMeta"];
};
PublicPreviewEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["PublicPreview"];
meta: components["schemas"]["ResponseMeta"];
};
PublishResultEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["PublishResult"];
meta: components["schemas"]["ResponseMeta"];
};
PublicationPageEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["PublicationPage"];
meta: components["schemas"]["ResponseMeta"];
};
PublicationSnapshotEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["PublicationSnapshot"];
meta: components["schemas"]["ResponseMeta"];
};
CatalogPageEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["CatalogPage"];
meta: components["schemas"]["ResponseMeta"];
};
AssetPageEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["AssetPage"];
meta: components["schemas"]["ResponseMeta"];
};
AssetDetailEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["AssetDetail"];
meta: components["schemas"]["ResponseMeta"];
};
AssetEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["Asset"];
meta: components["schemas"]["ResponseMeta"];
};
StudioSession: {
authenticated: boolean;
displayName: string;
@@ -466,7 +590,7 @@ export interface components {
relations: components["schemas"]["RelationInput"][];
};
CaseInput: components["schemas"]["WorkingCopyInputBase"] & {
/** @constant */
/** @enum {string} */
kind: "CASE";
problem: string;
conclusion: string;
@@ -486,7 +610,7 @@ export interface components {
kind: "CASE";
};
ReferenceInput: components["schemas"]["WorkingCopyInputBase"] & {
/** @constant */
/** @enum {string} */
kind: "REFERENCE";
purpose: string;
rules: components["schemas"]["ReferenceRule"][];
@@ -503,7 +627,7 @@ export interface components {
kind: "REFERENCE";
};
QuestionInput: components["schemas"]["WorkingCopyInputBase"] & {
/** @constant */
/** @enum {string} */
kind: "QUESTION";
/**
* @description Backend Inquiry lifecycle의 축약 view다.
@@ -521,7 +645,7 @@ export interface components {
constraints: components["schemas"]["OrderedText"][];
options: components["schemas"]["QuestionOption"][];
nextValidation: string;
resolution: components["schemas"]["QuestionResolution"] | null;
resolution?: components["schemas"]["QuestionResolution"] | null;
} & {
/**
* @description discriminator enum property added by openapi-typescript
@@ -530,7 +654,7 @@ export interface components {
kind: "QUESTION";
};
ProjectDecisionInput: components["schemas"]["WorkingCopyInputBase"] & {
/** @constant */
/** @enum {string} */
kind: "PROJECT_DECISION";
/**
* @description UI 용어다. Backend Domain의 `ACCEPTED`/`ADOPTED` 명칭이 다르면
@@ -565,7 +689,7 @@ export interface components {
updatedAt: string;
};
CaseWorkingCopy: components["schemas"]["WorkingCopyBase"] & {
/** @constant */
/** @enum {string} */
kind: "CASE";
problem: string;
conclusion: string;
@@ -582,7 +706,7 @@ export interface components {
kind: "CASE";
};
ReferenceWorkingCopy: components["schemas"]["WorkingCopyBase"] & {
/** @constant */
/** @enum {string} */
kind: "REFERENCE";
purpose: string;
rules: components["schemas"]["ReferenceRule"][];
@@ -599,7 +723,7 @@ export interface components {
kind: "REFERENCE";
};
QuestionWorkingCopy: components["schemas"]["WorkingCopyBase"] & {
/** @constant */
/** @enum {string} */
kind: "QUESTION";
/** @enum {string|null} */
questionStatus: "OPEN" | "RESOLVED" | null;
@@ -618,7 +742,7 @@ export interface components {
kind: "QUESTION";
};
ProjectDecisionWorkingCopy: components["schemas"]["WorkingCopyBase"] & {
/** @constant */
/** @enum {string} */
kind: "PROJECT_DECISION";
/** @enum {string|null} */
decisionStatus: "PROPOSED" | "ADOPTED" | null;
@@ -789,7 +913,7 @@ export interface components {
};
Inline: components["schemas"]["InlineText"] | components["schemas"]["InlineEmphasis"] | components["schemas"]["InlineStrong"] | components["schemas"]["InlineCode"] | components["schemas"]["InlineLink"] | components["schemas"]["InlineStatus"];
InlineEmphasis: components["schemas"]["InlineContainer"] & {
/** @constant */
/** @enum {string} */
type?: "EMPHASIS";
} & {
/**
@@ -799,7 +923,7 @@ export interface components {
type: "EMPHASIS";
};
InlineStrong: components["schemas"]["InlineContainer"] & {
/** @constant */
/** @enum {string} */
type?: "STRONG";
} & {
/**
@@ -844,7 +968,7 @@ export interface components {
items: components["schemas"]["ListItem"][];
};
UnorderedListBlock: components["schemas"]["ListBlockBase"] & {
/** @constant */
/** @enum {string} */
type?: "UNORDERED_LIST";
} & {
/**
@@ -854,7 +978,7 @@ export interface components {
type: "UNORDERED_LIST";
};
OrderedListBlock: components["schemas"]["ListBlockBase"] & {
/** @constant */
/** @enum {string} */
type?: "ORDERED_LIST";
} & {
/**
@@ -950,9 +1074,34 @@ export interface components {
height: number | null;
decorative: boolean;
};
CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"];
/** @description `---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다.
* */
ThematicBreakBlock: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "THEMATIC_BREAK";
};
/** @description `![alt](/media/...)` 로 쓴 그림이다.
*
* `EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시
* 시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은
* 링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다.
* */
ImageBlock: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "IMAGE";
src: string;
alt: string;
title: string | null;
};
CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"] | components["schemas"]["ThematicBreakBlock"] | components["schemas"]["ImageBlock"];
CasePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
/** @constant */
/** @enum {string} */
kind: "CASE";
problem: string;
conclusion: string;
@@ -969,7 +1118,7 @@ export interface components {
kind: "CASE";
};
ReferencePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
/** @constant */
/** @enum {string} */
kind: "REFERENCE";
purpose: string;
rules: components["schemas"]["ReferenceRule"][];
@@ -991,7 +1140,7 @@ export interface components {
linkLabel: string;
};
QuestionPublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
/** @constant */
/** @enum {string} */
kind: "QUESTION";
/**
* @description 공개 표현용 축약 상태다. Domain의 `INVESTIGATING`/`PAUSED`는 `OPEN`으로 표현된다.
@@ -1013,12 +1162,12 @@ export interface components {
kind: "QUESTION";
};
ProjectDecisionPublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
/** @constant */
/** @enum {string} */
kind: "PROJECT_DECISION";
/** @enum {string} */
status: "PROPOSED" | "ADOPTED";
/** Format: date */
decidedOn: string;
decidedOn: string | null;
statement: string;
rationale: string;
consequences: components["schemas"]["OrderedText"][];
@@ -1246,25 +1395,6 @@ export interface components {
path: string;
message: string;
};
ProblemDetails: {
/** Format: uri-reference */
type: string;
title: string;
status: number;
detail: string;
/** @enum {string} */
code: "AUTHENTICATION_REQUIRED" | "STUDIO_ACCESS_DENIED" | "DOCUMENT_NOT_FOUND" | "VERSION_CONFLICT" | "REQUEST_VALIDATION_FAILED" | "VALIDATION_FAILED" | "VALIDATION_STALE" | "PREVIEW_NOT_FOUND" | "PREVIEW_STALE" | "PREVIEW_EXPIRED" | "PUBLICATION_NOT_FOUND" | "PUBLICATION_CONFLICT" | "PUBLICATION_EVENT_NOT_FOUND" | "PUBLICATION_SNAPSHOT_NOT_FOUND" | "WARNING_ACKNOWLEDGEMENT_REQUIRED" | "IDEMPOTENCY_KEY_REUSED" | "ASSET_NOT_FOUND" | "ASSET_NOT_READY" | "ASSET_IN_USE" | "ASSET_QUARANTINED" | "PAYLOAD_TOO_LARGE" | "UNSUPPORTED_MEDIA_TYPE" | "STUDIO_UNAVAILABLE";
/** Format: uri-reference */
instance?: string;
traceId?: string;
fieldErrors?: components["schemas"]["FieldError"][];
latestDocument?: components["schemas"]["WorkingCopyDetail"];
latestPublication?: components["schemas"]["PublicationAggregate"];
conflictingFields?: string[];
retryable?: boolean;
} & {
[key: string]: unknown;
};
};
responses: {
/** @description Malformed request */
@@ -1273,7 +1403,7 @@ export interface components {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Authentication required */
@@ -1282,7 +1412,7 @@ export interface components {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Studio access denied */
@@ -1291,7 +1421,7 @@ export interface components {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Document not found */
@@ -1300,7 +1430,7 @@ export interface components {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Document or preview not found */
@@ -1309,7 +1439,7 @@ export interface components {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Publication not found */
@@ -1318,7 +1448,7 @@ export interface components {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Publication event or snapshot not found */
@@ -1327,7 +1457,7 @@ export interface components {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Asset not found */
@@ -1336,7 +1466,7 @@ export interface components {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Command conflicts with current state, freshness, or idempotency.
@@ -1348,7 +1478,7 @@ export interface components {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Request validation failed */
@@ -1357,7 +1487,7 @@ export interface components {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Preview 생성이 도메인 규칙으로 거절되었다 */
@@ -1366,7 +1496,7 @@ export interface components {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Publication validation이 실패했다.
@@ -1379,7 +1509,7 @@ export interface components {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Asset metadata 변경이 거절되었다 */
@@ -1388,7 +1518,7 @@ export interface components {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Upload exceeds the configured size limit */
@@ -1397,7 +1527,7 @@ export interface components {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unsupported media type */
@@ -1406,7 +1536,7 @@ export interface components {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Studio unavailable */
@@ -1415,7 +1545,7 @@ export interface components {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
@@ -1470,7 +1600,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StudioSession"];
"application/json": components["schemas"]["StudioSessionEnvelope"];
};
};
401: components["responses"]["AuthenticationRequired"];
@@ -1493,7 +1623,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StudioDashboard"];
"application/json": components["schemas"]["StudioDashboardEnvelope"];
};
};
401: components["responses"]["AuthenticationRequired"];
@@ -1527,7 +1657,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DocumentPage"];
"application/json": components["schemas"]["DocumentPageEnvelope"];
};
};
400: components["responses"]["MalformedRequest"];
@@ -1565,7 +1695,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WorkingCopy"];
"application/json": components["schemas"]["WorkingCopyEnvelope"];
};
};
400: components["responses"]["MalformedRequest"];
@@ -1593,7 +1723,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WorkingCopyDetail"];
"application/json": components["schemas"]["WorkingCopyDetailEnvelope"];
};
};
401: components["responses"]["AuthenticationRequired"];
@@ -1632,7 +1762,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WorkingCopyDetail"];
"application/json": components["schemas"]["WorkingCopyDetailEnvelope"];
};
};
400: components["responses"]["MalformedRequest"];
@@ -1674,7 +1804,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ValidationReport"];
"application/json": components["schemas"]["ValidationReportEnvelope"];
};
};
400: components["responses"]["MalformedRequest"];
@@ -1703,7 +1833,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PreviewDetail"];
"application/json": components["schemas"]["PreviewDetailEnvelope"];
};
};
401: components["responses"]["AuthenticationRequired"];
@@ -1742,7 +1872,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PublicPreview"];
"application/json": components["schemas"]["PublicPreviewEnvelope"];
};
};
400: components["responses"]["MalformedRequest"];
@@ -1784,7 +1914,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PublishResult"];
"application/json": components["schemas"]["PublishResultEnvelope"];
};
};
400: components["responses"]["MalformedRequest"];
@@ -1818,7 +1948,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PublicationPage"];
"application/json": components["schemas"]["PublicationPageEnvelope"];
};
};
400: components["responses"]["MalformedRequest"];
@@ -1858,7 +1988,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PublishResult"];
"application/json": components["schemas"]["PublishResultEnvelope"];
};
};
400: components["responses"]["MalformedRequest"];
@@ -1887,7 +2017,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PublicationSnapshot"];
"application/json": components["schemas"]["PublicationSnapshotEnvelope"];
};
};
401: components["responses"]["AuthenticationRequired"];
@@ -1918,7 +2048,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CatalogPage"];
"application/json": components["schemas"]["CatalogPageEnvelope"];
};
};
400: components["responses"]["MalformedRequest"];
@@ -1951,7 +2081,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AssetPage"];
"application/json": components["schemas"]["AssetPageEnvelope"];
};
};
400: components["responses"]["MalformedRequest"];
@@ -1989,7 +2119,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Asset"];
"application/json": components["schemas"]["AssetEnvelope"];
};
};
400: components["responses"]["MalformedRequest"];
@@ -2019,7 +2149,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AssetDetail"];
"application/json": components["schemas"]["AssetDetailEnvelope"];
};
};
401: components["responses"]["AuthenticationRequired"];
@@ -2058,7 +2188,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Asset"];
"application/json": components["schemas"]["AssetEnvelope"];
};
};
400: components["responses"]["MalformedRequest"];
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: Tech Log Studio API
version: 2.0.0
version: 3.1.0
description: |
Tech Log Studio orchestration 계약이다.
@@ -109,7 +109,7 @@ paths:
tags: [Session]
summary: 현재 Studio 세션과 CSRF 토큰을 조회한다
responses:
"200": { description: 인증된 Studio 세션, content: { application/json: { schema: { $ref: "#/components/schemas/StudioSession" } } } }
"200": { description: 인증된 Studio 세션, content: { application/json: { schema: { $ref: "#/components/schemas/StudioSessionEnvelope" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
@@ -123,7 +123,7 @@ paths:
`nextAction`을 포함한 모든 workflow 상태는 서버가 계산한다.
Frontend는 여러 endpoint를 조합해 workflow 상태를 재추론하지 않는다.
responses:
"200": { description: Dashboard lists and totals, content: { application/json: { schema: { $ref: "#/components/schemas/StudioDashboard" } } } }
"200": { description: Dashboard lists and totals, content: { application/json: { schema: { $ref: "#/components/schemas/StudioDashboardEnvelope" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
@@ -150,7 +150,7 @@ paths:
- { $ref: "#/components/parameters/Cursor" }
- { $ref: "#/components/parameters/Limit" }
responses:
"200": { description: Working-copy cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentPage" } } } }
"200": { description: Working-copy cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentPageEnvelope" } } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
@@ -169,7 +169,7 @@ paths:
"201":
description: Created working copy
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopy" } } }
content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
@@ -184,7 +184,7 @@ paths:
tags: [Documents]
summary: Get a working copy and its current state
responses:
"200": { description: Working-copy detail, content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetail" } } } }
"200": { description: Working-copy detail, content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetailEnvelope" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/DocumentNotFound" }
@@ -197,8 +197,8 @@ paths:
편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain
Action이 담당한다. 저장은 Public Projection을 변경하지 않는다.
`expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `ProblemDetails`
`latestDocument`로 현재 상태를 함께 제공한다.
`expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `error.details`
(`VersionConflictDetails`)의 `latestDocument`로 현재 상태를 함께 제공한다.
parameters:
- { $ref: "#/components/parameters/IdempotencyKey" }
- { $ref: "#/components/parameters/CsrfToken" }
@@ -207,7 +207,7 @@ paths:
"200":
description: Saved working-copy detail
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetail" } } }
content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetailEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
@@ -244,7 +244,7 @@ paths:
"200":
description: Validation report
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/ValidationReport" } } }
content: { application/json: { schema: { $ref: "#/components/schemas/ValidationReportEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
@@ -264,7 +264,7 @@ paths:
anonymous `/preview/{token}` 계약은 폐기되었다. Preview는 인증된
Studio API로만 조회한다.
responses:
"200": { description: Preview detail, content: { application/json: { schema: { $ref: "#/components/schemas/PreviewDetail" } } } }
"200": { description: Preview detail, content: { application/json: { schema: { $ref: "#/components/schemas/PreviewDetailEnvelope" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/PreviewNotFound" }
@@ -285,7 +285,7 @@ paths:
"201":
description: Created preview
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/PublicPreview" } } }
content: { application/json: { schema: { $ref: "#/components/schemas/PublicPreviewEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
@@ -329,7 +329,7 @@ paths:
"200":
description: Publication aggregate and immutable event
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/PublishResult" } } }
content: { application/json: { schema: { $ref: "#/components/schemas/PublishResultEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
@@ -350,7 +350,7 @@ paths:
- { $ref: "#/components/parameters/Cursor" }
- { $ref: "#/components/parameters/Limit" }
responses:
"200": { description: Publication cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationPage" } } } }
"200": { description: Publication cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationPageEnvelope" } } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
@@ -379,7 +379,7 @@ paths:
"200":
description: Updated publication aggregate and event
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/PublishResult" } } }
content: { application/json: { schema: { $ref: "#/components/schemas/PublishResultEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
@@ -401,7 +401,7 @@ paths:
`UNPUBLISHED` Event는 자체 snapshot을 갖지 않는다. 이 경우
`sourcePublishedEventId`가 가리키는 마지막 공개 Snapshot을 사용한다.
responses:
"200": { description: Publication snapshot, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationSnapshot" } } } }
"200": { description: Publication snapshot, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationSnapshotEnvelope" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/PublicationSnapshotNotFound" }
@@ -430,7 +430,7 @@ paths:
- { $ref: "#/components/parameters/Cursor" }
- { $ref: "#/components/parameters/Limit" }
responses:
"200": { description: Catalog cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/CatalogPage" } } } }
"200": { description: Catalog cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/CatalogPageEnvelope" } } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
@@ -449,7 +449,7 @@ paths:
- { $ref: "#/components/parameters/Cursor" }
- { $ref: "#/components/parameters/Limit" }
responses:
"200": { description: Asset cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/AssetPage" } } } }
"200": { description: Asset cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/AssetPageEnvelope" } } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
@@ -483,7 +483,7 @@ paths:
"201":
description: Stored asset
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/Asset" } } }
content: { application/json: { schema: { $ref: "#/components/schemas/AssetEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
@@ -500,7 +500,7 @@ paths:
tags: [Assets]
summary: Get an asset with its usage
responses:
"200": { description: Asset detail, content: { application/json: { schema: { $ref: "#/components/schemas/AssetDetail" } } } }
"200": { description: Asset detail, content: { application/json: { schema: { $ref: "#/components/schemas/AssetDetailEnvelope" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/AssetNotFound" }
@@ -520,7 +520,7 @@ paths:
"200":
description: Updated asset
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/Asset" } } }
content: { application/json: { schema: { $ref: "#/components/schemas/AssetEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
@@ -593,43 +593,232 @@ components:
IdempotencyReplayed: { description: True when the original result was replayed, schema: { type: boolean } }
responses:
MalformedRequest: { description: Malformed request, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
AuthenticationRequired: { description: Authentication required, x-error-codes: [AUTHENTICATION_REQUIRED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
AccessDenied: { description: Studio access denied, x-error-codes: [STUDIO_ACCESS_DENIED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
DocumentNotFound: { description: Document not found, x-error-codes: [DOCUMENT_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
PreviewNotFound: { description: Document or preview not found, x-error-codes: [DOCUMENT_NOT_FOUND, PREVIEW_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
PublicationNotFound: { description: Publication not found, x-error-codes: [PUBLICATION_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
PublicationSnapshotNotFound: { description: Publication event or snapshot not found, x-error-codes: [PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
AssetNotFound: { description: Asset not found, x-error-codes: [ASSET_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
MalformedRequest: { description: Malformed request, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
AuthenticationRequired: { description: Authentication required, x-error-codes: [AUTHENTICATION_REQUIRED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
AccessDenied: { description: Studio access denied, x-error-codes: [STUDIO_ACCESS_DENIED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
DocumentNotFound: { description: Document not found, x-error-codes: [DOCUMENT_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
PreviewNotFound: { description: Document or preview not found, x-error-codes: [DOCUMENT_NOT_FOUND, PREVIEW_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
PublicationNotFound: { description: Publication not found, x-error-codes: [PUBLICATION_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
PublicationSnapshotNotFound: { description: Publication event or snapshot not found, x-error-codes: [PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
AssetNotFound: { description: Asset not found, x-error-codes: [ASSET_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
CommandConflict:
description: |
Command conflicts with current state, freshness, or idempotency.
`ASSET_IN_USE`는 사용 중이거나 공개 이력이 있는 Asset의 hard delete 시도다.
x-error-codes: [VERSION_CONFLICT, PUBLICATION_CONFLICT, VALIDATION_STALE, PREVIEW_STALE, PREVIEW_EXPIRED, IDEMPOTENCY_KEY_REUSED, ASSET_IN_USE]
content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } }
RequestValidationFailed: { description: Request validation failed, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
RequestValidationFailed: { description: Request validation failed, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
PreviewRejected:
description: Preview 생성이 도메인 규칙으로 거절되었다
x-error-codes: [REQUEST_VALIDATION_FAILED, VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED]
content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } }
x-error-codes: [REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED]
content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
PublishRejected:
description: |
Publication validation이 실패했다.
`WARNING_ACKNOWLEDGEMENT_REQUIRED`는 `acknowledgedWarningCodes`가
현재 Validation의 WARNING 집합을 덮지 못한 경우다.
x-error-codes: [REQUEST_VALIDATION_FAILED, VALIDATION_FAILED, WARNING_ACKNOWLEDGEMENT_REQUIRED, ASSET_NOT_READY, ASSET_QUARANTINED]
content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } }
x-error-codes: [REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, WARNING_ACKNOWLEDGEMENT_REQUIRED, ASSET_NOT_READY, ASSET_QUARANTINED]
content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
AssetRejected:
description: Asset metadata 변경이 거절되었다
x-error-codes: [REQUEST_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED]
content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } }
PayloadTooLarge: { description: Upload exceeds the configured size limit, x-error-codes: [PAYLOAD_TOO_LARGE], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
UnsupportedMediaType: { description: Unsupported media type, x-error-codes: [UNSUPPORTED_MEDIA_TYPE], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
StudioUnavailable: { description: Studio unavailable, x-error-codes: [STUDIO_UNAVAILABLE], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
PayloadTooLarge: { description: Upload exceeds the configured size limit, x-error-codes: [PAYLOAD_TOO_LARGE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
UnsupportedMediaType: { description: Unsupported media type, x-error-codes: [UNSUPPORTED_MEDIA_TYPE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
StudioUnavailable: { description: Studio unavailable, x-error-codes: [STUDIO_UNAVAILABLE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
schemas:
# ------------------------------------------------------------- envelope
# wire format은 봉투다 (ADR-006). payload 스키마는 그대로 두고
# 응답만 <Payload>Envelope으로 감싼다.
ResponseMeta:
type: object
additionalProperties: false
required: [requestId, traceId]
properties:
requestId: { type: string, minLength: 1, maxLength: 200 }
traceId: { type: string, minLength: 1, maxLength: 200 }
correlationId: { type: [string, "null"], maxLength: 200 }
page: { type: ["object", "null"], additionalProperties: true, description: "Studio는 body 안 cursor 페이지네이션을 쓰므로 항상 null이다. 백엔드 템플릿의 ResponseMeta record가 이 필드를 직렬화한다." }
ApiError:
type: object
additionalProperties: false
required: [code, category, message, retryable]
properties:
code:
type: string
enum: [AUTHENTICATION_REQUIRED, STUDIO_ACCESS_DENIED, DOCUMENT_NOT_FOUND, VERSION_CONFLICT,
REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, VALIDATION_STALE, PREVIEW_NOT_FOUND,
PREVIEW_STALE, PREVIEW_EXPIRED, PUBLICATION_NOT_FOUND, PUBLICATION_CONFLICT,
PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND,
WARNING_ACKNOWLEDGEMENT_REQUIRED, IDEMPOTENCY_KEY_REUSED, ASSET_NOT_FOUND,
ASSET_NOT_READY, ASSET_IN_USE, ASSET_QUARANTINED, PAYLOAD_TOO_LARGE,
UNSUPPORTED_MEDIA_TYPE, STUDIO_UNAVAILABLE]
category:
type: string
enum: [VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT,
TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL]
message: { type: string, minLength: 1, maxLength: 5000 }
retryable: { type: boolean }
details:
oneOf:
- $ref: "#/components/schemas/ValidationErrorDetails"
- $ref: "#/components/schemas/VersionConflictDetails"
- $ref: "#/components/schemas/PublicationConflictDetails"
- type: "null"
ErrorEnvelope:
type: object
additionalProperties: false
required: [success, error, meta]
properties:
success: { type: boolean, const: false }
error: { $ref: "#/components/schemas/ApiError" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
ValidationErrorDetails:
type: object
additionalProperties: false
required: [fieldErrors]
properties:
fieldErrors: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/FieldError" } }
VersionConflictDetails:
type: object
additionalProperties: false
required: [latestDocument]
properties:
latestDocument: { $ref: "#/components/schemas/WorkingCopyDetail" }
conflictingFields:
type: array
uniqueItems: true
maxItems: 200
items: { type: string, pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" }
PublicationConflictDetails:
type: object
additionalProperties: false
required: [latestPublication]
properties:
latestPublication: { $ref: "#/components/schemas/PublicationAggregate" }
StudioSessionEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/StudioSession" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
StudioDashboardEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/StudioDashboard" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
DocumentPageEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/DocumentPage" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
WorkingCopyDetailEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/WorkingCopyDetail" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
WorkingCopyEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/WorkingCopy" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
ValidationReportEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/ValidationReport" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
PreviewDetailEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/PreviewDetail" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
PublicPreviewEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/PublicPreview" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
PublishResultEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/PublishResult" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
PublicationPageEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/PublicationPage" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
PublicationSnapshotEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/PublicationSnapshot" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
CatalogPageEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/CatalogPage" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
AssetPageEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/AssetPage" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
AssetDetailEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/AssetDetail" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
AssetEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/Asset" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
# ---------------------------------------------------------------- session
StudioSession:
type: object
@@ -682,7 +871,7 @@ components:
required: [id, text, order]
properties:
id: { type: string, format: uuid }
text: { type: string, minLength: 1, maxLength: 100000 }
text: { type: string, maxLength: 100000 }
order: { type: integer, minimum: 0 }
ReferenceRule:
type: object
@@ -739,7 +928,7 @@ components:
- type: object
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown]
properties:
kind: { type: string, const: CASE }
kind: { type: string, enum: [CASE] }
problem: { type: string, maxLength: 100000 }
conclusion: { type: string, maxLength: 100000 }
environment: { type: string, maxLength: 100000 }
@@ -758,7 +947,7 @@ components:
- type: object
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
properties:
kind: { type: string, const: REFERENCE }
kind: { type: string, enum: [REFERENCE] }
purpose: { type: string, maxLength: 100000 }
rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
@@ -770,9 +959,14 @@ components:
allOf:
- { $ref: "#/components/schemas/WorkingCopyInputBase" }
- type: object
required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution]
# `resolution` 은 여기 없다. 미해결 질문에는 해결 내용이 없고, 그것을 required 로 두면
# Java 생성기가 nullable 여부와 무관하게 @NotNull 을 찍는다 — oneOf 로 적은 null 을 그
# 생성기는 읽지 못한다. 실제로 그래서 Question 작업본을 만들 수 없었다: 프론트가 계약대로
# resolution: null 을 보냈고 백엔드가 422 로 거절했다. 같은 목록의 `questionStatus` 가
# 통과하는 것은 그쪽이 nullability 를 `type: [string, "null"]` 로 적었기 때문이다.
required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation]
properties:
kind: { type: string, const: QUESTION }
kind: { type: string, enum: [QUESTION] }
questionStatus:
type: [string, "null"]
enum: [OPEN, RESOLVED, null]
@@ -799,7 +993,7 @@ components:
- type: object
required: [kind, decisionStatus, decidedOn, statement, rationale, consequences]
properties:
kind: { type: string, const: PROJECT_DECISION }
kind: { type: string, enum: [PROJECT_DECISION] }
decisionStatus:
type: [string, "null"]
enum: [PROPOSED, ADOPTED, null]
@@ -846,7 +1040,7 @@ components:
- type: object
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown]
properties:
kind: { type: string, const: CASE }
kind: { type: string, enum: [CASE] }
problem: { type: string, maxLength: 100000 }
conclusion: { type: string, maxLength: 100000 }
environment: { type: string, maxLength: 100000 }
@@ -860,7 +1054,7 @@ components:
- type: object
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
properties:
kind: { type: string, const: REFERENCE }
kind: { type: string, enum: [REFERENCE] }
purpose: { type: string, maxLength: 100000 }
rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
@@ -874,7 +1068,7 @@ components:
- type: object
required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution]
properties:
kind: { type: string, const: QUESTION }
kind: { type: string, enum: [QUESTION] }
questionStatus: { type: [string, "null"], enum: [OPEN, RESOLVED, null] }
facts: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
@@ -893,7 +1087,7 @@ components:
- type: object
required: [kind, decisionStatus, decidedOn, statement, rationale, consequences]
properties:
kind: { type: string, const: PROJECT_DECISION }
kind: { type: string, enum: [PROJECT_DECISION] }
decisionStatus: { type: [string, "null"], enum: [PROPOSED, ADOPTED, null] }
decidedOn: { type: [string, "null"], format: date }
statement: { type: string, maxLength: 100000 }
@@ -1048,7 +1242,7 @@ components:
kind: { $ref: "#/components/schemas/RecordKind" }
slug: { type: string, minLength: 3, maxLength: 100, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" }
title: { type: string, minLength: 1, maxLength: 120 }
summary: { type: string, minLength: 1, maxLength: 300 }
summary: { type: string, maxLength: 300 }
publicPath: { type: string, minLength: 1, maxLength: 500 }
topic: { $ref: "#/components/schemas/DisplayTarget" }
project:
@@ -1062,8 +1256,8 @@ components:
additionalProperties: false
required: [type, text]
properties:
type: { type: string, const: TEXT }
text: { type: string, minLength: 1, maxLength: 100000 }
type: { type: string, enum: [TEXT] }
text: { type: string, maxLength: 100000 }
InlineContainer:
type: object
required: [type, children]
@@ -1075,14 +1269,14 @@ components:
additionalProperties: false
required: [type, code]
properties:
type: { type: string, const: INLINE_CODE }
type: { type: string, enum: [INLINE_CODE] }
code: { type: string, minLength: 1, maxLength: 100000 }
InlineLink:
type: object
additionalProperties: false
required: [type, label, href]
properties:
type: { type: string, const: LINK }
type: { type: string, enum: [LINK] }
label: { type: string, minLength: 1, maxLength: 100000 }
href: { type: string, format: uri, maxLength: 2000 }
InlineStatus:
@@ -1090,7 +1284,7 @@ components:
additionalProperties: false
required: [type, label, tone]
properties:
type: { type: string, const: STATUS }
type: { type: string, enum: [STATUS] }
label: { type: string, minLength: 1, maxLength: 120 }
tone: { type: string, enum: [warning, evidence, neutral] }
Inline:
@@ -1114,34 +1308,37 @@ components:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/InlineContainer" }
- { type: object, properties: { type: { type: string, const: EMPHASIS } } }
- { type: object, properties: { type: { type: string, enum: [EMPHASIS] } } }
InlineStrong:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/InlineContainer" }
- { type: object, properties: { type: { type: string, const: STRONG } } }
- { type: object, properties: { type: { type: string, enum: [STRONG] } } }
HeadingBlock:
type: object
additionalProperties: false
required: [type, id, level, content]
properties:
type: { type: string, const: HEADING }
type: { type: string, enum: [HEADING] }
id: { type: string, minLength: 1, maxLength: 200 }
level: { type: integer, minimum: 2, maximum: 4 }
# 작성자가 쓴 그대로 담는다. 서버 렌더러는 이 값을 2..4 로 좁혀 문서 안 제목 위계를
# 지키므로(BlockRenderer), 계약이 1..6 을 거절할 이유가 없다 — 거절하면 `#` 로 시작한
# 평범한 Markdown 이 통째로 렌더링되지 않는다.
level: { type: integer, minimum: 1, maximum: 6 }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
ParagraphBlock:
type: object
additionalProperties: false
required: [type, content]
properties:
type: { type: string, const: PARAGRAPH }
type: { type: string, enum: [PARAGRAPH] }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
BlockquoteBlock:
type: object
additionalProperties: false
required: [type, content]
properties:
type: { type: string, const: BLOCKQUOTE }
type: { type: string, enum: [BLOCKQUOTE] }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
ListItem:
type: object
@@ -1160,18 +1357,18 @@ components:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/ListBlockBase" }
- { type: object, properties: { type: { type: string, const: UNORDERED_LIST } } }
- { type: object, properties: { type: { type: string, enum: [UNORDERED_LIST] } } }
OrderedListBlock:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/ListBlockBase" }
- { type: object, properties: { type: { type: string, const: ORDERED_LIST } } }
- { type: object, properties: { type: { type: string, enum: [ORDERED_LIST] } } }
CodeBlock:
type: object
additionalProperties: false
required: [type, code, language, label]
properties:
type: { type: string, const: CODE_BLOCK }
type: { type: string, enum: [CODE_BLOCK] }
code: { type: string, maxLength: 100000 }
language: { type: [string, "null"], maxLength: 100 }
label: { type: [string, "null"], maxLength: 200 }
@@ -1202,7 +1399,7 @@ components:
additionalProperties: false
required: [type, id, caption, rowHeaderColumn, columns, rows]
properties:
type: { type: string, const: DATA_TABLE }
type: { type: string, enum: [DATA_TABLE] }
id: { type: string, minLength: 1, maxLength: 200 }
caption: { type: string, maxLength: 1000 }
rowHeaderColumn: { type: [integer, "null"], minimum: 1 }
@@ -1213,7 +1410,7 @@ components:
additionalProperties: false
required: [type, tone, label, content]
properties:
type: { type: string, const: CALLOUT }
type: { type: string, enum: [CALLOUT] }
tone: { type: string, enum: [warning, info] }
label: { type: string, maxLength: 200 }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
@@ -1236,7 +1433,7 @@ components:
Asset decorative=true → alt="" 허용
```
properties:
type: { type: string, const: EVIDENCE_FIGURE }
type: { type: string, enum: [EVIDENCE_FIGURE] }
key: { type: string, minLength: 1, maxLength: 200 }
alt: { type: string, maxLength: 1000 }
caption: { type: string, maxLength: 1000 }
@@ -1258,6 +1455,29 @@ components:
width: { type: [integer, "null"], minimum: 1 }
height: { type: [integer, "null"], minimum: 1 }
decorative: { type: boolean }
ThematicBreakBlock:
type: object
additionalProperties: false
description: |
`---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다.
required: [type]
properties:
type: { type: string, enum: [THEMATIC_BREAK] }
ImageBlock:
type: object
additionalProperties: false
description: |
`![alt](/media/...)` 로 쓴 그림이다.
`EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시
시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은
링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다.
required: [type, src, alt, title]
properties:
type: { type: string, enum: [IMAGE] }
src: { type: string, minLength: 1, maxLength: 500 }
alt: { type: string, maxLength: 300 }
title: { type: [string, "null"], maxLength: 300 }
CaseRenderBlock:
oneOf:
- { $ref: "#/components/schemas/HeadingBlock" }
@@ -1269,6 +1489,8 @@ components:
- { $ref: "#/components/schemas/DataTableBlock" }
- { $ref: "#/components/schemas/CalloutBlock" }
- { $ref: "#/components/schemas/EvidenceFigureBlock" }
- { $ref: "#/components/schemas/ThematicBreakBlock" }
- { $ref: "#/components/schemas/ImageBlock" }
discriminator:
propertyName: type
mapping:
@@ -1281,6 +1503,8 @@ components:
DATA_TABLE: "#/components/schemas/DataTableBlock"
CALLOUT: "#/components/schemas/CalloutBlock"
EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock"
THEMATIC_BREAK: "#/components/schemas/ThematicBreakBlock"
IMAGE: "#/components/schemas/ImageBlock"
CasePublicRenderModel:
unevaluatedProperties: false
allOf:
@@ -1288,9 +1512,9 @@ components:
- type: object
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyBlocks]
properties:
kind: { type: string, const: CASE }
problem: { type: string, minLength: 1, maxLength: 100000 }
conclusion: { type: string, minLength: 1, maxLength: 100000 }
kind: { type: string, enum: [CASE] }
problem: { type: string, maxLength: 100000 }
conclusion: { type: string, maxLength: 100000 }
environment: { type: string, maxLength: 100000 }
reproduction: { type: string, maxLength: 100000 }
lastVerifiedOn: { type: string, format: date }
@@ -1302,8 +1526,8 @@ components:
- type: object
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
properties:
kind: { type: string, const: REFERENCE }
purpose: { type: string, minLength: 1, maxLength: 100000 }
kind: { type: string, enum: [REFERENCE] }
purpose: { type: string, maxLength: 100000 }
rules: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
applyWhen: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
@@ -1314,7 +1538,7 @@ components:
additionalProperties: false
required: [summary, evidenceTarget, linkLabel]
properties:
summary: { type: string, minLength: 1, maxLength: 100000 }
summary: { type: string, maxLength: 100000 }
evidenceTarget: { $ref: "#/components/schemas/DisplayTarget" }
linkLabel: { type: string, minLength: 1, maxLength: 120 }
QuestionPublicRenderModel:
@@ -1324,7 +1548,7 @@ components:
- type: object
required: [kind, status, facts, assumptions, unknowns, constraints, options, nextValidation, resolution]
properties:
kind: { type: string, const: QUESTION }
kind: { type: string, enum: [QUESTION] }
status:
type: string
enum: [OPEN, RESOLVED]
@@ -1334,7 +1558,7 @@ components:
unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } }
nextValidation: { type: string, minLength: 1, maxLength: 100000 }
nextValidation: { type: string, maxLength: 100000 }
resolution:
oneOf:
- { $ref: "#/components/schemas/ResolvedQuestionResolution" }
@@ -1346,11 +1570,14 @@ components:
- type: object
required: [kind, status, decidedOn, statement, rationale, consequences]
properties:
kind: { type: string, const: PROJECT_DECISION }
kind: { type: string, enum: [PROJECT_DECISION] }
status: { type: string, enum: [PROPOSED, ADOPTED] }
decidedOn: { type: string, format: date }
statement: { type: string, minLength: 1, maxLength: 100000 }
rationale: { type: string, minLength: 1, maxLength: 100000 }
# 결정일은 비어 있을 수 있다. 검증은 이것을 경고로만 다루므로(DECIDED_ON_REQUIRED)
# 날짜 없이 게시할 수 있는데, 렌더 모델이 필수로 요구하면 그 문서는 미리보기조차
# 열리지 않는다 — 두 규칙이 어긋나면 작성자는 "경고라며 왜 안 되냐"를 만난다.
decidedOn: { type: [string, "null"], format: date }
statement: { type: string, maxLength: 100000 }
rationale: { type: string, maxLength: 100000 }
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
PublicRenderModel:
oneOf:
@@ -1645,51 +1872,3 @@ components:
pattern: "^(?:/(?:[^~/]|~0|~1)*)*$"
description: JSON Pointer to the invalid field
message: { type: string, minLength: 1, maxLength: 1000 }
ProblemDetails:
type: object
additionalProperties: true
required: [type, title, status, detail, code]
properties:
type: { type: string, format: uri-reference }
title: { type: string, minLength: 1, maxLength: 200 }
status: { type: integer, minimum: 400, maximum: 599 }
detail: { type: string, minLength: 1, maxLength: 5000 }
code:
type: string
enum:
- AUTHENTICATION_REQUIRED
- STUDIO_ACCESS_DENIED
- DOCUMENT_NOT_FOUND
- VERSION_CONFLICT
- REQUEST_VALIDATION_FAILED
- VALIDATION_FAILED
- VALIDATION_STALE
- PREVIEW_NOT_FOUND
- PREVIEW_STALE
- PREVIEW_EXPIRED
- PUBLICATION_NOT_FOUND
- PUBLICATION_CONFLICT
- PUBLICATION_EVENT_NOT_FOUND
- PUBLICATION_SNAPSHOT_NOT_FOUND
- WARNING_ACKNOWLEDGEMENT_REQUIRED
- IDEMPOTENCY_KEY_REUSED
- ASSET_NOT_FOUND
- ASSET_NOT_READY
- ASSET_IN_USE
- ASSET_QUARANTINED
- PAYLOAD_TOO_LARGE
- UNSUPPORTED_MEDIA_TYPE
- STUDIO_UNAVAILABLE
instance: { type: string, format: uri-reference }
traceId: { type: string, maxLength: 200 }
fieldErrors: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/FieldError" } }
latestDocument: { $ref: "#/components/schemas/WorkingCopyDetail" }
latestPublication: { $ref: "#/components/schemas/PublicationAggregate" }
conflictingFields:
type: array
uniqueItems: true
maxItems: 200
items:
type: string
pattern: "^(?:/(?:[^~/]|~0|~1)*)*$"
retryable: { type: boolean }
@@ -0,0 +1,539 @@
import type {
CommandEffectDescriptor,
InstalledContractContribution,
InstalledHttpContract,
} from "../../../contracts/external-contract-runtime.ts";
import type { ProblemDetails } from "./studio/contract.ts";
import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts";
import canonicalSource from "./management/canonical-source.json" with { type: "json" };
import {
envelopeData,
envelopeError,
passthroughInput,
} from "./tech-log-studio-contract-contribution.ts";
import { TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID } from "../adapters/http/studio-session-credentials.ts";
type PathValues = Readonly<Record<string, string>>;
type QueryEntries = readonly (readonly [string, string])[];
const NO_PATH: PathValues = Object.freeze({});
const NO_QUERY = Object.freeze([]) as QueryEntries;
/** studio-management-v1.yaml `ApiError.code` enum과 1:1이다. */
const MANAGEMENT_ERROR_CODES = Object.freeze([
"AUTHENTICATION_REQUIRED",
"STUDIO_ACCESS_DENIED",
"REQUEST_VALIDATION_FAILED",
"VERSION_CONFLICT",
"TOPIC_NOT_FOUND",
"TOPIC_NAME_TAKEN",
"TOPIC_SLUG_TAKEN",
"TOPIC_IN_USE",
"PROJECT_NOT_FOUND",
"PROJECT_SLUG_TAKEN",
"PROJECT_IN_USE",
"RELEASE_NOT_FOUND",
"RELEASE_VERSION_TAKEN",
"RELEASE_NOT_PUBLISHABLE",
"DOCUMENT_NOT_FOUND",
"DOCUMENT_PUBLISHED",
"DOCUMENT_IN_USE",
"QUESTION_NOT_FOUND",
"QUESTION_IN_USE",
"DECISION_NOT_FOUND",
"DECISION_IN_USE",
"INTERNAL_ERROR",
]);
const PROBLEM = envelopeError(MANAGEMENT_ERROR_CODES, "ManagementErrorEnvelope");
/** Studio 쪽과 같은 판정이다: 4xx 도메인 거절은 적용되지 않았음이 확정, 5xx·네트워크는 불확정. */
const COMMAND_EFFECT: CommandEffectDescriptor<ProblemDetails> = Object.freeze({
successEffect: "APPLIED_CONFIRMED" as const,
classifyProblem({ status }: Readonly<{ status: number; problem: unknown }>) {
return status >= 400 && status < 500 ? "NOT_APPLIED" : "MAYBE_APPLIED";
},
});
/**
* 관리 표면은 Studio 와 같은 세션·CSRF 를 쓴다. 계약이 `sessionCookie` 보안과 `CsrfToken`
* 파라미터를 선언하고 있고, 실제로 같은 백엔드의 같은 필터 체인을 지난다 — 그래서 인증 프로필도
* 공유한다. 부트스트랩 프로필은 쓰지 않는다: CSRF 토큰을 발급하는 것은 `getStudioSession`
* 하나뿐이고, 이 표면은 그 뒤에만 호출된다.
*/
function readOperation(
operationId: string,
pathTemplate: string,
responseByteLimit: number,
project: (input: never) => Readonly<{ pathValues: PathValues; queryEntries: QueryEntries }> = () =>
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY }),
): InstalledHttpContract<unknown, unknown, unknown> {
return Object.freeze({
contract: Object.freeze({
operationId,
method: "GET" as const,
pathTemplate,
inputValidator: passthroughInput(`${operationId}Input`),
outputValidator: envelopeData(`${operationId}Output`),
problemValidator: PROBLEM,
acceptedStatuses: Object.freeze([200]),
emptyBodyStatuses: Object.freeze([]),
retrySemantics: "SAFE" as const,
requestBody: "NONE" as const,
responseBody: "REQUIRED_JSON" as const,
commandRecovery: null,
commandEffect: null,
projectRequest(input: never) {
return Object.freeze({ ...project(input), body: null });
},
}),
frontend: Object.freeze({
policyId: `${operationId}_V1`,
requestByteLimit: 0,
responseByteLimit,
totalDeadlineMs: 10_000,
retryBudget: 2 as const,
authProfileId: TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID,
diagnosticsOperation: `techLog.management.${operationId}`,
}),
}) as InstalledHttpContract<unknown, unknown, unknown>;
}
/**
* 쓰기는 `Idempotency-Key` 를 쓰지 않는다 — 계약이 요구하지 않고, 재생 보호는 `expectedVersion`
* 이 맡는다. 그래서 `NOT_IDEMPOTENT` 가 아니라 재시도 예산 0 으로 둔다: 응답을 못 본 재시도가
* 두 번째 생성을 만들 수 있는 표면이다.
*/
function writeOperation(
operationId: string,
method: "POST" | "PUT" | "DELETE",
pathTemplate: string,
options: Readonly<{
acceptedStatuses: readonly number[];
emptyBodyStatuses?: readonly number[];
requestByteLimit: number;
responseByteLimit: number;
}>,
project: (input: never) => Readonly<{
pathValues: PathValues;
queryEntries: QueryEntries;
body: unknown;
}>,
): InstalledHttpContract<unknown, unknown, unknown> {
return Object.freeze({
contract: Object.freeze({
operationId,
method,
pathTemplate,
inputValidator: passthroughInput(`${operationId}Input`),
outputValidator: envelopeData(`${operationId}Output`),
problemValidator: PROBLEM,
acceptedStatuses: Object.freeze([...options.acceptedStatuses]),
emptyBodyStatuses: Object.freeze([...(options.emptyBodyStatuses ?? [])]),
// 생성은 재생 보호가 없으므로 NEVER 다. 수정·삭제는 expectedVersion 이 두 번째
// 적용을 409 로 막으므로 IDEMPOTENT 로 둘 수 있지만, 세 경우를 한 헬퍼가 만들고
// 있어 가장 보수적인 값으로 통일한다 — 재시도 예산도 0 이라 실제 차이는 없다.
retrySemantics: "NEVER" as const,
requestBody: "JSON" as const,
responseBody:
(options.emptyBodyStatuses ?? []).length > 0
? ("OPTIONAL_JSON" as const)
: ("REQUIRED_JSON" as const),
commandRecovery: null,
commandEffect: COMMAND_EFFECT,
projectRequest(input: never) {
return Object.freeze(project(input));
},
}),
frontend: Object.freeze({
policyId: `${operationId}_V1`,
requestByteLimit: options.requestByteLimit,
responseByteLimit: options.responseByteLimit,
totalDeadlineMs: 15_000,
retryBudget: 0 as const,
authProfileId: TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID,
diagnosticsOperation: `techLog.management.${operationId}`,
}),
// read 쪽과 달리 여기서만 unknown 을 거친다: `emptyBodyStatuses` 유무로 responseBody 가
// 갈리는 삼항이 union 타입을 만들어, 컴파일러가 리터럴을 대상 타입과 겹친다고 보지 않는다.
}) as unknown as InstalledHttpContract<unknown, unknown, unknown>;
}
const byId = (input: never) => {
const value = input as unknown as Readonly<{ id: string }>;
return Object.freeze({ pathValues: Object.freeze({ id: value.id }), queryEntries: NO_QUERY });
};
const T = "/api/v1/studio/topics";
const P = "/api/v1/studio/projects";
const R = "/api/v1/studio/releases";
const D = "/api/v1/studio";
const HTTP_CONTRACTS = Object.freeze([
readOperation("listStudioTopics", T, 131_072),
writeOperation(
"createTopic",
"POST",
T,
{ acceptedStatuses: [201], requestByteLimit: 16_384, responseByteLimit: 16_384 },
(input: never) =>
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }),
),
writeOperation(
"updateTopic",
"PUT",
`${T}/{id}`,
{ acceptedStatuses: [200], requestByteLimit: 16_384, responseByteLimit: 16_384 },
(input: never) => {
const value = input as unknown as Readonly<{ id: string; body: unknown }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: value.body,
});
},
),
writeOperation(
"deleteTopic",
"DELETE",
`${T}/{id}`,
{
acceptedStatuses: [204],
emptyBodyStatuses: [204],
requestByteLimit: 1_024,
responseByteLimit: 1_024,
},
(input: never) => {
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
readOperation("listStudioProjects", P, 262_144, (input: never) => {
const value = input as unknown as Readonly<{ page?: number; size?: number }> | undefined;
const entries: (readonly [string, string])[] = [];
if (value?.page !== undefined) entries.push(["page", String(value.page)]);
if (value?.size !== undefined) entries.push(["size", String(value.size)]);
return Object.freeze({ pathValues: NO_PATH, queryEntries: Object.freeze(entries) });
}),
readOperation("getProjectForEdit", `${P}/{id}`, 262_144, byId),
writeOperation(
"createProject",
"POST",
P,
{ acceptedStatuses: [201], requestByteLimit: 4_096, responseByteLimit: 8_192 },
(input: never) =>
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }),
),
writeOperation(
"updateProject",
"PUT",
`${P}/{id}`,
{ acceptedStatuses: [200], requestByteLimit: 131_072, responseByteLimit: 262_144 },
(input: never) => {
const value = input as unknown as Readonly<{ id: string; body: unknown }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: value.body,
});
},
),
writeOperation(
"deleteProject",
"DELETE",
`${P}/{id}`,
{
acceptedStatuses: [204],
emptyBodyStatuses: [204],
requestByteLimit: 1_024,
responseByteLimit: 1_024,
},
(input: never) => {
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
readOperation("listStudioReleases", R, 262_144, (input: never) => {
const value = input as unknown as Readonly<{ page?: number; size?: number }> | undefined;
const entries: (readonly [string, string])[] = [];
if (value?.page !== undefined) entries.push(["page", String(value.page)]);
if (value?.size !== undefined) entries.push(["size", String(value.size)]);
return Object.freeze({ pathValues: NO_PATH, queryEntries: Object.freeze(entries) });
}),
readOperation("getReleaseForEdit", `${R}/{id}`, 262_144, byId),
writeOperation(
"createRelease",
"POST",
R,
{ acceptedStatuses: [201], requestByteLimit: 4_096, responseByteLimit: 8_192 },
(input: never) =>
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }),
),
writeOperation(
"updateRelease",
"PUT",
`${R}/{id}`,
// 릴리즈 본문은 마크다운 여섯 구획이라 문서 다음으로 큰 요청이다.
{ acceptedStatuses: [200], requestByteLimit: 262_144, responseByteLimit: 262_144 },
(input: never) => {
const value = input as unknown as Readonly<{ id: string; body: unknown }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: value.body,
});
},
),
writeOperation(
"deleteRelease",
"DELETE",
`${R}/{id}`,
{
acceptedStatuses: [204],
emptyBodyStatuses: [204],
requestByteLimit: 1_024,
responseByteLimit: 1_024,
},
(input: never) => {
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
readOperation("listStudioProjectActivities", `${P}/{id}/activities`, 262_144, byId),
writeOperation(
"createProjectActivity",
"POST",
`${P}/{id}/activities`,
{ acceptedStatuses: [201], requestByteLimit: 16_384, responseByteLimit: 16_384 },
(input: never) => {
const value = input as unknown as Readonly<{ id: string; body: unknown }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: value.body,
});
},
),
writeOperation(
"updateProjectActivity",
"PUT",
`${P}/{id}/activities/{activityId}`,
{ acceptedStatuses: [200], requestByteLimit: 16_384, responseByteLimit: 16_384 },
(input: never) => {
const value = input as unknown as Readonly<{
id: string;
activityId: string;
body: unknown;
}>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id, activityId: value.activityId }),
queryEntries: NO_QUERY,
body: value.body,
});
},
),
writeOperation(
"deleteProjectActivity",
"DELETE",
`${P}/{id}/activities/{activityId}`,
{
acceptedStatuses: [204],
emptyBodyStatuses: [204],
requestByteLimit: 1_024,
responseByteLimit: 1_024,
},
(input: never) => {
const value = input as unknown as Readonly<{
id: string;
activityId: string;
expectedVersion: number;
}>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id, activityId: value.activityId }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
writeOperation(
"publishProject",
"POST",
`${P}/{id}/publish`,
{ acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 8_192 },
(input: never) => {
const value = input as unknown as Readonly<{
id: string;
expectedVersion: number;
visibility: "PUBLIC" | "UNLISTED";
}>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion, visibility: value.visibility },
});
},
),
writeOperation(
"unpublishProject",
"POST",
`${P}/{id}/unpublish`,
{ acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 262_144 },
(input: never) => {
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
readOperation("getHomeFocus", `${D}/home/focus`, 8_192),
writeOperation(
"updateHomeFocus",
"PUT",
`${D}/home/focus`,
{ acceptedStatuses: [200], requestByteLimit: 4_096, responseByteLimit: 8_192 },
(input: never) =>
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }),
),
writeOperation(
"publishRelease",
"POST",
`${R}/{id}/publish`,
{ acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 8_192 },
(input: never) => {
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
writeOperation(
"archiveRelease",
"POST",
`${R}/{id}/archive`,
{ acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 262_144 },
(input: never) => {
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
writeOperation(
"deleteCaseDraft",
"DELETE",
`${D}/cases/{id}`,
{
acceptedStatuses: [204],
emptyBodyStatuses: [204],
requestByteLimit: 1_024,
responseByteLimit: 1_024,
},
(input: never) => {
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
writeOperation(
"deleteReferenceDraft",
"DELETE",
`${D}/references/{id}`,
{
acceptedStatuses: [204],
emptyBodyStatuses: [204],
requestByteLimit: 1_024,
responseByteLimit: 1_024,
},
(input: never) => {
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
writeOperation(
"deleteQuestion",
"DELETE",
`${D}/questions/{id}`,
{
acceptedStatuses: [204],
emptyBodyStatuses: [204],
requestByteLimit: 1_024,
responseByteLimit: 1_024,
},
(input: never) => {
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
writeOperation(
"deleteProjectDecision",
"DELETE",
`${P}/{id}/decisions/{decisionId}`,
{
acceptedStatuses: [204],
emptyBodyStatuses: [204],
requestByteLimit: 1_024,
responseByteLimit: 1_024,
},
(input: never) => {
const value = input as unknown as Readonly<{
id: string;
decisionId: string;
expectedVersion: number;
}>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id, decisionId: value.decisionId }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
]);
export const TECH_LOG_MANAGEMENT_OPERATION_IDS = Object.freeze(
HTTP_CONTRACTS.map((entry) => entry.contract.operationId),
);
export const TECH_LOG_MANAGEMENT_CONTRIBUTION: InstalledContractContribution = Object.freeze({
contributionId: "tech-log-management-http-v1",
featureId: TECH_LOG_FEATURE_ID,
source: Object.freeze({
kind: "EXTERNAL_PACKAGE" as const,
package: Object.freeze({
packageId: canonicalSource.packageId,
version: canonicalSource.version,
digest: canonicalSource.digest as `sha256:${string}`,
runtimeProtocolVersion: 1 as const,
sourceRevision: canonicalSource.sourceRevision,
}),
}),
http: HTTP_CONTRACTS,
events: Object.freeze([]),
});
@@ -0,0 +1,171 @@
import type {
InstalledContractContribution,
InstalledHttpContract,
} from "../../../contracts/external-contract-runtime.ts";
import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts";
import canonicalSource from "./public/canonical-source.json" with { type: "json" };
import { envelopeData, envelopeError, passthroughInput } from "./tech-log-studio-contract-contribution.ts";
type PathValues = Readonly<Record<string, string>>;
type QueryEntries = readonly (readonly [string, string])[];
const NO_PATH: PathValues = Object.freeze({});
const NO_QUERY = Object.freeze([]) as QueryEntries;
/**
* public-v1.yaml `ApiError.code` enum과 1:1이다. `INTERNAL_ERROR` 는 이 기능이
* 아니라 스켈레톤 공통 처리기가 내는 코드이고, 계약이 그것까지 열거하므로 여기도
* 열거한다 — 빠지면 500 응답이 계약 위반으로 분류된다.
*/
const PUBLIC_ERROR_CODES = Object.freeze([
"PUBLIC_REQUEST_INVALID",
"PUBLIC_RESOURCE_NOT_FOUND",
"INTERNAL_ERROR",
]);
const PROBLEM = envelopeError(PUBLIC_ERROR_CODES, "PublicErrorEnvelope");
function queryOf(input: Readonly<Record<string, unknown>>): QueryEntries {
const entries: (readonly [string, string])[] = [];
for (const [key, value] of Object.entries(input)) {
if (value === undefined || value === null || value === "") continue;
entries.push([key, String(value)]);
}
return Object.freeze(entries);
}
/**
* Every public operation has the same shape, which is the point of holding this
* surface apart from Studio: all 18 are GET, none carries a body, none needs a
* session, and none needs a CSRF token. The `ANONYMOUS` auth profile is what
* states that — it forbids credentials outright, so a future change that starts
* sending the session cookie on a public read fails the profile check rather
* than silently making the cache-friendly surface user-specific.
*/
function publicRead(
operationId: string,
pathTemplate: string,
responseByteLimit: number,
project: (input: never) => Readonly<{ pathValues: PathValues; queryEntries: QueryEntries }> = () =>
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY }),
): InstalledHttpContract<unknown, unknown, unknown> {
return Object.freeze({
contract: Object.freeze({
operationId,
method: "GET" as const,
pathTemplate,
inputValidator: passthroughInput(`${operationId}Input`),
outputValidator: envelopeData(`${operationId}Output`),
problemValidator: PROBLEM,
// 404 is a normal answer here — a slug that is not published — so it is
// mapped by the gateway rather than treated as a transport failure.
acceptedStatuses: Object.freeze([200]),
emptyBodyStatuses: Object.freeze([]),
retrySemantics: "SAFE" as const,
requestBody: "NONE" as const,
responseBody: "REQUIRED_JSON" as const,
commandRecovery: null,
commandEffect: null,
projectRequest(input: never) {
return Object.freeze({ ...project(input), body: null });
},
}),
frontend: Object.freeze({
policyId: `${operationId}_V1`,
requestByteLimit: 0,
responseByteLimit,
totalDeadlineMs: 10_000,
retryBudget: 2 as const,
authProfileId: "ANONYMOUS",
diagnosticsOperation: `techLog.public.${operationId}`,
}),
}) as InstalledHttpContract<unknown, unknown, unknown>;
}
const bySlug = (input: never) => {
const value = input as unknown as Readonly<{ slug: string }>;
return Object.freeze({
pathValues: Object.freeze({ slug: value.slug }),
queryEntries: NO_QUERY,
});
};
const P = "/api/v1/public";
const HTTP_CONTRACTS = Object.freeze([
publicRead("getPublicSite", `${P}/site`, 32_768),
publicRead("getPublicHome", `${P}/home`, 262_144),
publicRead("exploreKnowledge", `${P}/explore/knowledge`, 262_144, (input: never) => {
const value = input as unknown as Readonly<{
type?: string;
topic?: string;
project?: string;
page?: number;
size?: number;
}>;
return Object.freeze({ pathValues: NO_PATH, queryEntries: queryOf(value ?? {}) });
}),
publicRead("exploreQuestions", `${P}/explore/questions`, 262_144, (input: never) => {
const value = input as unknown as Readonly<{
status?: string;
topic?: string;
project?: string;
page?: number;
size?: number;
}>;
return Object.freeze({ pathValues: NO_PATH, queryEntries: queryOf(value ?? {}) });
}),
publicRead("listPublicTopics", `${P}/topics`, 65_536),
publicRead("getPublicTopic", `${P}/topics/{topicSlug}`, 262_144, (input: never) => {
const value = input as unknown as Readonly<{ topicSlug: string }>;
return Object.freeze({
pathValues: Object.freeze({ topicSlug: value.topicSlug }),
queryEntries: NO_QUERY,
});
}),
publicRead("getPublicCase", `${P}/cases/{slug}`, 524_288, bySlug),
publicRead("getPublicReference", `${P}/references/{slug}`, 524_288, bySlug),
publicRead("getPublicQuestion", `${P}/questions/{slug}`, 524_288, bySlug),
publicRead("listPublicProjects", `${P}/projects`, 262_144),
publicRead("getPublicProject", `${P}/projects/{slug}`, 262_144, bySlug),
publicRead("listPublicProjectDecisions", `${P}/projects/{slug}/decisions`, 262_144, bySlug),
publicRead("listPublicProjectRecords", `${P}/projects/{slug}/records`, 262_144, bySlug),
publicRead("listPublicProjectActivities", `${P}/projects/{slug}/activities`, 262_144, bySlug),
publicRead("listPublicReleases", `${P}/releases`, 262_144),
publicRead("getPublicRelease", `${P}/releases/{version}`, 262_144, (input: never) => {
const value = input as unknown as Readonly<{ version: string }>;
return Object.freeze({
pathValues: Object.freeze({ version: value.version }),
queryEntries: NO_QUERY,
});
}),
publicRead("getPublicProfile", `${P}/profile`, 65_536),
publicRead("searchPublicResources", `${P}/search`, 262_144, (input: never) => {
const value = input as unknown as Readonly<{ q?: string; page?: number; size?: number }>;
return Object.freeze({ pathValues: NO_PATH, queryEntries: queryOf(value ?? {}) });
}),
]);
export const TECH_LOG_PUBLIC_OPERATION_IDS = Object.freeze(
HTTP_CONTRACTS.map((entry) => entry.contract.operationId),
);
export type TechLogPublicOperationId =
(typeof TECH_LOG_PUBLIC_OPERATION_IDS)[number];
export const TECH_LOG_PUBLIC_CONTRIBUTION: InstalledContractContribution =
Object.freeze({
contributionId: "tech-log-public-http-v1",
featureId: TECH_LOG_FEATURE_ID,
source: Object.freeze({
kind: "EXTERNAL_PACKAGE" as const,
package: Object.freeze({
packageId: canonicalSource.packageId,
version: canonicalSource.version,
digest: canonicalSource.digest as `sha256:${string}`,
runtimeProtocolVersion: 1 as const,
sourceRevision: canonicalSource.sourceRevision,
}),
}),
http: HTTP_CONTRACTS,
events: Object.freeze([]),
});
@@ -48,6 +48,10 @@ const TECH_LOG_ROUTE_SPECS = [
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATIONS", path: "/studio/publications", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "게시 기록", navigationLabel: "게시 기록", navigationOrder: 20 }),
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATION_PREVIEW", path: "/studio/publications/:publicationEventId/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogPublicationEventIdParams", searchSchema: null, title: "게시 Snapshot", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_TAXONOMY", path: "/studio/taxonomy", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "주제와 프로젝트", navigationLabel: "주제·프로젝트", navigationOrder: 40 }),
defineSpec({ routeId: "TECH_LOG_STUDIO_PROJECT_EDIT", path: "/studio/projects/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "프로젝트 편집", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASES", path: "/studio/releases", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "릴리즈", navigationLabel: "릴리즈", navigationOrder: 50 }),
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASE_EDIT", path: "/studio/releases/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "릴리즈 편집", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }),
] as const;
@@ -139,7 +143,13 @@ export const TECH_LOG_ROUTE_REGISTRY = Object.freeze(
spec.routeId,
Object.freeze({
...spec,
access: "public",
// Studio routes are the authenticated surface. Deriving this from the
// spec's own `layoutGroup` -- rather than restating it per route --
// keeps a newly added Studio route gated by construction. Registering
// every TechLog route as "public" made `decideRouteAccessForDefinition`
// a no-op for Studio: a signed-out visitor who typed /studio got the
// Studio shell rendered instead of the sign-in surface.
access: spec.layoutGroup === "STUDIO" ? "session-required" : "public",
loadingSurface: spec.path.endsWith("*") ? "none" : "app-shell",
errorSurface: spec.path.endsWith("*")
? "not-found"
@@ -8,6 +8,7 @@ import type {
} from "../../../contracts/external-contract-runtime.ts";
import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts";
import canonicalSource from "./studio/canonical-source.json" with { type: "json" };
import type { ProblemDetails } from "./studio/contract.ts";
import { STUDIO_ERROR_CODES } from "../adapters/http/studio-error-mapping.ts";
import {
assertExactlyOneTechLogStudioBootstrapOperation,
@@ -51,21 +52,93 @@ function zodValidator<T>(schemaId: string, schema: z.ZodType<T>): RuntimeValidat
const passthrough = <T>(schemaId: string) =>
zodValidator<T>(schemaId, z.unknown() as unknown as z.ZodType<T>);
const problemSchema = z
.object({
// canonical: `format: uri-reference` only, no length bound.
type: z.string().min(1),
title: z.string().min(1).max(200),
status: z.int().min(400).max(599),
detail: z.string().min(1).max(5000),
code: z.enum(STUDIO_ERROR_CODES as unknown as [string, ...string[]]),
})
/**
* Shared with the public contribution: both surfaces project their request
* inputs in code, so neither re-validates them at the transport boundary.
*/
export const passthroughInput = passthrough;
/**
* wire format은 봉투다 (ADR-006). 전송 계층은 봉투 뼈대만 검증하고 payload는
* 통과시킨다 — generated 타입이 컴파일 시점 계약이고, 런타임 재검증은 계약 갱신
* 때마다 두 곳을 고치게 만든다. 다만 봉투 자체는 반드시 검증한다: 여기서 통과시키면
* 잘못된 모양이 앱 계층까지 조용히 흘러간다.
*/
const metaSchema = z
.object({ requestId: z.string().min(1), traceId: z.string().min(1) })
.loose();
const PROBLEM = zodValidator("StudioProblemDetails", problemSchema);
export const envelopeData = <T>(schemaId: string): RuntimeValidator<T> =>
zodValidator<T>(
schemaId,
z
.object({ success: z.literal(true), data: z.unknown(), meta: metaSchema })
.loose()
.transform((envelope) => envelope.data as T) as unknown as z.ZodType<T>,
);
const apiErrorSchema = (codes: readonly string[]) =>
z
.object({
code: z.enum(codes as unknown as [string, ...string[]]),
category: z.string().min(1),
message: z.string().min(1).max(5000),
retryable: z.boolean(),
})
.loose();
/**
* 봉투 오류를 기존 ProblemDetails 형태로 옮긴다. 앱 계층(`StudioGatewayError`)은
* 그 모양을 계속 쓰므로 매핑을 여기서 끝내면 아래 계층이 무변경이다.
* `status`는 봉투에 없다 — 전송 계층이 실제 HTTP status를 따로 들고 있으므로
* 0으로 두고 `toStudioGatewayError`가 outcome의 status로 덮는다.
*
* (Task 3 fix round 1) `ProblemDetails`는 `contract.ts`에서 가져온다 — 이
* transform이 실제로 만드는 모양과 `contract.ts`가 선언하는 모양이 서로 다른
* 파일에서 독립적으로 정의되면(원래 상태) 둘이 갈라져도 아무 게이트도 못
* 잡는다. `details`는 wire 그대로 통째로 옮긴다 — `fieldErrors`/
* `latestDocument`/`conflictingFields`/`latestPublication`으로 분해하지
* 않는다. 지금 그 필드들을 평평하게 읽는 소비자가 없고, 분해는 실제 소비자가
* 생겼을 때 추가할 투기적 작업이다.
*/
/**
* The code enum is per-surface, and getting that wrong took the public site
* down. Public, Studio and Management each declare their own `ApiError.code`
* enum in their own contract; this validator was pinned to the Studio list and
* shared by all three, so every public error — `PUBLIC_RESOURCE_NOT_FOUND`
* first among them — failed the enum, became a CONTRACT_VIOLATION rather than a
* PROBLEM, and reached the screens as an unclassifiable failure. A visitor
* following a link to a project that no longer exists got the terminal error
* surface instead of a not-found page, and no gate noticed, because a strict
* enum checked against the wrong surface's contract still looks strict.
*
* Each caller now passes the enum from its own contract.
*/
export const envelopeError = (
codes: readonly string[] = STUDIO_ERROR_CODES as readonly string[],
schemaId = "StudioErrorEnvelope",
): RuntimeValidator<ProblemDetails> =>
zodValidator<ProblemDetails>(
schemaId,
z
.object({ success: z.literal(false), error: apiErrorSchema(codes), meta: metaSchema })
.loose()
.transform((envelope) => ({
type: `https://techlog.local/problems/${envelope.error.code.toLowerCase().replaceAll("_", "-")}`,
title: envelope.error.code,
status: 0,
detail: envelope.error.message,
code: envelope.error.code,
retryable: envelope.error.retryable,
category: envelope.error.category,
details: (envelope.error as { details?: ProblemDetails["details"] }).details ?? null,
})) as unknown as z.ZodType<ProblemDetails>,
);
const PROBLEM = envelopeError();
/** 4xx 도메인 거절은 적용되지 않았음이 확정이다. 5xx/네트워크는 불확정이다. */
const COMMAND_EFFECT: CommandEffectDescriptor<z.output<typeof problemSchema>> =
const COMMAND_EFFECT: CommandEffectDescriptor<ProblemDetails> =
Object.freeze({
successEffect: "APPLIED_CONFIRMED" as const,
classifyProblem({ status }: Readonly<{ status: number; problem: unknown }>) {
@@ -93,7 +166,7 @@ function safeOperation(
method: "GET" as const,
pathTemplate,
inputValidator: passthrough(`${operationId}Input`),
outputValidator: passthrough(`${operationId}Output`),
outputValidator: envelopeData(`${operationId}Output`),
problemValidator: PROBLEM,
acceptedStatuses: Object.freeze([200]),
emptyBodyStatuses: Object.freeze([]),
@@ -141,7 +214,7 @@ function keyedOperation(
method,
pathTemplate,
inputValidator: passthrough(`${operationId}Input`),
outputValidator: passthrough(`${operationId}Output`),
outputValidator: envelopeData(`${operationId}Output`),
problemValidator: PROBLEM,
acceptedStatuses: Object.freeze([options.acceptedStatus]),
emptyBodyStatuses: Object.freeze(options.acceptedStatus === 204 ? [204] : []),
@@ -180,14 +180,27 @@ function normalizeDirectives(source: string): string {
return line;
}
/*
`:::name key="value"` 를 remark-directive 가 읽는 `:::name{key="value"}` 로 바꾼다.
이름과 나머지 사이의 경계를 `\s` 로 못 박는 것이 중요하다. 예전에는 이름을
`[a-z0-9-]*` 로 두고 나머지가 `{` 로 시작하지 않기만 요구했는데, 정규식이 되돌아가며
이름의 마지막 글자를 나머지 쪽으로 넘겨 그 조건을 피해 갔다 — `:::note` 는 이름 `not`
에 본문 `e` 가 되어 `:::not{e}` 로, 이미 중괄호를 쓴 `:::table{id="t"}` 는
`:::tabl{e{id="t"}}` 로 망가졌다. 그래서 속성 없는 디렉티브는 이름이 통째로 바뀌고,
중괄호 형태는 아예 해석되지 않았다.
*/
return line.replace(
/^(:::[a-z][a-z0-9-]*)([^\n{][^\n]*)(\n?)$/i,
/^(:::[a-z][a-z0-9-]*)(\s+[^\n]*?)(\n?)$/i,
(
_match,
marker: string,
attributes: string,
newline: string,
) => `${marker}{${attributes.trim()}}${newline}`,
) => {
const trimmed = attributes.trim();
return trimmed ? `${marker}{${trimmed}}${newline}` : `${marker}${newline}`;
},
);
})
.join("");
@@ -199,6 +212,14 @@ function assertNever(value: never): never {
const trustedRelativeLinkOrigin = "https://techlog.invalid";
/** 서버가 아는 callout 이름과 화면에 붙일 말. 이름이 tone 을 겸하므로 속성을 받지 않는다. */
const CALLOUT_LABELS: Readonly<Record<string, string>> = {
note: "참고",
tip: "도움말",
warning: "주의",
danger: "위험",
};
function hasAsciiControlCharacter(value: string): boolean {
return Array.from(value).some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
@@ -326,8 +347,8 @@ function headingBlock(
node: Heading,
usedIds: Set<string>,
): components["schemas"]["HeadingBlock"] {
if (node.depth < 2 || node.depth > 4) {
invalid(node, "only heading levels 2 through 4 are supported");
if (node.depth < 1 || node.depth > 6) {
invalid(node, "only heading levels 1 through 6 are supported");
}
const children = [...node.children];
@@ -417,15 +438,34 @@ function tableCellContent(cell: TableCell): Inline[] {
return inlineFromNodes(cell.children);
}
/**
* `:::table` 없이 쓴 GFM 표에 붙일 값.
*
* <p>서버 렌더러는 파이프 표를 그대로 읽는다 — `:::table` 이라는 directive 자체를 모른다.
* 여기서만 감싸기를 요구하면 같은 본문이 Studio 와 공개 화면에서 다르게 읽히므로, 감싸지 않은
* 표도 받는다. `id` 는 자리 순서로 만들고 `caption` 은 비운다. 설명이 필요하면 `:::table` 로
* 감싸 `caption` 을 주면 된다.
*/
function bareTableAttributes(index: number): Record<string, string> {
return { id: `table-${index}`, caption: "", rowHeaderColumn: "none" };
}
function tableBlock(
node: ContainerDirective,
node: ContainerDirective | Table,
usedIds: Set<string>,
bareIndex?: number,
): components["schemas"]["DataTableBlock"] {
const attributes = attributesOf(node, ["id", "caption", "rowHeaderColumn"]);
if (node.children.length !== 1 || node.children[0].type !== "table") {
invalid(node, "table directive must contain exactly one GFM table");
const bare = node.type === "table";
const attributes = bare
? bareTableAttributes(bareIndex!)
: attributesOf(node as ContainerDirective, ["id", "caption", "rowHeaderColumn"]);
if (!bare) {
const container = node as ContainerDirective;
if (container.children.length !== 1 || container.children[0].type !== "table") {
invalid(node, "table directive must contain exactly one GFM table");
}
}
const table = node.children[0] as Table;
const table = (bare ? node : (node as ContainerDirective).children[0]) as Table;
if (table.children.length === 0) invalid(table, "table header is required");
const id = attributes.id;
@@ -506,6 +546,25 @@ function directiveBlock(
content: inlineFromNodes((node.children[0] as Paragraph).children),
};
}
/*
서버 렌더러가 아는 callout 이름이다(`note`/`tip` 은 정보, `warning`/`danger` 는 경고).
`:::callout tone="..."` 만 받으면 서버가 정상으로 읽는 본문을 여기서 거절하게 되므로 둘 다
받는다. 이름이 곧 tone 이라 속성이 없고, 라벨은 이름에서 만든다.
*/
case "note":
case "tip":
case "warning":
case "danger": {
if (node.children.length !== 1 || node.children[0].type !== "paragraph") {
invalid(node, `${node.name} directive must contain exactly one paragraph`);
}
return {
type: "CALLOUT",
tone: node.name === "note" || node.name === "tip" ? "info" : "warning",
label: CALLOUT_LABELS[node.name],
content: inlineFromNodes((node.children[0] as Paragraph).children),
};
}
case "evidence": {
const attributes = attributesOf(node, ["key", "alt", "caption", "zoom"]);
if (node.children.length !== 0) {
@@ -533,10 +592,34 @@ function directiveBlock(
}
}
/**
* 문단 하나에 그림만 있으면 그림 블록으로 읽는다.
*
* <p>Markdown 에서 `![alt](/media/...)` 는 문단 안의 inline 이다. 인라인 유니온에는 그림이 없고
* 앞으로도 둘 이유가 없다 — 글 가운데 끼워 넣는 그림은 이 문서 형식이 다루는 대상이 아니다.
* 그래서 "문단이 그림 하나로만 이루어진 경우"만 블록으로 올린다.
*/
function imageBlockOf(node: Paragraph): components["schemas"]["ImageBlock"] | null {
const visible = node.children.filter(
(child) => !(child.type === "text" && child.value.trim() === ""),
);
if (visible.length !== 1) return null;
const only = visible[0]!;
if (only.type !== "image") return null;
const image = only as unknown as { url: string; alt?: string | null; title?: string | null };
if (!isSafeLink(image.url)) invalid(node, `unsafe image URL: ${image.url}`);
return {
type: "IMAGE",
src: image.url,
alt: image.alt ?? "",
title: image.title ?? null,
};
}
function paragraphBlock(
node: Paragraph,
): components["schemas"]["ParagraphBlock"] {
return { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
): components["schemas"]["ParagraphBlock"] | components["schemas"]["ImageBlock"] {
return imageBlockOf(node) ?? { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
}
export function parseCaseContent(source: string): CaseAuthoringBlock[] {
@@ -564,6 +647,7 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
listItemCount += 1;
return `list-item-${listItemCount}`;
};
let bareTableCount = 0;
return tree.children.map((node: Content): CaseAuthoringBlock => {
switch (node.type) {
@@ -585,11 +669,17 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
return codeBlock(node);
case "containerDirective":
return directiveBlock(node, usedIds);
case "html":
case "table": {
// 서버 렌더러는 파이프 표를 그대로 읽는다. 여기서 거절하면 같은 본문이 Studio 와
// 공개 화면에서 다르게 읽힌다.
bareTableCount += 1;
return tableBlock(node, usedIds, bareTableCount);
}
case "thematicBreak":
return { type: "THEMATIC_BREAK" };
case "html":
case "definition":
case "yaml":
case "table":
case "footnoteDefinition":
case "leafDirective":
return invalid(node, `unsupported block syntax: ${node.type}`);
@@ -131,7 +131,18 @@ function renderContext(
function publicPath(input: WorkingCopyInput, project: CatalogEntry | null) {
if (input.kind === "PROJECT_DECISION") {
if (!project?.publicPath) fail("PROJECT public path is required");
/*
Decision 의 공개 주소는 자기 slug 가 아니라 프로젝트 주소 아래에 있다. 그래서 프로젝트가
공개되어 있지 않으면 이 문서에는 아직 주소가 없다.
예전 문구는 "PROJECT public path is required" 였다. 사실이지만 작성자가 할 일을 말해 주지
않는다 — 무엇을 어디서 눌러야 하는지 적는다.
*/
if (!project?.publicPath) {
fail(
"이 Decision 의 공개 주소는 프로젝트 주소 아래에 있습니다. 주제·프로젝트 화면에서 이 프로젝트를 먼저 게시해 주세요.",
);
}
return `${project.publicPath}/decisions#${input.slug}`;
}
const prefix =
@@ -186,7 +197,7 @@ export function projectWorkingCopy(
const project = catalogEntry(catalog, input.projectId, "PROJECT", false);
if (!topic) fail("TOPIC catalog entry is required");
if (input.kind === "PROJECT_DECISION" && !project) {
fail("PROJECT catalog entry is required");
fail("Decision 은 프로젝트에 속합니다. 기본 정보에서 프로젝트를 골라 주세요.");
}
const base = {
@@ -273,12 +284,16 @@ export function projectWorkingCopy(
case "PROJECT_DECISION":
if (!input.decisionStatus) fail("Decision status is required");
if (!input.decidedOn) fail("Decision date is required");
/*
결정일은 비어 있어도 모델을 만든다. 검증이 그것을 경고로만 다루므로 날짜 없이 게시할 수
있는데, 여기서 막으면 그 문서는 미리보기조차 열리지 않는다 — 작성자는 "경고라면서 왜
안 되냐"를 만난다. 화면이 "미정"이라고 말하면 된다.
*/
return {
...base,
kind: "PROJECT_DECISION",
status: input.decisionStatus,
decidedOn: input.decidedOn,
decidedOn: input.decidedOn ?? null,
statement: input.statement,
rationale: input.rationale,
consequences: ordered(input.consequences),
@@ -188,6 +188,14 @@ function serializeBlock(block: CaseRenderBlock): string {
`:::evidence key=${quoteAttribute(block.key)} alt=${quoteAttribute(block.alt)} caption=${quoteAttribute(block.caption)} zoom=${quoteAttribute(String(block.zoom))}`,
":::",
].join("\n");
case "THEMATIC_BREAK":
return "---";
case "IMAGE":
// 제목은 Markdown 이 따옴표로 감싼다. 없으면 붙이지 않아야 다시 읽었을 때 빈 제목이 되지
// 않는다.
return block.title === null
? `![${escapeText(block.alt)}](${block.src})`
: `![${escapeText(block.alt)}](${block.src} "${block.title.replaceAll('"', '\\"')}")`;
default:
return assertNever(block);
}
@@ -11,3 +11,28 @@ export function createLocalId(
const entropy = Math.floor(random() * 1_000_000_000).toString().padStart(9, "0");
return `${prefix}-${now()}-${entropy}`;
}
/**
* 편집기가 새로 만든 목록 항목(관계·근거·규칙·선택지·순서 있는 문장)의 id.
*
* `createLocalId` 와 나눠 두는 이유는 이 값이 화면 밖으로 나가기 때문이다. 그쪽은 접두사를 붙여
* `relation-<uuid>` 같은 문자열을 만들고, 그건 React key 나 Idempotency-Key 로는 좋지만 계약에는
* 넣을 수 없다 — 계약이 요구하는 형식은 uuid 이고, 접두사가 붙은 값은 서버가 파싱조차 하지 못해
* 400 (`InvalidFormatException`) 이 된다. 저장 버튼이 "계약을 어겼다"는 말만 남기고 아무것도 저장하지
* 않던 이유가 이것이었다.
*
* 서버는 자기가 소유하지 않은 id 를 신뢰하지 않고 새로 부여한다({@code StudioRelationStore.replace}).
* 그래서 여기서 만드는 값은 "이 줄은 새것"이라는 표시일 뿐, 저장 뒤의 진짜 id 는 서버가 정한다.
*/
export function createNewItemId(
source: RandomUuidSource | null | undefined = globalThis.crypto,
random: () => number = Math.random,
): string {
const uuid = source?.randomUUID?.();
if (uuid) return uuid;
// randomUUID 가 없는 환경(비보안 컨텍스트)을 위한 대비. 형식만 uuid v4 를 지키면 된다 — 값 자체는
// 서버가 어차피 새로 부여한다.
const hex = (length: number) =>
Array.from({ length }, () => Math.floor(random() * 16).toString(16)).join("");
return `${hex(8)}-${hex(4)}-4${hex(3)}-${"89ab"[Math.floor(random() * 4)]}${hex(3)}-${hex(12)}`;
}
@@ -180,7 +180,42 @@ function resolvePublicEvidenceAssetDescriptor(
};
}
/**
* 게시된 본문을 블록으로 바꾼다.
*
* <p>예전에는 하드코딩된 슬러그 하나만 진짜 파서를 탔고 나머지는 모두 {@link genericCaseBlocks}
* 를 거쳤다 — 정규식이 `##` 제목과 `-` 불릿만 알아보므로 표·코드·callout 은 물론 evidence
* directive 까지 글자 그대로 문단이 되어 공개 화면에 그대로 보였다.
*
* <p>본문이 지원하지 않는 문법을 담고 있으면 화면 전체를 잃는 대신 예전 방식으로 돌아간다.
* 읽는 사람에게는 덜 정확한 화면이 빈 화면보다 낫다.
*/
function caseBodyBlocks(record: CaseRecord): CaseAuthoringBlock[] {
if (record.slug === "collection-fetch-join-pagination") {
return parseCaseContent(fetchJoinBody);
}
if (!record.content.trim()) return genericCaseBlocks(record);
try {
return parseCaseContent(record.content);
} catch {
return genericCaseBlocks(record);
}
}
export function CaseDocumentPage({ record }: { record: CaseRecord }) {
/*
본문은 evidence 를 key 로만 가리키고 `/media/{assetId}` 는 UUID 로만 서빙하므로, 계약이 함께
준 `bodyAssets` 로 key 를 주소로 바꾼다. 대응이 없는 key 는 그 블록을 지운다 — 해석기가
던지면 문서 전체가 사라지고, 남겨 두면 주소 없는 그림 자리가 남는다.
*/
const assetsByKey = new Map(record.bodyAssets.map((asset) => [asset.assetKey, asset]));
const blocks = caseBodyBlocks(record).filter(
(block) =>
block.type !== "EVIDENCE_FIGURE" ||
record.slug === "collection-fetch-join-pagination" ||
assetsByKey.has(block.key),
);
const model = resolveCaseEvidenceAssets(
{
...publicRenderModelBase(record),
@@ -190,18 +225,37 @@ export function CaseDocumentPage({ record }: { record: CaseRecord }) {
environment: record.environment,
reproduction: record.verification,
lastVerifiedOn: record.lastVerifiedLabel.replaceAll(".", "-"),
bodyBlocks:
record.slug === "collection-fetch-join-pagination"
? parseCaseContent(fetchJoinBody)
: genericCaseBlocks(record),
bodyBlocks: blocks,
},
(key) => {
const asset = assetsByKey.get(key);
if (!asset) return resolvePublicEvidenceAssetDescriptor(key);
return {
assetId: asset.assetId,
assetKey: asset.assetKey,
mediaType: asset.contentType,
publicPath: asset.url,
width: asset.width,
height: asset.height,
decorative: asset.decorative,
};
},
resolvePublicEvidenceAssetDescriptor,
);
return (
<PublicRecordRenderer
model={model}
resolveEvidenceAsset={resolvePublicEvidenceAsset}
resolveEvidenceAsset={(key) => {
const asset = assetsByKey.get(key);
if (!asset) return resolvePublicEvidenceAsset(key);
return {
src: asset.url,
width: asset.width ?? 0,
height: asset.height ?? 0,
triggerLabel: `${asset.altText || asset.assetKey} 크게 보기`,
dialogLabel: asset.altText || asset.assetKey,
};
}}
resolvePublishedLabel={(path) =>
path === record.path ? record.publishedLabel : undefined
}
@@ -1,8 +1,7 @@
import { Link, useNavigate } from "react-router-dom";
import type { RecordKind } from "../../../application/ports/public-content-queries.ts";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ExploreFilterForm({
action,
@@ -18,24 +17,51 @@ export function ExploreFilterForm({
showType?: boolean;
}) {
const navigate = useNavigate();
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const publicRecords = publicContent.listRecords();
const topics = [...new Set(publicRecords.map((record) => record.topic))].sort();
const projectPrefix = "/projects/";
const projects = publicContent
.searchPublicContent("")
.filter((entity) => entity.contentType === "PROJECT")
.flatMap((entity) => {
if (!entity.path.startsWith(projectPrefix)) return [];
const item = publicContent.getProject(
decodeURIComponent(entity.path.slice(projectPrefix.length)),
// This form sits inside a page that renders its own loading state, so it does
// not hand back a fallback of its own — that would put a second skeleton
// inside a screen already showing one, and move the layout under it. It
// renders its real structure immediately with empty option lists and fills
// them in when the catalog arrives.
const view = usePublicContent(["tech-log", "explore-filters"], async (queries) => {
const projectPrefix = "/projects/";
const [records, entities] = await Promise.all([
queries.listRecords(),
queries.searchPublicContent(""),
]);
const projectSlugs = entities
.filter((entity) => entity.contentType === "PROJECT")
.flatMap((entity) =>
entity.path.startsWith(projectPrefix)
? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
: [],
);
return item ? [{ slug: item.slug, title: item.title }] : [];
});
const resolved = await Promise.all(projectSlugs.map((slug) => queries.getProject(slug)));
return {
// 주제는 이름이 아니라 slug 로 거른다 — 프로젝트와 같다. 이름을 실었을 때는
// `topic=OAuth/OIDC 인증 경계` 가 나갔고, slug 로 거르는 API 는 0건을 돌려줬다.
// 화면에 보일 이름과 보낼 slug 가 다르므로 짝으로 들고 있어야 한다.
topics: [
...new Map(
records
.filter((record) => record.topicSlug)
.map((record) => [record.topicSlug, record.topic] as const),
),
]
.map(([slug, name]) => ({ slug, name }))
.sort((left, right) => left.name.localeCompare(right.name, "ko-KR")),
projects: resolved
.filter((item) => item !== undefined)
.map((item) => ({ slug: item.slug, title: item.title })),
};
});
const topics = view.data?.topics ?? [];
const projects = view.data?.projects ?? [];
const normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
const selectedTopic = topics.find(
(item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic,
);
(item) =>
item.slug.toLocaleLowerCase("ko-KR") === normalizedTopic ||
item.name.toLocaleLowerCase("ko-KR") === normalizedTopic,
)?.slug;
const normalizedProject = project?.toLocaleLowerCase("ko-KR");
const selectedProject = projects.find(
(item) =>
@@ -43,14 +69,18 @@ export function ExploreFilterForm({
item.title.toLocaleLowerCase("ko-KR") === normalizedProject,
)?.slug;
const hasActiveFilter = Boolean((showType && kind) || topic || project);
const formKey = [kind, topic, project, showType].join(":");
// 선택지는 나중에 도착하는데 아래 select 들은 `defaultValue` 를 쓰는 비제어 요소다 —
// 첫 렌더에는 맞출 option 이 없어 값이 비어버린다. 도착 여부를 키에 넣어 그때 폼을
// 다시 마운트시키면 defaultValue 가 적용된다. 제어 요소로 바꾸지 않는 이유는 이 폼이
// submit 으로 URL 을 만드는 구조라 값의 주인이 URL 이기 때문이다.
const formKey = [kind, topic, project, showType, view.ready].join(":");
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
const search = new URLSearchParams();
for (const [key, value] of data) {
if (typeof value === "string") search.append(key, value);
if (typeof value === "string" && value !== "") search.append(key, value);
}
void navigate(`${action}?${search.toString()}`);
}
@@ -66,7 +96,7 @@ export function ExploreFilterForm({
{showType ? (
<label><span></span><select name="type" defaultValue={kind ?? ""}><option value=""></option><option value="CASE">Case</option><option value="REFERENCE">Reference</option><option value="QUESTION">Open Question</option></select></label>
) : null}
<label><span></span><select name="topic" defaultValue={selectedTopic ?? ""}><option value=""></option>{topics.map((item) => <option key={item}>{item}</option>)}</select></label>
<label><span></span><select name="topic" defaultValue={selectedTopic ?? ""}><option value=""></option>{topics.map((item) => <option value={item.slug} key={item.slug}>{item.name}</option>)}</select></label>
<label><span></span><select name="project" defaultValue={selectedProject ?? ""}><option value=""></option>{projects.map((item) => <option value={item.slug} key={item.slug}>{item.title}</option>)}</select></label>
<button type="submit"></button>
{hasActiveFilter ? <Link to={action}> </Link> : null}
@@ -19,7 +19,13 @@ export function PublicDocumentHeader({ record }: { record: PublicRecord }) {
{kindLabels[record.kind]}
</Link>
<span aria-hidden="true">/</span>
<Link to={`/topics/${record.topicSlug}`}>{record.topic}</Link>
{/*
주제 페이지가 아니라 그 주제로 거른 탐색으로 보낸다. `/topics/:slug` 는 세 개를
하드코딩해 두고 있어 실제 주제는 무엇이든 404 가 되고, 설명·범위·대표 기록이 전부
비어 있어 지금 채울 내용도 없다. 독자가 여기서 기대하는 것 — 같은 주제의 기록 목록 —
은 탐색 필터가 그대로 준다.
*/}
<Link to={`/explore?topic=${encodeURIComponent(record.topicSlug)}`}>{record.topic}</Link>
<span aria-hidden="true">/</span>
<Link to={`/projects/${record.projectSlug}`}>
{record.projectTitle}
@@ -57,7 +63,9 @@ export function publicRenderModelBase(
topic: {
id: `topic-${record.topicSlug}`,
label: record.topic,
publicPath: `/topics/${record.topicSlug}`,
// 공개 문서의 머리말이 실제로 그리는 주제 링크는 이 값이다 — 위의 breadcrumb 과 같은
// 이유로 탐색 필터를 가리킨다. 둘 중 하나만 고치면 화면에서는 그대로 404 로 간다.
publicPath: `/explore?topic=${encodeURIComponent(record.topicSlug)}`,
},
project: {
id: `project-${record.projectSlug}`,
@@ -1,8 +1,7 @@
import { useId, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { usePublicContent } from "../use-public-content.tsx";
type SearchDialogProps = {
className?: string;
@@ -19,8 +18,21 @@ export function SearchDialog({
const triggerRef = useRef<HTMLButtonElement>(null);
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLocaleLowerCase("ko-KR");
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const results = publicContent.searchPublicContent(normalizedQuery);
// Keyed on the empty query, then filtered here, rather than one request per
// keystroke. This is a type-ahead: re-querying per character would replace the
// result list with a loading skeleton on every key, which is a worse dialog
// than a stale-free local filter. The predicate is the same one the catalog
// applies for a non-empty query, so the visible result set is unchanged.
const view = usePublicContent(["tech-log", "search", "dialog"], async (queries) => ({
entities: await queries.searchPublicContent(""),
}));
const results = (view.data?.entities ?? []).filter((entity) =>
normalizedQuery
? [entity.title, entity.summary, entity.topic, entity.project, ...(entity.topics ?? [])]
.filter((value): value is string => Boolean(value))
.some((value) => value.toLocaleLowerCase("ko-KR").includes(normalizedQuery))
: true,
);
function open() {
onBeforeOpen?.();
@@ -1,10 +1,9 @@
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { CaseDocumentPage } from "../components/case-document-page.tsx";
import { usePublicContent } from "../use-public-content.tsx";
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
@@ -14,9 +13,12 @@ export function CasePage() {
const { params, search } = useRouteInput<"TECH_LOG_CASE">();
const slug = optionalString(params.slug);
const requestedState = optionalString(search.state);
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const record = slug ? publicContent.getRecord("CASE", slug) : undefined;
const view = usePublicContent(["tech-log", "case", slug], async (queries) => ({
record: slug ? await queries.getRecord("CASE", slug) : undefined,
}));
if (!view.ready) return view.fallback;
const { record } = view.data;
if (!record) return <RegisteredNotFoundRoute />;
return (
@@ -1,13 +1,12 @@
import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
import { PublicRecordList } from "../components/public-record-list.tsx";
import { usePublicContent } from "../use-public-content.tsx";
const kinds = {
cases: { kind: "CASE", title: "Case", description: "문제를 재현하고 관찰한 값에서 설계 결론까지 따라갑니다." },
@@ -28,18 +27,29 @@ function getKindConfig(value: string | undefined) {
export function ExploreKindPage() {
const { params, search } = useRouteInput<"TECH_LOG_EXPLORE_KIND">();
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const kind = optionalString(params.kind);
const config = getKindConfig(kind);
if (!config) return <RegisteredNotFoundRoute />;
const topic = optionalString(search.topic);
const project = optionalString(search.project);
const records = publicContent.listRecords({
kind: config.kind,
...(topic ? { topic } : {}),
...(project ? { project } : {}),
});
// The unknown-kind check reads as an early return, but it cannot come before
// the query: hooks run unconditionally or React loses the call order. The
// loader short-circuits instead, and the not-found route is chosen below.
const view = usePublicContent(
["tech-log", "explore-kind", config?.kind, topic, project],
async (queries) => ({
records: config
? await queries.listRecords({
kind: config.kind,
...(topic ? { topic } : {}),
...(project ? { project } : {}),
})
: [],
}),
);
if (!config) return <RegisteredNotFoundRoute />;
if (!view.ready) return view.fallback;
const { records } = view.data;
return <main id="main-content" className="shell public-index-page">
<header className="public-page-header"><p className="section-kicker">Explore</p><h1>{config.title}</h1><p>{config.description}</p></header>
<ExploreFilterForm action={`/explore/${kind}`} topic={topic} project={project} showType={false} />
@@ -1,9 +1,8 @@
import type { RecordKind } from "../../../application/ports/public-content-queries.ts";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
import { PublicRecordList } from "../components/public-record-list.tsx";
import { usePublicContent } from "../use-public-content.tsx";
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
@@ -17,13 +16,19 @@ export function ExplorePage() {
const kind = (["CASE", "REFERENCE", "QUESTION"] as const).find(
(item) => item === requestedKind,
) satisfies RecordKind | undefined;
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const records = publicContent.listRecords({
...(kind ? { kind } : {}),
...(topic ? { topic } : {}),
...(project ? { project } : {}),
});
const view = usePublicContent(
["tech-log", "explore", kind, topic, project],
async (queries) => ({
records: await queries.listRecords({
...(kind ? { kind } : {}),
...(topic ? { topic } : {}),
...(project ? { project } : {}),
}),
}),
);
if (!view.ready) return view.fallback;
const { records } = view.data;
return <main id="main-content" className="shell public-index-page">
<header className="public-page-header"><p className="section-kicker">Explore</p><h1></h1><p> , .</p></header>
<ExploreFilterForm action="/explore" kind={kind} topic={topic} project={project} />
@@ -1,11 +1,10 @@
import { Link } from "react-router-dom";
import type { PublicContentQueries } from "../../../application/ports/public-content-queries.ts";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { publicSiteConfig } from "../../../contracts/public-site-config.ts";
import { normalizeFocus } from "../../../domain/public/focus-state.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { usePublicContent } from "../use-public-content.tsx";
import { FatalErrorState } from "../components/fatal-error-state.tsx";
import { HomeFocus } from "../components/home-focus.tsx";
import {
@@ -40,81 +39,99 @@ function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
const publicRecords = publicContent.listRecords();
const publicRecordByPath = new Map(
publicRecords.map((record) => [record.path, record]),
);
const searchableEntities = publicContent.searchPublicContent("");
const projectPrefix = "/projects/";
const projectSlugs = searchableEntities
.filter((entity) => entity.contentType === "PROJECT")
.flatMap((entity) =>
entity.path.startsWith(projectPrefix)
? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
: [],
);
const projectTimeline = projectSlugs.flatMap((projectSlug) => {
const project = publicContent.getProject(projectSlug);
if (!project) return [];
return publicContent.getProjectActivity(projectSlug).map((activity) => {
const record = publicRecordByPath.get(
activity.recordPath ?? activity.path,
);
return {
id: activity.id,
typeLabel:
activity.type === "PUBLICATION" && record
? record.kind
: "PROJECT ACTIVITY",
title:
activity.type === "PUBLICATION" && record
? record.title
: activity.title,
summary: activity.summary,
date: activity.date,
dateTime: activity.dateTime,
topic: record?.topic ?? project.topics[0] ?? "",
project: project.title,
path: activity.path,
};
});
});
const releaseTimeline = searchableEntities
.filter((entity) => entity.contentType === "RELEASE")
.flatMap((entity) => {
const prefix = "/releases/";
if (!entity.path.startsWith(prefix)) return [];
const release = publicContent.getRelease(
decodeURIComponent(entity.path.slice(prefix.length)),
);
if (!release) return [];
return [
{
id: `release-${release.version}`,
typeLabel: "RELEASE",
title: release.title,
summary: release.summary,
date: release.publishedLabel,
dateTime: release.publishedAt,
topic: "TechLog",
project: "TechLog",
path: release.path,
},
];
});
/**
* 서버가 고른 최근 기록에 릴리스를 얹는다.
*
* 예전에는 이 함수가 공개된 프로젝트를 하나씩 돌며 활동을 모아 타임라인을 만들었다. 그래서 게시된
* 문서라도 그 문서가 매달린 프로젝트가 공개되어 있지 않으면 홈에서 통째로 사라졌다 — 실제로 Case 를
* 게시했는데 홈에는 릴리스 한 줄만 남았다. 무엇이 최근인지는 공개 투영이 이미 알고 있으므로 그것을
* 그대로 읽는다. 프로젝트마다 요청을 하나씩 보내던 N+1 도 같이 사라진다.
*
* 릴리스는 Publication 파이프라인을 거치지 않아 그 투영에 행이 없다. 그래서 릴리스만 따로 읽어
* 시간순으로 합친다.
*/
const latestTypeLabels: Readonly<Record<string, string>> = {
PROJECT_ACTIVITY: "PROJECT ACTIVITY",
QUESTION: "OPEN QUESTION",
};
return [...projectTimeline, ...releaseTimeline].sort((left, right) =>
async function getLatestEntries(
publicContent: PublicContentQueries,
): Promise<LatestEntry[]> {
const [records, searchableEntities] = await Promise.all([
publicContent.getLatestEntries(),
publicContent.searchPublicContent(""),
]);
const recordTimeline: LatestEntry[] = records.map((entry) => ({
id: entry.id,
// 목록의 다른 이름들과 같은 자리에 놓이므로 표기도 같은 규칙을 쓴다 — 대문자에 공백.
typeLabel: latestTypeLabels[entry.entryType] ?? entry.entryType,
title: entry.title,
summary: entry.summary,
date: dateLabel(entry.publishedAt),
dateTime: entry.publishedAt,
topic: entry.topic,
project: entry.project,
path: entry.path,
}));
const releaseTimeline = (
await Promise.all(
searchableEntities
.filter((entity) => entity.contentType === "RELEASE")
.map(async (entity) => {
const prefix = "/releases/";
if (!entity.path.startsWith(prefix)) return [];
const release = await publicContent.getRelease(
decodeURIComponent(entity.path.slice(prefix.length)),
);
if (!release) return [];
return [
{
id: `release-${release.version}`,
typeLabel: "RELEASE",
title: release.title,
summary: release.summary,
date: release.publishedLabel,
dateTime: release.publishedAt,
topic: "TechLog",
project: "TechLog",
path: release.path,
},
];
}),
)
).flat();
return [...recordTimeline, ...releaseTimeline].sort((left, right) =>
right.dateTime.localeCompare(left.dateTime),
);
}
/** 목록의 날짜 칸은 공개 화면 어디서나 같은 형식이다. */
function dateLabel(isoTimestamp: string): string {
const parsed = new Date(isoTimestamp);
if (Number.isNaN(parsed.getTime())) return "";
return `${parsed.getFullYear()}.${String(parsed.getMonth() + 1).padStart(2, "0")}.${String(
parsed.getDate(),
).padStart(2, "0")}`;
}
export function HomePage() {
const { search } = useRouteInput<"TECH_LOG_HOME">();
const requestedKey = optionalString(search.focus);
const requestedState = optionalString(search.state);
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const focusItems = publicContent.getHomeFocusItems();
const view = usePublicContent(["tech-log", "home"], async (queries) => {
const [focusItems, latestEntries] = await Promise.all([
queries.getHomeFocusItems(),
getLatestEntries(queries),
]);
return { focusItems, latestEntries };
});
if (!view.ready) return view.fallback;
const { focusItems, latestEntries } = view.data;
const availableFocusItems = requestedState === "focus-empty" ? [] : focusItems;
const normalizedKey = normalizeFocus(
requestedKey,
@@ -131,8 +148,6 @@ export function HomePage() {
return <FatalErrorState traceId="PREVIEW-HOME-500" />;
}
const latestEntries = getLatestEntries(publicContent);
return (
<main id="main-content">
<section className="shell home-identity" aria-labelledby="home-title">
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { publicSiteConfig } from "../../../contracts/public-site-config.ts";
import { usePublicContent } from "../use-public-content.tsx";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
const principles = [
@@ -22,16 +23,31 @@ const principles = [
},
] as const;
const currentProjectSlugs = ["backend-skeleton", "auth-lab"] as const;
const topics = ["Backend Architecture", "JPA", "Authentication", "Redis"] as const;
export function ProfilePage() {
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const currentProjects = currentProjectSlugs.flatMap((slug) => {
const project = publicContent.getProject(slug);
return project ? [project] : [];
// The two project slugs this named were the static fixture's, and they exist
// in no real deployment — the page asked the backend for them, took two 404s,
// and rendered nothing but an error. "Current projects" means the published
// ones, so read them from the catalogue the projects index already reads.
const view = usePublicContent(["tech-log", "profile"], async (queries) => {
const entries = (await queries.searchPublicContent("")).filter(
(item) => item.contentType === "PROJECT",
);
const [resolved, topics] = await Promise.all([
Promise.all(entries.map((item) => queries.getProject(item.path.replace("/projects/", "")))),
queries.listTopics(),
]);
return {
currentProjects: resolved.filter((project) => project !== undefined),
topics,
};
});
// Only the project list comes from the network. Returning the page-wide
// fallback here — as every public screen did — held the operator's name, the
// principles, and the topics behind a request that has nothing to do with
// them, so a visitor saw a skeleton, then possibly an error, where the page
// could have been readable the whole time. The markup below is unchanged;
// the fallback now sits in the one section that is actually waiting.
return (
<main id="main-content" className="shell profile-page">
<header className="profile-header">
@@ -61,29 +77,45 @@ export function ProfilePage() {
<p className="section-kicker">Current</p>
<h2 id="profile-projects-title"> </h2>
</div>
<ul>
{currentProjects.map((project) => (
<li key={project.slug}>
<Link to={`/projects/${project.slug}`}>
<div>
<strong>{project.title}</strong>
<span>{project.stage}</span>
</div>
<p>{project.currentGoal}</p>
<span aria-hidden="true"></span>
</Link>
</li>
))}
</ul>
{!view.ready ? (
view.fallback
) : view.data.currentProjects.length === 0 ? (
<p className="public-empty-note"> .</p>
) : (
<ul>
{view.data.currentProjects.map((project) => (
<li key={project.slug}>
<Link to={`/projects/${project.slug}`}>
<div>
<strong>{project.title}</strong>
<span>{project.stage}</span>
</div>
<p>{project.currentGoal}</p>
<span aria-hidden="true"></span>
</Link>
</li>
))}
</ul>
)}
</section>
{/*
이 목록은 코드에 네 개가 박혀 있었다 — Studio 에서 주제를 만들거나 지워도 프로필은
그대로였고, 고치려면 배포를 다시 해야 했다. 이제 공개 주제 목록을 그대로 그린다.
*/}
<section className="profile-topics" aria-labelledby="profile-topics-title">
<p className="section-kicker">Topics</p>
<h2 id="profile-topics-title"> </h2>
<ul>
{topics.map((topic) => (
<li key={topic}>{topic}</li>
))}
</ul>
{!view.ready ? (
view.fallback
) : view.data.topics.length === 0 ? (
<p className="public-empty-note"> .</p>
) : (
<ul>
{view.data.topics.map((topic) => (
<li key={topic.slug}>{topic.name}</li>
))}
</ul>
)}
</section>
</main>
);
@@ -1,44 +1,51 @@
import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { ProjectPageHeader } from "../components/project-page-header.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ProjectActivityPage() {
const { params } = useRouteInput<"TECH_LOG_PROJECT_ACTIVITY">();
const slug = typeof params.slug === "string" ? params.slug : "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const project = publicContent.getProject(slug);
const view = usePublicContent(["tech-log", "project", slug, "activity"], async (queries) => {
const project = await queries.getProject(slug);
return project
? { project, activity: await queries.getProjectActivity(slug) }
: { project: undefined, activity: [] };
});
if (!view.ready) return view.fallback;
const { project, activity } = view.data;
if (!project) return <RegisteredNotFoundRoute />;
const activity = publicContent.getProjectActivity(slug);
/*
활동은 로그다 — 언제 무엇을 올렸는지만 적는다.
예전에는 각 줄에 그 기록으로 가는 링크가 있었다. 그러면 같은 글에 닿는 길이 둘이 되고,
읽는 사람은 "기록"과 "활동"이 어떻게 다른지 매번 다시 판단해야 한다. 글을 읽는 자리는
기록 화면 하나로 둔다.
*/
return (
<main id="main-content" className="shell project-page">
<ProjectPageHeader project={project} title={`${project.title} 활동`} />
<ol className="project-activity-list">
{activity.map((item) => (
<li key={item.id}>
<article id={item.id}>
<div>
<span>{item.type}</span>
<time dateTime={item.dateTime}>{item.date}</time>
</div>
<h2>{item.title}</h2>
<p>{item.summary}</p>
<Link to={item.recordPath ?? item.path}>
{item.recordPath
? "연결된 공개 기록 읽기"
: "이 활동 위치 열기"}
</Link>
</article>
</li>
))}
</ol>
{activity.length === 0 ? (
<p className="public-empty-note"> .</p>
) : (
<ol className="project-activity-list">
{activity.map((item) => (
<li key={item.id}>
<article id={item.id}>
<div>
<span>{item.type}</span>
<time dateTime={item.dateTime}>{item.date}</time>
</div>
<h2>{item.title}</h2>
</article>
</li>
))}
</ol>
)}
</main>
);
}
@@ -1,22 +1,26 @@
import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { ProjectPageHeader } from "../components/project-page-header.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ProjectDecisionsPage() {
const { params } = useRouteInput<"TECH_LOG_PROJECT_DECISIONS">();
const slug = typeof params.slug === "string" ? params.slug : "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const project = publicContent.getProject(slug);
const view = usePublicContent(["tech-log", "project", slug, "decisions"], async (queries) => {
const project = await queries.getProject(slug);
return project
? { project, decisions: await queries.getProjectDecisions(slug) }
: { project: undefined, decisions: [] };
});
if (!view.ready) return view.fallback;
const { project, decisions } = view.data;
if (!project) return <RegisteredNotFoundRoute />;
const decisions = publicContent.getProjectDecisions(slug);
return (
<main id="main-content" className="shell project-page">
<ProjectPageHeader project={project} title={`${project.title} 결정`} />
@@ -1,24 +1,34 @@
import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { ProjectPageHeader } from "../components/project-page-header.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ProjectOverviewPage() {
const { params } = useRouteInput<"TECH_LOG_PROJECT">();
const slug = typeof params.slug === "string" ? params.slug : "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const project = publicContent.getProject(slug);
const view = usePublicContent(["tech-log", "project", slug, "overview"], async (queries) => {
const project = await queries.getProject(slug);
if (!project) {
return { project: undefined, records: [], decisions: [], activity: [] };
}
// Three independent reads for one screen: issued together rather than in
// sequence, so the page waits for the slowest instead of their sum.
const [records, decisions, activity] = await Promise.all([
queries.getProjectRecords(slug),
queries.getProjectDecisions(slug),
queries.getProjectActivity(slug),
]);
return { project, records, decisions, activity };
});
if (!view.ready) return view.fallback;
const { project, records, decisions, activity } = view.data;
if (!project) return <RegisteredNotFoundRoute />;
const records = publicContent.getProjectRecords(slug);
const decisions = publicContent.getProjectDecisions(slug);
const activity = publicContent.getProjectActivity(slug);
return (
<main id="main-content" className="shell project-page">
<ProjectPageHeader project={project} title={project.title} />
@@ -1,21 +1,25 @@
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { ProjectPageHeader } from "../components/project-page-header.tsx";
import { PublicRecordList } from "../components/public-record-list.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ProjectRecordsPage() {
const { params } = useRouteInput<"TECH_LOG_PROJECT_RECORDS">();
const slug = typeof params.slug === "string" ? params.slug : "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const project = publicContent.getProject(slug);
const view = usePublicContent(["tech-log", "project", slug, "records"], async (queries) => {
const project = await queries.getProject(slug);
return project
? { project, records: await queries.getProjectRecords(slug) }
: { project: undefined, records: [] };
});
if (!view.ready) return view.fallback;
const { project, records } = view.data;
if (!project) return <RegisteredNotFoundRoute />;
const records = publicContent.getProjectRecords(slug);
return (
<main id="main-content" className="shell project-page">
<ProjectPageHeader project={project} title={`${project.title} 기록`} />
@@ -1,18 +1,19 @@
import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ProjectsPage() {
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const projects = publicContent
.searchPublicContent("")
.filter((item) => item.contentType === "PROJECT")
.flatMap((item) => {
const project = publicContent.getProject(item.path.replace("/projects/", ""));
return project ? [project] : [];
});
const view = usePublicContent(["tech-log", "projects"], async (queries) => {
const entries = (await queries.searchPublicContent("")).filter(
(item) => item.contentType === "PROJECT",
);
const resolved = await Promise.all(
entries.map((item) => queries.getProject(item.path.replace("/projects/", ""))),
);
return { projects: resolved.filter((project) => project !== undefined) };
});
// Header first: it is fixed copy and owes the network nothing.
return (
<main
id="main-content"
@@ -26,8 +27,13 @@ export function ProjectsPage() {
.
</p>
</header>
{!view.ready ? (
view.fallback
) : view.data.projects.length === 0 ? (
<p className="public-empty-note"> .</p>
) : (
<ol className="project-index-list">
{projects.map((project, index) => (
{view.data.projects.map((project, index) => (
<li key={project.slug}>
<Link to={`/projects/${project.slug}`}>
<span>{String(index + 1).padStart(2, "0")}</span>
@@ -53,6 +59,7 @@ export function ProjectsPage() {
</li>
))}
</ol>
)}
</main>
);
}
@@ -1,10 +1,9 @@
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { QuestionDocumentPage } from "../components/question-document-page.tsx";
import { usePublicContent } from "../use-public-content.tsx";
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
@@ -13,9 +12,15 @@ function optionalString(value: unknown): string | undefined {
export function QuestionPage() {
const { params } = useRouteInput<"TECH_LOG_QUESTION">();
const slug = optionalString(params.slug);
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const record = slug ? publicContent.getRecord("QUESTION", slug) : undefined;
const view = usePublicContent(
["tech-log", "question", slug],
async (queries) => ({
record: slug ? await queries.getRecord("QUESTION", slug) : undefined,
}),
);
if (!view.ready) return view.fallback;
const { record } = view.data;
return record ? (
<QuestionDocumentPage record={record} />
) : (
@@ -1,10 +1,9 @@
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { ReferenceDocumentPage } from "../components/reference-document-page.tsx";
import { usePublicContent } from "../use-public-content.tsx";
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
@@ -13,9 +12,15 @@ function optionalString(value: unknown): string | undefined {
export function ReferencePage() {
const { params } = useRouteInput<"TECH_LOG_REFERENCE">();
const slug = optionalString(params.slug);
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const record = slug ? publicContent.getRecord("REFERENCE", slug) : undefined;
const view = usePublicContent(
["tech-log", "reference", slug],
async (queries) => ({
record: slug ? await queries.getRecord("REFERENCE", slug) : undefined,
}),
);
if (!view.ready) return view.fallback;
const { record } = view.data;
return record ? (
<ReferenceDocumentPage record={record} />
) : (
@@ -1,18 +1,20 @@
import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ReleasePage() {
const { params } = useRouteInput<"TECH_LOG_RELEASE">();
const version = typeof params.version === "string" ? params.version : "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const release = publicContent.getRelease(version);
const view = usePublicContent(["tech-log", "release", version], async (queries) => ({
release: await queries.getRelease(version),
}));
if (!view.ready) return view.fallback;
const { release } = view.data;
if (!release) return <RegisteredNotFoundRoute />;
return (
@@ -1,18 +1,21 @@
import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ReleasesPage() {
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const releases = publicContent
.searchPublicContent("")
.filter((item) => item.contentType === "RELEASE")
.flatMap((item) => {
const release = publicContent.getRelease(item.path.replace("/releases/", ""));
return release ? [release] : [];
});
const view = usePublicContent(["tech-log", "releases"], async (queries) => {
const entries = (await queries.searchPublicContent("")).filter(
(item) => item.contentType === "RELEASE",
);
const resolved = await Promise.all(
entries.map((item) => queries.getRelease(item.path.replace("/releases/", ""))),
);
return { releases: resolved.filter((release) => release !== undefined) };
});
// The header is fixed copy; only the list is a request. Returning the
// page-wide fallback here left a visitor with a skeleton — or an error —
// where the page's own explanation of itself could already be on screen.
return (
<main
id="main-content"
@@ -26,8 +29,13 @@ export function ReleasesPage() {
.
</p>
</header>
{!view.ready ? (
view.fallback
) : view.data.releases.length === 0 ? (
<p className="public-empty-note"> .</p>
) : (
<ol className="release-index-list">
{releases.map((release) => (
{view.data.releases.map((release) => (
<li key={release.version}>
<Link to={release.path}>
<div>
@@ -45,6 +53,7 @@ export function ReleasesPage() {
</li>
))}
</ol>
)}
</main>
);
}
@@ -1,8 +1,7 @@
import { Link, useNavigate } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { usePublicContent } from "../use-public-content.tsx";
const labels = { CASE: "Case", REFERENCE: "Reference", QUESTION: "Open Question", PROJECT: "Project", RELEASE: "Release" } as const;
@@ -14,8 +13,13 @@ export function SearchPage() {
const navigate = useNavigate();
const { search } = useRouteInput<"TECH_LOG_SEARCH">();
const query = optionalString(search.q)?.trim() ?? "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const results = publicContent.searchPublicContent(query);
// 키의 마지막 조각이 화면을 구분한다. 헤더의 검색 다이얼로그도 같은 카탈로그를 읽고
// 빈 검색어일 때 앞 세 조각이 완전히 겹치는데, 두 화면이 담아 오는 모양이 다르다
// (여기는 `results`, 다이얼로그는 `entities`). 키가 같으면 react-query 가 한쪽 캐시를
// 다른 쪽에 돌려주고, 받는 쪽은 없는 필드를 읽다 렌더에서 죽는다.
const view = usePublicContent(["tech-log", "search", "page", query], async (queries) => ({
results: await queries.searchPublicContent(query),
}));
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -25,6 +29,9 @@ export function SearchPage() {
void navigate(`/search?q=${encodeURIComponent(nextQuery)}`);
}
if (!view.ready) return view.fallback;
const { results } = view.data;
return <main id="main-content" className="shell search-page">
<header className="public-page-header"><p className="section-kicker">Search</p><h1></h1><p> , , .</p></header>
<form key={query} className="search-page-form" action="/search" method="get" onSubmit={submit}><label><span className="visually-hidden"></span><input type="search" name="q" defaultValue={query} placeholder="검색어를 입력하세요" /></label><button type="submit"></button></form>
@@ -1,10 +1,9 @@
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { PublicRecordList } from "../components/public-record-list.tsx";
import { usePublicContent } from "../use-public-content.tsx";
const topics = {
jpa: {
@@ -37,11 +36,18 @@ function topicConfig(value: unknown) {
export function TopicPage() {
const { params } = useRouteInput<"TECH_LOG_TOPIC">();
const topic = topicConfig(params.slug);
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
// Hooks run unconditionally, so the unknown-topic case is handled by the
// loader and the not-found route is chosen after it.
const slug = typeof params.slug === "string" ? params.slug : "";
const view = usePublicContent(
["tech-log", "topic", slug],
async (queries) =>
topic ? { records: await queries.listRecords({ topic: slug }) } : { records: [] },
);
if (!topic) return <RegisteredNotFoundRoute />;
if (!view.ready) return view.fallback;
const records = publicContent.listRecords({ topic: topic.title });
const { records } = view.data;
return (
<main id="main-content" className="shell public-index-page">
<header className="public-page-header">
@@ -29,7 +29,7 @@ export function PublicShell({ children, currentPath }: PublicShellProps) {
<Link to={publicSiteConfig.contactPath}>
{publicSiteConfig.contactLabel}
</Link>
<Link to={publicSiteConfig.latestRelease}> Release</Link>
<Link to={publicSiteConfig.releasesPath}> </Link>
</div>
</div>
</footer>
@@ -0,0 +1,109 @@
import { useCallback, useMemo, type ReactNode } from "react";
import { createFailure } from "../../../../contracts/errors.ts";
import {
LoadingSurface,
TerminalErrorSurface,
} from "../../../../presentation/components/async-surface.tsx";
import { useApplicationQuery } from "../../../../presentation/adapters/query/index.ts";
import { useApplication } from "../../../../presentation/providers/application-provider.tsx";
import type { PublicContentQueries } from "../../application/ports/public-content-queries.ts";
import { TECH_LOG_FEATURE_ID } from "../../application/tech-log-feature-input.ts";
/**
* One query per screen, not one per call.
*
* The public pages were written against a synchronous fixture, so they read
* whatever they needed inline — and several read in a loop: the home timeline
* walks every project for its activity, the explore filter walks search results
* to resolve project titles. Turning each of those into its own hook would mean
* a variable number of hooks per render, which React forbids outright.
*
* So a screen loads everything in one `execute`, where a loop is just a loop and
* `Promise.all` is available. The cost is that a screen waits for its slowest
* read; the benefit is that the page bodies keep computing from plain values and
* the markup is unchanged.
*
* The return is a discriminated union so a page can hand back `view.fallback`
* and have `view.data` narrow to present on the line after — without that, every
* page would need its own non-null assertion.
*/
export type PublicContentView<Value> =
| Readonly<{ ready: false; fallback: ReactNode; data?: undefined }>
| Readonly<{ ready: true; fallback: null; data: Value }>;
/**
* `Value extends object` is load-bearing, not decoration. `undefined` is how the
* query layer says "no result yet", so a loader that returned the record itself
* would make a genuinely missing slug — `getRecord` resolving to `undefined` —
* indistinguishable from a request still in flight, and the page would sit on a
* loading skeleton instead of rendering its not-found route. Wrapping the
* screen's reads in an object keeps the two apart.
*/
export function usePublicContent<Value extends object>(
queryKey: readonly unknown[],
load: (queries: PublicContentQueries) => Promise<Value>,
): PublicContentView<Value> {
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
// `load` is a new closure every render, so depending on it would re-run the
// query forever. The key is the declared identity of the request — the same
// rule the rest of the query layer follows — so the key is what this closes
// over.
const execute = useCallback(
async () => {
try {
return { ok: true as const, value: await load(publicContent) };
} catch (cause) {
return { ok: false as const, error: failureFor(cause) };
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by queryKey, see above
[publicContent, ...queryKey],
);
const query = useApplicationQuery<Value>(
useMemo(() => ({ queryKey, execute }), [execute, queryKey]),
);
if (query.data !== undefined) {
return Object.freeze({ ready: true as const, fallback: null, data: query.data });
}
const failure = query.state.failure;
return Object.freeze({
ready: false as const,
fallback: failure ? (
<TerminalErrorSurface
userMessageKey={failure.userMessageKey}
action={failure.action}
onAction={() => void query.retry()}
/>
) : (
<LoadingSurface />
),
});
}
/**
* Adapters throw. One that knows what went wrong attaches the classified
* failure to the error; anything else arriving here is a defect in this layer
* rather than a server condition, and is not reported as one.
*/
function failureFor(cause: unknown) {
const attached = (cause as { failure?: unknown } | null)?.failure;
if (isAppFailure(attached)) return attached;
return createFailure("UNKNOWN_CLIENT_FAILURE", "TECH_LOG_PUBLIC_CONTENT", 0, {
code: "PUBLIC_CONTENT_UNAVAILABLE",
});
}
function isAppFailure(
value: unknown,
): value is ReturnType<typeof createFailure> {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { kind?: unknown }).kind === "string" &&
typeof (value as { code?: unknown }).code === "string"
);
}
@@ -53,8 +53,12 @@ export type ResolvedAssetLike = Readonly<{
function fromDescriptor(descriptor: ResolvedAssetLike): EvidenceAsset {
return Object.freeze({
src: descriptor.publicPath,
width: descriptor.width ?? 1,
height: descriptor.height ?? 1,
// 치수를 모르면 0 으로 둔다 — 1 이 아니라. 1×1 은 "아주 작은 그림" 이라는 거짓말이고,
// `loading="lazy"` 와 만나면 브라우저는 화면에 걸리지 않는 1×1 상자를 영영 가져오지 않는다.
// 실제로 업로드가 치수를 기록하지 않아 모든 그림이 그렇게 사라졌다. 0 은 figure 가
// 자기 값을 모른다는 뜻이고, 렌더러가 그때 속성을 빼고 즉시 로드로 바꾼다.
width: descriptor.width ?? 0,
height: descriptor.height ?? 0,
triggerLabel: `${descriptor.assetKey} 이미지 크게 보기`,
dialogLabel: `${descriptor.assetKey} 확대`,
});
@@ -99,6 +99,19 @@ function renderBlock(
resolveEvidenceAsset={resolveEvidenceAsset}
/>
);
case "THEMATIC_BREAK":
return <hr key={key} className="document-rule" />;
case "IMAGE":
/*
작성자가 적은 경로를 그대로 쓴다. 경로 검증은 파싱할 때 끝났다(`isSafeLink`).
`title` 이 있으면 그림 설명으로 보여 준다 — Markdown 이 제목을 그런 뜻으로 쓴다.
*/
return (
<figure key={key} className="document-image">
<img src={block.src} alt={block.alt} loading="lazy" />
{block.title ? <figcaption>{block.title}</figcaption> : null}
</figure>
);
default:
return assertNever(block);
}
@@ -12,9 +12,20 @@ type DocumentTocProps = {
export function DocumentToc({ headings, variant }: DocumentTocProps) {
const [currentId, setCurrentId] = useState(headings[0]?.id ?? "");
const empty = headings.length === 0;
const detailsRef = useRef<HTMLDetailsElement>(null);
useEffect(() => {
/*
`IntersectionObserver` 가 없는 환경이 있다 — jsdom 이 그렇고, 오래된 브라우저도 그렇다.
없으면 "지금 읽는 절" 표시만 못 할 뿐 목차 자체는 쓸 수 있으므로, 없다고 문서를 통째로
못 그리게 두지 않는다.
한동안 이 컴포넌트는 픽스처 Case 하나에서만 쓰여 그 환경을 만난 적이 없었다. 모든 Case 가
목차를 받게 되면서 드러났다.
*/
if (typeof IntersectionObserver === "undefined") return undefined;
const elements = headings
.map((heading) => document.getElementById(heading.id))
.filter((element): element is HTMLElement => Boolean(element));
@@ -41,6 +52,12 @@ export function DocumentToc({ headings, variant }: DocumentTocProps) {
if (detailsRef.current) detailsRef.current.open = false;
}
/*
제목이 없는 문서에는 목차를 그리지 않는다. 예전에는 이 컴포넌트가 픽스처 문서 하나에서만
쓰여 빈 경우를 만날 일이 없었지만, 이제 모든 Case 가 지나므로 빈 레일이 남을 수 있다.
*/
if (empty) return null;
if (variant === "mobile") {
const current =
headings.find((heading) => heading.id === currentId) ?? headings[0];
@@ -30,16 +30,20 @@ export function PublicEvidenceFigure({
dialogRef.current?.close();
}
/**
* 치수를 아는 그림만 자리를 미리 잡는다. 모르는데 숫자를 적으면 그 상자가 진짜 크기가 되고,
* `loading="lazy"` 는 화면에 걸리지 않는 상자를 끝내 가져오지 않는다 — 그림이 통째로 사라진다.
* 모를 때는 속성을 빼고 즉시 로드해, 브라우저가 원래 크기로 그리게 둔다.
*/
const known = asset.width > 0 && asset.height > 0;
const sizing = known
? ({ width: asset.width, height: asset.height, loading: "lazy" } as const)
: ({ loading: "eager" } as const);
if (!zoom) {
return (
<figure className="evidence-figure">
<img
src={asset.src}
width={asset.width}
height={asset.height}
alt={alt}
loading="lazy"
/>
<img src={asset.src} alt={alt} {...sizing} />
<figcaption>{caption}</figcaption>
</figure>
);
@@ -56,13 +60,7 @@ export function PublicEvidenceFigure({
aria-describedby={descriptionId}
onClick={open}
>
<img
src={asset.src}
width={asset.width}
height={asset.height}
alt={alt}
loading="lazy"
/>
<img src={asset.src} alt={alt} {...sizing} />
<span> </span>
<span className="visually-hidden" id={descriptionId}>
{alt}
@@ -84,13 +82,7 @@ export function PublicEvidenceFigure({
<button type="button" className="dialog-close" onClick={close}>
</button>
<img
src={asset.src}
width={asset.width}
height={asset.height}
alt={alt}
loading="lazy"
/>
<img src={asset.src} alt={alt} {...sizing} />
<p>{caption}</p>
</div>
</dialog>
@@ -8,10 +8,44 @@ function assertNever(value: never): never {
throw new Error(`Unsupported inline value: ${JSON.stringify(value)}`);
}
/**
* 문단 안의 줄바꿈을 그대로 보여준다.
*
* <p>Markdown 은 한 번의 줄바꿈을 문단을 잇는 공백으로 읽는다. 파서는 그 줄바꿈을 텍스트에
* 남겨 두는데, 여기서 그대로 내보내면 HTML 이 다시 공백으로 접는다 — 작성자가 엔터로 나눠 쓴
* 글이 한 줄로 이어져 보였다. 미리보기만의 현상이 아니었다: 공개 화면도 같은 렌더러를 쓴다.
*
* <p>빈 줄로 나눈 문단은 파서가 이미 문단 둘로 만들어 두므로 여기 오지 않는다. 이 함수가 보는
* 것은 한 문단 안의 줄바꿈뿐이고, 작성자가 의도한 것도 그것이다.
*/
function renderText(text: string, key: number) {
const lines = text.split("\n");
if (lines.length === 1) return <Fragment key={key}>{text}</Fragment>;
return (
<Fragment key={key}>
{lines.map((line, index) => (
<Fragment key={index}>
{index > 0 ? <br /> : null}
{line}
</Fragment>
))}
</Fragment>
);
}
/**
* 작성자가 직접 쓴 평문. 요약·문제·결론·환경처럼 Markdown 을 거치지 않고 그대로 그려지는 칸들이
* 여기 온다 — 그 칸들은 텍스트 노드에 줄바꿈이 그대로 들어가고, HTML 이 그것을 공백으로 접어
* 작성자가 나눠 쓴 줄이 이어져 보였다. 본문(Markdown)만 고쳤을 때 이 칸들이 남아 있던 이유다.
*/
export function PlainText({ text }: { text: string }) {
return renderText(text, 0);
}
function renderInline(inline: Inline, key: number) {
switch (inline.type) {
case "TEXT":
return <Fragment key={key}>{inline.text}</Fragment>;
return renderText(inline.text, key);
case "INLINE_CODE":
return <code key={key}>{inline.code}</code>;
case "EMPHASIS":
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
import type { components } from "../../../contracts/studio/generated.ts";
import { inlinePlainText } from "../../../domain/content-format/inline-plain-text.ts";
import { PlainText } from "./inline-renderer.tsx";
import type {
ResolveEvidenceAsset,
ResolvePublishedLabel,
@@ -92,7 +93,7 @@ function ModelDocumentHeader({
) : null}
</nav>
<h1>{model.title}</h1>
<p>{model.summary}</p>
<p><PlainText text={model.summary} /></p>
<dl>
<div>
<dt></dt>
@@ -127,57 +128,18 @@ function publicRelations(
}));
}
function GenericCase({
model,
embedded,
resolveEvidenceAsset,
resolvePublishedLabel,
}: {
model: CasePublicRenderModel;
embedded: boolean;
} & RenderDependencies) {
const Root = embedded ? "div" : "main";
return (
<Root
id={embedded ? undefined : "main-content"}
className={`shell public-document-page${embedded ? " public-record-embedded" : ""}`}
>
<ModelDocumentHeader
model={model}
resolvePublishedLabel={resolvePublishedLabel}
/>
<section className="document-snapshot" aria-label="문제와 결론">
<div>
<p className="snapshot-label"></p>
<p>{model.problem}</p>
</div>
<div>
<p className="snapshot-label snapshot-label--answer"></p>
<p>{model.conclusion}</p>
</div>
</section>
<dl className="document-facts">
<div>
<dt> </dt>
<dd>{model.environment}</dd>
</div>
<div>
<dt> </dt>
<dd>{model.reproduction}</dd>
</div>
</dl>
<article className="public-document-body">
<CaseBodyRenderer
blocks={model.bodyBlocks}
resolveEvidenceAsset={resolveEvidenceAsset}
/>
</article>
<PublicDocumentRelations relations={publicRelations(model.relations)} />
</Root>
);
}
function FetchJoinCase({
/**
* Case 한 편.
*
* 한동안 Case 렌더러가 둘이었다. 이 완성된 배치와, 목차도 breadcrumb 도 없는 축약본. 어느 쪽을
* 쓸지는 `publicPath === "/cases/collection-fetch-join-pagination"` 라는 슬러그 비교가 정했다 —
* 설계 픽스처로 만든 문서 하나만 제대로 된 화면을 받고, 실제로 작성한 Case 는 전부 축약본으로
* 떨어졌다. 그래서 오른쪽 목차가 어떤 문서에서도 나타나지 않았다.
*
* 배치는 하나다. 목차는 본문에 제목이 있을 때 나온다.
*/
function CaseDocument({
model,
embedded,
resolveEvidenceAsset,
@@ -213,27 +175,27 @@ function FetchJoinCase({
</nav>
<h1>{model.title}</h1>
<p className="case-summary">{model.summary}</p>
<p className="case-summary"><PlainText text={model.summary} /></p>
<section className="case-snapshot" aria-label="문제와 결론">
<div>
<p className="snapshot-label"></p>
<p>{model.problem}</p>
<p><PlainText text={model.problem} /></p>
</div>
<div>
<p className="snapshot-label snapshot-label--answer"></p>
<p>{model.conclusion}</p>
<p><PlainText text={model.conclusion} /></p>
</div>
</section>
<dl className="case-meta">
<div>
<dt> </dt>
<dd>{model.environment}</dd>
<dd><PlainText text={model.environment} /></dd>
</div>
<div>
<dt></dt>
<dd>{model.reproduction.replace(/^Dataset:\s*/, "")}</dd>
<dt> </dt>
<dd><PlainText text={model.reproduction.replace(/^Dataset:\s*/, "")} /></dd>
</div>
<div>
<dt></dt>
@@ -287,7 +249,7 @@ function ReferenceDocument({
<section className="reference-purpose" aria-labelledby="purpose-title">
<p className="section-kicker">Purpose</p>
<h2 id="purpose-title"> </h2>
<p>{model.purpose}</p>
<p><PlainText text={model.purpose} /></p>
</section>
<article className="public-document-body reference-body">
<section aria-labelledby="rules-title">
@@ -434,7 +396,7 @@ function QuestionDocument({
>
<p className="section-kicker">Next</p>
<h2 id="next-validation-title"> </h2>
<p>{model.nextValidation}</p>
<p><PlainText text={model.nextValidation} /></p>
</section>
</article>
<PublicDocumentRelations relations={publicRelations(model.relations)} />
@@ -473,14 +435,18 @@ function ProjectDecisionDocument({
<header>
<div>
<span>{model.status}</span>
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
{model.decidedOn ? (
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
) : (
<span> </span>
)}
</div>
<h2>{model.title}</h2>
<p>{model.statement}</p>
<p><PlainText text={model.statement} /></p>
</header>
<section>
<h3> </h3>
<p>{model.rationale}</p>
<p><PlainText text={model.rationale} /></p>
</section>
<section>
<h3></h3>
@@ -518,16 +484,8 @@ export function PublicRecordRenderer({
} & RenderDependencies) {
switch (model.kind) {
case "CASE":
return model.publicPath ===
"/cases/collection-fetch-join-pagination" ? (
<FetchJoinCase
model={model}
embedded={embedded}
resolveEvidenceAsset={resolveEvidenceAsset}
resolvePublishedLabel={resolvePublishedLabel}
/>
) : (
<GenericCase
return (
<CaseDocument
model={model}
embedded={embedded}
resolveEvidenceAsset={resolveEvidenceAsset}
@@ -2,6 +2,8 @@ import { useEffect, useId, useState, type FormEvent } from "react";
import type { Asset } from "../../../contracts/studio/contract.ts";
import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts";
import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
/** One screenful of candidates; searching, not scrolling, reaches the rest. */
const PAGE_SIZE = 50;
@@ -57,6 +59,14 @@ export function AssetPicker({
// than fewer, and no trailing request after the author stops), and it is the
// pair `document-list.tsx` already uses for the same job.
const [searchDraft, setSearchDraft] = useState("");
/**
* 삽입할 때 확대를 허용할지. 예전에는 {@code kind === "DIAGRAM"} 일 때만 켰는데, 작성자가
* 스크린샷을 ATTACHMENT 나 IMAGE 로 올리면 확대가 꺼진 채로 들어갔고 켜는 방법도 없었다 —
* "줌이 왜 꺼져 있는지 모르겠다" 가 그것이다. 그림이면 켜 두고, 끄고 싶으면 여기서 끈다.
*/
const [allowZoom, setAllowZoom] = useState(true);
const [removingId, setRemovingId] = useState<string | null>(null);
const [notice, setNotice] = useState("");
const [q, setQ] = useState("");
const searchId = useId();
@@ -99,6 +109,27 @@ export function AssetPicker({
setQ(searchDraft.trim());
};
const removeAsset = async (asset: Asset) => {
if (removingId !== null) return;
setRemovingId(asset.id);
setNotice("");
try {
await gateway.deleteAsset(asset.id, {
idempotencyKey: createLocalId(`studio-asset-picker-delete-${asset.id}`),
});
setAssets((current) => current.filter((entry) => entry.id !== asset.id));
setNotice(`${asset.assetKey} 을(를) 삭제했습니다.`);
} catch (error) {
setNotice(
isStudioGatewayError(error)
? error.problem.detail
: "삭제하지 못했습니다. 문서에서 쓰이고 있을 수 있습니다.",
);
} finally {
setRemovingId(null);
}
};
const listMessage = status === "LOADING"
? "Asset 목록을 불러오는 중입니다."
: selectable.length > 0
@@ -124,6 +155,15 @@ export function AssetPicker({
<button type="submit"></button>
</div>
</form>
<label className="asset-picker-option">
<input
type="checkbox"
checked={allowZoom}
onChange={(event) => setAllowZoom(event.currentTarget.checked)}
/>
<span> </span>
</label>
{notice ? <p className="studio-asset-picker-empty" role="status">{notice}</p> : null}
{status === "ERROR"
? <p className="studio-error" role="alert">Asset .</p>
: <p className="studio-asset-picker-empty" role="status">{listMessage}</p>}
@@ -135,11 +175,23 @@ export function AssetPicker({
assetKey: asset.assetKey,
alt: asset.decorative ? "" : (asset.altText ?? ""),
caption: "",
zoom: asset.kind === "DIAGRAM",
zoom: allowZoom && asset.mediaType.startsWith("image/"),
}))}
>
{asset.assetKey}
</button>
{/*
문서를 쓰다가 잘못 올린 Asset 을 여기서 바로 지운다. 예전에는 Asset 화면으로 나가야
했고, 그러면 편집 중인 작업본을 떠나야 했다. 쓰이고 있는 Asset 은 서버가 거절한다.
*/}
<button
type="button"
className="studio-secondary-button"
disabled={removingId !== null}
onClick={() => { void removeAsset(asset); }}
>
</button>
</li>)}
</ul> : null}
</div>;

Some files were not shown because too many files have changed in this diff Show More