Files
tech-log-frontend/docs/superpowers/plans/2026-08-17-techlog-backend-alignment.md
T
DongHyeonkaandClaude Opus 5 a6fc536d8a docs: scope the TechLog contract packageId to satisfy PACKAGE_ID
The platform models contract contributions as published npm packages and
rejects an unscoped packageId at composition time. Use
@tech-log/studio-contract, the name a real publish would carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:05:16 +09:00

136 KiB
Raw Blame History

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.tsHTTP_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을 쓴다. 직접 헤더를 만들지 않는다. 계약의 projectRequestpathValues/queryEntries/body만 만들 수 있고 헤더 채널이 없다(external-contract-runtime.tsHttpRequestProjection). 헤더는 두 경로로만 들어간다.
    • 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을 넣지 않는다.
  • 계약 기여의 packageIdnpm scope 형식이어야 한다: PACKAGE_ID = /^@[a-z0-9][a-z0-9._-]{0,62}\/[a-z0-9][a-z0-9._-]{0,62}$/(external-contract-runtime.ts:218-219). scope 없는 이름은 composition 시점에 거절된다. 이 계약의 값은 @tech-log/studio-contract다 — 플랫폼이 기여를 게시된 패키지로 모델링하기 때문이며, 실제 배포로 승격할 때 같은 이름을 쓴다.
  • frontend.authProfileIdINSTALLED_REST_AUTH_PROFILES에 등록된 값이어야 한다. 없는 값이면 composition이 실패한다(external-contract-runtime.ts:325-326). 등록은 Task 3 Step 1이 한다.
  • frontend.responseByteLimit1 이상이어야 하고(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.tsopenapi-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:

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 사본도 생성 도구도 없기 때문이다.

/**
 * 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.jsonscripts에 세 줄만 더한다:

"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.schemasStudioSession, 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:

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=1missing 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: 커밋
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:

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:

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:

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: 커밋
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이다. SAFEcommandRecovery: null, commandEffect: null, retryBudget: 2다. 모든 항목의 frontend.authProfileId"TECH_LOG_STUDIO_SESSION", totalDeadlineMs10_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

deleteStudioAssetresponseBody: "NONE", emptyBodyStatuses: [204]다. 나머지는 responseBody: "REQUIRED_JSON", emptyBodyStatuses: []다. deleteStudioAssetresponseByteLimit이 0이 아닌 이유는 플랫폼이 1 이상을 요구하기 때문이다 — 본문 없는 204여도 오류 응답은 본문을 가진다.

  • Step 1: Studio auth profile 등록

authProfileIdINSTALLED_REST_AUTH_PROFILES에 없으면 계약 composition이 실패한다. 현재 등록된 것은 REFERENCE_EXTERNAL_BEARERANONYMOUS뿐이므로 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.tsREST_AUTH_PROFILES에 추가한다:

  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),
  }),

installRestAuthProfileRegistrySAME_ORIGIN_COOKIE 불변식을 만족하는지 확인한다: credentials !== "omit" ✓, allowed"authorization"이 없어야 함 ✓, required ⊆ allowed ✓.

tests/runtime-schema/http-schema.test.ts(또는 rest-profile을 다루는 기존 테스트)에 profile이 설치되는지 단언을 추가한다:

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:

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개를 같은 헬퍼로 선언한다.

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 두 개:

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개를 같은 두 헬퍼로 선언한다. 표의 respLimitresponseByteLimit에, 본문이 있는 KEYED operation의 requestByteLimitresponseByteLimit과 같은 값을, 본문 없는 deleteStudioAsset0을 쓴다.

query를 갖는 SAFE operation의 projectRequestqueryOf를 쓴다. 예를 들어 listStudioDocumentsq, kind, publicationStatus, nextAction, projectId, sort, cursor, limit을, listStudioPublicationsq, type, cursor, limit을, listStudioCatalogtype, q, cursor, limit을, listStudioAssetsq, kind, managementStatus, cursor, limit을 넘긴다.

path 파라미터를 갖는 operation의 pathValues 키는 템플릿과 정확히 같아야 한다: documentId, publicationId, publicationEventId, assetId.

마지막에 기여를 조립한다:

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.tsINSTALLED_CONTRACT_CONTRIBUTIONS를 수정한다. TechLog는 항상 설치되므로 조건 없이 포함한다:

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: 커밋
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.tssrc/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 의미론의 완전한 구현체이므로 재사용한다.

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:

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. 모든 메서드는 같은 두 헬퍼를 통과한다.

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.tsgatewayUnderTest 스텁을 지우고 realDependencies()를 넣는다. 플랫폼 계약 런타임을 실제로 조립하지 않고, 계약 기여의 projectRequest로 요청을 만들어 fetch로 보내는 얇은 executor를 쓴다 — 이 테스트의 대상은 gateway이지 플랫폼 전송이 아니다.

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: 커밋
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에 추가:

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.tsruntimeConfigV2ArtifactSchema에만 한 줄 추가한다. V1은 호환성 리더이므로 건드리지 않는다:

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.tsRuntimeConfig 타입에 추가:

  FEATURE_OVERRIDES: ProductFeatureOverrideMap;
  /** §3.5과 같은 결의 런타임 선택. V1 문서는 항상 MOCK으로 정규화된다. */
  TECH_LOG_STUDIO_SOURCE: "MOCK" | "HTTP";

같은 파일의 normalized 조립에 추가:

    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.tsENV_REGISTRY에 추가:

  FEATURE_OVERRIDES: runtime("public", false, null),
  // TechLog Studio 어댑터 선택. Backend 미완성 구간의 기본값은 MOCK이다.
  TECH_LOG_STUDIO_SOURCE: runtime("public", false, "MOCK"),
  • Step 4: 프로필 갱신

config/runtime/local.jsondevelopment.json"TECH_LOG_STUDIO_SOURCE": "MOCK"을, staging.jsonproduction.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.tsinstalledInputs()가 새 인자를 넘기도록 고치고, 스위치 테스트를 추가한다:

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:

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.tscreateInstalledFeatureInputs 시그니처에 studioSource를 더하고 createTechLogFeatureInstalledInput(context)로 넘긴다.

src/bootstrap/runtime-adapters.ts:506의 호출을 고친다:

  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: 커밋
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 끝에 추가:

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:

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:

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:

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: 커밋
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:

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:

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:

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가 필요 없으므로 재귀하지 않는다:

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이 요청에 실리는 유일한 경로다:

    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.tsTechLogInstallContextapiBaseUrl: string, requestTimeoutMs: number, csrf: CsrfTokenProvider를 더한다:

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.tssrc/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.tsxcreateStudioGateway를 lazy state/ref로 한 번만 호출하는 방식과 동일하게 createStudioAssetGateway도 한 번만 호출해 제공하도록 확장한다. bfcache pageshow로 provider generation이 바뀔 때 두 gateway가 함께 재생성돼야 한다.

  • Step 6: 합성 테스트 갱신과 green 확인

tests/features/tech-log/runtime-composition.test.ts의 키 단언을 고친다:

  assert.deepEqual(Object.keys(installed["tech-log"]).sort(), [
    "createStudioAssetGateway",
    "createStudioGateway",
    "publicContent",
  ]);

같은 파일에 공유 검증을 추가한다:

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.tsrequestBody 제약, 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: 커밋
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에 추가:

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의 한 줄을 지우고 주석으로 대체한다:

      // 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로 간주한다:

        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: 커밋
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 assetKeygetEvidenceAssetthrow해 즉시 미리보기가 깨진다.

canonical EvidenceFigureBlockResolvedAsset(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:

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:

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:290publication-event-preview-screen.tsx:169의 resolver를 createResolvedAssetResolver(renderModel.bodyBlocks)로 바꾼다. renderModelbodyBlocks가 없는 kind(REFERENCE/QUESTION/PROJECT_DECISION)는 빈 배열을 넘긴다.

instant-preview.tsx:49createAssetCatalogResolver(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: 커밋
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:

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 속성값은 "를 포함할 수 없다 — 파서가 속성을 따옴표로 구분하기 때문이다. 값에서 "를 제거하고 개행을 공백으로 접는다.

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:

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.tsxbodyMarkdown textarea 옆에 Picker 진입 버튼을 둔다. 삽입은 커서 위치를 보존한다:

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.tsxassets prop을 열어뒀지만 아직 빈 배열이 들어간다. 이 단계에서 실제로 연결하지 않으면 방금 삽입한 directive가 즉시 미리보기에서 자리표시자로 보인다.

Picker가 적재한 목록을 편집기 화면(document-editor-screen.tsx)이 소유하게 하고, Picker와 InstantPreview 양쪽에 같은 배열을 내려준다. Picker가 아직 목록을 못 받았거나 업로드 직후라면 새 Asset을 배열에 더한다:

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}  />

AssetPickeronLoaded?: (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: 커밋
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_ASSETSpath: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null

navigationLabelnull로 두는 것이 핵심이다. Asset은 secondary utility이며 primary navigation(작업본 · 게시 기록 · 새 문서)을 CMS 구조로 바꾸지 않는다.

  • Step 1: 라우트 테스트 갱신

tests/features/tech-log/route-contract.test.ts에서 기대 라우트 수를 27에서 28로 올리고 새 항목을 단언한다:

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.tsTECH_LOG_ROUTE_SPECS에서 TECH_LOG_STUDIO_PUBLICATION_PREVIEW 뒤, TECH_LOG_STUDIO_NOT_FOUND 앞에 추가한다:

  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.tsxTECH_LOG_STUDIO_ASSETS → <AssetsPage /> 매핑을 더한다.

  • Step 4: 화면 구현

삭제 가능 여부 판정을 순수 함수로 분리한다 — 이것이 이 화면의 유일한 규칙이다.

asset-library.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 안에서 AssetLibrarycreateStudioAssetGateway() 결과를 넘겨 렌더링한다. gateway는 Studio provider가 보유한 인스턴스를 쓰고 화면에서 새로 만들지 않는다.

  • Step 5: 화면 테스트 작성과 green 확인

tests/features/tech-log/asset-library.test.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: 커밋
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:

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.jsonTECH_LOG_STUDIO_SOURCE를 임시로 HTTP로 바꾸고 corepack pnpm dev를 띄운다. /studio가 백엔드 부재로 오류 상태를 표시하되 화이트스크린이나 처리되지 않은 예외가 없어야 한다. 확인 후 MOCK으로 되돌린다.

  • Step 6: 문서 갱신

README.mdTECH_LOG_STUDIO_SOURCE 스위치와 generate:tech-log-contract / check:tech-log-contract 사용법을 추가한다. spec 문서 상단 ## 상태에 완료 Task와 미완료 항목(실서버 대조, Public HTTP 전환)을 기록한다.

  • Step 7: 커밋
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_CONFLICTIDEMPOTENCY_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에 커밋돼 있다.