From 78766accdf908114ea4abd9eab8229234555faaf Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Mon, 17 Aug 2026 23:44:25 +0900 Subject: [PATCH] docs: plan the TechLog backend alignment implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12개 Task로 분해한다. 계약 생성·digest 고정(1), 오류/CSRF(2), 계약 기여(3), StudioGateway HTTP(4), 런타임 스위치(5), Asset 포트(6), multipart 전송과 배선(7), alt 규칙 이동(8), evidence resolver(9), Picker(10), Library(11), 전체 게이트(12). 자체 검토에서 세 결함을 고쳤다. - Asset gateway가 feature input에 배선되지 않아 UI가 도달할 수 없었다. - Backend assetKey를 해석할 resolver가 없어 삽입한 directive가 즉시 미리보기를 깨뜨렸다. 렌더러의 기존 주입점을 쓰는 Task를 추가했다. - dialog/library 단계에 코드가 없었다. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-17-techlog-backend-alignment.md | 3321 +++++++++++++++++ 1 file changed, 3321 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-17-techlog-backend-alignment.md diff --git a/docs/superpowers/plans/2026-08-17-techlog-backend-alignment.md b/docs/superpowers/plans/2026-08-17-techlog-backend-alignment.md new file mode 100644 index 0000000..17551ca --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-techlog-backend-alignment.md @@ -0,0 +1,3321 @@ +# TechLog Backend Alignment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Studio HTTP 계약을 canonical `studio-v1.yaml` 단일 출처에 고정하고, `StudioGateway`의 모든 operation과 Asset capability를 HTTP 어댑터로 구현한다. + +**Architecture:** canonical OpenAPI를 저장소에 vendor하고 타입을 생성한다. 계약 기여(`EXTERNAL_PACKAGE` provenance)의 digest로 canonical revision을 암호학적으로 고정해 drift를 빌드에서 막는다. JSON operation 18개는 플랫폼 계약 런타임(`contractOperations`)을 통과하고, 계약 런타임이 표현할 수 없는 multipart 업로드 1개만 전용 전송 seam으로 분리한다. mock gateway는 삭제하지 않고 런타임 스위치 뒤의 기본값으로 유지한다. + +**Tech Stack:** TypeScript 7, React 19, Vite 8, Vitest 4, MSW 2, zod 4, `openapi-typescript`(신규) + +**Spec:** `docs/superpowers/specs/2026-08-17-techlog-backend-alignment-design.md` + +## Global Constraints + +- Canonical 계약: `/home/donghyeon/workspace/tech-log-design-package/contracts/openapi/studio-v1.yaml`, spec version `2.0.0`, digest `sha256:85a65004f29880334b9a0a3b54089450a898f1a815f679b2985b87ed8723df5b`, 설계 패키지 revision `0ec5582`. +- canonical operation은 **19개**다. JSON 18개는 계약 기여에, multipart `uploadStudioAsset` 1개는 업로드 전송 seam에 존재해야 한다. +- 오류 코드는 **23개** 전부를 처리한다. 목록은 Task 2에 있다. +- 브랜치는 `feature/techlog-backend-alignment`다. `main`에 직접 커밋하지 않는다. +- `src/application/ports/studio-gateway.ts`의 시그니처를 변경하지 않는다. UI는 어댑터 종류를 알지 못한다. +- 현재 Public UI·라우트와 Studio 작업 흐름을 변경하지 않는다. `presentation/public/**`, `presentation/shared/public-render/**`, 기존 `document-*`/`validation-*`/`publish-*`/`publication-*` 컴포넌트는 Task 8~10에서 지정한 지점 외에는 손대지 않는다. +- **`tests/mocks/scenarios/catalog.ts`의 `HTTP_SCENARIO_OPERATION_IDS`에 TechLog operation을 추가하지 않는다.** 그 카탈로그는 플랫폼 전송 계층의 실패 모드 행렬이며 reference feature 3개 operation으로 이미 입증돼 있다. 어떤 스크립트도 계약 등록부와 대조하지 않으므로(확인함) TechLog operation을 넣으면 19 시나리오 × 18 operation의 증거 부담만 생긴다. TechLog는 `tests/features/tech-log/`에서 MSW로 gateway 수준 계약을 검증한다. +- 플랫폼 파일 중 이 계획이 수정을 허용하는 것은 `src/contracts/release-artifacts.ts`, `src/bootstrap/runtime-config-schema.ts`, `src/contracts/env.ts`, `src/features/installed-contract-contributions.ts`, `src/features/installed-feature-adapters.ts`, `src/bootstrap/runtime-adapters.ts`뿐이다. `src/contracts/external-contract-runtime.ts`와 `src/adapters/http/client.ts`는 **수정하지 않는다**(template 동기화 대상). +- 매 Task는 red → green → 게이트 → commit 순서를 지킨다. + +--- + +### Task 1: 계약 생성 파이프라인과 drift 게이트 + +canonical yaml을 vendor하고 타입을 재생성 가능하게 만든다. 현재 `generated.ts`는 `openapi-typescript`로 만들어졌으나 도구도 스크립트도 저장소에 없어 재생성이 불가능하다 — 두 계약이 갈라진 근본 원인이다. + +**Files:** +- Create: `scripts/generate-tech-log-contract.ts` +- Create: `src/features/tech-log/contracts/studio/canonical-source.json` +- Modify: `src/features/tech-log/contracts/studio/studio-api.openapi.yaml` (canonical 사본으로 교체) +- Modify: `src/features/tech-log/contracts/studio/generated.ts` (재생성) +- Modify: `package.json` (devDependency + scripts) +- Test: `tests/features/tech-log/contract-generation.test.ts` + +**Interfaces:** +- Consumes: 없음 (첫 Task) +- Produces: `canonical-source.json` — `{ packageId: string, version: string, digest: string, sourceRevision: string, operationIds: string[] }`. Task 2가 provenance와 parity 검증에 사용한다. + +- [ ] **Step 1: 실패하는 테스트 작성** + +`tests/features/tech-log/contract-generation.test.ts`: + +```typescript +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { test } from "vitest"; + +import canonicalSource from "../../../src/features/tech-log/contracts/studio/canonical-source.json" with { type: "json" }; + +const YAML_PATH = "src/features/tech-log/contracts/studio/studio-api.openapi.yaml"; + +test("vendored contract matches the recorded canonical digest", () => { + const bytes = readFileSync(YAML_PATH); + const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + assert.equal(digest, canonicalSource.digest); +}); + +test("canonical source records the pinned revision and version", () => { + assert.equal(canonicalSource.packageId, "tech-log-studio-contract"); + assert.equal(canonicalSource.version, "2.0.0"); + assert.equal(canonicalSource.sourceRevision, "0ec5582"); + assert.match(canonicalSource.digest, /^sha256:[0-9a-f]{64}$/); +}); + +test("canonical source lists all 19 operationIds", () => { + assert.equal(canonicalSource.operationIds.length, 19); + assert.ok(canonicalSource.operationIds.includes("uploadStudioAsset")); + assert.ok(canonicalSource.operationIds.includes("getStudioSession")); +}); + +test("vendored contract declares the CSRF header", () => { + const yaml = readFileSync(YAML_PATH, "utf8"); + assert.ok(yaml.includes("X-CSRF-TOKEN")); +}); +``` + +- [ ] **Step 2: red 확인** + +Run: `corepack pnpm exec vitest run tests/features/tech-log/contract-generation.test.ts` +Expected: FAIL — `canonical-source.json` 모듈을 찾을 수 없다. + +- [ ] **Step 3: 생성 스크립트 작성** + +`scripts/generate-tech-log-contract.ts`: + +```typescript +/** + * canonical studio-v1.yaml을 vendor하고 타입을 생성한다. + * `--check`는 재생성 결과가 커밋 내용과 동일한지 검증만 하고 쓰지 않는다. + */ +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +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"; + +const check = argv.includes("--check"); + +function digestOf(bytes: Buffer): string { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +} + +function operationIdsOf(yaml: string): string[] { + return [...yaml.matchAll(/^\s+operationId:\s*(\S+)\s*$/gmu)].map( + (match) => match[1]!, + ); +} + +function specVersionOf(yaml: string): string { + const match = /^\s{2}version:\s*(\S+)\s*$/mu.exec(yaml); + if (!match) throw new Error("canonical yaml has no info.version"); + return match[1]!; +} + +function revisionOf(): string { + return execFileSync("git", ["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"], { + encoding: "utf8", + }).trim(); +} + +const canonicalBytes = readFileSync(CANONICAL_YAML); +const canonicalText = canonicalBytes.toString("utf8"); + +const record = { + packageId: "tech-log-studio-contract", + version: specVersionOf(canonicalText), + digest: digestOf(canonicalBytes), + sourceRevision: revisionOf(), + operationIds: operationIdsOf(canonicalText), +}; + +const generated = execFileSync( + "corepack", + ["pnpm", "exec", "openapi-typescript", CANONICAL_YAML], + { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }, +); + +const recordText = `${JSON.stringify(record, null, 2)}\n`; + +if (check) { + const problems: string[] = []; + if (readFileSync(VENDOR_YAML, "utf8") !== canonicalText) { + problems.push(`${VENDOR_YAML} differs from the canonical contract`); + } + if (readFileSync(GENERATED, "utf8") !== generated) { + problems.push(`${GENERATED} is not the current generation output`); + } + if (readFileSync(SOURCE_RECORD, "utf8") !== recordText) { + problems.push(`${SOURCE_RECORD} does not match the canonical digest/revision`); + } + if (problems.length > 0) { + console.error(`tech-log contract drift:\n- ${problems.join("\n- ")}`); + console.error("Run: corepack pnpm generate:tech-log-contract"); + exit(1); + } + console.log("tech-log contract is in sync with the canonical source."); + exit(0); +} + +writeFileSync(VENDOR_YAML, canonicalText); +writeFileSync(GENERATED, generated); +writeFileSync(SOURCE_RECORD, recordText); +console.log(`Generated from ${record.packageId}@${record.version} (${record.sourceRevision}).`); +``` + +- [ ] **Step 4: 의존성과 스크립트 추가** + +Run: + +```bash +corepack pnpm add -D openapi-typescript@7.9.1 +``` + +`package.json`의 `scripts`에 세 줄을 추가한다: + +```json +"generate:tech-log-contract": "node scripts/generate-tech-log-contract.ts", +"check:tech-log-contract": "node scripts/generate-tech-log-contract.ts --check", +"test:tech-log": "vitest run tests/features/tech-log --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/tech-log.xml", +``` + +`test:all` 스크립트의 `test:reference-feature` 뒤에 `&& corepack pnpm test:tech-log`를 추가한다. + +- [ ] **Step 5: 생성 실행** + +Run: `corepack pnpm generate:tech-log-contract` +Expected: `Generated from tech-log-studio-contract@2.0.0 (0ec5582).` + +`generated.ts`의 diff가 크다. `paths`에 `/api/v1/studio/session`, `/api/v1/studio/assets`, `/api/v1/studio/assets/{assetId}`가 생기고 `components.schemas`에 `StudioSession`, `Asset`, `AssetDetail`, `AssetPage`, `AssetUploadForm`, `UpdateAssetCommand`, `AssetUsage`, `AssetKind`, `AssetManagementStatus`가 생기는지 육안 확인한다. + +- [ ] **Step 6: green 확인** + +Run: `corepack pnpm exec vitest run tests/features/tech-log/contract-generation.test.ts` +Expected: 4 tests PASS + +- [ ] **Step 7: drift 게이트가 실제로 잡는지 확인** + +Run: + +```bash +printf '\n# tampered\n' >> src/features/tech-log/contracts/studio/studio-api.openapi.yaml +corepack pnpm check:tech-log-contract; echo "exit=$?" +git checkout -- src/features/tech-log/contracts/studio/studio-api.openapi.yaml +corepack pnpm check:tech-log-contract; echo "exit=$?" +``` + +Expected: 첫 실행 `exit=1`과 drift 메시지, 복원 후 `exit=0`. + +- [ ] **Step 8: 기존 계약 소비자 회귀 확인** + +Run: `corepack pnpm check:types:app && corepack pnpm test:tech-log` +Expected: PASS. `contract.ts`의 기존 타입 alias가 새 `generated.ts`에서도 전부 해석돼야 한다. 실패하면 `contract.ts`에서 이름이 바뀐 alias만 수정한다 — 새 타입 추가는 Task 6에서 한다. + +- [ ] **Step 9: 커밋** + +```bash +git add package.json pnpm-lock.yaml scripts/generate-tech-log-contract.ts \ + src/features/tech-log/contracts/studio/ tests/features/tech-log/contract-generation.test.ts +git commit -m "build: generate the TechLog Studio contract from canonical source" +``` + +--- + +### Task 2: 오류 매핑과 CSRF 세션 + +계약 기여를 쓰기 전에, 모든 operation이 공유할 두 조각을 먼저 만든다. + +**Files:** +- Create: `src/features/tech-log/adapters/http/studio-error-mapping.ts` +- Create: `src/features/tech-log/adapters/http/studio-session-csrf.ts` +- Test: `tests/features/tech-log/studio-error-mapping.test.ts` + +**Interfaces:** +- Consumes: `StudioGatewayError`(`application/ports/studio-gateway-error.ts`), `ProblemDetails`(`contracts/studio/contract.ts`), `HttpExecutionOutcome`(`adapters/http/http-execution-v3.ts`) +- Produces: + - `STUDIO_ERROR_CODES: readonly ProblemDetails["code"][]` — 23개 + - `toStudioGatewayError(outcome: HttpExecutionOutcome, operationId: string): StudioGatewayError` + - `createCsrfTokenProvider(deps: { execute: CsrfSessionExecutor }): { token(options?: { signal?: AbortSignal }): Promise; invalidate(): void }` + - `type CsrfSessionExecutor = (options?: { signal?: AbortSignal }) => Promise<{ csrfToken: string; csrfHeaderName: string }>` + +- [ ] **Step 1: 실패하는 테스트 작성** + +`tests/features/tech-log/studio-error-mapping.test.ts`: + +```typescript +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { + STUDIO_ERROR_CODES, + toStudioGatewayError, +} from "../../../src/features/tech-log/adapters/http/studio-error-mapping.ts"; +import { createCsrfTokenProvider } from "../../../src/features/tech-log/adapters/http/studio-session-csrf.ts"; +import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts"; + +test("covers every canonical error code exactly once", () => { + assert.equal(STUDIO_ERROR_CODES.length, 23); + assert.equal(new Set(STUDIO_ERROR_CODES).size, 23); + for (const code of ["IDEMPOTENCY_KEY_REUSED", "WARNING_ACKNOWLEDGEMENT_REQUIRED", "ASSET_QUARANTINED"]) { + assert.ok(STUDIO_ERROR_CODES.includes(code as never), `${code} is missing`); + } +}); + +test("maps a PROBLEM outcome onto the port error, preserving the code", () => { + const error = toStudioGatewayError( + { + kind: "PROBLEM", + problem: { + type: "https://techlog.local/problems/version-conflict", + title: "VERSION_CONFLICT", + status: 409, + detail: "Expected 3; current 4.", + code: "VERSION_CONFLICT", + }, + metadata: { status: 409 }, + effect: "NOT_APPLIED", + } as never, + "saveStudioDocument", + ); + + assert.ok(isStudioGatewayError(error)); + assert.equal(error.code, "VERSION_CONFLICT"); + assert.equal(error.status, 409); +}); + +test("maps a transport failure onto STUDIO_UNAVAILABLE without inventing a domain code", () => { + const error = toStudioGatewayError( + { kind: "TRANSPORT_FAILURE", failure: { kind: "TIMEOUT" }, effect: "MAYBE_APPLIED" } as never, + "getStudioDashboard", + ); + + assert.equal(error.code, "STUDIO_UNAVAILABLE"); + assert.equal(error.retryable, true); +}); + +test("maps UNAUTHENTICATED onto AUTHENTICATION_REQUIRED", () => { + const error = toStudioGatewayError( + { kind: "UNAUTHENTICATED", effect: "NOT_APPLIED" } as never, + "getStudioDashboard", + ); + + assert.equal(error.code, "AUTHENTICATION_REQUIRED"); + assert.equal(error.status, 401); +}); + +test("fetches the CSRF token once and reuses it until invalidated", async () => { + let calls = 0; + const provider = createCsrfTokenProvider({ + async execute() { + calls += 1; + return { csrfToken: `token-${calls}`, csrfHeaderName: "X-CSRF-TOKEN" }; + }, + }); + + assert.equal(await provider.token(), "token-1"); + assert.equal(await provider.token(), "token-1"); + assert.equal(calls, 1); + + provider.invalidate(); + assert.equal(await provider.token(), "token-2"); + assert.equal(calls, 2); +}); + +test("does not stampede concurrent CSRF requests", async () => { + let calls = 0; + const provider = createCsrfTokenProvider({ + async execute() { + calls += 1; + await Promise.resolve(); + return { csrfToken: "token", csrfHeaderName: "X-CSRF-TOKEN" }; + }, + }); + + await Promise.all([provider.token(), provider.token(), provider.token()]); + assert.equal(calls, 1); +}); +``` + +- [ ] **Step 2: red 확인** + +Run: `corepack pnpm exec vitest run tests/features/tech-log/studio-error-mapping.test.ts` +Expected: FAIL — 두 모듈을 찾을 수 없다. + +- [ ] **Step 3: 오류 매핑 구현** + +`src/features/tech-log/adapters/http/studio-error-mapping.ts`: + +```typescript +import type { HttpExecutionOutcome } from "../../../../adapters/http/http-execution-v3.ts"; +import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts"; +import type { ProblemDetails } from "../../contracts/studio/contract.ts"; + +/** canonical studio-v1.yaml `ProblemDetails.code` enum과 1:1이다. */ +export const STUDIO_ERROR_CODES = Object.freeze([ + "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", +]) as readonly ProblemDetails["code"][]; + +const CODES = new Set(STUDIO_ERROR_CODES); + +function synthetic( + code: ProblemDetails["code"], + status: number, + detail: string, + retryable: boolean, +): StudioGatewayError { + return new StudioGatewayError({ + type: `https://techlog.local/problems/${code.toLowerCase().replaceAll("_", "-")}`, + title: code, + status, + detail, + code, + retryable, + }); +} + +/** + * 서버가 계약 밖 코드를 보내면 도메인 코드를 지어내지 않는다. 전송 계층 + * 실패와 마찬가지로 `STUDIO_UNAVAILABLE`로 접는다. + */ +export function toStudioGatewayError( + outcome: HttpExecutionOutcome, + operationId: string, +): StudioGatewayError { + switch (outcome.kind) { + case "PROBLEM": { + const problem = outcome.problem as ProblemDetails; + if (problem && typeof problem.code === "string" && CODES.has(problem.code)) { + return new StudioGatewayError(problem); + } + return synthetic( + "STUDIO_UNAVAILABLE", + outcome.metadata.status, + `${operationId} returned an uncontracted problem code.`, + false, + ); + } + case "UNAUTHENTICATED": + return synthetic("AUTHENTICATION_REQUIRED", 401, `${operationId} requires authentication.`, false); + case "FORBIDDEN": + return synthetic("STUDIO_ACCESS_DENIED", 403, `${operationId} was denied.`, false); + case "CANCELLED": + return synthetic("STUDIO_UNAVAILABLE", 499, `${operationId} was cancelled.`, false); + case "RATE_LIMITED": + return synthetic("STUDIO_UNAVAILABLE", 429, `${operationId} was rate limited.`, true); + case "TRANSPORT_FAILURE": + return synthetic("STUDIO_UNAVAILABLE", 503, `${operationId} transport failed.`, true); + case "AUTH_INTEGRATION_FAILURE": + case "CONTRACT_VIOLATION": + return synthetic("STUDIO_UNAVAILABLE", 502, `${operationId} broke its contract.`, false); + case "SUCCESS": + throw new Error(`${operationId}: success outcome is not an error`); + } +} +``` + +- [ ] **Step 4: CSRF 제공자 구현** + +`src/features/tech-log/adapters/http/studio-session-csrf.ts`: + +```typescript +export type StudioSessionSnapshot = Readonly<{ + csrfToken: string; + csrfHeaderName: string; +}>; + +export type CsrfSessionExecutor = ( + options?: Readonly<{ signal?: AbortSignal }>, +) => Promise; + +export type CsrfTokenProvider = Readonly<{ + token(options?: Readonly<{ signal?: AbortSignal }>): Promise; + headerName(options?: Readonly<{ signal?: AbortSignal }>): Promise; + invalidate(): void; +}>; + +/** + * CSRF는 전송 관심사다. UI는 토큰을 보지 않으므로 포트로 노출하지 않고 + * 어댑터 내부에서 캐시한다. 동시 요청은 하나의 in-flight 조회를 공유한다. + */ +export function createCsrfTokenProvider( + deps: Readonly<{ execute: CsrfSessionExecutor }>, +): CsrfTokenProvider { + let cached: StudioSessionSnapshot | null = null; + let inFlight: Promise | null = null; + + async function resolve( + options?: Readonly<{ signal?: AbortSignal }>, + ): Promise { + if (cached) return cached; + inFlight ??= deps.execute(options).then( + (snapshot) => { + cached = snapshot; + inFlight = null; + return snapshot; + }, + (error: unknown) => { + inFlight = null; + throw error; + }, + ); + return inFlight; + } + + return Object.freeze({ + async token(options) { + return (await resolve(options)).csrfToken; + }, + async headerName(options) { + return (await resolve(options)).csrfHeaderName; + }, + invalidate() { + cached = null; + }, + }); +} +``` + +- [ ] **Step 5: green 확인** + +Run: `corepack pnpm exec vitest run tests/features/tech-log/studio-error-mapping.test.ts` +Expected: 6 tests PASS + +- [ ] **Step 6: 커밋** + +```bash +git add src/features/tech-log/adapters/http/ tests/features/tech-log/studio-error-mapping.test.ts +git commit -m "feat: add TechLog Studio error mapping and CSRF token provider" +``` + +--- + +### Task 3: Studio 계약 기여 — JSON operation 18개 + +계약 기여는 서비스 패키지당 하나다. Asset의 JSON operation 4개도 같은 `studio-v1` 패키지이므로 한 파일에 선언한다. + +**Files:** +- Create: `src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts` +- Modify: `src/features/installed-contract-contributions.ts` +- Test: `tests/features/tech-log/studio-contract-contribution.test.ts` + +**Interfaces:** +- Consumes: `canonical-source.json`(Task 1), `InstalledContractContribution`/`InstalledHttpContract`(`src/contracts/external-contract-runtime.ts`) +- Produces: + - `TECH_LOG_STUDIO_CONTRIBUTION: InstalledContractContribution` + - `TECH_LOG_STUDIO_OPERATION_IDS: readonly string[]` — 18개 + - `type TechLogStudioOperationId = (typeof TECH_LOG_STUDIO_OPERATION_IDS)[number]` + +**선언할 operation 18개.** 각 항목은 `operationId · method · pathTemplate · requestBody · retrySemantics · acceptedStatuses · responseByteLimit`이다. `retrySemantics: "KEYED"`인 항목은 전부 `commandRecovery = { mode: "IDEMPOTENCY_REPLAY", operationIdentityField: "idempotencyKey" }`, `frontend.retryBudget = 0`이다. `SAFE`는 `commandRecovery: null`, `commandEffect: null`, `retryBudget: 2`다. 모든 항목의 `frontend.authProfileId`는 `"TECH_LOG_STUDIO_SESSION"`, `totalDeadlineMs`는 `10_000`이다. + +| operationId | method | pathTemplate | requestBody | retry | accepted | respLimit | +|---|---|---|---|---|---|---| +| `getStudioSession` | GET | `/api/v1/studio/session` | NONE | SAFE | 200 | 8_192 | +| `getStudioDashboard` | GET | `/api/v1/studio/dashboard` | NONE | SAFE | 200 | 262_144 | +| `listStudioDocuments` | GET | `/api/v1/studio/documents` | NONE | SAFE | 200 | 262_144 | +| `createStudioDocument` | POST | `/api/v1/studio/documents` | JSON | KEYED | 201 | 131_072 | +| `getStudioDocument` | GET | `/api/v1/studio/documents/{documentId}` | NONE | SAFE | 200 | 524_288 | +| `saveStudioDocument` | PUT | `/api/v1/studio/documents/{documentId}` | JSON | KEYED | 200 | 524_288 | +| `validateStudioDocument` | POST | `/api/v1/studio/documents/{documentId}/validate` | JSON | KEYED | 200 | 262_144 | +| `getCurrentStudioPreview` | GET | `/api/v1/studio/documents/{documentId}/preview` | NONE | SAFE | 200 | 1_048_576 | +| `createStudioPreview` | POST | `/api/v1/studio/documents/{documentId}/preview` | JSON | KEYED | 201 | 1_048_576 | +| `publishStudioDocument` | POST | `/api/v1/studio/documents/{documentId}/publish` | JSON | KEYED | 200 | 131_072 | +| `listStudioPublications` | GET | `/api/v1/studio/publications` | NONE | SAFE | 200 | 262_144 | +| `unpublishStudioPublication` | POST | `/api/v1/studio/publications/{publicationId}/unpublish` | JSON | KEYED | 200 | 131_072 | +| `getStudioPublicationSnapshot` | GET | `/api/v1/studio/publications/{publicationEventId}/preview` | NONE | SAFE | 200 | 1_048_576 | +| `listStudioCatalog` | GET | `/api/v1/studio/catalog` | NONE | SAFE | 200 | 131_072 | +| `listStudioAssets` | GET | `/api/v1/studio/assets` | NONE | SAFE | 200 | 262_144 | +| `getStudioAsset` | GET | `/api/v1/studio/assets/{assetId}` | NONE | SAFE | 200 | 65_536 | +| `updateStudioAsset` | PUT | `/api/v1/studio/assets/{assetId}` | JSON | KEYED | 200 | 65_536 | +| `deleteStudioAsset` | DELETE | `/api/v1/studio/assets/{assetId}` | NONE | KEYED | 204 | 0 | + +`deleteStudioAsset`만 `responseBody: "NONE"`, `emptyBodyStatuses: [204]`다. 나머지는 `responseBody: "REQUIRED_JSON"`, `emptyBodyStatuses: []`다. + +- [ ] **Step 1: 실패하는 테스트 작성** + +`tests/features/tech-log/studio-contract-contribution.test.ts`: + +```typescript +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import canonicalSource from "../../../src/features/tech-log/contracts/studio/canonical-source.json" with { type: "json" }; +import { + TECH_LOG_STUDIO_CONTRIBUTION, + TECH_LOG_STUDIO_OPERATION_IDS, +} from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts"; +import { composeContractContributions } from "../../../src/contracts/external-contract-runtime.ts"; + +const UPLOAD = "uploadStudioAsset"; + +test("declares every canonical operation except the multipart upload", () => { + const expected = canonicalSource.operationIds.filter((id) => id !== UPLOAD); + assert.equal(expected.length, 18); + assert.deepEqual([...TECH_LOG_STUDIO_OPERATION_IDS].sort(), [...expected].sort()); +}); + +test("pins the canonical digest and revision as external package provenance", () => { + const source = TECH_LOG_STUDIO_CONTRIBUTION.source; + assert.equal(source.kind, "EXTERNAL_PACKAGE"); + if (source.kind !== "EXTERNAL_PACKAGE") return; + assert.equal(source.package.packageId, canonicalSource.packageId); + assert.equal(source.package.version, canonicalSource.version); + assert.equal(source.package.digest, canonicalSource.digest); + assert.equal(source.package.sourceRevision, canonicalSource.sourceRevision); + assert.equal(source.package.runtimeProtocolVersion, 1); +}); + +test("composes without violating the platform contract runtime", () => { + const composed = composeContractContributions([TECH_LOG_STUDIO_CONTRIBUTION]); + assert.equal(composed.externalPackages.length, 1); +}); + +test("every mutating operation replays by idempotency key and never auto-retries", () => { + for (const entry of TECH_LOG_STUDIO_CONTRIBUTION.http) { + if (entry.contract.retrySemantics !== "KEYED") continue; + assert.deepEqual(entry.contract.commandRecovery, { + mode: "IDEMPOTENCY_REPLAY", + operationIdentityField: "idempotencyKey", + }, `${entry.contract.operationId} recovery`); + assert.equal(entry.frontend.retryBudget, 0, `${entry.contract.operationId} budget`); + } +}); + +test("path templates match the canonical /api/v1/studio prefix", () => { + for (const entry of TECH_LOG_STUDIO_CONTRIBUTION.http) { + assert.ok( + entry.contract.pathTemplate.startsWith("/api/v1/studio/"), + `${entry.contract.operationId}: ${entry.contract.pathTemplate}`, + ); + } +}); +``` + +- [ ] **Step 2: red 확인** + +Run: `corepack pnpm exec vitest run tests/features/tech-log/studio-contract-contribution.test.ts` +Expected: FAIL — 기여 모듈을 찾을 수 없다. + +- [ ] **Step 3: 기여 파일 작성** + +`src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts`. 먼저 공용 헬퍼와 대표 operation 두 개를 정확한 형태로 만든 뒤, 위 표의 나머지 16개를 같은 헬퍼로 선언한다. + +```typescript +import { z } from "zod"; + +import type { + CommandEffectDescriptor, + InstalledContractContribution, + InstalledHttpContract, + RuntimeValidator, +} 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 { STUDIO_ERROR_CODES } from "../adapters/http/studio-error-mapping.ts"; + +function zodValidator(schemaId: string, schema: z.ZodType): RuntimeValidator { + return Object.freeze({ + schemaId, + safeParse(value: unknown) { + const result = schema.safeParse(value); + if (result.success) { + return Object.freeze({ success: true as const, data: structuredClone(result.data) }); + } + return Object.freeze({ + success: false as const, + issues: Object.freeze( + result.error.issues.map((issue) => + Object.freeze({ + path: Object.freeze( + issue.path.map((segment): string | number => + typeof segment === "number" ? segment : String(segment), + ), + ), + code: String(issue.code), + }), + ), + ), + }); + }, + }); +} + +/** + * 서버 payload는 canonical 계약이 소유한다. 전송 계층은 문제 문서만 엄격히 + * 검증하고 성공 payload는 통과시킨다 — generated 타입이 컴파일 시점 계약이고, + * 런타임 재검증은 계약 갱신 때마다 두 곳을 고치게 만든다. + */ +const passthrough = (schemaId: string) => + zodValidator(schemaId, z.unknown() as unknown as z.ZodType); + +const problemSchema = z + .object({ + type: z.string().min(1).max(512), + title: z.string().min(1).max(240), + 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[]]), + }) + .loose(); + +const PROBLEM = zodValidator("StudioProblemDetails", problemSchema); + +/** 4xx 도메인 거절은 적용되지 않았음이 확정이다. 5xx/네트워크는 불확정이다. */ +const COMMAND_EFFECT: CommandEffectDescriptor> = + Object.freeze({ + successEffect: "APPLIED_CONFIRMED" as const, + classifyProblem({ status }: Readonly<{ status: number; problem: unknown }>) { + return status >= 400 && status < 500 ? "NOT_APPLIED" : "MAYBE_APPLIED"; + }, + }); + +type PathValues = Readonly>; +type QueryEntries = readonly (readonly [string, string])[]; + +function safeOperation( + operationId: string, + pathTemplate: string, + responseByteLimit: number, + project: (input: never) => Readonly<{ pathValues: PathValues; queryEntries: QueryEntries }>, +): InstalledHttpContract { + return Object.freeze({ + contract: Object.freeze({ + operationId, + method: "GET" as const, + pathTemplate, + inputValidator: passthrough(`${operationId}Input`), + outputValidator: passthrough(`${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) { + const projected = project(input); + return Object.freeze({ ...projected, body: null }); + }, + }), + frontend: Object.freeze({ + policyId: `${operationId}_V1`, + requestByteLimit: 0, + responseByteLimit, + totalDeadlineMs: 10_000, + retryBudget: 2 as const, + authProfileId: "TECH_LOG_STUDIO_SESSION", + diagnosticsOperation: `techLog.studio.${operationId}`, + }), + }) as InstalledHttpContract; +} + +function keyedOperation( + operationId: string, + method: "POST" | "PUT" | "DELETE", + pathTemplate: string, + options: Readonly<{ + acceptedStatus: number; + responseByteLimit: number; + requestByteLimit: number; + hasBody: boolean; + }>, + project: (input: never) => Readonly<{ + pathValues: PathValues; + queryEntries: QueryEntries; + body: unknown; + }>, +): InstalledHttpContract { + return Object.freeze({ + contract: Object.freeze({ + operationId, + method, + pathTemplate, + inputValidator: passthrough(`${operationId}Input`), + outputValidator: passthrough(`${operationId}Output`), + problemValidator: PROBLEM, + acceptedStatuses: Object.freeze([options.acceptedStatus]), + emptyBodyStatuses: Object.freeze(options.acceptedStatus === 204 ? [204] : []), + retrySemantics: "KEYED" as const, + requestBody: options.hasBody ? ("JSON" as const) : ("NONE" as const), + responseBody: options.acceptedStatus === 204 ? ("NONE" as const) : ("REQUIRED_JSON" as const), + commandRecovery: Object.freeze({ + mode: "IDEMPOTENCY_REPLAY" as const, + operationIdentityField: "idempotencyKey", + }), + commandEffect: COMMAND_EFFECT, + projectRequest: project, + }), + frontend: Object.freeze({ + policyId: `${operationId}_V1`, + requestByteLimit: options.requestByteLimit, + responseByteLimit: options.responseByteLimit, + totalDeadlineMs: 10_000, + // §8.3. 발신된 KEYED 명령은 자동 재시도하지 않는다. + retryBudget: 0 as const, + authProfileId: "TECH_LOG_STUDIO_SESSION", + diagnosticsOperation: `techLog.studio.${operationId}`, + }), + }) as InstalledHttpContract; +} + +const NO_PATH = Object.freeze({}); +const NO_QUERY = Object.freeze([]) as QueryEntries; + +function queryOf(input: Readonly>): QueryEntries { + const entries: (readonly [string, string])[] = []; + for (const [key, value] of Object.entries(input)) { + if (value === undefined || value === null) continue; + entries.push([key, String(value)]); + } + return Object.freeze(entries); +} +``` + +대표 operation 두 개: + +```typescript +const GET_STUDIO_SESSION = safeOperation( + "getStudioSession", + "/api/v1/studio/session", + 8_192, + () => Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY }), +); + +const SAVE_STUDIO_DOCUMENT = keyedOperation( + "saveStudioDocument", + "PUT", + "/api/v1/studio/documents/{documentId}", + { acceptedStatus: 200, responseByteLimit: 524_288, requestByteLimit: 524_288, hasBody: true }, + (input: never) => { + const value = input as unknown as Readonly<{ + documentId: string; + expectedVersion: number; + document: unknown; + }>; + return Object.freeze({ + pathValues: Object.freeze({ documentId: value.documentId }), + queryEntries: NO_QUERY, + body: Object.freeze({ + expectedVersion: value.expectedVersion, + document: value.document, + }), + }); + }, +); +``` + +나머지 16개를 같은 두 헬퍼로 선언한다. 표의 `respLimit`을 `responseByteLimit`에, 본문이 있는 KEYED operation의 `requestByteLimit`은 `responseByteLimit`과 같은 값을, 본문 없는 `deleteStudioAsset`은 `0`을 쓴다. + +query를 갖는 SAFE operation의 `projectRequest`는 `queryOf`를 쓴다. 예를 들어 `listStudioDocuments`는 `q, kind, publicationStatus, nextAction, projectId, sort, cursor, limit`을, `listStudioPublications`는 `q, type, cursor, limit`을, `listStudioCatalog`는 `type, q, cursor, limit`을, `listStudioAssets`는 `q, kind, managementStatus, cursor, limit`을 넘긴다. + +path 파라미터를 갖는 operation의 `pathValues` 키는 템플릿과 정확히 같아야 한다: `documentId`, `publicationId`, `publicationEventId`, `assetId`. + +마지막에 기여를 조립한다: + +```typescript +const HTTP_CONTRACTS = Object.freeze([ + GET_STUDIO_SESSION, + GET_STUDIO_DASHBOARD, + LIST_STUDIO_DOCUMENTS, + CREATE_STUDIO_DOCUMENT, + GET_STUDIO_DOCUMENT, + SAVE_STUDIO_DOCUMENT, + VALIDATE_STUDIO_DOCUMENT, + GET_CURRENT_STUDIO_PREVIEW, + CREATE_STUDIO_PREVIEW, + PUBLISH_STUDIO_DOCUMENT, + LIST_STUDIO_PUBLICATIONS, + UNPUBLISH_STUDIO_PUBLICATION, + GET_STUDIO_PUBLICATION_SNAPSHOT, + LIST_STUDIO_CATALOG, + LIST_STUDIO_ASSETS, + GET_STUDIO_ASSET, + UPDATE_STUDIO_ASSET, + DELETE_STUDIO_ASSET, +]); + +export const TECH_LOG_STUDIO_OPERATION_IDS = Object.freeze( + HTTP_CONTRACTS.map((entry) => entry.contract.operationId), +); + +export type TechLogStudioOperationId = + (typeof TECH_LOG_STUDIO_OPERATION_IDS)[number]; + +export const TECH_LOG_STUDIO_CONTRIBUTION: InstalledContractContribution = + Object.freeze({ + contributionId: "tech-log-studio-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([]), + }); +``` + +- [ ] **Step 4: 설치 등록부에 추가** + +`src/features/installed-contract-contributions.ts`의 `INSTALLED_CONTRACT_CONTRIBUTIONS`를 수정한다. TechLog는 항상 설치되므로 조건 없이 포함한다: + +```typescript +import { TECH_LOG_STUDIO_CONTRIBUTION } from "./tech-log/contracts/tech-log-studio-contract-contribution.ts"; + +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], + ); +``` + +- [ ] **Step 5: green 확인** + +Run: `corepack pnpm exec vitest run tests/features/tech-log/studio-contract-contribution.test.ts` +Expected: 5 tests PASS + +- [ ] **Step 6: 계약 집합과 아키텍처 게이트 확인** + +Run: `corepack pnpm check:types:app && corepack pnpm generate:contract-set && corepack pnpm check:architecture` +Expected: PASS. `contractSet`에 `tech-log-studio-contract@2.0.0`이 나타난다. `EXTERNAL_PACKAGE`이므로 `TEMPLATE_FIXTURE`와 달리 release digest에 반영된다. + +- [ ] **Step 7: 커밋** + +```bash +git add src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts \ + src/features/installed-contract-contributions.ts \ + tests/features/tech-log/studio-contract-contribution.test.ts .generated/ +git commit -m "feat: register the TechLog Studio contract contribution" +``` + +--- + +### Task 4: HTTP StudioGateway 구현 + +**Files:** +- Create: `src/features/tech-log/adapters/http/http-studio-gateway.ts` +- Create: `tests/mocks/handlers/tech-log-studio.ts` +- Test: `tests/features/tech-log/http-studio-gateway.test.ts` + +**Interfaces:** +- Consumes: `toStudioGatewayError`/`createCsrfTokenProvider`(Task 2), `TechLogStudioOperationId`(Task 3), `StudioGateway`(`application/ports/studio-gateway.ts`), `InstalledContractOperationExecutor`(`features/reference-feature/adapters/create-reference-feature-input.ts`의 동형 타입) +- Produces: + - `type StudioOperationExecutor = Readonly<{ execute(operationId: string, input: unknown, context: Readonly<{ routeId: string; signal?: AbortSignal; intent?: MutationIntent }>): Promise> }>` + - `createHttpStudioGateway(deps: Readonly<{ operations: StudioOperationExecutor; csrf: CsrfTokenProvider }>): StudioGateway` + +- [ ] **Step 1: MSW 핸들러 작성** + +`tests/mocks/handlers/tech-log-studio.ts`. mock gateway가 이미 canonical 의미론의 완전한 구현체이므로 재사용한다. + +```typescript +import { http, HttpResponse } from "msw"; + +import { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts"; +import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts"; + +const BASE = "http://api.test"; + +function problemResponse(error: unknown) { + if (isStudioGatewayError(error)) { + return HttpResponse.json(error.problem, { + status: error.status, + headers: { "content-type": "application/problem+json" }, + }); + } + throw error; +} + +async function json(work: () => Promise, status = 200) { + try { + return HttpResponse.json((await work()) as never, { status }); + } catch (error) { + return problemResponse(error); + } +} + +export function createTechLogStudioHandlers( + gateway = createMockStudioGateway(), + baseUrl = BASE, +) { + const key = (request: Request) => + request.headers.get("Idempotency-Key") ?? "missing-key"; + + return { + gateway, + handlers: [ + http.get(`${baseUrl}/api/v1/studio/session`, () => + HttpResponse.json({ + authenticated: true, + displayName: "테스트 편집자", + roles: ["STUDIO_EDITOR"], + csrfToken: "csrf-test-token", + csrfHeaderName: "X-CSRF-TOKEN", + }), + ), + http.get(`${baseUrl}/api/v1/studio/dashboard`, () => + json(() => gateway.getDashboard()), + ), + http.get(`${baseUrl}/api/v1/studio/documents`, ({ request }) => { + const url = new URL(request.url); + const limit = url.searchParams.get("limit"); + return json(() => + gateway.listDocuments({ + ...(url.searchParams.get("q") ? { q: url.searchParams.get("q")! } : {}), + ...(limit ? { limit: Number(limit) } : {}), + } as never), + ); + }), + http.post(`${baseUrl}/api/v1/studio/documents`, async ({ request }) => + json( + async () => + gateway.createDocument((await request.json()) as never, { + idempotencyKey: key(request), + }), + 201, + ), + ), + http.get(`${baseUrl}/api/v1/studio/documents/:documentId`, ({ params }) => + json(() => gateway.getDocument(String(params.documentId))), + ), + http.put( + `${baseUrl}/api/v1/studio/documents/:documentId`, + async ({ request, params }) => + json(async () => + gateway.saveDocument( + String(params.documentId), + (await request.json()) as never, + { idempotencyKey: key(request) }, + ), + ), + ), + http.post( + `${baseUrl}/api/v1/studio/documents/:documentId/validate`, + async ({ request, params }) => + json(async () => + gateway.validateDocument( + String(params.documentId), + (await request.json()) as never, + { idempotencyKey: key(request) }, + ), + ), + ), + http.get( + `${baseUrl}/api/v1/studio/documents/:documentId/preview`, + ({ params }) => json(() => gateway.getCurrentPreview(String(params.documentId))), + ), + http.post( + `${baseUrl}/api/v1/studio/documents/:documentId/preview`, + async ({ request, params }) => + json( + async () => + gateway.createPreview( + String(params.documentId), + (await request.json()) as never, + { idempotencyKey: key(request) }, + ), + 201, + ), + ), + http.post( + `${baseUrl}/api/v1/studio/documents/:documentId/publish`, + async ({ request, params }) => + json(async () => + gateway.publishDocument( + String(params.documentId), + (await request.json()) as never, + { idempotencyKey: key(request) }, + ), + ), + ), + http.get(`${baseUrl}/api/v1/studio/publications`, () => + json(() => gateway.listPublications({})), + ), + http.post( + `${baseUrl}/api/v1/studio/publications/:publicationId/unpublish`, + async ({ request, params }) => + json(async () => + gateway.unpublishPublication( + String(params.publicationId), + (await request.json()) as never, + { idempotencyKey: key(request) }, + ), + ), + ), + http.get( + `${baseUrl}/api/v1/studio/publications/:publicationEventId/preview`, + ({ params }) => + json(() => gateway.getPublicationSnapshot(String(params.publicationEventId))), + ), + http.get(`${baseUrl}/api/v1/studio/catalog`, ({ request }) => { + const url = new URL(request.url); + return json(() => + gateway.getCatalog({ type: url.searchParams.get("type") as never }), + ); + }), + ], + }; +} +``` + +- [ ] **Step 2: 실패하는 gateway 테스트 작성** + +`tests/features/tech-log/http-studio-gateway.test.ts`: + +```typescript +import assert from "node:assert/strict"; +import { afterAll, afterEach, beforeAll, test } from "vitest"; +import { setupServer } from "msw/node"; + +import { createHttpStudioGateway } from "../../../src/features/tech-log/adapters/http/http-studio-gateway.ts"; +import { createCsrfTokenProvider } from "../../../src/features/tech-log/adapters/http/studio-session-csrf.ts"; +import { createTechLogStudioHandlers } from "../../mocks/handlers/tech-log-studio.ts"; +import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts"; +import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts"; + +const { handlers } = createTechLogStudioHandlers(); +const server = setupServer(...handlers); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +/** + * 전송 계층을 얇게 세운다. 이 테스트가 증명하는 것은 gateway가 canonical + * 경로/본문/헤더를 정확히 만들고 응답을 포트 계약으로 되돌린다는 것이다. + */ +function gatewayUnderTest() { + const seen: { csrf: string | null; idempotency: string | null }[] = []; + const operations = { + async execute(operationId: string, input: unknown, context: never) { + void operationId; + void input; + void context; + throw new Error("replaced in Step 4"); + }, + }; + return { operations, seen }; +} + +test("reads the dashboard through the canonical path", async () => { + const gateway = createHttpStudioGateway(await realDependencies()); + const dashboard = await gateway.getDashboard(); + assert.equal(typeof dashboard.totals.documents, "number"); + assert.ok(dashboard.totals.documents > 0); +}); + +test("saves with expectedVersion and returns the new version", async () => { + const gateway = createHttpStudioGateway(await realDependencies()); + const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase); + const { id, version, updatedAt, ...input } = before.document; + void id; + void updatedAt; + + const saved = await gateway.saveDocument( + before.document.id, + { expectedVersion: version, document: { ...input, title: "HTTP 경로로 저장" } }, + { idempotencyKey: "save-1" }, + ); + + assert.equal(saved.document.title, "HTTP 경로로 저장"); + assert.equal(saved.document.version, version + 1); +}); + +test("surfaces VERSION_CONFLICT as the port error, not a transport error", async () => { + const gateway = createHttpStudioGateway(await realDependencies()); + const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase); + const { id, version, updatedAt, ...input } = before.document; + void id; + void updatedAt; + + await assert.rejects( + gateway.saveDocument( + before.document.id, + { expectedVersion: version + 99, document: input }, + { idempotencyKey: "conflict-1" }, + ), + (error: unknown) => { + assert.ok(isStudioGatewayError(error)); + assert.equal(error.code, "VERSION_CONFLICT"); + assert.equal(error.status, 409); + return true; + }, + ); +}); + +test("sends the CSRF header and an Idempotency-Key on every mutation", async () => { + const captured: { csrf: string | null; key: string | null }[] = []; + server.use( + ...createTechLogStudioHandlers().handlers, + ); + server.events.on("request:start", ({ request }) => { + if (request.method === "GET") return; + captured.push({ + csrf: request.headers.get("X-CSRF-TOKEN"), + key: request.headers.get("Idempotency-Key"), + }); + }); + + const gateway = createHttpStudioGateway(await realDependencies()); + const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase); + const { id, version, updatedAt, ...input } = before.document; + void id; + void updatedAt; + await gateway.saveDocument( + before.document.id, + { expectedVersion: version, document: input }, + { idempotencyKey: "csrf-1" }, + ); + + assert.equal(captured.length, 1); + assert.equal(captured[0]!.csrf, "csrf-test-token"); + assert.equal(captured[0]!.key, "csrf-1"); +}); +``` + +`realDependencies()`는 Step 4에서 만드는 헬퍼다. 이 Step에서는 파일이 없어 실패해야 정상이다. + +- [ ] **Step 3: red 확인** + +Run: `corepack pnpm exec vitest run tests/features/tech-log/http-studio-gateway.test.ts` +Expected: FAIL — `http-studio-gateway.ts`를 찾을 수 없다. + +- [ ] **Step 4: gateway 구현** + +`src/features/tech-log/adapters/http/http-studio-gateway.ts`. 모든 메서드는 같은 두 헬퍼를 통과한다. + +```typescript +import type { MutationIntent } from "../../../../contracts/mutation-intent.ts"; +import type { HttpExecutionOutcome } from "../../../../adapters/http/http-execution-v3.ts"; +import type { + IdempotentOptions, + RequestOptions, + StudioGateway, +} from "../../application/ports/studio-gateway.ts"; +import { toStudioGatewayError } from "./studio-error-mapping.ts"; +import type { CsrfTokenProvider } from "./studio-session-csrf.ts"; + +export type StudioOperationExecutor = Readonly<{ + execute( + operationId: string, + input: unknown, + context: Readonly<{ + routeId: string; + signal?: AbortSignal; + intent?: MutationIntent; + }>, + ): Promise>; +}>; + +export type HttpStudioGatewayDependencies = Readonly<{ + operations: StudioOperationExecutor; + csrf: CsrfTokenProvider; +}>; + +const ROUTE_ID = "TECH_LOG_STUDIO"; + +export function createHttpStudioGateway( + deps: HttpStudioGatewayDependencies, +): StudioGateway { + async function read( + operationId: string, + input: unknown, + options?: RequestOptions, + ): Promise { + const outcome = await deps.operations.execute(operationId, input, { + routeId: ROUTE_ID, + ...(options?.signal ? { signal: options.signal } : {}), + }); + if (outcome.kind !== "SUCCESS") throw toStudioGatewayError(outcome, operationId); + return outcome.value as T; + } + + async function command( + operationId: string, + input: unknown, + options: IdempotentOptions, + ): Promise { + const csrfToken = await deps.csrf.token( + options.signal ? { signal: options.signal } : undefined, + ); + const outcome = await deps.operations.execute( + operationId, + { ...(input as Record), idempotencyKey: options.idempotencyKey, csrfToken }, + { + routeId: ROUTE_ID, + intent: { idempotencyKey: options.idempotencyKey } as MutationIntent, + ...(options.signal ? { signal: options.signal } : {}), + }, + ); + if (outcome.kind === "SUCCESS") return outcome.value as T; + const error = toStudioGatewayError(outcome, operationId); + // 세션 만료는 토큰을 버려 다음 명령이 새로 받아오게 한다. + if (error.code === "AUTHENTICATION_REQUIRED") deps.csrf.invalidate(); + throw error; + } + + return Object.freeze({ + getDashboard: (options) => read("getStudioDashboard", {}, options), + listDocuments: (query, options) => read("listStudioDocuments", query, options), + createDocument: (input, options) => command("createStudioDocument", input, options), + getDocument: (documentId, options) => + read("getStudioDocument", { documentId }, options), + saveDocument: (documentId, cmd, options) => + command("saveStudioDocument", { documentId, ...cmd }, options), + validateDocument: (documentId, cmd, options) => + command("validateStudioDocument", { documentId, ...cmd }, options), + createPreview: (documentId, cmd, options) => + command("createStudioPreview", { documentId, ...cmd }, options), + getCurrentPreview: (documentId, options) => + read("getCurrentStudioPreview", { documentId }, options), + publishDocument: (documentId, cmd, options) => + command("publishStudioDocument", { documentId, ...cmd }, options), + unpublishPublication: (publicationId, cmd, options) => + command("unpublishStudioPublication", { publicationId, ...cmd }, options), + listPublications: (query, options) => read("listStudioPublications", query, options), + getPublicationSnapshot: (publicationEventId, options) => + read("getStudioPublicationSnapshot", { publicationEventId }, options), + getCatalog: (query, options) => read("listStudioCatalog", query, options), + }) satisfies StudioGateway; +} +``` + +- [ ] **Step 5: 테스트 헬퍼 완성** + +`http-studio-gateway.test.ts`의 `gatewayUnderTest` 스텁을 지우고 `realDependencies()`를 넣는다. 플랫폼 계약 런타임을 실제로 조립하지 않고, 계약 기여의 `projectRequest`로 요청을 만들어 `fetch`로 보내는 얇은 executor를 쓴다 — 이 테스트의 대상은 gateway이지 플랫폼 전송이 아니다. + +```typescript +import { TECH_LOG_STUDIO_CONTRIBUTION } from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts"; + +const BASE = "http://api.test"; + +async function realDependencies() { + const byId = new Map( + TECH_LOG_STUDIO_CONTRIBUTION.http.map((entry) => [entry.contract.operationId, entry]), + ); + + const csrf = createCsrfTokenProvider({ + async execute() { + const response = await fetch(`${BASE}/api/v1/studio/session`); + const body = (await response.json()) as { + csrfToken: string; + csrfHeaderName: string; + }; + return { csrfToken: body.csrfToken, csrfHeaderName: body.csrfHeaderName }; + }, + }); + + const operations = { + async execute(operationId: string, input: unknown, context: { signal?: AbortSignal }) { + const entry = byId.get(operationId); + if (!entry) throw new Error(`unregistered operation: ${operationId}`); + const { contract } = entry; + const projected = contract.projectRequest(input as never); + + let path = contract.pathTemplate; + for (const [name, value] of Object.entries(projected.pathValues)) { + path = path.replace(`{${name}}`, encodeURIComponent(value)); + } + const url = new URL(`${BASE}${path}`); + for (const [name, value] of projected.queryEntries) { + url.searchParams.append(name, value); + } + + const headers: Record = {}; + const payload = input as Record; + if (contract.retrySemantics === "KEYED") { + headers["Idempotency-Key"] = String(payload["idempotencyKey"]); + headers["X-CSRF-TOKEN"] = String(payload["csrfToken"]); + } + if (contract.requestBody === "JSON") headers["content-type"] = "application/json"; + + const response = await fetch(url, { + method: contract.method, + headers, + ...(contract.requestBody === "JSON" + ? { body: JSON.stringify(projected.body) } + : {}), + ...(context.signal ? { signal: context.signal } : {}), + }); + + if (contract.acceptedStatuses.includes(response.status)) { + const value = + contract.responseBody === "NONE" ? null : await response.json(); + return { kind: "SUCCESS" as const, value, effect: "APPLIED_CONFIRMED" as const }; + } + return { + kind: "PROBLEM" as const, + problem: await response.json(), + metadata: { status: response.status }, + effect: "NOT_APPLIED" as const, + }; + }, + }; + + return { operations, csrf } as never; +} +``` + +`projectRequest`가 KEYED operation에서 `idempotencyKey`/`csrfToken`을 본문에 넣지 않도록, Task 3의 각 KEYED `projectRequest`는 body를 명시적으로 조립해야 한다(`SAVE_STUDIO_DOCUMENT` 예시가 그렇게 돼 있다). 이 테스트가 그 규칙을 지킨다. + +- [ ] **Step 6: green 확인** + +Run: `corepack pnpm exec vitest run tests/features/tech-log/http-studio-gateway.test.ts` +Expected: 4 tests PASS + +- [ ] **Step 7: 커밋** + +```bash +git add src/features/tech-log/adapters/http/http-studio-gateway.ts \ + tests/mocks/handlers/tech-log-studio.ts \ + tests/features/tech-log/http-studio-gateway.test.ts +git commit -m "feat: implement the TechLog Studio HTTP gateway" +``` + +--- + +### Task 5: 런타임 스위치 + +`config/runtime/*.json`은 `.strict()` zod 스키마로 검증되므로 새 키는 스키마에 먼저 추가해야 한다. **선택 필드 + 기본값 `MOCK`**으로 추가해 기존 문서와 V1 호환성을 깨지 않는다. + +**Files:** +- Modify: `src/contracts/release-artifacts.ts:153-161` (V2 스키마에만 추가) +- Modify: `src/bootstrap/runtime-config-schema.ts:35-55, 108-141` +- Modify: `src/contracts/env.ts:33-56` +- Modify: `config/runtime/local.json`, `development.json`, `staging.json`, `production.json` +- Modify: `src/features/tech-log/adapters/create-tech-log-feature-input.ts` +- Modify: `src/features/installed-feature-adapters.ts` +- Modify: `src/bootstrap/runtime-adapters.ts:506-508` +- Test: `tests/runtime-schema/runtime-config.test.ts`, `tests/features/tech-log/runtime-composition.test.ts` + +**Interfaces:** +- Consumes: `createHttpStudioGateway`(Task 4), `createCsrfTokenProvider`(Task 2) +- Produces: `createTechLogFeatureInstalledInput(context: Readonly<{ studioSource: "MOCK" | "HTTP"; contractOperations: StudioOperationExecutor }>)` — 기존 무인자 시그니처를 대체한다. + +- [ ] **Step 1: 실패하는 스키마 테스트 추가** + +`tests/runtime-schema/runtime-config.test.ts`에 추가: + +```typescript +test("accepts TECH_LOG_STUDIO_SOURCE and defaults it to MOCK", () => { + const base = { + APP_ENV: "local", + API_BASE_URL: "http://localhost:8080/", + TELEMETRY_ENABLED: false, + AUTH_MODE: "demo", + CONFIG_SCHEMA_VERSION: "2.0", + CAPABILITY_OVERRIDES: { + REALTIME: "DEFAULT", + WEB_WORKER: "DEFAULT", + SERVICE_WORKER: "DEFAULT", + OFFLINE_COMMANDS: "DEFAULT", + }, + FEATURE_OVERRIDES: {}, + }; + + const defaulted = validateRuntimeConfig(base); + assert.equal(defaulted.success, true); + if (defaulted.success) { + assert.equal(defaulted.data.TECH_LOG_STUDIO_SOURCE, "MOCK"); + } + + const explicit = validateRuntimeConfig({ ...base, TECH_LOG_STUDIO_SOURCE: "HTTP" }); + assert.equal(explicit.success, true); + if (explicit.success) { + assert.equal(explicit.data.TECH_LOG_STUDIO_SOURCE, "HTTP"); + } + + const invalid = validateRuntimeConfig({ ...base, TECH_LOG_STUDIO_SOURCE: "LIVE" }); + assert.equal(invalid.success, false); +}); +``` + +- [ ] **Step 2: red 확인** + +Run: `corepack pnpm test:runtime-schema` +Expected: FAIL — strict 스키마가 알 수 없는 키를 거절한다. + +- [ ] **Step 3: 스키마 확장** + +`src/contracts/release-artifacts.ts`의 `runtimeConfigV2ArtifactSchema`에만 한 줄 추가한다. V1은 호환성 리더이므로 건드리지 않는다: + +```typescript +export const runtimeConfigV2ArtifactSchema = z + .object({ + ...runtimeConfigArtifactFields, + CONFIG_SCHEMA_VERSION: z.literal("2.0"), + CAPABILITY_OVERRIDES: capabilityOverrideArtifactSchema, + FEATURE_OVERRIDES: featureOverrideArtifactSchema, + // TechLog Studio 어댑터 선택. Backend가 없을 때 기본값은 mock이다. + TECH_LOG_STUDIO_SOURCE: z.enum(["MOCK", "HTTP"]).default("MOCK"), + }) + .strict() + .superRefine(runtimeConfigArtifactInvariants); +``` + +`src/bootstrap/runtime-config-schema.ts`의 `RuntimeConfig` 타입에 추가: + +```typescript + FEATURE_OVERRIDES: ProductFeatureOverrideMap; + /** §3.5과 같은 결의 런타임 선택. V1 문서는 항상 MOCK으로 정규화된다. */ + TECH_LOG_STUDIO_SOURCE: "MOCK" | "HTTP"; +``` + +같은 파일의 `normalized` 조립에 추가: + +```typescript + FEATURE_OVERRIDES: Object.freeze({ + ...(isV2 ? (parsed as RuntimeConfigV2).FEATURE_OVERRIDES : {}), + }), + TECH_LOG_STUDIO_SOURCE: isV2 + ? (parsed as RuntimeConfigV2).TECH_LOG_STUDIO_SOURCE + : "MOCK", +``` + +`src/contracts/env.ts`의 `ENV_REGISTRY`에 추가: + +```typescript + FEATURE_OVERRIDES: runtime("public", false, null), + // TechLog Studio 어댑터 선택. Backend 미완성 구간의 기본값은 MOCK이다. + TECH_LOG_STUDIO_SOURCE: runtime("public", false, "MOCK"), +``` + +- [ ] **Step 4: 프로필 갱신** + +`config/runtime/local.json`과 `development.json`에 `"TECH_LOG_STUDIO_SOURCE": "MOCK"`을, `staging.json`과 `production.json`에 `"TECH_LOG_STUDIO_SOURCE": "HTTP"`를 `FEATURE_OVERRIDES` 앞에 추가한다. + +- [ ] **Step 5: green 확인** + +Run: `corepack pnpm test:runtime-schema` +Expected: PASS + +- [ ] **Step 6: 합성 테스트 갱신** + +`tests/features/tech-log/runtime-composition.test.ts`의 `installedInputs()`가 새 인자를 넘기도록 고치고, 스위치 테스트를 추가한다: + +```typescript +function installedInputs( + studioSource: "MOCK" | "HTTP" = "MOCK", +): InstalledInputs { + return createInstalledFeatureInputs({ + studioSource, + contractOperations: { + async execute() { + throw new Error("executor is not used by composition tests"); + }, + }, + }); +} + +test("selects the mock gateway by default and the HTTP gateway when switched", () => { + const mockGateway = installedInputs("MOCK")["tech-log"].createStudioGateway(); + const httpGateway = installedInputs("HTTP")["tech-log"].createStudioGateway(); + + // mock은 인메모리 픽스처를 즉시 읽는다. HTTP는 executor를 호출해 실패한다. + assert.doesNotReject(mockGateway.getDashboard()); + assert.rejects(httpGateway.getDashboard()); +}); +``` + +- [ ] **Step 7: 합성 배선 구현** + +`src/features/tech-log/adapters/create-tech-log-feature-input.ts`: + +```typescript +import { + TECH_LOG_FEATURE_ID, + type TechLogFeatureInput, +} from "../application/tech-log-feature-input.ts"; +import { createHttpStudioGateway, type StudioOperationExecutor } from "./http/http-studio-gateway.ts"; +import { createCsrfTokenProvider } from "./http/studio-session-csrf.ts"; +import { createMockStudioGateway } from "./mock/mock-studio-gateway.ts"; +import { publicContentQueries } from "./static/public-query.ts"; + +export type TechLogInstallContext = Readonly<{ + studioSource: "MOCK" | "HTTP"; + contractOperations: StudioOperationExecutor; +}>; + +export function createTechLogFeatureInstalledInput(context: TechLogInstallContext) { + const createStudioGateway = () => { + if (context.studioSource === "MOCK") return createMockStudioGateway(); + // 세션은 gateway 수명과 같다. 새 Studio 세션은 새 CSRF 토큰을 받는다. + const csrf = createCsrfTokenProvider({ + async execute(options) { + const outcome = await context.contractOperations.execute( + "getStudioSession", + {}, + { routeId: "TECH_LOG_STUDIO", ...(options?.signal ? { signal: options.signal } : {}) }, + ); + if (outcome.kind !== "SUCCESS") { + throw new Error("studio session is unavailable"); + } + const value = outcome.value as { csrfToken: string; csrfHeaderName: string }; + return { csrfToken: value.csrfToken, csrfHeaderName: value.csrfHeaderName }; + }, + }); + return createHttpStudioGateway({ operations: context.contractOperations, csrf }); + }; + + const input: TechLogFeatureInput = Object.freeze({ + publicContent: publicContentQueries, + createStudioGateway, + }); + + return Object.freeze({ featureId: TECH_LOG_FEATURE_ID, input }); +} +``` + +`src/features/installed-feature-adapters.ts`의 `createInstalledFeatureInputs` 시그니처에 `studioSource`를 더하고 `createTechLogFeatureInstalledInput(context)`로 넘긴다. + +`src/bootstrap/runtime-adapters.ts:506`의 호출을 고친다: + +```typescript + const featureInputs = createInstalledFeatureInputs({ + contractOperations, + studioSource: config.TECH_LOG_STUDIO_SOURCE, + }); +``` + +`config`는 그 스코프에서 이미 쓰이는 런타임 설정 값이다. 이름이 다르면 같은 스코프의 런타임 설정 식별자를 사용한다. + +- [ ] **Step 8: green과 전체 회귀 확인** + +Run: `corepack pnpm check:types && corepack pnpm test:runtime-schema && corepack pnpm test:tech-log && corepack pnpm test:unit` +Expected: PASS. 기본값이 `MOCK`이므로 기존 Studio 화면 테스트가 전부 그대로 통과해야 한다. + +- [ ] **Step 9: 커밋** + +```bash +git add src/contracts/release-artifacts.ts src/contracts/env.ts \ + src/bootstrap/runtime-config-schema.ts src/bootstrap/runtime-adapters.ts \ + src/features/installed-feature-adapters.ts \ + src/features/tech-log/adapters/create-tech-log-feature-input.ts \ + config/runtime/ tests/runtime-schema/runtime-config.test.ts \ + tests/features/tech-log/runtime-composition.test.ts +git commit -m "feat: select the TechLog Studio adapter from runtime configuration" +``` + +--- + +### Task 6: Asset 포트와 JSON gateway + +**Files:** +- Create: `src/features/tech-log/application/ports/studio-asset-gateway.ts` +- Create: `src/features/tech-log/adapters/http/http-studio-asset-gateway.ts` +- Modify: `src/features/tech-log/contracts/studio/contract.ts` +- Test: `tests/features/tech-log/studio-asset-gateway.test.ts` + +**Interfaces:** +- Consumes: Task 3의 4개 asset operation, Task 2의 오류 매핑·CSRF, Task 4의 `StudioOperationExecutor` +- Produces: + - `contract.ts` 신규 alias: `Asset`, `AssetDetail`, `AssetPage`, `AssetKind`, `AssetManagementStatus`, `AssetUsage`, `UpdateAssetCommand` + - `type ListAssetsQuery = { q?: string; kind?: AssetKind; managementStatus?: AssetManagementStatus; cursor?: string; limit?: number }` + - `type UploadAssetForm = { file: File; kind: AssetKind; altText?: string; decorative?: boolean }` + - `interface StudioAssetGateway { listAssets(query, options?): Promise; uploadAsset(form, options): Promise; getAsset(assetId, options?): Promise; updateAssetMetadata(assetId, command, options): Promise; deleteAsset(assetId, options): Promise }` + - `createHttpStudioAssetGateway(deps: { operations: StudioOperationExecutor; csrf: CsrfTokenProvider; upload: StudioAssetUploadTransport }): StudioAssetGateway` + - `type StudioAssetUploadTransport = { upload(form: UploadAssetForm, headers: Readonly>, options?: { signal?: AbortSignal }): Promise }` — 구현은 Task 7이 제공한다. + +- [ ] **Step 1: 계약 alias 추가** + +`src/features/tech-log/contracts/studio/contract.ts` 끝에 추가: + +```typescript +export type Asset = Schemas["Asset"]; +export type AssetDetail = Schemas["AssetDetail"]; +export type AssetPage = Schemas["AssetPage"]; +export type AssetUsage = Schemas["AssetUsage"]; +export type AssetKind = Schemas["AssetKind"]; +export type AssetManagementStatus = Schemas["AssetManagementStatus"]; +export type UpdateAssetCommand = Schemas["UpdateAssetCommand"]; +export type StudioSession = Schemas["StudioSession"]; +``` + +- [ ] **Step 2: 실패하는 테스트 작성** + +`tests/features/tech-log/studio-asset-gateway.test.ts`: + +```typescript +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { createHttpStudioAssetGateway } from "../../../src/features/tech-log/adapters/http/http-studio-asset-gateway.ts"; +import { createCsrfTokenProvider } from "../../../src/features/tech-log/adapters/http/studio-session-csrf.ts"; +import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts"; + +const READY_ASSET = { + id: "11111111-1111-4111-8111-111111111111", + assetKey: "fetch-strategy-boundary", + kind: "DIAGRAM", + mediaType: "image/svg+xml", + originalFilename: "boundary.svg", + byteSize: 4096, + width: 1080, + height: 420, + altText: "Fetch Join과 Batch Fetch 비교", + decorative: false, + managementStatus: "READY", + publicPath: "/media/fetch-strategy-boundary.svg", + usageCount: 1, + version: 1, + createdAt: "2026-08-14T01:00:00.000Z", + updatedAt: "2026-08-14T01:00:00.000Z", +}; + +function deps(outcomes: Record) { + const calls: { operationId: string; input: unknown }[] = []; + return { + calls, + dependencies: { + operations: { + async execute(operationId: string, input: unknown) { + calls.push({ operationId, input }); + const outcome = outcomes[operationId]; + if (!outcome) throw new Error(`no outcome for ${operationId}`); + return outcome as never; + }, + }, + csrf: createCsrfTokenProvider({ + async execute() { + return { csrfToken: "csrf", csrfHeaderName: "X-CSRF-TOKEN" }; + }, + }), + upload: { + async upload() { + return READY_ASSET as never; + }, + }, + }, + }; +} + +test("lists assets through the canonical operation", async () => { + const { calls, dependencies } = deps({ + listStudioAssets: { + kind: "SUCCESS", + value: { items: [READY_ASSET], nextCursor: null }, + effect: "APPLIED_CONFIRMED", + }, + }); + const gateway = createHttpStudioAssetGateway(dependencies as never); + + const page = await gateway.listAssets({ kind: "DIAGRAM", limit: 20 }); + + assert.equal(page.items.length, 1); + assert.equal(calls[0]!.operationId, "listStudioAssets"); +}); + +test("delegates upload to the transport with CSRF and idempotency headers", async () => { + let received: Record = {}; + const { dependencies } = deps({}); + const gateway = createHttpStudioAssetGateway({ + ...dependencies, + upload: { + async upload(_form: unknown, headers: Record) { + received = headers; + return READY_ASSET as never; + }, + }, + } as never); + + const asset = await gateway.uploadAsset( + { file: new File([""], "boundary.svg", { type: "image/svg+xml" }), kind: "DIAGRAM" }, + { idempotencyKey: "upload-1" }, + ); + + assert.equal(asset.managementStatus, "READY"); + assert.equal(received["X-CSRF-TOKEN"], "csrf"); + assert.equal(received["Idempotency-Key"], "upload-1"); +}); + +test("surfaces ASSET_IN_USE from a rejected delete", async () => { + const { dependencies } = deps({ + deleteStudioAsset: { + kind: "PROBLEM", + problem: { + type: "https://techlog.local/problems/asset-in-use", + title: "ASSET_IN_USE", + status: 409, + detail: "사용 중인 Asset은 삭제할 수 없습니다.", + code: "ASSET_IN_USE", + }, + metadata: { status: 409 }, + effect: "NOT_APPLIED", + }, + }); + const gateway = createHttpStudioAssetGateway(dependencies as never); + + await assert.rejects( + gateway.deleteAsset(READY_ASSET.id, { idempotencyKey: "delete-1" }), + (error: unknown) => { + assert.ok(isStudioGatewayError(error)); + assert.equal(error.code, "ASSET_IN_USE"); + return true; + }, + ); +}); + +test("sends expectedVersion when updating metadata", async () => { + const { calls, dependencies } = deps({ + updateStudioAsset: { + kind: "SUCCESS", + value: { ...READY_ASSET, version: 2, decorative: true, altText: null }, + effect: "APPLIED_CONFIRMED", + }, + }); + const gateway = createHttpStudioAssetGateway(dependencies as never); + + const updated = await gateway.updateAssetMetadata( + READY_ASSET.id, + { expectedVersion: 1, decorative: true, altText: null }, + { idempotencyKey: "update-1" }, + ); + + assert.equal(updated.version, 2); + const input = calls[0]!.input as Record; + assert.equal(input["expectedVersion"], 1); + assert.equal(input["assetId"], READY_ASSET.id); +}); +``` + +- [ ] **Step 3: red 확인** + +Run: `corepack pnpm exec vitest run tests/features/tech-log/studio-asset-gateway.test.ts` +Expected: FAIL — 두 모듈이 없다. + +- [ ] **Step 4: 포트 정의** + +`src/features/tech-log/application/ports/studio-asset-gateway.ts`: + +```typescript +import type { + Asset, + AssetDetail, + AssetKind, + AssetManagementStatus, + AssetPage, + UpdateAssetCommand, +} from "../../contracts/studio/contract.ts"; +import type { IdempotentOptions, RequestOptions } from "./studio-gateway.ts"; + +export type ListAssetsQuery = Readonly<{ + q?: string; + kind?: AssetKind; + managementStatus?: AssetManagementStatus; + cursor?: string; + limit?: number; +}>; + +export type UploadAssetForm = Readonly<{ + file: File; + kind: AssetKind; + altText?: string; + decorative?: boolean; +}>; + +/** + * Asset은 `StudioGateway`와 별도 포트다. 파일 전송과 JSON orchestration은 + * 실패 모델이 다르고, 업로드 구현을 presigned/resumable로 바꿀 때 교체 범위가 + * 이 포트 뒤에서 끝나야 한다. + */ +export interface StudioAssetGateway { + listAssets(query: ListAssetsQuery, options?: RequestOptions): Promise; + uploadAsset(form: UploadAssetForm, options: IdempotentOptions): Promise; + getAsset(assetId: string, options?: RequestOptions): Promise; + updateAssetMetadata( + assetId: string, + command: UpdateAssetCommand, + options: IdempotentOptions, + ): Promise; + deleteAsset(assetId: string, options: IdempotentOptions): Promise; +} +``` + +- [ ] **Step 5: gateway 구현** + +`src/features/tech-log/adapters/http/http-studio-asset-gateway.ts`: + +```typescript +import type { + Asset, + AssetDetail, + AssetPage, + UpdateAssetCommand, +} from "../../contracts/studio/contract.ts"; +import type { + ListAssetsQuery, + StudioAssetGateway, + UploadAssetForm, +} from "../../application/ports/studio-asset-gateway.ts"; +import type { IdempotentOptions, RequestOptions } from "../../application/ports/studio-gateway.ts"; +import { toStudioGatewayError } from "./studio-error-mapping.ts"; +import type { StudioOperationExecutor } from "./http-studio-gateway.ts"; +import type { CsrfTokenProvider } from "./studio-session-csrf.ts"; + +export type StudioAssetUploadTransport = Readonly<{ + upload( + form: UploadAssetForm, + headers: Readonly>, + options?: Readonly<{ signal?: AbortSignal }>, + ): Promise; +}>; + +export type HttpStudioAssetGatewayDependencies = Readonly<{ + operations: StudioOperationExecutor; + csrf: CsrfTokenProvider; + upload: StudioAssetUploadTransport; +}>; + +const ROUTE_ID = "TECH_LOG_STUDIO_ASSETS"; + +export function createHttpStudioAssetGateway( + deps: HttpStudioAssetGatewayDependencies, +): StudioAssetGateway { + async function read(operationId: string, input: unknown, options?: RequestOptions) { + const outcome = await deps.operations.execute(operationId, input, { + routeId: ROUTE_ID, + ...(options?.signal ? { signal: options.signal } : {}), + }); + if (outcome.kind !== "SUCCESS") throw toStudioGatewayError(outcome, operationId); + return outcome.value as T; + } + + async function command(operationId: string, input: unknown, options: IdempotentOptions) { + const csrfToken = await deps.csrf.token( + options.signal ? { signal: options.signal } : undefined, + ); + const outcome = await deps.operations.execute( + operationId, + { ...(input as Record), idempotencyKey: options.idempotencyKey, csrfToken }, + { + routeId: ROUTE_ID, + ...(options.signal ? { signal: options.signal } : {}), + }, + ); + if (outcome.kind === "SUCCESS") return outcome.value as T; + const error = toStudioGatewayError(outcome, operationId); + if (error.code === "AUTHENTICATION_REQUIRED") deps.csrf.invalidate(); + throw error; + } + + return Object.freeze({ + listAssets: (query, options) => read("listStudioAssets", query, options), + getAsset: (assetId, options) => read("getStudioAsset", { assetId }, options), + async uploadAsset(form, options) { + const [token, headerName] = await Promise.all([ + deps.csrf.token(options.signal ? { signal: options.signal } : undefined), + deps.csrf.headerName(options.signal ? { signal: options.signal } : undefined), + ]); + return deps.upload.upload( + form, + Object.freeze({ + [headerName]: token, + "Idempotency-Key": options.idempotencyKey, + }), + options.signal ? { signal: options.signal } : undefined, + ); + }, + updateAssetMetadata: (assetId, cmd: UpdateAssetCommand, options) => + command("updateStudioAsset", { assetId, ...cmd }, options), + async deleteAsset(assetId, options) { + await command("deleteStudioAsset", { assetId }, options); + }, + }) satisfies StudioAssetGateway; +} +``` + +- [ ] **Step 6: green 확인** + +Run: `corepack pnpm exec vitest run tests/features/tech-log/studio-asset-gateway.test.ts` +Expected: 4 tests PASS + +- [ ] **Step 7: 커밋** + +```bash +git add src/features/tech-log/application/ports/studio-asset-gateway.ts \ + src/features/tech-log/adapters/http/http-studio-asset-gateway.ts \ + src/features/tech-log/contracts/studio/contract.ts \ + tests/features/tech-log/studio-asset-gateway.test.ts +git commit -m "feat: add the TechLog Studio asset gateway port and JSON adapter" +``` + +--- + +### Task 7: multipart 업로드 전송 + +플랫폼 계약 런타임은 `requestBody: "NONE" | "JSON"`만 허용하고 저수준 client는 본문을 `JSON.stringify`로 고정한다. 업로드 한 operation만 전용 seam으로 분리한다. + +**Files:** +- Create: `src/features/tech-log/adapters/http/asset-upload-transport.ts` +- Create: `docs/reviews/adapters/06-tech-log-asset-upload.md` +- Test: `tests/features/tech-log/asset-upload-transport.test.ts` + +**Interfaces:** +- Consumes: `StudioAssetUploadTransport`, `UploadAssetForm`, `Asset`(Task 6), `toStudioGatewayError`(Task 2) +- Produces: `createAssetUploadTransport(deps: Readonly<{ baseUrl: string; timeoutMs: number; fetch?: typeof globalThis.fetch }>): StudioAssetUploadTransport` + +- [ ] **Step 1: 실패하는 테스트 작성** + +`tests/features/tech-log/asset-upload-transport.test.ts`: + +```typescript +import assert from "node:assert/strict"; +import { afterAll, afterEach, beforeAll, test } from "vitest"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; + +import { createAssetUploadTransport } from "../../../src/features/tech-log/adapters/http/asset-upload-transport.ts"; +import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts"; + +const BASE = "http://api.test"; +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const transport = () => + createAssetUploadTransport({ baseUrl: `${BASE}/`, timeoutMs: 10_000 }); + +const svg = () => new File([""], "b.svg", { type: "image/svg+xml" }); + +test("posts multipart form data with the supplied headers", async () => { + let seen: { kind: unknown; alt: unknown; csrf: string | null; key: string | null } | null = null; + + server.use( + http.post(`${BASE}/api/v1/studio/assets`, async ({ request }) => { + const form = await request.formData(); + seen = { + kind: form.get("kind"), + alt: form.get("altText"), + csrf: request.headers.get("X-CSRF-TOKEN"), + key: request.headers.get("Idempotency-Key"), + }; + return HttpResponse.json({ id: "a", managementStatus: "READY" }, { status: 201 }); + }), + ); + + const asset = await transport().upload( + { file: svg(), kind: "DIAGRAM", altText: "경계 다이어그램", decorative: false }, + { "X-CSRF-TOKEN": "csrf", "Idempotency-Key": "up-1" }, + ); + + assert.equal((asset as { id: string }).id, "a"); + assert.equal(seen!.kind, "DIAGRAM"); + assert.equal(seen!.alt, "경계 다이어그램"); + assert.equal(seen!.csrf, "csrf"); + assert.equal(seen!.key, "up-1"); +}); + +test("does not set content-type itself so the boundary survives", async () => { + let contentType: string | null = "unset"; + server.use( + http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => { + contentType = request.headers.get("content-type"); + return HttpResponse.json({ id: "a" }, { status: 201 }); + }), + ); + + await transport().upload({ file: svg(), kind: "IMAGE" }, {}); + + assert.ok(contentType?.startsWith("multipart/form-data; boundary=")); +}); + +test("maps 413 onto PAYLOAD_TOO_LARGE", async () => { + server.use( + http.post(`${BASE}/api/v1/studio/assets`, () => + HttpResponse.json( + { + type: "https://techlog.local/problems/payload-too-large", + title: "PAYLOAD_TOO_LARGE", + status: 413, + detail: "파일이 너무 큽니다.", + code: "PAYLOAD_TOO_LARGE", + }, + { status: 413, headers: { "content-type": "application/problem+json" } }, + ), + ), + ); + + await assert.rejects( + transport().upload({ file: svg(), kind: "IMAGE" }, {}), + (error: unknown) => { + assert.ok(isStudioGatewayError(error)); + assert.equal(error.code, "PAYLOAD_TOO_LARGE"); + return true; + }, + ); +}); + +test("maps 415 onto UNSUPPORTED_MEDIA_TYPE", async () => { + server.use( + http.post(`${BASE}/api/v1/studio/assets`, () => + HttpResponse.json( + { + type: "https://techlog.local/problems/unsupported-media-type", + title: "UNSUPPORTED_MEDIA_TYPE", + status: 415, + detail: "지원하지 않는 형식입니다.", + code: "UNSUPPORTED_MEDIA_TYPE", + }, + { status: 415, headers: { "content-type": "application/problem+json" } }, + ), + ), + ); + + await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => { + assert.ok(isStudioGatewayError(error)); + assert.equal(error.code, "UNSUPPORTED_MEDIA_TYPE"); + return true; + }); +}); + +test("maps a network failure onto STUDIO_UNAVAILABLE", async () => { + server.use(http.post(`${BASE}/api/v1/studio/assets`, () => HttpResponse.error())); + + await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => { + assert.ok(isStudioGatewayError(error)); + assert.equal(error.code, "STUDIO_UNAVAILABLE"); + return true; + }); +}); +``` + +- [ ] **Step 2: red 확인** + +Run: `corepack pnpm exec vitest run tests/features/tech-log/asset-upload-transport.test.ts` +Expected: FAIL — 모듈 없음. + +- [ ] **Step 3: 전송 구현** + +`src/features/tech-log/adapters/http/asset-upload-transport.ts`: + +```typescript +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 type { StudioAssetUploadTransport } from "./http-studio-asset-gateway.ts"; +import { STUDIO_ERROR_CODES } from "./studio-error-mapping.ts"; + +const CODES = new Set(STUDIO_ERROR_CODES); + +/** + * 계약 런타임은 `requestBody: "NONE" | "JSON"`만 표현할 수 있고 저수준 client는 + * 본문을 JSON으로 직렬화한다. canonical `POST /assets`는 multipart이므로 이 + * operation만 좁은 seam으로 분리한다. presigned/resumable로 옮기거나 플랫폼에 + * MULTIPART 모드가 생기면 이 파일만 교체한다. + */ +export function createAssetUploadTransport( + deps: Readonly<{ + baseUrl: string; + timeoutMs: number; + fetch?: typeof globalThis.fetch; + }>, +): StudioAssetUploadTransport { + const doFetch = deps.fetch ?? globalThis.fetch.bind(globalThis); + const endpoint = new URL("api/v1/studio/assets", deps.baseUrl).href; + + function unavailable(detail: string): StudioGatewayError { + return new StudioGatewayError({ + type: "https://techlog.local/problems/studio-unavailable", + title: "STUDIO_UNAVAILABLE", + status: 503, + detail, + code: "STUDIO_UNAVAILABLE", + retryable: true, + }); + } + + return Object.freeze({ + async upload(form: UploadAssetForm, headers, options) { + const body = new FormData(); + body.append("file", form.file, form.file.name); + body.append("kind", form.kind); + if (form.altText !== undefined) body.append("altText", form.altText); + if (form.decorative !== undefined) body.append("decorative", String(form.decorative)); + + const timeout = AbortSignal.timeout(deps.timeoutMs); + const signal = options?.signal + ? AbortSignal.any([options.signal, timeout]) + : timeout; + + let response: Response; + try { + // content-type을 직접 넣지 않는다. boundary는 fetch가 만든다. + response = await doFetch(endpoint, { + method: "POST", + headers: { ...headers }, + body, + signal, + credentials: "include", + }); + } catch (error) { + throw unavailable( + error instanceof Error ? `Upload transport failed: ${error.message}` : "Upload transport failed.", + ); + } + + if (response.status === 201 || response.status === 200) { + return (await response.json()) as Asset; + } + + let problem: ProblemDetails | null = null; + try { + problem = (await response.json()) as ProblemDetails; + } catch { + problem = null; + } + if (problem && typeof problem.code === "string" && CODES.has(problem.code)) { + throw new StudioGatewayError(problem); + } + throw unavailable(`Upload returned an uncontracted status ${response.status}.`); + }, + }); +} +``` + +- [ ] **Step 4: green 확인** + +Run: `corepack pnpm exec vitest run tests/features/tech-log/asset-upload-transport.test.ts` +Expected: 5 tests PASS + +- [ ] **Step 5: Asset gateway를 feature input에 배선** + +여기까지는 Asset gateway를 만들 수 있을 뿐 UI가 도달할 수 없다. 이제 두 조각이 다 있으므로 배선한다. + +`src/features/tech-log/application/tech-log-feature-input.ts`: + +```typescript +import type { PublicContentQueries } from "./ports/public-content-queries.ts"; +import type { StudioAssetGateway } from "./ports/studio-asset-gateway.ts"; +import type { StudioGateway } from "./ports/studio-gateway.ts"; + +export const TECH_LOG_FEATURE_ID = "tech-log" as const; + +export type TechLogFeatureInput = Readonly<{ + publicContent: PublicContentQueries; + createStudioGateway(): StudioGateway; + createStudioAssetGateway(): StudioAssetGateway; +}>; +``` + +`create-tech-log-feature-input.ts`의 `TechLogInstallContext`에 `apiBaseUrl: string`과 `requestTimeoutMs: number`를 더하고, gateway 두 개가 **같은 CSRF 제공자를 공유**하도록 세션 생성을 밖으로 끌어낸다. 공유하지 않으면 Studio 세션 하나에 토큰이 두 개 생긴다. + +```typescript +export function createTechLogFeatureInstalledInput(context: TechLogInstallContext) { + function createSession() { + return createCsrfTokenProvider({ + async execute(options) { + const outcome = await context.contractOperations.execute( + "getStudioSession", + {}, + { routeId: "TECH_LOG_STUDIO", ...(options?.signal ? { signal: options.signal } : {}) }, + ); + if (outcome.kind !== "SUCCESS") throw new Error("studio session is unavailable"); + const value = outcome.value as { csrfToken: string; csrfHeaderName: string }; + return { csrfToken: value.csrfToken, csrfHeaderName: value.csrfHeaderName }; + }, + }); + } + + // MOCK에서도 Asset gateway는 필요하다. 백엔드가 없으면 목록이 비고 업로드는 + // 전송 실패로 끝난다 — UI가 그 상태를 표시하는 것이 정상 동작이다. + const createStudioAssetGateway = () => + createHttpStudioAssetGateway({ + operations: context.contractOperations, + csrf: createSession(), + upload: createAssetUploadTransport({ + baseUrl: context.apiBaseUrl, + timeoutMs: context.requestTimeoutMs, + }), + }); + + const createStudioGateway = () => { + if (context.studioSource === "MOCK") return createMockStudioGateway(); + return createHttpStudioGateway({ + operations: context.contractOperations, + csrf: createSession(), + }); + }; + + const input: TechLogFeatureInput = Object.freeze({ + publicContent: publicContentQueries, + createStudioGateway, + createStudioAssetGateway, + }); + + return Object.freeze({ featureId: TECH_LOG_FEATURE_ID, input }); +} +``` + +`src/features/installed-feature-adapters.ts`와 `src/bootstrap/runtime-adapters.ts:506`의 호출에 `apiBaseUrl: config.API_BASE_URL`, `requestTimeoutMs: config.REQUEST_TIMEOUT_MS`를 더한다. + +`src/features/tech-log/presentation/studio/studio-provider.tsx`가 `createStudioGateway`를 lazy state/ref로 한 번만 호출하는 방식과 **동일하게** `createStudioAssetGateway`도 한 번만 호출해 제공하도록 확장한다. bfcache `pageshow`로 provider generation이 바뀔 때 두 gateway가 함께 재생성돼야 한다. + +- [ ] **Step 6: 합성 테스트 갱신과 green 확인** + +`tests/features/tech-log/runtime-composition.test.ts`의 키 단언을 고친다: + +```typescript + assert.deepEqual(Object.keys(installed["tech-log"]).sort(), [ + "createStudioAssetGateway", + "createStudioGateway", + "publicContent", + ]); +``` + +같은 파일에 공유 검증을 추가한다: + +```typescript +test("each Studio session gets its own asset gateway instance", () => { + const installed = installedInputs("HTTP"); + assert.notEqual( + installed["tech-log"].createStudioAssetGateway(), + installed["tech-log"].createStudioAssetGateway(), + ); +}); +``` + +Run: `corepack pnpm check:types:app && corepack pnpm test:tech-log` +Expected: PASS + +- [ ] **Step 7: 어댑터 리뷰 문서 작성** + +`docs/reviews/adapters/06-tech-log-asset-upload.md`에 다음을 기록한다: 우회 대상(`external-contract-runtime.ts`의 `requestBody` 제약, `client.ts:719`의 JSON 고정), 우회 범위(19개 중 `uploadStudioAsset` 1개), 유지되는 보증(CSRF·Idempotency-Key·오류 코드 매핑·타임아웃·credentials), 포기한 보증(계약 런타임의 byte limit·retry policy·진단 계측), 교체 계획(presigned/resumable 또는 플랫폼 MULTIPART 모드 도입 시 이 파일만 교체). `docs/reviews/adapters/INVENTORY.md`에 항목을 추가한다. + +- [ ] **Step 8: 게이트 확인** + +Run: `corepack pnpm check:adapter-inventory && corepack pnpm check:architecture && corepack pnpm check:browser-security` +Expected: PASS + +- [ ] **Step 9: 커밋** + +```bash +git add src/features/tech-log/adapters/http/asset-upload-transport.ts \ + src/features/tech-log/application/tech-log-feature-input.ts \ + src/features/tech-log/adapters/create-tech-log-feature-input.ts \ + src/features/tech-log/presentation/studio/studio-provider.tsx \ + src/features/installed-feature-adapters.ts src/bootstrap/runtime-adapters.ts \ + docs/reviews/adapters/ tests/features/tech-log/ +git commit -m "feat: add the TechLog asset multipart upload transport" +``` + +--- + +### Task 8: evidence alt 규칙을 publish 검증으로 이동 + +parser는 Asset의 `decorative`를 모르므로 빈 alt의 필요 여부를 판단할 수 없다. 규칙을 옮긴다. 이는 content format의 **의미 변경**이므로 픽스처를 함께 갱신한다. + +**Files:** +- Modify: `src/features/tech-log/domain/content-format/parse-case-content.ts:505` +- Modify: `src/features/tech-log/adapters/mock/validate-working-copy.ts:166` +- Test: `tests/features/tech-log/content-format.test.ts` + +**Interfaces:** +- Consumes: 없음 +- Produces: parser는 빈 `alt`를 허용한다. `EVIDENCE_FIGURE.alt`는 여전히 `string`이며 빈 문자열이 될 수 있다. + +- [ ] **Step 1: 실패하는 테스트 추가** + +`tests/features/tech-log/content-format.test.ts`에 추가: + +```typescript +test("parses a decorative evidence figure with an empty alt", () => { + const source = ':::evidence key="fetch-strategy-boundary" alt="" caption="" zoom="false"\n:::\n'; + const parsed = parseCaseContent(source); + + assert.equal(parsed.ok, true); + if (!parsed.ok) return; + const block = parsed.value.blocks.find((item) => item.type === "EVIDENCE_FIGURE"); + assert.ok(block); + assert.equal(block.alt, ""); +}); + +test("round-trips an empty alt without reintroducing it as an error", () => { + const source = ':::evidence key="fetch-strategy-boundary" alt="" caption="" zoom="false"\n:::\n'; + const parsed = parseCaseContent(source); + assert.equal(parsed.ok, true); + if (!parsed.ok) return; + const reparsed = parseCaseContent(serializeCaseContent(parsed.value)); + assert.equal(reparsed.ok, true); +}); + +test("still rejects an unsafe evidence key", () => { + const parsed = parseCaseContent(':::evidence key="../etc" alt="x" caption="" zoom="false"\n:::\n'); + assert.equal(parsed.ok, false); +}); +``` + +기존 테스트 중 "빈 alt가 파싱 오류"를 단언하는 케이스가 있으면 위 의미로 고친다. 삭제하지 말고 반대 단언으로 바꾼다. + +- [ ] **Step 2: red 확인** + +Run: `corepack pnpm exec vitest run tests/features/tech-log/content-format.test.ts` +Expected: FAIL — parser가 빈 alt를 거절한다. + +- [ ] **Step 3: parser 규칙 제거** + +`src/features/tech-log/domain/content-format/parse-case-content.ts:505`의 한 줄을 지우고 주석으로 대체한다: + +```typescript + // alt 필요 여부는 Asset의 `decorative`에 달려 있어 여기서는 판단할 수 + // 없다. 빈 alt는 구문 오류가 아니며 publish 검증이 Asset metadata와 함께 + // 판정한다(설계 §7.7). +``` + +- [ ] **Step 4: publish 검증에 규칙 추가** + +`src/features/tech-log/adapters/mock/validate-working-copy.ts:166` 부근의 evidence 검사에 alt 규칙을 더한다. mock은 Asset metadata를 모르므로 정적 레지스트리를 `decorative: false`로 간주한다: + +```typescript + if (!isSupportedEvidenceKey(block.key)) { + error("EVIDENCE_UNSUPPORTED", "/bodyMarkdown", `지원하지 않는 Evidence: ${block.key}`); + } else if (block.alt.trim().length === 0) { + // 정적 evidence 자산은 장식용이 아니다. Asset 기반 경로에서는 서버가 + // Asset.decorative로 같은 판정을 한다. + error("EVIDENCE_ALT_REQUIRED", "/bodyMarkdown", `Evidence에 대체 텍스트가 필요합니다: ${block.key}`); + } +``` + +- [ ] **Step 5: green 확인** + +Run: `corepack pnpm exec vitest run tests/features/tech-log/content-format.test.ts && corepack pnpm test:tech-log` +Expected: PASS + +- [ ] **Step 6: 커밋** + +```bash +git add src/features/tech-log/domain/content-format/parse-case-content.ts \ + src/features/tech-log/adapters/mock/validate-working-copy.ts \ + tests/features/tech-log/content-format.test.ts +git commit -m "fix: judge evidence alt text with asset metadata, not syntax" +``` + +--- + +### Task 9: Asset 기반 evidence resolver + +렌더러에는 이미 `resolveEvidenceAsset: ResolveEvidenceAsset` 주입점이 6개 지점에 있다(`domain/public-render-content.ts:9`). 새로 만들 것은 렌더러 구조가 아니라 **Backend Asset을 해석하는 resolver 구현**이다. 이 Task 없이 Task 10을 하면 사용자가 삽입한 backend `assetKey`를 `getEvidenceAsset`이 `throw`해 즉시 미리보기가 깨진다. + +canonical `EvidenceFigureBlock`은 `ResolvedAsset`(`assetId`, `assetKey`, `mediaType`, `publicPath`, `width`, `height`, `decorative`)을 블록 안에 담아 보낸다. 따라서 서버가 만든 세 표면(Public Preview·Snapshot·Published)은 블록에서 바로 해석하고, **Instant Preview만** 클라이언트가 적재한 Asset 목록으로 해석한다. + +**Files:** +- Create: `src/features/tech-log/presentation/shared/public-render/asset-resolvers.ts` +- Modify: `src/features/tech-log/presentation/studio/components/instant-preview.tsx:49` +- Modify: `src/features/tech-log/presentation/studio/components/public-preview-screen.tsx:290` +- Modify: `src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx:169` +- Test: `tests/features/tech-log/evidence-asset-resolver.test.tsx` + +**Interfaces:** +- Consumes: `Asset`(Task 6), `ResolveEvidenceAsset`/`EvidenceAsset`(`domain/public-render-content.ts`) +- Produces: + - `createAssetCatalogResolver(assets: readonly Asset[]): ResolveEvidenceAsset` — Instant Preview용. 정적 레지스트리로 폴백한다. + - `createResolvedAssetResolver(blocks: readonly { type: string; key?: string; asset?: ResolvedAssetLike }[]): ResolveEvidenceAsset` — 서버 render model용. + - `MISSING_EVIDENCE_ASSET: EvidenceAsset` — 해석 실패 시의 안전한 자리표시자. + +- [ ] **Step 1: 실패하는 테스트 작성** + +`tests/features/tech-log/evidence-asset-resolver.test.tsx`: + +```typescript +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { + createAssetCatalogResolver, + createResolvedAssetResolver, + MISSING_EVIDENCE_ASSET, +} from "../../../src/features/tech-log/presentation/shared/public-render/asset-resolvers.ts"; + +const READY = { + id: "11111111-1111-4111-8111-111111111111", + assetKey: "boundary", + kind: "DIAGRAM", + mediaType: "image/svg+xml", + originalFilename: "b.svg", + byteSize: 10, + width: 1080, + height: 420, + altText: "경계", + decorative: false, + managementStatus: "READY", + publicPath: "/media/boundary.svg", + usageCount: 0, + version: 1, + createdAt: "2026-08-14T01:00:00.000Z", + updatedAt: "2026-08-14T01:00:00.000Z", +} as never; + +test("resolves a backend asset key to its public path", () => { + const resolve = createAssetCatalogResolver([READY]); + const asset = resolve("boundary"); + + assert.equal(asset.src, "/media/boundary.svg"); + assert.equal(asset.width, 1080); + assert.equal(asset.height, 420); +}); + +test("falls back to the static registry for the legacy hardcoded key", () => { + const resolve = createAssetCatalogResolver([]); + const asset = resolve("fetch-strategy-boundary"); + + assert.equal(asset.src, "/media/fetch-strategy-boundary.svg"); +}); + +test("returns the placeholder instead of throwing on an unknown key", () => { + const resolve = createAssetCatalogResolver([]); + assert.deepEqual(resolve("does-not-exist"), MISSING_EVIDENCE_ASSET); +}); + +test("never resolves a QUARANTINED asset", () => { + const resolve = createAssetCatalogResolver([ + { ...(READY as object), managementStatus: "QUARANTINED" } as never, + ]); + assert.deepEqual(resolve("boundary"), MISSING_EVIDENCE_ASSET); +}); + +test("resolves from the server render model blocks", () => { + const resolve = createResolvedAssetResolver([ + { + type: "EVIDENCE_FIGURE", + key: "boundary", + asset: { + assetId: "11111111-1111-4111-8111-111111111111", + assetKey: "boundary", + mediaType: "image/svg+xml", + publicPath: "/media/boundary.svg", + width: 800, + height: 300, + decorative: false, + }, + }, + ]); + + assert.equal(resolve("boundary").src, "/media/boundary.svg"); + assert.equal(resolve("boundary").width, 800); +}); +``` + +- [ ] **Step 2: red 확인** + +Run: `corepack pnpm exec vitest run tests/features/tech-log/evidence-asset-resolver.test.tsx` +Expected: FAIL — 모듈 없음. + +- [ ] **Step 3: resolver 구현** + +`src/features/tech-log/presentation/shared/public-render/asset-resolvers.ts`: + +```typescript +import { + getEvidenceAsset, + isSupportedEvidenceKey, +} from "../../../adapters/static/evidence-assets.ts"; +import type { Asset } from "../../../contracts/studio/contract.ts"; +import type { + EvidenceAsset, + ResolveEvidenceAsset, +} from "../../../domain/public-render-content.ts"; + +export type ResolvedAssetLike = Readonly<{ + assetKey: string; + mediaType: string; + publicPath: string; + width: number | null; + height: number | null; + decorative: boolean; +}>; + +/** + * `ResolveEvidenceAsset`은 total 함수다. 해석 실패에 throw하면 편집 중인 문서 + * 하나가 화면 전체를 무너뜨리므로 자리표시자를 돌려준다. 게시 차단은 publish + * 검증이 담당한다. + */ +export const MISSING_EVIDENCE_ASSET: EvidenceAsset = Object.freeze({ + src: "", + width: 1, + height: 1, + triggerLabel: "사용할 수 없는 Evidence", + dialogLabel: "사용할 수 없는 Evidence", +}); + +function fromDescriptor(descriptor: ResolvedAssetLike): EvidenceAsset { + const label = `${descriptor.assetKey} 이미지 크게 보기`; + return Object.freeze({ + src: descriptor.publicPath, + width: descriptor.width ?? 1, + height: descriptor.height ?? 1, + triggerLabel: label, + dialogLabel: `${descriptor.assetKey} 확대`, + }); +} + +function staticFallback(key: string): EvidenceAsset { + return isSupportedEvidenceKey(key) ? getEvidenceAsset(key) : MISSING_EVIDENCE_ASSET; +} + +/** Instant Preview: 편집기가 적재한 Asset 목록으로 해석한다. */ +export function createAssetCatalogResolver( + assets: readonly Asset[], +): ResolveEvidenceAsset { + const byKey = new Map(); + for (const asset of assets) { + // QUARANTINED/REJECTED는 어떤 표면에도 렌더링하지 않는다. + if (asset.managementStatus !== "READY") continue; + byKey.set(asset.assetKey, asset); + } + + return (key: string) => { + const asset = byKey.get(key); + if (!asset || asset.publicPath === null) return staticFallback(key); + return fromDescriptor({ + assetKey: asset.assetKey, + mediaType: asset.mediaType, + publicPath: asset.publicPath, + width: asset.width, + height: asset.height, + decorative: asset.decorative, + }); + }; +} + +/** Public Preview·Snapshot·Published: 서버가 블록에 실어 보낸 descriptor를 쓴다. */ +export function createResolvedAssetResolver( + blocks: readonly Readonly<{ type: string; key?: string; asset?: ResolvedAssetLike }>[], +): ResolveEvidenceAsset { + const byKey = new Map(); + for (const block of blocks) { + if (block.type !== "EVIDENCE_FIGURE" || !block.asset) continue; + byKey.set(block.asset.assetKey, block.asset); + } + + return (key: string) => { + const descriptor = byKey.get(key); + return descriptor ? fromDescriptor(descriptor) : staticFallback(key); + }; +} +``` + +- [ ] **Step 4: 주입 지점 교체** + +`public-preview-screen.tsx:290`과 `publication-event-preview-screen.tsx:169`의 resolver를 `createResolvedAssetResolver(renderModel.bodyBlocks)`로 바꾼다. `renderModel`에 `bodyBlocks`가 없는 kind(REFERENCE/QUESTION/PROJECT_DECISION)는 빈 배열을 넘긴다. + +`instant-preview.tsx:49`는 `createAssetCatalogResolver(assets)`를 쓴다. `assets`는 Task 10에서 Asset Picker가 적재하는 목록과 같은 출처여야 하므로, Studio provider가 보유한 Asset 목록 상태를 props로 받는다. Task 10 이전에는 빈 배열을 넘겨도 정적 폴백으로 기존 동작이 유지된다. + +기존 `resolvePreviewEvidenceAsset`, `resolvePublicEvidenceAsset`, `missingQuestionEvidence`, `missingReferenceEvidence`는 그대로 둔다. Public 화면(`case-document-page.tsx` 등)은 이 사이클에서 정적 경로를 유지한다. + +- [ ] **Step 5: green 확인** + +Run: `corepack pnpm exec vitest run tests/features/tech-log/evidence-asset-resolver.test.tsx && corepack pnpm test:tech-log` +Expected: PASS. 기존 preview·snapshot 화면 테스트가 그대로 통과해야 한다 — 정적 키는 폴백으로 같은 결과를 낸다. + +- [ ] **Step 6: 커밋** + +```bash +git add src/features/tech-log/presentation/shared/public-render/asset-resolvers.ts \ + src/features/tech-log/presentation/studio/components/ \ + tests/features/tech-log/evidence-asset-resolver.test.tsx +git commit -m "feat: resolve evidence figures from backend asset descriptors" +``` + +--- + +### Task 10: Asset Picker와 evidence directive 삽입 + +편집 흐름의 기본 진입점이다. Studio primary navigation은 변경하지 않는다. + +**Files:** +- Create: `src/features/tech-log/presentation/studio/components/asset-picker.tsx` +- Create: `src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx` +- Modify: `src/features/tech-log/presentation/studio/components/case-fields.tsx` +- Test: `tests/features/tech-log/asset-picker.test.tsx` + +**Interfaces:** +- Consumes: `StudioAssetGateway`(Task 6) +- Produces: + - `buildEvidenceDirective(input: Readonly<{ assetKey: string; alt: string; caption: string; zoom: boolean }>): string` + - ` void} />` + - ` void} onClose={() => void} />` + +- [ ] **Step 1: 실패하는 테스트 작성** + +`tests/features/tech-log/asset-picker.test.tsx`: + +```tsx +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { + AssetPicker, + buildEvidenceDirective, +} from "../../../src/features/tech-log/presentation/studio/components/asset-picker.tsx"; +import type { StudioAssetGateway } from "../../../src/features/tech-log/application/ports/studio-asset-gateway.ts"; + +const READY = { + id: "11111111-1111-4111-8111-111111111111", + assetKey: "fetch-strategy-boundary", + kind: "DIAGRAM", + mediaType: "image/svg+xml", + originalFilename: "boundary.svg", + byteSize: 4096, + width: 1080, + height: 420, + altText: "Fetch Join 경계", + decorative: false, + managementStatus: "READY", + publicPath: "/media/fetch-strategy-boundary.svg", + usageCount: 1, + version: 1, + createdAt: "2026-08-14T01:00:00.000Z", + updatedAt: "2026-08-14T01:00:00.000Z", +}; + +const QUARANTINED = { ...READY, id: "22222222-2222-4222-8222-222222222222", assetKey: "unsafe", managementStatus: "QUARANTINED" }; + +function gatewayOf(items: unknown[]): StudioAssetGateway { + return { + async listAssets() { + return { items, nextCursor: null } as never; + }, + async uploadAsset() { + throw new Error("not used"); + }, + async getAsset() { + throw new Error("not used"); + }, + async updateAssetMetadata() { + throw new Error("not used"); + }, + async deleteAsset() {}, + }; +} + +test("builds the evidence directive with escaped attribute values", () => { + assert.equal( + buildEvidenceDirective({ + assetKey: "fetch-strategy-boundary", + alt: "Fetch Join 경계", + caption: "그림 1", + zoom: true, + }), + ':::evidence key="fetch-strategy-boundary" alt="Fetch Join 경계" caption="그림 1" zoom="true"\n:::', + ); +}); + +test("inserts the directive for the chosen asset", async () => { + const user = userEvent.setup(); + const inserted: string[] = []; + render( inserted.push(value)} />); + + await user.click(await screen.findByRole("button", { name: /fetch-strategy-boundary/ })); + + assert.equal(inserted.length, 1); + assert.ok(inserted[0]!.includes('key="fetch-strategy-boundary"')); + assert.ok(inserted[0]!.startsWith(":::evidence ")); +}); + +test("does not offer a QUARANTINED asset for insertion", async () => { + render( {}} />); + + await screen.findByRole("button", { name: /fetch-strategy-boundary/ }); + assert.equal(screen.queryByRole("button", { name: /unsafe/ }), null); +}); +``` + +- [ ] **Step 2: red 확인** + +Run: `corepack pnpm exec vitest run tests/features/tech-log/asset-picker.test.tsx` +Expected: FAIL — 컴포넌트 없음. + +- [ ] **Step 3: directive 빌더와 picker 구현** + +`asset-picker.tsx`. directive 속성값은 `"`를 포함할 수 없다 — 파서가 속성을 따옴표로 구분하기 때문이다. 값에서 `"`를 제거하고 개행을 공백으로 접는다. + +```tsx +import { useEffect, useState } from "react"; + +import type { Asset } from "../../../contracts/studio/contract.ts"; +import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts"; + +function attributeValue(raw: string): string { + return raw.replaceAll('"', "").replace(/\s+/gu, " ").trim(); +} + +export function buildEvidenceDirective( + input: Readonly<{ assetKey: string; alt: string; caption: string; zoom: boolean }>, +): string { + const key = attributeValue(input.assetKey); + const alt = attributeValue(input.alt); + const caption = attributeValue(input.caption); + return `:::evidence key="${key}" alt="${alt}" caption="${caption}" zoom="${input.zoom}"\n:::`; +} + +export function AssetPicker(props: Readonly<{ + gateway: StudioAssetGateway; + onInsert: (directive: string) => void; +}>) { + const [assets, setAssets] = useState([]); + const [failed, setFailed] = useState(false); + + useEffect(() => { + const controller = new AbortController(); + props.gateway + .listAssets({ managementStatus: "READY", limit: 50 }, { signal: controller.signal }) + .then((page) => setAssets(page.items)) + .catch(() => setFailed(true)); + return () => controller.abort(); + }, [props.gateway]); + + // READY만 삽입 후보다. 서버 필터를 신뢰하되 방어적으로 한 번 더 거른다. + const selectable = assets.filter((asset) => asset.managementStatus === "READY"); + + if (failed) return

Asset 목록을 불러오지 못했습니다.

; + + return
+
    + {selectable.map((asset) =>
  • + +
  • )} +
+
; +} +``` + +- [ ] **Step 4: 업로드 상태 기계 구현** + +업로드 상태는 설계 §7.8이 요구하는 8가지를 구분한다. 상태 판정은 순수 함수로 분리해 dialog와 독립적으로 테스트한다. + +`asset-upload-dialog.tsx`: + +```tsx +import { useRef, useState } from "react"; + +import type { Asset, AssetKind } from "../../../contracts/studio/contract.ts"; +import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts"; +import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts"; + +export type UploadState = + | { kind: "IDLE" } + | { kind: "SELECTION_FAILED"; message: string } + | { kind: "UPLOADING" } + | { kind: "TRANSPORT_FAILED"; message: string } + | { kind: "TOO_LARGE" } + | { kind: "UNSUPPORTED_TYPE" } + | { kind: "READY"; asset: Asset } + | { kind: "REJECTED"; asset: Asset } + | { kind: "QUARANTINED"; asset: Asset }; + +/** 업로드 전송 성공과 서버 검증 성공은 다르다. 성공 응답도 상태로 나눈다. */ +export function stateForUploaded(asset: Asset): UploadState { + switch (asset.managementStatus) { + case "READY": + return { kind: "READY", asset }; + case "QUARANTINED": + return { kind: "QUARANTINED", asset }; + case "REJECTED": + case "ARCHIVED": + return { kind: "REJECTED", asset }; + } +} + +export function stateForError(error: unknown): UploadState { + if (isStudioGatewayError(error)) { + if (error.code === "PAYLOAD_TOO_LARGE") return { kind: "TOO_LARGE" }; + if (error.code === "UNSUPPORTED_MEDIA_TYPE") return { kind: "UNSUPPORTED_TYPE" }; + return { kind: "TRANSPORT_FAILED", message: error.problem.detail }; + } + return { kind: "TRANSPORT_FAILED", message: "업로드를 전송하지 못했습니다." }; +} + +const MESSAGES: Record = { + IDLE: "", + SELECTION_FAILED: "파일을 선택하지 못했습니다.", + UPLOADING: "업로드 중입니다.", + TRANSPORT_FAILED: "업로드를 전송하지 못했습니다.", + TOO_LARGE: "파일 크기가 허용 범위를 넘었습니다.", + UNSUPPORTED_TYPE: "지원하지 않는 파일 형식입니다.", + READY: "업로드했습니다.", + REJECTED: "서버 검증에서 거절되어 사용할 수 없습니다.", + QUARANTINED: "보안 검사에서 격리되어 사용할 수 없습니다.", +}; + +export function AssetUploadDialog(props: Readonly<{ + gateway: StudioAssetGateway; + kind: AssetKind; + idempotencyKey: string; + onUploaded: (asset: Asset) => void; + onClose: () => void; +}>) { + const [state, setState] = useState({ kind: "IDLE" }); + const inputRef = useRef(null); + + async function submit(file: File) { + setState({ kind: "UPLOADING" }); + try { + const asset = await props.gateway.uploadAsset( + { file, kind: props.kind }, + { idempotencyKey: props.idempotencyKey }, + ); + const next = stateForUploaded(asset); + setState(next); + if (next.kind === "READY") props.onUploaded(asset); + } catch (error) { + setState(stateForError(error)); + } + } + + return
+ { + const file = event.currentTarget.files?.[0]; + if (!file) { + setState({ kind: "SELECTION_FAILED", message: MESSAGES.SELECTION_FAILED }); + return; + } + void submit(file); + }} + /> +

{MESSAGES[state.kind]}

+ +
; +} +``` + +`unpublish-dialog.tsx`의 focus trap·복귀 패턴과 클래스 구성을 그대로 따라 dialog 껍데기를 맞춘다. + +- [ ] **Step 5: 편집기에 연결** + +`case-fields.tsx`의 `bodyMarkdown` textarea 옆에 Picker 진입 버튼을 둔다. 삽입은 커서 위치를 보존한다: + +```tsx +function insertAtCursor( + textarea: HTMLTextAreaElement, + directive: string, + commit: (next: string) => void, +) { + const { selectionStart, selectionEnd, value } = textarea; + const prefix = value.slice(0, selectionStart); + const suffix = value.slice(selectionEnd); + // directive는 블록이므로 앞뒤 빈 줄을 보장한다. + const before = prefix.length === 0 || prefix.endsWith("\n\n") ? prefix : `${prefix}\n\n`; + const after = suffix.startsWith("\n") ? suffix : `\n${suffix}`; + commit(`${before}${directive}${after}`); +} +``` + +다른 필드와 레이아웃 구조는 바꾸지 않는다. Picker와 Upload dialog는 `bodyMarkdown`을 가진 CASE 편집기에만 붙인다. + +- [ ] **Step 6: green 확인** + +Run: `corepack pnpm exec vitest run tests/features/tech-log/asset-picker.test.tsx && corepack pnpm test:tech-log` +Expected: PASS + +- [ ] **Step 7: 커밋** + +```bash +git add src/features/tech-log/presentation/studio/components/ \ + tests/features/tech-log/asset-picker.test.tsx +git commit -m "feat: insert evidence directives from the TechLog asset picker" +``` + +--- + +### Task 11: Asset Library 화면과 라우트 + +**Files:** +- Create: `src/features/tech-log/presentation/studio/pages/assets-page.tsx` +- Create: `src/features/tech-log/presentation/studio/components/asset-library.tsx` +- Modify: `src/features/tech-log/contracts/tech-log-route-contract.ts` +- Modify: `src/features/tech-log/presentation/tech-log-route-runtime.tsx` +- Test: `tests/features/tech-log/route-contract.test.ts`, `tests/features/tech-log/asset-library.test.tsx` + +**Interfaces:** +- Consumes: `StudioAssetGateway`(Task 6) +- Produces: 라우트 `TECH_LOG_STUDIO_ASSETS` — `path: "/studio/assets"`, `layoutGroup: "STUDIO"`, `paramsSchema: null`, `searchSchema: null`, `title: "Asset"`, `navigationLabel: null`, `navigationOrder: null` + +`navigationLabel`을 `null`로 두는 것이 핵심이다. Asset은 secondary utility이며 primary navigation(`작업본 · 게시 기록 · 새 문서`)을 CMS 구조로 바꾸지 않는다. + +- [ ] **Step 1: 라우트 테스트 갱신** + +`tests/features/tech-log/route-contract.test.ts`에서 기대 라우트 수를 27에서 28로 올리고 새 항목을 단언한다: + +```typescript +test("registers the studio asset library outside primary navigation", () => { + const route = TECH_LOG_ROUTE_DEFINITIONS.find( + (item) => item.routeId === "TECH_LOG_STUDIO_ASSETS", + ); + + assert.ok(route); + assert.equal(route.path, "/studio/assets"); + assert.equal(route.layoutGroup, "STUDIO"); + assert.equal(route.navigationLabel, null); + assert.equal(route.navigationOrder, null); +}); +``` + +기존 "정확히 27개" 단언이 있으면 28로 고친다. `/studio/*` catch-all보다 앞에 선언돼야 한다. + +- [ ] **Step 2: red 확인** + +Run: `corepack pnpm exec vitest run tests/features/tech-log/route-contract.test.ts` +Expected: FAIL + +- [ ] **Step 3: 라우트 추가** + +`tech-log-route-contract.ts`의 `TECH_LOG_ROUTE_SPECS`에서 `TECH_LOG_STUDIO_PUBLICATION_PREVIEW` 뒤, `TECH_LOG_STUDIO_NOT_FOUND` 앞에 추가한다: + +```typescript + defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }), +``` + +`tech-log-route-runtime.tsx`에 `TECH_LOG_STUDIO_ASSETS → ` 매핑을 더한다. + +- [ ] **Step 4: 화면 구현** + +삭제 가능 여부 판정을 순수 함수로 분리한다 — 이것이 이 화면의 유일한 규칙이다. + +`asset-library.tsx`: + +```tsx +import { useEffect, useState } from "react"; + +import type { Asset, AssetDetail } from "../../../contracts/studio/contract.ts"; +import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts"; +import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts"; + +/** + * 공개 이력이 있거나 사용 중인 Asset은 hard delete하지 않는다. 서버도 같은 + * 규칙으로 `ASSET_IN_USE`를 던지므로 화면은 시도 자체를 막아 왕복을 줄인다. + */ +export function canHardDelete(detail: AssetDetail): boolean { + return !detail.hasPublicationHistory && detail.usages.length === 0 && + detail.asset.usageCount === 0; +} + +export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) { + const [assets, setAssets] = useState([]); + const [selected, setSelected] = useState(null); + const [notice, setNotice] = useState(""); + + useEffect(() => { + const controller = new AbortController(); + props.gateway + .listAssets({ limit: 50 }, { signal: controller.signal }) + .then((page) => setAssets(page.items)) + .catch(() => setNotice("Asset 목록을 불러오지 못했습니다.")); + return () => controller.abort(); + }, [props.gateway]); + + async function archive(detail: AssetDetail) { + try { + await props.gateway.updateAssetMetadata( + detail.asset.id, + { expectedVersion: detail.asset.version, managementStatus: "ARCHIVED" }, + { idempotencyKey: `archive-${detail.asset.id}-${detail.asset.version}` }, + ); + setNotice("보관했습니다."); + } catch (error) { + setNotice(isStudioGatewayError(error) ? error.problem.detail : "보관하지 못했습니다."); + } + } + + async function remove(detail: AssetDetail) { + try { + await props.gateway.deleteAsset(detail.asset.id, { + idempotencyKey: `delete-${detail.asset.id}-${detail.asset.version}`, + }); + setAssets((current) => current.filter((item) => item.id !== detail.asset.id)); + setNotice("삭제했습니다."); + } catch (error) { + setNotice(isStudioGatewayError(error) ? error.problem.detail : "삭제하지 못했습니다."); + } + } + + return
+

{notice}

+
    + {assets.map((asset) =>
  • + + {asset.managementStatus} + 사용 {asset.usageCount}건 +
  • )} +
+ {selected ?
+

{selected.asset.assetKey}

+
    + {selected.usages.map((usage) =>
  • {usage.title}
  • )} +
+ {canHardDelete(selected) + ? + : } +
: null} +
; +} +``` + +`assets-page.tsx`는 Studio shell 안에서 `AssetLibrary`에 `createStudioAssetGateway()` 결과를 넘겨 렌더링한다. gateway는 Studio provider가 보유한 인스턴스를 쓰고 화면에서 새로 만들지 않는다. + +- [ ] **Step 5: 화면 테스트 작성과 green 확인** + +`tests/features/tech-log/asset-library.test.tsx`: + +```tsx +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { + AssetLibrary, + canHardDelete, +} from "../../../src/features/tech-log/presentation/studio/components/asset-library.tsx"; +import { StudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts"; + +const ASSET = { + id: "11111111-1111-4111-8111-111111111111", + assetKey: "boundary", + kind: "DIAGRAM", + mediaType: "image/svg+xml", + originalFilename: "b.svg", + byteSize: 10, + width: 1080, + height: 420, + altText: "경계", + decorative: false, + managementStatus: "READY", + publicPath: "/media/boundary.svg", + usageCount: 0, + version: 1, + createdAt: "2026-08-14T01:00:00.000Z", + updatedAt: "2026-08-14T01:00:00.000Z", +} as never; + +function gatewayOf(detail: unknown, onDelete?: () => never) { + return { + async listAssets() { + return { items: [ASSET], nextCursor: null } as never; + }, + async getAsset() { + return detail as never; + }, + async uploadAsset() { + throw new Error("not used"); + }, + async updateAssetMetadata() { + return ASSET; + }, + async deleteAsset() { + if (onDelete) onDelete(); + }, + } as never; +} + +test("offers hard delete only for an unused asset with no publication history", () => { + assert.equal( + canHardDelete({ asset: ASSET, usages: [], hasPublicationHistory: false } as never), + true, + ); + assert.equal( + canHardDelete({ asset: ASSET, usages: [], hasPublicationHistory: true } as never), + false, + ); + assert.equal( + canHardDelete({ + asset: ASSET, + usages: [{ documentId: "d", documentKind: "CASE", title: "문서", published: true }], + hasPublicationHistory: false, + } as never), + false, + ); +}); + +test("shows archive instead of delete for an asset in use", async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole("button", { name: "boundary" })); + + assert.ok(await screen.findByRole("button", { name: "보관" })); + assert.equal(screen.queryByRole("button", { name: "삭제" }), null); +}); + +test("surfaces ASSET_IN_USE when the server rejects a delete", async () => { + const user = userEvent.setup(); + render( { + throw new StudioGatewayError({ + type: "https://techlog.local/problems/asset-in-use", + title: "ASSET_IN_USE", + status: 409, + detail: "사용 중인 Asset은 삭제할 수 없습니다.", + code: "ASSET_IN_USE", + }); + }, + )} />); + + await user.click(await screen.findByRole("button", { name: "boundary" })); + await user.click(await screen.findByRole("button", { name: "삭제" })); + + assert.ok(await screen.findByText("사용 중인 Asset은 삭제할 수 없습니다.")); +}); +``` + +Run: `corepack pnpm exec vitest run tests/features/tech-log/route-contract.test.ts tests/features/tech-log/asset-library.test.tsx` +Expected: PASS + +- [ ] **Step 6: 라우트 거버넌스 기준선 갱신** + +Run: `corepack pnpm check:registries:structure && corepack pnpm check:registries` +기준선 불일치가 나오면 라우트 추가에 맞춰 승인된 기준선을 갱신하고 다시 실행한다. + +- [ ] **Step 7: 커밋** + +```bash +git add src/features/tech-log/presentation/ src/features/tech-log/contracts/tech-log-route-contract.ts \ + tests/features/tech-log/ config/ +git commit -m "feat: add the TechLog Studio asset library route and screen" +``` + +--- + +### Task 12: 전체 게이트와 정합 증거 + +**Files:** +- Modify: `docs/superpowers/specs/2026-08-17-techlog-backend-alignment-design.md` (완료 상태 기록) +- Modify: `README.md` (스위치 사용법) +- Test: 전체 스위트 + +- [ ] **Step 1: 계약 parity 최종 확인** + +Run: `corepack pnpm check:tech-log-contract` +Expected: `tech-log contract is in sync with the canonical source.` + +- [ ] **Step 2: 전체 테스트** + +Run: `corepack pnpm test:all && corepack pnpm test:coverage` +Expected: PASS. 실패한 항목이 있으면 그 Task로 돌아가 고친다. 통과 여부를 추정하지 않는다. + +- [ ] **Step 3: 정적 게이트** + +Run: + +```bash +corepack pnpm lint && corepack pnpm check:types && corepack pnpm check:architecture \ + && corepack pnpm check:design-system && corepack pnpm check:i18n \ + && corepack pnpm check:adapter-inventory && corepack pnpm check:registries \ + && corepack pnpm scan:security && corepack pnpm check:browser-security +``` + +Expected: 전부 PASS + +- [ ] **Step 4: Studio 흐름과 렌더러 불변 회귀 확인** + +Run: `corepack pnpm test:e2e -- --grep "tech-log-studio"` +Expected: PASS. 기본 스위치가 `MOCK`이므로 기존 Studio 워크플로가 변경 없이 통과해야 한다. + +렌더러 불변(설계 §9)을 확인한다. 같은 문서에 대해 `Instant Preview`, `Public Preview`, `Published Public`, `Publication Snapshot` 네 화면이 같은 semantic output을 내야 한다. 네 화면 모두 같은 `case-body-renderer.tsx`를 쓰고 Task 9가 resolver를 통일했으므로, 기존 preview·snapshot·public 화면 테스트가 전부 통과하면 성립한다. 실패하는 화면이 있으면 resolver 주입 지점이 어긋난 것이므로 Task 9로 돌아간다. + +Run: `corepack pnpm exec vitest run tests/features/tech-log/studio-validation-preview.test.tsx tests/features/tech-log/studio-publication-flow.test.tsx tests/features/tech-log/public-document-screens.test.tsx` +Expected: PASS + +- [ ] **Step 5: HTTP 스위치 수동 확인** + +`config/runtime/local.json`의 `TECH_LOG_STUDIO_SOURCE`를 임시로 `HTTP`로 바꾸고 `corepack pnpm dev`를 띄운다. `/studio`가 백엔드 부재로 오류 상태를 표시하되 화이트스크린이나 처리되지 않은 예외가 없어야 한다. 확인 후 `MOCK`으로 되돌린다. + +- [ ] **Step 6: 문서 갱신** + +`README.md`에 `TECH_LOG_STUDIO_SOURCE` 스위치와 `generate:tech-log-contract` / `check:tech-log-contract` 사용법을 추가한다. spec 문서 상단 `## 상태`에 완료 Task와 미완료 항목(실서버 대조, Public HTTP 전환)을 기록한다. + +- [ ] **Step 7: 커밋** + +```bash +git add README.md docs/superpowers/specs/2026-08-17-techlog-backend-alignment-design.md +git commit -m "docs: record TechLog backend alignment completion state" +``` + +--- + +## 완료 확인 + +계획 종료 시 다음이 모두 성립해야 한다. + +1. `check:tech-log-contract`가 canonical drift를 잡는다. +2. canonical 19개 operation이 전부 덮인다 — 18개는 계약 기여, 1개는 업로드 seam. +3. `StudioGateway` 13개 메서드가 HTTP 어댑터로 동작하고 MSW로 검증된다. +4. 23개 오류 코드가 매핑되고 `VERSION_CONFLICT`와 `IDEMPOTENCY_KEY_REUSED`가 구분된다. +5. Asset을 업로드·조회·수정·보관·삭제할 수 있고 `READY`만 삽입 후보다. +6. `QUARANTINED` Asset이 picker에도 렌더러에도 나타나지 않는다. +7. Backend `assetKey`가 네 렌더 표면에서 전부 해석되고, 미해석 키가 화면을 무너뜨리지 않는다. +8. evidence alt 판정이 parser에서 publish 검증으로 옮겨졌다. +9. `createStudioAssetGateway`가 feature input에 배선돼 UI가 Asset 포트에 도달한다. +10. `/studio/assets`가 primary navigation을 바꾸지 않고 추가됐다. +11. 기본 `MOCK`에서 기존 Public·Studio parity 스위트가 전부 통과한다. +12. `main`이 아닌 `feature/techlog-backend-alignment`에 커밋돼 있다.