Files
tech-log-frontend/docs/reviews/adapters/06-tech-log-asset-upload.md
T
DongHyeonkaandClaude Opus 5 2cab4974b7 fix: break the TechLog CSRF bootstrap cycle and close the review's fix-round-1 items
C1 (Critical): getStudioSession was stamped with the same
TECH_LOG_STUDIO_SESSION auth profile as every other Studio operation, and
that profile requires the CSRF header it is getStudioSession's own job to
issue -- an unconditional cycle that recursed without bound in HTTP mode.
Fixed with a credential-free TECH_LOG_STUDIO_BOOTSTRAP auth profile for
getStudioSession alone, a synchronous re-entrancy guard in
createCsrfTokenProvider as defense in depth, and a throwing stub in place of
the prior `let x!: T` assertion. Added a composition-level regression test
that wires the real executor, CSRF provider, and credential-attach function
together and proves getStudioSession dispatches exactly once while its token
reaches both a JSON operation and the multipart upload.

Also: invalidate the cached CSRF token on a 401/403 from the upload path
(I2), a throwing useStudioAssetGateway() accessor so Task 11 cannot silently
compile a null-gateway UI (I3), and the M1-M5 minors from the review (guard
a malformed success body, cover the untested error fallbacks, align aborted
uploads with the JSON path's non-retryable CANCELLED mapping, derive the
credential header name from one source instead of two, and correct the
adapter review doc's operation count).

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

11 KiB

Adapter Review — TechLog Asset Multipart Upload

검토 기준: feature/techlog-backend-alignment (2026-08-18, Task 7)

범위: src/features/tech-log/adapters/http/asset-upload-transport.ts 1개 파일과 그 wiring — create-tech-log-feature-input.ts, installed-feature-adapters.ts, bootstrap/runtime-adapters.tsattachCredentials/techLogCsrf. src/adapters/** 전수 리뷰(INVENTORY)와는 별도 트랙이다: 이 파일은 src/features/tech-log/adapters/** 아래에 있고, TechLog는 자체 canonical HTTP 계약을 갖는 product feature이지 템플릿의 범용 adapter 계층이 아니다.

결론

TechLog Studio는 canonical 계약상 19개 operation을 갖는다. 그중 18개는 external-contract-runtime.ts가 표현할 수 있는 requestBody: "NONE" | "JSON" 범위 안에 있고, 플랫폼의 V3 실행기(http-execution-v3.ts) · 저수준 client(client.ts) · attachCredentials credential seam을 그대로 통과한다. 나머지 1개, uploadStudioAsset(POST /api/v1/studio/assets)만 multipart/form-data를 요구한다. 이 요구를 플랫폼이 표현할 수 없으므로, 이 operation 하나만 별도의 좁은 transport(asset-upload-transport.ts)로 분리했다.

이 seam은 플랫폼을 대체하지 않는다. CSRF, Idempotency-Key, canonical 오류 코드 매핑, timeout, credentials는 동일한 provider·동일한 오류 taxonomy로 다시 구현해 대칭을 유지한다. 포기하는 것은 플랫폼이 대신 강제해 주던 부분 — 계약 실행기의 byte 상한, retry policy, V3 진단 계측 — 뿐이며 이는 아래에 명시적으로 기록한다. 조립 지점은 createTechLogFeatureInstalledInput이 무조건(HTTP/MOCK 무관) createHttpStudioAssetGateway를 구성하고, 그 안에 이 transport를 주입하는 한 곳뿐이다.

우회 대상과 이유

  • src/contracts/external-contract-runtime.tsrequestBody union은 "NONE" | "JSON" 두 값만 갖는다. multipart/form-data를 표현할 세 번째 값이 없다.
  • src/adapters/http/client.ts:719 부근의 저수준 client는 본문이 있는 모든 request를 JSON.stringify(input)으로 직렬화해 고정 content-type: application/json으로 보낸다. File을 이 경로에 태우면 파일 바이트 대신 그 JSON 표현(빈 객체거나 오류)이 전송된다.
  • 두 제약 모두 이번 task의 global constraint로 수정 금지 대상이다(external-contract-runtime.ts, client.ts, http-execution-v3.ts, mutation-intent.ts, 생성된 계약 산출물, studio-gateway.ts 포트). 계약 실행기 자체를 바꾸는 대신, uploadStudioAsset 한 operation만 포트 경계 뒤에서 다른 구현으로 우회한다.

우회 범위

canonical Studio API 전체는 19개 operation이다. tech-log-studio-contract-contribution.ts는 그중 18개만 등록한다 — uploadStudioAsset은 계약 실행기가 표현할 수 없으므로 애초에 그 파일에 없다(studio-contract-contribution.test.ts의 "declares every canonical operation except the multipart upload"가 18을 고정한다).

분류 operation 경로
JSON, 계약 실행기 경유 getStudioSession, getStudioDashboard, listStudioDocuments, createStudioDocument, getStudioDocument, saveStudioDocument, validateStudioDocument, getCurrentStudioPreview, createStudioPreview, publishStudioDocument, listStudioPublications, unpublishStudioPublication, getStudioPublicationSnapshot, listStudioCatalog, listStudioAssets, getStudioAsset, updateStudioAsset, deleteStudioAsset (18개) contractHttp.execute()client.tsattachCredentials
multipart, 플랫폼 우회 uploadStudioAsset (1개) asset-upload-transport.ts의 직접 fetch()

18개는 contractOperations.execute(operationId, input, { routeId, intent? })를 통해 나가며 attachCredentials가 매 요청에 x-csrf-token을 싣는다. 1개(uploadStudioAsset)만 이 경로를 완전히 벗어나 createAssetUploadTransport가 직접 fetch()한다. StudioAssetGateway.uploadAsset()이 이 transport를 호출하는 유일한 지점이며, 포트 시그니처(Promise<Asset>)는 나머지 4개 asset operation과 동일해 호출자는 어느 경로인지 알 필요가 없다.

유지되는 보증

플랫폼이 18개 JSON operation에 자동으로 제공하는 것을, 이 transport는 같은 provider·같은 값으로 손으로 다시 만든다.

보증 JSON 경로 multipart 경로
CSRF attachCredentialstechLogCsrf.token()/headerName()으로 얻은 값을 그 이름 그대로 요청 헤더에 싣는다 StudioAssetGateway.uploadAsset()같은 techLogCsrf provider에서 token()/headerName()을 읽어 transport에 넘긴다 — provider가 composition root에 하나뿐이므로 세션당 토큰도 하나다. getStudioSession 자신은 이 provider를 거치지 않는 TECH_LOG_STUDIO_BOOTSTRAP auth profile을 쓴다: 그 provider가 토큰을 얻으려고 호출하는 operation이 같은 provider의 토큰을 요구하면 순환이 되기 때문이다(docs가 아니라 코드로 고정: studio-csrf-composition.test.ts)
Idempotency-Key 실행 intent(mutationIntent())에서 나와 client가 헤더로 싣는다 호출자(uploadAsset options)의 idempotencyKey를 gateway가 그대로 헤더로 전달한다
canonical 오류 코드 매핑 toStudioGatewayError()STUDIO_ERROR_CODES에 있는 problem.codeStudioGatewayError로 승격하고, 계약 밖 코드는 STUDIO_UNAVAILABLE로 접는다 transport가 동일한 STUDIO_ERROR_CODES 집합을 재사용해 같은 규칙으로 매핑한다. 서버가 PAYLOAD_TOO_LARGE/UNSUPPORTED_MEDIA_TYPE처럼 이 목록에 있는 코드를 보내면 그대로 StudioGatewayError가 되고, 계약에 없는 코드나 파싱 불가능한 본문은 도메인 코드를 지어내지 않고 STUDIO_UNAVAILABLE로 접는다
timeout 계약의 requestDeadlineCeilingMs AbortSignal.timeout(deps.timeoutMs)를 호출자 signal과 AbortSignal.any()로 합성한다
credentials TECH_LOG_STUDIO_SESSION 프로필의 credentials: "include" fetch() 호출에 동일하게 credentials: "include"를 명시한다

포기하는 보증

  • byte 상한: 계약 실행기의 bounded body reader/writer가 응답 크기를 강제하는 것과 달리, 이 transport의 요청 body(FormData)와 응답 JSON 파싱에는 별도 상한이 없다. 서버가 413 PAYLOAD_TOO_LARGE로 거절하는 것에 의존한다.
  • retry policy: 계약 실행기의 retry-policy.ts는 이 operation에 적용되지 않는다. uploadStudioAsset은 애초에 계약에서 retrySemantics를 선언할 수 없는 경로 밖에 있으므로, 재시도는 호출자(향후 Task 11의 Asset Library UI)가 명시적으로 다시 uploadAsset()을 호출하는 형태로만 존재한다.
  • V3 진단 계측: createHttpObservationProjector가 만드는 api.request.* diagnostics/telemetry 이벤트는 contractHttp.execute() 내부에서만 발생한다. 이 transport는 그 관찰 경계 밖에서 직접 fetch()하므로 업로드 성공/실패는 diagnostics 스트림에 나타나지 않는다. routeId: "TECH_LOG_STUDIO_ASSETS"는 나머지 4개 asset JSON operation에는 여전히 붙지만, uploadStudioAsset 자체에는 대응하는 diagnostics 레코드가 없다.

이 세 항목 모두 이번 task 범위에서 새로 만들지 않는다 — 다시 만들려면 플랫폼과 동일한 bounded reader/retry/observation을 복제해야 하고, 그것은 계약 실행기를 다시 짓는 것과 다르지 않다. 대신 아래 교체 계획으로 닫는다.

ROUTE_ID 검토 (Task 6 리뷰 인계 항목)

http-studio-asset-gateway.tsconst ROUTE_ID = "TECH_LOG_STUDIO_ASSETS"는 diagnostics/telemetry 버킷을 나누는 low-cardinality routing 메타데이터이지, 등록된 route 경로가 아니다. 이제 gateway가 실제로 배선되어 나머지 4개 asset JSON operation(listAssets, getAsset, updateAssetMetadata, deleteAsset)이 이 값으로 나가는 시점에서 다시 확인한 결과, 그대로 유지한다. 문서/편집 operation의 TECH_LOG_STUDIO와 별개 값을 쓰는 것은 두 가지를 갖는다.

  1. Asset 수명주기(업로드·목록·삭제)는 문서 편집과 실패 특성이 다르다 — 예를 들어 PAYLOAD_TOO_LARGE/UNSUPPORTED_MEDIA_TYPE/ASSET_QUARANTINED는 asset 쪽에만 있다. 별도 routeId는 이 실패를 diagnostics에서 문서 편집 트래픽과 섞지 않는다.
  2. uploadStudioAsset 자체는 계약 실행기를 우회해 이 routeId를 진단에 보고하지 않지만, 같은 이름을 4개 JSON operation에 유지해 두면 향후 업로드가 presigned/resumable로 옮겨가거나 플랫폼에 MULTIPART 모드가 생겨 계약 경로로 복귀할 때, 같은 routeId 아래 asset 트래픽 전체가 이미 일관되게 모여 있다.

값을 바꿀 이유(예: 기존 registry 충돌, 명명 규칙 위반)는 없었다.

교체 계획

  1. presigned/resumable 업로드로 이전. src/adapters/browser-transfer/에 이미 presigned capability와 resumable checkpoint 인프라가 있다(별도 리뷰: 04 — Browser transfer). Studio asset 업로드가 그쪽으로 옮겨가면, 이 transport는 presigned URL 발급을 위한 작은 JSON operation(계약 실행기 경유 가능)과 실제 바이트 전송을 위한 presigned executor 호출로 나뉜다. POST /api/v1/studio/assets의 multipart 자체가 없어진다.
  2. 플랫폼에 requestBody: "MULTIPART" 모드가 생기는 경우. external-contract-runtime.tsclient.tsFormData 본문을 표현할 수 있게 확장되면, uploadStudioAsset을 다른 17개 operation과 함께 tech-log-studio-contract-contribution.ts에 등록하고 createHttpStudioAssetGatewayupload 의존성을 제거한다. StudioAssetGateway 포트 시그니처(uploadAsset(form, options): Promise<Asset>)는 바뀌지 않는다 — 교체는 이 파일과 create-tech-log-feature-input.ts의 배선 한 줄에서 끝난다.

두 경로 모두 StudioAssetUploadTransport/StudioAssetGateway 포트 경계 뒤에서 일어나므로, presentation 계층(Task 11의 Asset Library UI)은 재작성하지 않는다.

검증

corepack pnpm exec vitest run tests/features/tech-log/asset-upload-transport.test.ts
corepack pnpm exec vitest run tests/features/tech-log/studio-asset-gateway.test.ts
corepack pnpm exec vitest run tests/features/tech-log/runtime-composition.test.ts
corepack pnpm exec vitest run tests/features/tech-log/studio-csrf-composition.test.ts
corepack pnpm exec vitest run tests/features/tech-log/studio-session-csrf.test.ts
corepack pnpm check:types
corepack pnpm test:tech-log

studio-csrf-composition.test.ts는 fix round 1에서 추가됐다 — 실 createContractHttpExecutor · createCsrfTokenProvider · attachStudioSessionCredentials를 composition root와 같은 방식으로 조립해 getStudioSession이 정확히 한 번만 나가고 그 토큰이 JSON operation과 업로드 양쪽에 모두 실리는지 검증한다. studio-session-csrf.test.ts는 provider의 재진입 가드를 단독으로 고정한다.

정확한 실행 결과는 .superpowers/sdd/2026-08-17-techlog-backend-alignment/task-7-report.md에 있다.