184 lines
5.2 KiB
JavaScript
184 lines
5.2 KiB
JavaScript
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
|
|
import { classifyLiveHostingBaseUrl } from "./lib/hosting-probe.mjs";
|
|
|
|
const cachePolicy = JSON.parse(
|
|
await readFile("config/hosting/cache-policy.json", "utf8"),
|
|
);
|
|
const securityPolicy = JSON.parse(
|
|
await readFile("config/hosting/security-headers.json", "utf8"),
|
|
);
|
|
const baseUrl = process.env.HOSTING_BASE_URL;
|
|
const liveTarget = baseUrl ? classifyLiveHostingBaseUrl(baseUrl) : null;
|
|
const distFiles = (await readdir("dist", { recursive: true })).map(String);
|
|
const publicSourceMaps = distFiles.filter((file) => file.endsWith(".map"));
|
|
const publicServiceWorkers = distFiles.filter((file) =>
|
|
/(?:^|\/)(?:service-worker|sw)(?:[.-][^/]*)?\.js$/i.test(file),
|
|
);
|
|
|
|
/** @type {Record<string, Record<string, string>>} */
|
|
let responses = {};
|
|
let mode;
|
|
/** @type {Array<{
|
|
* surface: string;
|
|
* header: string;
|
|
* expected: unknown;
|
|
* observed: unknown;
|
|
* reason?: string;
|
|
* passed: boolean;
|
|
* }>} */
|
|
const probeResults = [];
|
|
|
|
if (liveTarget?.passed) {
|
|
mode = "live";
|
|
const assets = await readdir("dist/assets");
|
|
const hashedJavaScript = assets.find((file) => file.endsWith(".js"));
|
|
if (!hashedJavaScript) throw new Error("No built hashed JavaScript found.");
|
|
const paths = {
|
|
index: "/",
|
|
runtimeConfig: "/config.json",
|
|
releaseManifest: "/release-manifest.json",
|
|
hashedAsset: `/assets/${hashedJavaScript}`,
|
|
};
|
|
responses = {};
|
|
for (const [surface, pathname] of Object.entries(paths)) {
|
|
const requestedUrl = new URL(pathname, liveTarget.url);
|
|
try {
|
|
const response = await fetch(requestedUrl, { redirect: "follow" });
|
|
const finalUrl = new URL(response.url);
|
|
probeResults.push(
|
|
{
|
|
surface,
|
|
header: "http-status",
|
|
expected: 200,
|
|
observed: response.status,
|
|
passed: response.status === 200,
|
|
},
|
|
{
|
|
surface,
|
|
header: "final-origin",
|
|
expected: liveTarget.url.origin,
|
|
observed: finalUrl.origin,
|
|
passed: finalUrl.origin === liveTarget.url.origin,
|
|
},
|
|
);
|
|
responses[surface] = Object.fromEntries(
|
|
[...response.headers.entries()].map(([name, value]) => [
|
|
name.toLowerCase(),
|
|
value,
|
|
]),
|
|
);
|
|
} catch (error) {
|
|
probeResults.push({
|
|
surface,
|
|
header: "transport",
|
|
expected: "reachable",
|
|
observed: error instanceof Error ? error.name : "UnknownError",
|
|
passed: false,
|
|
});
|
|
}
|
|
}
|
|
} else if (liveTarget) {
|
|
mode = "invalid-live";
|
|
probeResults.push({
|
|
surface: "deployment",
|
|
header: "base-url",
|
|
expected: "canonical non-loopback HTTPS root URL",
|
|
observed: liveTarget.observedOrigin,
|
|
reason: liveTarget.reason,
|
|
passed: false,
|
|
});
|
|
} else {
|
|
mode = "fixture";
|
|
responses = JSON.parse(
|
|
await readFile("config/hosting/response-headers.fixture.json", "utf8"),
|
|
).responses;
|
|
}
|
|
|
|
const results = [...probeResults];
|
|
for (const [surface, policy] of Object.entries(cachePolicy.surfaces)) {
|
|
if (!("cacheControl" in policy)) continue;
|
|
const observed = responses[surface]?.["cache-control"];
|
|
results.push({
|
|
surface,
|
|
header: "cache-control",
|
|
expected: policy.cacheControl,
|
|
observed,
|
|
passed: observed === policy.cacheControl,
|
|
});
|
|
const observedContentType = responses[surface]?.["content-type"];
|
|
const observedMime = observedContentType
|
|
?.split(";", 1)[0]
|
|
.trim()
|
|
.toLowerCase();
|
|
results.push({
|
|
surface,
|
|
header: "content-type",
|
|
expected: policy.contentTypes,
|
|
observed: observedContentType,
|
|
passed: policy.contentTypes.includes(observedMime),
|
|
});
|
|
if (policy.securityHeaders) {
|
|
for (const [header, expected] of Object.entries(securityPolicy.headers)) {
|
|
const observedSecurity = responses[surface]?.[header.toLowerCase()];
|
|
results.push({
|
|
surface,
|
|
header: header.toLowerCase(),
|
|
expected,
|
|
observed: observedSecurity,
|
|
passed: observedSecurity === expected,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
results.push({
|
|
surface: "sourceMap",
|
|
header: "public",
|
|
expected: false,
|
|
observed: publicSourceMaps.length > 0,
|
|
passed:
|
|
cachePolicy.surfaces.sourceMap.public === false &&
|
|
publicSourceMaps.length === 0,
|
|
});
|
|
results.push({
|
|
surface: "serviceWorker",
|
|
header: "enabled",
|
|
expected: false,
|
|
observed: publicServiceWorkers.length > 0,
|
|
passed:
|
|
cachePolicy.surfaces.serviceWorker.enabled === false &&
|
|
publicServiceWorkers.length === 0,
|
|
});
|
|
|
|
const passed = results.every((result) => result.passed);
|
|
await mkdir("artifacts/release", { recursive: true });
|
|
await writeFile(
|
|
"artifacts/release/hosting-headers.json",
|
|
`${JSON.stringify(
|
|
{
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
mode,
|
|
baseUrl: liveTarget?.observedOrigin ?? null,
|
|
providerVerificationRequired: mode !== "live",
|
|
results,
|
|
passed,
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
|
|
if (!passed) {
|
|
process.stderr.write(
|
|
"Hosting cache/content-type/security header verification failed.\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
process.stdout.write(
|
|
`Hosting header contract: PASS (${mode}; live verification ${
|
|
mode === "live" ? "complete" : "required before promotion"
|
|
})\n`,
|
|
);
|