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); if (manifest.network === "baseSepolia") { if (Object.hasOwn(manifest, "rpcUrl") && new URL(manifest.rpcUrl).protocol !== "https:") { throw new Error("manifest Base Sepolia rpcUrl must use HTTPS"); } if (Object.hasOwn(manifest, "explorerBaseUrl") && manifest.explorerBaseUrl !== "https://sepolia.basescan.org") { throw new Error("manifest Base Sepolia explorerBaseUrl must use BaseScan"); } } } 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; }); }