fix: make Service Worker cache and removal outcomes truthful

SW-URL-01: canonicalize each generated root-relative manifest URL against the
registration scope once, re-check same-origin, and share that absolute identity
across install cache keys, fetch classification and cache lookup or delete.
Previously every verified asset fell through to the network.

SW-01: serve verified static requests only from the current release cache. A
CacheStorage-wide match could return a previous release's response for the same
URL while the delete targeted a cache that was never read. The worker scope
facade no longer exposes a wide match at all.

SW-02: cache reset deletes only names that parse as owned, so a foreign cache
sharing the ca-static-v1- prefix survives.

SW-03: unregister() resolving to false is a FAILED unregister, not UNREGISTERED.

SW-04: staged removal reports what happened - ABSENT, UNREGISTERED and PURGED
map to DISABLED, OWNERSHIP_MISMATCH to INCOMPATIBLE and FAILED to FAILED - so a
later release cannot delete the worker while a registration or owned cache is
still present.

SW-05: add the runtime-neutral service-worker-static-manifest codec that owns
exact row keys, the extension and content-type allowlist, the root-relative URL
rule and the length-prefixed canonical bytes. The generator and the build gate
hash those same bytes, and the build gate now decodes and recomputes the set
digest instead of type-casting the manifest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-14 00:25:11 +09:00
co-authored by Claude Opus 5
parent cc4e875c2d
commit 58efe6ddbd
10 changed files with 722 additions and 55 deletions
+27 -11
View File
@@ -1,3 +1,10 @@
import { createHash } from "node:crypto";
import {
canonicalStaticManifestBytes,
decodeStaticAssetManifest,
} from "../../src/contracts/service-worker-static-manifest.ts";
import type {
InstalledServiceWorkerSelection,
ServiceWorkerHandlerId,
@@ -79,19 +86,28 @@ export function resolveServiceWorkerBuildInput(input: Readonly<{
});
}
/**
* SW-05. The build gate no longer type-casts the manifest. It decodes every row
* through the shared runtime-neutral codec and recomputes the set digest from
* the same canonical bytes the generator hashed, so a tampered row, a reordered
* set or a stale digest fails admission instead of shipping.
*/
function parseAssets(value: unknown): StaticAssetManifestV1 {
const candidate = record(value);
if (
candidate?.schemaVersion !== 1 ||
typeof candidate.buildId !== "string" ||
typeof candidate.releaseId !== "string" ||
typeof candidate.setDigest !== "string" ||
!DIGEST.test(candidate.setDigest) ||
!Array.isArray(candidate.assets)
) {
throw new TypeError("Generated Service Worker asset manifest is invalid.");
const decoded = decodeStaticAssetManifest(value);
if (!decoded.ok) {
throw new TypeError(
`Generated Service Worker asset manifest is invalid: ${decoded.error.reason}`,
);
}
return candidate as unknown as StaticAssetManifestV1;
const expected = `sha256:${createHash("sha256")
.update(canonicalStaticManifestBytes(decoded.manifest.assets))
.digest("hex")}`;
if (expected !== decoded.manifest.setDigest) {
throw new TypeError(
"Generated Service Worker asset manifest set digest does not match its assets.",
);
}
return decoded.manifest as unknown as StaticAssetManifestV1;
}
function record(value: unknown): Record<string, unknown> | null {