Files
tech-log-frontend/docs/superpowers/plans/2026-08-17-techlog-backend-alignment.md
T
DongHyeonkaandClaude Opus 5 78766accdf docs: plan the TechLog backend alignment implementation
12개 Task로 분해한다. 계약 생성·digest 고정(1), 오류/CSRF(2), 계약 기여(3),
StudioGateway HTTP(4), 런타임 스위치(5), Asset 포트(6), multipart 전송과
배선(7), alt 규칙 이동(8), evidence resolver(9), Picker(10), Library(11),
전체 게이트(12).

자체 검토에서 세 결함을 고쳤다.
- Asset gateway가 feature input에 배선되지 않아 UI가 도달할 수 없었다.
- Backend assetKey를 해석할 resolver가 없어 삽입한 directive가 즉시
  미리보기를 깨뜨렸다. 렌더러의 기존 주입점을 쓰는 Task를 추가했다.
- dialog/library 단계에 코드가 없었다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:44:25 +09:00

3322 lines
125 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TechLog Backend Alignment Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Studio HTTP 계약을 canonical `studio-v1.yaml` 단일 출처에 고정하고, `StudioGateway`의 모든 operation과 Asset capability를 HTTP 어댑터로 구현한다.
**Architecture:** canonical OpenAPI를 저장소에 vendor하고 타입을 생성한다. 계약 기여(`EXTERNAL_PACKAGE` provenance)의 digest로 canonical revision을 암호학적으로 고정해 drift를 빌드에서 막는다. JSON operation 18개는 플랫폼 계약 런타임(`contractOperations`)을 통과하고, 계약 런타임이 표현할 수 없는 multipart 업로드 1개만 전용 전송 seam으로 분리한다. mock gateway는 삭제하지 않고 런타임 스위치 뒤의 기본값으로 유지한다.
**Tech Stack:** TypeScript 7, React 19, Vite 8, Vitest 4, MSW 2, zod 4, `openapi-typescript`(신규)
**Spec:** `docs/superpowers/specs/2026-08-17-techlog-backend-alignment-design.md`
## Global Constraints
- Canonical 계약: `/home/donghyeon/workspace/tech-log-design-package/contracts/openapi/studio-v1.yaml`, spec version `2.0.0`, digest `sha256:85a65004f29880334b9a0a3b54089450a898f1a815f679b2985b87ed8723df5b`, 설계 패키지 revision `0ec5582`.
- canonical operation은 **19개**다. JSON 18개는 계약 기여에, multipart `uploadStudioAsset` 1개는 업로드 전송 seam에 존재해야 한다.
- 오류 코드는 **23개** 전부를 처리한다. 목록은 Task 2에 있다.
- 브랜치는 `feature/techlog-backend-alignment`다. `main`에 직접 커밋하지 않는다.
- `src/application/ports/studio-gateway.ts`의 시그니처를 변경하지 않는다. UI는 어댑터 종류를 알지 못한다.
- 현재 Public UI·라우트와 Studio 작업 흐름을 변경하지 않는다. `presentation/public/**`, `presentation/shared/public-render/**`, 기존 `document-*`/`validation-*`/`publish-*`/`publication-*` 컴포넌트는 Task 8~10에서 지정한 지점 외에는 손대지 않는다.
- **`tests/mocks/scenarios/catalog.ts``HTTP_SCENARIO_OPERATION_IDS`에 TechLog operation을 추가하지 않는다.** 그 카탈로그는 플랫폼 전송 계층의 실패 모드 행렬이며 reference feature 3개 operation으로 이미 입증돼 있다. 어떤 스크립트도 계약 등록부와 대조하지 않으므로(확인함) TechLog operation을 넣으면 19 시나리오 × 18 operation의 증거 부담만 생긴다. TechLog는 `tests/features/tech-log/`에서 MSW로 gateway 수준 계약을 검증한다.
- 플랫폼 파일 중 이 계획이 수정을 허용하는 것은 `src/contracts/release-artifacts.ts`, `src/bootstrap/runtime-config-schema.ts`, `src/contracts/env.ts`, `src/features/installed-contract-contributions.ts`, `src/features/installed-feature-adapters.ts`, `src/bootstrap/runtime-adapters.ts`뿐이다. `src/contracts/external-contract-runtime.ts``src/adapters/http/client.ts`**수정하지 않는다**(template 동기화 대상).
- 매 Task는 red → green → 게이트 → commit 순서를 지킨다.
---
### Task 1: 계약 생성 파이프라인과 drift 게이트
canonical yaml을 vendor하고 타입을 재생성 가능하게 만든다. 현재 `generated.ts``openapi-typescript`로 만들어졌으나 도구도 스크립트도 저장소에 없어 재생성이 불가능하다 — 두 계약이 갈라진 근본 원인이다.
**Files:**
- Create: `scripts/generate-tech-log-contract.ts`
- Create: `src/features/tech-log/contracts/studio/canonical-source.json`
- Modify: `src/features/tech-log/contracts/studio/studio-api.openapi.yaml` (canonical 사본으로 교체)
- Modify: `src/features/tech-log/contracts/studio/generated.ts` (재생성)
- Modify: `package.json` (devDependency + scripts)
- Test: `tests/features/tech-log/contract-generation.test.ts`
**Interfaces:**
- Consumes: 없음 (첫 Task)
- Produces: `canonical-source.json``{ packageId: string, version: string, digest: string, sourceRevision: string, operationIds: string[] }`. Task 2가 provenance와 parity 검증에 사용한다.
- [ ] **Step 1: 실패하는 테스트 작성**
`tests/features/tech-log/contract-generation.test.ts`:
```typescript
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { test } from "vitest";
import canonicalSource from "../../../src/features/tech-log/contracts/studio/canonical-source.json" with { type: "json" };
const YAML_PATH = "src/features/tech-log/contracts/studio/studio-api.openapi.yaml";
test("vendored contract matches the recorded canonical digest", () => {
const bytes = readFileSync(YAML_PATH);
const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
assert.equal(digest, canonicalSource.digest);
});
test("canonical source records the pinned revision and version", () => {
assert.equal(canonicalSource.packageId, "tech-log-studio-contract");
assert.equal(canonicalSource.version, "2.0.0");
assert.equal(canonicalSource.sourceRevision, "0ec5582");
assert.match(canonicalSource.digest, /^sha256:[0-9a-f]{64}$/);
});
test("canonical source lists all 19 operationIds", () => {
assert.equal(canonicalSource.operationIds.length, 19);
assert.ok(canonicalSource.operationIds.includes("uploadStudioAsset"));
assert.ok(canonicalSource.operationIds.includes("getStudioSession"));
});
test("vendored contract declares the CSRF header", () => {
const yaml = readFileSync(YAML_PATH, "utf8");
assert.ok(yaml.includes("X-CSRF-TOKEN"));
});
```
- [ ] **Step 2: red 확인**
Run: `corepack pnpm exec vitest run tests/features/tech-log/contract-generation.test.ts`
Expected: FAIL — `canonical-source.json` 모듈을 찾을 수 없다.
- [ ] **Step 3: 생성 스크립트 작성**
`scripts/generate-tech-log-contract.ts`:
```typescript
/**
* canonical studio-v1.yaml을 vendor하고 타입을 생성한다.
* `--check`는 재생성 결과가 커밋 내용과 동일한지 검증만 하고 쓰지 않는다.
*/
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { argv, env, exit } from "node:process";
const CANONICAL_ROOT =
env.TECH_LOG_DESIGN_PACKAGE ??
"/home/donghyeon/workspace/tech-log-design-package";
const CANONICAL_YAML = `${CANONICAL_ROOT}/contracts/openapi/studio-v1.yaml`;
const VENDOR_YAML = "src/features/tech-log/contracts/studio/studio-api.openapi.yaml";
const GENERATED = "src/features/tech-log/contracts/studio/generated.ts";
const SOURCE_RECORD = "src/features/tech-log/contracts/studio/canonical-source.json";
const check = argv.includes("--check");
function digestOf(bytes: Buffer): string {
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
}
function operationIdsOf(yaml: string): string[] {
return [...yaml.matchAll(/^\s+operationId:\s*(\S+)\s*$/gmu)].map(
(match) => match[1]!,
);
}
function specVersionOf(yaml: string): string {
const match = /^\s{2}version:\s*(\S+)\s*$/mu.exec(yaml);
if (!match) throw new Error("canonical yaml has no info.version");
return match[1]!;
}
function revisionOf(): string {
return execFileSync("git", ["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"], {
encoding: "utf8",
}).trim();
}
const canonicalBytes = readFileSync(CANONICAL_YAML);
const canonicalText = canonicalBytes.toString("utf8");
const record = {
packageId: "tech-log-studio-contract",
version: specVersionOf(canonicalText),
digest: digestOf(canonicalBytes),
sourceRevision: revisionOf(),
operationIds: operationIdsOf(canonicalText),
};
const generated = execFileSync(
"corepack",
["pnpm", "exec", "openapi-typescript", CANONICAL_YAML],
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
);
const recordText = `${JSON.stringify(record, null, 2)}\n`;
if (check) {
const problems: string[] = [];
if (readFileSync(VENDOR_YAML, "utf8") !== canonicalText) {
problems.push(`${VENDOR_YAML} differs from the canonical contract`);
}
if (readFileSync(GENERATED, "utf8") !== generated) {
problems.push(`${GENERATED} is not the current generation output`);
}
if (readFileSync(SOURCE_RECORD, "utf8") !== recordText) {
problems.push(`${SOURCE_RECORD} does not match the canonical digest/revision`);
}
if (problems.length > 0) {
console.error(`tech-log contract drift:\n- ${problems.join("\n- ")}`);
console.error("Run: corepack pnpm generate:tech-log-contract");
exit(1);
}
console.log("tech-log contract is in sync with the canonical source.");
exit(0);
}
writeFileSync(VENDOR_YAML, canonicalText);
writeFileSync(GENERATED, generated);
writeFileSync(SOURCE_RECORD, recordText);
console.log(`Generated from ${record.packageId}@${record.version} (${record.sourceRevision}).`);
```
- [ ] **Step 4: 의존성과 스크립트 추가**
Run:
```bash
corepack pnpm add -D openapi-typescript@7.9.1
```
`package.json``scripts`에 세 줄을 추가한다:
```json
"generate:tech-log-contract": "node scripts/generate-tech-log-contract.ts",
"check:tech-log-contract": "node scripts/generate-tech-log-contract.ts --check",
"test:tech-log": "vitest run tests/features/tech-log --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/tech-log.xml",
```
`test:all` 스크립트의 `test:reference-feature` 뒤에 `&& corepack pnpm test:tech-log`를 추가한다.
- [ ] **Step 5: 생성 실행**
Run: `corepack pnpm generate:tech-log-contract`
Expected: `Generated from tech-log-studio-contract@2.0.0 (0ec5582).`
`generated.ts`의 diff가 크다. `paths``/api/v1/studio/session`, `/api/v1/studio/assets`, `/api/v1/studio/assets/{assetId}`가 생기고 `components.schemas``StudioSession`, `Asset`, `AssetDetail`, `AssetPage`, `AssetUploadForm`, `UpdateAssetCommand`, `AssetUsage`, `AssetKind`, `AssetManagementStatus`가 생기는지 육안 확인한다.
- [ ] **Step 6: green 확인**
Run: `corepack pnpm exec vitest run tests/features/tech-log/contract-generation.test.ts`
Expected: 4 tests PASS
- [ ] **Step 7: drift 게이트가 실제로 잡는지 확인**
Run:
```bash
printf '\n# tampered\n' >> src/features/tech-log/contracts/studio/studio-api.openapi.yaml
corepack pnpm check:tech-log-contract; echo "exit=$?"
git checkout -- src/features/tech-log/contracts/studio/studio-api.openapi.yaml
corepack pnpm check:tech-log-contract; echo "exit=$?"
```
Expected: 첫 실행 `exit=1`과 drift 메시지, 복원 후 `exit=0`.
- [ ] **Step 8: 기존 계약 소비자 회귀 확인**
Run: `corepack pnpm check:types:app && corepack pnpm test:tech-log`
Expected: PASS. `contract.ts`의 기존 타입 alias가 새 `generated.ts`에서도 전부 해석돼야 한다. 실패하면 `contract.ts`에서 이름이 바뀐 alias만 수정한다 — 새 타입 추가는 Task 6에서 한다.
- [ ] **Step 9: 커밋**
```bash
git add package.json pnpm-lock.yaml scripts/generate-tech-log-contract.ts \
src/features/tech-log/contracts/studio/ tests/features/tech-log/contract-generation.test.ts
git commit -m "build: generate the TechLog Studio contract from canonical source"
```
---
### Task 2: 오류 매핑과 CSRF 세션
계약 기여를 쓰기 전에, 모든 operation이 공유할 두 조각을 먼저 만든다.
**Files:**
- Create: `src/features/tech-log/adapters/http/studio-error-mapping.ts`
- Create: `src/features/tech-log/adapters/http/studio-session-csrf.ts`
- Test: `tests/features/tech-log/studio-error-mapping.test.ts`
**Interfaces:**
- Consumes: `StudioGatewayError`(`application/ports/studio-gateway-error.ts`), `ProblemDetails`(`contracts/studio/contract.ts`), `HttpExecutionOutcome`(`adapters/http/http-execution-v3.ts`)
- Produces:
- `STUDIO_ERROR_CODES: readonly ProblemDetails["code"][]` — 23개
- `toStudioGatewayError(outcome: HttpExecutionOutcome<unknown, unknown>, operationId: string): StudioGatewayError`
- `createCsrfTokenProvider(deps: { execute: CsrfSessionExecutor }): { token(options?: { signal?: AbortSignal }): Promise<string>; 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<string>(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<unknown, unknown>,
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<StudioSessionSnapshot>;
export type CsrfTokenProvider = Readonly<{
token(options?: Readonly<{ signal?: AbortSignal }>): Promise<string>;
headerName(options?: Readonly<{ signal?: AbortSignal }>): Promise<string>;
invalidate(): void;
}>;
/**
* CSRF는 전송 관심사다. UI는 토큰을 보지 않으므로 포트로 노출하지 않고
* 어댑터 내부에서 캐시한다. 동시 요청은 하나의 in-flight 조회를 공유한다.
*/
export function createCsrfTokenProvider(
deps: Readonly<{ execute: CsrfSessionExecutor }>,
): CsrfTokenProvider {
let cached: StudioSessionSnapshot | null = null;
let inFlight: Promise<StudioSessionSnapshot> | null = null;
async function resolve(
options?: Readonly<{ signal?: AbortSignal }>,
): Promise<StudioSessionSnapshot> {
if (cached) return cached;
inFlight ??= deps.execute(options).then(
(snapshot) => {
cached = snapshot;
inFlight = null;
return snapshot;
},
(error: unknown) => {
inFlight = null;
throw error;
},
);
return inFlight;
}
return Object.freeze({
async token(options) {
return (await resolve(options)).csrfToken;
},
async headerName(options) {
return (await resolve(options)).csrfHeaderName;
},
invalidate() {
cached = null;
},
});
}
```
- [ ] **Step 5: green 확인**
Run: `corepack pnpm exec vitest run tests/features/tech-log/studio-error-mapping.test.ts`
Expected: 6 tests PASS
- [ ] **Step 6: 커밋**
```bash
git add src/features/tech-log/adapters/http/ tests/features/tech-log/studio-error-mapping.test.ts
git commit -m "feat: add TechLog Studio error mapping and CSRF token provider"
```
---
### Task 3: Studio 계약 기여 — JSON operation 18개
계약 기여는 서비스 패키지당 하나다. Asset의 JSON operation 4개도 같은 `studio-v1` 패키지이므로 한 파일에 선언한다.
**Files:**
- Create: `src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts`
- Modify: `src/features/installed-contract-contributions.ts`
- Test: `tests/features/tech-log/studio-contract-contribution.test.ts`
**Interfaces:**
- Consumes: `canonical-source.json`(Task 1), `InstalledContractContribution`/`InstalledHttpContract`(`src/contracts/external-contract-runtime.ts`)
- Produces:
- `TECH_LOG_STUDIO_CONTRIBUTION: InstalledContractContribution`
- `TECH_LOG_STUDIO_OPERATION_IDS: readonly string[]` — 18개
- `type TechLogStudioOperationId = (typeof TECH_LOG_STUDIO_OPERATION_IDS)[number]`
**선언할 operation 18개.** 각 항목은 `operationId · method · pathTemplate · requestBody · retrySemantics · acceptedStatuses · responseByteLimit`이다. `retrySemantics: "KEYED"`인 항목은 전부 `commandRecovery = { mode: "IDEMPOTENCY_REPLAY", operationIdentityField: "idempotencyKey" }`, `frontend.retryBudget = 0`이다. `SAFE``commandRecovery: null`, `commandEffect: null`, `retryBudget: 2`다. 모든 항목의 `frontend.authProfileId``"TECH_LOG_STUDIO_SESSION"`, `totalDeadlineMs``10_000`이다.
| operationId | method | pathTemplate | requestBody | retry | accepted | respLimit |
|---|---|---|---|---|---|---|
| `getStudioSession` | GET | `/api/v1/studio/session` | NONE | SAFE | 200 | 8_192 |
| `getStudioDashboard` | GET | `/api/v1/studio/dashboard` | NONE | SAFE | 200 | 262_144 |
| `listStudioDocuments` | GET | `/api/v1/studio/documents` | NONE | SAFE | 200 | 262_144 |
| `createStudioDocument` | POST | `/api/v1/studio/documents` | JSON | KEYED | 201 | 131_072 |
| `getStudioDocument` | GET | `/api/v1/studio/documents/{documentId}` | NONE | SAFE | 200 | 524_288 |
| `saveStudioDocument` | PUT | `/api/v1/studio/documents/{documentId}` | JSON | KEYED | 200 | 524_288 |
| `validateStudioDocument` | POST | `/api/v1/studio/documents/{documentId}/validate` | JSON | KEYED | 200 | 262_144 |
| `getCurrentStudioPreview` | GET | `/api/v1/studio/documents/{documentId}/preview` | NONE | SAFE | 200 | 1_048_576 |
| `createStudioPreview` | POST | `/api/v1/studio/documents/{documentId}/preview` | JSON | KEYED | 201 | 1_048_576 |
| `publishStudioDocument` | POST | `/api/v1/studio/documents/{documentId}/publish` | JSON | KEYED | 200 | 131_072 |
| `listStudioPublications` | GET | `/api/v1/studio/publications` | NONE | SAFE | 200 | 262_144 |
| `unpublishStudioPublication` | POST | `/api/v1/studio/publications/{publicationId}/unpublish` | JSON | KEYED | 200 | 131_072 |
| `getStudioPublicationSnapshot` | GET | `/api/v1/studio/publications/{publicationEventId}/preview` | NONE | SAFE | 200 | 1_048_576 |
| `listStudioCatalog` | GET | `/api/v1/studio/catalog` | NONE | SAFE | 200 | 131_072 |
| `listStudioAssets` | GET | `/api/v1/studio/assets` | NONE | SAFE | 200 | 262_144 |
| `getStudioAsset` | GET | `/api/v1/studio/assets/{assetId}` | NONE | SAFE | 200 | 65_536 |
| `updateStudioAsset` | PUT | `/api/v1/studio/assets/{assetId}` | JSON | KEYED | 200 | 65_536 |
| `deleteStudioAsset` | DELETE | `/api/v1/studio/assets/{assetId}` | NONE | KEYED | 204 | 0 |
`deleteStudioAsset``responseBody: "NONE"`, `emptyBodyStatuses: [204]`다. 나머지는 `responseBody: "REQUIRED_JSON"`, `emptyBodyStatuses: []`다.
- [ ] **Step 1: 실패하는 테스트 작성**
`tests/features/tech-log/studio-contract-contribution.test.ts`:
```typescript
import assert from "node:assert/strict";
import { test } from "vitest";
import canonicalSource from "../../../src/features/tech-log/contracts/studio/canonical-source.json" with { type: "json" };
import {
TECH_LOG_STUDIO_CONTRIBUTION,
TECH_LOG_STUDIO_OPERATION_IDS,
} from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts";
import { composeContractContributions } from "../../../src/contracts/external-contract-runtime.ts";
const UPLOAD = "uploadStudioAsset";
test("declares every canonical operation except the multipart upload", () => {
const expected = canonicalSource.operationIds.filter((id) => id !== UPLOAD);
assert.equal(expected.length, 18);
assert.deepEqual([...TECH_LOG_STUDIO_OPERATION_IDS].sort(), [...expected].sort());
});
test("pins the canonical digest and revision as external package provenance", () => {
const source = TECH_LOG_STUDIO_CONTRIBUTION.source;
assert.equal(source.kind, "EXTERNAL_PACKAGE");
if (source.kind !== "EXTERNAL_PACKAGE") return;
assert.equal(source.package.packageId, canonicalSource.packageId);
assert.equal(source.package.version, canonicalSource.version);
assert.equal(source.package.digest, canonicalSource.digest);
assert.equal(source.package.sourceRevision, canonicalSource.sourceRevision);
assert.equal(source.package.runtimeProtocolVersion, 1);
});
test("composes without violating the platform contract runtime", () => {
const composed = composeContractContributions([TECH_LOG_STUDIO_CONTRIBUTION]);
assert.equal(composed.externalPackages.length, 1);
});
test("every mutating operation replays by idempotency key and never auto-retries", () => {
for (const entry of TECH_LOG_STUDIO_CONTRIBUTION.http) {
if (entry.contract.retrySemantics !== "KEYED") continue;
assert.deepEqual(entry.contract.commandRecovery, {
mode: "IDEMPOTENCY_REPLAY",
operationIdentityField: "idempotencyKey",
}, `${entry.contract.operationId} recovery`);
assert.equal(entry.frontend.retryBudget, 0, `${entry.contract.operationId} budget`);
}
});
test("path templates match the canonical /api/v1/studio prefix", () => {
for (const entry of TECH_LOG_STUDIO_CONTRIBUTION.http) {
assert.ok(
entry.contract.pathTemplate.startsWith("/api/v1/studio/"),
`${entry.contract.operationId}: ${entry.contract.pathTemplate}`,
);
}
});
```
- [ ] **Step 2: red 확인**
Run: `corepack pnpm exec vitest run tests/features/tech-log/studio-contract-contribution.test.ts`
Expected: FAIL — 기여 모듈을 찾을 수 없다.
- [ ] **Step 3: 기여 파일 작성**
`src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts`. 먼저 공용 헬퍼와 대표 operation 두 개를 정확한 형태로 만든 뒤, 위 표의 나머지 16개를 같은 헬퍼로 선언한다.
```typescript
import { z } from "zod";
import type {
CommandEffectDescriptor,
InstalledContractContribution,
InstalledHttpContract,
RuntimeValidator,
} from "../../../contracts/external-contract-runtime.ts";
import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts";
import canonicalSource from "./studio/canonical-source.json" with { type: "json" };
import { STUDIO_ERROR_CODES } from "../adapters/http/studio-error-mapping.ts";
function zodValidator<T>(schemaId: string, schema: z.ZodType<T>): RuntimeValidator<T> {
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 = <T>(schemaId: string) =>
zodValidator<T>(schemaId, z.unknown() as unknown as z.ZodType<T>);
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<z.output<typeof problemSchema>> =
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<Record<string, string>>;
type QueryEntries = readonly (readonly [string, string])[];
function safeOperation(
operationId: string,
pathTemplate: string,
responseByteLimit: number,
project: (input: never) => Readonly<{ pathValues: PathValues; queryEntries: QueryEntries }>,
): InstalledHttpContract<unknown, unknown, unknown> {
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<unknown, unknown, unknown>;
}
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<unknown, unknown, unknown> {
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<unknown, unknown, unknown>;
}
const NO_PATH = Object.freeze({});
const NO_QUERY = Object.freeze([]) as QueryEntries;
function queryOf(input: Readonly<Record<string, unknown>>): QueryEntries {
const entries: (readonly [string, string])[] = [];
for (const [key, value] of Object.entries(input)) {
if (value === undefined || value === null) continue;
entries.push([key, String(value)]);
}
return Object.freeze(entries);
}
```
대표 operation 두 개:
```typescript
const GET_STUDIO_SESSION = safeOperation(
"getStudioSession",
"/api/v1/studio/session",
8_192,
() => Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY }),
);
const SAVE_STUDIO_DOCUMENT = keyedOperation(
"saveStudioDocument",
"PUT",
"/api/v1/studio/documents/{documentId}",
{ acceptedStatus: 200, responseByteLimit: 524_288, requestByteLimit: 524_288, hasBody: true },
(input: never) => {
const value = input as unknown as Readonly<{
documentId: string;
expectedVersion: number;
document: unknown;
}>;
return Object.freeze({
pathValues: Object.freeze({ documentId: value.documentId }),
queryEntries: NO_QUERY,
body: Object.freeze({
expectedVersion: value.expectedVersion,
document: value.document,
}),
});
},
);
```
나머지 16개를 같은 두 헬퍼로 선언한다. 표의 `respLimit``responseByteLimit`에, 본문이 있는 KEYED operation의 `requestByteLimit``responseByteLimit`과 같은 값을, 본문 없는 `deleteStudioAsset``0`을 쓴다.
query를 갖는 SAFE operation의 `projectRequest``queryOf`를 쓴다. 예를 들어 `listStudioDocuments``q, kind, publicationStatus, nextAction, projectId, sort, cursor, limit`을, `listStudioPublications``q, type, cursor, limit`을, `listStudioCatalog``type, q, cursor, limit`을, `listStudioAssets``q, kind, managementStatus, cursor, limit`을 넘긴다.
path 파라미터를 갖는 operation의 `pathValues` 키는 템플릿과 정확히 같아야 한다: `documentId`, `publicationId`, `publicationEventId`, `assetId`.
마지막에 기여를 조립한다:
```typescript
const HTTP_CONTRACTS = Object.freeze([
GET_STUDIO_SESSION,
GET_STUDIO_DASHBOARD,
LIST_STUDIO_DOCUMENTS,
CREATE_STUDIO_DOCUMENT,
GET_STUDIO_DOCUMENT,
SAVE_STUDIO_DOCUMENT,
VALIDATE_STUDIO_DOCUMENT,
GET_CURRENT_STUDIO_PREVIEW,
CREATE_STUDIO_PREVIEW,
PUBLISH_STUDIO_DOCUMENT,
LIST_STUDIO_PUBLICATIONS,
UNPUBLISH_STUDIO_PUBLICATION,
GET_STUDIO_PUBLICATION_SNAPSHOT,
LIST_STUDIO_CATALOG,
LIST_STUDIO_ASSETS,
GET_STUDIO_ASSET,
UPDATE_STUDIO_ASSET,
DELETE_STUDIO_ASSET,
]);
export const TECH_LOG_STUDIO_OPERATION_IDS = Object.freeze(
HTTP_CONTRACTS.map((entry) => entry.contract.operationId),
);
export type TechLogStudioOperationId =
(typeof TECH_LOG_STUDIO_OPERATION_IDS)[number];
export const TECH_LOG_STUDIO_CONTRIBUTION: InstalledContractContribution =
Object.freeze({
contributionId: "tech-log-studio-http-v1",
featureId: TECH_LOG_FEATURE_ID,
source: Object.freeze({
kind: "EXTERNAL_PACKAGE" as const,
package: Object.freeze({
packageId: canonicalSource.packageId,
version: canonicalSource.version,
digest: canonicalSource.digest as `sha256:${string}`,
runtimeProtocolVersion: 1 as const,
sourceRevision: canonicalSource.sourceRevision,
}),
}),
http: HTTP_CONTRACTS,
events: Object.freeze([]),
});
```
- [ ] **Step 4: 설치 등록부에 추가**
`src/features/installed-contract-contributions.ts``INSTALLED_CONTRACT_CONTRIBUTIONS`를 수정한다. TechLog는 항상 설치되므로 조건 없이 포함한다:
```typescript
import { TECH_LOG_STUDIO_CONTRIBUTION } from "./tech-log/contracts/tech-log-studio-contract-contribution.ts";
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
Object.freeze(
INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)
? [REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION, TECH_LOG_STUDIO_CONTRIBUTION]
: [TECH_LOG_STUDIO_CONTRIBUTION],
);
```
- [ ] **Step 5: green 확인**
Run: `corepack pnpm exec vitest run tests/features/tech-log/studio-contract-contribution.test.ts`
Expected: 5 tests PASS
- [ ] **Step 6: 계약 집합과 아키텍처 게이트 확인**
Run: `corepack pnpm check:types:app && corepack pnpm generate:contract-set && corepack pnpm check:architecture`
Expected: PASS. `contractSet``tech-log-studio-contract@2.0.0`이 나타난다. `EXTERNAL_PACKAGE`이므로 `TEMPLATE_FIXTURE`와 달리 release digest에 반영된다.
- [ ] **Step 7: 커밋**
```bash
git add src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts \
src/features/installed-contract-contributions.ts \
tests/features/tech-log/studio-contract-contribution.test.ts .generated/
git commit -m "feat: register the TechLog Studio contract contribution"
```
---
### Task 4: HTTP StudioGateway 구현
**Files:**
- Create: `src/features/tech-log/adapters/http/http-studio-gateway.ts`
- Create: `tests/mocks/handlers/tech-log-studio.ts`
- Test: `tests/features/tech-log/http-studio-gateway.test.ts`
**Interfaces:**
- Consumes: `toStudioGatewayError`/`createCsrfTokenProvider`(Task 2), `TechLogStudioOperationId`(Task 3), `StudioGateway`(`application/ports/studio-gateway.ts`), `InstalledContractOperationExecutor`(`features/reference-feature/adapters/create-reference-feature-input.ts`의 동형 타입)
- Produces:
- `type StudioOperationExecutor = Readonly<{ execute(operationId: string, input: unknown, context: Readonly<{ routeId: string; signal?: AbortSignal; intent?: MutationIntent }>): Promise<HttpExecutionOutcome<unknown, unknown>> }>`
- `createHttpStudioGateway(deps: Readonly<{ operations: StudioOperationExecutor; csrf: CsrfTokenProvider }>): StudioGateway`
- [ ] **Step 1: MSW 핸들러 작성**
`tests/mocks/handlers/tech-log-studio.ts`. mock gateway가 이미 canonical 의미론의 완전한 구현체이므로 재사용한다.
```typescript
import { http, HttpResponse } from "msw";
import { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts";
import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
const BASE = "http://api.test";
function problemResponse(error: unknown) {
if (isStudioGatewayError(error)) {
return HttpResponse.json(error.problem, {
status: error.status,
headers: { "content-type": "application/problem+json" },
});
}
throw error;
}
async function json(work: () => Promise<unknown>, status = 200) {
try {
return HttpResponse.json((await work()) as never, { status });
} catch (error) {
return problemResponse(error);
}
}
export function createTechLogStudioHandlers(
gateway = createMockStudioGateway(),
baseUrl = BASE,
) {
const key = (request: Request) =>
request.headers.get("Idempotency-Key") ?? "missing-key";
return {
gateway,
handlers: [
http.get(`${baseUrl}/api/v1/studio/session`, () =>
HttpResponse.json({
authenticated: true,
displayName: "테스트 편집자",
roles: ["STUDIO_EDITOR"],
csrfToken: "csrf-test-token",
csrfHeaderName: "X-CSRF-TOKEN",
}),
),
http.get(`${baseUrl}/api/v1/studio/dashboard`, () =>
json(() => gateway.getDashboard()),
),
http.get(`${baseUrl}/api/v1/studio/documents`, ({ request }) => {
const url = new URL(request.url);
const limit = url.searchParams.get("limit");
return json(() =>
gateway.listDocuments({
...(url.searchParams.get("q") ? { q: url.searchParams.get("q")! } : {}),
...(limit ? { limit: Number(limit) } : {}),
} as never),
);
}),
http.post(`${baseUrl}/api/v1/studio/documents`, async ({ request }) =>
json(
async () =>
gateway.createDocument((await request.json()) as never, {
idempotencyKey: key(request),
}),
201,
),
),
http.get(`${baseUrl}/api/v1/studio/documents/:documentId`, ({ params }) =>
json(() => gateway.getDocument(String(params.documentId))),
),
http.put(
`${baseUrl}/api/v1/studio/documents/:documentId`,
async ({ request, params }) =>
json(async () =>
gateway.saveDocument(
String(params.documentId),
(await request.json()) as never,
{ idempotencyKey: key(request) },
),
),
),
http.post(
`${baseUrl}/api/v1/studio/documents/:documentId/validate`,
async ({ request, params }) =>
json(async () =>
gateway.validateDocument(
String(params.documentId),
(await request.json()) as never,
{ idempotencyKey: key(request) },
),
),
),
http.get(
`${baseUrl}/api/v1/studio/documents/:documentId/preview`,
({ params }) => json(() => gateway.getCurrentPreview(String(params.documentId))),
),
http.post(
`${baseUrl}/api/v1/studio/documents/:documentId/preview`,
async ({ request, params }) =>
json(
async () =>
gateway.createPreview(
String(params.documentId),
(await request.json()) as never,
{ idempotencyKey: key(request) },
),
201,
),
),
http.post(
`${baseUrl}/api/v1/studio/documents/:documentId/publish`,
async ({ request, params }) =>
json(async () =>
gateway.publishDocument(
String(params.documentId),
(await request.json()) as never,
{ idempotencyKey: key(request) },
),
),
),
http.get(`${baseUrl}/api/v1/studio/publications`, () =>
json(() => gateway.listPublications({})),
),
http.post(
`${baseUrl}/api/v1/studio/publications/:publicationId/unpublish`,
async ({ request, params }) =>
json(async () =>
gateway.unpublishPublication(
String(params.publicationId),
(await request.json()) as never,
{ idempotencyKey: key(request) },
),
),
),
http.get(
`${baseUrl}/api/v1/studio/publications/:publicationEventId/preview`,
({ params }) =>
json(() => gateway.getPublicationSnapshot(String(params.publicationEventId))),
),
http.get(`${baseUrl}/api/v1/studio/catalog`, ({ request }) => {
const url = new URL(request.url);
return json(() =>
gateway.getCatalog({ type: url.searchParams.get("type") as never }),
);
}),
],
};
}
```
- [ ] **Step 2: 실패하는 gateway 테스트 작성**
`tests/features/tech-log/http-studio-gateway.test.ts`:
```typescript
import assert from "node:assert/strict";
import { afterAll, afterEach, beforeAll, test } from "vitest";
import { setupServer } from "msw/node";
import { createHttpStudioGateway } from "../../../src/features/tech-log/adapters/http/http-studio-gateway.ts";
import { createCsrfTokenProvider } from "../../../src/features/tech-log/adapters/http/studio-session-csrf.ts";
import { createTechLogStudioHandlers } from "../../mocks/handlers/tech-log-studio.ts";
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
const { handlers } = createTechLogStudioHandlers();
const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
/**
* 전송 계층을 얇게 세운다. 이 테스트가 증명하는 것은 gateway가 canonical
* 경로/본문/헤더를 정확히 만들고 응답을 포트 계약으로 되돌린다는 것이다.
*/
function gatewayUnderTest() {
const seen: { csrf: string | null; idempotency: string | null }[] = [];
const operations = {
async execute(operationId: string, input: unknown, context: never) {
void operationId;
void input;
void context;
throw new Error("replaced in Step 4");
},
};
return { operations, seen };
}
test("reads the dashboard through the canonical path", async () => {
const gateway = createHttpStudioGateway(await realDependencies());
const dashboard = await gateway.getDashboard();
assert.equal(typeof dashboard.totals.documents, "number");
assert.ok(dashboard.totals.documents > 0);
});
test("saves with expectedVersion and returns the new version", async () => {
const gateway = createHttpStudioGateway(await realDependencies());
const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase);
const { id, version, updatedAt, ...input } = before.document;
void id;
void updatedAt;
const saved = await gateway.saveDocument(
before.document.id,
{ expectedVersion: version, document: { ...input, title: "HTTP 경로로 저장" } },
{ idempotencyKey: "save-1" },
);
assert.equal(saved.document.title, "HTTP 경로로 저장");
assert.equal(saved.document.version, version + 1);
});
test("surfaces VERSION_CONFLICT as the port error, not a transport error", async () => {
const gateway = createHttpStudioGateway(await realDependencies());
const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase);
const { id, version, updatedAt, ...input } = before.document;
void id;
void updatedAt;
await assert.rejects(
gateway.saveDocument(
before.document.id,
{ expectedVersion: version + 99, document: input },
{ idempotencyKey: "conflict-1" },
),
(error: unknown) => {
assert.ok(isStudioGatewayError(error));
assert.equal(error.code, "VERSION_CONFLICT");
assert.equal(error.status, 409);
return true;
},
);
});
test("sends the CSRF header and an Idempotency-Key on every mutation", async () => {
const captured: { csrf: string | null; key: string | null }[] = [];
server.use(
...createTechLogStudioHandlers().handlers,
);
server.events.on("request:start", ({ request }) => {
if (request.method === "GET") return;
captured.push({
csrf: request.headers.get("X-CSRF-TOKEN"),
key: request.headers.get("Idempotency-Key"),
});
});
const gateway = createHttpStudioGateway(await realDependencies());
const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase);
const { id, version, updatedAt, ...input } = before.document;
void id;
void updatedAt;
await gateway.saveDocument(
before.document.id,
{ expectedVersion: version, document: input },
{ idempotencyKey: "csrf-1" },
);
assert.equal(captured.length, 1);
assert.equal(captured[0]!.csrf, "csrf-test-token");
assert.equal(captured[0]!.key, "csrf-1");
});
```
`realDependencies()`는 Step 4에서 만드는 헬퍼다. 이 Step에서는 파일이 없어 실패해야 정상이다.
- [ ] **Step 3: red 확인**
Run: `corepack pnpm exec vitest run tests/features/tech-log/http-studio-gateway.test.ts`
Expected: FAIL — `http-studio-gateway.ts`를 찾을 수 없다.
- [ ] **Step 4: gateway 구현**
`src/features/tech-log/adapters/http/http-studio-gateway.ts`. 모든 메서드는 같은 두 헬퍼를 통과한다.
```typescript
import type { MutationIntent } from "../../../../contracts/mutation-intent.ts";
import type { HttpExecutionOutcome } from "../../../../adapters/http/http-execution-v3.ts";
import type {
IdempotentOptions,
RequestOptions,
StudioGateway,
} from "../../application/ports/studio-gateway.ts";
import { toStudioGatewayError } from "./studio-error-mapping.ts";
import type { CsrfTokenProvider } from "./studio-session-csrf.ts";
export type StudioOperationExecutor = Readonly<{
execute(
operationId: string,
input: unknown,
context: Readonly<{
routeId: string;
signal?: AbortSignal;
intent?: MutationIntent;
}>,
): Promise<HttpExecutionOutcome<unknown, unknown>>;
}>;
export type HttpStudioGatewayDependencies = Readonly<{
operations: StudioOperationExecutor;
csrf: CsrfTokenProvider;
}>;
const ROUTE_ID = "TECH_LOG_STUDIO";
export function createHttpStudioGateway(
deps: HttpStudioGatewayDependencies,
): StudioGateway {
async function read<T>(
operationId: string,
input: unknown,
options?: RequestOptions,
): Promise<T> {
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<T>(
operationId: string,
input: unknown,
options: IdempotentOptions,
): Promise<T> {
const csrfToken = await deps.csrf.token(
options.signal ? { signal: options.signal } : undefined,
);
const outcome = await deps.operations.execute(
operationId,
{ ...(input as Record<string, unknown>), idempotencyKey: options.idempotencyKey, csrfToken },
{
routeId: ROUTE_ID,
intent: { idempotencyKey: options.idempotencyKey } as MutationIntent,
...(options.signal ? { signal: options.signal } : {}),
},
);
if (outcome.kind === "SUCCESS") return outcome.value as T;
const error = toStudioGatewayError(outcome, operationId);
// 세션 만료는 토큰을 버려 다음 명령이 새로 받아오게 한다.
if (error.code === "AUTHENTICATION_REQUIRED") deps.csrf.invalidate();
throw error;
}
return Object.freeze({
getDashboard: (options) => read("getStudioDashboard", {}, options),
listDocuments: (query, options) => read("listStudioDocuments", query, options),
createDocument: (input, options) => command("createStudioDocument", input, options),
getDocument: (documentId, options) =>
read("getStudioDocument", { documentId }, options),
saveDocument: (documentId, cmd, options) =>
command("saveStudioDocument", { documentId, ...cmd }, options),
validateDocument: (documentId, cmd, options) =>
command("validateStudioDocument", { documentId, ...cmd }, options),
createPreview: (documentId, cmd, options) =>
command("createStudioPreview", { documentId, ...cmd }, options),
getCurrentPreview: (documentId, options) =>
read("getCurrentStudioPreview", { documentId }, options),
publishDocument: (documentId, cmd, options) =>
command("publishStudioDocument", { documentId, ...cmd }, options),
unpublishPublication: (publicationId, cmd, options) =>
command("unpublishStudioPublication", { publicationId, ...cmd }, options),
listPublications: (query, options) => read("listStudioPublications", query, options),
getPublicationSnapshot: (publicationEventId, options) =>
read("getStudioPublicationSnapshot", { publicationEventId }, options),
getCatalog: (query, options) => read("listStudioCatalog", query, options),
}) satisfies StudioGateway;
}
```
- [ ] **Step 5: 테스트 헬퍼 완성**
`http-studio-gateway.test.ts``gatewayUnderTest` 스텁을 지우고 `realDependencies()`를 넣는다. 플랫폼 계약 런타임을 실제로 조립하지 않고, 계약 기여의 `projectRequest`로 요청을 만들어 `fetch`로 보내는 얇은 executor를 쓴다 — 이 테스트의 대상은 gateway이지 플랫폼 전송이 아니다.
```typescript
import { TECH_LOG_STUDIO_CONTRIBUTION } from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts";
const BASE = "http://api.test";
async function realDependencies() {
const byId = new Map(
TECH_LOG_STUDIO_CONTRIBUTION.http.map((entry) => [entry.contract.operationId, entry]),
);
const csrf = createCsrfTokenProvider({
async execute() {
const response = await fetch(`${BASE}/api/v1/studio/session`);
const body = (await response.json()) as {
csrfToken: string;
csrfHeaderName: string;
};
return { csrfToken: body.csrfToken, csrfHeaderName: body.csrfHeaderName };
},
});
const operations = {
async execute(operationId: string, input: unknown, context: { signal?: AbortSignal }) {
const entry = byId.get(operationId);
if (!entry) throw new Error(`unregistered operation: ${operationId}`);
const { contract } = entry;
const projected = contract.projectRequest(input as never);
let path = contract.pathTemplate;
for (const [name, value] of Object.entries(projected.pathValues)) {
path = path.replace(`{${name}}`, encodeURIComponent(value));
}
const url = new URL(`${BASE}${path}`);
for (const [name, value] of projected.queryEntries) {
url.searchParams.append(name, value);
}
const headers: Record<string, string> = {};
const payload = input as Record<string, unknown>;
if (contract.retrySemantics === "KEYED") {
headers["Idempotency-Key"] = String(payload["idempotencyKey"]);
headers["X-CSRF-TOKEN"] = String(payload["csrfToken"]);
}
if (contract.requestBody === "JSON") headers["content-type"] = "application/json";
const response = await fetch(url, {
method: contract.method,
headers,
...(contract.requestBody === "JSON"
? { body: JSON.stringify(projected.body) }
: {}),
...(context.signal ? { signal: context.signal } : {}),
});
if (contract.acceptedStatuses.includes(response.status)) {
const value =
contract.responseBody === "NONE" ? null : await response.json();
return { kind: "SUCCESS" as const, value, effect: "APPLIED_CONFIRMED" as const };
}
return {
kind: "PROBLEM" as const,
problem: await response.json(),
metadata: { status: response.status },
effect: "NOT_APPLIED" as const,
};
},
};
return { operations, csrf } as never;
}
```
`projectRequest`가 KEYED operation에서 `idempotencyKey`/`csrfToken`을 본문에 넣지 않도록, Task 3의 각 KEYED `projectRequest`는 body를 명시적으로 조립해야 한다(`SAVE_STUDIO_DOCUMENT` 예시가 그렇게 돼 있다). 이 테스트가 그 규칙을 지킨다.
- [ ] **Step 6: green 확인**
Run: `corepack pnpm exec vitest run tests/features/tech-log/http-studio-gateway.test.ts`
Expected: 4 tests PASS
- [ ] **Step 7: 커밋**
```bash
git add src/features/tech-log/adapters/http/http-studio-gateway.ts \
tests/mocks/handlers/tech-log-studio.ts \
tests/features/tech-log/http-studio-gateway.test.ts
git commit -m "feat: implement the TechLog Studio HTTP gateway"
```
---
### Task 5: 런타임 스위치
`config/runtime/*.json``.strict()` zod 스키마로 검증되므로 새 키는 스키마에 먼저 추가해야 한다. **선택 필드 + 기본값 `MOCK`**으로 추가해 기존 문서와 V1 호환성을 깨지 않는다.
**Files:**
- Modify: `src/contracts/release-artifacts.ts:153-161` (V2 스키마에만 추가)
- Modify: `src/bootstrap/runtime-config-schema.ts:35-55, 108-141`
- Modify: `src/contracts/env.ts:33-56`
- Modify: `config/runtime/local.json`, `development.json`, `staging.json`, `production.json`
- Modify: `src/features/tech-log/adapters/create-tech-log-feature-input.ts`
- Modify: `src/features/installed-feature-adapters.ts`
- Modify: `src/bootstrap/runtime-adapters.ts:506-508`
- Test: `tests/runtime-schema/runtime-config.test.ts`, `tests/features/tech-log/runtime-composition.test.ts`
**Interfaces:**
- Consumes: `createHttpStudioGateway`(Task 4), `createCsrfTokenProvider`(Task 2)
- Produces: `createTechLogFeatureInstalledInput(context: Readonly<{ studioSource: "MOCK" | "HTTP"; contractOperations: StudioOperationExecutor }>)` — 기존 무인자 시그니처를 대체한다.
- [ ] **Step 1: 실패하는 스키마 테스트 추가**
`tests/runtime-schema/runtime-config.test.ts`에 추가:
```typescript
test("accepts TECH_LOG_STUDIO_SOURCE and defaults it to MOCK", () => {
const base = {
APP_ENV: "local",
API_BASE_URL: "http://localhost:8080/",
TELEMETRY_ENABLED: false,
AUTH_MODE: "demo",
CONFIG_SCHEMA_VERSION: "2.0",
CAPABILITY_OVERRIDES: {
REALTIME: "DEFAULT",
WEB_WORKER: "DEFAULT",
SERVICE_WORKER: "DEFAULT",
OFFLINE_COMMANDS: "DEFAULT",
},
FEATURE_OVERRIDES: {},
};
const defaulted = validateRuntimeConfig(base);
assert.equal(defaulted.success, true);
if (defaulted.success) {
assert.equal(defaulted.data.TECH_LOG_STUDIO_SOURCE, "MOCK");
}
const explicit = validateRuntimeConfig({ ...base, TECH_LOG_STUDIO_SOURCE: "HTTP" });
assert.equal(explicit.success, true);
if (explicit.success) {
assert.equal(explicit.data.TECH_LOG_STUDIO_SOURCE, "HTTP");
}
const invalid = validateRuntimeConfig({ ...base, TECH_LOG_STUDIO_SOURCE: "LIVE" });
assert.equal(invalid.success, false);
});
```
- [ ] **Step 2: red 확인**
Run: `corepack pnpm test:runtime-schema`
Expected: FAIL — strict 스키마가 알 수 없는 키를 거절한다.
- [ ] **Step 3: 스키마 확장**
`src/contracts/release-artifacts.ts``runtimeConfigV2ArtifactSchema`에만 한 줄 추가한다. V1은 호환성 리더이므로 건드리지 않는다:
```typescript
export const runtimeConfigV2ArtifactSchema = z
.object({
...runtimeConfigArtifactFields,
CONFIG_SCHEMA_VERSION: z.literal("2.0"),
CAPABILITY_OVERRIDES: capabilityOverrideArtifactSchema,
FEATURE_OVERRIDES: featureOverrideArtifactSchema,
// TechLog Studio 어댑터 선택. Backend가 없을 때 기본값은 mock이다.
TECH_LOG_STUDIO_SOURCE: z.enum(["MOCK", "HTTP"]).default("MOCK"),
})
.strict()
.superRefine(runtimeConfigArtifactInvariants);
```
`src/bootstrap/runtime-config-schema.ts``RuntimeConfig` 타입에 추가:
```typescript
FEATURE_OVERRIDES: ProductFeatureOverrideMap;
/** §3.5과 같은 결의 런타임 선택. V1 문서는 항상 MOCK으로 정규화된다. */
TECH_LOG_STUDIO_SOURCE: "MOCK" | "HTTP";
```
같은 파일의 `normalized` 조립에 추가:
```typescript
FEATURE_OVERRIDES: Object.freeze({
...(isV2 ? (parsed as RuntimeConfigV2).FEATURE_OVERRIDES : {}),
}),
TECH_LOG_STUDIO_SOURCE: isV2
? (parsed as RuntimeConfigV2).TECH_LOG_STUDIO_SOURCE
: "MOCK",
```
`src/contracts/env.ts``ENV_REGISTRY`에 추가:
```typescript
FEATURE_OVERRIDES: runtime("public", false, null),
// TechLog Studio 어댑터 선택. Backend 미완성 구간의 기본값은 MOCK이다.
TECH_LOG_STUDIO_SOURCE: runtime("public", false, "MOCK"),
```
- [ ] **Step 4: 프로필 갱신**
`config/runtime/local.json``development.json``"TECH_LOG_STUDIO_SOURCE": "MOCK"`을, `staging.json``production.json``"TECH_LOG_STUDIO_SOURCE": "HTTP"``FEATURE_OVERRIDES` 앞에 추가한다.
- [ ] **Step 5: green 확인**
Run: `corepack pnpm test:runtime-schema`
Expected: PASS
- [ ] **Step 6: 합성 테스트 갱신**
`tests/features/tech-log/runtime-composition.test.ts``installedInputs()`가 새 인자를 넘기도록 고치고, 스위치 테스트를 추가한다:
```typescript
function installedInputs(
studioSource: "MOCK" | "HTTP" = "MOCK",
): InstalledInputs {
return createInstalledFeatureInputs({
studioSource,
contractOperations: {
async execute() {
throw new Error("executor is not used by composition tests");
},
},
});
}
test("selects the mock gateway by default and the HTTP gateway when switched", () => {
const mockGateway = installedInputs("MOCK")["tech-log"].createStudioGateway();
const httpGateway = installedInputs("HTTP")["tech-log"].createStudioGateway();
// mock은 인메모리 픽스처를 즉시 읽는다. HTTP는 executor를 호출해 실패한다.
assert.doesNotReject(mockGateway.getDashboard());
assert.rejects(httpGateway.getDashboard());
});
```
- [ ] **Step 7: 합성 배선 구현**
`src/features/tech-log/adapters/create-tech-log-feature-input.ts`:
```typescript
import {
TECH_LOG_FEATURE_ID,
type TechLogFeatureInput,
} from "../application/tech-log-feature-input.ts";
import { createHttpStudioGateway, type StudioOperationExecutor } from "./http/http-studio-gateway.ts";
import { createCsrfTokenProvider } from "./http/studio-session-csrf.ts";
import { createMockStudioGateway } from "./mock/mock-studio-gateway.ts";
import { publicContentQueries } from "./static/public-query.ts";
export type TechLogInstallContext = Readonly<{
studioSource: "MOCK" | "HTTP";
contractOperations: StudioOperationExecutor;
}>;
export function createTechLogFeatureInstalledInput(context: TechLogInstallContext) {
const createStudioGateway = () => {
if (context.studioSource === "MOCK") return createMockStudioGateway();
// 세션은 gateway 수명과 같다. 새 Studio 세션은 새 CSRF 토큰을 받는다.
const csrf = createCsrfTokenProvider({
async execute(options) {
const outcome = await context.contractOperations.execute(
"getStudioSession",
{},
{ routeId: "TECH_LOG_STUDIO", ...(options?.signal ? { signal: options.signal } : {}) },
);
if (outcome.kind !== "SUCCESS") {
throw new Error("studio session is unavailable");
}
const value = outcome.value as { csrfToken: string; csrfHeaderName: string };
return { csrfToken: value.csrfToken, csrfHeaderName: value.csrfHeaderName };
},
});
return createHttpStudioGateway({ operations: context.contractOperations, csrf });
};
const input: TechLogFeatureInput = Object.freeze({
publicContent: publicContentQueries,
createStudioGateway,
});
return Object.freeze({ featureId: TECH_LOG_FEATURE_ID, input });
}
```
`src/features/installed-feature-adapters.ts``createInstalledFeatureInputs` 시그니처에 `studioSource`를 더하고 `createTechLogFeatureInstalledInput(context)`로 넘긴다.
`src/bootstrap/runtime-adapters.ts:506`의 호출을 고친다:
```typescript
const featureInputs = createInstalledFeatureInputs({
contractOperations,
studioSource: config.TECH_LOG_STUDIO_SOURCE,
});
```
`config`는 그 스코프에서 이미 쓰이는 런타임 설정 값이다. 이름이 다르면 같은 스코프의 런타임 설정 식별자를 사용한다.
- [ ] **Step 8: green과 전체 회귀 확인**
Run: `corepack pnpm check:types && corepack pnpm test:runtime-schema && corepack pnpm test:tech-log && corepack pnpm test:unit`
Expected: PASS. 기본값이 `MOCK`이므로 기존 Studio 화면 테스트가 전부 그대로 통과해야 한다.
- [ ] **Step 9: 커밋**
```bash
git add src/contracts/release-artifacts.ts src/contracts/env.ts \
src/bootstrap/runtime-config-schema.ts src/bootstrap/runtime-adapters.ts \
src/features/installed-feature-adapters.ts \
src/features/tech-log/adapters/create-tech-log-feature-input.ts \
config/runtime/ tests/runtime-schema/runtime-config.test.ts \
tests/features/tech-log/runtime-composition.test.ts
git commit -m "feat: select the TechLog Studio adapter from runtime configuration"
```
---
### Task 6: Asset 포트와 JSON gateway
**Files:**
- Create: `src/features/tech-log/application/ports/studio-asset-gateway.ts`
- Create: `src/features/tech-log/adapters/http/http-studio-asset-gateway.ts`
- Modify: `src/features/tech-log/contracts/studio/contract.ts`
- Test: `tests/features/tech-log/studio-asset-gateway.test.ts`
**Interfaces:**
- Consumes: Task 3의 4개 asset operation, Task 2의 오류 매핑·CSRF, Task 4의 `StudioOperationExecutor`
- Produces:
- `contract.ts` 신규 alias: `Asset`, `AssetDetail`, `AssetPage`, `AssetKind`, `AssetManagementStatus`, `AssetUsage`, `UpdateAssetCommand`
- `type ListAssetsQuery = { q?: string; kind?: AssetKind; managementStatus?: AssetManagementStatus; cursor?: string; limit?: number }`
- `type UploadAssetForm = { file: File; kind: AssetKind; altText?: string; decorative?: boolean }`
- `interface StudioAssetGateway { listAssets(query, options?): Promise<AssetPage>; uploadAsset(form, options): Promise<Asset>; getAsset(assetId, options?): Promise<AssetDetail>; updateAssetMetadata(assetId, command, options): Promise<Asset>; deleteAsset(assetId, options): Promise<void> }`
- `createHttpStudioAssetGateway(deps: { operations: StudioOperationExecutor; csrf: CsrfTokenProvider; upload: StudioAssetUploadTransport }): StudioAssetGateway`
- `type StudioAssetUploadTransport = { upload(form: UploadAssetForm, headers: Readonly<Record<string,string>>, options?: { signal?: AbortSignal }): Promise<Asset> }` — 구현은 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<string, unknown>) {
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<string, string> = {};
const { dependencies } = deps({});
const gateway = createHttpStudioAssetGateway({
...dependencies,
upload: {
async upload(_form: unknown, headers: Record<string, string>) {
received = headers;
return READY_ASSET as never;
},
},
} as never);
const asset = await gateway.uploadAsset(
{ file: new File(["<svg/>"], "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<string, unknown>;
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<AssetPage>;
uploadAsset(form: UploadAssetForm, options: IdempotentOptions): Promise<Asset>;
getAsset(assetId: string, options?: RequestOptions): Promise<AssetDetail>;
updateAssetMetadata(
assetId: string,
command: UpdateAssetCommand,
options: IdempotentOptions,
): Promise<Asset>;
deleteAsset(assetId: string, options: IdempotentOptions): Promise<void>;
}
```
- [ ] **Step 5: gateway 구현**
`src/features/tech-log/adapters/http/http-studio-asset-gateway.ts`:
```typescript
import type {
Asset,
AssetDetail,
AssetPage,
UpdateAssetCommand,
} from "../../contracts/studio/contract.ts";
import type {
ListAssetsQuery,
StudioAssetGateway,
UploadAssetForm,
} from "../../application/ports/studio-asset-gateway.ts";
import type { IdempotentOptions, RequestOptions } from "../../application/ports/studio-gateway.ts";
import { toStudioGatewayError } from "./studio-error-mapping.ts";
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
import type { CsrfTokenProvider } from "./studio-session-csrf.ts";
export type StudioAssetUploadTransport = Readonly<{
upload(
form: UploadAssetForm,
headers: Readonly<Record<string, string>>,
options?: Readonly<{ signal?: AbortSignal }>,
): Promise<Asset>;
}>;
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<T>(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<T>(operationId: string, input: unknown, options: IdempotentOptions) {
const csrfToken = await deps.csrf.token(
options.signal ? { signal: options.signal } : undefined,
);
const outcome = await deps.operations.execute(
operationId,
{ ...(input as Record<string, unknown>), idempotencyKey: options.idempotencyKey, csrfToken },
{
routeId: ROUTE_ID,
...(options.signal ? { signal: options.signal } : {}),
},
);
if (outcome.kind === "SUCCESS") return outcome.value as T;
const error = toStudioGatewayError(outcome, operationId);
if (error.code === "AUTHENTICATION_REQUIRED") deps.csrf.invalidate();
throw error;
}
return Object.freeze({
listAssets: (query, options) => read<AssetPage>("listStudioAssets", query, options),
getAsset: (assetId, options) => read<AssetDetail>("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<Asset>("updateStudioAsset", { assetId, ...cmd }, options),
async deleteAsset(assetId, options) {
await command<null>("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(["<svg/>"], "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<string>(STUDIO_ERROR_CODES);
/**
* 계약 런타임은 `requestBody: "NONE" | "JSON"`만 표현할 수 있고 저수준 client는
* 본문을 JSON으로 직렬화한다. canonical `POST /assets`는 multipart이므로 이
* operation만 좁은 seam으로 분리한다. presigned/resumable로 옮기거나 플랫폼에
* MULTIPART 모드가 생기면 이 파일만 교체한다.
*/
export function createAssetUploadTransport(
deps: Readonly<{
baseUrl: string;
timeoutMs: number;
fetch?: typeof globalThis.fetch;
}>,
): StudioAssetUploadTransport {
const doFetch = deps.fetch ?? globalThis.fetch.bind(globalThis);
const endpoint = new URL("api/v1/studio/assets", deps.baseUrl).href;
function unavailable(detail: string): StudioGatewayError {
return new StudioGatewayError({
type: "https://techlog.local/problems/studio-unavailable",
title: "STUDIO_UNAVAILABLE",
status: 503,
detail,
code: "STUDIO_UNAVAILABLE",
retryable: true,
});
}
return Object.freeze({
async upload(form: UploadAssetForm, headers, options) {
const body = new FormData();
body.append("file", form.file, form.file.name);
body.append("kind", form.kind);
if (form.altText !== undefined) body.append("altText", form.altText);
if (form.decorative !== undefined) body.append("decorative", String(form.decorative));
const timeout = AbortSignal.timeout(deps.timeoutMs);
const signal = options?.signal
? AbortSignal.any([options.signal, timeout])
: timeout;
let response: Response;
try {
// content-type을 직접 넣지 않는다. boundary는 fetch가 만든다.
response = await doFetch(endpoint, {
method: "POST",
headers: { ...headers },
body,
signal,
credentials: "include",
});
} catch (error) {
throw unavailable(
error instanceof Error ? `Upload transport failed: ${error.message}` : "Upload transport failed.",
);
}
if (response.status === 201 || response.status === 200) {
return (await response.json()) as Asset;
}
let problem: ProblemDetails | null = null;
try {
problem = (await response.json()) as ProblemDetails;
} catch {
problem = null;
}
if (problem && typeof problem.code === "string" && CODES.has(problem.code)) {
throw new StudioGatewayError(problem);
}
throw unavailable(`Upload returned an uncontracted status ${response.status}.`);
},
});
}
```
- [ ] **Step 4: green 확인**
Run: `corepack pnpm exec vitest run tests/features/tech-log/asset-upload-transport.test.ts`
Expected: 5 tests PASS
- [ ] **Step 5: Asset gateway를 feature input에 배선**
여기까지는 Asset gateway를 만들 수 있을 뿐 UI가 도달할 수 없다. 이제 두 조각이 다 있으므로 배선한다.
`src/features/tech-log/application/tech-log-feature-input.ts`:
```typescript
import type { PublicContentQueries } from "./ports/public-content-queries.ts";
import type { StudioAssetGateway } from "./ports/studio-asset-gateway.ts";
import type { StudioGateway } from "./ports/studio-gateway.ts";
export const TECH_LOG_FEATURE_ID = "tech-log" as const;
export type TechLogFeatureInput = Readonly<{
publicContent: PublicContentQueries;
createStudioGateway(): StudioGateway;
createStudioAssetGateway(): StudioAssetGateway;
}>;
```
`create-tech-log-feature-input.ts``TechLogInstallContext``apiBaseUrl: string``requestTimeoutMs: number`를 더하고, gateway 두 개가 **같은 CSRF 제공자를 공유**하도록 세션 생성을 밖으로 끌어낸다. 공유하지 않으면 Studio 세션 하나에 토큰이 두 개 생긴다.
```typescript
export function createTechLogFeatureInstalledInput(context: TechLogInstallContext) {
function createSession() {
return createCsrfTokenProvider({
async execute(options) {
const outcome = await context.contractOperations.execute(
"getStudioSession",
{},
{ routeId: "TECH_LOG_STUDIO", ...(options?.signal ? { signal: options.signal } : {}) },
);
if (outcome.kind !== "SUCCESS") throw new Error("studio session is unavailable");
const value = outcome.value as { csrfToken: string; csrfHeaderName: string };
return { csrfToken: value.csrfToken, csrfHeaderName: value.csrfHeaderName };
},
});
}
// MOCK에서도 Asset gateway는 필요하다. 백엔드가 없으면 목록이 비고 업로드는
// 전송 실패로 끝난다 — UI가 그 상태를 표시하는 것이 정상 동작이다.
const createStudioAssetGateway = () =>
createHttpStudioAssetGateway({
operations: context.contractOperations,
csrf: createSession(),
upload: createAssetUploadTransport({
baseUrl: context.apiBaseUrl,
timeoutMs: context.requestTimeoutMs,
}),
});
const createStudioGateway = () => {
if (context.studioSource === "MOCK") return createMockStudioGateway();
return createHttpStudioGateway({
operations: context.contractOperations,
csrf: createSession(),
});
};
const input: TechLogFeatureInput = Object.freeze({
publicContent: publicContentQueries,
createStudioGateway,
createStudioAssetGateway,
});
return Object.freeze({ featureId: TECH_LOG_FEATURE_ID, input });
}
```
`src/features/installed-feature-adapters.ts``src/bootstrap/runtime-adapters.ts:506`의 호출에 `apiBaseUrl: config.API_BASE_URL`, `requestTimeoutMs: config.REQUEST_TIMEOUT_MS`를 더한다.
`src/features/tech-log/presentation/studio/studio-provider.tsx``createStudioGateway`를 lazy state/ref로 한 번만 호출하는 방식과 **동일하게** `createStudioAssetGateway`도 한 번만 호출해 제공하도록 확장한다. bfcache `pageshow`로 provider generation이 바뀔 때 두 gateway가 함께 재생성돼야 한다.
- [ ] **Step 6: 합성 테스트 갱신과 green 확인**
`tests/features/tech-log/runtime-composition.test.ts`의 키 단언을 고친다:
```typescript
assert.deepEqual(Object.keys(installed["tech-log"]).sort(), [
"createStudioAssetGateway",
"createStudioGateway",
"publicContent",
]);
```
같은 파일에 공유 검증을 추가한다:
```typescript
test("each Studio session gets its own asset gateway instance", () => {
const installed = installedInputs("HTTP");
assert.notEqual(
installed["tech-log"].createStudioAssetGateway(),
installed["tech-log"].createStudioAssetGateway(),
);
});
```
Run: `corepack pnpm check:types:app && corepack pnpm test:tech-log`
Expected: PASS
- [ ] **Step 7: 어댑터 리뷰 문서 작성**
`docs/reviews/adapters/06-tech-log-asset-upload.md`에 다음을 기록한다: 우회 대상(`external-contract-runtime.ts``requestBody` 제약, `client.ts:719`의 JSON 고정), 우회 범위(19개 중 `uploadStudioAsset` 1개), 유지되는 보증(CSRF·Idempotency-Key·오류 코드 매핑·타임아웃·credentials), 포기한 보증(계약 런타임의 byte limit·retry policy·진단 계측), 교체 계획(presigned/resumable 또는 플랫폼 MULTIPART 모드 도입 시 이 파일만 교체). `docs/reviews/adapters/INVENTORY.md`에 항목을 추가한다.
- [ ] **Step 8: 게이트 확인**
Run: `corepack pnpm check:adapter-inventory && corepack pnpm check:architecture && corepack pnpm check:browser-security`
Expected: PASS
- [ ] **Step 9: 커밋**
```bash
git add src/features/tech-log/adapters/http/asset-upload-transport.ts \
src/features/tech-log/application/tech-log-feature-input.ts \
src/features/tech-log/adapters/create-tech-log-feature-input.ts \
src/features/tech-log/presentation/studio/studio-provider.tsx \
src/features/installed-feature-adapters.ts src/bootstrap/runtime-adapters.ts \
docs/reviews/adapters/ tests/features/tech-log/
git commit -m "feat: add the TechLog asset multipart upload transport"
```
---
### Task 8: evidence alt 규칙을 publish 검증으로 이동
parser는 Asset의 `decorative`를 모르므로 빈 alt의 필요 여부를 판단할 수 없다. 규칙을 옮긴다. 이는 content format의 **의미 변경**이므로 픽스처를 함께 갱신한다.
**Files:**
- Modify: `src/features/tech-log/domain/content-format/parse-case-content.ts:505`
- Modify: `src/features/tech-log/adapters/mock/validate-working-copy.ts:166`
- Test: `tests/features/tech-log/content-format.test.ts`
**Interfaces:**
- Consumes: 없음
- Produces: parser는 빈 `alt`를 허용한다. `EVIDENCE_FIGURE.alt`는 여전히 `string`이며 빈 문자열이 될 수 있다.
- [ ] **Step 1: 실패하는 테스트 추가**
`tests/features/tech-log/content-format.test.ts`에 추가:
```typescript
test("parses a decorative evidence figure with an empty alt", () => {
const source = ':::evidence key="fetch-strategy-boundary" alt="" caption="" zoom="false"\n:::\n';
const parsed = parseCaseContent(source);
assert.equal(parsed.ok, true);
if (!parsed.ok) return;
const block = parsed.value.blocks.find((item) => item.type === "EVIDENCE_FIGURE");
assert.ok(block);
assert.equal(block.alt, "");
});
test("round-trips an empty alt without reintroducing it as an error", () => {
const source = ':::evidence key="fetch-strategy-boundary" alt="" caption="" zoom="false"\n:::\n';
const parsed = parseCaseContent(source);
assert.equal(parsed.ok, true);
if (!parsed.ok) return;
const reparsed = parseCaseContent(serializeCaseContent(parsed.value));
assert.equal(reparsed.ok, true);
});
test("still rejects an unsafe evidence key", () => {
const parsed = parseCaseContent(':::evidence key="../etc" alt="x" caption="" zoom="false"\n:::\n');
assert.equal(parsed.ok, false);
});
```
기존 테스트 중 "빈 alt가 파싱 오류"를 단언하는 케이스가 있으면 위 의미로 고친다. 삭제하지 말고 반대 단언으로 바꾼다.
- [ ] **Step 2: red 확인**
Run: `corepack pnpm exec vitest run tests/features/tech-log/content-format.test.ts`
Expected: FAIL — parser가 빈 alt를 거절한다.
- [ ] **Step 3: parser 규칙 제거**
`src/features/tech-log/domain/content-format/parse-case-content.ts:505`의 한 줄을 지우고 주석으로 대체한다:
```typescript
// alt 필요 여부는 Asset의 `decorative`에 달려 있어 여기서는 판단할 수
// 없다. 빈 alt는 구문 오류가 아니며 publish 검증이 Asset metadata와 함께
// 판정한다(설계 §7.7).
```
- [ ] **Step 4: publish 검증에 규칙 추가**
`src/features/tech-log/adapters/mock/validate-working-copy.ts:166` 부근의 evidence 검사에 alt 규칙을 더한다. mock은 Asset metadata를 모르므로 정적 레지스트리를 `decorative: false`로 간주한다:
```typescript
if (!isSupportedEvidenceKey(block.key)) {
error("EVIDENCE_UNSUPPORTED", "/bodyMarkdown", `지원하지 않는 Evidence: ${block.key}`);
} else if (block.alt.trim().length === 0) {
// 정적 evidence 자산은 장식용이 아니다. Asset 기반 경로에서는 서버가
// Asset.decorative로 같은 판정을 한다.
error("EVIDENCE_ALT_REQUIRED", "/bodyMarkdown", `Evidence에 대체 텍스트가 필요합니다: ${block.key}`);
}
```
- [ ] **Step 5: green 확인**
Run: `corepack pnpm exec vitest run tests/features/tech-log/content-format.test.ts && corepack pnpm test:tech-log`
Expected: PASS
- [ ] **Step 6: 커밋**
```bash
git add src/features/tech-log/domain/content-format/parse-case-content.ts \
src/features/tech-log/adapters/mock/validate-working-copy.ts \
tests/features/tech-log/content-format.test.ts
git commit -m "fix: judge evidence alt text with asset metadata, not syntax"
```
---
### Task 9: Asset 기반 evidence resolver
렌더러에는 이미 `resolveEvidenceAsset: ResolveEvidenceAsset` 주입점이 6개 지점에 있다(`domain/public-render-content.ts:9`). 새로 만들 것은 렌더러 구조가 아니라 **Backend Asset을 해석하는 resolver 구현**이다. 이 Task 없이 Task 10을 하면 사용자가 삽입한 backend `assetKey``getEvidenceAsset``throw`해 즉시 미리보기가 깨진다.
canonical `EvidenceFigureBlock``ResolvedAsset`(`assetId`, `assetKey`, `mediaType`, `publicPath`, `width`, `height`, `decorative`)을 블록 안에 담아 보낸다. 따라서 서버가 만든 세 표면(Public Preview·Snapshot·Published)은 블록에서 바로 해석하고, **Instant Preview만** 클라이언트가 적재한 Asset 목록으로 해석한다.
**Files:**
- Create: `src/features/tech-log/presentation/shared/public-render/asset-resolvers.ts`
- Modify: `src/features/tech-log/presentation/studio/components/instant-preview.tsx:49`
- Modify: `src/features/tech-log/presentation/studio/components/public-preview-screen.tsx:290`
- Modify: `src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx:169`
- Test: `tests/features/tech-log/evidence-asset-resolver.test.tsx`
**Interfaces:**
- Consumes: `Asset`(Task 6), `ResolveEvidenceAsset`/`EvidenceAsset`(`domain/public-render-content.ts`)
- Produces:
- `createAssetCatalogResolver(assets: readonly Asset[]): ResolveEvidenceAsset` — Instant Preview용. 정적 레지스트리로 폴백한다.
- `createResolvedAssetResolver(blocks: readonly { type: string; key?: string; asset?: ResolvedAssetLike }[]): ResolveEvidenceAsset` — 서버 render model용.
- `MISSING_EVIDENCE_ASSET: EvidenceAsset` — 해석 실패 시의 안전한 자리표시자.
- [ ] **Step 1: 실패하는 테스트 작성**
`tests/features/tech-log/evidence-asset-resolver.test.tsx`:
```typescript
import assert from "node:assert/strict";
import { test } from "vitest";
import {
createAssetCatalogResolver,
createResolvedAssetResolver,
MISSING_EVIDENCE_ASSET,
} from "../../../src/features/tech-log/presentation/shared/public-render/asset-resolvers.ts";
const READY = {
id: "11111111-1111-4111-8111-111111111111",
assetKey: "boundary",
kind: "DIAGRAM",
mediaType: "image/svg+xml",
originalFilename: "b.svg",
byteSize: 10,
width: 1080,
height: 420,
altText: "경계",
decorative: false,
managementStatus: "READY",
publicPath: "/media/boundary.svg",
usageCount: 0,
version: 1,
createdAt: "2026-08-14T01:00:00.000Z",
updatedAt: "2026-08-14T01:00:00.000Z",
} as never;
test("resolves a backend asset key to its public path", () => {
const resolve = createAssetCatalogResolver([READY]);
const asset = resolve("boundary");
assert.equal(asset.src, "/media/boundary.svg");
assert.equal(asset.width, 1080);
assert.equal(asset.height, 420);
});
test("falls back to the static registry for the legacy hardcoded key", () => {
const resolve = createAssetCatalogResolver([]);
const asset = resolve("fetch-strategy-boundary");
assert.equal(asset.src, "/media/fetch-strategy-boundary.svg");
});
test("returns the placeholder instead of throwing on an unknown key", () => {
const resolve = createAssetCatalogResolver([]);
assert.deepEqual(resolve("does-not-exist"), MISSING_EVIDENCE_ASSET);
});
test("never resolves a QUARANTINED asset", () => {
const resolve = createAssetCatalogResolver([
{ ...(READY as object), managementStatus: "QUARANTINED" } as never,
]);
assert.deepEqual(resolve("boundary"), MISSING_EVIDENCE_ASSET);
});
test("resolves from the server render model blocks", () => {
const resolve = createResolvedAssetResolver([
{
type: "EVIDENCE_FIGURE",
key: "boundary",
asset: {
assetId: "11111111-1111-4111-8111-111111111111",
assetKey: "boundary",
mediaType: "image/svg+xml",
publicPath: "/media/boundary.svg",
width: 800,
height: 300,
decorative: false,
},
},
]);
assert.equal(resolve("boundary").src, "/media/boundary.svg");
assert.equal(resolve("boundary").width, 800);
});
```
- [ ] **Step 2: red 확인**
Run: `corepack pnpm exec vitest run tests/features/tech-log/evidence-asset-resolver.test.tsx`
Expected: FAIL — 모듈 없음.
- [ ] **Step 3: resolver 구현**
`src/features/tech-log/presentation/shared/public-render/asset-resolvers.ts`:
```typescript
import {
getEvidenceAsset,
isSupportedEvidenceKey,
} from "../../../adapters/static/evidence-assets.ts";
import type { Asset } from "../../../contracts/studio/contract.ts";
import type {
EvidenceAsset,
ResolveEvidenceAsset,
} from "../../../domain/public-render-content.ts";
export type ResolvedAssetLike = Readonly<{
assetKey: string;
mediaType: string;
publicPath: string;
width: number | null;
height: number | null;
decorative: boolean;
}>;
/**
* `ResolveEvidenceAsset`은 total 함수다. 해석 실패에 throw하면 편집 중인 문서
* 하나가 화면 전체를 무너뜨리므로 자리표시자를 돌려준다. 게시 차단은 publish
* 검증이 담당한다.
*/
export const MISSING_EVIDENCE_ASSET: EvidenceAsset = Object.freeze({
src: "",
width: 1,
height: 1,
triggerLabel: "사용할 수 없는 Evidence",
dialogLabel: "사용할 수 없는 Evidence",
});
function fromDescriptor(descriptor: ResolvedAssetLike): EvidenceAsset {
const label = `${descriptor.assetKey} 이미지 크게 보기`;
return Object.freeze({
src: descriptor.publicPath,
width: descriptor.width ?? 1,
height: descriptor.height ?? 1,
triggerLabel: label,
dialogLabel: `${descriptor.assetKey} 확대`,
});
}
function staticFallback(key: string): EvidenceAsset {
return isSupportedEvidenceKey(key) ? getEvidenceAsset(key) : MISSING_EVIDENCE_ASSET;
}
/** Instant Preview: 편집기가 적재한 Asset 목록으로 해석한다. */
export function createAssetCatalogResolver(
assets: readonly Asset[],
): ResolveEvidenceAsset {
const byKey = new Map<string, Asset>();
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<string, ResolvedAssetLike>();
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`
- `<AssetPicker gateway={...} onInsert={(directive: string) => void} />`
- `<AssetUploadDialog gateway={...} onUploaded={(asset: Asset) => 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(<AssetPicker gateway={gatewayOf([READY])} onInsert={(value) => 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(<AssetPicker gateway={gatewayOf([READY, QUARANTINED])} onInsert={() => {}} />);
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<readonly Asset[]>([]);
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 <p className="studio-error">Asset 목록을 불러오지 못했습니다.</p>;
return <div className="asset-picker">
<ul>
{selectable.map((asset) => <li key={asset.id}>
<button
type="button"
onClick={() => props.onInsert(buildEvidenceDirective({
assetKey: asset.assetKey,
alt: asset.decorative ? "" : (asset.altText ?? ""),
caption: "",
zoom: asset.kind === "DIAGRAM",
}))}
>
{asset.assetKey}
</button>
</li>)}
</ul>
</div>;
}
```
- [ ] **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<UploadState["kind"], string> = {
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<UploadState>({ kind: "IDLE" });
const inputRef = useRef<HTMLInputElement>(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 <div className="asset-upload-dialog">
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml,application/pdf"
disabled={state.kind === "UPLOADING"}
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (!file) {
setState({ kind: "SELECTION_FAILED", message: MESSAGES.SELECTION_FAILED });
return;
}
void submit(file);
}}
/>
<p role="status" aria-live="polite">{MESSAGES[state.kind]}</p>
<button type="button" onClick={props.onClose}>닫기</button>
</div>;
}
```
`unpublish-dialog.tsx`의 focus trap·복귀 패턴과 클래스 구성을 그대로 따라 dialog 껍데기를 맞춘다.
- [ ] **Step 5: 편집기에 연결**
`case-fields.tsx``bodyMarkdown` textarea 옆에 Picker 진입 버튼을 둔다. 삽입은 커서 위치를 보존한다:
```tsx
function insertAtCursor(
textarea: HTMLTextAreaElement,
directive: string,
commit: (next: string) => void,
) {
const { selectionStart, selectionEnd, value } = textarea;
const prefix = value.slice(0, selectionStart);
const suffix = value.slice(selectionEnd);
// directive는 블록이므로 앞뒤 빈 줄을 보장한다.
const before = prefix.length === 0 || prefix.endsWith("\n\n") ? prefix : `${prefix}\n\n`;
const after = suffix.startsWith("\n") ? suffix : `\n${suffix}`;
commit(`${before}${directive}${after}`);
}
```
다른 필드와 레이아웃 구조는 바꾸지 않는다. Picker와 Upload dialog는 `bodyMarkdown`을 가진 CASE 편집기에만 붙인다.
- [ ] **Step 6: green 확인**
Run: `corepack pnpm exec vitest run tests/features/tech-log/asset-picker.test.tsx && corepack pnpm test:tech-log`
Expected: PASS
- [ ] **Step 7: 커밋**
```bash
git add src/features/tech-log/presentation/studio/components/ \
tests/features/tech-log/asset-picker.test.tsx
git commit -m "feat: insert evidence directives from the TechLog asset picker"
```
---
### Task 11: Asset Library 화면과 라우트
**Files:**
- Create: `src/features/tech-log/presentation/studio/pages/assets-page.tsx`
- Create: `src/features/tech-log/presentation/studio/components/asset-library.tsx`
- Modify: `src/features/tech-log/contracts/tech-log-route-contract.ts`
- Modify: `src/features/tech-log/presentation/tech-log-route-runtime.tsx`
- Test: `tests/features/tech-log/route-contract.test.ts`, `tests/features/tech-log/asset-library.test.tsx`
**Interfaces:**
- Consumes: `StudioAssetGateway`(Task 6)
- Produces: 라우트 `TECH_LOG_STUDIO_ASSETS``path: "/studio/assets"`, `layoutGroup: "STUDIO"`, `paramsSchema: null`, `searchSchema: null`, `title: "Asset"`, `navigationLabel: null`, `navigationOrder: null`
`navigationLabel``null`로 두는 것이 핵심이다. Asset은 secondary utility이며 primary navigation(`작업본 · 게시 기록 · 새 문서`)을 CMS 구조로 바꾸지 않는다.
- [ ] **Step 1: 라우트 테스트 갱신**
`tests/features/tech-log/route-contract.test.ts`에서 기대 라우트 수를 27에서 28로 올리고 새 항목을 단언한다:
```typescript
test("registers the studio asset library outside primary navigation", () => {
const route = TECH_LOG_ROUTE_DEFINITIONS.find(
(item) => item.routeId === "TECH_LOG_STUDIO_ASSETS",
);
assert.ok(route);
assert.equal(route.path, "/studio/assets");
assert.equal(route.layoutGroup, "STUDIO");
assert.equal(route.navigationLabel, null);
assert.equal(route.navigationOrder, null);
});
```
기존 "정확히 27개" 단언이 있으면 28로 고친다. `/studio/*` catch-all보다 앞에 선언돼야 한다.
- [ ] **Step 2: red 확인**
Run: `corepack pnpm exec vitest run tests/features/tech-log/route-contract.test.ts`
Expected: FAIL
- [ ] **Step 3: 라우트 추가**
`tech-log-route-contract.ts``TECH_LOG_ROUTE_SPECS`에서 `TECH_LOG_STUDIO_PUBLICATION_PREVIEW` 뒤, `TECH_LOG_STUDIO_NOT_FOUND` 앞에 추가한다:
```typescript
defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }),
```
`tech-log-route-runtime.tsx``TECH_LOG_STUDIO_ASSETS → <AssetsPage />` 매핑을 더한다.
- [ ] **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<readonly Asset[]>([]);
const [selected, setSelected] = useState<AssetDetail | null>(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 <section className="asset-library">
<p role="status" aria-live="polite">{notice}</p>
<ul>
{assets.map((asset) => <li key={asset.id}>
<button
type="button"
onClick={() => {
void props.gateway.getAsset(asset.id).then(setSelected).catch(() => {
setNotice("Asset 상세를 불러오지 못했습니다.");
});
}}
>
{asset.assetKey}
</button>
<span>{asset.managementStatus}</span>
<span>사용 {asset.usageCount}</span>
</li>)}
</ul>
{selected ? <div className="asset-detail">
<h2>{selected.asset.assetKey}</h2>
<ul>
{selected.usages.map((usage) => <li key={usage.documentId}>{usage.title}</li>)}
</ul>
{canHardDelete(selected)
? <button type="button" onClick={() => void remove(selected)}>삭제</button>
: <button type="button" onClick={() => void archive(selected)}>보관</button>}
</div> : null}
</section>;
}
```
`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(<AssetLibrary gateway={gatewayOf({
asset: { ...(ASSET as object), usageCount: 1 },
usages: [{ documentId: "d", documentKind: "CASE", title: "사용 중 문서", published: true }],
hasPublicationHistory: true,
})} />);
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(<AssetLibrary gateway={gatewayOf(
{ asset: ASSET, usages: [], hasPublicationHistory: false },
() => {
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`에 커밋돼 있다.