295 lines
8.9 KiB
TypeScript
295 lines
8.9 KiB
TypeScript
import {
|
|
verifyContractSet,
|
|
type ContractSet,
|
|
type ContractSetFailureCode,
|
|
} from "../contracts/contract-set.ts";
|
|
import type { ContractSetPackage } from "../contracts/contract-set-canonical.ts";
|
|
import {
|
|
releaseManifestV1ArtifactSchema,
|
|
releaseManifestV2ArtifactSchema,
|
|
type ReleaseManifestV1Artifact,
|
|
type ReleaseManifestV2Artifact,
|
|
} from "../contracts/release-artifacts.ts";
|
|
import { EXPECTED_CONTRACT_SET_PACKAGES } from "../features/installed-contract-contributions.ts";
|
|
import {
|
|
BOOT_JSON_POLICIES,
|
|
readBoundedBootJson,
|
|
type BootLoadFailure,
|
|
} from "./read-bounded-boot-json.ts";
|
|
import type { RuntimeConfigLoadResult } from "./load-runtime-config.ts";
|
|
|
|
/** §5.8. Retained for one compatibility window; carries the removed scalar. */
|
|
export const releaseManifestV1Schema = releaseManifestV1ArtifactSchema;
|
|
|
|
/** §5.2. The frontend build's compiled external contract package set. */
|
|
export const releaseManifestV2Schema = releaseManifestV2ArtifactSchema;
|
|
|
|
export type ReleaseManifestV1 = ReleaseManifestV1Artifact;
|
|
export type ReleaseManifestV2 = ReleaseManifestV2Artifact;
|
|
|
|
/**
|
|
* The composition-facing manifest. A V1 document is normalized onto it with a
|
|
* null contract set so downstream runtime never branches on schema version.
|
|
*/
|
|
export type ReleaseManifest = Readonly<{
|
|
schemaVersion: 1 | 2;
|
|
appVersion: string;
|
|
buildId: string;
|
|
commitSha: string;
|
|
configSchemaVersion: string;
|
|
assetManifestHash: string;
|
|
releaseId: string;
|
|
builtAt: string;
|
|
routeChunks: Readonly<Record<string, string>>;
|
|
contractSet: ContractSet | null;
|
|
legacyApiContractVersion?: string;
|
|
}>;
|
|
|
|
export type ReleaseManifestErrorCode =
|
|
| "MANIFEST_BUILD_MISMATCH"
|
|
| "MANIFEST_PROTOCOL_PAIR_MISMATCH"
|
|
| "MANIFEST_CONFIG_SCHEMA_MISMATCH"
|
|
| "MANIFEST_API_CONTRACT_MISMATCH"
|
|
| "MANIFEST_RELEASE_MISMATCH"
|
|
| "MANIFEST_ASSET_MISMATCH"
|
|
| "MANIFEST_FETCH_FAILED"
|
|
| "MANIFEST_TIMEOUT"
|
|
| "MANIFEST_HTTP_FAILED"
|
|
| "MANIFEST_CONTENT_TYPE_INVALID"
|
|
| "MANIFEST_BODY_TOO_LARGE"
|
|
| "MANIFEST_UTF8_INVALID"
|
|
| "MANIFEST_JSON_INVALID"
|
|
| "MANIFEST_SCHEMA_INVALID"
|
|
| ContractSetFailureCode;
|
|
|
|
export type ReleaseManifestFailureKind =
|
|
| "BUILD_MISMATCH"
|
|
| "PROTOCOL_PAIR_MISMATCH"
|
|
| "CONFIG_MISMATCH"
|
|
| "API_CONTRACT_MISMATCH"
|
|
| "RELEASE_MISMATCH"
|
|
| "ASSET_MISMATCH"
|
|
| "CONTRACT_SET_MISMATCH"
|
|
| "RELEASE_MANIFEST_FAILURE";
|
|
|
|
export type ReleaseManifestSafe = Readonly<{
|
|
kind: ReleaseManifestFailureKind;
|
|
code: ReleaseManifestErrorCode;
|
|
buildId: string;
|
|
releaseId?: string;
|
|
supportReference: string;
|
|
}>;
|
|
|
|
type ReleaseManifestSafeInput = Readonly<{
|
|
buildId: string;
|
|
releaseId?: string;
|
|
}>;
|
|
|
|
function failureKindFor(
|
|
code: ReleaseManifestErrorCode,
|
|
): ReleaseManifestFailureKind {
|
|
switch (code) {
|
|
case "MANIFEST_BUILD_MISMATCH":
|
|
return "BUILD_MISMATCH";
|
|
case "MANIFEST_PROTOCOL_PAIR_MISMATCH":
|
|
return "PROTOCOL_PAIR_MISMATCH";
|
|
case "MANIFEST_CONFIG_SCHEMA_MISMATCH":
|
|
return "CONFIG_MISMATCH";
|
|
case "MANIFEST_API_CONTRACT_MISMATCH":
|
|
return "API_CONTRACT_MISMATCH";
|
|
case "MANIFEST_RELEASE_MISMATCH":
|
|
return "RELEASE_MISMATCH";
|
|
case "MANIFEST_ASSET_MISMATCH":
|
|
return "ASSET_MISMATCH";
|
|
default:
|
|
return code.startsWith("CONTRACT_")
|
|
? "CONTRACT_SET_MISMATCH"
|
|
: "RELEASE_MANIFEST_FAILURE";
|
|
}
|
|
}
|
|
|
|
export class ReleaseManifestError extends Error {
|
|
readonly kind: ReleaseManifestFailureKind;
|
|
readonly code: ReleaseManifestErrorCode;
|
|
readonly safe: ReleaseManifestSafe;
|
|
|
|
constructor(code: ReleaseManifestErrorCode, safe: ReleaseManifestSafeInput) {
|
|
super("Release manifest could not be loaded");
|
|
this.name = "ReleaseManifestError";
|
|
this.kind = failureKindFor(code);
|
|
this.code = code;
|
|
this.safe = Object.freeze({
|
|
kind: this.kind,
|
|
code,
|
|
buildId: safe.buildId,
|
|
releaseId: safe.releaseId,
|
|
supportReference: `${safe.buildId}:${code}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const READ_FAILURE_CODE: Readonly<
|
|
Record<BootLoadFailure, ReleaseManifestErrorCode>
|
|
> = Object.freeze({
|
|
FETCH_FAILED: "MANIFEST_FETCH_FAILED",
|
|
TIMEOUT: "MANIFEST_TIMEOUT",
|
|
HTTP_STATUS_INVALID: "MANIFEST_HTTP_FAILED",
|
|
CONTENT_TYPE_INVALID: "MANIFEST_CONTENT_TYPE_INVALID",
|
|
BODY_TOO_LARGE: "MANIFEST_BODY_TOO_LARGE",
|
|
UTF8_INVALID: "MANIFEST_UTF8_INVALID",
|
|
JSON_INVALID: "MANIFEST_JSON_INVALID",
|
|
SHAPE_INVALID: "MANIFEST_SCHEMA_INVALID",
|
|
SECRET_NAME_REJECTED: "MANIFEST_SCHEMA_INVALID",
|
|
SCHEMA_INVALID: "MANIFEST_SCHEMA_INVALID",
|
|
BUILD_MISMATCH: "MANIFEST_BUILD_MISMATCH",
|
|
RELEASE_MISMATCH: "MANIFEST_RELEASE_MISMATCH",
|
|
ASSET_MISMATCH: "MANIFEST_ASSET_MISMATCH",
|
|
CONTRACT_SET_MISMATCH: "CONTRACT_SET_DIGEST_MISMATCH",
|
|
});
|
|
|
|
export type FetchReleaseManifestOptions = Readonly<{
|
|
fetcher?: typeof fetch;
|
|
buildId: string;
|
|
releaseId?: string;
|
|
signal?: AbortSignal;
|
|
}>;
|
|
|
|
/**
|
|
* Fetches and validates the active manifest without imposing the current build
|
|
* tuple. Chunk recovery uses this no-store view to detect a new release.
|
|
*/
|
|
export async function fetchReleaseManifest(
|
|
url: string,
|
|
options: FetchReleaseManifestOptions,
|
|
): Promise<ReleaseManifest> {
|
|
const outcome = await readBoundedBootJson(
|
|
url,
|
|
BOOT_JSON_POLICIES.RELEASE_MANIFEST,
|
|
{
|
|
...(options.fetcher ? { fetcher: options.fetcher } : {}),
|
|
...(options.signal ? { signal: options.signal } : {}),
|
|
},
|
|
);
|
|
if (!outcome.ok) {
|
|
throw new ReleaseManifestError(READ_FAILURE_CODE[outcome.failure], options);
|
|
}
|
|
|
|
if (outcome.value.schemaVersion === 2) {
|
|
const parsed = releaseManifestV2Schema.safeParse(outcome.value);
|
|
if (!parsed.success) {
|
|
throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", options);
|
|
}
|
|
return Object.freeze(structuredClone(parsed.data));
|
|
}
|
|
|
|
const parsed = releaseManifestV1Schema.safeParse(outcome.value);
|
|
if (!parsed.success) {
|
|
throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", options);
|
|
}
|
|
const { apiContractVersion, ...rest } = structuredClone(parsed.data);
|
|
return Object.freeze({
|
|
...rest,
|
|
contractSet: null,
|
|
legacyApiContractVersion: apiContractVersion,
|
|
});
|
|
}
|
|
|
|
export type LoadReleaseManifestOptions = Readonly<{
|
|
fetcher?: typeof fetch;
|
|
expectedAssetManifestHash?: string;
|
|
expectedContractSetPackages?: readonly ContractSetPackage[];
|
|
signal?: AbortSignal;
|
|
}>;
|
|
|
|
export async function loadReleaseManifest(
|
|
runtime: RuntimeConfigLoadResult,
|
|
options: LoadReleaseManifestOptions = {},
|
|
): Promise<ReleaseManifest> {
|
|
const identity: ReleaseManifestSafeInput = {
|
|
buildId: runtime.build.buildId,
|
|
...(runtime.config.RELEASE_ID
|
|
? { releaseId: runtime.config.RELEASE_ID }
|
|
: {}),
|
|
};
|
|
|
|
const manifest = await fetchReleaseManifest(
|
|
runtime.config.RELEASE_MANIFEST_URL,
|
|
{
|
|
...(options.fetcher ? { fetcher: options.fetcher } : {}),
|
|
...(options.signal ? { signal: options.signal } : {}),
|
|
buildId: runtime.build.buildId,
|
|
...(runtime.config.RELEASE_ID
|
|
? { releaseId: runtime.config.RELEASE_ID }
|
|
: {}),
|
|
},
|
|
);
|
|
|
|
const expectedManifestVersion = runtime.configSchema === "V1" ? 1 : 2;
|
|
if (manifest.schemaVersion !== expectedManifestVersion) {
|
|
throw new ReleaseManifestError(
|
|
"MANIFEST_PROTOCOL_PAIR_MISMATCH",
|
|
identity,
|
|
);
|
|
}
|
|
|
|
// §6.7 steps 5-6, in order: build, config, release, assets, then contractSet.
|
|
let mismatchCode: ReleaseManifestErrorCode | null = null;
|
|
if (manifest.buildId !== runtime.build.buildId) {
|
|
mismatchCode = "MANIFEST_BUILD_MISMATCH";
|
|
}
|
|
if (
|
|
!mismatchCode &&
|
|
runtime.config.BUILD_ID &&
|
|
manifest.buildId !== runtime.config.BUILD_ID
|
|
) {
|
|
mismatchCode = "MANIFEST_BUILD_MISMATCH";
|
|
}
|
|
if (
|
|
!mismatchCode &&
|
|
manifest.configSchemaVersion !== runtime.config.CONFIG_SCHEMA_VERSION
|
|
) {
|
|
mismatchCode = "MANIFEST_CONFIG_SCHEMA_MISMATCH";
|
|
}
|
|
if (
|
|
!mismatchCode &&
|
|
runtime.configSchema === "V1" &&
|
|
(manifest.legacyApiContractVersion === undefined ||
|
|
runtime.config.LEGACY_API_CONTRACT_VERSION === undefined ||
|
|
manifest.legacyApiContractVersion !==
|
|
runtime.config.LEGACY_API_CONTRACT_VERSION)
|
|
) {
|
|
mismatchCode = "MANIFEST_API_CONTRACT_MISMATCH";
|
|
}
|
|
if (
|
|
!mismatchCode &&
|
|
runtime.config.RELEASE_ID &&
|
|
manifest.releaseId !== runtime.config.RELEASE_ID
|
|
) {
|
|
mismatchCode = "MANIFEST_RELEASE_MISMATCH";
|
|
}
|
|
if (
|
|
!mismatchCode &&
|
|
options.expectedAssetManifestHash &&
|
|
manifest.assetManifestHash !== options.expectedAssetManifestHash
|
|
) {
|
|
mismatchCode = "MANIFEST_ASSET_MISMATCH";
|
|
}
|
|
if (mismatchCode) {
|
|
throw new ReleaseManifestError(mismatchCode, identity);
|
|
}
|
|
|
|
if (manifest.schemaVersion === 2 && manifest.contractSet) {
|
|
const verification = await verifyContractSet({
|
|
expected:
|
|
options.expectedContractSetPackages ??
|
|
(EXPECTED_CONTRACT_SET_PACKAGES as readonly ContractSetPackage[]),
|
|
manifest: manifest.contractSet,
|
|
});
|
|
if (!verification.ok) {
|
|
throw new ReleaseManifestError(verification.code, identity);
|
|
}
|
|
}
|
|
|
|
return manifest;
|
|
}
|