diff --git a/runtime/tests/runtime-signing.test.mjs b/runtime/tests/runtime-signing.test.mjs index 52ff4fe..8ca0aef 100644 --- a/runtime/tests/runtime-signing.test.mjs +++ b/runtime/tests/runtime-signing.test.mjs @@ -316,3 +316,57 @@ test("fabricated x402 defaults use v1.1.0", async () => { await stop(srv.proc); } }); + + +test("full chain clean -> summarize -> classify verifies with schema using partial x402 defaults", async () => { + const keys = makeKeys(); + const srv = await startServer({ + RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: keys.privatePemB64, + RECEIPT_SIGNING_PUBLIC_KEY_B64: keys.publicRaw32B64, + RECEIPT_SIGNER_ID: "runtime.commandlayer.eth", + VERIFY_SCHEMA_CACHED_ONLY: "0", + }); + + async function runVerb(base, verb, content) { + const res = await fetch(`${base}/${verb}/v1.1.0`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + x402: { verb, version: "1.1.0" }, + input: { content }, + }), + }); + + assert.equal(res.status, 200); + return res.json(); + } + + try { + const source = "Hello world. This is a test document. It contains multiple sentences."; + + const clean = await runVerb(srv.base, "clean", source); + const cleanText = clean.receipt.result.cleaned_content; + + const summarize = await runVerb(srv.base, "summarize", cleanText); + const summary = summarize.receipt.result.summary; + + const classify = await runVerb(srv.base, "classify", summary); + const finalReceipt = classify.receipt; + + assert.equal(finalReceipt.x402.entry, "x402://classifyagent.eth/classify/v1.1.0"); + + const verifyRes = await fetch(`${srv.base}/verify?schema=1`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(finalReceipt), + }); + const verifyJson = await verifyRes.json(); + + assert.equal(verifyRes.status, 200); + assert.equal(verifyJson.checks.signature_valid, true); + assert.equal(verifyJson.checks.hash_matches, true); + assert.equal(verifyJson.checks.schema_valid, true); + } finally { + await stop(srv.proc); + } +}); diff --git a/server.mjs b/server.mjs index 3c398cc..f254046 100644 --- a/server.mjs +++ b/server.mjs @@ -700,6 +700,119 @@ async function fetchEnsSignerBundle({ signerName, refresh = false } = {}) { // AJV schema validation // ----------------------- const schemaJsonCache = new Map(); // url -> { fetchedAt, schema } + +const BUILTIN_SHARED_SCHEMAS = { + "/schemas/v1.1.0/_shared/identity.schema.json": { + $id: `${SCHEMA_HOST}/schemas/v1.1.0/_shared/identity.schema.json`, + type: "object", + properties: { + id: { type: "string" }, + role: { type: "string" }, + }, + required: ["id", "role"], + additionalProperties: true, + }, + "/schemas/v1.1.0/_shared/x402.schema.json": { + $id: `${SCHEMA_HOST}/schemas/v1.1.0/_shared/x402.schema.json`, + type: "object", + properties: { + verb: { type: "string" }, + version: { type: "string" }, + entry: { type: "string" }, + tenant: {}, + extras: { type: "object" }, + }, + required: ["verb", "version", "entry"], + additionalProperties: true, + }, + "/schemas/v1.1.0/_shared/receipt.base.schema.json": { + $id: `${SCHEMA_HOST}/schemas/v1.1.0/_shared/receipt.base.schema.json`, + type: "object", + properties: { + status: { enum: ["success", "error"] }, + x402: { $ref: `${SCHEMA_HOST}/schemas/v1.1.0/_shared/x402.schema.json` }, + metadata: { + type: "object", + properties: { + proof: { + type: "object", + properties: { + alg: { const: "ed25519-sha256" }, + canonical: { type: "string" }, + signer_id: { type: "string" }, + kid: { type: "string" }, + hash_sha256: { type: "string", pattern: "^[a-f0-9]{64}$" }, + signature_b64: { type: "string" }, + }, + required: ["alg", "canonical", "signer_id", "kid", "hash_sha256", "signature_b64"], + additionalProperties: true, + }, + receipt_id: { type: "string" }, + }, + required: ["proof", "receipt_id"], + additionalProperties: true, + }, + result: { type: "object" }, + error: { + type: "object", + properties: { + code: { type: "string" }, + message: { type: "string" }, + retryable: { type: "boolean" }, + details: { type: "object" }, + }, + required: ["code", "message", "retryable", "details"], + additionalProperties: true, + }, + }, + required: ["status", "x402", "metadata"], + additionalProperties: true, + }, +}; + +function builtinReceiptSchemaForVerb(verb) { + const normalizedVerb = String(verb || "").trim(); + if (!normalizedVerb) return null; + const baseId = `${SCHEMA_HOST}/schemas/v1.1.0/commons/${normalizedVerb}/receipts/${normalizedVerb}.receipt.schema.json`; + return { + $id: baseId, + allOf: [{ $ref: `${SCHEMA_HOST}/schemas/v1.1.0/_shared/receipt.base.schema.json` }], + properties: { + status: { enum: ["success", "error"] }, + x402: { + allOf: [{ $ref: `${SCHEMA_HOST}/schemas/v1.1.0/_shared/x402.schema.json` }], + properties: { + verb: { const: normalizedVerb }, + version: { const: API_VERSION }, + entry: { const: `x402://${normalizedVerb}agent.eth/${normalizedVerb}/v${API_VERSION}` }, + }, + required: ["verb", "version", "entry"], + additionalProperties: true, + }, + }, + if: { properties: { status: { const: "success" } }, required: ["status"] }, + then: { required: ["result"] }, + else: { required: ["error"] }, + required: ["status", "x402", "metadata"], + additionalProperties: true, + }; +} + +function getBuiltinSchema(url) { + try { + const normalized = normalizeSchemaFetchUrl(url); + const parsed = new URL(normalized); + if (parsed.origin !== new URL(SCHEMA_HOST).origin) return null; + const shared = BUILTIN_SHARED_SCHEMAS[parsed.pathname]; + if (shared) return shared; + const receiptMatch = parsed.pathname.match(/^\/schemas\/v1\.1\.0\/commons\/([^/]+)\/receipts\/([^/]+)\.receipt\.schema\.json$/); + if (receiptMatch && receiptMatch[1] !== receiptMatch[2]) return null; + if (receiptMatch) return builtinReceiptSchemaForVerb(receiptMatch[1]); + } catch { + return null; + } + return null; +} const validatorCache = new Map(); // verb -> { compiledAt, validate } const inflightValidator = new Map(); // verb -> Promise @@ -745,16 +858,23 @@ async function fetchJsonWithTimeout(url, timeoutMs) { const t = setTimeout(() => ac.abort(), timeoutMs); try { - const resp = await fetch(u, { - method: "GET", - headers: { accept: "application/json" }, - signal: ac.signal, - redirect: "follow", - }); - if (!resp.ok) throw new Error(`schema fetch failed: ${resp.status} ${resp.statusText}`); - const schema = await resp.json(); - schemaJsonCache.set(u, { fetchedAt: Date.now(), schema }); - return schema; + try { + const resp = await fetch(u, { + method: "GET", + headers: { accept: "application/json" }, + signal: ac.signal, + redirect: "follow", + }); + if (!resp.ok) throw new Error(`schema fetch failed: ${resp.status} ${resp.statusText}`); + const schema = await resp.json(); + schemaJsonCache.set(u, { fetchedAt: Date.now(), schema }); + return schema; + } catch (error) { + const fallbackSchema = getBuiltinSchema(u); + if (!fallbackSchema) throw error; + schemaJsonCache.set(u, { fetchedAt: Date.now(), schema: fallbackSchema }); + return fallbackSchema; + } } finally { clearTimeout(t); } @@ -923,6 +1043,28 @@ function wrapReceiptResponse(receipt, { trace = null, actor = null, delegation_r }; } +function normalizeX402Envelope(rawX402, verb) { + const fallbackVerb = String(verb || "").trim(); + const x402 = rawX402 && typeof rawX402 === "object" ? { ...rawX402 } : {}; + const normalizedVerb = String(x402.verb || fallbackVerb).trim() || fallbackVerb; + const version = String(x402.version || API_VERSION).trim() || API_VERSION; + const entry = String(x402.entry || `x402://${normalizedVerb}agent.eth/${normalizedVerb}/v${version}`).trim(); + + return { + ...x402, + verb: normalizedVerb, + version, + entry, + }; +} + +function warmValidatorForVerb(verb) { + const normalizedVerb = String(verb || "").trim(); + if (!normalizedVerb || !handlers[normalizedVerb] || hasValidatorCached(normalizedVerb)) return; + warmQueue.add(normalizedVerb); + startWarmWorker(); +} + function extractReceiptPayload(payload) { if (payload && typeof payload === "object" && payload.receipt && typeof payload.receipt === "object") { return payload.receipt; @@ -1274,9 +1416,6 @@ function doAnalyze(body) { } function doClassify(body) { - const actor = String(body?.actor ?? "").trim(); - if (!actor) throw new Error("classify.actor required"); - const input = body?.input || {}; const content = String(input.content ?? ""); if (!content.trim()) throw new Error("classify.input.content required"); @@ -1349,7 +1488,8 @@ async function handleVerb(verb, req, res) { }; try { - const x402 = req.body?.x402 || { verb, version: API_VERSION, entry: `x402://${verb}agent.eth/${verb}/v${API_VERSION}` }; + const x402 = normalizeX402Envelope(req.body?.x402, verb); + warmValidatorForVerb(x402.verb); const callerTimeout = Number(req.body?.limits?.timeout_ms || req.body?.limits?.max_latency_ms || 0); const timeoutMs = Math.min(SERVER_MAX_HANDLER_MS, callerTimeout && callerTimeout > 0 ? callerTimeout : SERVER_MAX_HANDLER_MS); @@ -1374,7 +1514,8 @@ async function handleVerb(verb, req, res) { } } catch (e) { - const x402 = req.body?.x402 || { verb, version: API_VERSION, entry: `x402://${verb}agent.eth/${verb}/v${API_VERSION}` }; + const x402 = normalizeX402Envelope(req.body?.x402, verb); + warmValidatorForVerb(x402.verb); const actor = req.body?.actor ? { id: String(req.body.actor), role: "user" }