Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/shared/deployment/deploy-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1533,7 +1533,7 @@ describe("release asset manifest", () => {
polling,
),
Error,
"identifies a different release than the one being deployed",
"identifies a different release than the one requested",
);
});

Expand Down
13 changes: 7 additions & 6 deletions docs/api-reference/veryfront/release-assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@ const url = releaseAssetUrl("a".repeat(64), "js");

| Name | Description | Source |
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `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) |
| `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#L594) |
| `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#L604) |
| `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#L403) |
| `describeReadyReleaseAssetManifestRejection` | Explain why a ready manifest response was rejected. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L425) |
| `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#L305) |
Expand All @@ -78,9 +78,9 @@ const url = releaseAssetUrl("a".repeat(64), "js");
| `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#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) |
| `parseReadyReleaseAssetManifestResponse` | Parse an untrusted ready response without executing accessors. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L380) |
| `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#L362) |
| `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#L480) |
| `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) |
Expand All @@ -104,6 +104,7 @@ const url = releaseAssetUrl("a".repeat(64), "js");
| `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) |
| `ReleaseAssetManifestParseOptions` | Options shared by the dependency-free consumption parsers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-schema.ts#L339) |
| `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) |
Expand Down
1 change: 1 addition & 0 deletions src/release-assets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export {
type ReleaseAssetDependencyMode,
type ReleaseAssetEntry,
type ReleaseAssetManifest,
type ReleaseAssetManifestParseOptions,
type ReleaseAssetManifestResponse,
type ReleaseAssetManifestState,
type ReleaseAssetRouteEntry,
Expand Down
11 changes: 9 additions & 2 deletions src/release-assets/manifest-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,10 @@ function fetchManifest(releaseId: string): Promise<ReleaseAssetManifest | null>
: "invalid";
const manifestState = normalizeManifestState(state);
const readyResponse = isUsableManifestState(state)
? parseReadyReleaseAssetManifestResponse(result, releaseId)
// Runtime reads serve releases published before the v2 move, so they
// must accept the v1 body still in storage. Producer-side callers
// (build executor, CLI deploy wait) deliberately do not.
? parseReadyReleaseAssetManifestResponse(result, releaseId, { acceptLegacyV1: true })
: null;
const manifest = readyResponse?.manifest ?? null;

Expand Down Expand Up @@ -465,7 +468,11 @@ function fetchManifest(releaseId: string): Promise<ReleaseAssetManifest | null>
// framework version skew from a corrupt payload.
logger.error("Release manifest is ready upstream but failed validation", {
releaseId,
reason: describeReadyReleaseAssetManifestRejection(result, releaseId),
// Same acceptance as the parse above, so the reason describes what
// this caller actually rejected.
reason: describeReadyReleaseAssetManifestRejection(result, releaseId, {
acceptLegacyV1: true,
}),
});
} else {
markManifestDecision(`fetch_${manifestState}`);
Expand Down
85 changes: 79 additions & 6 deletions src/release-assets/manifest-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,11 @@ function legacyV1Manifest(): Record<string, unknown> {
};
}

const LEGACY = { acceptLegacyV1: true } as const;

describe("legacy v1 manifest consumption", () => {
it("admits modules from a stored v1 body", () => {
const manifest = parseReleaseAssetManifest(legacyV1Manifest());
const manifest = parseReleaseAssetManifest(legacyV1Manifest(), LEGACY);
assertExists(manifest);
assertEquals(manifest.schemaVersion, RELEASE_ASSET_MANIFEST_SCHEMA_VERSION);
assertEquals(manifest.modules["pages/index.tsx"]?.contentHash, "a".repeat(64));
Expand All @@ -106,14 +108,14 @@ describe("legacy v1 manifest consumption", () => {
// 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());
const manifest = parseReleaseAssetManifest(legacyV1Manifest(), LEGACY);
assertExists(manifest);
assertEquals(manifest.css, []);
assertEquals(manifest.routes["/"]?.css, []);
});

it("reports source dependency mode for a v1 body", () => {
const manifest = parseReleaseAssetManifest(legacyV1Manifest());
const manifest = parseReleaseAssetManifest(legacyV1Manifest(), LEGACY);
assertExists(manifest);
assertEquals(manifest.dependencyMode, "source");
});
Expand All @@ -123,11 +125,39 @@ describe("legacy v1 manifest consumption", () => {
corruptModuleKey.modules = {
"../escape.tsx": { contentHash: "a".repeat(64), size: 1, contentType: "text/javascript" },
};
assertEquals(parseReleaseAssetManifest(corruptModuleKey), null);
assertEquals(parseReleaseAssetManifest(corruptModuleKey, LEGACY), null);

const danglingRoute = legacyV1Manifest();
danglingRoute.routes = { "/": { modules: ["pages/missing.tsx"], css: [] } };
assertEquals(parseReleaseAssetManifest(danglingRoute), null);
assertEquals(parseReleaseAssetManifest(danglingRoute, LEGACY), null);
});

it("rejects a __proto__ route key instead of silently dropping it", () => {
// Route keys are untrusted. The adapter accumulates them on a
// null-prototype object so `__proto__` arrives at the validator as an
// ordinary own property and is rejected for not being a canonical route
// path. On a plain `{}` it would hit the prototype setter instead, which
// swallows the key and reshapes the accumulator -- a different route to
// the same rejection, but one that hides which key was at fault.
const hostile = legacyV1Manifest();
const routes: Record<string, unknown> = Object.create(null);
routes["/"] = { modules: ["pages/index.tsx"], css: [] };
routes["__proto__"] = { modules: ["pages/index.tsx"], css: [] };
hostile.routes = routes;

assertEquals(parseReleaseAssetManifest(hostile, LEGACY), null);
// Pollution shows up as a property reachable from an unrelated object, not
// as a changed prototype identity, so probe for the injected value itself.
assertEquals(
({} as Record<string, unknown>).modules,
undefined,
"a route entry leaked onto Object.prototype while parsing",
);
assertEquals(
({} as Record<string, unknown>).css,
undefined,
"a route entry leaked onto Object.prototype while parsing",
);
});

it("accepts a ready response carrying a v1 body", () => {
Expand All @@ -139,6 +169,7 @@ describe("legacy v1 manifest consumption", () => {
const parsed = parseReadyReleaseAssetManifestResponse(
response,
"22222222-2222-2222-2222-222222222222",
LEGACY,
);
assertExists(parsed);
assertEquals(parsed.manifest.modules["pages/index.tsx"]?.size, 1234);
Expand All @@ -147,6 +178,24 @@ describe("legacy v1 manifest consumption", () => {
it("keeps the strict validator v2-only so builds cannot emit v1", () => {
assertEquals(getReleaseAssetManifestSchema().safeParse(legacyV1Manifest()).success, false);
});

it("rejects a v1 body unless the caller opts in", () => {
// The default has to stay strict. Producer-side callers -- the build
// executor verifying what it just emitted, the CLI waiting on a deploy --
// rely on it to surface a builder/framework skew instead of absorbing it.
assertEquals(parseReleaseAssetManifest(legacyV1Manifest()), null);
assertEquals(
parseReleaseAssetManifest(legacyV1Manifest(), { acceptLegacyV1: false }),
null,
);
assertEquals(
parseReadyReleaseAssetManifestResponse(
{ state: "ready", manifest_version: 1, manifest: legacyV1Manifest() },
"22222222-2222-2222-2222-222222222222",
),
null,
);
});
});

describe("release asset manifest schema", () => {
Expand Down Expand Up @@ -399,7 +448,7 @@ describe("release asset manifest schema", () => {
throw new Error("hostile ownKeys");
},
});
assertEquals(parseReleaseAssetManifest(hostile), null);
assertEquals(parseReleaseAssetManifest(hostile, LEGACY), null);
});

it("rejects accessor-backed input without executing accessors", () => {
Expand Down Expand Up @@ -470,6 +519,30 @@ describe("describeReadyReleaseAssetManifestRejection", () => {
assertStringIncludes(reason, "different framework version");
});

it("does not call a malformed v1 body a skew for a caller that reads v1", () => {
// A runtime read accepts v1, so a v1 body that still fails is corrupt.
// Reporting skew would send operators to upgrade the builder for something
// an upgrade cannot fix.
const reason = describeReadyReleaseAssetManifestRejection(
{ state: "ready", manifest_version: 1, manifest: { schemaVersion: 1, releaseId: "r1" } },
"r1",
{ acceptLegacyV1: true },
);

assertEquals(reason, "the manifest body did not match the expected schema");
});

it("still names a skew for a version no caller reads", () => {
const reason = describeReadyReleaseAssetManifestRejection(
{ state: "ready", manifest_version: 1, manifest: { schemaVersion: 3, releaseId: "r1" } },
"r1",
{ acceptLegacyV1: true },
);

assertStringIncludes(reason, "schema version 3");
assertStringIncludes(reason, `versions 1 and ${RELEASE_ASSET_MANIFEST_SCHEMA_VERSION}`);
});

it("distinguishes the other rejection paths", () => {
assertStringIncludes(
describeReadyReleaseAssetManifestRejection("not-an-object", "r1"),
Expand Down
Loading
Loading