# 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`, operation 19개. - **digest와 revision은 생성 시점에 `canonical-source.json`에 기록한다. 계획서나 테스트에 값을 박지 않는다.** canonical 저장소는 활발히 편집 중이다(2026-08-17 하루에만 두 번 변경, 마지막은 description 전용이라 구조 영향 없음). 고정 값을 박으면 계약이 그대로인데도 테스트가 깨진다. - 생성기는 저장소 의존성에 넣지 않는다. `openapi-typescript`는 TypeScript 5의 classic compiler API를 요구하고 이 저장소는 TypeScript `7.0.2`를 고정한다(VD-01). TS7 루트 export는 `{version, versionMajorMinor}`뿐이라 compiler API가 없다. 격리된 `pnpm dlx`로 실행한다. - 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/contracts/rest-profiles.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`, `src/adapters/http/http-execution-v3.ts`는 **수정하지 않는다**(template 동기화 대상). - **인증·CSRF·Idempotency는 플랫폼 seam을 쓴다. 직접 헤더를 만들지 않는다.** 계약의 `projectRequest`는 `pathValues`/`queryEntries`/`body`만 만들 수 있고 헤더 채널이 없다(`external-contract-runtime.ts`의 `HttpRequestProjection`). 헤더는 두 경로로만 들어간다. - `Idempotency-Key`: 실행 context의 `intent.idempotencyKey`에서 온다(`http-execution-v3.ts:689-690`). 계약 소유 헤더이므로 credential 쪽에서 공급하면 거절된다(`:657-661`). 명령 gateway는 `intent`를 넘기고 **입력 본문에 `idempotencyKey`를 넣지 않는다**. - `x-csrf-token`: `attachCredentials` collaborator가 공급하고 `admitCredentialHeaders`가 auth profile의 허용 목록으로 검사한다(`http-execution-v3.ts:435, :672`). gateway 입력에 `csrfToken`을 넣지 않는다. - 계약 기여의 `packageId`는 **npm scope 형식**이어야 한다: `PACKAGE_ID = /^@[a-z0-9][a-z0-9._-]{0,62}\/[a-z0-9][a-z0-9._-]{0,62}$/`(`external-contract-runtime.ts:218-219`). scope 없는 이름은 composition 시점에 거절된다. 이 계약의 값은 `@tech-log/studio-contract`다 — 플랫폼이 기여를 게시된 패키지로 모델링하기 때문이며, 실제 배포로 승격할 때 같은 이름을 쓴다. - `frontend.authProfileId`는 `INSTALLED_REST_AUTH_PROFILES`에 등록된 값이어야 한다. 없는 값이면 **composition이 실패한다**(`external-contract-runtime.ts:325-326`). 등록은 Task 3 Step 1이 한다. - `frontend.responseByteLimit`은 **1 이상**이어야 하고(`external-contract-runtime.ts:336-337`) `hardResponseBytes: 8_388_608`을 넘을 수 없다. `requestByteLimit`은 0 이상 `hardRequestBytes: 1_048_576` 이하다. - 매 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"); // revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로 // 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다. assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/); 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`. 두 모드는 **의존성이 다르다**. 생성은 canonical 저장소와 네트워크가 필요하고, 검증은 저장소 안의 파일만 읽는다 — CI에는 canonical 사본도 생성 도구도 없기 때문이다. ```typescript /** * canonical studio-v1.yaml을 vendor하고 타입을 생성한다. * * 생성기는 저장소 의존성에 넣지 않는다. `openapi-typescript`는 TypeScript 5의 * classic compiler API를 요구하는데 이 저장소는 TypeScript 7.0.2를 고정하고 * 있고(VD-01), TS7 루트는 compiler API를 노출하지 않는다. 격리된 `pnpm dlx` * 환경에서 실행하면 lockfile과 peer 계약을 건드리지 않고 같은 산출물을 얻는다. * * `--check`는 canonical 저장소도 생성기도 없이 동작한다. vendor된 계약이 * 기록된 digest와 일치하는지, 기록된 operationId가 생성물에 모두 존재하는지만 * 본다. 손으로 yaml이나 generated.ts를 고치면 여기서 걸린다. */ 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 OPENAPI_TYPESCRIPT = "openapi-typescript@7.9.1"; const GENERATOR_TYPESCRIPT = "typescript@5.9.3"; const check = argv.includes("--check"); function digestOf(bytes: Buffer | string): 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]!; } type CanonicalRecord = Readonly<{ packageId: string; version: string; digest: string; sourceRevision: string; operationIds: readonly string[]; }>; function fail(problems: readonly string[]): never { console.error(`tech-log contract drift:\n- ${problems.join("\n- ")}`); console.error("Run: corepack pnpm generate:tech-log-contract"); exit(1); } if (check) { const vendored = readFileSync(VENDOR_YAML, "utf8"); const generated = readFileSync(GENERATED, "utf8"); const record = JSON.parse(readFileSync(SOURCE_RECORD, "utf8")) as CanonicalRecord; const problems: string[] = []; if (digestOf(readFileSync(VENDOR_YAML)) !== record.digest) { problems.push(`${VENDOR_YAML} does not hash to the recorded digest`); } const vendoredOperations = operationIdsOf(vendored); if (vendoredOperations.join(" ") !== [...record.operationIds].join(" ")) { problems.push(`${SOURCE_RECORD} operationIds differ from ${VENDOR_YAML}`); } if (specVersionOf(vendored) !== record.version) { problems.push(`${SOURCE_RECORD} version differs from ${VENDOR_YAML}`); } // 생성물은 operationId로 키가 매겨진 `operations` 인터페이스를 노출한다. for (const operationId of record.operationIds) { if (!new RegExp(`^\\s{4}${operationId}:`, "mu").test(generated)) { problems.push(`${GENERATED} is missing operation ${operationId}`); } } if (problems.length > 0) fail(problems); console.log( `tech-log contract is in sync: ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`, ); exit(0); } const canonicalBytes = readFileSync(CANONICAL_YAML); const canonicalText = canonicalBytes.toString("utf8"); const record: CanonicalRecord = { packageId: "@tech-log/studio-contract", version: specVersionOf(canonicalText), digest: digestOf(canonicalBytes), sourceRevision: execFileSync( "git", ["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"], { encoding: "utf8" }, ).trim(), operationIds: operationIdsOf(canonicalText), }; // 격리 실행. 저장소의 node_modules와 lockfile은 그대로다. const generated = execFileSync( "corepack", [ "pnpm", "dlx", "--package", GENERATOR_TYPESCRIPT, "--package", OPENAPI_TYPESCRIPT, "openapi-typescript", CANONICAL_YAML, ], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }, ); writeFileSync(VENDOR_YAML, canonicalText); writeFileSync(GENERATED, generated); writeFileSync(SOURCE_RECORD, `${JSON.stringify(record, null, 2)}\n`); console.log( `Generated from ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`, ); ``` - [ ] **Step 4: 스크립트 등록** `openapi-typescript`를 devDependency로 **추가하지 않는다**. `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 (<7자리 revision>), 19 operations.` `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`과 digest 불일치 메시지, 복원 후 `exit=0`. 생성물 훼손도 잡는지 확인한다. `generated.ts`에서 `getStudioSession` operation 키 이름을 임시로 바꾼 뒤 `check`를 실행하고 되돌린다. Expected: `exit=1`과 `missing operation getStudioSession`. - [ ] **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 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/contracts/rest-profiles.ts` (auth profile 등록) - Modify: `src/features/installed-contract-contributions.ts` - Test: `tests/features/tech-log/studio-contract-contribution.test.ts`, `tests/runtime-schema/http-schema.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 | 65_536 | `deleteStudioAsset`만 `responseBody: "NONE"`, `emptyBodyStatuses: [204]`다. 나머지는 `responseBody: "REQUIRED_JSON"`, `emptyBodyStatuses: []`다. `deleteStudioAsset`의 `responseByteLimit`이 0이 아닌 이유는 플랫폼이 1 이상을 요구하기 때문이다 — 본문 없는 204여도 오류 응답은 본문을 가진다. - [ ] **Step 1: Studio auth profile 등록** `authProfileId`가 `INSTALLED_REST_AUTH_PROFILES`에 없으면 계약 composition이 실패한다. 현재 등록된 것은 `REFERENCE_EXTERNAL_BEARER`와 `ANONYMOUS`뿐이므로 Studio 세션 profile을 먼저 만든다. canonical은 "Mutating Studio Operation은 Session Cookie와 `X-CSRF-TOKEN`을 요구한다"고 정한다. 플랫폼에는 이 조합이 이미 1급으로 있다 — `transport: "SAME_ORIGIN_COOKIE"`와 credential header `"x-csrf-token"`. `src/contracts/rest-profiles.ts`의 `REST_AUTH_PROFILES`에 추가한다: ```typescript TECH_LOG_STUDIO_SESSION: Object.freeze({ authProfileId: "TECH_LOG_STUDIO_SESSION", transport: "SAME_ORIGIN_COOKIE", credentials: "include", allowedCredentialHeaders: Object.freeze(["x-csrf-token"] as const), requiredCredentialHeaders: Object.freeze(["x-csrf-token"] as const), }), ``` `installRestAuthProfileRegistry`의 `SAME_ORIGIN_COOKIE` 불변식을 만족하는지 확인한다: `credentials !== "omit"` ✓, `allowed`에 `"authorization"`이 없어야 함 ✓, `required ⊆ allowed` ✓. `tests/runtime-schema/http-schema.test.ts`(또는 rest-profile을 다루는 기존 테스트)에 profile이 설치되는지 단언을 추가한다: ```typescript test("installs the TechLog Studio session auth profile", () => { const profile = INSTALLED_REST_AUTH_PROFILES.get("TECH_LOG_STUDIO_SESSION"); assert.ok(profile); assert.equal(profile.transport, "SAME_ORIGIN_COOKIE"); assert.deepEqual([...profile.requiredCredentialHeaders], ["x-csrf-token"]); }); ``` Run: `corepack pnpm test:runtime-schema` Expected: PASS - [ ] **Step 2: 실패하는 테스트 작성** `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 3: red 확인** Run: `corepack pnpm exec vitest run tests/features/tech-log/studio-contract-contribution.test.ts` Expected: FAIL — 기여 모듈을 찾을 수 없다. - [ ] **Step 4: 기여 파일 작성** `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 5: 설치 등록부에 추가** `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 6: green 확인** Run: `corepack pnpm exec vitest run tests/features/tech-log/studio-contract-contribution.test.ts` Expected: 5 tests PASS - [ ] **Step 7: 계약 집합과 아키텍처 게이트 확인** 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 8: 커밋** ```bash git add src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts \ src/contracts/rest-profiles.ts src/features/installed-contract-contributions.ts \ tests/features/tech-log/studio-contract-contribution.test.ts \ tests/runtime-schema/http-schema.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` - Move: `src/features/tech-log/adapters/mock/stable-stringify.ts` → `src/features/tech-log/adapters/stable-stringify.ts` (mock의 import 경로를 함께 고친다) - Test: `tests/features/tech-log/http-studio-gateway.test.ts` HTTP 어댑터가 `mock/`에서 import하면 프로덕션 경로가 mock 경로에 의존한다. 두 어댑터가 공유하는 순수 함수이므로 한 단계 위로 옮긴다. 내용은 바꾸지 않는다. **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 }>): StudioGateway` - `mutationIntent(operationId: string, idempotencyKey: string, input: unknown): MutationIntent` — Task 6도 이 함수를 쓴다 - [ ] **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 { defineIdempotencyKey, defineMutationIntent, type MutationIntent, } from "../../../../contracts/mutation-intent.ts"; import type { HttpExecutionOutcome } from "../../../../adapters/http/http-execution-v3.ts"; import { stableStringify } from "../stable-stringify.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; }>; const ROUTE_ID = "TECH_LOG_STUDIO"; /** * `createBrowserMutationIntentFactory`는 쓰지 않는다. 그 factory는 * `requiresIdempotencyKey`일 때 키를 **스스로 생성**하는데, 이 포트의 키는 * 호출자가 만들어 안전한 재시도에 재사용하는 값이다. `defineMutationIntent`가 * 호출자 공급 키를 검증하며 받아주는 정식 경로다(`mutation-intent.ts`의 * "A caller-supplied value is never trimmed, regenerated or silently dropped"). */ export function mutationIntent( operationId: string, idempotencyKey: string, input: unknown, ): MutationIntent { return defineMutationIntent({ intentId: globalThis.crypto.randomUUID(), operationId, canonicalInputIdentity: canonicalIdentity(input), idempotencyKey: defineIdempotencyKey(idempotencyKey), createdAtMonotonicMs: globalThis.performance.now(), }); } /** 같은 명령의 재시도가 같은 신원을 갖도록 키 순서를 고정해 직렬화한다. */ function canonicalIdentity(input: unknown): string { const identity = stableStringify(input); return identity.length > 16_000 ? identity.slice(0, 16_000) : identity; } /** * 헤더는 gateway가 만들지 않는다. `Idempotency-Key`는 실행 context의 intent에서, * `x-csrf-token`은 credential collaborator에서 온다. gateway가 입력 본문에 * 넣으면 계약 본문이 오염되고 계약 소유 헤더는 거절된다. */ 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 outcome = await deps.operations.execute(operationId, input, { routeId: ROUTE_ID, intent: mutationIntent(operationId, options.idempotencyKey, input), ...(options.signal ? { signal: options.signal } : {}), }); if (outcome.kind === "SUCCESS") return outcome.value as T; throw toStudioGatewayError(outcome, operationId); } 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; intent?: unknown }) { 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); } // 실행기가 헤더를 만드는 두 경로를 그대로 재현한다: intent → Idempotency-Key, // credential collaborator → x-csrf-token. const headers: Record = {}; if (contract.retrySemantics === "KEYED") { const intent = (context as { intent?: { idempotencyKey?: string } }).intent; if (intent?.idempotencyKey) headers["Idempotency-Key"] = intent.idempotencyKey; headers["X-CSRF-TOKEN"] = await csrf.token(); } 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 } as never; } ``` Task 3의 각 KEYED `projectRequest`는 body를 명시적으로 조립해야 한다(`SAVE_STUDIO_DOCUMENT` 예시가 그렇게 돼 있다) — 입력을 그대로 spread하면 계약에 없는 필드가 본문에 섞인다. 이 테스트가 그 규칙을 지킨다. - [ ] **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 { mutationIntent, 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) { // Task 4와 동일한 규칙: 헤더는 gateway가 만들지 않는다. `Idempotency-Key`는 // intent에서, `x-csrf-token`은 credential collaborator에서 온다. const outcome = await deps.operations.execute(operationId, input, { routeId: ROUTE_ID, intent: mutationIntent(operationId, options.idempotencyKey, input), ...(options.signal ? { signal: options.signal } : {}), }); if (outcome.kind === "SUCCESS") return outcome.value as T; throw toStudioGatewayError(outcome, operationId); } 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; }>; ``` CSRF 제공자는 **하나**이고 두 곳이 쓴다: 플랫폼 실행기의 credential seam(JSON operation 18개)과 업로드 전송(multipart 1개). 그러므로 feature input이 아니라 **composition root가 소유**한다. 나눠 가지면 Studio 세션 하나에 토큰이 두 개 생긴다. `src/bootstrap/runtime-adapters.ts`에서 `contractHttp` 조립 **앞에** 제공자를 만든다. `getStudioSession` 자체는 SAFE operation이라 CSRF가 필요 없으므로 재귀하지 않는다: ```typescript const techLogCsrf = createCsrfTokenProvider({ async execute(options) { const outcome = await 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 }; }, }); ``` `contractOperations`가 아직 없는 위치라면 제공자를 지연 생성(`let` + 최초 호출 시 조립)해 순환을 피한다. 같은 파일 `attachCredentials`(`:435`)에 Studio 분기를 더한다. 이것이 `x-csrf-token`이 요청에 실리는 **유일한** 경로다: ```typescript async attachCredentials(operation, authContext) { if (serverStateScope.getPhase() !== "READY") { return Object.freeze({ kind: "SCOPE_FENCED" as const }); } if (operation.authProfileId === "TECH_LOG_STUDIO_SESSION") { // Studio는 세션 쿠키로 인증하고 CSRF 토큰만 증명 헤더로 싣는다. // 조회 operation은 이 profile을 쓰더라도 헤더 없이 통과해야 하므로 // 토큰 조회 실패를 세션 상태로 승격하지 않는다. try { return Object.freeze({ kind: "READY" as const, headers: Object.freeze({ "x-csrf-token": await techLogCsrf.token({ signal: authContext.signal }), }), }); } catch { return Object.freeze({ kind: "UNAVAILABLE" as const }); } } const state = authSession.getState(); // ... 기존 bearer 경로는 그대로 둔다 }, ``` `create-tech-log-feature-input.ts`의 `TechLogInstallContext`에 `apiBaseUrl: string`, `requestTimeoutMs: number`, `csrf: CsrfTokenProvider`를 더한다: ```typescript export function createTechLogFeatureInstalledInput(context: TechLogInstallContext) { // MOCK에서도 Asset gateway는 필요하다. 백엔드가 없으면 목록이 비고 업로드는 // 전송 실패로 끝난다 — UI가 그 상태를 표시하는 것이 정상 동작이다. const createStudioAssetGateway = () => createHttpStudioAssetGateway({ operations: context.contractOperations, csrf: context.csrf, upload: createAssetUploadTransport({ baseUrl: context.apiBaseUrl, timeoutMs: context.requestTimeoutMs, }), }); const createStudioGateway = () => context.studioSource === "MOCK" ? createMockStudioGateway() : createHttpStudioGateway({ operations: context.contractOperations }); 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`, `csrf: techLogCsrf`를 더한다. Studio 세션이 만료돼 `AUTHENTICATION_REQUIRED`가 오면 캐시된 토큰을 버려야 한다. gateway가 아니라 `attachCredentials` 소유자가 버린다 — `authSession.onUnauthenticated()`를 호출하는 `runtime-adapters.ts:499` 부근의 `UNAUTHENTICATED` 분기에서 `techLogCsrf.invalidate()`를 함께 호출한다. `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 편집기에만 붙인다. **적재한 Asset 목록을 즉시 미리보기에 넘긴다.** Task 9가 `instant-preview.tsx`에 `assets` prop을 열어뒀지만 아직 빈 배열이 들어간다. 이 단계에서 실제로 연결하지 않으면 방금 삽입한 directive가 즉시 미리보기에서 자리표시자로 보인다. Picker가 적재한 목록을 편집기 화면(`document-editor-screen.tsx`)이 소유하게 하고, Picker와 InstantPreview 양쪽에 같은 배열을 내려준다. Picker가 아직 목록을 못 받았거나 업로드 직후라면 새 Asset을 배열에 더한다: ```tsx const [assets, setAssets] = useState([]); // Picker의 목록 적재 결과와 업로드 성공 결과가 같은 배열로 모인다. setAssets((current) => [asset, ...current])} … /> ``` `AssetPicker`에 `onLoaded?: (assets: readonly Asset[]) => void` prop을 더하고 `listAssets` 성공 시 호출한다. Task 10 Step 1의 기존 테스트는 이 prop 없이도 통과해야 하므로 선택 prop으로 둔다. - [ ] **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`에 커밋돼 있다.