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>
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.ts1개 파일과 그 wiring —create-tech-log-feature-input.ts,installed-feature-adapters.ts,bootstrap/runtime-adapters.ts의attachCredentials/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.ts의requestBodyunion은"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.ts → attachCredentials |
| 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 | attachCredentials가 techLogCsrf.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.code만 StudioGatewayError로 승격하고, 계약 밖 코드는 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.ts의 const 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와 별개 값을 쓰는 것은 두 가지를 갖는다.
- Asset 수명주기(업로드·목록·삭제)는 문서 편집과 실패 특성이 다르다 — 예를 들어
PAYLOAD_TOO_LARGE/UNSUPPORTED_MEDIA_TYPE/ASSET_QUARANTINED는 asset 쪽에만 있다. 별도 routeId는 이 실패를 diagnostics에서 문서 편집 트래픽과 섞지 않는다. uploadStudioAsset자체는 계약 실행기를 우회해 이 routeId를 진단에 보고하지 않지만, 같은 이름을 4개 JSON operation에 유지해 두면 향후 업로드가 presigned/resumable로 옮겨가거나 플랫폼에 MULTIPART 모드가 생겨 계약 경로로 복귀할 때, 같은 routeId 아래 asset 트래픽 전체가 이미 일관되게 모여 있다.
값을 바꿀 이유(예: 기존 registry 충돌, 명명 규칙 위반)는 없었다.
교체 계획
- 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 자체가 없어진다. - 플랫폼에
requestBody: "MULTIPART"모드가 생기는 경우.external-contract-runtime.ts와client.ts가FormData본문을 표현할 수 있게 확장되면,uploadStudioAsset을 다른 17개 operation과 함께tech-log-studio-contract-contribution.ts에 등록하고createHttpStudioAssetGateway의upload의존성을 제거한다.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에 있다.