openapi-typescript needs the TypeScript 5 classic compiler API; this repo pins typescript@7.0.2 (VD-01), whose root export has no compiler API. Run the generator in an isolated pnpm dlx environment instead, so the lockfile and peer contract are untouched. Also make check:tech-log-contract work without the canonical repo or the generator — it read an absolute path to a sibling repo that CI never has. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3480 lines
135 KiB
Markdown
3480 lines
135 KiB
Markdown
# TechLog Backend Alignment Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Studio HTTP 계약을 canonical `studio-v1.yaml` 단일 출처에 고정하고, `StudioGateway`의 모든 operation과 Asset capability를 HTTP 어댑터로 구현한다.
|
||
|
||
**Architecture:** canonical OpenAPI를 저장소에 vendor하고 타입을 생성한다. 계약 기여(`EXTERNAL_PACKAGE` provenance)의 digest로 canonical revision을 암호학적으로 고정해 drift를 빌드에서 막는다. JSON operation 18개는 플랫폼 계약 런타임(`contractOperations`)을 통과하고, 계약 런타임이 표현할 수 없는 multipart 업로드 1개만 전용 전송 seam으로 분리한다. mock gateway는 삭제하지 않고 런타임 스위치 뒤의 기본값으로 유지한다.
|
||
|
||
**Tech Stack:** TypeScript 7, React 19, Vite 8, Vitest 4, MSW 2, zod 4, `openapi-typescript`(신규)
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-08-17-techlog-backend-alignment-design.md`
|
||
|
||
## Global Constraints
|
||
|
||
- Canonical 계약: `/home/donghyeon/workspace/tech-log-design-package/contracts/openapi/studio-v1.yaml`, spec version `2.0.0`, operation 19개.
|
||
- **digest와 revision은 생성 시점에 `canonical-source.json`에 기록한다. 계획서나 테스트에 값을 박지 않는다.** canonical 저장소는 활발히 편집 중이다(2026-08-17 하루에만 두 번 변경, 마지막은 description 전용이라 구조 영향 없음). 고정 값을 박으면 계약이 그대로인데도 테스트가 깨진다.
|
||
- 생성기는 저장소 의존성에 넣지 않는다. `openapi-typescript`는 TypeScript 5의 classic compiler API를 요구하고 이 저장소는 TypeScript `7.0.2`를 고정한다(VD-01). TS7 루트 export는 `{version, versionMajorMinor}`뿐이라 compiler API가 없다. 격리된 `pnpm dlx`로 실행한다.
|
||
- canonical operation은 **19개**다. JSON 18개는 계약 기여에, multipart `uploadStudioAsset` 1개는 업로드 전송 seam에 존재해야 한다.
|
||
- 오류 코드는 **23개** 전부를 처리한다. 목록은 Task 2에 있다.
|
||
- 브랜치는 `feature/techlog-backend-alignment`다. `main`에 직접 커밋하지 않는다.
|
||
- `src/application/ports/studio-gateway.ts`의 시그니처를 변경하지 않는다. UI는 어댑터 종류를 알지 못한다.
|
||
- 현재 Public UI·라우트와 Studio 작업 흐름을 변경하지 않는다. `presentation/public/**`, `presentation/shared/public-render/**`, 기존 `document-*`/`validation-*`/`publish-*`/`publication-*` 컴포넌트는 Task 8~10에서 지정한 지점 외에는 손대지 않는다.
|
||
- **`tests/mocks/scenarios/catalog.ts`의 `HTTP_SCENARIO_OPERATION_IDS`에 TechLog operation을 추가하지 않는다.** 그 카탈로그는 플랫폼 전송 계층의 실패 모드 행렬이며 reference feature 3개 operation으로 이미 입증돼 있다. 어떤 스크립트도 계약 등록부와 대조하지 않으므로(확인함) TechLog operation을 넣으면 19 시나리오 × 18 operation의 증거 부담만 생긴다. TechLog는 `tests/features/tech-log/`에서 MSW로 gateway 수준 계약을 검증한다.
|
||
- 플랫폼 파일 중 이 계획이 수정을 허용하는 것은 `src/contracts/release-artifacts.ts`, `src/bootstrap/runtime-config-schema.ts`, `src/contracts/env.ts`, `src/contracts/rest-profiles.ts`, `src/features/installed-contract-contributions.ts`, `src/features/installed-feature-adapters.ts`, `src/bootstrap/runtime-adapters.ts`뿐이다. `src/contracts/external-contract-runtime.ts`, `src/adapters/http/client.ts`, `src/adapters/http/http-execution-v3.ts`는 **수정하지 않는다**(template 동기화 대상).
|
||
- **인증·CSRF·Idempotency는 플랫폼 seam을 쓴다. 직접 헤더를 만들지 않는다.** 계약의 `projectRequest`는 `pathValues`/`queryEntries`/`body`만 만들 수 있고 헤더 채널이 없다(`external-contract-runtime.ts`의 `HttpRequestProjection`). 헤더는 두 경로로만 들어간다.
|
||
- `Idempotency-Key`: 실행 context의 `intent.idempotencyKey`에서 온다(`http-execution-v3.ts:689-690`). 계약 소유 헤더이므로 credential 쪽에서 공급하면 거절된다(`:657-661`). 명령 gateway는 `intent`를 넘기고 **입력 본문에 `idempotencyKey`를 넣지 않는다**.
|
||
- `x-csrf-token`: `attachCredentials` collaborator가 공급하고 `admitCredentialHeaders`가 auth profile의 허용 목록으로 검사한다(`http-execution-v3.ts:435, :672`). gateway 입력에 `csrfToken`을 넣지 않는다.
|
||
- `frontend.authProfileId`는 `INSTALLED_REST_AUTH_PROFILES`에 등록된 값이어야 한다. 없는 값이면 **composition이 실패한다**(`external-contract-runtime.ts:325-326`). 등록은 Task 3 Step 1이 한다.
|
||
- `frontend.responseByteLimit`은 **1 이상**이어야 하고(`external-contract-runtime.ts:336-337`) `hardResponseBytes: 8_388_608`을 넘을 수 없다. `requestByteLimit`은 0 이상 `hardRequestBytes: 1_048_576` 이하다.
|
||
- 매 Task는 red → green → 게이트 → commit 순서를 지킨다.
|
||
|
||
---
|
||
|
||
### Task 1: 계약 생성 파이프라인과 drift 게이트
|
||
|
||
canonical yaml을 vendor하고 타입을 재생성 가능하게 만든다. 현재 `generated.ts`는 `openapi-typescript`로 만들어졌으나 도구도 스크립트도 저장소에 없어 재생성이 불가능하다 — 두 계약이 갈라진 근본 원인이다.
|
||
|
||
**Files:**
|
||
- Create: `scripts/generate-tech-log-contract.ts`
|
||
- Create: `src/features/tech-log/contracts/studio/canonical-source.json`
|
||
- Modify: `src/features/tech-log/contracts/studio/studio-api.openapi.yaml` (canonical 사본으로 교체)
|
||
- Modify: `src/features/tech-log/contracts/studio/generated.ts` (재생성)
|
||
- Modify: `package.json` (devDependency + scripts)
|
||
- Test: `tests/features/tech-log/contract-generation.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: 없음 (첫 Task)
|
||
- Produces: `canonical-source.json` — `{ packageId: string, version: string, digest: string, sourceRevision: string, operationIds: string[] }`. Task 2가 provenance와 parity 검증에 사용한다.
|
||
|
||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||
|
||
`tests/features/tech-log/contract-generation.test.ts`:
|
||
|
||
```typescript
|
||
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import { createHash } from "node:crypto";
|
||
import { test } from "vitest";
|
||
|
||
import canonicalSource from "../../../src/features/tech-log/contracts/studio/canonical-source.json" with { type: "json" };
|
||
|
||
const YAML_PATH = "src/features/tech-log/contracts/studio/studio-api.openapi.yaml";
|
||
|
||
test("vendored contract matches the recorded canonical digest", () => {
|
||
const bytes = readFileSync(YAML_PATH);
|
||
const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
||
assert.equal(digest, canonicalSource.digest);
|
||
});
|
||
|
||
test("canonical source records the pinned revision and version", () => {
|
||
assert.equal(canonicalSource.packageId, "tech-log-studio-contract");
|
||
assert.equal(canonicalSource.version, "2.0.0");
|
||
// revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로
|
||
// 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다.
|
||
assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/);
|
||
assert.match(canonicalSource.digest, /^sha256:[0-9a-f]{64}$/);
|
||
});
|
||
|
||
test("canonical source lists all 19 operationIds", () => {
|
||
assert.equal(canonicalSource.operationIds.length, 19);
|
||
assert.ok(canonicalSource.operationIds.includes("uploadStudioAsset"));
|
||
assert.ok(canonicalSource.operationIds.includes("getStudioSession"));
|
||
});
|
||
|
||
test("vendored contract declares the CSRF header", () => {
|
||
const yaml = readFileSync(YAML_PATH, "utf8");
|
||
assert.ok(yaml.includes("X-CSRF-TOKEN"));
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: red 확인**
|
||
|
||
Run: `corepack pnpm exec vitest run tests/features/tech-log/contract-generation.test.ts`
|
||
Expected: FAIL — `canonical-source.json` 모듈을 찾을 수 없다.
|
||
|
||
- [ ] **Step 3: 생성 스크립트 작성**
|
||
|
||
`scripts/generate-tech-log-contract.ts`. 두 모드는 **의존성이 다르다**. 생성은 canonical 저장소와 네트워크가 필요하고, 검증은 저장소 안의 파일만 읽는다 — CI에는 canonical 사본도 생성 도구도 없기 때문이다.
|
||
|
||
```typescript
|
||
/**
|
||
* canonical studio-v1.yaml을 vendor하고 타입을 생성한다.
|
||
*
|
||
* 생성기는 저장소 의존성에 넣지 않는다. `openapi-typescript`는 TypeScript 5의
|
||
* classic compiler API를 요구하는데 이 저장소는 TypeScript 7.0.2를 고정하고
|
||
* 있고(VD-01), TS7 루트는 compiler API를 노출하지 않는다. 격리된 `pnpm dlx`
|
||
* 환경에서 실행하면 lockfile과 peer 계약을 건드리지 않고 같은 산출물을 얻는다.
|
||
*
|
||
* `--check`는 canonical 저장소도 생성기도 없이 동작한다. vendor된 계약이
|
||
* 기록된 digest와 일치하는지, 기록된 operationId가 생성물에 모두 존재하는지만
|
||
* 본다. 손으로 yaml이나 generated.ts를 고치면 여기서 걸린다.
|
||
*/
|
||
import { createHash } from "node:crypto";
|
||
import { execFileSync } from "node:child_process";
|
||
import { readFileSync, writeFileSync } from "node:fs";
|
||
import { argv, env, exit } from "node:process";
|
||
|
||
const CANONICAL_ROOT =
|
||
env.TECH_LOG_DESIGN_PACKAGE ?? "/home/donghyeon/workspace/tech-log-design-package";
|
||
const CANONICAL_YAML = `${CANONICAL_ROOT}/contracts/openapi/studio-v1.yaml`;
|
||
const VENDOR_YAML = "src/features/tech-log/contracts/studio/studio-api.openapi.yaml";
|
||
const GENERATED = "src/features/tech-log/contracts/studio/generated.ts";
|
||
const SOURCE_RECORD = "src/features/tech-log/contracts/studio/canonical-source.json";
|
||
|
||
const OPENAPI_TYPESCRIPT = "openapi-typescript@7.9.1";
|
||
const GENERATOR_TYPESCRIPT = "typescript@5.9.3";
|
||
|
||
const check = argv.includes("--check");
|
||
|
||
function digestOf(bytes: Buffer | string): string {
|
||
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
||
}
|
||
|
||
function operationIdsOf(yaml: string): string[] {
|
||
return [...yaml.matchAll(/^\s+operationId:\s*(\S+)\s*$/gmu)].map((match) => match[1]!);
|
||
}
|
||
|
||
function specVersionOf(yaml: string): string {
|
||
const match = /^\s{2}version:\s*(\S+)\s*$/mu.exec(yaml);
|
||
if (!match) throw new Error("canonical yaml has no info.version");
|
||
return match[1]!;
|
||
}
|
||
|
||
type CanonicalRecord = Readonly<{
|
||
packageId: string;
|
||
version: string;
|
||
digest: string;
|
||
sourceRevision: string;
|
||
operationIds: readonly string[];
|
||
}>;
|
||
|
||
function fail(problems: readonly string[]): never {
|
||
console.error(`tech-log contract drift:\n- ${problems.join("\n- ")}`);
|
||
console.error("Run: corepack pnpm generate:tech-log-contract");
|
||
exit(1);
|
||
}
|
||
|
||
if (check) {
|
||
const vendored = readFileSync(VENDOR_YAML, "utf8");
|
||
const generated = readFileSync(GENERATED, "utf8");
|
||
const record = JSON.parse(readFileSync(SOURCE_RECORD, "utf8")) as CanonicalRecord;
|
||
const problems: string[] = [];
|
||
|
||
if (digestOf(readFileSync(VENDOR_YAML)) !== record.digest) {
|
||
problems.push(`${VENDOR_YAML} does not hash to the recorded digest`);
|
||
}
|
||
const vendoredOperations = operationIdsOf(vendored);
|
||
if (vendoredOperations.join(" ") !== [...record.operationIds].join(" ")) {
|
||
problems.push(`${SOURCE_RECORD} operationIds differ from ${VENDOR_YAML}`);
|
||
}
|
||
if (specVersionOf(vendored) !== record.version) {
|
||
problems.push(`${SOURCE_RECORD} version differs from ${VENDOR_YAML}`);
|
||
}
|
||
// 생성물은 operationId로 키가 매겨진 `operations` 인터페이스를 노출한다.
|
||
for (const operationId of record.operationIds) {
|
||
if (!new RegExp(`^\\s{4}${operationId}:`, "mu").test(generated)) {
|
||
problems.push(`${GENERATED} is missing operation ${operationId}`);
|
||
}
|
||
}
|
||
if (problems.length > 0) fail(problems);
|
||
console.log(
|
||
`tech-log contract is in sync: ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
|
||
);
|
||
exit(0);
|
||
}
|
||
|
||
const canonicalBytes = readFileSync(CANONICAL_YAML);
|
||
const canonicalText = canonicalBytes.toString("utf8");
|
||
|
||
const record: CanonicalRecord = {
|
||
packageId: "tech-log-studio-contract",
|
||
version: specVersionOf(canonicalText),
|
||
digest: digestOf(canonicalBytes),
|
||
sourceRevision: execFileSync(
|
||
"git",
|
||
["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"],
|
||
{ encoding: "utf8" },
|
||
).trim(),
|
||
operationIds: operationIdsOf(canonicalText),
|
||
};
|
||
|
||
// 격리 실행. 저장소의 node_modules와 lockfile은 그대로다.
|
||
const generated = execFileSync(
|
||
"corepack",
|
||
[
|
||
"pnpm",
|
||
"dlx",
|
||
"--package",
|
||
GENERATOR_TYPESCRIPT,
|
||
"--package",
|
||
OPENAPI_TYPESCRIPT,
|
||
"openapi-typescript",
|
||
CANONICAL_YAML,
|
||
],
|
||
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
|
||
);
|
||
|
||
writeFileSync(VENDOR_YAML, canonicalText);
|
||
writeFileSync(GENERATED, generated);
|
||
writeFileSync(SOURCE_RECORD, `${JSON.stringify(record, null, 2)}\n`);
|
||
console.log(
|
||
`Generated from ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 4: 스크립트 등록**
|
||
|
||
`openapi-typescript`를 devDependency로 **추가하지 않는다**. `package.json`의 `scripts`에 세 줄만 더한다:
|
||
|
||
```json
|
||
"generate:tech-log-contract": "node scripts/generate-tech-log-contract.ts",
|
||
"check:tech-log-contract": "node scripts/generate-tech-log-contract.ts --check",
|
||
"test:tech-log": "vitest run tests/features/tech-log --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/tech-log.xml",
|
||
```
|
||
|
||
`test:all` 스크립트의 `test:reference-feature` 뒤에 `&& corepack pnpm test:tech-log`를 추가한다.
|
||
|
||
- [ ] **Step 5: 생성 실행**
|
||
|
||
Run: `corepack pnpm generate:tech-log-contract`
|
||
Expected: `Generated from tech-log-studio-contract@2.0.0 (<7자리 revision>), 19 operations.`
|
||
|
||
`generated.ts`의 diff가 크다. `paths`에 `/api/v1/studio/session`, `/api/v1/studio/assets`, `/api/v1/studio/assets/{assetId}`가 생기고 `components.schemas`에 `StudioSession`, `Asset`, `AssetDetail`, `AssetPage`, `AssetUploadForm`, `UpdateAssetCommand`, `AssetUsage`, `AssetKind`, `AssetManagementStatus`가 생기는지 육안 확인한다.
|
||
|
||
- [ ] **Step 6: green 확인**
|
||
|
||
Run: `corepack pnpm exec vitest run tests/features/tech-log/contract-generation.test.ts`
|
||
Expected: 4 tests PASS
|
||
|
||
- [ ] **Step 7: drift 게이트가 실제로 잡는지 확인**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
printf '\n# tampered\n' >> src/features/tech-log/contracts/studio/studio-api.openapi.yaml
|
||
corepack pnpm check:tech-log-contract; echo "exit=$?"
|
||
git checkout -- src/features/tech-log/contracts/studio/studio-api.openapi.yaml
|
||
corepack pnpm check:tech-log-contract; echo "exit=$?"
|
||
```
|
||
|
||
Expected: 첫 실행 `exit=1`과 digest 불일치 메시지, 복원 후 `exit=0`.
|
||
|
||
생성물 훼손도 잡는지 확인한다. `generated.ts`에서 `getStudioSession` operation 키 이름을 임시로 바꾼 뒤 `check`를 실행하고 되돌린다.
|
||
|
||
Expected: `exit=1`과 `missing operation getStudioSession`.
|
||
|
||
- [ ] **Step 8: 기존 계약 소비자 회귀 확인**
|
||
|
||
Run: `corepack pnpm check:types:app && corepack pnpm test:tech-log`
|
||
Expected: PASS. `contract.ts`의 기존 타입 alias가 새 `generated.ts`에서도 전부 해석돼야 한다. 실패하면 `contract.ts`에서 이름이 바뀐 alias만 수정한다 — 새 타입 추가는 Task 6에서 한다.
|
||
|
||
- [ ] **Step 9: 커밋**
|
||
|
||
```bash
|
||
git add package.json scripts/generate-tech-log-contract.ts \
|
||
src/features/tech-log/contracts/studio/ tests/features/tech-log/contract-generation.test.ts
|
||
git commit -m "build: generate the TechLog Studio contract from canonical source"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: 오류 매핑과 CSRF 세션
|
||
|
||
계약 기여를 쓰기 전에, 모든 operation이 공유할 두 조각을 먼저 만든다.
|
||
|
||
**Files:**
|
||
- Create: `src/features/tech-log/adapters/http/studio-error-mapping.ts`
|
||
- Create: `src/features/tech-log/adapters/http/studio-session-csrf.ts`
|
||
- Test: `tests/features/tech-log/studio-error-mapping.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `StudioGatewayError`(`application/ports/studio-gateway-error.ts`), `ProblemDetails`(`contracts/studio/contract.ts`), `HttpExecutionOutcome`(`adapters/http/http-execution-v3.ts`)
|
||
- Produces:
|
||
- `STUDIO_ERROR_CODES: readonly ProblemDetails["code"][]` — 23개
|
||
- `toStudioGatewayError(outcome: HttpExecutionOutcome<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/contracts/rest-profiles.ts` (auth profile 등록)
|
||
- Modify: `src/features/installed-contract-contributions.ts`
|
||
- Test: `tests/features/tech-log/studio-contract-contribution.test.ts`, `tests/runtime-schema/http-schema.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `canonical-source.json`(Task 1), `InstalledContractContribution`/`InstalledHttpContract`(`src/contracts/external-contract-runtime.ts`)
|
||
- Produces:
|
||
- `TECH_LOG_STUDIO_CONTRIBUTION: InstalledContractContribution`
|
||
- `TECH_LOG_STUDIO_OPERATION_IDS: readonly string[]` — 18개
|
||
- `type TechLogStudioOperationId = (typeof TECH_LOG_STUDIO_OPERATION_IDS)[number]`
|
||
|
||
**선언할 operation 18개.** 각 항목은 `operationId · method · pathTemplate · requestBody · retrySemantics · acceptedStatuses · responseByteLimit`이다. `retrySemantics: "KEYED"`인 항목은 전부 `commandRecovery = { mode: "IDEMPOTENCY_REPLAY", operationIdentityField: "idempotencyKey" }`, `frontend.retryBudget = 0`이다. `SAFE`는 `commandRecovery: null`, `commandEffect: null`, `retryBudget: 2`다. 모든 항목의 `frontend.authProfileId`는 `"TECH_LOG_STUDIO_SESSION"`, `totalDeadlineMs`는 `10_000`이다.
|
||
|
||
| operationId | method | pathTemplate | requestBody | retry | accepted | respLimit |
|
||
|---|---|---|---|---|---|---|
|
||
| `getStudioSession` | GET | `/api/v1/studio/session` | NONE | SAFE | 200 | 8_192 |
|
||
| `getStudioDashboard` | GET | `/api/v1/studio/dashboard` | NONE | SAFE | 200 | 262_144 |
|
||
| `listStudioDocuments` | GET | `/api/v1/studio/documents` | NONE | SAFE | 200 | 262_144 |
|
||
| `createStudioDocument` | POST | `/api/v1/studio/documents` | JSON | KEYED | 201 | 131_072 |
|
||
| `getStudioDocument` | GET | `/api/v1/studio/documents/{documentId}` | NONE | SAFE | 200 | 524_288 |
|
||
| `saveStudioDocument` | PUT | `/api/v1/studio/documents/{documentId}` | JSON | KEYED | 200 | 524_288 |
|
||
| `validateStudioDocument` | POST | `/api/v1/studio/documents/{documentId}/validate` | JSON | KEYED | 200 | 262_144 |
|
||
| `getCurrentStudioPreview` | GET | `/api/v1/studio/documents/{documentId}/preview` | NONE | SAFE | 200 | 1_048_576 |
|
||
| `createStudioPreview` | POST | `/api/v1/studio/documents/{documentId}/preview` | JSON | KEYED | 201 | 1_048_576 |
|
||
| `publishStudioDocument` | POST | `/api/v1/studio/documents/{documentId}/publish` | JSON | KEYED | 200 | 131_072 |
|
||
| `listStudioPublications` | GET | `/api/v1/studio/publications` | NONE | SAFE | 200 | 262_144 |
|
||
| `unpublishStudioPublication` | POST | `/api/v1/studio/publications/{publicationId}/unpublish` | JSON | KEYED | 200 | 131_072 |
|
||
| `getStudioPublicationSnapshot` | GET | `/api/v1/studio/publications/{publicationEventId}/preview` | NONE | SAFE | 200 | 1_048_576 |
|
||
| `listStudioCatalog` | GET | `/api/v1/studio/catalog` | NONE | SAFE | 200 | 131_072 |
|
||
| `listStudioAssets` | GET | `/api/v1/studio/assets` | NONE | SAFE | 200 | 262_144 |
|
||
| `getStudioAsset` | GET | `/api/v1/studio/assets/{assetId}` | NONE | SAFE | 200 | 65_536 |
|
||
| `updateStudioAsset` | PUT | `/api/v1/studio/assets/{assetId}` | JSON | KEYED | 200 | 65_536 |
|
||
| `deleteStudioAsset` | DELETE | `/api/v1/studio/assets/{assetId}` | NONE | KEYED | 204 | 65_536 |
|
||
|
||
`deleteStudioAsset`만 `responseBody: "NONE"`, `emptyBodyStatuses: [204]`다. 나머지는 `responseBody: "REQUIRED_JSON"`, `emptyBodyStatuses: []`다. `deleteStudioAsset`의 `responseByteLimit`이 0이 아닌 이유는 플랫폼이 1 이상을 요구하기 때문이다 — 본문 없는 204여도 오류 응답은 본문을 가진다.
|
||
|
||
- [ ] **Step 1: Studio auth profile 등록**
|
||
|
||
`authProfileId`가 `INSTALLED_REST_AUTH_PROFILES`에 없으면 계약 composition이 실패한다. 현재 등록된 것은 `REFERENCE_EXTERNAL_BEARER`와 `ANONYMOUS`뿐이므로 Studio 세션 profile을 먼저 만든다.
|
||
|
||
canonical은 "Mutating Studio Operation은 Session Cookie와 `X-CSRF-TOKEN`을 요구한다"고 정한다. 플랫폼에는 이 조합이 이미 1급으로 있다 — `transport: "SAME_ORIGIN_COOKIE"`와 credential header `"x-csrf-token"`.
|
||
|
||
`src/contracts/rest-profiles.ts`의 `REST_AUTH_PROFILES`에 추가한다:
|
||
|
||
```typescript
|
||
TECH_LOG_STUDIO_SESSION: Object.freeze({
|
||
authProfileId: "TECH_LOG_STUDIO_SESSION",
|
||
transport: "SAME_ORIGIN_COOKIE",
|
||
credentials: "include",
|
||
allowedCredentialHeaders: Object.freeze(["x-csrf-token"] as const),
|
||
requiredCredentialHeaders: Object.freeze(["x-csrf-token"] as const),
|
||
}),
|
||
```
|
||
|
||
`installRestAuthProfileRegistry`의 `SAME_ORIGIN_COOKIE` 불변식을 만족하는지 확인한다: `credentials !== "omit"` ✓, `allowed`에 `"authorization"`이 없어야 함 ✓, `required ⊆ allowed` ✓.
|
||
|
||
`tests/runtime-schema/http-schema.test.ts`(또는 rest-profile을 다루는 기존 테스트)에 profile이 설치되는지 단언을 추가한다:
|
||
|
||
```typescript
|
||
test("installs the TechLog Studio session auth profile", () => {
|
||
const profile = INSTALLED_REST_AUTH_PROFILES.get("TECH_LOG_STUDIO_SESSION");
|
||
assert.ok(profile);
|
||
assert.equal(profile.transport, "SAME_ORIGIN_COOKIE");
|
||
assert.deepEqual([...profile.requiredCredentialHeaders], ["x-csrf-token"]);
|
||
});
|
||
```
|
||
|
||
Run: `corepack pnpm test:runtime-schema`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 2: 실패하는 테스트 작성**
|
||
|
||
`tests/features/tech-log/studio-contract-contribution.test.ts`:
|
||
|
||
```typescript
|
||
import assert from "node:assert/strict";
|
||
import { test } from "vitest";
|
||
|
||
import canonicalSource from "../../../src/features/tech-log/contracts/studio/canonical-source.json" with { type: "json" };
|
||
import {
|
||
TECH_LOG_STUDIO_CONTRIBUTION,
|
||
TECH_LOG_STUDIO_OPERATION_IDS,
|
||
} from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts";
|
||
import { composeContractContributions } from "../../../src/contracts/external-contract-runtime.ts";
|
||
|
||
const UPLOAD = "uploadStudioAsset";
|
||
|
||
test("declares every canonical operation except the multipart upload", () => {
|
||
const expected = canonicalSource.operationIds.filter((id) => id !== UPLOAD);
|
||
assert.equal(expected.length, 18);
|
||
assert.deepEqual([...TECH_LOG_STUDIO_OPERATION_IDS].sort(), [...expected].sort());
|
||
});
|
||
|
||
test("pins the canonical digest and revision as external package provenance", () => {
|
||
const source = TECH_LOG_STUDIO_CONTRIBUTION.source;
|
||
assert.equal(source.kind, "EXTERNAL_PACKAGE");
|
||
if (source.kind !== "EXTERNAL_PACKAGE") return;
|
||
assert.equal(source.package.packageId, canonicalSource.packageId);
|
||
assert.equal(source.package.version, canonicalSource.version);
|
||
assert.equal(source.package.digest, canonicalSource.digest);
|
||
assert.equal(source.package.sourceRevision, canonicalSource.sourceRevision);
|
||
assert.equal(source.package.runtimeProtocolVersion, 1);
|
||
});
|
||
|
||
test("composes without violating the platform contract runtime", () => {
|
||
const composed = composeContractContributions([TECH_LOG_STUDIO_CONTRIBUTION]);
|
||
assert.equal(composed.externalPackages.length, 1);
|
||
});
|
||
|
||
test("every mutating operation replays by idempotency key and never auto-retries", () => {
|
||
for (const entry of TECH_LOG_STUDIO_CONTRIBUTION.http) {
|
||
if (entry.contract.retrySemantics !== "KEYED") continue;
|
||
assert.deepEqual(entry.contract.commandRecovery, {
|
||
mode: "IDEMPOTENCY_REPLAY",
|
||
operationIdentityField: "idempotencyKey",
|
||
}, `${entry.contract.operationId} recovery`);
|
||
assert.equal(entry.frontend.retryBudget, 0, `${entry.contract.operationId} budget`);
|
||
}
|
||
});
|
||
|
||
test("path templates match the canonical /api/v1/studio prefix", () => {
|
||
for (const entry of TECH_LOG_STUDIO_CONTRIBUTION.http) {
|
||
assert.ok(
|
||
entry.contract.pathTemplate.startsWith("/api/v1/studio/"),
|
||
`${entry.contract.operationId}: ${entry.contract.pathTemplate}`,
|
||
);
|
||
}
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: red 확인**
|
||
|
||
Run: `corepack pnpm exec vitest run tests/features/tech-log/studio-contract-contribution.test.ts`
|
||
Expected: FAIL — 기여 모듈을 찾을 수 없다.
|
||
|
||
- [ ] **Step 4: 기여 파일 작성**
|
||
|
||
`src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts`. 먼저 공용 헬퍼와 대표 operation 두 개를 정확한 형태로 만든 뒤, 위 표의 나머지 16개를 같은 헬퍼로 선언한다.
|
||
|
||
```typescript
|
||
import { z } from "zod";
|
||
|
||
import type {
|
||
CommandEffectDescriptor,
|
||
InstalledContractContribution,
|
||
InstalledHttpContract,
|
||
RuntimeValidator,
|
||
} from "../../../contracts/external-contract-runtime.ts";
|
||
import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts";
|
||
import canonicalSource from "./studio/canonical-source.json" with { type: "json" };
|
||
import { STUDIO_ERROR_CODES } from "../adapters/http/studio-error-mapping.ts";
|
||
|
||
function zodValidator<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 5: 설치 등록부에 추가**
|
||
|
||
`src/features/installed-contract-contributions.ts`의 `INSTALLED_CONTRACT_CONTRIBUTIONS`를 수정한다. TechLog는 항상 설치되므로 조건 없이 포함한다:
|
||
|
||
```typescript
|
||
import { TECH_LOG_STUDIO_CONTRIBUTION } from "./tech-log/contracts/tech-log-studio-contract-contribution.ts";
|
||
|
||
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
|
||
Object.freeze(
|
||
INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)
|
||
? [REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION, TECH_LOG_STUDIO_CONTRIBUTION]
|
||
: [TECH_LOG_STUDIO_CONTRIBUTION],
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 6: green 확인**
|
||
|
||
Run: `corepack pnpm exec vitest run tests/features/tech-log/studio-contract-contribution.test.ts`
|
||
Expected: 5 tests PASS
|
||
|
||
- [ ] **Step 7: 계약 집합과 아키텍처 게이트 확인**
|
||
|
||
Run: `corepack pnpm check:types:app && corepack pnpm generate:contract-set && corepack pnpm check:architecture`
|
||
Expected: PASS. `contractSet`에 `tech-log-studio-contract@2.0.0`이 나타난다. `EXTERNAL_PACKAGE`이므로 `TEMPLATE_FIXTURE`와 달리 release digest에 반영된다.
|
||
|
||
- [ ] **Step 8: 커밋**
|
||
|
||
```bash
|
||
git add src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts \
|
||
src/contracts/rest-profiles.ts src/features/installed-contract-contributions.ts \
|
||
tests/features/tech-log/studio-contract-contribution.test.ts \
|
||
tests/runtime-schema/http-schema.test.ts .generated/
|
||
git commit -m "feat: register the TechLog Studio contract contribution"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: HTTP StudioGateway 구현
|
||
|
||
**Files:**
|
||
- Create: `src/features/tech-log/adapters/http/http-studio-gateway.ts`
|
||
- Create: `tests/mocks/handlers/tech-log-studio.ts`
|
||
- Move: `src/features/tech-log/adapters/mock/stable-stringify.ts` → `src/features/tech-log/adapters/stable-stringify.ts` (mock의 import 경로를 함께 고친다)
|
||
- Test: `tests/features/tech-log/http-studio-gateway.test.ts`
|
||
|
||
HTTP 어댑터가 `mock/`에서 import하면 프로덕션 경로가 mock 경로에 의존한다. 두 어댑터가 공유하는 순수 함수이므로 한 단계 위로 옮긴다. 내용은 바꾸지 않는다.
|
||
|
||
**Interfaces:**
|
||
- Consumes: `toStudioGatewayError`/`createCsrfTokenProvider`(Task 2), `TechLogStudioOperationId`(Task 3), `StudioGateway`(`application/ports/studio-gateway.ts`), `InstalledContractOperationExecutor`(`features/reference-feature/adapters/create-reference-feature-input.ts`의 동형 타입)
|
||
- Produces:
|
||
- `type StudioOperationExecutor = Readonly<{ execute(operationId: string, input: unknown, context: Readonly<{ routeId: string; signal?: AbortSignal; intent?: MutationIntent }>): Promise<HttpExecutionOutcome<unknown, unknown>> }>`
|
||
- `createHttpStudioGateway(deps: Readonly<{ operations: StudioOperationExecutor }>): StudioGateway`
|
||
- `mutationIntent(operationId: string, idempotencyKey: string, input: unknown): MutationIntent` — Task 6도 이 함수를 쓴다
|
||
|
||
- [ ] **Step 1: MSW 핸들러 작성**
|
||
|
||
`tests/mocks/handlers/tech-log-studio.ts`. mock gateway가 이미 canonical 의미론의 완전한 구현체이므로 재사용한다.
|
||
|
||
```typescript
|
||
import { http, HttpResponse } from "msw";
|
||
|
||
import { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts";
|
||
import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
|
||
|
||
const BASE = "http://api.test";
|
||
|
||
function problemResponse(error: unknown) {
|
||
if (isStudioGatewayError(error)) {
|
||
return HttpResponse.json(error.problem, {
|
||
status: error.status,
|
||
headers: { "content-type": "application/problem+json" },
|
||
});
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
async function json(work: () => Promise<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 {
|
||
defineIdempotencyKey,
|
||
defineMutationIntent,
|
||
type MutationIntent,
|
||
} from "../../../../contracts/mutation-intent.ts";
|
||
import type { HttpExecutionOutcome } from "../../../../adapters/http/http-execution-v3.ts";
|
||
import { stableStringify } from "../stable-stringify.ts";
|
||
import type {
|
||
IdempotentOptions,
|
||
RequestOptions,
|
||
StudioGateway,
|
||
} from "../../application/ports/studio-gateway.ts";
|
||
import { toStudioGatewayError } from "./studio-error-mapping.ts";
|
||
import type { CsrfTokenProvider } from "./studio-session-csrf.ts";
|
||
|
||
export type StudioOperationExecutor = Readonly<{
|
||
execute(
|
||
operationId: string,
|
||
input: unknown,
|
||
context: Readonly<{
|
||
routeId: string;
|
||
signal?: AbortSignal;
|
||
intent?: MutationIntent;
|
||
}>,
|
||
): Promise<HttpExecutionOutcome<unknown, unknown>>;
|
||
}>;
|
||
|
||
export type HttpStudioGatewayDependencies = Readonly<{
|
||
operations: StudioOperationExecutor;
|
||
}>;
|
||
|
||
const ROUTE_ID = "TECH_LOG_STUDIO";
|
||
|
||
/**
|
||
* `createBrowserMutationIntentFactory`는 쓰지 않는다. 그 factory는
|
||
* `requiresIdempotencyKey`일 때 키를 **스스로 생성**하는데, 이 포트의 키는
|
||
* 호출자가 만들어 안전한 재시도에 재사용하는 값이다. `defineMutationIntent`가
|
||
* 호출자 공급 키를 검증하며 받아주는 정식 경로다(`mutation-intent.ts`의
|
||
* "A caller-supplied value is never trimmed, regenerated or silently dropped").
|
||
*/
|
||
export function mutationIntent(
|
||
operationId: string,
|
||
idempotencyKey: string,
|
||
input: unknown,
|
||
): MutationIntent {
|
||
return defineMutationIntent({
|
||
intentId: globalThis.crypto.randomUUID(),
|
||
operationId,
|
||
canonicalInputIdentity: canonicalIdentity(input),
|
||
idempotencyKey: defineIdempotencyKey(idempotencyKey),
|
||
createdAtMonotonicMs: globalThis.performance.now(),
|
||
});
|
||
}
|
||
|
||
/** 같은 명령의 재시도가 같은 신원을 갖도록 키 순서를 고정해 직렬화한다. */
|
||
function canonicalIdentity(input: unknown): string {
|
||
const identity = stableStringify(input);
|
||
return identity.length > 16_000 ? identity.slice(0, 16_000) : identity;
|
||
}
|
||
|
||
/**
|
||
* 헤더는 gateway가 만들지 않는다. `Idempotency-Key`는 실행 context의 intent에서,
|
||
* `x-csrf-token`은 credential collaborator에서 온다. gateway가 입력 본문에
|
||
* 넣으면 계약 본문이 오염되고 계약 소유 헤더는 거절된다.
|
||
*/
|
||
export function createHttpStudioGateway(
|
||
deps: HttpStudioGatewayDependencies,
|
||
): StudioGateway {
|
||
async function read<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 outcome = await deps.operations.execute(operationId, input, {
|
||
routeId: ROUTE_ID,
|
||
intent: mutationIntent(operationId, options.idempotencyKey, input),
|
||
...(options.signal ? { signal: options.signal } : {}),
|
||
});
|
||
if (outcome.kind === "SUCCESS") return outcome.value as T;
|
||
throw toStudioGatewayError(outcome, operationId);
|
||
}
|
||
|
||
return Object.freeze({
|
||
getDashboard: (options) => read("getStudioDashboard", {}, options),
|
||
listDocuments: (query, options) => read("listStudioDocuments", query, options),
|
||
createDocument: (input, options) => command("createStudioDocument", input, options),
|
||
getDocument: (documentId, options) =>
|
||
read("getStudioDocument", { documentId }, options),
|
||
saveDocument: (documentId, cmd, options) =>
|
||
command("saveStudioDocument", { documentId, ...cmd }, options),
|
||
validateDocument: (documentId, cmd, options) =>
|
||
command("validateStudioDocument", { documentId, ...cmd }, options),
|
||
createPreview: (documentId, cmd, options) =>
|
||
command("createStudioPreview", { documentId, ...cmd }, options),
|
||
getCurrentPreview: (documentId, options) =>
|
||
read("getCurrentStudioPreview", { documentId }, options),
|
||
publishDocument: (documentId, cmd, options) =>
|
||
command("publishStudioDocument", { documentId, ...cmd }, options),
|
||
unpublishPublication: (publicationId, cmd, options) =>
|
||
command("unpublishStudioPublication", { publicationId, ...cmd }, options),
|
||
listPublications: (query, options) => read("listStudioPublications", query, options),
|
||
getPublicationSnapshot: (publicationEventId, options) =>
|
||
read("getStudioPublicationSnapshot", { publicationEventId }, options),
|
||
getCatalog: (query, options) => read("listStudioCatalog", query, options),
|
||
}) satisfies StudioGateway;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 테스트 헬퍼 완성**
|
||
|
||
`http-studio-gateway.test.ts`의 `gatewayUnderTest` 스텁을 지우고 `realDependencies()`를 넣는다. 플랫폼 계약 런타임을 실제로 조립하지 않고, 계약 기여의 `projectRequest`로 요청을 만들어 `fetch`로 보내는 얇은 executor를 쓴다 — 이 테스트의 대상은 gateway이지 플랫폼 전송이 아니다.
|
||
|
||
```typescript
|
||
import { TECH_LOG_STUDIO_CONTRIBUTION } from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts";
|
||
|
||
const BASE = "http://api.test";
|
||
|
||
async function realDependencies() {
|
||
const byId = new Map(
|
||
TECH_LOG_STUDIO_CONTRIBUTION.http.map((entry) => [entry.contract.operationId, entry]),
|
||
);
|
||
|
||
const csrf = createCsrfTokenProvider({
|
||
async execute() {
|
||
const response = await fetch(`${BASE}/api/v1/studio/session`);
|
||
const body = (await response.json()) as {
|
||
csrfToken: string;
|
||
csrfHeaderName: string;
|
||
};
|
||
return { csrfToken: body.csrfToken, csrfHeaderName: body.csrfHeaderName };
|
||
},
|
||
});
|
||
|
||
const operations = {
|
||
async execute(operationId: string, input: unknown, context: { signal?: AbortSignal; intent?: unknown }) {
|
||
const entry = byId.get(operationId);
|
||
if (!entry) throw new Error(`unregistered operation: ${operationId}`);
|
||
const { contract } = entry;
|
||
const projected = contract.projectRequest(input as never);
|
||
|
||
let path = contract.pathTemplate;
|
||
for (const [name, value] of Object.entries(projected.pathValues)) {
|
||
path = path.replace(`{${name}}`, encodeURIComponent(value));
|
||
}
|
||
const url = new URL(`${BASE}${path}`);
|
||
for (const [name, value] of projected.queryEntries) {
|
||
url.searchParams.append(name, value);
|
||
}
|
||
|
||
// 실행기가 헤더를 만드는 두 경로를 그대로 재현한다: intent → Idempotency-Key,
|
||
// credential collaborator → x-csrf-token.
|
||
const headers: Record<string, string> = {};
|
||
if (contract.retrySemantics === "KEYED") {
|
||
const intent = (context as { intent?: { idempotencyKey?: string } }).intent;
|
||
if (intent?.idempotencyKey) headers["Idempotency-Key"] = intent.idempotencyKey;
|
||
headers["X-CSRF-TOKEN"] = await csrf.token();
|
||
}
|
||
if (contract.requestBody === "JSON") headers["content-type"] = "application/json";
|
||
|
||
const response = await fetch(url, {
|
||
method: contract.method,
|
||
headers,
|
||
...(contract.requestBody === "JSON"
|
||
? { body: JSON.stringify(projected.body) }
|
||
: {}),
|
||
...(context.signal ? { signal: context.signal } : {}),
|
||
});
|
||
|
||
if (contract.acceptedStatuses.includes(response.status)) {
|
||
const value =
|
||
contract.responseBody === "NONE" ? null : await response.json();
|
||
return { kind: "SUCCESS" as const, value, effect: "APPLIED_CONFIRMED" as const };
|
||
}
|
||
return {
|
||
kind: "PROBLEM" as const,
|
||
problem: await response.json(),
|
||
metadata: { status: response.status },
|
||
effect: "NOT_APPLIED" as const,
|
||
};
|
||
},
|
||
};
|
||
|
||
return { operations } as never;
|
||
}
|
||
```
|
||
|
||
Task 3의 각 KEYED `projectRequest`는 body를 명시적으로 조립해야 한다(`SAVE_STUDIO_DOCUMENT` 예시가 그렇게 돼 있다) — 입력을 그대로 spread하면 계약에 없는 필드가 본문에 섞인다. 이 테스트가 그 규칙을 지킨다.
|
||
|
||
- [ ] **Step 6: green 확인**
|
||
|
||
Run: `corepack pnpm exec vitest run tests/features/tech-log/http-studio-gateway.test.ts`
|
||
Expected: 4 tests PASS
|
||
|
||
- [ ] **Step 7: 커밋**
|
||
|
||
```bash
|
||
git add src/features/tech-log/adapters/http/http-studio-gateway.ts \
|
||
tests/mocks/handlers/tech-log-studio.ts \
|
||
tests/features/tech-log/http-studio-gateway.test.ts
|
||
git commit -m "feat: implement the TechLog Studio HTTP gateway"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: 런타임 스위치
|
||
|
||
`config/runtime/*.json`은 `.strict()` zod 스키마로 검증되므로 새 키는 스키마에 먼저 추가해야 한다. **선택 필드 + 기본값 `MOCK`**으로 추가해 기존 문서와 V1 호환성을 깨지 않는다.
|
||
|
||
**Files:**
|
||
- Modify: `src/contracts/release-artifacts.ts:153-161` (V2 스키마에만 추가)
|
||
- Modify: `src/bootstrap/runtime-config-schema.ts:35-55, 108-141`
|
||
- Modify: `src/contracts/env.ts:33-56`
|
||
- Modify: `config/runtime/local.json`, `development.json`, `staging.json`, `production.json`
|
||
- Modify: `src/features/tech-log/adapters/create-tech-log-feature-input.ts`
|
||
- Modify: `src/features/installed-feature-adapters.ts`
|
||
- Modify: `src/bootstrap/runtime-adapters.ts:506-508`
|
||
- Test: `tests/runtime-schema/runtime-config.test.ts`, `tests/features/tech-log/runtime-composition.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `createHttpStudioGateway`(Task 4), `createCsrfTokenProvider`(Task 2)
|
||
- Produces: `createTechLogFeatureInstalledInput(context: Readonly<{ studioSource: "MOCK" | "HTTP"; contractOperations: StudioOperationExecutor }>)` — 기존 무인자 시그니처를 대체한다.
|
||
|
||
- [ ] **Step 1: 실패하는 스키마 테스트 추가**
|
||
|
||
`tests/runtime-schema/runtime-config.test.ts`에 추가:
|
||
|
||
```typescript
|
||
test("accepts TECH_LOG_STUDIO_SOURCE and defaults it to MOCK", () => {
|
||
const base = {
|
||
APP_ENV: "local",
|
||
API_BASE_URL: "http://localhost:8080/",
|
||
TELEMETRY_ENABLED: false,
|
||
AUTH_MODE: "demo",
|
||
CONFIG_SCHEMA_VERSION: "2.0",
|
||
CAPABILITY_OVERRIDES: {
|
||
REALTIME: "DEFAULT",
|
||
WEB_WORKER: "DEFAULT",
|
||
SERVICE_WORKER: "DEFAULT",
|
||
OFFLINE_COMMANDS: "DEFAULT",
|
||
},
|
||
FEATURE_OVERRIDES: {},
|
||
};
|
||
|
||
const defaulted = validateRuntimeConfig(base);
|
||
assert.equal(defaulted.success, true);
|
||
if (defaulted.success) {
|
||
assert.equal(defaulted.data.TECH_LOG_STUDIO_SOURCE, "MOCK");
|
||
}
|
||
|
||
const explicit = validateRuntimeConfig({ ...base, TECH_LOG_STUDIO_SOURCE: "HTTP" });
|
||
assert.equal(explicit.success, true);
|
||
if (explicit.success) {
|
||
assert.equal(explicit.data.TECH_LOG_STUDIO_SOURCE, "HTTP");
|
||
}
|
||
|
||
const invalid = validateRuntimeConfig({ ...base, TECH_LOG_STUDIO_SOURCE: "LIVE" });
|
||
assert.equal(invalid.success, false);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: red 확인**
|
||
|
||
Run: `corepack pnpm test:runtime-schema`
|
||
Expected: FAIL — strict 스키마가 알 수 없는 키를 거절한다.
|
||
|
||
- [ ] **Step 3: 스키마 확장**
|
||
|
||
`src/contracts/release-artifacts.ts`의 `runtimeConfigV2ArtifactSchema`에만 한 줄 추가한다. V1은 호환성 리더이므로 건드리지 않는다:
|
||
|
||
```typescript
|
||
export const runtimeConfigV2ArtifactSchema = z
|
||
.object({
|
||
...runtimeConfigArtifactFields,
|
||
CONFIG_SCHEMA_VERSION: z.literal("2.0"),
|
||
CAPABILITY_OVERRIDES: capabilityOverrideArtifactSchema,
|
||
FEATURE_OVERRIDES: featureOverrideArtifactSchema,
|
||
// TechLog Studio 어댑터 선택. Backend가 없을 때 기본값은 mock이다.
|
||
TECH_LOG_STUDIO_SOURCE: z.enum(["MOCK", "HTTP"]).default("MOCK"),
|
||
})
|
||
.strict()
|
||
.superRefine(runtimeConfigArtifactInvariants);
|
||
```
|
||
|
||
`src/bootstrap/runtime-config-schema.ts`의 `RuntimeConfig` 타입에 추가:
|
||
|
||
```typescript
|
||
FEATURE_OVERRIDES: ProductFeatureOverrideMap;
|
||
/** §3.5과 같은 결의 런타임 선택. V1 문서는 항상 MOCK으로 정규화된다. */
|
||
TECH_LOG_STUDIO_SOURCE: "MOCK" | "HTTP";
|
||
```
|
||
|
||
같은 파일의 `normalized` 조립에 추가:
|
||
|
||
```typescript
|
||
FEATURE_OVERRIDES: Object.freeze({
|
||
...(isV2 ? (parsed as RuntimeConfigV2).FEATURE_OVERRIDES : {}),
|
||
}),
|
||
TECH_LOG_STUDIO_SOURCE: isV2
|
||
? (parsed as RuntimeConfigV2).TECH_LOG_STUDIO_SOURCE
|
||
: "MOCK",
|
||
```
|
||
|
||
`src/contracts/env.ts`의 `ENV_REGISTRY`에 추가:
|
||
|
||
```typescript
|
||
FEATURE_OVERRIDES: runtime("public", false, null),
|
||
// TechLog Studio 어댑터 선택. Backend 미완성 구간의 기본값은 MOCK이다.
|
||
TECH_LOG_STUDIO_SOURCE: runtime("public", false, "MOCK"),
|
||
```
|
||
|
||
- [ ] **Step 4: 프로필 갱신**
|
||
|
||
`config/runtime/local.json`과 `development.json`에 `"TECH_LOG_STUDIO_SOURCE": "MOCK"`을, `staging.json`과 `production.json`에 `"TECH_LOG_STUDIO_SOURCE": "HTTP"`를 `FEATURE_OVERRIDES` 앞에 추가한다.
|
||
|
||
- [ ] **Step 5: green 확인**
|
||
|
||
Run: `corepack pnpm test:runtime-schema`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 6: 합성 테스트 갱신**
|
||
|
||
`tests/features/tech-log/runtime-composition.test.ts`의 `installedInputs()`가 새 인자를 넘기도록 고치고, 스위치 테스트를 추가한다:
|
||
|
||
```typescript
|
||
function installedInputs(
|
||
studioSource: "MOCK" | "HTTP" = "MOCK",
|
||
): InstalledInputs {
|
||
return createInstalledFeatureInputs({
|
||
studioSource,
|
||
contractOperations: {
|
||
async execute() {
|
||
throw new Error("executor is not used by composition tests");
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
test("selects the mock gateway by default and the HTTP gateway when switched", () => {
|
||
const mockGateway = installedInputs("MOCK")["tech-log"].createStudioGateway();
|
||
const httpGateway = installedInputs("HTTP")["tech-log"].createStudioGateway();
|
||
|
||
// mock은 인메모리 픽스처를 즉시 읽는다. HTTP는 executor를 호출해 실패한다.
|
||
assert.doesNotReject(mockGateway.getDashboard());
|
||
assert.rejects(httpGateway.getDashboard());
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 7: 합성 배선 구현**
|
||
|
||
`src/features/tech-log/adapters/create-tech-log-feature-input.ts`:
|
||
|
||
```typescript
|
||
import {
|
||
TECH_LOG_FEATURE_ID,
|
||
type TechLogFeatureInput,
|
||
} from "../application/tech-log-feature-input.ts";
|
||
import { createHttpStudioGateway, type StudioOperationExecutor } from "./http/http-studio-gateway.ts";
|
||
import { createCsrfTokenProvider } from "./http/studio-session-csrf.ts";
|
||
import { createMockStudioGateway } from "./mock/mock-studio-gateway.ts";
|
||
import { publicContentQueries } from "./static/public-query.ts";
|
||
|
||
export type TechLogInstallContext = Readonly<{
|
||
studioSource: "MOCK" | "HTTP";
|
||
contractOperations: StudioOperationExecutor;
|
||
}>;
|
||
|
||
export function createTechLogFeatureInstalledInput(context: TechLogInstallContext) {
|
||
const createStudioGateway = () => {
|
||
if (context.studioSource === "MOCK") return createMockStudioGateway();
|
||
// 세션은 gateway 수명과 같다. 새 Studio 세션은 새 CSRF 토큰을 받는다.
|
||
const csrf = createCsrfTokenProvider({
|
||
async execute(options) {
|
||
const outcome = await context.contractOperations.execute(
|
||
"getStudioSession",
|
||
{},
|
||
{ routeId: "TECH_LOG_STUDIO", ...(options?.signal ? { signal: options.signal } : {}) },
|
||
);
|
||
if (outcome.kind !== "SUCCESS") {
|
||
throw new Error("studio session is unavailable");
|
||
}
|
||
const value = outcome.value as { csrfToken: string; csrfHeaderName: string };
|
||
return { csrfToken: value.csrfToken, csrfHeaderName: value.csrfHeaderName };
|
||
},
|
||
});
|
||
return createHttpStudioGateway({ operations: context.contractOperations, csrf });
|
||
};
|
||
|
||
const input: TechLogFeatureInput = Object.freeze({
|
||
publicContent: publicContentQueries,
|
||
createStudioGateway,
|
||
});
|
||
|
||
return Object.freeze({ featureId: TECH_LOG_FEATURE_ID, input });
|
||
}
|
||
```
|
||
|
||
`src/features/installed-feature-adapters.ts`의 `createInstalledFeatureInputs` 시그니처에 `studioSource`를 더하고 `createTechLogFeatureInstalledInput(context)`로 넘긴다.
|
||
|
||
`src/bootstrap/runtime-adapters.ts:506`의 호출을 고친다:
|
||
|
||
```typescript
|
||
const featureInputs = createInstalledFeatureInputs({
|
||
contractOperations,
|
||
studioSource: config.TECH_LOG_STUDIO_SOURCE,
|
||
});
|
||
```
|
||
|
||
`config`는 그 스코프에서 이미 쓰이는 런타임 설정 값이다. 이름이 다르면 같은 스코프의 런타임 설정 식별자를 사용한다.
|
||
|
||
- [ ] **Step 8: green과 전체 회귀 확인**
|
||
|
||
Run: `corepack pnpm check:types && corepack pnpm test:runtime-schema && corepack pnpm test:tech-log && corepack pnpm test:unit`
|
||
Expected: PASS. 기본값이 `MOCK`이므로 기존 Studio 화면 테스트가 전부 그대로 통과해야 한다.
|
||
|
||
- [ ] **Step 9: 커밋**
|
||
|
||
```bash
|
||
git add src/contracts/release-artifacts.ts src/contracts/env.ts \
|
||
src/bootstrap/runtime-config-schema.ts src/bootstrap/runtime-adapters.ts \
|
||
src/features/installed-feature-adapters.ts \
|
||
src/features/tech-log/adapters/create-tech-log-feature-input.ts \
|
||
config/runtime/ tests/runtime-schema/runtime-config.test.ts \
|
||
tests/features/tech-log/runtime-composition.test.ts
|
||
git commit -m "feat: select the TechLog Studio adapter from runtime configuration"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Asset 포트와 JSON gateway
|
||
|
||
**Files:**
|
||
- Create: `src/features/tech-log/application/ports/studio-asset-gateway.ts`
|
||
- Create: `src/features/tech-log/adapters/http/http-studio-asset-gateway.ts`
|
||
- Modify: `src/features/tech-log/contracts/studio/contract.ts`
|
||
- Test: `tests/features/tech-log/studio-asset-gateway.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 3의 4개 asset operation, Task 2의 오류 매핑·CSRF, Task 4의 `StudioOperationExecutor`
|
||
- Produces:
|
||
- `contract.ts` 신규 alias: `Asset`, `AssetDetail`, `AssetPage`, `AssetKind`, `AssetManagementStatus`, `AssetUsage`, `UpdateAssetCommand`
|
||
- `type ListAssetsQuery = { q?: string; kind?: AssetKind; managementStatus?: AssetManagementStatus; cursor?: string; limit?: number }`
|
||
- `type UploadAssetForm = { file: File; kind: AssetKind; altText?: string; decorative?: boolean }`
|
||
- `interface StudioAssetGateway { listAssets(query, options?): Promise<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 { mutationIntent, type StudioOperationExecutor } from "./http-studio-gateway.ts";
|
||
import type { CsrfTokenProvider } from "./studio-session-csrf.ts";
|
||
|
||
export type StudioAssetUploadTransport = Readonly<{
|
||
upload(
|
||
form: UploadAssetForm,
|
||
headers: Readonly<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) {
|
||
// Task 4와 동일한 규칙: 헤더는 gateway가 만들지 않는다. `Idempotency-Key`는
|
||
// intent에서, `x-csrf-token`은 credential collaborator에서 온다.
|
||
const outcome = await deps.operations.execute(operationId, input, {
|
||
routeId: ROUTE_ID,
|
||
intent: mutationIntent(operationId, options.idempotencyKey, input),
|
||
...(options.signal ? { signal: options.signal } : {}),
|
||
});
|
||
if (outcome.kind === "SUCCESS") return outcome.value as T;
|
||
throw toStudioGatewayError(outcome, operationId);
|
||
}
|
||
|
||
return Object.freeze({
|
||
listAssets: (query, options) => read<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;
|
||
}>;
|
||
```
|
||
|
||
CSRF 제공자는 **하나**이고 두 곳이 쓴다: 플랫폼 실행기의 credential seam(JSON operation 18개)과 업로드 전송(multipart 1개). 그러므로 feature input이 아니라 **composition root가 소유**한다. 나눠 가지면 Studio 세션 하나에 토큰이 두 개 생긴다.
|
||
|
||
`src/bootstrap/runtime-adapters.ts`에서 `contractHttp` 조립 **앞에** 제공자를 만든다. `getStudioSession` 자체는 SAFE operation이라 CSRF가 필요 없으므로 재귀하지 않는다:
|
||
|
||
```typescript
|
||
const techLogCsrf = createCsrfTokenProvider({
|
||
async execute(options) {
|
||
const outcome = await contractOperations.execute(
|
||
"getStudioSession",
|
||
{},
|
||
{ routeId: "TECH_LOG_STUDIO", ...(options?.signal ? { signal: options.signal } : {}) },
|
||
);
|
||
if (outcome.kind !== "SUCCESS") throw new Error("studio session is unavailable");
|
||
const value = outcome.value as { csrfToken: string; csrfHeaderName: string };
|
||
return { csrfToken: value.csrfToken, csrfHeaderName: value.csrfHeaderName };
|
||
},
|
||
});
|
||
```
|
||
|
||
`contractOperations`가 아직 없는 위치라면 제공자를 지연 생성(`let` + 최초 호출 시 조립)해 순환을 피한다.
|
||
|
||
같은 파일 `attachCredentials`(`:435`)에 Studio 분기를 더한다. 이것이 `x-csrf-token`이 요청에 실리는 **유일한** 경로다:
|
||
|
||
```typescript
|
||
async attachCredentials(operation, authContext) {
|
||
if (serverStateScope.getPhase() !== "READY") {
|
||
return Object.freeze({ kind: "SCOPE_FENCED" as const });
|
||
}
|
||
if (operation.authProfileId === "TECH_LOG_STUDIO_SESSION") {
|
||
// Studio는 세션 쿠키로 인증하고 CSRF 토큰만 증명 헤더로 싣는다.
|
||
// 조회 operation은 이 profile을 쓰더라도 헤더 없이 통과해야 하므로
|
||
// 토큰 조회 실패를 세션 상태로 승격하지 않는다.
|
||
try {
|
||
return Object.freeze({
|
||
kind: "READY" as const,
|
||
headers: Object.freeze({
|
||
"x-csrf-token": await techLogCsrf.token({ signal: authContext.signal }),
|
||
}),
|
||
});
|
||
} catch {
|
||
return Object.freeze({ kind: "UNAVAILABLE" as const });
|
||
}
|
||
}
|
||
const state = authSession.getState();
|
||
// ... 기존 bearer 경로는 그대로 둔다
|
||
},
|
||
```
|
||
|
||
`create-tech-log-feature-input.ts`의 `TechLogInstallContext`에 `apiBaseUrl: string`, `requestTimeoutMs: number`, `csrf: CsrfTokenProvider`를 더한다:
|
||
|
||
```typescript
|
||
export function createTechLogFeatureInstalledInput(context: TechLogInstallContext) {
|
||
// MOCK에서도 Asset gateway는 필요하다. 백엔드가 없으면 목록이 비고 업로드는
|
||
// 전송 실패로 끝난다 — UI가 그 상태를 표시하는 것이 정상 동작이다.
|
||
const createStudioAssetGateway = () =>
|
||
createHttpStudioAssetGateway({
|
||
operations: context.contractOperations,
|
||
csrf: context.csrf,
|
||
upload: createAssetUploadTransport({
|
||
baseUrl: context.apiBaseUrl,
|
||
timeoutMs: context.requestTimeoutMs,
|
||
}),
|
||
});
|
||
|
||
const createStudioGateway = () =>
|
||
context.studioSource === "MOCK"
|
||
? createMockStudioGateway()
|
||
: createHttpStudioGateway({ operations: context.contractOperations });
|
||
|
||
const input: TechLogFeatureInput = Object.freeze({
|
||
publicContent: publicContentQueries,
|
||
createStudioGateway,
|
||
createStudioAssetGateway,
|
||
});
|
||
|
||
return Object.freeze({ featureId: TECH_LOG_FEATURE_ID, input });
|
||
}
|
||
```
|
||
|
||
`src/features/installed-feature-adapters.ts`와 `src/bootstrap/runtime-adapters.ts:506`의 호출에 `apiBaseUrl: config.API_BASE_URL`, `requestTimeoutMs: config.REQUEST_TIMEOUT_MS`, `csrf: techLogCsrf`를 더한다.
|
||
|
||
Studio 세션이 만료돼 `AUTHENTICATION_REQUIRED`가 오면 캐시된 토큰을 버려야 한다. gateway가 아니라 `attachCredentials` 소유자가 버린다 — `authSession.onUnauthenticated()`를 호출하는 `runtime-adapters.ts:499` 부근의 `UNAUTHENTICATED` 분기에서 `techLogCsrf.invalidate()`를 함께 호출한다.
|
||
|
||
`src/features/tech-log/presentation/studio/studio-provider.tsx`가 `createStudioGateway`를 lazy state/ref로 한 번만 호출하는 방식과 **동일하게** `createStudioAssetGateway`도 한 번만 호출해 제공하도록 확장한다. bfcache `pageshow`로 provider generation이 바뀔 때 두 gateway가 함께 재생성돼야 한다.
|
||
|
||
- [ ] **Step 6: 합성 테스트 갱신과 green 확인**
|
||
|
||
`tests/features/tech-log/runtime-composition.test.ts`의 키 단언을 고친다:
|
||
|
||
```typescript
|
||
assert.deepEqual(Object.keys(installed["tech-log"]).sort(), [
|
||
"createStudioAssetGateway",
|
||
"createStudioGateway",
|
||
"publicContent",
|
||
]);
|
||
```
|
||
|
||
같은 파일에 공유 검증을 추가한다:
|
||
|
||
```typescript
|
||
test("each Studio session gets its own asset gateway instance", () => {
|
||
const installed = installedInputs("HTTP");
|
||
assert.notEqual(
|
||
installed["tech-log"].createStudioAssetGateway(),
|
||
installed["tech-log"].createStudioAssetGateway(),
|
||
);
|
||
});
|
||
```
|
||
|
||
Run: `corepack pnpm check:types:app && corepack pnpm test:tech-log`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 7: 어댑터 리뷰 문서 작성**
|
||
|
||
`docs/reviews/adapters/06-tech-log-asset-upload.md`에 다음을 기록한다: 우회 대상(`external-contract-runtime.ts`의 `requestBody` 제약, `client.ts:719`의 JSON 고정), 우회 범위(19개 중 `uploadStudioAsset` 1개), 유지되는 보증(CSRF·Idempotency-Key·오류 코드 매핑·타임아웃·credentials), 포기한 보증(계약 런타임의 byte limit·retry policy·진단 계측), 교체 계획(presigned/resumable 또는 플랫폼 MULTIPART 모드 도입 시 이 파일만 교체). `docs/reviews/adapters/INVENTORY.md`에 항목을 추가한다.
|
||
|
||
- [ ] **Step 8: 게이트 확인**
|
||
|
||
Run: `corepack pnpm check:adapter-inventory && corepack pnpm check:architecture && corepack pnpm check:browser-security`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 9: 커밋**
|
||
|
||
```bash
|
||
git add src/features/tech-log/adapters/http/asset-upload-transport.ts \
|
||
src/features/tech-log/application/tech-log-feature-input.ts \
|
||
src/features/tech-log/adapters/create-tech-log-feature-input.ts \
|
||
src/features/tech-log/presentation/studio/studio-provider.tsx \
|
||
src/features/installed-feature-adapters.ts src/bootstrap/runtime-adapters.ts \
|
||
docs/reviews/adapters/ tests/features/tech-log/
|
||
git commit -m "feat: add the TechLog asset multipart upload transport"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: evidence alt 규칙을 publish 검증으로 이동
|
||
|
||
parser는 Asset의 `decorative`를 모르므로 빈 alt의 필요 여부를 판단할 수 없다. 규칙을 옮긴다. 이는 content format의 **의미 변경**이므로 픽스처를 함께 갱신한다.
|
||
|
||
**Files:**
|
||
- Modify: `src/features/tech-log/domain/content-format/parse-case-content.ts:505`
|
||
- Modify: `src/features/tech-log/adapters/mock/validate-working-copy.ts:166`
|
||
- Test: `tests/features/tech-log/content-format.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: 없음
|
||
- Produces: parser는 빈 `alt`를 허용한다. `EVIDENCE_FIGURE.alt`는 여전히 `string`이며 빈 문자열이 될 수 있다.
|
||
|
||
- [ ] **Step 1: 실패하는 테스트 추가**
|
||
|
||
`tests/features/tech-log/content-format.test.ts`에 추가:
|
||
|
||
```typescript
|
||
test("parses a decorative evidence figure with an empty alt", () => {
|
||
const source = ':::evidence key="fetch-strategy-boundary" alt="" caption="" zoom="false"\n:::\n';
|
||
const parsed = parseCaseContent(source);
|
||
|
||
assert.equal(parsed.ok, true);
|
||
if (!parsed.ok) return;
|
||
const block = parsed.value.blocks.find((item) => item.type === "EVIDENCE_FIGURE");
|
||
assert.ok(block);
|
||
assert.equal(block.alt, "");
|
||
});
|
||
|
||
test("round-trips an empty alt without reintroducing it as an error", () => {
|
||
const source = ':::evidence key="fetch-strategy-boundary" alt="" caption="" zoom="false"\n:::\n';
|
||
const parsed = parseCaseContent(source);
|
||
assert.equal(parsed.ok, true);
|
||
if (!parsed.ok) return;
|
||
const reparsed = parseCaseContent(serializeCaseContent(parsed.value));
|
||
assert.equal(reparsed.ok, true);
|
||
});
|
||
|
||
test("still rejects an unsafe evidence key", () => {
|
||
const parsed = parseCaseContent(':::evidence key="../etc" alt="x" caption="" zoom="false"\n:::\n');
|
||
assert.equal(parsed.ok, false);
|
||
});
|
||
```
|
||
|
||
기존 테스트 중 "빈 alt가 파싱 오류"를 단언하는 케이스가 있으면 위 의미로 고친다. 삭제하지 말고 반대 단언으로 바꾼다.
|
||
|
||
- [ ] **Step 2: red 확인**
|
||
|
||
Run: `corepack pnpm exec vitest run tests/features/tech-log/content-format.test.ts`
|
||
Expected: FAIL — parser가 빈 alt를 거절한다.
|
||
|
||
- [ ] **Step 3: parser 규칙 제거**
|
||
|
||
`src/features/tech-log/domain/content-format/parse-case-content.ts:505`의 한 줄을 지우고 주석으로 대체한다:
|
||
|
||
```typescript
|
||
// alt 필요 여부는 Asset의 `decorative`에 달려 있어 여기서는 판단할 수
|
||
// 없다. 빈 alt는 구문 오류가 아니며 publish 검증이 Asset metadata와 함께
|
||
// 판정한다(설계 §7.7).
|
||
```
|
||
|
||
- [ ] **Step 4: publish 검증에 규칙 추가**
|
||
|
||
`src/features/tech-log/adapters/mock/validate-working-copy.ts:166` 부근의 evidence 검사에 alt 규칙을 더한다. mock은 Asset metadata를 모르므로 정적 레지스트리를 `decorative: false`로 간주한다:
|
||
|
||
```typescript
|
||
if (!isSupportedEvidenceKey(block.key)) {
|
||
error("EVIDENCE_UNSUPPORTED", "/bodyMarkdown", `지원하지 않는 Evidence: ${block.key}`);
|
||
} else if (block.alt.trim().length === 0) {
|
||
// 정적 evidence 자산은 장식용이 아니다. Asset 기반 경로에서는 서버가
|
||
// Asset.decorative로 같은 판정을 한다.
|
||
error("EVIDENCE_ALT_REQUIRED", "/bodyMarkdown", `Evidence에 대체 텍스트가 필요합니다: ${block.key}`);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: green 확인**
|
||
|
||
Run: `corepack pnpm exec vitest run tests/features/tech-log/content-format.test.ts && corepack pnpm test:tech-log`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 6: 커밋**
|
||
|
||
```bash
|
||
git add src/features/tech-log/domain/content-format/parse-case-content.ts \
|
||
src/features/tech-log/adapters/mock/validate-working-copy.ts \
|
||
tests/features/tech-log/content-format.test.ts
|
||
git commit -m "fix: judge evidence alt text with asset metadata, not syntax"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Asset 기반 evidence resolver
|
||
|
||
렌더러에는 이미 `resolveEvidenceAsset: ResolveEvidenceAsset` 주입점이 6개 지점에 있다(`domain/public-render-content.ts:9`). 새로 만들 것은 렌더러 구조가 아니라 **Backend Asset을 해석하는 resolver 구현**이다. 이 Task 없이 Task 10을 하면 사용자가 삽입한 backend `assetKey`를 `getEvidenceAsset`이 `throw`해 즉시 미리보기가 깨진다.
|
||
|
||
canonical `EvidenceFigureBlock`은 `ResolvedAsset`(`assetId`, `assetKey`, `mediaType`, `publicPath`, `width`, `height`, `decorative`)을 블록 안에 담아 보낸다. 따라서 서버가 만든 세 표면(Public Preview·Snapshot·Published)은 블록에서 바로 해석하고, **Instant Preview만** 클라이언트가 적재한 Asset 목록으로 해석한다.
|
||
|
||
**Files:**
|
||
- Create: `src/features/tech-log/presentation/shared/public-render/asset-resolvers.ts`
|
||
- Modify: `src/features/tech-log/presentation/studio/components/instant-preview.tsx:49`
|
||
- Modify: `src/features/tech-log/presentation/studio/components/public-preview-screen.tsx:290`
|
||
- Modify: `src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx:169`
|
||
- Test: `tests/features/tech-log/evidence-asset-resolver.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Asset`(Task 6), `ResolveEvidenceAsset`/`EvidenceAsset`(`domain/public-render-content.ts`)
|
||
- Produces:
|
||
- `createAssetCatalogResolver(assets: readonly Asset[]): ResolveEvidenceAsset` — Instant Preview용. 정적 레지스트리로 폴백한다.
|
||
- `createResolvedAssetResolver(blocks: readonly { type: string; key?: string; asset?: ResolvedAssetLike }[]): ResolveEvidenceAsset` — 서버 render model용.
|
||
- `MISSING_EVIDENCE_ASSET: EvidenceAsset` — 해석 실패 시의 안전한 자리표시자.
|
||
|
||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||
|
||
`tests/features/tech-log/evidence-asset-resolver.test.tsx`:
|
||
|
||
```typescript
|
||
import assert from "node:assert/strict";
|
||
import { test } from "vitest";
|
||
|
||
import {
|
||
createAssetCatalogResolver,
|
||
createResolvedAssetResolver,
|
||
MISSING_EVIDENCE_ASSET,
|
||
} from "../../../src/features/tech-log/presentation/shared/public-render/asset-resolvers.ts";
|
||
|
||
const READY = {
|
||
id: "11111111-1111-4111-8111-111111111111",
|
||
assetKey: "boundary",
|
||
kind: "DIAGRAM",
|
||
mediaType: "image/svg+xml",
|
||
originalFilename: "b.svg",
|
||
byteSize: 10,
|
||
width: 1080,
|
||
height: 420,
|
||
altText: "경계",
|
||
decorative: false,
|
||
managementStatus: "READY",
|
||
publicPath: "/media/boundary.svg",
|
||
usageCount: 0,
|
||
version: 1,
|
||
createdAt: "2026-08-14T01:00:00.000Z",
|
||
updatedAt: "2026-08-14T01:00:00.000Z",
|
||
} as never;
|
||
|
||
test("resolves a backend asset key to its public path", () => {
|
||
const resolve = createAssetCatalogResolver([READY]);
|
||
const asset = resolve("boundary");
|
||
|
||
assert.equal(asset.src, "/media/boundary.svg");
|
||
assert.equal(asset.width, 1080);
|
||
assert.equal(asset.height, 420);
|
||
});
|
||
|
||
test("falls back to the static registry for the legacy hardcoded key", () => {
|
||
const resolve = createAssetCatalogResolver([]);
|
||
const asset = resolve("fetch-strategy-boundary");
|
||
|
||
assert.equal(asset.src, "/media/fetch-strategy-boundary.svg");
|
||
});
|
||
|
||
test("returns the placeholder instead of throwing on an unknown key", () => {
|
||
const resolve = createAssetCatalogResolver([]);
|
||
assert.deepEqual(resolve("does-not-exist"), MISSING_EVIDENCE_ASSET);
|
||
});
|
||
|
||
test("never resolves a QUARANTINED asset", () => {
|
||
const resolve = createAssetCatalogResolver([
|
||
{ ...(READY as object), managementStatus: "QUARANTINED" } as never,
|
||
]);
|
||
assert.deepEqual(resolve("boundary"), MISSING_EVIDENCE_ASSET);
|
||
});
|
||
|
||
test("resolves from the server render model blocks", () => {
|
||
const resolve = createResolvedAssetResolver([
|
||
{
|
||
type: "EVIDENCE_FIGURE",
|
||
key: "boundary",
|
||
asset: {
|
||
assetId: "11111111-1111-4111-8111-111111111111",
|
||
assetKey: "boundary",
|
||
mediaType: "image/svg+xml",
|
||
publicPath: "/media/boundary.svg",
|
||
width: 800,
|
||
height: 300,
|
||
decorative: false,
|
||
},
|
||
},
|
||
]);
|
||
|
||
assert.equal(resolve("boundary").src, "/media/boundary.svg");
|
||
assert.equal(resolve("boundary").width, 800);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: red 확인**
|
||
|
||
Run: `corepack pnpm exec vitest run tests/features/tech-log/evidence-asset-resolver.test.tsx`
|
||
Expected: FAIL — 모듈 없음.
|
||
|
||
- [ ] **Step 3: resolver 구현**
|
||
|
||
`src/features/tech-log/presentation/shared/public-render/asset-resolvers.ts`:
|
||
|
||
```typescript
|
||
import {
|
||
getEvidenceAsset,
|
||
isSupportedEvidenceKey,
|
||
} from "../../../adapters/static/evidence-assets.ts";
|
||
import type { Asset } from "../../../contracts/studio/contract.ts";
|
||
import type {
|
||
EvidenceAsset,
|
||
ResolveEvidenceAsset,
|
||
} from "../../../domain/public-render-content.ts";
|
||
|
||
export type ResolvedAssetLike = Readonly<{
|
||
assetKey: string;
|
||
mediaType: string;
|
||
publicPath: string;
|
||
width: number | null;
|
||
height: number | null;
|
||
decorative: boolean;
|
||
}>;
|
||
|
||
/**
|
||
* `ResolveEvidenceAsset`은 total 함수다. 해석 실패에 throw하면 편집 중인 문서
|
||
* 하나가 화면 전체를 무너뜨리므로 자리표시자를 돌려준다. 게시 차단은 publish
|
||
* 검증이 담당한다.
|
||
*/
|
||
export const MISSING_EVIDENCE_ASSET: EvidenceAsset = Object.freeze({
|
||
src: "",
|
||
width: 1,
|
||
height: 1,
|
||
triggerLabel: "사용할 수 없는 Evidence",
|
||
dialogLabel: "사용할 수 없는 Evidence",
|
||
});
|
||
|
||
function fromDescriptor(descriptor: ResolvedAssetLike): EvidenceAsset {
|
||
const label = `${descriptor.assetKey} 이미지 크게 보기`;
|
||
return Object.freeze({
|
||
src: descriptor.publicPath,
|
||
width: descriptor.width ?? 1,
|
||
height: descriptor.height ?? 1,
|
||
triggerLabel: label,
|
||
dialogLabel: `${descriptor.assetKey} 확대`,
|
||
});
|
||
}
|
||
|
||
function staticFallback(key: string): EvidenceAsset {
|
||
return isSupportedEvidenceKey(key) ? getEvidenceAsset(key) : MISSING_EVIDENCE_ASSET;
|
||
}
|
||
|
||
/** Instant Preview: 편집기가 적재한 Asset 목록으로 해석한다. */
|
||
export function createAssetCatalogResolver(
|
||
assets: readonly Asset[],
|
||
): ResolveEvidenceAsset {
|
||
const byKey = new Map<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 편집기에만 붙인다.
|
||
|
||
**적재한 Asset 목록을 즉시 미리보기에 넘긴다.** Task 9가 `instant-preview.tsx`에 `assets` prop을 열어뒀지만 아직 빈 배열이 들어간다. 이 단계에서 실제로 연결하지 않으면 방금 삽입한 directive가 즉시 미리보기에서 자리표시자로 보인다.
|
||
|
||
Picker가 적재한 목록을 편집기 화면(`document-editor-screen.tsx`)이 소유하게 하고, Picker와 InstantPreview 양쪽에 같은 배열을 내려준다. Picker가 아직 목록을 못 받았거나 업로드 직후라면 새 Asset을 배열에 더한다:
|
||
|
||
```tsx
|
||
const [assets, setAssets] = useState<readonly Asset[]>([]);
|
||
// Picker의 목록 적재 결과와 업로드 성공 결과가 같은 배열로 모인다.
|
||
<AssetPicker gateway={assetGateway} onLoaded={setAssets} onInsert={insert} />
|
||
<AssetUploadDialog
|
||
gateway={assetGateway}
|
||
onUploaded={(asset) => setAssets((current) => [asset, ...current])}
|
||
…
|
||
/>
|
||
<InstantPreview assets={assets} … />
|
||
```
|
||
|
||
`AssetPicker`에 `onLoaded?: (assets: readonly Asset[]) => void` prop을 더하고 `listAssets` 성공 시 호출한다. Task 10 Step 1의 기존 테스트는 이 prop 없이도 통과해야 하므로 선택 prop으로 둔다.
|
||
|
||
- [ ] **Step 6: green 확인**
|
||
|
||
Run: `corepack pnpm exec vitest run tests/features/tech-log/asset-picker.test.tsx && corepack pnpm test:tech-log`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 7: 커밋**
|
||
|
||
```bash
|
||
git add src/features/tech-log/presentation/studio/components/ \
|
||
tests/features/tech-log/asset-picker.test.tsx
|
||
git commit -m "feat: insert evidence directives from the TechLog asset picker"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Asset Library 화면과 라우트
|
||
|
||
**Files:**
|
||
- Create: `src/features/tech-log/presentation/studio/pages/assets-page.tsx`
|
||
- Create: `src/features/tech-log/presentation/studio/components/asset-library.tsx`
|
||
- Modify: `src/features/tech-log/contracts/tech-log-route-contract.ts`
|
||
- Modify: `src/features/tech-log/presentation/tech-log-route-runtime.tsx`
|
||
- Test: `tests/features/tech-log/route-contract.test.ts`, `tests/features/tech-log/asset-library.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `StudioAssetGateway`(Task 6)
|
||
- Produces: 라우트 `TECH_LOG_STUDIO_ASSETS` — `path: "/studio/assets"`, `layoutGroup: "STUDIO"`, `paramsSchema: null`, `searchSchema: null`, `title: "Asset"`, `navigationLabel: null`, `navigationOrder: null`
|
||
|
||
`navigationLabel`을 `null`로 두는 것이 핵심이다. Asset은 secondary utility이며 primary navigation(`작업본 · 게시 기록 · 새 문서`)을 CMS 구조로 바꾸지 않는다.
|
||
|
||
- [ ] **Step 1: 라우트 테스트 갱신**
|
||
|
||
`tests/features/tech-log/route-contract.test.ts`에서 기대 라우트 수를 27에서 28로 올리고 새 항목을 단언한다:
|
||
|
||
```typescript
|
||
test("registers the studio asset library outside primary navigation", () => {
|
||
const route = TECH_LOG_ROUTE_DEFINITIONS.find(
|
||
(item) => item.routeId === "TECH_LOG_STUDIO_ASSETS",
|
||
);
|
||
|
||
assert.ok(route);
|
||
assert.equal(route.path, "/studio/assets");
|
||
assert.equal(route.layoutGroup, "STUDIO");
|
||
assert.equal(route.navigationLabel, null);
|
||
assert.equal(route.navigationOrder, null);
|
||
});
|
||
```
|
||
|
||
기존 "정확히 27개" 단언이 있으면 28로 고친다. `/studio/*` catch-all보다 앞에 선언돼야 한다.
|
||
|
||
- [ ] **Step 2: red 확인**
|
||
|
||
Run: `corepack pnpm exec vitest run tests/features/tech-log/route-contract.test.ts`
|
||
Expected: FAIL
|
||
|
||
- [ ] **Step 3: 라우트 추가**
|
||
|
||
`tech-log-route-contract.ts`의 `TECH_LOG_ROUTE_SPECS`에서 `TECH_LOG_STUDIO_PUBLICATION_PREVIEW` 뒤, `TECH_LOG_STUDIO_NOT_FOUND` 앞에 추가한다:
|
||
|
||
```typescript
|
||
defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }),
|
||
```
|
||
|
||
`tech-log-route-runtime.tsx`에 `TECH_LOG_STUDIO_ASSETS → <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`에 커밋돼 있다.
|