diff --git a/docs/superpowers/plans/2026-08-17-techlog-backend-alignment.md b/docs/superpowers/plans/2026-08-17-techlog-backend-alignment.md index 17551ca..afebe9d 100644 --- a/docs/superpowers/plans/2026-08-17-techlog-backend-alignment.md +++ b/docs/superpowers/plans/2026-08-17-techlog-backend-alignment.md @@ -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> }>` - - `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 { - const csrfToken = await deps.csrf.token( - options.signal ? { signal: options.signal } : undefined, - ); - const outcome = await deps.operations.execute( - operationId, - { ...(input as Record), 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 = {}; - const payload = input as Record; 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(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), 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([]); +// Picker의 목록 적재 결과와 업로드 성공 결과가 같은 배열로 모인다. + + setAssets((current) => [asset, ...current])} + … +/> + +``` + +`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`