docs: correct the alignment plan against platform transport constraints

Pre-flight scan found five defects that would have failed at composition:

- authProfileId TECH_LOG_STUDIO_SESSION was never registered
- deleteStudioAsset declared responseByteLimit 0 (platform requires >= 1)
- gateways passed CSRF/idempotency through operation input, but contract
  projection has no header channel; both must use the platform seams
- the browser mutation intent factory generates its own key, discarding the
  caller-supplied one the port contract depends on
- instant preview was never wired to the loaded asset list

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-17 23:52:47 +09:00
co-authored by Claude Opus 5
parent 78766accdf
commit 9f0599a389
@@ -19,7 +19,12 @@
- `src/application/ports/studio-gateway.ts`의 시그니처를 변경하지 않는다. UI는 어댑터 종류를 알지 못한다.
- 현재 Public UI·라우트와 Studio 작업 흐름을 변경하지 않는다. `presentation/public/**`, `presentation/shared/public-render/**`, 기존 `document-*`/`validation-*`/`publish-*`/`publication-*` 컴포넌트는 Task 8~10에서 지정한 지점 외에는 손대지 않는다.
- **`tests/mocks/scenarios/catalog.ts``HTTP_SCENARIO_OPERATION_IDS`에 TechLog operation을 추가하지 않는다.** 그 카탈로그는 플랫폼 전송 계층의 실패 모드 행렬이며 reference feature 3개 operation으로 이미 입증돼 있다. 어떤 스크립트도 계약 등록부와 대조하지 않으므로(확인함) TechLog operation을 넣으면 19 시나리오 × 18 operation의 증거 부담만 생긴다. TechLog는 `tests/features/tech-log/`에서 MSW로 gateway 수준 계약을 검증한다.
- 플랫폼 파일 중 이 계획이 수정을 허용하는 것은 `src/contracts/release-artifacts.ts`, `src/bootstrap/runtime-config-schema.ts`, `src/contracts/env.ts`, `src/features/installed-contract-contributions.ts`, `src/features/installed-feature-adapters.ts`, `src/bootstrap/runtime-adapters.ts`뿐이다. `src/contracts/external-contract-runtime.ts` `src/adapters/http/client.ts`**수정하지 않는다**(template 동기화 대상).
- 플랫폼 파일 중 이 계획이 수정을 허용하는 것은 `src/contracts/release-artifacts.ts`, `src/bootstrap/runtime-config-schema.ts`, `src/contracts/env.ts`, `src/contracts/rest-profiles.ts`, `src/features/installed-contract-contributions.ts`, `src/features/installed-feature-adapters.ts`, `src/bootstrap/runtime-adapters.ts`뿐이다. `src/contracts/external-contract-runtime.ts`, `src/adapters/http/client.ts`, `src/adapters/http/http-execution-v3.ts`**수정하지 않는다**(template 동기화 대상).
- **인증·CSRF·Idempotency는 플랫폼 seam을 쓴다. 직접 헤더를 만들지 않는다.** 계약의 `projectRequest``pathValues`/`queryEntries`/`body`만 만들 수 있고 헤더 채널이 없다(`external-contract-runtime.ts``HttpRequestProjection`). 헤더는 두 경로로만 들어간다.
- `Idempotency-Key`: 실행 context의 `intent.idempotencyKey`에서 온다(`http-execution-v3.ts:689-690`). 계약 소유 헤더이므로 credential 쪽에서 공급하면 거절된다(`:657-661`). 명령 gateway는 `intent`를 넘기고 **입력 본문에 `idempotencyKey`를 넣지 않는다**.
- `x-csrf-token`: `attachCredentials` collaborator가 공급하고 `admitCredentialHeaders`가 auth profile의 허용 목록으로 검사한다(`http-execution-v3.ts:435, :672`). gateway 입력에 `csrfToken`을 넣지 않는다.
- `frontend.authProfileId``INSTALLED_REST_AUTH_PROFILES`에 등록된 값이어야 한다. 없는 값이면 **composition이 실패한다**(`external-contract-runtime.ts:325-326`). 등록은 Task 3 Step 1이 한다.
- `frontend.responseByteLimit`은 **1 이상**이어야 하고(`external-contract-runtime.ts:336-337`) `hardResponseBytes: 8_388_608`을 넘을 수 없다. `requestByteLimit`은 0 이상 `hardRequestBytes: 1_048_576` 이하다.
- 매 Task는 red → green → 게이트 → commit 순서를 지킨다.
---
@@ -529,8 +534,9 @@ git commit -m "feat: add TechLog Studio error mapping and CSRF token provider"
**Files:**
- Create: `src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts`
- Modify: `src/contracts/rest-profiles.ts` (auth profile 등록)
- Modify: `src/features/installed-contract-contributions.ts`
- Test: `tests/features/tech-log/studio-contract-contribution.test.ts`
- Test: `tests/features/tech-log/studio-contract-contribution.test.ts`, `tests/runtime-schema/http-schema.test.ts`
**Interfaces:**
- Consumes: `canonical-source.json`(Task 1), `InstalledContractContribution`/`InstalledHttpContract`(`src/contracts/external-contract-runtime.ts`)
@@ -560,11 +566,45 @@ git commit -m "feat: add TechLog Studio error mapping and CSRF token provider"
| `listStudioAssets` | GET | `/api/v1/studio/assets` | NONE | SAFE | 200 | 262_144 |
| `getStudioAsset` | GET | `/api/v1/studio/assets/{assetId}` | NONE | SAFE | 200 | 65_536 |
| `updateStudioAsset` | PUT | `/api/v1/studio/assets/{assetId}` | JSON | KEYED | 200 | 65_536 |
| `deleteStudioAsset` | DELETE | `/api/v1/studio/assets/{assetId}` | NONE | KEYED | 204 | 0 |
| `deleteStudioAsset` | DELETE | `/api/v1/studio/assets/{assetId}` | NONE | KEYED | 204 | 65_536 |
`deleteStudioAsset``responseBody: "NONE"`, `emptyBodyStatuses: [204]`다. 나머지는 `responseBody: "REQUIRED_JSON"`, `emptyBodyStatuses: []`다.
`deleteStudioAsset``responseBody: "NONE"`, `emptyBodyStatuses: [204]`다. 나머지는 `responseBody: "REQUIRED_JSON"`, `emptyBodyStatuses: []`다. `deleteStudioAsset``responseByteLimit`이 0이 아닌 이유는 플랫폼이 1 이상을 요구하기 때문이다 — 본문 없는 204여도 오류 응답은 본문을 가진다.
- [ ] **Step 1: 실패하는 테스트 작성**
- [ ] **Step 1: Studio auth profile 등록**
`authProfileId``INSTALLED_REST_AUTH_PROFILES`에 없으면 계약 composition이 실패한다. 현재 등록된 것은 `REFERENCE_EXTERNAL_BEARER``ANONYMOUS`뿐이므로 Studio 세션 profile을 먼저 만든다.
canonical은 "Mutating Studio Operation은 Session Cookie와 `X-CSRF-TOKEN`을 요구한다"고 정한다. 플랫폼에는 이 조합이 이미 1급으로 있다 — `transport: "SAME_ORIGIN_COOKIE"`와 credential header `"x-csrf-token"`.
`src/contracts/rest-profiles.ts``REST_AUTH_PROFILES`에 추가한다:
```typescript
TECH_LOG_STUDIO_SESSION: Object.freeze({
authProfileId: "TECH_LOG_STUDIO_SESSION",
transport: "SAME_ORIGIN_COOKIE",
credentials: "include",
allowedCredentialHeaders: Object.freeze(["x-csrf-token"] as const),
requiredCredentialHeaders: Object.freeze(["x-csrf-token"] as const),
}),
```
`installRestAuthProfileRegistry``SAME_ORIGIN_COOKIE` 불변식을 만족하는지 확인한다: `credentials !== "omit"` ✓, `allowed``"authorization"`이 없어야 함 ✓, `required ⊆ allowed` ✓.
`tests/runtime-schema/http-schema.test.ts`(또는 rest-profile을 다루는 기존 테스트)에 profile이 설치되는지 단언을 추가한다:
```typescript
test("installs the TechLog Studio session auth profile", () => {
const profile = INSTALLED_REST_AUTH_PROFILES.get("TECH_LOG_STUDIO_SESSION");
assert.ok(profile);
assert.equal(profile.transport, "SAME_ORIGIN_COOKIE");
assert.deepEqual([...profile.requiredCredentialHeaders], ["x-csrf-token"]);
});
```
Run: `corepack pnpm test:runtime-schema`
Expected: PASS
- [ ] **Step 2: 실패하는 테스트 작성**
`tests/features/tech-log/studio-contract-contribution.test.ts`:
@@ -624,12 +664,12 @@ test("path templates match the canonical /api/v1/studio prefix", () => {
});
```
- [ ] **Step 2: red 확인**
- [ ] **Step 3: red 확인**
Run: `corepack pnpm exec vitest run tests/features/tech-log/studio-contract-contribution.test.ts`
Expected: FAIL — 기여 모듈을 찾을 수 없다.
- [ ] **Step 3: 기여 파일 작성**
- [ ] **Step 4: 기여 파일 작성**
`src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts`. 먼저 공용 헬퍼와 대표 operation 두 개를 정확한 형태로 만든 뒤, 위 표의 나머지 16개를 같은 헬퍼로 선언한다.
@@ -894,7 +934,7 @@ export const TECH_LOG_STUDIO_CONTRIBUTION: InstalledContractContribution =
});
```
- [ ] **Step 4: 설치 등록부에 추가**
- [ ] **Step 5: 설치 등록부에 추가**
`src/features/installed-contract-contributions.ts``INSTALLED_CONTRACT_CONTRIBUTIONS`를 수정한다. TechLog는 항상 설치되므로 조건 없이 포함한다:
@@ -909,22 +949,23 @@ export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContrib
);
```
- [ ] **Step 5: green 확인**
- [ ] **Step 6: green 확인**
Run: `corepack pnpm exec vitest run tests/features/tech-log/studio-contract-contribution.test.ts`
Expected: 5 tests PASS
- [ ] **Step 6: 계약 집합과 아키텍처 게이트 확인**
- [ ] **Step 7: 계약 집합과 아키텍처 게이트 확인**
Run: `corepack pnpm check:types:app && corepack pnpm generate:contract-set && corepack pnpm check:architecture`
Expected: PASS. `contractSet``tech-log-studio-contract@2.0.0`이 나타난다. `EXTERNAL_PACKAGE`이므로 `TEMPLATE_FIXTURE`와 달리 release digest에 반영된다.
- [ ] **Step 7: 커밋**
- [ ] **Step 8: 커밋**
```bash
git add src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts \
src/features/installed-contract-contributions.ts \
tests/features/tech-log/studio-contract-contribution.test.ts .generated/
src/contracts/rest-profiles.ts src/features/installed-contract-contributions.ts \
tests/features/tech-log/studio-contract-contribution.test.ts \
tests/runtime-schema/http-schema.test.ts .generated/
git commit -m "feat: register the TechLog Studio contract contribution"
```
@@ -935,13 +976,17 @@ git commit -m "feat: register the TechLog Studio contract contribution"
**Files:**
- Create: `src/features/tech-log/adapters/http/http-studio-gateway.ts`
- Create: `tests/mocks/handlers/tech-log-studio.ts`
- Move: `src/features/tech-log/adapters/mock/stable-stringify.ts``src/features/tech-log/adapters/stable-stringify.ts` (mock의 import 경로를 함께 고친다)
- Test: `tests/features/tech-log/http-studio-gateway.test.ts`
HTTP 어댑터가 `mock/`에서 import하면 프로덕션 경로가 mock 경로에 의존한다. 두 어댑터가 공유하는 순수 함수이므로 한 단계 위로 옮긴다. 내용은 바꾸지 않는다.
**Interfaces:**
- Consumes: `toStudioGatewayError`/`createCsrfTokenProvider`(Task 2), `TechLogStudioOperationId`(Task 3), `StudioGateway`(`application/ports/studio-gateway.ts`), `InstalledContractOperationExecutor`(`features/reference-feature/adapters/create-reference-feature-input.ts`의 동형 타입)
- Produces:
- `type StudioOperationExecutor = Readonly<{ execute(operationId: string, input: unknown, context: Readonly<{ routeId: string; signal?: AbortSignal; intent?: MutationIntent }>): Promise<HttpExecutionOutcome<unknown, unknown>> }>`
- `createHttpStudioGateway(deps: Readonly<{ operations: StudioOperationExecutor; csrf: CsrfTokenProvider }>): StudioGateway`
- `createHttpStudioGateway(deps: Readonly<{ operations: StudioOperationExecutor }>): StudioGateway`
- `mutationIntent(operationId: string, idempotencyKey: string, input: unknown): MutationIntent` — Task 6도 이 함수를 쓴다
- [ ] **Step 1: MSW 핸들러 작성**
@@ -1224,8 +1269,13 @@ Expected: FAIL — `http-studio-gateway.ts`를 찾을 수 없다.
`src/features/tech-log/adapters/http/http-studio-gateway.ts`. 모든 메서드는 같은 두 헬퍼를 통과한다.
```typescript
import type { MutationIntent } from "../../../../contracts/mutation-intent.ts";
import {
defineIdempotencyKey,
defineMutationIntent,
type MutationIntent,
} from "../../../../contracts/mutation-intent.ts";
import type { HttpExecutionOutcome } from "../../../../adapters/http/http-execution-v3.ts";
import { stableStringify } from "../stable-stringify.ts";
import type {
IdempotentOptions,
RequestOptions,
@@ -1248,11 +1298,42 @@ export type StudioOperationExecutor = Readonly<{
export type HttpStudioGatewayDependencies = Readonly<{
operations: StudioOperationExecutor;
csrf: CsrfTokenProvider;
}>;
const ROUTE_ID = "TECH_LOG_STUDIO";
/**
* `createBrowserMutationIntentFactory`는 쓰지 않는다. 그 factory는
* `requiresIdempotencyKey`일 때 키를 **스스로 생성**하는데, 이 포트의 키는
* 호출자가 만들어 안전한 재시도에 재사용하는 값이다. `defineMutationIntent`가
* 호출자 공급 키를 검증하며 받아주는 정식 경로다(`mutation-intent.ts`의
* "A caller-supplied value is never trimmed, regenerated or silently dropped").
*/
export function mutationIntent(
operationId: string,
idempotencyKey: string,
input: unknown,
): MutationIntent {
return defineMutationIntent({
intentId: globalThis.crypto.randomUUID(),
operationId,
canonicalInputIdentity: canonicalIdentity(input),
idempotencyKey: defineIdempotencyKey(idempotencyKey),
createdAtMonotonicMs: globalThis.performance.now(),
});
}
/** 같은 명령의 재시도가 같은 신원을 갖도록 키 순서를 고정해 직렬화한다. */
function canonicalIdentity(input: unknown): string {
const identity = stableStringify(input);
return identity.length > 16_000 ? identity.slice(0, 16_000) : identity;
}
/**
* 헤더는 gateway가 만들지 않는다. `Idempotency-Key`는 실행 context의 intent에서,
* `x-csrf-token`은 credential collaborator에서 온다. gateway가 입력 본문에
* 넣으면 계약 본문이 오염되고 계약 소유 헤더는 거절된다.
*/
export function createHttpStudioGateway(
deps: HttpStudioGatewayDependencies,
): StudioGateway {
@@ -1274,23 +1355,13 @@ export function createHttpStudioGateway(
input: unknown,
options: IdempotentOptions,
): Promise<T> {
const csrfToken = await deps.csrf.token(
options.signal ? { signal: options.signal } : undefined,
);
const outcome = await deps.operations.execute(
operationId,
{ ...(input as Record<string, unknown>), idempotencyKey: options.idempotencyKey, csrfToken },
{
routeId: ROUTE_ID,
intent: { idempotencyKey: options.idempotencyKey } as MutationIntent,
...(options.signal ? { signal: options.signal } : {}),
},
);
const outcome = await deps.operations.execute(operationId, input, {
routeId: ROUTE_ID,
intent: mutationIntent(operationId, options.idempotencyKey, input),
...(options.signal ? { signal: options.signal } : {}),
});
if (outcome.kind === "SUCCESS") return outcome.value as T;
const error = toStudioGatewayError(outcome, operationId);
// 세션 만료는 토큰을 버려 다음 명령이 새로 받아오게 한다.
if (error.code === "AUTHENTICATION_REQUIRED") deps.csrf.invalidate();
throw error;
throw toStudioGatewayError(outcome, operationId);
}
return Object.freeze({
@@ -1345,7 +1416,7 @@ async function realDependencies() {
});
const operations = {
async execute(operationId: string, input: unknown, context: { signal?: AbortSignal }) {
async execute(operationId: string, input: unknown, context: { signal?: AbortSignal; intent?: unknown }) {
const entry = byId.get(operationId);
if (!entry) throw new Error(`unregistered operation: ${operationId}`);
const { contract } = entry;
@@ -1360,11 +1431,13 @@ async function realDependencies() {
url.searchParams.append(name, value);
}
// 실행기가 헤더를 만드는 두 경로를 그대로 재현한다: intent → Idempotency-Key,
// credential collaborator → x-csrf-token.
const headers: Record<string, string> = {};
const payload = input as Record<string, unknown>;
if (contract.retrySemantics === "KEYED") {
headers["Idempotency-Key"] = String(payload["idempotencyKey"]);
headers["X-CSRF-TOKEN"] = String(payload["csrfToken"]);
const intent = (context as { intent?: { idempotencyKey?: string } }).intent;
if (intent?.idempotencyKey) headers["Idempotency-Key"] = intent.idempotencyKey;
headers["X-CSRF-TOKEN"] = await csrf.token();
}
if (contract.requestBody === "JSON") headers["content-type"] = "application/json";
@@ -1391,11 +1464,11 @@ async function realDependencies() {
},
};
return { operations, csrf } as never;
return { operations } as never;
}
```
`projectRequest`가 KEYED operation에서 `idempotencyKey`/`csrfToken`을 본문에 넣지 않도록, Task 3의 각 KEYED `projectRequest`는 body를 명시적으로 조립해야 한다(`SAVE_STUDIO_DOCUMENT` 예시가 그렇게 돼 있다). 이 테스트가 그 규칙을 지킨다.
Task 3의 각 KEYED `projectRequest`는 body를 명시적으로 조립해야 한다(`SAVE_STUDIO_DOCUMENT` 예시가 그렇게 돼 있다) — 입력을 그대로 spread하면 계약에 없는 필드가 본문에 섞인다. 이 테스트가 그 규칙을 지킨다.
- [ ] **Step 6: green 확인**
@@ -1888,7 +1961,7 @@ import type {
} from "../../application/ports/studio-asset-gateway.ts";
import type { IdempotentOptions, RequestOptions } from "../../application/ports/studio-gateway.ts";
import { toStudioGatewayError } from "./studio-error-mapping.ts";
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
import { mutationIntent, type StudioOperationExecutor } from "./http-studio-gateway.ts";
import type { CsrfTokenProvider } from "./studio-session-csrf.ts";
export type StudioAssetUploadTransport = Readonly<{
@@ -1920,21 +1993,15 @@ export function createHttpStudioAssetGateway(
}
async function command<T>(operationId: string, input: unknown, options: IdempotentOptions) {
const csrfToken = await deps.csrf.token(
options.signal ? { signal: options.signal } : undefined,
);
const outcome = await deps.operations.execute(
operationId,
{ ...(input as Record<string, unknown>), idempotencyKey: options.idempotencyKey, csrfToken },
{
routeId: ROUTE_ID,
...(options.signal ? { signal: options.signal } : {}),
},
);
// Task 4와 동일한 규칙: 헤더는 gateway가 만들지 않는다. `Idempotency-Key`는
// intent에서, `x-csrf-token`은 credential collaborator에서 온다.
const outcome = await deps.operations.execute(operationId, input, {
routeId: ROUTE_ID,
intent: mutationIntent(operationId, options.idempotencyKey, input),
...(options.signal ? { signal: options.signal } : {}),
});
if (outcome.kind === "SUCCESS") return outcome.value as T;
const error = toStudioGatewayError(outcome, operationId);
if (error.code === "AUTHENTICATION_REQUIRED") deps.csrf.invalidate();
throw error;
throw toStudioGatewayError(outcome, operationId);
}
return Object.freeze({
@@ -2238,44 +2305,74 @@ export type TechLogFeatureInput = Readonly<{
}>;
```
`create-tech-log-feature-input.ts``TechLogInstallContext``apiBaseUrl: string``requestTimeoutMs: number`를 더하고, gateway 두 개가 **같은 CSRF 제공자를 공유**하도록 세션 생성을 밖으로 끌어낸다. 공유하지 않으면 Studio 세션 하나에 토큰이 두 개 생긴다.
CSRF 제공자는 **하나**이고 두 곳이 쓴다: 플랫폼 실행기의 credential seam(JSON operation 18개)과 업로드 전송(multipart 1개). 그러므로 feature input이 아니라 **composition root가 소유**한다. 나눠 가지면 Studio 세션 하나에 토큰이 두 개 생긴다.
`src/bootstrap/runtime-adapters.ts`에서 `contractHttp` 조립 **앞에** 제공자를 만든다. `getStudioSession` 자체는 SAFE operation이라 CSRF가 필요 없으므로 재귀하지 않는다:
```typescript
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 };
},
});
```
`contractOperations`가 아직 없는 위치라면 제공자를 지연 생성(`let` + 최초 호출 시 조립)해 순환을 피한다.
같은 파일 `attachCredentials`(`:435`)에 Studio 분기를 더한다. 이것이 `x-csrf-token`이 요청에 실리는 **유일한** 경로다:
```typescript
async attachCredentials(operation, authContext) {
if (serverStateScope.getPhase() !== "READY") {
return Object.freeze({ kind: "SCOPE_FENCED" as const });
}
if (operation.authProfileId === "TECH_LOG_STUDIO_SESSION") {
// Studio는 세션 쿠키로 인증하고 CSRF 토큰만 증명 헤더로 싣는다.
// 조회 operation은 이 profile을 쓰더라도 헤더 없이 통과해야 하므로
// 토큰 조회 실패를 세션 상태로 승격하지 않는다.
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();
// ... 기존 bearer 경로는 그대로 둔다
},
```
`create-tech-log-feature-input.ts``TechLogInstallContext``apiBaseUrl: string`, `requestTimeoutMs: number`, `csrf: CsrfTokenProvider`를 더한다:
```typescript
export function createTechLogFeatureInstalledInput(context: TechLogInstallContext) {
function createSession() {
return createCsrfTokenProvider({
async execute(options) {
const outcome = await context.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 };
},
});
}
// MOCK에서도 Asset gateway는 필요하다. 백엔드가 없으면 목록이 비고 업로드는
// 전송 실패로 끝난다 — UI가 그 상태를 표시하는 것이 정상 동작이다.
const createStudioAssetGateway = () =>
createHttpStudioAssetGateway({
operations: context.contractOperations,
csrf: createSession(),
csrf: context.csrf,
upload: createAssetUploadTransport({
baseUrl: context.apiBaseUrl,
timeoutMs: context.requestTimeoutMs,
}),
});
const createStudioGateway = () => {
if (context.studioSource === "MOCK") return createMockStudioGateway();
return createHttpStudioGateway({
operations: context.contractOperations,
csrf: createSession(),
});
};
const createStudioGateway = () =>
context.studioSource === "MOCK"
? createMockStudioGateway()
: createHttpStudioGateway({ operations: context.contractOperations });
const input: TechLogFeatureInput = Object.freeze({
publicContent: publicContentQueries,
@@ -2287,7 +2384,9 @@ export function createTechLogFeatureInstalledInput(context: TechLogInstallContex
}
```
`src/features/installed-feature-adapters.ts``src/bootstrap/runtime-adapters.ts:506`의 호출에 `apiBaseUrl: config.API_BASE_URL`, `requestTimeoutMs: config.REQUEST_TIMEOUT_MS`를 더한다.
`src/features/installed-feature-adapters.ts``src/bootstrap/runtime-adapters.ts:506`의 호출에 `apiBaseUrl: config.API_BASE_URL`, `requestTimeoutMs: config.REQUEST_TIMEOUT_MS`, `csrf: techLogCsrf`를 더한다.
Studio 세션이 만료돼 `AUTHENTICATION_REQUIRED`가 오면 캐시된 토큰을 버려야 한다. gateway가 아니라 `attachCredentials` 소유자가 버린다 — `authSession.onUnauthenticated()`를 호출하는 `runtime-adapters.ts:499` 부근의 `UNAUTHENTICATED` 분기에서 `techLogCsrf.invalidate()`를 함께 호출한다.
`src/features/tech-log/presentation/studio/studio-provider.tsx``createStudioGateway`를 lazy state/ref로 한 번만 호출하는 방식과 **동일하게** `createStudioAssetGateway`도 한 번만 호출해 제공하도록 확장한다. bfcache `pageshow`로 provider generation이 바뀔 때 두 gateway가 함께 재생성돼야 한다.
@@ -2959,6 +3058,24 @@ function insertAtCursor(
다른 필드와 레이아웃 구조는 바꾸지 않는다. Picker와 Upload dialog는 `bodyMarkdown`을 가진 CASE 편집기에만 붙인다.
**적재한 Asset 목록을 즉시 미리보기에 넘긴다.** Task 9가 `instant-preview.tsx``assets` prop을 열어뒀지만 아직 빈 배열이 들어간다. 이 단계에서 실제로 연결하지 않으면 방금 삽입한 directive가 즉시 미리보기에서 자리표시자로 보인다.
Picker가 적재한 목록을 편집기 화면(`document-editor-screen.tsx`)이 소유하게 하고, Picker와 InstantPreview 양쪽에 같은 배열을 내려준다. Picker가 아직 목록을 못 받았거나 업로드 직후라면 새 Asset을 배열에 더한다:
```tsx
const [assets, setAssets] = useState<readonly Asset[]>([]);
// Picker의 목록 적재 결과와 업로드 성공 결과가 같은 배열로 모인다.
<AssetPicker gateway={assetGateway} onLoaded={setAssets} onInsert={insert} />
<AssetUploadDialog
gateway={assetGateway}
onUploaded={(asset) => setAssets((current) => [asset, ...current])}
/>
<InstantPreview assets={assets} />
```
`AssetPicker``onLoaded?: (assets: readonly Asset[]) => void` prop을 더하고 `listAssets` 성공 시 호출한다. Task 10 Step 1의 기존 테스트는 이 prop 없이도 통과해야 하므로 선택 prop으로 둔다.
- [ ] **Step 6: green 확인**
Run: `corepack pnpm exec vitest run tests/features/tech-log/asset-picker.test.tsx && corepack pnpm test:tech-log`