From 1ec76281b67991f231c9cc5331ecfc775ac71141 Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Fri, 20 Mar 2026 16:04:33 -0400 Subject: [PATCH] [runtime] preserve custom schema hosts in verifier tests and runtime Why: schema validation tests and custom schema hosts should work without the verifier rewriting non-CommandLayer URLs to HTTPS. Contract impact: none --- runtime/tests/runtime-signing.test.mjs | 112 +++++++++++++++++++++++++ server.mjs | 10 ++- 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/runtime/tests/runtime-signing.test.mjs b/runtime/tests/runtime-signing.test.mjs index 52ff4fe..cc6b92d 100644 --- a/runtime/tests/runtime-signing.test.mjs +++ b/runtime/tests/runtime-signing.test.mjs @@ -76,6 +76,72 @@ async function stop(proc) { if (proc.exitCode === null) proc.kill("SIGKILL"); } +async function startSchemaHost() { + const port = await freePort(); + const receiptSchema = { + $id: `http://127.0.0.1:${port}/schemas/v1.1.0/commons/describe/receipts/describe.receipt.schema.json`, + type: "object", + required: ["status", "x402", "result", "metadata"], + properties: { + status: { const: "success" }, + x402: { + type: "object", + required: ["verb", "version", "entry"], + properties: { + verb: { const: "describe" }, + version: { type: "string" }, + entry: { type: "string" }, + }, + }, + result: { + type: "object", + required: ["description", "bullets", "properties"], + properties: { + description: { type: "string" }, + bullets: { type: "array" }, + properties: { type: "object" }, + }, + }, + metadata: { + type: "object", + required: ["proof", "receipt_id"], + properties: { + receipt_id: { type: "string" }, + proof: { + type: "object", + required: ["alg", "signer_id", "kid", "hash_sha256", "signature_b64", "canonical"], + properties: { + alg: { const: "ed25519-sha256" }, + signer_id: { type: "string" }, + kid: { type: "string" }, + hash_sha256: { type: "string" }, + signature_b64: { type: "string" }, + canonical: { const: "json.sorted_keys.v1" }, + }, + }, + }, + }, + }, + }; + + const server = http.createServer((req, res) => { + if (req.url === "/schemas/v1.1.0/commons/describe/receipts/describe.receipt.schema.json") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(receiptSchema)); + return; + } + + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "not_found" })); + }); + + await new Promise((resolve, reject) => server.listen(port, "127.0.0.1", (err) => (err ? reject(err) : resolve()))); + return { + base: `http://127.0.0.1:${port}`, + close: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))), + }; +} + test("boot fails fast without keys unless DEV_AUTO_KEYS=1", async () => { const res = spawnSync(process.execPath, ["server.mjs"], { cwd: process.cwd(), @@ -148,6 +214,52 @@ test("/verify accepts both wrapped and bare receipts from the production signing } }); +test("schema validation fails on malformed receipt", async () => { + const keys = makeKeys(); + const schemaHost = await startSchemaHost(); + const srv = await startServer({ + VERIFY_SCHEMA_CACHED_ONLY: "0", + SCHEMA_HOST: schemaHost.base, + RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: keys.privatePemB64, + RECEIPT_SIGNING_PUBLIC_KEY_B64: keys.publicRaw32B64, + RECEIPT_SIGNER_ID: "runtime.commandlayer.eth", + }); + + try { + const response = await createDescribeReceipt(srv.base); + const { receipt } = unwrapReceiptResponse(response); + + const validVerifyResp = await fetch(`${srv.base}/verify?schema=1`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(receipt), + }); + const validVerifyJson = await validVerifyResp.json(); + + assert.equal(validVerifyResp.status, 200); + assert.equal(validVerifyJson.checks?.schema_valid, true); + + const malformedReceipt = structuredClone(receipt); + + delete malformedReceipt.result; + + const verifyResp = await fetch(`${srv.base}/verify?schema=1`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(malformedReceipt), + }); + + const verifyJson = await verifyResp.json(); + + assert.equal(verifyResp.status, 200); + assert.equal(verifyJson.checks?.schema_valid, false); + assert.match(JSON.stringify(verifyJson.errors?.schema_errors || []), /required|result/); + } finally { + await stop(srv.proc); + await schemaHost.close(); + } +}); + test("/verify reports a hash/signature failure when a signed production receipt is tampered", async () => { const keys = makeKeys(); const srv = await startServer({ diff --git a/server.mjs b/server.mjs index 3c398cc..4eed4db 100644 --- a/server.mjs +++ b/server.mjs @@ -723,12 +723,14 @@ function cachePrune(map, { ttlMs, maxEntries, tsField } = {}) { function normalizeSchemaFetchUrl(url) { if (!url) return url; let u = String(url); - u = u.replace(/^http:\/\//i, "https://"); - u = u.replace(/^https:\/\/commandlayer\.org/i, "https://www.commandlayer.org"); + + u = u.replace(/^https?:\/\/commandlayer\.org/i, "https://www.commandlayer.org"); u = u.replace(/^https:\/\/www\.commandlayer\.org\/+/, "https://www.commandlayer.org/"); - if (SCHEMA_HOST.startsWith("https://www.commandlayer.org")) { - u = u.replace(/^https:\/\/commandlayer\.org/i, "https://www.commandlayer.org"); + + if (/^http:\/\/(commandlayer\.org|www\.commandlayer\.org)(\/|$)/i.test(u)) { + u = u.replace(/^http:\/\//i, "https://"); } + return u; }