diff --git a/docs/reviews/adapters/06-tech-log-asset-upload.md b/docs/reviews/adapters/06-tech-log-asset-upload.md new file mode 100644 index 0000000..5a528c8 --- /dev/null +++ b/docs/reviews/adapters/06-tech-log-asset-upload.md @@ -0,0 +1,76 @@ +# 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.ts`의 `attachCredentials`/`techLogCsrf`. `src/adapters/**` 전수 리뷰([INVENTORY](./INVENTORY.md))와는 별도 트랙이다: 이 파일은 `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`의 `requestBody` 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만 포트 경계 뒤에서 다른 구현으로 우회한다. + +## 우회 범위 + +`tech-log-studio-contract-contribution.ts`가 등록하는 canonical operation은 19개다. + +| 분류 | 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`)는 나머지 4개 asset operation과 동일해 호출자는 어느 경로인지 알 필요가 없다. + +## 유지되는 보증 + +플랫폼이 18개 JSON operation에 자동으로 제공하는 것을, 이 transport는 같은 provider·같은 값으로 손으로 다시 만든다. + +| 보증 | JSON 경로 | multipart 경로 | +| --- | --- | --- | +| CSRF | `attachCredentials`가 `techLogCsrf.token()`으로 얻은 값을 `x-csrf-token`에 싣는다 | `StudioAssetGateway.uploadAsset()`이 **같은** `techLogCsrf` provider에서 `token()`/`headerName()`을 읽어 transport에 넘긴다 — provider가 composition root에 하나뿐이므로 세션당 토큰도 하나다 | +| 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`와 별개 값을 쓰는 것은 두 가지를 갖는다. + +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](./04-browser-transfer.md)). Studio asset 업로드가 그쪽으로 옮겨가면, 이 transport는 presigned URL 발급을 위한 작은 JSON operation(계약 실행기 경유 가능)과 실제 바이트 전송을 위한 presigned executor 호출로 나뉜다. `POST /api/v1/studio/assets`의 multipart 자체가 없어진다. +2. **플랫폼에 `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`)는 바뀌지 않는다 — 교체는 이 파일과 `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 check:types +corepack pnpm test:tech-log +``` + +정확한 실행 결과는 `.superpowers/sdd/2026-08-17-techlog-backend-alignment/task-7-report.md`에 있다. diff --git a/docs/reviews/adapters/INVENTORY.md b/docs/reviews/adapters/INVENTORY.md index 828fc6e..ccfa8bf 100644 --- a/docs/reviews/adapters/INVENTORY.md +++ b/docs/reviews/adapters/INVENTORY.md @@ -131,3 +131,9 @@ 합계: **120/120**. 새 adapter 파일이 추가되면 이 ledger와 해당 상세 리뷰 inventory를 같은 변경에서 갱신한다. +## Feature-scoped adapter 리뷰 (`src/adapters/**` 밖) + +이 표는 `corepack pnpm check:adapter-inventory`가 `git ls-files src/adapters`와 대조하는 목록이라 `src/features/**/adapters/**` 파일은 포함하지 않는다. TechLog는 자체 canonical HTTP 계약을 갖는 product feature이며 그 adapter는 별도 트랙으로 검토한다. + +- `src/features/tech-log/adapters/http/asset-upload-transport.ts` — [06 — TechLog asset multipart upload](./06-tech-log-asset-upload.md) + diff --git a/src/bootstrap/runtime-adapters.ts b/src/bootstrap/runtime-adapters.ts index 57e71a5..5c2b1f2 100644 --- a/src/bootstrap/runtime-adapters.ts +++ b/src/bootstrap/runtime-adapters.ts @@ -27,6 +27,8 @@ import { createConditionalValidatorStore } from "../adapters/query-cache/conditi import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.ts"; import { createBrowserMutationIntentFactory } from "../adapters/platform/browser-mutation-intent-factory.ts"; import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.ts"; +import { createCsrfTokenProvider } from "../features/tech-log/adapters/http/studio-session-csrf.ts"; +import type { StudioOperationExecutor as TechLogStudioOperationExecutor } from "../features/tech-log/adapters/http/http-studio-gateway.ts"; import type { AuthSessionPort } from "../application/ports/auth-session-port.ts"; import type { ReleaseInfo } from "../application/ports/release-info-port.ts"; import { @@ -420,6 +422,43 @@ export async function createRuntimeAdapters( location.reload(); }, }); + /** + * §7.7 / Task 7. There is exactly one CSRF provider per Studio session, and + * it is owned by the composition root — not by the TechLog feature input — + * because two collaborators share it: `attachCredentials` below (the only + * path by which `x-csrf-token` reaches the 18 JSON operations) and the + * multipart upload transport, which bypasses the platform executor + * entirely and must set the header itself. If each built its own provider, + * one Studio session would hold two different tokens. + * + * `execute` calls `getStudioSession` through `contractOperations`, which is + * declared further below — a real ordering hazard, since `attachCredentials` + * (needed to build `contractHttp`, needed to build `contractOperations`) + * needs this provider first. `getStudioSession` is a SAFE operation and + * needs no CSRF itself, so there is no true cycle: the callback below only + * *runs* once the whole runtime is composed and a Studio request is made, + * by which point `contractOperations` is assigned. `let` plus a forward + * reference inside this closure defers the read to call time instead of + * declaration time. + */ + let contractOperations!: TechLogStudioOperationExecutor; + 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 }; + }, + }); const contractHttp = createContractHttpExecutor({ baseUrl: config.API_BASE_URL, maxRetryAttempts: config.MAX_RETRY_ATTEMPTS, @@ -436,6 +475,25 @@ export async function createRuntimeAdapters( if (serverStateScope.getPhase() !== "READY") { return Object.freeze({ kind: "SCOPE_FENCED" as const }); } + if (operation.authProfileId === "TECH_LOG_STUDIO_SESSION") { + // Studio authenticates with a session cookie and carries only the + // CSRF token as a proof header. Read operations use this profile too + // — the server does not require the header for them — so a failure + // to fetch the token fails only this one request (`UNAVAILABLE`) and + // is not promoted to a session-level `UNAUTHENTICATED`, which would + // trigger a global re-authentication flow the session itself did not + // warrant. + 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(); if (state === "integration-failed") { return Object.freeze({ kind: "UNAVAILABLE" as const }); @@ -465,7 +523,7 @@ export async function createRuntimeAdapters( }, observe: createHttpObservationProjector({ diagnostics, telemetry }), }); - const contractOperations = Object.freeze({ + contractOperations = Object.freeze({ async execute( operationId: string, input: unknown, @@ -499,6 +557,11 @@ export async function createRuntimeAdapters( }); if (outcome.kind === "UNAUTHENTICATED") { authSession.onUnauthenticated(); + // The Studio session (and the CSRF token it issued) expired. The + // cache owner discards it here, not the gateway — the gateway has no + // way to know a 401 on one operation invalidates a token shared by + // every other in-flight and future Studio request. + techLogCsrf.invalidate(); } return outcome; }, @@ -506,6 +569,9 @@ export async function createRuntimeAdapters( const featureInputs = createInstalledFeatureInputs({ contractOperations, studioSource: config.TECH_LOG_STUDIO_SOURCE, + apiBaseUrl: config.API_BASE_URL, + requestTimeoutMs: config.REQUEST_TIMEOUT_MS, + csrf: techLogCsrf, }); return Object.freeze({ diff --git a/src/features/installed-feature-adapters.ts b/src/features/installed-feature-adapters.ts index 1d998e0..56c4982 100644 --- a/src/features/installed-feature-adapters.ts +++ b/src/features/installed-feature-adapters.ts @@ -2,6 +2,7 @@ import type { ApplicationFeatureInputs } from "../application/ports/in/applicati import { createReferenceFeatureInstalledInput } from "./reference-feature/adapters/create-reference-feature-input.ts"; import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts"; import { createTechLogFeatureInstalledInput } from "./tech-log/adapters/create-tech-log-feature-input.ts"; +import type { CsrfTokenProvider } from "./tech-log/adapters/http/studio-session-csrf.ts"; import { TECH_LOG_FEATURE_ID } from "./tech-log/application/tech-log-feature-input.ts"; import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts"; @@ -20,7 +21,12 @@ type InstalledFeatureInputs = Readonly< export function createInstalledFeatureInputs( context: Parameters[0] & - Readonly<{ studioSource: "MOCK" | "HTTP" }>, + Readonly<{ + studioSource: "MOCK" | "HTTP"; + apiBaseUrl: string; + requestTimeoutMs: number; + csrf: CsrfTokenProvider; + }>, ): InstalledFeatureInputs { const techLogFeature = createTechLogFeatureInstalledInput(context); if (!INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)) { diff --git a/src/features/tech-log/adapters/create-tech-log-feature-input.ts b/src/features/tech-log/adapters/create-tech-log-feature-input.ts index 68f0ff7..f580d77 100644 --- a/src/features/tech-log/adapters/create-tech-log-feature-input.ts +++ b/src/features/tech-log/adapters/create-tech-log-feature-input.ts @@ -2,10 +2,13 @@ import { TECH_LOG_FEATURE_ID, type TechLogFeatureInput, } from "../application/tech-log-feature-input.ts"; +import { createAssetUploadTransport } from "./http/asset-upload-transport.ts"; +import { createHttpStudioAssetGateway } from "./http/http-studio-asset-gateway.ts"; import { createHttpStudioGateway, type StudioOperationExecutor, } from "./http/http-studio-gateway.ts"; +import type { CsrfTokenProvider } from "./http/studio-session-csrf.ts"; import { createMockStudioGateway } from "./mock/mock-studio-gateway.ts"; import { publicContentQueries } from "./static/public-query.ts"; @@ -15,15 +18,35 @@ import { publicContentQueries } from "./static/public-query.ts"; * platform's `attachCredentials` collaborator at the composition root. This * context only has to say which adapter to construct and hand it the * composed contract executor. + * + * CSRF has exactly one provider per Studio session, owned by the composition + * root — it is shared with the platform's credential collaborator, so it is + * threaded in here rather than constructed locally. */ export type TechLogInstallContext = Readonly<{ studioSource: "MOCK" | "HTTP"; contractOperations: StudioOperationExecutor; + apiBaseUrl: string; + requestTimeoutMs: number; + csrf: CsrfTokenProvider; }>; export function createTechLogFeatureInstalledInput( context: TechLogInstallContext, ) { + // The Asset gateway is needed even on MOCK: with no backend behind it, the + // list comes back empty and uploads fail with a transport error — the UI + // surfacing that state is the correct behavior, not a bug to route around. + const createStudioAssetGateway = () => + createHttpStudioAssetGateway({ + operations: context.contractOperations, + csrf: context.csrf, + upload: createAssetUploadTransport({ + baseUrl: context.apiBaseUrl, + timeoutMs: context.requestTimeoutMs, + }), + }); + const createStudioGateway = () => context.studioSource === "MOCK" ? createMockStudioGateway() @@ -32,6 +55,7 @@ export function createTechLogFeatureInstalledInput( const input: TechLogFeatureInput = Object.freeze({ publicContent: publicContentQueries, createStudioGateway, + createStudioAssetGateway, }); return Object.freeze({ diff --git a/src/features/tech-log/adapters/http/asset-upload-transport.ts b/src/features/tech-log/adapters/http/asset-upload-transport.ts new file mode 100644 index 0000000..247dd80 --- /dev/null +++ b/src/features/tech-log/adapters/http/asset-upload-transport.ts @@ -0,0 +1,85 @@ +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(STUDIO_ERROR_CODES); + +/** + * The contract runtime can only express `requestBody: "NONE" | "JSON"` and the + * low-level client always serializes the body as JSON (see + * `external-contract-runtime.ts` and `client.ts:719`). The canonical + * `POST /assets` is multipart, so this one operation is split into a narrow + * seam instead. If upload moves to presigned/resumable transfer, or the + * platform grows a MULTIPART mode, only this file is replaced. + */ +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 is never set by hand here. fetch generates the + // multipart boundary; setting it manually produces a body the server + // cannot parse. + 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; + 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}.`); + }, + }); +} diff --git a/src/features/tech-log/application/tech-log-feature-input.ts b/src/features/tech-log/application/tech-log-feature-input.ts index 9eb506e..8f150f1 100644 --- a/src/features/tech-log/application/tech-log-feature-input.ts +++ b/src/features/tech-log/application/tech-log-feature-input.ts @@ -1,4 +1,5 @@ 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; @@ -6,6 +7,7 @@ export const TECH_LOG_FEATURE_ID = "tech-log" as const; export type TechLogFeatureInput = Readonly<{ publicContent: PublicContentQueries; createStudioGateway(): StudioGateway; + createStudioAssetGateway(): StudioAssetGateway; }>; declare module "../../../application/ports/in/application-api.ts" { diff --git a/src/features/tech-log/presentation/studio/studio-provider.tsx b/src/features/tech-log/presentation/studio/studio-provider.tsx index 263f73c..21580a7 100644 --- a/src/features/tech-log/presentation/studio/studio-provider.tsx +++ b/src/features/tech-log/presentation/studio/studio-provider.tsx @@ -7,6 +7,7 @@ import { } from "react"; import { isStudioGatewayError } from "../../application/ports/studio-gateway-error.ts"; +import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts"; import type { StudioGateway } from "../../application/ports/studio-gateway.ts"; import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts"; import type { @@ -25,6 +26,9 @@ import { useBeforeUnload } from "./components/use-before-unload.ts"; type StudioProviderProps = Readonly<{ children: ReactNode; createGateway: () => StudioGateway; + // Optional so test harnesses that only exercise the document gateway keep + // working unchanged. `StudioShell` always supplies one in the running app. + createAssetGateway?: () => StudioAssetGateway; resolvePublishedLabel?: ResolvePublishedLabel; now?: () => Date; navigate?: (href: string) => void; @@ -48,11 +52,18 @@ const missingPublishedLabel: ResolvePublishedLabel = () => undefined; export function StudioProvider({ children, createGateway, + createAssetGateway, resolvePublishedLabel = missingPublishedLabel, now = () => new Date("2026-08-14T01:00:00.000Z"), navigate = defaultNavigate, }: StudioProviderProps) { const [gateway] = useState(() => createGateway()); + // Same lazy, called-once-per-mount pattern as `gateway`. Both are keyed to + // the same provider generation in `StudioShell`, so a persisted `pageshow` + // remount recreates them together — never one without the other. + const [assetGateway] = useState( + () => createAssetGateway?.() ?? null, + ); const [editor, setEditor] = useState(null); const [pendingHref, setPendingHref] = useState(null); const [requestAnnouncement, setRequestAnnouncement] = useState(""); @@ -148,6 +159,7 @@ export function StudioProvider({ const value = useMemo( () => ({ gateway, + assetGateway, resolvePublishedLabel, now, editor, @@ -160,6 +172,7 @@ export function StudioProvider({ clearEditor, }), [ + assetGateway, beginEditor, clearEditor, editor, diff --git a/src/features/tech-log/presentation/studio/studio-shell.tsx b/src/features/tech-log/presentation/studio/studio-shell.tsx index bd9250f..d10a301 100644 --- a/src/features/tech-log/presentation/studio/studio-shell.tsx +++ b/src/features/tech-log/presentation/studio/studio-shell.tsx @@ -47,6 +47,10 @@ export function StudioShell({ children }: StudioShellProps) { () => application.features.get(TECH_LOG_FEATURE_ID).createStudioGateway(), [application], ); + const createAssetGateway = useCallback( + () => application.features.get(TECH_LOG_FEATURE_ID).createStudioAssetGateway(), + [application], + ); const resolvePublishedLabel = useCallback( (path: string) => application.features @@ -69,6 +73,7 @@ export function StudioShell({ children }: StudioShellProps) { diff --git a/src/features/tech-log/presentation/studio/use-studio.ts b/src/features/tech-log/presentation/studio/use-studio.ts index 754a814..85c97b6 100644 --- a/src/features/tech-log/presentation/studio/use-studio.ts +++ b/src/features/tech-log/presentation/studio/use-studio.ts @@ -1,5 +1,6 @@ import { createContext, useContext, useMemo } from "react"; +import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts"; import type { StudioGateway } from "../../application/ports/studio-gateway.ts"; import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts"; import type { @@ -18,6 +19,10 @@ export type StudioEditorState = Readonly<{ export type StudioContextValue = Readonly<{ gateway: StudioGateway; + // `null` only in test harnesses that render `StudioProvider` without an + // `createAssetGateway` prop. `StudioShell` — the real app path — always + // supplies one, so production code sees this populated. + assetGateway: StudioAssetGateway | null; resolvePublishedLabel: ResolvePublishedLabel; now(): Date; editor: StudioEditorState | null; diff --git a/tests/features/tech-log/asset-upload-transport.test.ts b/tests/features/tech-log/asset-upload-transport.test.ts new file mode 100644 index 0000000..fd35d22 --- /dev/null +++ b/tests/features/tech-log/asset-upload-transport.test.ts @@ -0,0 +1,120 @@ +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([""], "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; + }); +}); diff --git a/tests/features/tech-log/feature-input.test.ts b/tests/features/tech-log/feature-input.test.ts index 73d500a..d379aae 100644 --- a/tests/features/tech-log/feature-input.test.ts +++ b/tests/features/tech-log/feature-input.test.ts @@ -14,7 +14,10 @@ type Equal = type Assert = Condition; type TechLogFeatureInputExposesNoMissingOrAdditionalKeys = Assert< - Equal + Equal< + keyof ApplicationFeatureInputs["tech-log"], + "publicContent" | "createStudioGateway" | "createStudioAssetGateway" + > >; type TechLogFeatureInputRegistryValueMatchesFeatureContract = Assert< Equal diff --git a/tests/features/tech-log/runtime-composition.test.ts b/tests/features/tech-log/runtime-composition.test.ts index 39ce710..41d4b8b 100644 --- a/tests/features/tech-log/runtime-composition.test.ts +++ b/tests/features/tech-log/runtime-composition.test.ts @@ -4,6 +4,7 @@ import { test } from "vitest"; import type { ApplicationFeatureInputs } from "../../../src/application/ports/in/application-api.ts"; import { createInstalledFeatureInputs } from "../../../src/features/installed-feature-adapters.ts"; import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts"; +import { createCsrfTokenProvider } from "../../../src/features/tech-log/adapters/http/studio-session-csrf.ts"; import type { TechLogFeatureInput } from "../../../src/features/tech-log/application/tech-log-feature-input.ts"; import type { WorkingCopy } from "../../../src/features/tech-log/contracts/studio/contract.ts"; @@ -42,6 +43,13 @@ function installedInputs( throw new Error("reference executor is not used by composition tests"); }, }, + apiBaseUrl: "http://composition.test/", + requestTimeoutMs: 10_000, + csrf: createCsrfTokenProvider({ + async execute() { + throw new Error("CSRF provider is not used by composition tests"); + }, + }), }); } @@ -50,6 +58,7 @@ test("installs TechLog beside the retained reference feature through application assert.deepEqual(Object.keys(installed), ["reference-feature", "tech-log"]); assert.deepEqual(Object.keys(installed["tech-log"]).sort(), [ + "createStudioAssetGateway", "createStudioGateway", "publicContent", ]); @@ -107,3 +116,11 @@ test("each createStudioGateway call owns an isolated mutable Studio session", as "컬렉션 Fetch Join과 페이징은 왜 충돌하는가", ); }); + +test("each Studio session gets its own asset gateway instance", () => { + const installed = installedInputs("HTTP"); + assert.notEqual( + installed["tech-log"].createStudioAssetGateway(), + installed["tech-log"].createStudioAssetGateway(), + ); +}); diff --git a/tests/features/tech-log/studio-asset-gateway.test.ts b/tests/features/tech-log/studio-asset-gateway.test.ts index a4d5bd6..94aa067 100644 --- a/tests/features/tech-log/studio-asset-gateway.test.ts +++ b/tests/features/tech-log/studio-asset-gateway.test.ts @@ -67,6 +67,33 @@ test("lists assets through the canonical operation", async () => { assert.equal(calls[0]!.operationId, "listStudioAssets"); }); +test("gets a single asset detail through the canonical operation", async () => { + const detail = { + asset: READY_ASSET, + usages: [ + { + documentId: "22222222-2222-4222-8222-222222222222", + documentTitle: "Fetch Join과 Batch Fetch 비교", + }, + ], + hasPublicationHistory: true, + }; + const { calls, dependencies } = deps({ + getStudioAsset: { + kind: "SUCCESS", + value: detail, + effect: "APPLIED_CONFIRMED", + }, + }); + const gateway = createHttpStudioAssetGateway(dependencies as never); + + const result = await gateway.getAsset(READY_ASSET.id); + + assert.equal(calls[0]!.operationId, "getStudioAsset"); + assert.deepEqual(calls[0]!.input, { assetId: READY_ASSET.id }); + assert.deepEqual(result, detail); +}); + test("delegates upload to the transport with CSRF and idempotency headers", async () => { let received: Record = {}; const { dependencies } = deps({}); diff --git a/tests/helpers/studio-install-context.ts b/tests/helpers/studio-install-context.ts index c6ee0ed..5e756da 100644 --- a/tests/helpers/studio-install-context.ts +++ b/tests/helpers/studio-install-context.ts @@ -1,9 +1,17 @@ +import { createCsrfTokenProvider } from "../../src/features/tech-log/adapters/http/studio-session-csrf.ts"; import type { TechLogInstallContext } from "../../src/features/tech-log/adapters/create-tech-log-feature-input.ts"; /** * The install context the existing Studio test suite composes against. It * always selects the mock adapter, so `contractOperations` is a throwing stub * — `createTechLogFeatureInstalledInput` never reads it on the MOCK branch. + * + * `createStudioAssetGateway` is unconditional (Task 7), so `apiBaseUrl`, + * `requestTimeoutMs` and `csrf` must still be well-formed even here: building + * the gateway constructs the upload transport eagerly. No existing test + * exercises the asset gateway's operations, so `contractOperations` and + * `csrf` stay throwing stubs — the same "never actually used" contract as + * before. */ export const MOCK_STUDIO_INSTALL_CONTEXT: TechLogInstallContext = Object.freeze({ studioSource: "MOCK", @@ -12,4 +20,11 @@ export const MOCK_STUDIO_INSTALL_CONTEXT: TechLogInstallContext = Object.freeze( throw new Error("contract executor is not used by the mock Studio gateway"); }, }), + apiBaseUrl: "http://mock-studio.test/", + requestTimeoutMs: 10_000, + csrf: createCsrfTokenProvider({ + async execute() { + throw new Error("CSRF provider is not used by the mock Studio gateway"); + }, + }), });