Files
tech-log-frontend/docs/reviews/adapters/04-browser-transfer.md
T
DongHyeonkaandClaude Opus 5 4bff9ca151 chore: sync the frontend template from 4dc033c to 8157ad4
The product was materialized from the template at `4dc033c` and has stayed
on it through 43 template commits, so it was missing all three rounds of
adapter remediation — including files it never had, such as the shared
`abortable-operation` primitive and the `exact-snapshot` decoder that
later fixes are written against. Taking only the newest round was not
possible for that reason: the delta is coherent only as a whole.

The product had not touched `src/adapters` at all since materialization,
so the 140-file delta applied with a three-way merge and no conflicts.
`package.json` was the single overlap and merged cleanly: the product owns
`name`, the template contributed `check:adapter-inventory`,
`check:remediation-ledger` and the image-resolve-signal type fixture.
All 24 product-owned files — README, index.html, CI workflow, i18n
catalog, home page, generated schemas, evidence scripts, component and
visual snapshots — are byte-identical to `main`.

`template.lock.json` now pins the synced revision and tree.

Verified in this repository, not inherited from the template: six type
projects, lint, nine gates (adapter inventory, remediation ledger,
registries, diagnostics, realtime boundaries, architecture, browser
file/storage boundaries, optional recipes, documentation), the production
build, and 2,054 of 2,073 tests. The 19 failures are all in
`tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing
sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template
records; four suites that failed once under parallel load pass in
isolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:04:58 +09:00

31 KiB

Adapter Review — Browser Transfer

검토 기준: develop / 4dc033c (2026-08-13)

범위: src/adapters/browser-transfer/**, 직접 연결된 application port, unit test, docs/architecture/presigned-transfer-and-image-cdn.md

결론

브라우저 전송 계열은 URL·header·subscription material을 application/presentation에서 차단하고, identity capability와 strict decoder를 사용하는 방향이 좋다. 특히 presigned single-use vault, multipart checkpoint CAS, image preset registry와 private descriptor 서명 검증은 유지해야 한다.

다만 실제 조합 전에 해결해야 할 P1 항목이 세 개 있다.

  1. presigned download는 open()에서 이미 fetch와 timeout을 시작하지만 반환된 source에는 close()가 없다. 호출자가 stream을 늦게 열거나 열지 않으면 정상 API 사용만으로 body/timeout 자원이 방치된다 (BT-PRE-01).
  2. IndexedDB checkpoint partition 삭제는 BLOCKED를 반환한 뒤에도 native deleteDatabase()가 늦게 commit될 수 있다. 반환 결과가 실제 effect certainty를 표현하지 못한다 (BT-UP-03).
  3. presigned capability wire envelope에는 top-level protocol literal이 없다. 이미 아키텍처 문서가 요구한 PRESIGNED_TRANSFER_V1을 실제 request/response decoder가 아직 강제하지 않는다 (BT-PRE-02).

파일 크기만을 이유로 나누면 안 되지만, resumable-upload-runtime.ts 2,196줄과 image-cdn-runtime.ts 1,340줄은 각각 state transition, I/O orchestration, retry, persistence, presentation projection을 동시에 소유한다. characterization test를 먼저 고정한 뒤 State Machine·Saga·Strategy 경계로 분리하는 것이 안전하다.

판정 기준

표기 의미
P1 조합 또는 배포 전에 수정. 결과 거짓 보고, 보안/정합성, 자원 수명주기 결함
P2 다음 리팩터링 묶음에서 수정. 계약 모호성, 실패 격리, 유지보수 위험
P3 동작을 고정한 뒤 정리. 테스트 seam, 중복, 가독성
VERIFIED_DEFECT 현재 코드 경로만으로 재현 가능한 결함
CONTRACT_GAP provider/consumer 간 의미가 타입이나 decoder에 충분히 고정되지 않음
REFACTOR 현재 외부 동작은 보존하면서 내부 책임을 재배치
PLANNED_GAP 기존 아키텍처 문서가 이미 미구현으로 선언한 항목. 현재 구현의 회귀로 계산하지 않음
KEEP 의도와 테스트가 일치하므로 변경하지 않음

전체 파일 판정

모듈 현재 역할 판정 후속 항목
browser-transfer/index.ts 하위 capability export KEEP public export 증가는 각 capability 계획에서만 수행
presigned/index.ts presigned public surface KEEP BT-PRE-04에서 vault issuer 노출만 축소 검토
presigned/incremental-sha256.ts streaming SHA-256 KEEP WebCrypto digest()로 바꾸면 전체 buffering이 되므로 교체 금지
presigned/presigned-capability-http-provider.ts BFF capability 발급, strict decode CONTRACT_GAP BT-PRE-02, BT-PRE-03, BT-X-01
presigned/presigned-capability-vault.ts identity capability 보관/폐기 REFACTOR BT-PRE-04
presigned/presigned-transfer-executor.ts GET stream/PUT part 실행 VERIFIED_DEFECT BT-PRE-01, BT-PRE-03, BT-X-01
resumable-upload/checkpoint-schema.ts durable schema guard KEEP schema V1 golden fixture 유지
resumable-upload/fetch-json-transport.ts bounded JSON control transport VERIFIED_DEFECT BT-UP-01, BT-UP-02, BT-X-01
resumable-upload/http-control-plane-adapter.ts operation별 wire decoder KEEP/REFACTOR runtime 분리 뒤 decoder만 남김
resumable-upload/index.ts resumable public surface KEEP facade 호환 유지
resumable-upload/indexeddb-checkpoint-store.ts scope-bound CAS store/admin VERIFIED_DEFECT BT-UP-03
resumable-upload/presigned-upload-part-executor.ts multipart와 presigned bridge VERIFIED_DEFECT BT-UP-04
resumable-upload/resumable-upload-runtime.ts session state/retry/part scheduling/commit REFACTOR BT-UP-05, BT-UP-06
resumable-upload/runtime-policy.ts hard bound snapshot KEEP 값 변경은 contract migration으로만 수행
resumable-upload/upload-byte-source.ts stream/range source snapshot과 hashing KEEP/REFACTOR runtime에서 source preparation Strategy로 주입
resumable-upload/upload-cancellation-channel.ts best-effort cross-context cancel hint KEEP backend/CAS가 authority라는 주석과 동작 유지
resumable-upload/upload-mutation-lock.ts Web Lock exclusive mutation PLANNED_GAP BT-UP-07
image-cdn/README.md 안전한 composition 예제 KEEP resolve signal 결정 반영 필요
image-cdn/browser-image-probe.ts bounded fetch/header/static decode probe VERIFIED_DEFECT BT-IMG-02
image-cdn/image-cdn-policy.ts origin/preset/hard-limit registry KEEP composition-owned identity reference 유지
image-cdn/image-cdn-runtime.ts asset acceptance, signature, URL/projection REFACTOR BT-IMG-01, BT-IMG-03
image-cdn/image-header-metadata.ts PNG/JPEG/WebP/AVIF static header parser KEEP 별도 fuzz/golden corpus로 보호; 작은 parser로 임의 분해 금지
image-cdn/p256-image-capability-verifier.ts P-256 P1363 verifier KEEP key overlap contract 유지
image-cdn/index.ts image public surface KEEP descriptor provider가 생길 때만 export 확장

직접 연결 경계도 다음과 같이 대조했다: src/application/ports/browser-transfer/authorized-download.ts, src/application/ports/browser-transfer/image-cdn.ts, src/application/ports/browser-transfer/presigned-transfer.ts, src/application/ports/browser-transfer/resumable-upload.ts, barrel src/application/ports/browser-transfer/index.ts, 그리고 presigned source의 직접 consumer src/adapters/browser-files/download-delivery-adapter.ts. native URL/header/File/Response를 application port로 올리지 않는 방향은 유지하며, BT-PRE-01close() migration은 이 consumer까지 포함한다.

Presigned transfer 상세

BT-PRE-01 — open()이 반환되기 전에 download lease가 시작됨

  • 우선순위/분류: P1 / VERIFIED_DEFECT
  • 근거: presigned-transfer-executor.ts:114-192, :408-631
  • 현재 동작:
    • openDownload()이 capability를 claim/consume한 뒤 즉시 fetch()를 수행한다.
    • timeout scope도 open() 안에서 시작한다.
    • response body와 scope는 반환된 PresignedDownloadByteSource.stream()을 완주하거나 실패해야만 해제된다.
    • source port에는 close()/dispose()가 없다.
  • 영향:
    • 호출자가 source를 받은 뒤 stream 시작을 늦추면, 실제 consumer deadline이 아니라 open() 시점의 timeout으로 실패한다.
    • 호출자가 stream을 열지 않으면 body cancellation과 listener/timer cleanup을 명시적으로 수행할 방법이 없다.
    • capability는 이미 single-use로 소비되므로 동일 source를 복구할 수도 없다.

결정: lazy, single-start lease로 변경한다. open()은 policy/vault 검증과 capability consume까지만 수행하고 fetch는 첫 stream(signal) 진입 시 시작한다. source에 close(): void를 추가해 미사용 lease도 명시적으로 폐기한다. close()와 stream의 first-start는 하나의 state machine을 공유한다.

type PresignedDownloadByteSource = Readonly<{
  byteLength: number;
  capability: PresignedDownloadCapability;
  integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION";
  stream(signal: AbortSignal): AsyncIterable<BrowserDataResult<Uint8Array>>;
  close(): void;
}>;

type DownloadLeaseState = "READY" | "STREAMING" | "CLOSED";

구현 규칙:

  1. READY -> STREAMING만 fetch를 시작한다.
  2. READY -> CLOSED는 network I/O 없이 끝낸다.
  3. STREAMING -> CLOSED는 composed signal abort, reader/body cancel, timer/listener release를 한 번만 수행한다.
  4. 두 번째 stream()은 기존처럼 CONFLICT / REISSUE_CAPABILITY다.
  5. digest 성공 전 chunk는 현재의 VERIFIED_ON_SUCCESSFUL_EXHAUSTION 의미를 유지한다. consumer는 최종 success 전 파일을 commit하면 안 된다.
  6. stream() 직전에 capability expiry와 minimum remaining lifetime을 다시 확인하고, open() 때 받은 outer signal과 stream signal을 함께 적용한다. 오래 보관되어 만료된 source는 fetch를 시작하지 않는다.

테스트 추가 (tests/unit/presigned-transfer.test.ts):

  • does not fetch until the returned download source starts streaming
  • closes an unused source without issuing a request
  • starts the transfer deadline at first stream consumption
  • close during a pending read cancels the reader and releases listeners once
  • stream after close returns one terminal conflict without fetching

마이그레이션: port에 close()를 추가한 뒤 직접 consumer인 src/adapters/browser-files/download-delivery-adapter.ts를 포함한 모든 consumer를 source 획득 직후 try/finally { source.close(); }로 감싼다. size reject, createWritable()/prompt 실패, object-URL strategy의 stream 전 실패도 tests/unit/browser-file-download.test.ts로 고정한다. 그 다음 fetch를 lazy로 옮긴다. rollback은 eager fetch 구현으로 되돌릴 수 있지만 close() API는 유지한다.

완료 조건: 위 테스트와 기존 presigned suite가 통과하고, source를 생성만 한 테스트에서 fetch 호출 수와 active timer가 모두 0이다.

BT-PRE-02 — capability wire envelope의 protocol version 부재

  • 우선순위/분류: P1 / CONTRACT_GAP, 기존 문서의 미완료 항목
  • 근거: presigned-capability-http-provider.ts:173-210, :436-462; docs/architecture/presigned-transfer-and-image-cdn.md:93-108
  • 현재 동작: request body와 strict response key set에 top-level transfer protocol이 없다. multipart binding 내부 protocol만으로 전체 capability envelope version을 식별한다.
  • 영향: 서버가 필드를 추가/재해석할 때 old/new client가 같은 shape를 서로 다른 의미로 받아들일 수 있다. strict decoder라서 단순 필드 추가도 곧바로 장애가 되지만, 장애가 version mismatch로 분류되지 않는다.

결정:

  • request와 response에 protocol: "PRESIGNED_TRANSFER_V1"을 필수로 추가한다.
  • missing/unknown protocol은 현재 closed taxonomy의 POLICY_REJECTED, retryable false, recovery REISSUE_CAPABILITY로 닫는다. 이 변경에서 새 failure code를 만들지 않는다.
  • multipart의 PRESIGNED_MULTIPART_V1은 하위 binding protocol로 그대로 유지한다.
  • protocol은 PresignedTransferCapability, PresignedCapabilityRegistration/Binding, vault snapshot, executor common-binding validator까지 전파해 request → registration → consumption exact parity를 보장한다.
  • server는 request shape를 협상해 legacy request에는 legacy response, V1 request에는 V1 response를 반환한다. strict legacy decoder를 깨뜨리므로 legacy response에 V1 field를 먼저 emit하거나 한 response에 dual fields를 넣지 않는다.

테스트 추가:

  • request body exact-key snapshot과 protocol literal
  • missing, V0, V2 protocol response 거절
  • V1 download와 V1 multipart capability 수락
  • protocol mismatch가 vault register() 전에 종료됨

배포 순서: request-shape negotiated provider 배포 → V1 client 배포 → old-client drain 기간 관찰 → provider legacy request/response 제거. rollback 시 provider는 두 request shape를 계속 수락하되 각각 matching exact response를 반환한다.

BT-PRE-03 — timeout이 non-cooperative fetch를 실제로 bound하지 못함

  • 우선순위/분류: P2 / VERIFIED_DEFECT
  • 근거: capability provider :186-214, executor presigned-transfer-executor.ts:157-190, 각 파일의 createAbortScope()
  • 현재 동작: timer는 AbortController만 abort한다. injected fetcher 또는 host가 signal을 무시하면 await fetcher(...) 자체는 끝나지 않는다.
  • 영향: API가 선언한 timeout이 hard bound가 아니며, teardown도 fetch settlement에 묶인다.

결정: 공통 AbortableOperationScoperace(task, onLateValue)를 사용한다 (BT-X-01). deadline/caller abort가 먼저 끝나면 즉시 typed failure를 반환하고, 늦게 온 Response는 body를 취소한다. timer 생성 실패 시 이미 붙인 external listener를 즉시 제거한다.

테스트 추가:

  • signal을 무시하는 fetch Promise가 timeout 뒤에도 pending인 fixture
  • timeout 결과가 정시에 반환되고 late response body가 취소되는지 검증
  • scheduler setTimeout/clearTimeout throw 시 listener 누수와 public rejection이 없는지 검증

BT-PRE-04 — vault가 스스로 registration invariant를 소유하지 않음

  • 우선순위/분류: P2 / REFACTOR
  • 근거: presigned-capability-vault.ts:112-174
  • 현재 동작: HTTP provider가 URL, header, expiry, byte/digest를 검사하지만 exported vault의 register()는 전달받은 registration을 그대로 snapshot한다.
  • 영향: 다른 issuer adapter가 추가되거나 테스트/조합 코드가 vault를 직접 사용하면 동일한 capability 타입에 더 약한 invariant가 들어갈 수 있다.

결정: issuer/consumer 권한을 wiring 단계에서 분리하고 공통 invariant validator를 적용한다.

  1. createPresignedCapabilityVault(){ issuer: PresignedCapabilityIssuer; consumer: PresignedCapabilityConsumer }를 반환한다. provider option에는 issuer만, executor option에는 consumer만 전달한다. root barrel에는 factory와 consumer-facing type만 export하고 issuer type은 provider의 구조적 parameter로 숨긴다.
  2. issuer 등록 직전 공통 validatePresignedCapabilityRegistration()으로 method/binding/URL/header/status/bytes/digest/expiry를 다시 검증한다.
  3. HTTP decoder는 wire-specific shape를 검사하고, vault validator는 runtime invariant만 검사한다. decoder 로직을 통째로 중복하지 않는다.

테스트: malformed registration을 직접 issuer seam에 넣는 table test와, HTTP provider의 valid 결과가 동일 snapshot으로 등록되는 parity test를 추가한다.

BT-PRE-05 — encoded path의 provider 해석 차이

  • 우선순위/분류: P2 / SECURITY_HARDENING
  • 근거: presigned-capability-http-provider.ts:517-533, :1078-1097
  • 현재 동작: literal ./..와 backslash는 거절하지만 %2f, %5c, %25... 같은 encoded separator가 object-store/CDN에서 한 번 더 decode되는지 계약이 없다.
  • 결정: raw URL.pathname의 각 segment를 strict UTF-8 percent-decode한다. decoded segment에서 /, backslash, NUL, ./.., 그리고 literal % 뒤 두 hex digit을 거절한 뒤, 대문자 percent-hex canonical encoder 결과와 raw segment를 비교한다. 이 규칙은 %252e%252e double encoding을 닫고 valid opaque UTF-8 segment는 허용한다. CDN/provider conformance fixture가 같은 canonicalizer를 사용한다.
  • 테스트: %2F, %5C, %252e%252e, mixed-case encoding, valid UTF-8 opaque segment를 포함한다.

Resumable upload 상세

BT-UP-01 — AbortSignal 구조 검증과 cleanup 사용이 불일치

  • 우선순위/분류: P2 / VERIFIED_DEFECT
  • 근거: fetch-json-transport.ts:548-582, :670-677
  • 현재 동작: isAbortSignal()abortedaddEventListener만 검사하지만 FetchAttempt.release()removeEventListener()를 무조건 호출한다.
  • 영향: 구조적으로 허용된 signal이 finally에서 throw하여 typed result 대신 Promise rejection을 만든다.
  • 수정: native getter 기반 또는 최소한 removeEventListener까지 포함한 공통 guard를 사용하고, release cleanup은 terminal result를 덮지 않도록 catch한다.
  • 테스트: remove가 없는 structural fake는 입력에서 INVALID_INPUT; remove가 cleanup 중 throw하는 hostile facade는 typed terminal result를 보존.

BT-UP-02 — transport clock/scheduler가 전역에 고정됨

  • 우선순위/분류: P3 / REFACTOR
  • 근거: fetch-json-transport.ts:571-580, :626-636
  • 현재 동작: request timeout은 global timer, HTTP-date Retry-AfterDate.now()를 직접 사용한다.
  • 결정: dependencies에 clock.now()scheduler를 추가하고 snapshot/validate한다. delta-seconds와 HTTP-date parsing은 같은 captured now를 사용한다.
  • 테스트: fake clock으로 경계값, clock rollback, invalid date, max clamp를 결정론적으로 검증.

BT-UP-03 — deleteDatabase() timeout 뒤 late delete effect

  • 우선순위/분류: P1 / VERIFIED_DEFECT
  • 근거: indexeddb-checkpoint-store.ts:375-439
  • 현재 동작: deletePartition()onblocked 후 timer가 끝나면 BLOCKED를 반환한다. 그러나 IndexedDB delete request는 취소할 수 없고, 다른 tab이 닫히면 반환 이후 onsuccess로 실제 DB가 삭제될 수 있다.
  • 영향: caller가 BLOCKEDNOT_APPLIED로 해석할 수 있지만 native request는 나중에 성공/실패할 수 있어 반환값과 effect certainty가 모순된다. 다른 realm의 open/delete ordering까지 현재 증거 없이 단정하지 않는다.

결정: delete dispatch 이후에는 failure certainty를 NOT_APPLIED로 표현하지 않는다. port outcome을 다음처럼 명시한다.

type PartitionDeleteOutcome =
  | { state: "DELETED"; effect: "APPLIED" }
  | { state: "PENDING"; effect: "UNKNOWN"; reason: "BLOCKED_DEADLINE" };
  • pre-dispatch invalid/aborted/unsupported만 기존 failure다.
  • PENDING을 받은 runtime은 해당 store instance를 terminal closed로 유지한다. 같은 JS realm에서는 (IDBFactory identity, databaseName) pending-deletion registry가 새 factory 생성을 막고 late onsuccess/onerror에서 해제한다. 다른 realm은 native IndexedDB blocked ordering과 명시적 recovery UX로 처리하며 client-only global registry를 주장하지 않는다.
  • late onsuccess/onerror는 observer에 기록한다. 다시 확인하려면 별도 inspectPartitionDeletion() 또는 새 page generation에서 DB 목록/open 결과를 사용한다.
  • 단순히 timer를 제거해 무한 대기시키지는 않는다.

테스트 추가 (tests/unit/resumable-upload-checkpoint.test.ts): blocked deadline → PENDING → late success, blocked deadline → late error, PENDING 뒤 store method가 UNAVAILABLE, caller abort before dispatch, concurrent new runtime 금지.

BT-UP-04 — bridge clock의 non-finite 값이 expiry 검사를 통과함

  • 우선순위/분류: P2 / VERIFIED_DEFECT
  • 근거: presigned-upload-part-executor.ts:36-70
  • 현재 동작: now()NaN 또는 음수이면 expiry 비교를 우회한다. +Infinity는 현재도 expiry 비교에서 거절되지만 dependency failure가 capability policy failure로 잘못 분류된다.
  • 수정: Number.isSafeInteger(nowEpochMs) && nowEpochMs >= 0을 먼저 검사하고 실패 시 UNAVAILABLE / RESUME을 반환한다.
  • 테스트: NaN/음수의 현재 bypass, +Infinity의 현재 rejection, 수정 후 모든 non-finite/negative clock의 UNAVAILABLE / RESUME, throw, 만료 경계 expiresAt === now, 유효 now + 1.

BT-UP-05 — runtime의 상태 전이와 side effect가 한 파일에 결합됨

  • 우선순위/분류: P2 / REFACTOR
  • 근거: resumable-upload-runtime.ts 2,196줄; session resolution, retry, hashing, scheduler, CAS, abort saga, validation과 telemetry를 함께 소유
  • 외부 facade는 유지하고 다음 내부 경계만 추출한다.
새 내부 모듈 책임 적용 패턴
upload-session-state-machine.ts ACTIVE/ABORT_PENDING/completed transition의 순수 함수 State Machine
upload-session-reconciler.ts local checkpoint와 server status 수렴 Reconciler
upload-part-scheduler.ts memory/server/client concurrency와 receipt serialization Bounded Work Queue
upload-retry-executor.ts retry budget, Retry-After, jitter, attempt deadline Policy + Template Method
upload-abort-saga.ts local tombstone → backend abort → checkpoint removal Saga/Compensation
resumable-upload-runtime.ts public facade, lifecycle, mutation lock orchestration만 Facade

추출 순서:

  1. 기존 tests/unit/resumable-upload-runtime.test.ts에 observable call-order characterization를 추가한다.
  2. 순수 transition 함수와 table test를 먼저 만든다.
  3. retry executor, reconciler, part scheduler, abort saga 순서로 한 모듈씩 이동한다.
  4. 각 이동 뒤 기존 suite 전체를 그대로 실행한다. fixture expected 값을 리팩터링에 맞춰 바꾸지 않는다.

변경 금지:

  • part idempotency key derivation
  • server-authoritative status reconciliation
  • accepted receipt의 순차 CAS persistence
  • checkpoint에 URL/credential을 저장하지 않는 규칙
  • first part failure 뒤 이미 시작한 sibling의 확정 receipt를 기다려 저장하는 현재 정책. 이를 즉시 cancel하면 remote success가 ambiguous해질 수 있으므로 별도 behavior change로 다룬다.

BT-UP-06 — sync close()가 drain 완료를 증명하지 못함

  • 우선순위/분류: P2 / LIFECYCLE_REFACTOR
  • 근거: resumable-upload-runtime.ts:280-287
  • 현재 동작: lifetime abort 직후 checkpoint store를 닫고 반환한다. native fetch/IDB가 signal에 반응해 정리될 것으로 기대하지만 caller는 active operation의 terminal settlement를 기다릴 수 없다.
  • 결정: application ResumableUploadPortclose(): void는 admission을 닫고 같은 single-flight drain을 시작하는 호환 facade로 유지한다. adapter runtime lifecycle surface에 향후 composition owner가 awaitdispose(): Promise<void>를 추가한다. dispose()는 이미 시작된 drain promise를 공유하고 active operation registry를 abort한 뒤 bounded allSettled 후 store/channel을 닫는다. 현재 production bootstrap consumer가 있다고 가정하지 않는다.
  • 테스트: close 중 신규 admission 거절, active fetch/IDB abort, 중복 dispose single-flight, cleanup deadline, late provider success가 checkpoint를 다시 쓰지 못함.

BT-UP-07 — Web Locks 비지원 정책이 composition 결과로 표현되지 않음

  • 우선순위/분류: P1 before composition / PLANNED_GAP
  • 근거: upload-mutation-lock.ts:19-58; 아키텍처 completion ledger의 optional capability decision
  • 현재 동작: factory는 LockManager가 없으면 throw한다. multi-tab 안전성을 희생하는 in-memory fallback은 없다.
  • 결정: silent fallback은 추가하지 않는다. composition이 Web Locks 미지원 시 resumable upload capability를 UNSUPPORTED로 명시하고 일반 foreground upload 또는 재선택 UX로 degrade한다. 실제 지원 browser matrix가 확정되기 전 default composition에는 설치하지 않는다.

Image CDN 상세

BT-IMG-01 — resolve signal을 일관되게 필수화할지에 대한 API 단순화

  • 우선순위/분류: P3 / API CONSISTENCY DECISION, 현재 동작 결함 아님
  • 근거: application port image-cdn.ts:167-172; runtime image-cdn-runtime.ts:518-527
  • 현재 동작: optional signal을 허용하고 PRIMARY_REQUIRED preset은 signal 부재를 명시적 UNSUPPORTED로 표현한다. 문서가 signal 없는 probe 성공을 약속하지 않으므로 defect는 아니다.
  • 결정: hidden preset precondition을 줄이기 위해 다음 major contract 정리에서 resolve() signal을 필수화한다. 이는 runtime correctness fix가 아니라 API consistency 개선이다.
  • 마이그레이션: tests/fixtures/typecheck/invalid-image-cdn-resolve-signal.ts와 대응 typecheck script를 먼저 추가하고 모든 caller/README에 lifecycle signal을 전달한 뒤 port와 optional 분기를 바꾼다. P1/P2와 같은 PR에 섞지 않는다.

BT-IMG-02 — Cache-Control quoted value parser가 malformed 값을 수락

  • 우선순위/분류: P2 / VERIFIED_DEFECT
  • 근거: browser-image-probe.ts:272-373
  • 현재 동작: rawValue.replace(/^"|"$/gu, "")는 한쪽 quote만 있는 max-age="60 또는 max-age=60"도 숫자 60으로 만들 수 있다.
  • 영향: probe가 malformed cache policy를 immutable public response로 승인할 수 있다.
  • 수정:
    • comma split 전에 quote/escape-aware tokenizer를 사용해 quoted extension의 comma를 directive 경계로 취급하지 않는다.
    • quoted-string은 시작/종료 quote가 모두 있고 escape/control 문자가 유효할 때만 unquote한다.
    • numeric directives는 unquoted digits 또는 완전한 quoted digits만 허용한다.
    • private response는 no-store가 필수이며 public, private, immutable, max-age, s-maxage, no-cache, must-revalidate, proxy-revalidate가 함께 있으면 fail-closed한다. 문법상 유효한 unknown extension만 무시한다.
    • parser는 중복 directive를 계속 거절한다.
  • 테스트: unmatched quote, escaped quote, duplicate, comma-in-quoted extension, contradictory public/private directives, valid quoted max-age.

BT-IMG-03 — acceptance, verification, URL projection의 응집도 분리

  • 우선순위/분류: P3 / REFACTOR
  • 근거: image-cdn-runtime.ts 1,340줄
  • facade와 WeakMap capability identity는 유지하고 다음 내부 모듈만 추출한다.
새 내부 모듈 책임
image-asset-decoder.ts public/private exact shape snapshot
image-capability-verification.ts canonical payload, digest, key verifier deadline
image-presentation-projector.ts candidate URL/srcset/descriptor 생성
image-cdn-runtime.ts issued reference WeakMap, close, facade orchestration

image-header-metadata.ts는 format parser라는 단일 책임을 이미 가진다. LOC만 보고 더 쪼개지 말고 fuzz corpus와 malformed container table을 보강한다.

BT-IMG-04 — descriptor provider/refresh는 아직 구현 대상

  • 우선순위/분류: P1 before composition / PLANNED_GAP
  • 근거: docs/architecture/presigned-transfer-and-image-cdn.md:574-610
  • 현재 상태: signature 검증/runtime/probe는 있으나 BFF에서 descriptor를 가져오고 single-flight refresh하는 provider와 <picture> renderer는 없다.
  • 결정: 현재 runtime을 직접 product composition에 노출하지 않는다. 향후 provider는 protocol: "IMAGE_CDN_DESCRIPTOR_V1", exact authority/request binding, minimum remaining TTL, single-flight refresh, close-generation fence를 필수로 한다. renderer는 descriptor 필드만 투영하고 alt/error/placeholder 정책은 feature 소유로 둔다.

공통 개선

BT-X-01 — abort/deadline/late-result mechanics 통합

  • 우선순위: P2 / REFACTOR
  • 중복 근거: presigned provider/executor, image probe/runtime, resumable runtime, browser files, HTTP, Web Push에 createAbortScope, combineAbortSignals, awaitWithAbort, readWithSignal 변형이 반복된다.
  • 결정: result taxonomy는 각 adapter에 남기고 mechanics만 src/adapters/platform/abortable-operation.ts로 추출한다.

필수 API:

type AbortableOperationScope = Readonly<{
  signal: AbortSignal;
  terminal(): "OPEN" | "CALLER_ABORT" | "DEADLINE" | "CLOSED";
  race<T>(
    task: Promise<T>,
    onLateValue?: (value: T) => void,
  ): Promise<
    | { kind: "VALUE"; value: T }
    | { kind: "TERMINAL"; terminal: "CALLER_ABORT" | "DEADLINE" | "CLOSED" }
  >;
  close(): void;
}>;

불변식:

  • caller abort와 deadline 중 최초 하나만 terminal authority다.
  • close()는 idempotent하고 timer/listener cleanup throw를 삼킨다.
  • late rejection은 항상 관찰되어 unhandled rejection이 되지 않는다.
  • late Response/ImageBitmap/native handle은 caller가 제공한 compensator로 닫고 값을 버린다. 각 subsystem adapter가 TERMINAL을 자기 Result taxonomy로 변환한다.
  • 이 utility는 BrowserDataResult, WebPushResult, HTTP outcome을 import하지 않는다.

적용 순서: 새 utility golden test → presigned → image → resumable transport → 다른 adapter. 한 PR에서 모든 subsystem을 동시에 바꾸지 않는다.

유지해야 할 설계

  • raw presigned URL/header가 application port를 통과하지 않고 identity capability vault 안에만 존재한다.
  • capability는 exact WeakMap identity이며 single-use claim 후 vault에서 제거된다.
  • upload byte는 hash/network await 전에 snapshot한다.
  • multipart checkpoint에는 URL, credential, capability material을 저장하지 않는다.
  • multipart receipt는 revision CAS로 순차 commit하고 server status가 복구 authority다.
  • cross-context cancellation은 hint일 뿐 backend idempotency/Web Lock/CAS를 대체하지 않는다.
  • public image는 revision rollover, private image는 signed expiry/revocation으로 구분한다.
  • private image는 exact signed URL, credential omit, no-store, static container와 decode budget을 확인한다.
  • composition hard limit은 adapter implementation ceiling보다 느슨해질 수 없다.
  • P-256 key overlap set과 terminal close() generation fence를 유지한다.

실행 순서와 의존성

  1. BT-UP-03, BT-PRE-01, BT-PRE-02를 각각 독립 PR로 해결한다.
  2. BT-X-01 utility golden test를 만들고 BT-PRE-03, BT-UP-01, BT-UP-02를 이관한다.
  3. BT-UP-04, BT-IMG-01, BT-IMG-02, BT-PRE-04/05를 작은 contract-hardening PR로 처리한다.
  4. behavior suite가 모두 green인 뒤 BT-UP-05/06, BT-IMG-03 구조 분리를 수행한다.
  5. 실제 product 선택이 있을 때만 BT-UP-07, BT-IMG-04를 composition plan으로 연다.

각 PR 공통 gate:

corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts \
  tests/unit/resumable-upload-checkpoint.test.ts \
  tests/unit/resumable-upload-fetch-transport.test.ts \
  tests/unit/resumable-upload-http-control-plane.test.ts \
  tests/unit/resumable-upload-runtime.test.ts \
  tests/unit/image-cdn-runtime.test.ts
corepack pnpm check:types
corepack pnpm check:architecture
corepack pnpm lint
git diff --check

실제 browser gate도 capability promotion 전에 실행한다.

corepack pnpm test:browser-capabilities -- \
  tests/browser-capabilities/presigned-streaming.spec.ts \
  tests/browser-capabilities/resumable-upload.spec.ts \
  tests/browser-capabilities/image-cdn.spec.ts

해당 browser/provider 환경이 없으면 이 gate는 UNVERIFIED로 남기며 capability availability를 승격하지 않는다.

구현 완료 정의

  • 모든 P1 항목에 failing-before/fixed-after test가 있다.
  • wire version과 migration 순서가 provider fixture에 반영된다.
  • 어떤 timeout 경로도 non-cooperative Promise 때문에 public API를 무한 대기시키지 않는다.
  • delete partition 결과가 late native commit 가능성을 숨기지 않는다.
  • runtime facade의 public capability identity, failure taxonomy, persisted V1 schema는 명시된 migration 외에는 바뀌지 않는다.
  • 기존 문서의 AVAILABLE_NOT_COMPOSED/PLANNED_GAP 상태를 code defect 완료로 오인하지 않는다.