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
123 changes: 123 additions & 0 deletions runtime/tests/versioned-routes.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
});
18 changes: 11 additions & 7 deletions server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";

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