feat: Studio 응답 봉투를 전송 경계에서 언랩한다

studio-v1.yaml v3.0.0(ADR-006)에 맞춰 계약을 재생성하고, 성공은
{success,data,meta}, 실패는 {success,error,meta} 봉투를 전송 경계에서
언랩하는 envelopeData/envelopeError validator를 도입한다. 앱·도메인
계층은 기존과 같은 payload/ProblemDetails 모양을 계속 받고,
StudioGateway 포트 시그니처는 무변경이다.

- tech-log-studio-contract-contribution.ts: envelopeData/envelopeError
  도입, 18개 operation의 outputValidator를 passthrough에서 envelopeData로
  교체
- studio-error-mapping.ts: 봉투 오류의 status(항상 0)를
  outcome.metadata.status로 덮는다. SafeResponseMetadata.status가
  실제 필드명이며(httpStatus 아님) PROBLEM outcome에서 필수 필드다
- contract.ts: 삭제된 ProblemDetails 생성 스키마를 손으로 유지 — 앱
  계층·mock 게이트웨이가 그 모양을 계속 소비한다
- asset-upload-transport.ts: multipart 업로드는 일반 계약 런타임을
  거치지 않는 별도 seam이지만 같은 wire 봉투를 쓴다 — envelopeData/
  envelopeError를 재사용해 이 경로도 언랩한다 (브리프 파일 목록 밖의
  발견, report에 기록)
- 테스트: 신규 studio-envelope-unwrap.test.ts(TDD) + 봉투 뼈대를 직접
  만드는 기존 테스트(asset-upload-transport, studio-csrf-composition,
  contract-generation)를 봉투 형태로 갱신

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 21:55:45 +09:00
co-authored by Claude Opus 5
parent d84b57bb3f
commit 25a6b63d27
11 changed files with 640 additions and 216 deletions
@@ -1,11 +1,21 @@
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 { envelopeData, envelopeError } from "../../contracts/tech-log-studio-contract-contribution.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);
/**
* canonical `uploadStudioAsset`도 다른 18개 operation과 같은 봉투(ADR-006)를
* 쓴다 — 이 seam만 일반 계약 런타임을 안 거칠 뿐이지 wire format은 같다.
* 그래서 `tech-log-studio-contract-contribution.ts`의 언랩 validator를 그대로
* 재사용한다: 봉투 뼈대 검증 로직이 두 곳에서 따로 드리프트하는 것을 막는다.
*/
const UPLOAD_DATA = envelopeData<Asset>("uploadStudioAssetOutput");
const UPLOAD_PROBLEM = envelopeError();
/**
* The contract runtime can only express `requestBody: "NONE" | "JSON"` and the
* low-level client always serializes the body as JSON (see
@@ -81,8 +91,9 @@ export function createAssetUploadTransport(
}
if (response.status === 201 || response.status === 200) {
let body: unknown;
try {
return (await response.json()) as Asset;
body = await response.json();
} catch {
// M1 (fix round 1). This port's contract is `StudioGatewayError`;
// a malformed success body must not throw a raw `SyntaxError` out
@@ -91,16 +102,33 @@ export function createAssetUploadTransport(
`Upload returned status ${response.status} with a body that could not be parsed as JSON.`,
);
}
const parsed = UPLOAD_DATA.safeParse(body);
if (!parsed.success) {
// 봉투 뼈대(`{success:true, data, meta}`)가 아니다 — payload는
// 통과시키되 봉투 자체는 반드시 검증한다(다른 18개 operation과 동일
// 원칙, ADR-006).
throw unavailable(
`Upload returned status ${response.status} with a body that did not match the response envelope.`,
);
}
return parsed.data;
}
let problem: ProblemDetails | null;
let problemBody: unknown = null;
try {
problem = (await response.json()) as ProblemDetails;
problemBody = await response.json();
} catch {
problem = null;
problemBody = null;
}
if (problem && typeof problem.code === "string" && CODES.has(problem.code)) {
throw new StudioGatewayError(problem);
const parsedProblem = problemBody === null ? null : UPLOAD_PROBLEM.safeParse(problemBody);
if (parsedProblem && parsedProblem.success && CODES.has(parsedProblem.data.code)) {
// `studio-error-mapping.ts`의 PROBLEM 분기와 같은 캐스트: 봉투는 이미
// `code`를 `apiErrorSchema`의 enum으로 검증했으므로(`CODES.has` 확인도
// 통과) `ProblemDetails["code"]`로 좁혀도 안전하다. envelope에는 HTTP
// status가 없다 (`envelopeError`가 0으로 둔다) — 이 seam은 실제 status를
// 이미 들고 있으므로 바로 덮는다.
const problem = parsedProblem.data as unknown as ProblemDetails;
throw new StudioGatewayError({ ...problem, status: response.status });
}
// Fix round 2, item 2. The real status is passed through (not the
// hardcoded 503 default) so `http-studio-asset-gateway.ts`'s