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
@@ -20,6 +20,14 @@ const transport = () =>
const svg = () => new File(["<svg/>"], "b.svg", { type: "image/svg+xml" });
// wire format은 봉투다 (ADR-006) — `uploadStudioAsset`도 다른 operation과
// 같은 `AssetEnvelope`/`ErrorEnvelope`를 쓴다.
const META = { requestId: "r", traceId: "t", correlationId: null, page: null };
const dataEnvelope = (data: unknown) => ({ success: true, data, meta: META });
const errorEnvelope = (
error: Readonly<{ code: string; category: string; message: string; retryable: boolean }>,
) => ({ success: false, error, meta: META });
test("posts multipart form data with the supplied headers", async () => {
let seen: { kind: unknown; alt: unknown; csrf: string | null; key: string | null } | null = null;
@@ -32,7 +40,7 @@ test("posts multipart form data with the supplied headers", async () => {
csrf: request.headers.get("X-CSRF-TOKEN"),
key: request.headers.get("Idempotency-Key"),
};
return HttpResponse.json({ id: "a", managementStatus: "READY" }, { status: 201 });
return HttpResponse.json(dataEnvelope({ id: "a", managementStatus: "READY" }), { status: 201 });
}),
);
@@ -53,7 +61,7 @@ test("does not set content-type itself so the boundary survives", async () => {
server.use(
http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => {
contentType = request.headers.get("content-type");
return HttpResponse.json({ id: "a" }, { status: 201 });
return HttpResponse.json(dataEnvelope({ id: "a" }), { status: 201 });
}),
);
@@ -66,14 +74,13 @@ 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: "파일이 너무 큽니다.",
errorEnvelope({
code: "PAYLOAD_TOO_LARGE",
},
{ status: 413, headers: { "content-type": "application/problem+json" } },
category: "VALIDATION",
message: "파일이 너무 큽니다.",
retryable: false,
}),
{ status: 413 },
),
),
);
@@ -92,14 +99,13 @@ 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: "지원하지 않는 형식입니다.",
errorEnvelope({
code: "UNSUPPORTED_MEDIA_TYPE",
},
{ status: 415, headers: { "content-type": "application/problem+json" } },
category: "VALIDATION",
message: "지원하지 않는 형식입니다.",
retryable: false,
}),
{ status: 415 },
),
),
);
@@ -128,14 +134,15 @@ test("falls back to STUDIO_UNAVAILABLE for an uncontracted problem code instead
server.use(
http.post(`${BASE}/api/v1/studio/assets`, () =>
HttpResponse.json(
{
type: "https://techlog.local/problems/teapot",
title: "IM_A_TEAPOT",
status: 418,
detail: "이 서버는 커피를 내릴 수 없습니다.",
// 봉투 뼈대는 정상이지만 `code`가 계약 밖이다 — envelope 검증은
// 통과하되(구조는 맞음) `apiErrorSchema`의 code enum에서 걸린다.
errorEnvelope({
code: "IM_A_TEAPOT",
},
{ status: 418, headers: { "content-type": "application/problem+json" } },
category: "INTERNAL",
message: "이 서버는 커피를 내릴 수 없습니다.",
retryable: false,
}),
{ status: 418 },
),
),
);
@@ -187,7 +194,7 @@ test("maps an aborted upload onto a non-retryable STUDIO_UNAVAILABLE", async ()
server.use(
http.post(`${BASE}/api/v1/studio/assets`, async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
return HttpResponse.json({ id: "a" }, { status: 201 });
return HttpResponse.json(dataEnvelope({ id: "a" }), { status: 201 });
}),
);