diff --git a/runtime/tests/versioned-routes.test.mjs b/runtime/tests/versioned-routes.test.mjs new file mode 100644 index 0000000..885d3da --- /dev/null +++ b/runtime/tests/versioned-routes.test.mjs @@ -0,0 +1,123 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import net from "node:net"; +import { generateKeyPairSync, createHash } from "node:crypto"; + +function freePort() { + return new Promise((resolve, reject) => { + const s = net.createServer(); + s.on("error", reject); + s.listen(0, "127.0.0.1", () => { + const addr = s.address(); + s.close(() => resolve(addr.port)); + }); + }); +} + +function makeKeys() { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const privatePem = privateKey.export({ type: "pkcs8", format: "pem" }); + const privatePemB64 = Buffer.from(String(privatePem), "utf8").toString("base64"); + const spki = publicKey.export({ type: "spki", format: "der" }); + const raw32 = Buffer.from(spki).subarray(spki.length - 32); + return { + privatePemB64, + publicRaw32B64: raw32.toString("base64"), + kid: createHash("sha256").update(raw32).digest("base64url").slice(0, 16), + }; +} + +async function startServer(extraEnv) { + const port = await freePort(); + const proc = spawn(process.execPath, ["server.mjs"], { + cwd: process.cwd(), + env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), ...extraEnv }, + stdio: ["ignore", "pipe", "pipe"], + }); + + const base = `http://127.0.0.1:${port}`; + for (let i = 0; i < 80; i++) { + try { + const r = await fetch(`${base}/health`); + if (r.ok) return { proc, base }; + } catch {} + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error("server did not boot"); +} + +async function stop(proc) { + if (proc.exitCode !== null) return; + proc.kill("SIGTERM"); + await new Promise((r) => setTimeout(r, 200)); + if (proc.exitCode === null) proc.kill("SIGKILL"); +} + +function unwrapReceiptResponse(payload) { + return payload?.receipt || payload; +} + +function summarizeBody(version) { + return { + execution: { verb: "summarize", version, class: "commons" }, + input: { content: "CommandLayer runtime produces deterministic receipts.", max_sentences: 1 }, + }; +} + +test("POST /summarize/v1.0.0 and /summarize/v1.1.0 both succeed and reflect route version", 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", + }); + + try { + const v100Resp = await fetch(`${srv.base}/summarize/v1.0.0`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(summarizeBody("1.1.0")), + }); + assert.equal(v100Resp.status, 200); + const v100Payload = await v100Resp.json(); + const v100Receipt = unwrapReceiptResponse(v100Payload); + assert.equal(v100Receipt.verb, "summarize"); + assert.equal(v100Receipt.version, "1.0.0"); + + const v110Resp = await fetch(`${srv.base}/summarize/v1.1.0`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(summarizeBody("1.0.0")), + }); + assert.equal(v110Resp.status, 200); + const v110Payload = await v110Resp.json(); + const v110Receipt = unwrapReceiptResponse(v110Payload); + assert.equal(v110Receipt.verb, "summarize"); + assert.equal(v110Receipt.version, "1.1.0"); + } finally { + await stop(srv.proc); + } +}); + +test("POST /summarize with unsupported version returns 404", 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", + }); + + try { + const resp = await fetch(`${srv.base}/summarize/v2.0.0`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(summarizeBody("2.0.0")), + }); + assert.equal(resp.status, 404); + const payload = await resp.json(); + assert.equal(payload.error, "not_found"); + } finally { + await stop(srv.proc); + } +}); diff --git a/server.mjs b/server.mjs index 6197c4a..76986a6 100644 --- a/server.mjs +++ b/server.mjs @@ -141,6 +141,7 @@ const SERVICE_NAME = process.env.SERVICE_NAME || "commandlayer-runtime"; const SERVICE_VERSION = process.env.SERVICE_VERSION || "1.1.0"; const CANONICAL_BASE = (process.env.CANONICAL_BASE_URL || "https://runtime.commandlayer.org").replace(/\/+$/, ""); const API_VERSION = process.env.API_VERSION || "1.1.0"; +const SUPPORTED_COMMONS_VERSIONS = ["1.0.0", "1.1.0"]; // ENS verifier config const ETH_RPC_URL = runtimeConfig.ethRpcUrl; @@ -1090,11 +1091,12 @@ function wrapReceiptResponse(receipt, { trace = null, actor = null, delegation_r }; } -function normalizeExecutionEnvelope(rawExecution, verb) { +function normalizeExecutionEnvelope(rawExecution, verb, requestedVersion = null) { const fallbackVerb = String(verb || "").trim(); const execution = rawExecution && typeof rawExecution === "object" ? { ...rawExecution } : {}; const normalizedVerb = String(execution.verb || fallbackVerb).trim() || fallbackVerb; - const version = String(execution.version || API_VERSION).trim() || API_VERSION; + const routeVersion = String(requestedVersion || "").trim(); + const version = routeVersion || String(execution.version || API_VERSION).trim() || API_VERSION; const entry = String(execution.entry || `${CANONICAL_BASE}/execute`).trim(); const executionClass = String(execution.class || "commons").trim() || "commons"; @@ -1518,7 +1520,7 @@ const handlers = { classify: async (b) => doClassify(b), }; -async function handleVerb(verb, req, res) { +async function handleVerb(verb, req, res, requestedVersion = null) { if (!enabled(verb)) { return res.status(404).json({ ...makeError(404, `Verb not enabled: ${verb}`), ...instancePayload() }); } @@ -1539,7 +1541,7 @@ async function handleVerb(verb, req, res) { }; try { - const execution = normalizeExecutionEnvelope(req.body?.execution ?? req.body, verb); + const execution = normalizeExecutionEnvelope(req.body?.execution ?? req.body, verb, requestedVersion); warmValidatorForVerb(execution.verb); const callerTimeout = Number(req.body?.limits?.timeout_ms || req.body?.limits?.max_latency_ms || 0); @@ -1561,7 +1563,7 @@ async function handleVerb(verb, req, res) { } } catch (e) { - const execution = normalizeExecutionEnvelope(req.body?.execution ?? req.body, verb); + const execution = normalizeExecutionEnvelope(req.body?.execution ?? req.body, verb, requestedVersion); warmValidatorForVerb(execution.verb); const actor = req.body?.actor ? { id: String(req.body.actor), role: "user" } : null; @@ -1590,7 +1592,7 @@ async function handleVerb(verb, req, res) { // ----------------------- app.get("/", (req, res) => { res.setHeader("Content-Type", "application/json; charset=utf-8"); - const verbs = (ENABLED_VERBS || []).map((v) => `/${v}/v${API_VERSION}`); + const verbs = (ENABLED_VERBS || []).flatMap((v) => SUPPORTED_COMMONS_VERSIONS.map((version) => `/${v}/v${version}`)); return res.status(200).end( JSON.stringify({ ok: true, @@ -2034,7 +2036,9 @@ app.post("/execute", (req, res) => { }); for (const verb of ENABLED_VERBS) { - app.post(`/${verb}/v${API_VERSION}`, (req, res) => handleVerb(verb, req, res)); + for (const version of SUPPORTED_COMMONS_VERSIONS) { + app.post(`/${verb}/v${version}`, (req, res) => handleVerb(verb, req, res, version)); + } } // JSON 404 for any unknown routes