From b246124fc8c6c587c39c2a6d2451550a090f601e Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Wed, 18 Mar 2026 16:13:02 -0400 Subject: [PATCH] [runtime] wrap canonical receipts in runtime metadata envelope Why: keep the signed Commons receipt verifiable while moving runtime-only trace and actor context out of the canonical payload. Contract impact: verb responses now return { receipt, runtime_metadata } and /verify accepts either wrapped or bare receipts. --- README.md | 40 ++++++++++--- runtime/tests/runtime-signing.test.mjs | 26 +++++++-- scripts/smoke.mjs | 9 +-- server.mjs | 77 ++++++++++---------------- tests/fixtures/golden.public.pem | 2 +- tests/fixtures/golden.receipt.json | 60 ++++++++++---------- tests/fixtures/make-golden.mjs | 12 +++- tests/golden.mjs | 3 +- tests/smoke.mjs | 14 ++++- 9 files changed, 143 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index c869d66..cd8289c 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ Reference Node.js runtime for CommandLayer Commons verbs. This service executes ## What this service does - Exposes `POST //v1.0.0` endpoints for Commons verbs (`fetch`, `describe`, `format`, `clean`, `parse`, `summarize`, `convert`, `explain`, `analyze`, `classify`). -- Returns signed receipts containing: - - deterministic result payloads, - - execution trace metadata, - - proof metadata (`alg`, canonical mode, SHA-256 hash, signature). +- Returns wrapper responses with: + - `receipt`: the signed Commons-compatible canonical receipt, + - `runtime_metadata`: optional runtime-only context such as trace and actor data, + - proof metadata (`alg`, canonical mode, SHA-256 hash, signature) kept inside `receipt.metadata.proof`. - Exposes `POST /verify` to verify receipt hash/signature, and optionally validate schema + fetch public key from ENS. - Includes schema validator caching, warmup queueing, SSRF protections for `fetch`, and runtime safety budgets. @@ -18,7 +18,7 @@ Reference Node.js runtime for CommandLayer Commons verbs. This service executes - `GET /` — service index with links and enabled verbs. - `GET /health` — process/service health and signer readiness. -- `POST //v1.0.0` — execute a single verb and return a signed receipt. +- `POST //v1.0.0` — execute a single verb and return a wrapper containing a signed receipt plus optional runtime metadata. - `POST /verify` — verify receipt integrity/signature; optional schema and ENS verification. ### Debug routes @@ -68,7 +68,7 @@ You should see `"ok": true` and `"signer_ok": true`. ## Example flow -### Request a fetch receipt +### Request a fetch receipt wrapper ```bash RECEIPT=$(curl -s -X POST "http://localhost:8080/fetch/v1.0.0" \ @@ -83,6 +83,9 @@ RECEIPT=$(curl -s -X POST "http://localhost:8080/fetch/v1.0.0" \ }') printf '%s\n' "$RECEIPT" | jq . + +# canonical receipt only +printf '%s\n' "$RECEIPT" | jq '.receipt' ``` ### Verify the receipt locally @@ -101,14 +104,33 @@ printf '%s' "$RECEIPT" | curl -s -X POST "http://localhost:8080/verify?ens=1" \ -d @- | jq . ``` +## Response boundary + +Verb endpoints now return: + +```json +{ + "receipt": { "...": "signed canonical receipt" }, + "runtime_metadata": { + "trace": { "...": "optional" }, + "actor": { "...": "optional" }, + "delegation_result": { "...": "optional" } + } +} +``` + +Treat `receipt` as the verifiable payload. `runtime_metadata` is runtime-only context and is not part of the signed canonical receipt. + +`POST /verify` accepts either a bare canonical receipt or the wrapped response above and will extract `.receipt` automatically when present. + ## Verification semantics `POST /verify` supports query flags: - `ens=1` — fetch verifier pubkey from ENS TXT records (`cl.sig.pub`, `cl.sig.canonical`, optional `cl.sig.kid`). -- `strict_kid=1` — when `ens=1` and `cl.sig.kid` exists, require receipt `metadata.proof.kid` to match ENS `cl.sig.kid`. +- `strict_kid=1` — when `ens=1` and `cl.sig.kid` exists, require canonical receipt `metadata.proof.kid` to match ENS `cl.sig.kid`. - `refresh=1` — bypass ENS cache and refresh lookup. -- `schema=1` — validate receipt against verb schema. +- `schema=1` — validate the canonical receipt against the verb receipt schema. When `VERIFY_SCHEMA_CACHED_ONLY=1` (default), schema validation is edge-safe: @@ -149,7 +171,7 @@ See [`docs/OPERATIONS.md`](docs/OPERATIONS.md) for deployment and runbook guidan ```bash # mint -curl -s -X POST http://localhost:8080/describe/v1.0.0 -H "Content-Type: application/json" -d '{"x402":{"verb":"describe","version":"1.0.0","entry":"x402://describeagent.eth/describe/v1.0.0"},"input":{"subject":"CommandLayer","detail_level":"short"}}' | tee receipt.json | jq '.metadata.proof | {kid, canonical_id, hash_sha256, signature_b64}' +curl -s -X POST http://localhost:8080/describe/v1.0.0 -H "Content-Type: application/json" -d '{"x402":{"verb":"describe","version":"1.0.0","entry":"x402://describeagent.eth/describe/v1.0.0"},"input":{"subject":"CommandLayer","detail_level":"short"}}' | tee receipt.json | jq '.receipt.metadata.proof | {kid, canonical_id, hash_sha256, signature_b64}' # verify env curl -s -X POST http://localhost:8080/verify -H "Content-Type: application/json" --data-binary @receipt.json | jq . diff --git a/runtime/tests/runtime-signing.test.mjs b/runtime/tests/runtime-signing.test.mjs index 7b1adec..107794d 100644 --- a/runtime/tests/runtime-signing.test.mjs +++ b/runtime/tests/runtime-signing.test.mjs @@ -49,6 +49,11 @@ async function startServer(extraEnv) { throw new Error(`server did not boot: ${stderr}`); } + +function unwrapReceiptResponse(payload) { + return { receipt: payload?.receipt || payload, runtimeMetadata: payload?.runtime_metadata || null }; +} + async function stop(proc) { if (proc.exitCode !== null) return; proc.kill("SIGTERM"); @@ -91,7 +96,9 @@ test("private PEM_B64 + public raw32 b64 path signs and /verify roundtrip works" body: JSON.stringify(body), }); assert.equal(receiptResp.status, 200); - const receipt = await receiptResp.json(); + const response = await receiptResp.json(); + const { receipt, runtimeMetadata } = unwrapReceiptResponse(response); + assert.equal(runtimeMetadata?.trace?.provider, "runtime"); assert.ok(receipt.metadata?.proof?.signature_b64); assert.ok(receipt.metadata?.proof?.hash_sha256); assert.equal(receipt.metadata?.proof?.signer_id, "runtime.commandlayer.eth"); @@ -100,12 +107,22 @@ test("private PEM_B64 + public raw32 b64 path signs and /verify roundtrip works" const verifyResp = await fetch(`${srv.base}/verify`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(receipt), + body: JSON.stringify(response), }); const verifyJson = await verifyResp.json(); assert.equal(verifyResp.status, 200); assert.equal(verifyJson.ok, true); assert.equal(verifyJson.verified_with, "env"); + + const verifyBareResp = await fetch(`${srv.base}/verify`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(receipt), + }); + const verifyBareJson = await verifyBareResp.json(); + assert.equal(verifyBareResp.status, 200); + assert.equal(verifyBareJson.ok, true); + assert.equal(verifyBareJson.verified_with, "env"); } finally { await stop(srv.proc); } @@ -131,18 +148,19 @@ test("/verify?ens=1 passes with mocked ENS TXT response", async () => { input: { subject: "t", detail_level: "short" }, trace: { provider: "test" }, }; - const receipt = await ( + const response = await ( await fetch(`${srv.base}/describe/v1.0.0`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }) ).json(); + const { receipt } = unwrapReceiptResponse(response); const verifyResp = await fetch(`${srv.base}/verify?ens=1`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(receipt), + body: JSON.stringify(response), }); const verifyJson = await verifyResp.json(); assert.equal(verifyResp.status, 200); diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 024acdc..48e5a40 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -23,16 +23,17 @@ try { headers: { "content-type": "application/json" }, body: JSON.stringify(input), }); - const receipt = await describeResp.json(); - if (!describeResp.ok) fail("/describe/v1.0.0 http", receipt); + const response = await describeResp.json(); + const receipt = response?.receipt || response; + if (!describeResp.ok) fail("/describe/v1.0.0 http", response); if (!receipt?.metadata?.proof?.hash_sha256 || !receipt?.metadata?.proof?.signature_b64) { - fail("describe proof fields", receipt?.metadata?.proof || receipt); + fail("describe proof fields", receipt?.metadata?.proof || response); } const verifyResp = await fetch(`${base}/verify?schema=0&ens=0`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(receipt), + body: JSON.stringify(response), }); const verify = await verifyResp.json(); if (!verifyResp.ok) fail("/verify http", verify); diff --git a/server.mjs b/server.mjs index 8086118..a6830e0 100644 --- a/server.mjs +++ b/server.mjs @@ -872,12 +872,10 @@ function startWarmWorker() { // ----------------------- // receipts (runtime-core: single source of truth) // ----------------------- -function makeReceipt({ x402, trace, result, status = "success", error = null, delegation_result = null, actor = null }) { +function makeReceipt({ x402, result, status = "success", error = null }) { let receipt = { status, x402, - trace, - ...(delegation_result ? { delegation_result } : {}), ...(error ? { error } : {}), ...(status === "success" ? { result } : {}), metadata: { @@ -893,8 +891,6 @@ function makeReceipt({ x402, trace, result, status = "success", error = null, de }, }; - if (actor) receipt.metadata.actor = actor; - const privPem = getActivePrivatePem(); if (!privPem) throw new Error("Missing/invalid private key"); @@ -914,8 +910,28 @@ function makeReceipt({ x402, trace, result, status = "success", error = null, de return receipt; } -function normalizeReceiptForRuntimeCoreVerify(receipt) { - const cloned = JSON.parse(JSON.stringify(receipt || {})); +function wrapReceiptResponse(receipt, { trace = null, actor = null, delegation_result = null } = {}) { + const runtime_metadata = { + ...(trace ? { trace } : {}), + ...(actor ? { actor } : {}), + ...(delegation_result ? { delegation_result } : {}), + }; + + return { + receipt, + ...(Object.keys(runtime_metadata).length ? { runtime_metadata } : {}), + }; +} + +function extractReceiptPayload(payload) { + if (payload && typeof payload === "object" && payload.receipt && typeof payload.receipt === "object") { + return payload.receipt; + } + return payload; +} + +function normalizeReceiptForRuntimeCoreVerify(payload) { + const cloned = JSON.parse(JSON.stringify(extractReceiptPayload(payload) || {})); if (cloned?.metadata?.proof && typeof cloned.metadata.proof === "object") { const proof = cloned.metadata.proof; if (!proof.canonical && proof.canonical_id) proof.canonical = proof.canonical_id; @@ -1351,18 +1367,12 @@ async function handleVerb(verb, req, res) { try { - const receipt = makeReceipt({ x402, trace, result, status: "success", actor }); - return res.json(receipt); + const receipt = makeReceipt({ x402, result, status: "success" }); + return res.json(wrapReceiptResponse(receipt, { trace, actor })); } catch (signErr) { return respondSigningError(res, signErr); } - - const receipt = makeReceipt({ x402, trace, result, status: "success", actor }); - - -return res.json(receipt); - } catch (e) { const x402 = req.body?.x402 || { verb, version: "1.0.0", entry: `x402://${verb}agent.eth/${verb}/v1.0.0` }; @@ -1382,40 +1392,12 @@ return res.json(receipt); try { - const receipt = makeReceipt({ x402, trace, status: "error", error: err, actor }); - return res.status(500).json(receipt); + const receipt = makeReceipt({ x402, status: "error", error: err }); + return res.status(500).json(wrapReceiptResponse(receipt, { trace, actor })); } catch (signErr) { return respondSigningError(res, signErr); } - - let receipt; - try { - receipt = makeReceipt({ x402, trace, status: "error", error: err, actor }); - } catch (e2) { - receipt = { - status: "error", - x402, - trace, - error: err, - metadata: { - proof: { - alg: "ed25519-sha256", - canonical: CANONICAL_ID_SORTED_KEYS_V1, - signer_id: SIGNER_ID, - kid: SIGNER_KID, - hash_sha256: null, - signature_b64: null, - note: "unsigned_error_receipt", - }, - receipt_id: "", - ...(actor ? { actor } : {}), - }, - }; - } - - return res.status(500).json(receipt); - } } @@ -1601,7 +1583,8 @@ app.post("/debug/prewarm", requireDebug, (req, res) => { // verify endpoint (signature/hash + optional schema + optional ENS binding) // ----------------------- app.post("/verify", async (req, res) => { - const receipt = req.body; + const verifyInput = req.body; + const receipt = extractReceiptPayload(verifyInput); const wantEns = String(req.query.ens || "0") === "1"; const strictKid = String(req.query.strict_kid || "0") === "1"; @@ -1775,7 +1758,7 @@ app.post("/verify", async (req, res) => { schemaOk = false; schemaErrors = [{ message: "validator_missing" }]; } else { - const ok = validate(receipt); + const ok = validate(runtimeCoreReceipt); schemaOk = !!ok; if (!ok) schemaErrors = ajvErrorsToSimple(validate.errors) || [{ message: "schema validation failed" }]; } diff --git a/tests/fixtures/golden.public.pem b/tests/fixtures/golden.public.pem index 2f22a37..ccecc90 100644 --- a/tests/fixtures/golden.public.pem +++ b/tests/fixtures/golden.public.pem @@ -1,3 +1,3 @@ -----BEGIN PUBLIC KEY----- -MCowBQYDK2VwAyEAsYjUeG5oO/BTxwyd7U+BPcuwp3dBu51AAiv3DQNczv8= +MCowBQYDK2VwAyEAIL4DH52qyRXv6DYEA253pjohH/l6Slr1cmtP/uJYGnQ= -----END PUBLIC KEY----- diff --git a/tests/fixtures/golden.receipt.json b/tests/fixtures/golden.receipt.json index e3da1b1..1a48ab8 100644 --- a/tests/fixtures/golden.receipt.json +++ b/tests/fixtures/golden.receipt.json @@ -1,34 +1,38 @@ { - "status": "success", - "x402": { - "verb": "describe", - "version": "1.0.0", - "entry": "x402://describeagent.eth/describe/v1.0.0" - }, - "trace": { - "provider": "golden" - }, - "result": { - "description": "golden", - "bullets": [ - "a", - "b", - "c" - ], - "properties": { + "receipt": { + "status": "success", + "x402": { "verb": "describe", - "version": "1.0.0" + "version": "1.0.0", + "entry": "x402://describeagent.eth/describe/v1.0.0" + }, + "result": { + "description": "golden", + "bullets": [ + "a", + "b", + "c" + ], + "properties": { + "verb": "describe", + "version": "1.0.0" + } + }, + "metadata": { + "proof": { + "alg": "ed25519-sha256", + "canonical": "json.sorted_keys.v1", + "signer_id": "runtime.commandlayer.eth", + "kid": "v1", + "hash_sha256": "8deae11dc363a4d43164a5e6778cc09ca92079b739185f261c656bb4852b7d96", + "signature_b64": "l8rytZcttuB/McrYrN8/oHZH9ifVnP0teDa8fgWGGwtUq5h9i2wZdU1qW9J0+rseHwzgX1eFIA1AtPzjVkW5BQ==" + }, + "receipt_id": "8deae11dc363a4d43164a5e6778cc09ca92079b739185f261c656bb4852b7d96" } }, - "metadata": { - "proof": { - "alg": "ed25519-sha256", - "canonical": "json.sorted_keys.v1", - "signer_id": "runtime.commandlayer.eth", - "kid": "v1", - "hash_sha256": "b3294386df7b67d507b2995fafa836845bcfae42bea43ccdfe9b73e310ee61ec", - "signature_b64": "60tVtjnMg77ZSA8TWN2TV1qj8B8uV3fHaHNVCC/JscX+wvatwpPMr08F8BWC2MM/81rAGxlZAMTYphQMfQCpCA==" - }, - "receipt_id": "b3294386df7b67d507b2995fafa836845bcfae42bea43ccdfe9b73e310ee61ec" + "runtime_metadata": { + "trace": { + "provider": "golden" + } } } diff --git a/tests/fixtures/make-golden.mjs b/tests/fixtures/make-golden.mjs index e92a8e3..748ab17 100644 --- a/tests/fixtures/make-golden.mjs +++ b/tests/fixtures/make-golden.mjs @@ -20,8 +20,7 @@ const pubPem = wrapPem(pubDer.toString("base64"), "-----BEGIN PUBLIC KEY-----", const receiptUnsigned = { status: "success", x402: { verb: "describe", version: "1.0.0", entry: "x402://describeagent.eth/describe/v1.0.0" }, - trace: { provider: "golden" }, - result: { description: "golden", bullets: ["a","b","c"], properties: { verb: "describe", version: "1.0.0" } }, + result: { description: "golden", bullets: ["a", "b", "c"], properties: { verb: "describe", version: "1.0.0" } }, metadata: { proof: { alg: "ed25519-sha256", @@ -52,7 +51,14 @@ if (!v?.ok) { throw new Error("golden receipt verify failed"); } +const wrappedReceipt = { + receipt: receiptSigned, + runtime_metadata: { + trace: { provider: "golden" }, + }, +}; + fs.writeFileSync("tests/fixtures/golden.public.pem", pubPem, "utf8"); -fs.writeFileSync("tests/fixtures/golden.receipt.json", JSON.stringify(receiptSigned, null, 2) + "\n", "utf8"); +fs.writeFileSync("tests/fixtures/golden.receipt.json", JSON.stringify(wrappedReceipt, null, 2) + "\n", "utf8"); console.log("wrote tests/fixtures/golden.public.pem and golden.receipt.json"); diff --git a/tests/golden.mjs b/tests/golden.mjs index 5b92ae3..1c074b7 100644 --- a/tests/golden.mjs +++ b/tests/golden.mjs @@ -2,7 +2,8 @@ import fs from "fs"; import assert from "assert"; import { verifyReceiptEd25519Sha256 } from "@commandlayer/runtime-core"; -const receipt = JSON.parse(fs.readFileSync("tests/fixtures/golden.receipt.json", "utf8")); +const wrapped = JSON.parse(fs.readFileSync("tests/fixtures/golden.receipt.json", "utf8")); +const receipt = wrapped.receipt || wrapped; const pubPem = fs.readFileSync("tests/fixtures/golden.public.pem", "utf8"); const v = verifyReceiptEd25519Sha256(receipt, { diff --git a/tests/smoke.mjs b/tests/smoke.mjs index ed81d99..67afe6b 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -38,6 +38,13 @@ async function httpJson(url, { method = "GET", headers = {}, body } = {}) { return { status: res.status, ok: res.ok, json }; } + +function extractReceiptEnvelope(body) { + const receipt = body?.receipt || body; + const runtimeMetadata = body?.runtime_metadata || null; + return { receipt, runtimeMetadata }; +} + function assertReceiptInvariants(receipt) { if (!receipt || typeof receipt !== "object") throw new Error("receipt must be object"); const proof = receipt?.metadata?.proof || {}; @@ -209,13 +216,14 @@ async function main() { const describe = await httpJson(`${baseUrl}/describe/v1.0.0`, { method: "POST", body: describeBody }); assert.equal(describe.status, 200, `describe failed: ${JSON.stringify(describe.json)}`); - assert.equal(describe.json.status, "success", `describe receipt status must be success`); + const { receipt, runtimeMetadata } = extractReceiptEnvelope(describe.json); + assert.equal(receipt.status, "success", `describe receipt status must be success`); + assert.equal(runtimeMetadata?.trace?.provider, "runtime", `runtime_metadata.trace.provider must be runtime`); - const receipt = describe.json; const proof = extractProof(receipt); const verifyUrl = SMOKE_ENS ? `${baseUrl}/verify?ens=1&refresh=1` : `${baseUrl}/verify`; - const verify = await httpJson(verifyUrl, { method: "POST", body: receipt }); + const verify = await httpJson(verifyUrl, { method: "POST", body: describe.json }); assert.equal(verify.status, 200, `verify failed: ${JSON.stringify(verify.json)}`); assert.equal(verify.json.ok, true, `verify ok must be true: ${JSON.stringify(verify.json)}`);