feat: bridge chain artifacts to typed web reads
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
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; });
|
||||
}
|
||||
Reference in New Issue
Block a user