Files
tech-log-frontend/src/bootstrap/load-release-manifest.ts
T

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;
}