fix: align deployment manifest schema
This commit is contained in:
+66
-25
@@ -8,22 +8,20 @@ export const EDUCATIONAL_WARNING = "Educational demo — mock token — never us
|
||||
|
||||
const NETWORKS = {
|
||||
anvil: {
|
||||
name: "anvil", chainId: 31337, canonical: "anvil.json", recovery: "make reset-local", rpcUrl: "http://127.0.0.1:8545", explorerUrl: "",
|
||||
name: "anvil", chainId: 31337, canonical: "anvil.json", recovery: "make reset-local", rpcUrl: "http://127.0.0.1:8545",
|
||||
},
|
||||
"base-sepolia": {
|
||||
name: "base-sepolia", chainId: 84532, canonical: "base-sepolia.json", recovery: "make archive-base-manifest", rpcUrl: "https://sepolia.base.org", explorerUrl: "https://sepolia.basescan.org",
|
||||
baseSepolia: {
|
||||
name: "baseSepolia", chainId: 84532, canonical: "base-sepolia.json", recovery: "make archive-base-manifest",
|
||||
},
|
||||
};
|
||||
|
||||
const MANIFEST_FIELDS = [
|
||||
"schemaVersion", "network", "chainId", "deploymentBlock", "rpcUrl", "explorerUrl", "token", "proxy", "implementation", "owner", "actorLabels", "actors",
|
||||
];
|
||||
const REQUIRED_MANIFEST_FIELDS = ["schemaVersion", "network", "chainId", "deploymentBlock", "token", "proxy", "implementation", "owner", "actors"];
|
||||
const OPTIONAL_MANIFEST_FIELDS = ["rpcUrl", "explorerBaseUrl"];
|
||||
const ANVIL_TEST_PHRASE = "test test test test test test test test test test test junk";
|
||||
const PROHIBITED_STRING_VALUE = /(?:private[_ -]?key|mnemonic|secret|password|credential|api[_ -]?key)|0x[a-fA-F0-9]{64}/i;
|
||||
|
||||
export function networkSpec(network) {
|
||||
const normalized = network === "baseSepolia" ? "base-sepolia" : network;
|
||||
const spec = NETWORKS[normalized];
|
||||
const spec = NETWORKS[network];
|
||||
if (!spec) throw new Error(`unsupported deployment network: ${network}`);
|
||||
return spec;
|
||||
}
|
||||
@@ -106,24 +104,26 @@ export function validateManifest(manifest, { pending = false } = {}) {
|
||||
if (!Number.isSafeInteger(manifest.deploymentBlock) || manifest.deploymentBlock < (pending ? 0 : 1)) {
|
||||
throw new Error(`manifest deploymentBlock must be ${pending ? "a nonnegative integer" : "at least 1"}`);
|
||||
}
|
||||
if (manifest.rpcUrl !== spec.rpcUrl || manifest.explorerUrl !== spec.explorerUrl) {
|
||||
throw new Error(`manifest display URLs must use the public ${manifest.network} endpoints`);
|
||||
}
|
||||
assertManifestUrls(manifest, spec);
|
||||
for (const field of ["token", "proxy", "implementation", "owner"]) assertAddress(manifest[field], field);
|
||||
if (!Array.isArray(manifest.actorLabels) || !Array.isArray(manifest.actors) || manifest.actorLabels.length !== manifest.actors.length || manifest.actors.length === 0) {
|
||||
throw new Error("manifest actors and actorLabels must be nonempty parallel arrays");
|
||||
}
|
||||
if (!Array.isArray(manifest.actors) || manifest.actors.length === 0) throw new Error("manifest actors must be a nonempty array");
|
||||
const labels = new Set();
|
||||
const actors = new Set();
|
||||
for (let index = 0; index < manifest.actors.length; index += 1) {
|
||||
const label = manifest.actorLabels[index];
|
||||
for (const [index, actorRecord] of manifest.actors.entries()) {
|
||||
if (!actorRecord || typeof actorRecord !== "object" || Array.isArray(actorRecord)) throw new Error(`actors[${index}] must be an object`);
|
||||
const keys = Object.keys(actorRecord);
|
||||
if (keys.length !== 2 || !Object.hasOwn(actorRecord, "label") || !Object.hasOwn(actorRecord, "address")) {
|
||||
throw new Error(`actors[${index}] must contain exactly label and address`);
|
||||
}
|
||||
const { label, address } = actorRecord;
|
||||
if (typeof label !== "string" || label.trim() === "" || labels.has(label)) throw new Error("manifest actor labels must be unique nonempty strings");
|
||||
labels.add(label);
|
||||
assertAddress(manifest.actors[index], `actors[${index}]`);
|
||||
const actor = manifest.actors[index].toLowerCase();
|
||||
assertAddress(address, `actors[${index}].address`);
|
||||
const actor = address.toLowerCase();
|
||||
if (actors.has(actor)) throw new Error("manifest actors must be unique");
|
||||
actors.add(actor);
|
||||
}
|
||||
assertActorConfiguration(manifest);
|
||||
}
|
||||
|
||||
export async function atomicWriteJson(path, value) {
|
||||
@@ -149,14 +149,55 @@ function assertAddress(value, label) {
|
||||
}
|
||||
|
||||
function assertExactSchema(manifest) {
|
||||
for (const field of MANIFEST_FIELDS) {
|
||||
for (const field of REQUIRED_MANIFEST_FIELDS) {
|
||||
if (!Object.hasOwn(manifest, field)) throw new Error(`manifest is missing required field ${field}`);
|
||||
}
|
||||
for (const field of Object.keys(manifest)) {
|
||||
if (!MANIFEST_FIELDS.includes(field)) throw new Error(`manifest contains unknown field ${field}`);
|
||||
if (![...REQUIRED_MANIFEST_FIELDS, ...OPTIONAL_MANIFEST_FIELDS].includes(field)) throw new Error(`manifest contains unknown field ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertManifestUrls(manifest, spec) {
|
||||
if (manifest.network === "anvil") {
|
||||
if (manifest.rpcUrl !== spec.rpcUrl) throw new Error("manifest anvil rpcUrl must use the local public endpoint");
|
||||
if (Object.hasOwn(manifest, "explorerBaseUrl")) throw new Error("manifest anvil must omit explorerBaseUrl");
|
||||
return;
|
||||
}
|
||||
for (const field of OPTIONAL_MANIFEST_FIELDS) {
|
||||
if (Object.hasOwn(manifest, field)) assertPublicUrl(manifest[field], field);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPublicUrl(value, field) {
|
||||
if (typeof value !== "string") throw new Error(`manifest ${field} must be a URL string`);
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`manifest ${field} must be a public URL`);
|
||||
}
|
||||
if ((url.protocol !== "https:" && url.protocol !== "http:") || url.username || url.password) {
|
||||
throw new Error(`manifest ${field} must be a public URL without credentials`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertActorConfiguration(manifest) {
|
||||
const { actors, network, owner } = manifest;
|
||||
if (network === "anvil") {
|
||||
const expected = [
|
||||
["owner", "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"],
|
||||
["Alice", "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"],
|
||||
["Bob", "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"],
|
||||
];
|
||||
if (actors.length !== expected.length || actors.some((actor, index) => actor.label !== expected[index][0] || actor.address.toLowerCase() !== expected[index][1].toLowerCase())) {
|
||||
throw new Error("manifest anvil actors must match the documented local actor configuration");
|
||||
}
|
||||
} else if (actors.length !== 1 || actors[0].label !== "owner") {
|
||||
throw new Error("manifest baseSepolia must contain only the owner actor");
|
||||
}
|
||||
if (actors[0].address.toLowerCase() !== owner.toLowerCase()) throw new Error("manifest owner must be actor zero");
|
||||
}
|
||||
|
||||
function rejectProhibitedStringValues(value, path = "") {
|
||||
if (typeof value === "string") {
|
||||
if (value.includes(ANVIL_TEST_PHRASE) || PROHIBITED_STRING_VALUE.test(value)) {
|
||||
@@ -171,6 +212,7 @@ function rejectProhibitedStringValues(value, path = "") {
|
||||
if (!value || typeof value !== "object") return;
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
const nestedPath = path ? `${path}.${key}` : key;
|
||||
rejectProhibitedStringValues(key, nestedPath);
|
||||
rejectProhibitedStringValues(nested, nestedPath);
|
||||
}
|
||||
}
|
||||
@@ -181,14 +223,13 @@ function publicManifest(manifest, deploymentBlock) {
|
||||
network: manifest.network,
|
||||
chainId: manifest.chainId,
|
||||
deploymentBlock,
|
||||
rpcUrl: manifest.rpcUrl,
|
||||
explorerUrl: manifest.explorerUrl,
|
||||
...(Object.hasOwn(manifest, "rpcUrl") ? { rpcUrl: manifest.rpcUrl } : {}),
|
||||
...(Object.hasOwn(manifest, "explorerBaseUrl") ? { explorerBaseUrl: manifest.explorerBaseUrl } : {}),
|
||||
token: manifest.token,
|
||||
proxy: manifest.proxy,
|
||||
implementation: manifest.implementation,
|
||||
owner: manifest.owner,
|
||||
actorLabels: [...manifest.actorLabels],
|
||||
actors: [...manifest.actors],
|
||||
actors: manifest.actors.map(({ label, address }) => ({ label, address })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -240,7 +281,7 @@ export async function runFinalizeCli(argv, { root = process.cwd(), log = console
|
||||
if (network !== "--rpc-url" || typeof rest[0] !== "string" || rest.length !== 1) throw new Error("usage: finalize-manifest.mjs deploy --rpc-url <url>");
|
||||
return finalizeDeployment({ root, rpc: fetchRpc(rest[0]) });
|
||||
}
|
||||
throw new Error("usage: finalize-manifest.mjs preflight-deploy <anvil|base-sepolia> | deploy --rpc-url <url>");
|
||||
throw new Error("usage: finalize-manifest.mjs preflight-deploy <anvil|baseSepolia> | deploy --rpc-url <url>");
|
||||
}
|
||||
|
||||
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function selectManifest({ root = process.cwd(), network }) {
|
||||
|
||||
export async function runSelectCli(argv, { root = process.cwd(), log = console.log } = {}) {
|
||||
log(EDUCATIONAL_WARNING);
|
||||
if (argv.length !== 1) throw new Error("usage: select-manifest.mjs <anvil|base-sepolia>");
|
||||
if (argv.length !== 1) throw new Error("usage: select-manifest.mjs <anvil|baseSepolia>");
|
||||
return selectManifest({ root, network: argv[0] });
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { atomicWrite, finalizeDeployment, preflightDeploy, runFinalizeCli } from "./finalize-manifest.mjs";
|
||||
import { atomicWrite, finalizeDeployment, preflightDeploy, runFinalizeCli, validateManifest } from "./finalize-manifest.mjs";
|
||||
import { runSelectCli, selectManifest } from "./select-manifest.mjs";
|
||||
|
||||
const TOKEN = "0x1000000000000000000000000000000000000001";
|
||||
@@ -21,9 +21,9 @@ test("preflight rejects an existing target canonical manifest with the safe reco
|
||||
() => preflightDeploy({ root, network: "anvil" }),
|
||||
/make reset-local/
|
||||
);
|
||||
await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "base-sepolia", chainId: 84532 }));
|
||||
await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "baseSepolia", chainId: 84532 }));
|
||||
await assert.rejects(
|
||||
() => preflightDeploy({ root, network: "base-sepolia" }),
|
||||
() => preflightDeploy({ root, network: "baseSepolia" }),
|
||||
/make archive-base-manifest/
|
||||
);
|
||||
});
|
||||
@@ -42,17 +42,38 @@ test("direct manifest CLIs print the exact educational warning", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("finalizer confirms a nested public actor manifest and preserves omitted Base URLs", async () => {
|
||||
await withFixture(async (root) => {
|
||||
const pending = manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 0 });
|
||||
await writeJson(join(root, "deployments", "pending.json"), pending);
|
||||
await writeBroadcast(root, pending);
|
||||
|
||||
const output = await finalizeDeployment({ root, rpc: fakeRpc({ chainId: "0x14a34" }) });
|
||||
assert.deepEqual(output.manifest, { ...pending, deploymentBlock: 42 });
|
||||
assert.equal(Object.hasOwn(output.manifest, "rpcUrl"), false);
|
||||
assert.equal(Object.hasOwn(output.manifest, "explorerBaseUrl"), false);
|
||||
assert.deepEqual(output.manifest.actors, [{ label: "owner", address: OWNER }]);
|
||||
});
|
||||
});
|
||||
|
||||
test("validator rejects the legacy parallel actor and explorer schema", () => {
|
||||
assert.throws(
|
||||
() => validateManifest(legacyManifest()),
|
||||
/unknown field|missing required field/
|
||||
);
|
||||
});
|
||||
|
||||
test("finalizer writes only a receipt-confirmed manifest and preserves active until selection", async () => {
|
||||
await withFixture(async (root) => {
|
||||
const pending = manifest({ deploymentBlock: 0 });
|
||||
await writeJson(join(root, "deployments", "pending.json"), pending);
|
||||
await writeJson(join(root, "deployments", "active.json"), manifest({ network: "base-sepolia", chainId: 84532, deploymentBlock: 88 }));
|
||||
await writeJson(join(root, "deployments", "active.json"), manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 88 }));
|
||||
await writeBroadcast(root, pending, { hash: "0xaaa", contractAddress: PROXY });
|
||||
|
||||
const output = await finalizeDeployment({ root, rpc: fakeRpc() });
|
||||
assert.equal(output.path, join(root, "deployments", "anvil.json"));
|
||||
assert.deepEqual(await readJson(output.path), { ...pending, deploymentBlock: 42 });
|
||||
assert.deepEqual(await readJson(join(root, "deployments", "active.json")), manifest({ network: "base-sepolia", chainId: 84532, deploymentBlock: 88 }));
|
||||
assert.deepEqual(await readJson(join(root, "deployments", "active.json")), manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 88 }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,9 +88,9 @@ test("finalizer rejects invalid receipt, transaction, RPC, code, slot, and secre
|
||||
["missing code", { rpc: fakeRpc({ missingCode: TOKEN }) }, /has no code/],
|
||||
["implementation slot mismatch", { rpc: fakeRpc({ slot: TOKEN }) }, /implementation slot/],
|
||||
["secret-bearing pending manifest", { pending: manifest({ rpcUrl: "https://user:password@example.invalid" }) }, /credential|secret|endpoints/i],
|
||||
["mnemonic in actor label", { pending: manifest({ deploymentBlock: 0, actorLabels: ["owner", "test test test test test test test test test test test junk", "Bob"] }) }, /prohibited/i],
|
||||
["private key in actor label", { pending: manifest({ deploymentBlock: 0, actorLabels: ["owner", "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", "Bob"] }) }, /prohibited/i],
|
||||
["credential in RPC path", { pending: manifest({ deploymentBlock: 0, rpcUrl: "https://sepolia.base.org/v1/secret-token" }) }, /public endpoint|prohibited/i],
|
||||
["mnemonic in actor label", { pending: manifest({ deploymentBlock: 0, actors: localActors("test test test test test test test test test test test junk") }) }, /prohibited/i],
|
||||
["private key in actor label", { pending: manifest({ deploymentBlock: 0, actors: localActors("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") }) }, /prohibited/i],
|
||||
["credential in RPC path", { pending: manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 0, rpcUrl: "https://sepolia.base.org/v1/secret-token" }) }, /public URL|prohibited/i],
|
||||
["unknown manifest field", { pending: manifest({ deploymentBlock: 0, harmlessLookingField: "not allowed" }) }, /unknown field/i],
|
||||
];
|
||||
|
||||
@@ -92,7 +113,7 @@ test("finalizer rejects invalid receipt, transaction, RPC, code, slot, and secre
|
||||
|
||||
test("finalizer failure leaves an initially absent canonical manifest absent", async () => {
|
||||
await withFixture(async (root) => {
|
||||
const pending = manifest({ deploymentBlock: 0, actorLabels: ["owner", "MNEMONIC", "Bob"] });
|
||||
const pending = manifest({ deploymentBlock: 0, actors: localActors("MNEMONIC") });
|
||||
await writeJson(join(root, "deployments", "pending.json"), pending);
|
||||
await writeJson(join(root, "deployments", "active.json"), manifest({ deploymentBlock: 8 }));
|
||||
await writeBroadcast(root, pending);
|
||||
@@ -105,23 +126,23 @@ test("finalizer failure leaves an initially absent canonical manifest absent", a
|
||||
test("selection atomically replaces active with only a valid named canonical manifest", async () => {
|
||||
await withFixture(async (root) => {
|
||||
const anvil = manifest({ deploymentBlock: 31 });
|
||||
const base = manifest({ network: "base-sepolia", chainId: 84532, deploymentBlock: 32 });
|
||||
const base = manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 32 });
|
||||
await writeJson(join(root, "deployments", "anvil.json"), anvil);
|
||||
await writeJson(join(root, "deployments", "base-sepolia.json"), base);
|
||||
|
||||
await selectManifest({ root, network: "anvil" });
|
||||
const anvilBytes = await readFile(join(root, "deployments", "anvil.json"));
|
||||
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), anvilBytes);
|
||||
await selectManifest({ root, network: "base-sepolia" });
|
||||
await selectManifest({ root, network: "baseSepolia" });
|
||||
assert.deepEqual(await readFile(join(root, "deployments", "anvil.json")), anvilBytes);
|
||||
assert.deepEqual(await readJson(join(root, "deployments", "active.json")), base);
|
||||
const baseBytes = await readFile(join(root, "deployments", "base-sepolia.json"));
|
||||
await selectManifest({ root, network: "anvil" });
|
||||
assert.deepEqual(await readFile(join(root, "deployments", "base-sepolia.json")), baseBytes);
|
||||
|
||||
await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "base-sepolia", chainId: 84532, deploymentBlock: 0 }));
|
||||
await writeJson(join(root, "deployments", "base-sepolia.json"), manifest({ network: "baseSepolia", chainId: 84532, deploymentBlock: 0 }));
|
||||
const beforeActive = await readFile(join(root, "deployments", "active.json"));
|
||||
await assert.rejects(() => selectManifest({ root, network: "base-sepolia" }), /deploymentBlock/);
|
||||
await assert.rejects(() => selectManifest({ root, network: "baseSepolia" }), /deploymentBlock/);
|
||||
assert.deepEqual(await readFile(join(root, "deployments", "active.json")), beforeActive);
|
||||
});
|
||||
});
|
||||
@@ -157,14 +178,30 @@ test("atomic writes remove the ignored staging file when rename fails", async ()
|
||||
});
|
||||
|
||||
function manifest(overrides = {}) {
|
||||
const baseSepolia = overrides.network === "base-sepolia";
|
||||
const baseSepolia = overrides.network === "baseSepolia";
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
network: baseSepolia ? "base-sepolia" : "anvil",
|
||||
network: baseSepolia ? "baseSepolia" : "anvil",
|
||||
chainId: baseSepolia ? 84532 : 31337,
|
||||
deploymentBlock: 1,
|
||||
rpcUrl: baseSepolia ? "https://sepolia.base.org" : "http://127.0.0.1:8545",
|
||||
explorerUrl: baseSepolia ? "https://sepolia.basescan.org" : "",
|
||||
...(baseSepolia ? {} : { rpcUrl: "http://127.0.0.1:8545" }),
|
||||
token: TOKEN,
|
||||
proxy: PROXY,
|
||||
implementation: IMPLEMENTATION,
|
||||
owner: OWNER,
|
||||
actors: baseSepolia ? [{ label: "owner", address: OWNER }] : localActors(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function legacyManifest(overrides = {}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
network: "anvil",
|
||||
chainId: 31337,
|
||||
deploymentBlock: 1,
|
||||
rpcUrl: "http://127.0.0.1:8545",
|
||||
explorerUrl: "",
|
||||
token: TOKEN,
|
||||
proxy: PROXY,
|
||||
implementation: IMPLEMENTATION,
|
||||
@@ -175,6 +212,14 @@ function manifest(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function localActors(aliceLabel = "Alice") {
|
||||
return [
|
||||
{ label: "owner", address: OWNER },
|
||||
{ label: aliceLabel, address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" },
|
||||
{ label: "Bob", address: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" },
|
||||
];
|
||||
}
|
||||
|
||||
async function withFixture(fn) {
|
||||
const root = await mkdtemp(join(tmpdir(), "uups-finalizer-"));
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user