Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions runtime/tests/runtime-signing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
171 changes: 156 additions & 15 deletions server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<validate>

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand All @@ -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" }
Expand Down
Loading