diff --git a/docs/operations/adapter-remediation-ledger.md b/docs/operations/adapter-remediation-ledger.md index d009458..8cc1bf2 100644 --- a/docs/operations/adapter-remediation-ledger.md +++ b/docs/operations/adapter-remediation-ledger.md @@ -90,9 +90,9 @@ Rollout state starts at `NOT_STARTED`; documented-unimplemented items start at | --- | --- | --- | --- | --- | --- | --- | | STO-01 | OPFS not composed in template; **Critical** for any product writer | `corepack pnpm exec vitest run tests/unit/opfs-byte-store.test.ts tests/unit/opfs-worker-runtime.test.ts tests/unit/indexeddb-opfs-journal.test.ts` | `fix: preserve OPFS recovery authority during cleanup` | `FIXED_NOT_RELEASED` | OPFS reconcile backlog or journal growth | Red 4 new saga cases → green 25/25 across the three OPFS suites; `check:types` PASS (incl. web-worker); `check:browser-file-storage-boundaries` PASS; `lint` PASS; `test:unit` 1511 passed with only the pre-existing environmental `ci-artifact-contract` failures | | STO-02 | Browser file runtime not composed | `corepack pnpm exec vitest run tests/unit/browser-file-download.test.ts` | `fix: execute canonical browser download targets` | `FIXED_NOT_RELEASED` | download navigation blocked by canonical target | Red 2 failed (raw relative href handed to host) → green 17/17 | -| STO-03 | Public cache not composed | `corepack pnpm exec vitest run tests/unit/public-response-cache.test.ts` | — | `NOT_STARTED` | composition rejection of an existing policy | — | -| STO-04 | Public cache not composed | `corepack pnpm exec vitest run tests/unit/public-response-cache.test.ts` | — | `NOT_STARTED` | restage loop or bandwidth spike | — | -| STO-05 | Public cache not composed | `corepack pnpm exec vitest run tests/unit/public-response-cache.test.ts` | — | `NOT_STARTED` | activation permitted without required capability | — | +| STO-03 | Public cache not composed | `corepack pnpm exec vitest run tests/unit/public-response-cache.test.ts` | `fix: make public cache staging repairable` | `FIXED_NOT_RELEASED` | composition rejection of an existing policy | Red 4 cases across STO-03..05 → green 21/21; `check:types` PASS; `check:browser-file-storage-boundaries` PASS; `lint` PASS | +| STO-04 | Public cache not composed | `corepack pnpm exec vitest run tests/unit/public-response-cache.test.ts` | `fix: make public cache staging repairable` | `FIXED_NOT_RELEASED` | restage loop or bandwidth spike | — | +| STO-05 | Public cache not composed | `corepack pnpm exec vitest run tests/unit/public-response-cache.test.ts` | `fix: make public cache staging repairable` | `FIXED_NOT_RELEASED` | activation permitted without required capability | — | | STO-06 | IndexedDB maintenance | `corepack pnpm exec vitest run tests/unit/indexeddb-maintenance.test.ts` | — | `NOT_STARTED` | migration checkpoint stall | — | | STO-07 | OPFS worker protocol | `corepack pnpm exec vitest run tests/unit/opfs-worker-runtime.test.ts` | — | `NOT_STARTED` | page/worker `INCOMPATIBLE` spike | — | | STO-08 | Hypothesis; browser characterization required | `corepack pnpm exec playwright test --config playwright.capabilities.config.ts tests/browser-capabilities/browser-files.spec.ts` | none (source unchanged) | `UNVERIFIED` | n/a until characterized | chromium 2/2 PASS; webkit could not launch (`libevent-2.1-7t64`, `libavif16` missing — environmental). The existing spec does not exercise `Window.showOpenFilePicker`/`showSaveFilePicker`, which need a user gesture and a native dialog, so the receiver-binding hypothesis is **neither reproduced nor refuted**. No `SystemPickerHost` was introduced: the plan forbids implementing an uncharacterized hypothesis as a defect. | diff --git a/docs/operations/client-cache-and-storage-recovery.md b/docs/operations/client-cache-and-storage-recovery.md index 7771936..cd43294 100644 --- a/docs/operations/client-cache-and-storage-recovery.md +++ b/docs/operations/client-cache-and-storage-recovery.md @@ -12,6 +12,26 @@ session/account Query lifecycle, strict query policy와 Web Storage v2 lifecycle 현재 `DESIGNED_NOT_IMPLEMENTED`다. 아래 목표 절차를 현재 runtime의 보장으로 해석하지 않는다. + +## Public cache staging repair와 offline activation (STO-03 ~ STO-05) + +- release marker는 "staging이 끝났다"는 **주장**이고 모든 entry의 존재·digest + 증거가 아니다. 같은 manifest로 `stageRelease`를 다시 호출하면 runtime이 + candidate를 재검증하고, browser eviction이나 부분 손상이 발견되면 그 owned + candidate만 삭제한 뒤 network에서 다시 stage한다. marker만 보고 성공을 + 반환하지 않는다. +- 검증 중 abort나 읽기 불가(UNKNOWN)는 stage 성공이 아니며 active pointer를 + 건드리지 않는다. candidate를 임의로 삭제하지도 않는다. +- `activateRelease`와 `cleanupOwned`는 network I/O가 없다. fetcher 없이도 + 동작하므로 offline rollback과 quota recovery cleanup이 `UNSUPPORTED`로 막히지 + 않는다. 두 operation은 Cache Storage와 mutation lock만 요구하고 실패 시 + recovery는 `RETRY`다. `stageRelease`만 fetcher를 요구하며 recovery는 + `ONLINE_ONLY`다. +- variant를 사용하는 policy(`allowedVaryHeaderNames` 비어 있지 않음)는 반드시 + `allowedResponseHeaderNames`에 `vary`를 포함해야 한다. 아니면 composition이 + `TypeError`로 즉시 실패한다. 저장된 variant가 같은 key로 충돌하는 상태를 만들지 + 않기 위한 cross-field invariant다. + ## 1. 변경할 수 없는 복구 원칙 - 서버가 server state와 authorization의 source of truth다. diff --git a/src/adapters/cache-storage/public-cache-policy.ts b/src/adapters/cache-storage/public-cache-policy.ts index f818ba5..0c069e6 100644 --- a/src/adapters/cache-storage/public-cache-policy.ts +++ b/src/adapters/cache-storage/public-cache-policy.ts @@ -132,6 +132,11 @@ export function assertPublicCachePolicy( policy.allowedVaryHeaderNames.some( (name) => !policy.allowedRequestHeaderNames.includes(name), ) || + // STO-03. Enabling variants while stripping `vary` from stored responses + // makes every variant collide on the same cache key, so the combination is + // rejected at composition instead of producing an unusable candidate. + (policy.allowedVaryHeaderNames.length > 0 && + !policy.allowedResponseHeaderNames.includes("vary")) || policy.forbiddenQueryParameterNames.some((name) => name.length === 0) || policy.allowedQueryParameterNames.some((name) => policy.forbiddenQueryParameterNames.includes(name), diff --git a/src/adapters/cache-storage/public-response-cache-adapter.ts b/src/adapters/cache-storage/public-response-cache-adapter.ts index e3c05d6..c548575 100644 --- a/src/adapters/cache-storage/public-response-cache-adapter.ts +++ b/src/adapters/cache-storage/public-response-cache-adapter.ts @@ -241,7 +241,7 @@ export function createPublicResponseCacheAdapter( const aborted = abortedResult(signal, "CACHE_LOOKUP"); if (aborted) return aborted; if (!dependencies.cacheStorage) { - return unsupported("CACHE_LOOKUP"); + return unsupported("CACHE_LOOKUP", "RETRY"); } let assetRequest: NormalizedAsset; try { @@ -389,10 +389,7 @@ export function createPublicResponseCacheAdapter( const signal = options.signal; const aborted = abortedResult(signal, "CACHE_STAGE"); if (aborted) return aborted; - const availability = mutationAvailability( - dependencies, - "CACHE_STAGE", - ); + const availability = stageAvailability(dependencies); if (availability) return availability; let normalized: NormalizedReleaseManifest; @@ -438,8 +435,28 @@ export function createPublicResponseCacheAdapter( normalized.manifestDigestHex && marker.value.entryCount === normalized.assets.length ) { - return browserDataSuccess(summaryFromMarker(marker.value)); + // STO-04. The marker is a claim that staging completed, not + // evidence that every entry still exists and matches. Browser + // eviction, manual deletion and partial corruption all leave the + // marker intact, so the candidate is re-verified before reuse. + const verified = await verifyReleaseCandidate( + existing, + marker.value.assets, + policy, + dependencies.crypto, + signal, + ); + if (verified.kind === "VERIFIED") { + return browserDataSuccess(summaryFromMarker(marker.value)); + } + if (verified.kind === "UNKNOWN") { + // Abort or an unreadable candidate is never stage success and + // never silently deletes an owned candidate. + return verified.failure; + } } + // Only this owned candidate is removed; the network restage below + // repairs it. await dependencies.cacheStorage!.delete(cacheName); } @@ -535,7 +552,7 @@ export function createPublicResponseCacheAdapter( const signal = options.signal; const aborted = abortedResult(signal, "CACHE_ACTIVATE"); if (aborted) return aborted; - const availability = mutationAvailability( + const availability = localMutationAvailability( dependencies, "CACHE_ACTIVATE", ); @@ -589,31 +606,25 @@ export function createPublicResponseCacheAdapter( ); } - for (const asset of marker.assets) { - if (signal?.aborted) { - return browserDataFailure("ABORTED", "CACHE_ACTIVATE"); - } - const cached = await cache.match(createNativeRequest(asset)); - if (!cached) { - return browserDataFailure( - "INTEGRITY_FAILED", - "CACHE_ACTIVATE", - { recovery: "REHYDRATE" }, - ); - } - const verified = await readAndValidateResponse( - cached, - asset, - policy, - dependencies.crypto, - signal, + const candidate = await verifyReleaseCandidate( + cache, + marker.assets, + policy, + dependencies.crypto, + signal, + ); + if (candidate.kind === "UNKNOWN") { + return rebaseFailure( + candidate.failure.error, + "CACHE_ACTIVATE", + ); + } + if (candidate.kind === "REPAIRABLE") { + return browserDataFailure( + "INTEGRITY_FAILED", + "CACHE_ACTIVATE", + { recovery: "REHYDRATE" }, ); - if (!verified.ok) { - return rebaseFailure( - verified.error, - "CACHE_ACTIVATE", - ); - } } const previousPointer = await readActivePointer( @@ -692,7 +703,7 @@ export function createPublicResponseCacheAdapter( const signal = request.signal; const aborted = abortedResult(signal, "CACHE_DELETE"); if (aborted) return aborted; - const availability = mutationAvailability( + const availability = localMutationAvailability( dependencies, "CACHE_DELETE", ); @@ -771,7 +782,7 @@ export function createPublicResponseCacheAdapter( async inspect() { if (!dependencies.cacheStorage) { - return unsupported("CACHE_LOOKUP"); + return unsupported("CACHE_LOOKUP", "RETRY"); } try { const names = await dependencies.cacheStorage.keys(); @@ -1640,24 +1651,104 @@ function summaryFromMarker( }); } -function mutationAvailability( +type ReleaseCandidateVerdict = + | Readonly<{ kind: "VERIFIED" }> + /** An exact, owned entry is missing or no longer matches the manifest. */ + | Readonly<{ kind: "REPAIRABLE" }> + /** Abort or an unreadable candidate: never success, never a silent delete. */ + | Readonly<{ kind: "UNKNOWN"; failure: BrowserFailureResult }>; + +/** + * STO-04. The single verification authority shared by the stage fast path and + * activation, so "the marker says it is staged" can never stand in for "every + * entry is present and matches". + */ +async function verifyReleaseCandidate( + cache: Readonly<{ match(request: Request): Promise }>, + assets: readonly NormalizedAsset[], + policy: PublicCacheRuntimePolicy, + crypto: Readonly<{ + digestSha256(bytes: Uint8Array): Promise; + }>, + signal: AbortSignal | undefined, +): Promise { + for (const asset of assets) { + if (signal?.aborted) { + return Object.freeze({ + kind: "UNKNOWN" as const, + failure: asFailure( + browserDataFailure("ABORTED", "CACHE_ACTIVATE"), + ), + }); + } + let cached: Response | undefined; + try { + cached = await cache.match(createNativeRequest(asset)); + } catch { + return Object.freeze({ + kind: "UNKNOWN" as const, + failure: asFailure( + browserDataFailure("UNAVAILABLE", "CACHE_ACTIVATE", { + retryable: true, + recovery: "RETRY", + }), + ), + }); + } + if (!cached) return Object.freeze({ kind: "REPAIRABLE" as const }); + const verified = await readAndValidateResponse( + cached, + asset, + policy, + crypto, + signal, + ); + if (!verified.ok) { + return verified.error.code === "ABORTED" + ? Object.freeze({ + kind: "UNKNOWN" as const, + failure: asFailure(verified), + }) + : Object.freeze({ kind: "REPAIRABLE" as const }); + } + } + return Object.freeze({ kind: "VERIFIED" as const }); +} + +/** + * STO-05. Staging is the only operation that reaches the network, so it is the + * only one that requires a fetcher. + */ +function stageAvailability( dependencies: PublicResponseCacheDependencySnapshot, - operation: BrowserDataOperation, ): BrowserFailureResult | null { return dependencies.cacheStorage && dependencies.fetcher && dependencies.mutationLock ? null - : unsupported(operation); + : unsupported("CACHE_STAGE", "ONLINE_ONLY"); +} + +/** + * Activation, rollback and cleanup are local Cache Storage mutations. Requiring + * a fetcher would block an offline rollback or a quota-recovery cleanup that + * needs no network at all. + */ +function localMutationAvailability( + dependencies: PublicResponseCacheDependencySnapshot, + operation: "CACHE_ACTIVATE" | "CACHE_DELETE", +): BrowserFailureResult | null { + return dependencies.cacheStorage && dependencies.mutationLock + ? null + : unsupported(operation, "RETRY"); } function unsupported( operation: BrowserDataOperation, + recovery: "ONLINE_ONLY" | "RETRY", ): BrowserFailureResult { return asFailure( - browserDataFailure("UNSUPPORTED", operation, { - recovery: "ONLINE_ONLY", - }), + browserDataFailure("UNSUPPORTED", operation, { recovery }), ); } diff --git a/tests/unit/public-response-cache.test.ts b/tests/unit/public-response-cache.test.ts index 9648fa4..4201535 100644 --- a/tests/unit/public-response-cache.test.ts +++ b/tests/unit/public-response-cache.test.ts @@ -6,6 +6,7 @@ import type { } from "../../src/application/ports/browser-file-storage/cache-storage-ports.ts"; import { createDefaultPublicCachePolicy, + resolvePublicCachePolicy, type PublicCacheRuntimePolicy, } from "../../src/adapters/cache-storage/public-cache-policy.ts"; import { @@ -140,6 +141,159 @@ async function manifestFor( } describe("public response Cache Storage adapter", () => { + it("rejects a policy that enables variants but strips Vary", () => { + const base = createDefaultPublicCachePolicy( + "https://assets.example.test", + ); + // STO-03. Enabling variants while dropping Vary from the response + // allowlist makes every stored variant collide on the same key. + expect(() => + resolvePublicCachePolicy({ + ...base, + allowedRequestHeaderNames: ["accept", "accept-language"], + allowedVaryHeaderNames: ["accept-language"], + allowedResponseHeaderNames: base.allowedResponseHeaderNames.filter( + (name) => name !== "vary", + ), + }), + ).toThrow(TypeError); + // The same policy with Vary preserved is accepted. + expect(() => + resolvePublicCachePolicy({ + ...base, + allowedRequestHeaderNames: ["accept", "accept-language"], + allowedVaryHeaderNames: ["accept-language"], + }), + ).not.toThrow(); + }); + + it("restages an evicted entry even when the release marker remains", async () => { + const policy = createDefaultPublicCachePolicy( + "https://assets.example.test", + ); + const bytes = new Uint8Array([9, 9, 9, 9]); + const asset: PublicCacheAsset = { + absoluteUrl: "https://assets.example.test/evicted.js", + expectedByteLength: bytes.byteLength, + expectedContentType: "application/javascript", + integrity: { + algorithm: "SHA-256", + digestHex: await digestHex(bytes), + }, + }; + const cacheStorage = new MemoryCacheStorage(); + let fetches = 0; + const adapter = createPublicResponseCacheAdapter({ + cacheStorage: cacheStorage as unknown as CacheStorage, + crypto: globalThis.crypto, + mutationLock: immediateLock, + policy, + fetcher: async () => { + fetches += 1; + return new Response(bytes, { + headers: { + "cache-control": "public", + "content-type": "application/javascript", + }, + }); + }, + }); + const manifest = await manifestFor("evicted-release", [asset], policy); + expect(await adapter.admin.stageRelease(manifest)).toMatchObject({ + ok: true, + }); + expect(fetches).toBe(1); + + // The browser evicts the payload but leaves the marker behind. + const cacheName = [...cacheStorage.caches.keys()].find((name) => + name.includes("evicted-release"), + ); + if (!cacheName) throw new Error("staged cache missing"); + const cache = cacheStorage.caches.get(cacheName)!; + const payloadIndex = cache.responses.findIndex( + (entry) => entry.request.url === asset.absoluteUrl, + ); + expect(payloadIndex).toBeGreaterThanOrEqual(0); + cache.responses.splice(payloadIndex, 1); + + // A marker is a claim, not evidence: restaging must repair. + expect(await adapter.admin.stageRelease(manifest)).toMatchObject({ + ok: true, + }); + expect(fetches).toBe(2); + expect( + await adapter.admin.activateRelease( + manifest.releaseRegistryId, + manifest.manifestDigestHex, + ), + ).toMatchObject({ ok: true }); + }); + + it("activates a verified prestaged release without a fetcher", async () => { + const policy = createDefaultPublicCachePolicy( + "https://assets.example.test", + ); + const bytes = new Uint8Array([4, 4, 4, 4]); + const asset: PublicCacheAsset = { + absoluteUrl: "https://assets.example.test/offline.js", + expectedByteLength: bytes.byteLength, + expectedContentType: "application/javascript", + integrity: { + algorithm: "SHA-256", + digestHex: await digestHex(bytes), + }, + }; + const cacheStorage = new MemoryCacheStorage(); + const online = createPublicResponseCacheAdapter({ + cacheStorage: cacheStorage as unknown as CacheStorage, + crypto: globalThis.crypto, + mutationLock: immediateLock, + policy, + fetcher: async () => + new Response(bytes, { + headers: { + "cache-control": "public", + "content-type": "application/javascript", + }, + }), + }); + const manifest = await manifestFor("offline-release", [asset], policy); + expect(await online.admin.stageRelease(manifest)).toMatchObject({ + ok: true, + }); + + // STO-05. Activation and cleanup perform no network I/O, so a missing + // fetcher must not make an offline rollback UNSUPPORTED. + const offline = createPublicResponseCacheAdapter({ + cacheStorage: cacheStorage as unknown as CacheStorage, + crypto: globalThis.crypto, + mutationLock: immediateLock, + policy, + }); + expect( + await offline.admin.activateRelease( + manifest.releaseRegistryId, + manifest.manifestDigestHex, + ), + ).toMatchObject({ ok: true }); + }); + + it("cleans exact owned caches without a fetcher", async () => { + const policy = createDefaultPublicCachePolicy( + "https://assets.example.test", + ); + const cacheStorage = new MemoryCacheStorage(); + const offline = createPublicResponseCacheAdapter({ + cacheStorage: cacheStorage as unknown as CacheStorage, + crypto: globalThis.crypto, + mutationLock: immediateLock, + policy, + }); + expect(await offline.admin.cleanupOwned()).toMatchObject({ + ok: true, + }); + }); + it("stages, re-verifies and atomically activates an exact release", async () => { const cacheStorage = new MemoryCacheStorage(); const bytes = new Uint8Array([1, 2, 3, 4]);