From ebd1ac77964f540614ba8393461f424021b490f2 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 6 Aug 2026 15:58:12 +0200 Subject: [PATCH 1/2] fix(release-assets): read the v1 manifest still in storage Every browser module 503s on any release published before the v2 move. `/_vf_modules/pages/index.js` answers "Browser module manifest unavailable", the dynamic import fails, and hydration dies. SSR still renders, so the page looks fine while nothing on it works. The API answers GET with `{state: "ready", manifest: null}`: the row says ready, the stored body is v1, and veryfront-api#4270 moved that validator to v2 by replacing the pin rather than widening it. This side then classifies the response as `fetch_ready_invalid` and refuses admission for the whole release. 260 stored manifests are v1 against 7 at v2, across 54 deployed projects. Consumption must read what was published, not only what the current builder emits. `parseReleaseAssetManifest` now reshapes a v1 body and runs the full v2 validator over the result, so every bound, key check and hostile-input guard still applies -- the adapter only reshapes, it never validates. `getReleaseAssetManifestSchema` stays v2-only, so builds still cannot emit v1 and the legacy set only shrinks. `fallback` is dropped (nothing reads it) and `dependencyMode` is reported as `source`, which is what v1's always-empty `dependencies` means. CSS is dropped rather than upcast: a v1 entry carries no `cssPipelineIdentity` and a short-token `styleProfileHash`, and both are cache-correctness keys that cannot be recovered from the stored artifact. Synthesizing them risks serving the wrong stylesheet, so the adapter reports no manifest CSS and the renderer keeps its own pipeline -- the per-entry fallback html-shell-generator already documents. Verified against codersociety's real stored body: 148 modules and 65 routes parse, and `pages/index.mdx` and `pages/privacy.mdx` -- the two keys the production 503s name -- both pass admission. --- src/release-assets/manifest-schema.test.ts | 95 ++++++++++++++++++++++ src/release-assets/manifest-schema.ts | 94 +++++++++++++++++++++ 2 files changed, 189 insertions(+) diff --git a/src/release-assets/manifest-schema.test.ts b/src/release-assets/manifest-schema.test.ts index 87cbe664f4..8f36158ceb 100644 --- a/src/release-assets/manifest-schema.test.ts +++ b/src/release-assets/manifest-schema.test.ts @@ -54,6 +54,101 @@ function validManifest(): ReleaseAssetManifest { }; } +/** + * A stored v1 body, shaped exactly as production holds it: `fallback` present, + * `dependencyMode` absent, and a CSS entry whose `styleProfileHash` is the + * legacy short token rather than a sha256. + */ +function legacyV1Manifest(): Record { + return { + schemaVersion: 1, + projectId: "11111111-1111-1111-1111-111111111111", + releaseId: "22222222-2222-2222-2222-222222222222", + releaseVersion: 7, + manifestVersion: 1, + builderVersion: "0.1.841", + sourceContentHash: "d".repeat(64), + createdAt: "2026-06-12T00:00:00.000Z", + assetBasePath: "/_vf/assets", + modules: { + "pages/index.tsx": { + contentHash: "a".repeat(64), + size: 1234, + contentType: "text/javascript", + }, + }, + css: [ + { + contentHash: "b".repeat(64), + size: 4321, + contentType: "text/css", + styleProfileHash: "-4ij92d", + }, + ], + routes: { + "/": { modules: ["pages/index.tsx"], css: ["b".repeat(64)] }, + }, + dependencies: {}, + fallback: { mode: "none", gaps: [] }, + }; +} + +describe("legacy v1 manifest consumption", () => { + it("admits modules from a stored v1 body", () => { + const manifest = parseReleaseAssetManifest(legacyV1Manifest()); + assertExists(manifest); + assertEquals(manifest.schemaVersion, RELEASE_ASSET_MANIFEST_SCHEMA_VERSION); + assertEquals(manifest.modules["pages/index.tsx"]?.contentHash, "a".repeat(64)); + assertEquals(manifest.routes["/"]?.modules, ["pages/index.tsx"]); + }); + + it("drops legacy CSS rather than inventing v2 identities", () => { + // v1 CSS carries no `cssPipelineIdentity` and a non-sha256 profile hash. + // Synthesizing either would fabricate a cache-correctness key, so the + // adapter reports no manifest CSS and the renderer keeps its own pipeline. + const manifest = parseReleaseAssetManifest(legacyV1Manifest()); + assertExists(manifest); + assertEquals(manifest.css, []); + assertEquals(manifest.routes["/"]?.css, []); + }); + + it("reports source dependency mode for a v1 body", () => { + const manifest = parseReleaseAssetManifest(legacyV1Manifest()); + assertExists(manifest); + assertEquals(manifest.dependencyMode, "source"); + }); + + it("applies v2 bounds to the adapted body", () => { + const corruptModuleKey = legacyV1Manifest(); + corruptModuleKey.modules = { + "../escape.tsx": { contentHash: "a".repeat(64), size: 1, contentType: "text/javascript" }, + }; + assertEquals(parseReleaseAssetManifest(corruptModuleKey), null); + + const danglingRoute = legacyV1Manifest(); + danglingRoute.routes = { "/": { modules: ["pages/missing.tsx"], css: [] } }; + assertEquals(parseReleaseAssetManifest(danglingRoute), null); + }); + + it("accepts a ready response carrying a v1 body", () => { + const response = { + state: "ready", + manifest_version: 1, + manifest: legacyV1Manifest(), + }; + const parsed = parseReadyReleaseAssetManifestResponse( + response, + "22222222-2222-2222-2222-222222222222", + ); + assertExists(parsed); + assertEquals(parsed.manifest.modules["pages/index.tsx"]?.size, 1234); + }); + + it("keeps the strict validator v2-only so builds cannot emit v1", () => { + assertEquals(getReleaseAssetManifestSchema().safeParse(legacyV1Manifest()).success, false); + }); +}); + describe("release asset manifest schema", () => { it("round-trips a valid manifest through the zod validator", () => { const manifest = validManifest(); diff --git a/src/release-assets/manifest-schema.ts b/src/release-assets/manifest-schema.ts index baccbd7e40..061690536b 100644 --- a/src/release-assets/manifest-schema.ts +++ b/src/release-assets/manifest-schema.ts @@ -63,6 +63,28 @@ const MANIFEST_KEYS = new Set([ "dependencyMode", "dependencies", ]); +/** + * Key set of the v1 body still held in storage for every release published + * before the v2 move. v1 carried `fallback` and had no `dependencyMode`. + */ +const LEGACY_V1_MANIFEST_KEYS = new Set([ + "schemaVersion", + "projectId", + "releaseId", + "releaseVersion", + "manifestVersion", + "builderVersion", + "sourceContentHash", + "createdAt", + "assetBasePath", + "modules", + "css", + "routes", + "dependencies", + "fallback", +]); +const LEGACY_V1_SCHEMA_VERSION = 1; + const ASSET_ENTRY_KEYS = new Set(["contentHash", "size", "contentType"]); const CSS_ENTRY_KEYS = new Set([ "contentHash", @@ -431,7 +453,79 @@ export function readUntrustedOwnDataProperty(value: unknown, key: PropertyKey): } } +/** + * Parse an untrusted body, tolerating the v1 shape still held in storage. + * + * Consumption must read what was published, not only what the current builder + * emits: every release predating the v2 move stored a v1 body, and refusing + * those takes the whole release's browser modules offline. Production stays + * strict — `getReleaseAssetManifestSchema` is v2-only, so no new v1 can be + * written — while reads adapt the old shape and then apply the full v2 + * validator to it. The adapter only reshapes; it never validates. + */ function parseReleaseAssetManifestImpl(value: unknown): ReleaseAssetManifest | null { + const current = parseCurrentManifestBody(value); + if (current) return current; + + const adapted = adaptLegacyV1ManifestBody(value); + return adapted ? parseCurrentManifestBody(adapted) : null; +} + +/** + * Reshape a v1 body into the v2 shape, or null when it is not a v1 body. + * + * `fallback` is dropped (nothing reads it) and `dependencyMode` is reported as + * `source`, which is what v1's always-empty `dependencies` means. CSS is + * dropped entirely: a v1 entry carries no `cssPipelineIdentity` and a + * short-token `styleProfileHash`, and both are cache-correctness keys that + * cannot be recovered from the stored artifact. Reporting no manifest CSS + * routes styling back through the renderer's own pipeline, which is the + * documented per-entry fallback; fabricating the identities would risk serving + * the wrong stylesheet. Route CSS references are cleared with it so the + * reference check still resolves. + */ +function adaptLegacyV1ManifestBody(value: unknown): Record | null { + const candidate = snapshotExactDataRecord(value, LEGACY_V1_MANIFEST_KEYS); + if (!candidate) return null; + if (candidate.schemaVersion !== LEGACY_V1_SCHEMA_VERSION) return null; + + const routes = adaptLegacyV1Routes(candidate.routes); + if (!routes) return null; + + return { + schemaVersion: RELEASE_ASSET_MANIFEST_SCHEMA_VERSION, + projectId: candidate.projectId, + releaseId: candidate.releaseId, + releaseVersion: candidate.releaseVersion, + manifestVersion: candidate.manifestVersion, + builderVersion: candidate.builderVersion, + sourceContentHash: candidate.sourceContentHash, + createdAt: candidate.createdAt, + assetBasePath: candidate.assetBasePath, + modules: candidate.modules, + css: [], + routes, + dependencyMode: "source", + dependencies: candidate.dependencies, + }; +} + +/** Copy v1 route entries with their CSS closure cleared. */ +function adaptLegacyV1Routes(value: unknown): Record | null { + if (!isPlainRecord(value)) return null; + const keys = Object.keys(value); + if (keys.length > MAX_ROUTE_ENTRIES) return null; + + const routes: Record = {}; + for (const key of keys) { + const entry = readUntrustedOwnDataProperty(value, key); + if (!isPlainRecord(entry)) return null; + routes[key] = { modules: readUntrustedOwnDataProperty(entry, "modules"), css: [] }; + } + return routes; +} + +function parseCurrentManifestBody(value: unknown): ReleaseAssetManifest | null { const candidate = snapshotExactDataRecord(value, MANIFEST_KEYS); if (!candidate) return null; if (candidate.schemaVersion !== RELEASE_ASSET_MANIFEST_SCHEMA_VERSION) return null; From 391f57af9ad80ca412ea1f530de3f30764f23218 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 6 Aug 2026 16:07:21 +0200 Subject: [PATCH 2/2] docs(api-reference): repin release-assets declaration lines The v1 adapter shifted every declaration below it in manifest-schema.ts, so `docs:api-reference:check` reported veryfront/release-assets.md stale. Regenerated with `deno task docs`; the diff is line pins only, no description or symbol changes. --- .../api-reference/veryfront/release-assets.md | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/api-reference/veryfront/release-assets.md b/docs/api-reference/veryfront/release-assets.md index 28bdc60359..baf623c8fa 100644 --- a/docs/api-reference/veryfront/release-assets.md +++ b/docs/api-reference/veryfront/release-assets.md @@ -69,18 +69,18 @@ const url = releaseAssetUrl("a".repeat(64), "js"); | `clearCachedReleaseAssetManifests` | Clear cached manifest bodies while keeping registered fetchers intact. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L587) | | `clearReleaseAssetManifestCache` | Clear the cache and fetcher registry (tests / adapter teardown). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L597) | | `contentTypeForExtension` | Resolve the content type for an extension, or null if not allowed. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L96) | -| `describeReadyReleaseAssetManifestRejection` | Explain why a ready manifest response was rejected. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L381) | +| `describeReadyReleaseAssetManifestRejection` | Explain why a ready manifest response was rejected. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L403) | | `getReadyManifestForRender` | Return a ready manifest for `releaseId` if one is cached, else null. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L258) | | `getReadyManifestForRenderAsync` | Await a ready manifest for rendering when release-manifest consumption is enabled. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L359) | -| `hasImmutableReleaseAssetDependencies` | True only when manifest dependency entries are safe immutable rewrite targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L283) | +| `hasImmutableReleaseAssetDependencies` | True only when manifest dependency entries are safe immutable rewrite targets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L305) | | `isAllowedReleaseAssetContentType` | True when the value is a valid allowlisted release asset content type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L105) | | `isReleaseAssetManifestEnabled` | True when production manifest consumption is enabled via env flag. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L203) | -| `isSafeBoundedText` | Check that an untrusted value is a non-empty, trimmed string within `maxLength` that contains no control characters. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L80) | +| `isSafeBoundedText` | Check that an untrusted value is a non-empty, trimmed string within `maxLength` that contains no control characters. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L102) | | `isValidContentHash` | Validate a content hash is exactly 64 lowercase hex characters. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L114) | | `normalizeManifestModuleKey` | Normalize a logical module path to the manifest's key convention. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/html-consumption.ts#L27) | -| `parseReadyReleaseAssetManifestResponse` | Parse an untrusted ready response without executing accessors. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L337) | -| `parseReleaseAssetManifest` | Parse an untrusted manifest without requiring a registered schema extension. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L322) | -| `readUntrustedOwnDataProperty` | Read an own data property from an untrusted value without invoking accessors. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L425) | +| `parseReadyReleaseAssetManifestResponse` | Parse an untrusted ready response without executing accessors. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L359) | +| `parseReleaseAssetManifest` | Parse an untrusted manifest without requiring a registered schema extension. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L344) | +| `readUntrustedOwnDataProperty` | Read an own data property from an untrusted value without invoking accessors. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L447) | | `registerManifestFetcherForRelease` | Register a project-scoped manifest fetcher for the given releaseId. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L159) | | `releaseAssetUrl` | Map a 64-hex content hash + extension to its public asset URL. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L85) | | `resolveManifestModuleUrl` | Resolve a module URL through the manifest. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/html-consumption.ts#L42) | @@ -92,24 +92,24 @@ const url = releaseAssetUrl("a".repeat(64), "js"); | Name | Description | Source | | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `ImmutableReleaseAssetManifest` | Manifest whose dependency entries name uploaded content-addressed assets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L278) | +| `ImmutableReleaseAssetManifest` | Manifest whose dependency entries name uploaded content-addressed assets. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L300) | | `ReadyManifestReadOptions` | Controls revalidation behavior for awaited manifest reads. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L77) | -| `ReadyReleaseAssetManifestResponse` | Strict ready response with a generation-matched validated manifest body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L306) | +| `ReadyReleaseAssetManifestResponse` | Strict ready response with a generation-matched validated manifest body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L328) | | `ReleaseAssetContentType` | MIME types accepted for immutable release asset uploads and responses. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L24) | -| `ReleaseAssetCssEntry` | Content-addressed CSS entry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L272) | -| `ReleaseAssetDependencyMode` | Capability represented by entries in the manifest dependency map. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L276) | -| `ReleaseAssetEntry` | Content-addressed JavaScript module entry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L270) | +| `ReleaseAssetCssEntry` | Content-addressed CSS entry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L294) | +| `ReleaseAssetDependencyMode` | Capability represented by entries in the manifest dependency map. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L298) | +| `ReleaseAssetEntry` | Content-addressed JavaScript module entry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L292) | | `ReleaseAssetExtension` | File extensions supported by the immutable release asset endpoint. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L22) | -| `ReleaseAssetManifest` | Validated, immutable release asset manifest v2 body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L266) | +| `ReleaseAssetManifest` | Validated, immutable release asset manifest v2 body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L288) | | `ReleaseAssetManifestFetchContext` | Cancellation context passed to a release-scoped manifest fetcher. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L92) | | `ReleaseAssetManifestFetcher` | Fetcher used to retrieve a manifest for a release. Registered per-releaseId by the runtime adapter that owns that release, so the correct project-scoped token is always used. Returns null when the manifest is unavailable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L109) | | `ReleaseAssetManifestFetcherCleanup` | Idempotent cleanup for one fetcher registration. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L117) | -| `ReleaseAssetManifestResponse` | Response shape for the GET asset-manifest endpoint. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L299) | -| `ReleaseAssetManifestState` | Manifest lifecycle states (DB-owned; mirrored here for runtime checks). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L290) | -| `ReleaseAssetRouteEntry` | Per-route module and CSS closure. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L274) | +| `ReleaseAssetManifestResponse` | Response shape for the GET asset-manifest endpoint. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L321) | +| `ReleaseAssetManifestState` | Manifest lifecycle states (DB-owned; mirrored here for runtime checks). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L312) | +| `ReleaseAssetRouteEntry` | Per-route module and CSS closure. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L296) | ### Constants | Name | Description | Source | | ------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `getReleaseAssetManifestSchema` | Extension-backed validator for the strict release asset manifest v2 body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L216) | +| `getReleaseAssetManifestSchema` | Extension-backed validator for the strict release asset manifest v2 body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L238) |