Files
uupl-smart-contract/tools/publish-web-manifest.mjs
T

72 lines
5.0 KiB
JavaScript

import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const requiredFields = ["schemaVersion", "network", "chainId", "deploymentBlock", "token", "proxy", "implementation", "owner", "actors"];
const optionalFields = ["rpcUrl", "explorerBaseUrl"];
const zeroAddress = "0x0000000000000000000000000000000000000000";
const secretMarker = /(?:private[_ -]?key|mnemonic|secret|password|credential|api[_ -]?key)|0x[a-fA-F0-9]{64}/i;
export async function publishManifest({
activePath = resolve(repositoryRoot, "deployments/active.json"),
outputPath = resolve(repositoryRoot, "web/public/deployment.json"),
} = {}) {
let contents;
try { contents = await readFile(activePath, "utf8"); } catch { throw new Error(`active manifest is missing at ${activePath}`); }
let manifest;
try { manifest = JSON.parse(contents); } catch { throw new Error("active manifest contains invalid JSON"); }
validatePublicManifest(manifest);
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(manifest, null, 2)}\n`);
return manifest;
}
export function validatePublicManifest(manifest) {
if (!isRecord(manifest)) throw new Error("manifest must be an object");
for (const field of requiredFields) if (!Object.hasOwn(manifest, field)) throw new Error(`manifest is missing required field ${field}`);
for (const field of Object.keys(manifest)) if (!requiredFields.includes(field) && !optionalFields.includes(field)) throw new Error(`manifest contains unknown field ${field}`);
rejectSecrets(manifest);
if (manifest.schemaVersion !== 1) throw new Error("manifest schemaVersion must be 1");
if (manifest.network !== "anvil" && manifest.network !== "baseSepolia") throw new Error("manifest network is unsupported");
if (manifest.chainId !== (manifest.network === "anvil" ? 31337 : 84532)) throw new Error("manifest chainId does not match network");
if (!Number.isSafeInteger(manifest.deploymentBlock) || manifest.deploymentBlock < 1) throw new Error("manifest deploymentBlock must be at least 1");
for (const field of ["token", "proxy", "implementation", "owner"]) assertAddress(manifest[field], field);
assertActors(manifest.actors);
for (const field of optionalFields) if (Object.hasOwn(manifest, field)) assertPublicUrl(manifest[field], field);
}
function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
function assertAddress(value, field) {
if (typeof value !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(value) || value.toLowerCase() === zeroAddress) throw new Error(`manifest ${field} must be a nonzero address`);
}
function assertActors(value) {
if (!Array.isArray(value) || value.length === 0) throw new Error("manifest actors must be a nonempty array");
const labels = new Set(); const addresses = new Set();
for (const [index, actor] of value.entries()) {
if (!isRecord(actor) || Object.keys(actor).length !== 2 || !Object.hasOwn(actor, "label") || !Object.hasOwn(actor, "address")) throw new Error(`manifest actors[${index}] must contain label and address`);
if (typeof actor.label !== "string" || actor.label.trim() === "" || labels.has(actor.label)) throw new Error("manifest actor labels must be unique nonempty strings");
assertAddress(actor.address, `actors[${index}].address`);
const normalized = actor.address.toLowerCase();
if (addresses.has(normalized)) throw new Error("manifest actors must be unique");
labels.add(actor.label); addresses.add(normalized);
}
}
function assertPublicUrl(value, field) {
if (typeof value !== "string") throw new Error(`manifest ${field} must be a public URL`);
let url; try { url = new URL(value); } catch { throw new Error(`manifest ${field} must be a public URL`); }
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) throw new Error(`manifest ${field} must be a public URL without credentials`);
for (const key of url.searchParams.keys()) if (/(key|token|secret)/i.test(key)) throw new Error(`manifest ${field} must not contain credential query parameters`);
}
function rejectSecrets(value, path = "") {
if (typeof value === "string") { if (secretMarker.test(value)) throw new Error(`manifest contains prohibited secret material at ${path}`); return; }
if (Array.isArray(value)) { value.forEach((item, index) => rejectSecrets(item, `${path}[${index}]`)); return; }
if (!isRecord(value)) return;
for (const [key, nested] of Object.entries(value)) { rejectSecrets(key, path); rejectSecrets(nested, path ? `${path}.${key}` : key); }
}
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
if (process.argv.length !== 2) { console.error("usage: publish-web-manifest.mjs"); process.exitCode = 1; }
else publishManifest().catch((error) => { console.error(error.message); process.exitCode = 1; });
}