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
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: CI

on:
push:
pull_request:

jobs:
checks:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Syntax check
run: npm run check

- name: Smoke tests
run: npm test
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
"type": "module",
"scripts": {
"start": "node server.mjs",
"dev:commercial": "PORT=8090 node commercial.server.mjs"
"check": "node --check server.mjs",
"test": "node tests/smoke.mjs",
"ci": "npm run check && npm test"
},
"dependencies": {
"ajv": "^8.17.1",
Expand Down
121 changes: 116 additions & 5 deletions server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,37 @@ import net from "net";
const app = express();
app.use(express.json({ limit: "2mb" }));

// ---- basic CORS (no dependency)
// ---- CORS (no dependency)
const CORS_ALLOW_ORIGINS = (process.env.CORS_ALLOW_ORIGINS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const CORS_ALLOW_HEADERS = process.env.CORS_ALLOW_HEADERS || "Content-Type, Authorization";
const CORS_ALLOW_METHODS = process.env.CORS_ALLOW_METHODS || "GET,POST,OPTIONS";

function originAllowed(origin) {
if (!origin) return true; // non-browser / same-origin requests
if (CORS_ALLOW_ORIGINS.includes("*")) return true;
return CORS_ALLOW_ORIGINS.includes(origin);
Comment on lines +20 to +23

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Permit same-origin Origin headers by default

originAllowed only accepts requests with no Origin header or an explicit allowlist match, so with the default empty CORS_ALLOW_ORIGINS any request that includes Origin is rejected at middleware with 403. This breaks legitimate same-origin browser calls that still send Origin (commonly non-GET fetch/XHR), so a default deployment can fail browser traffic even when frontend and API share the same host.

Useful? React with 👍 / 👎.

}

app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
if (req.method === "OPTIONS") return res.status(204).end();
const origin = req.headers.origin;
if (originAllowed(origin)) {
if (origin && CORS_ALLOW_ORIGINS.includes("*")) {
res.setHeader("Access-Control-Allow-Origin", "*");
} else if (origin) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
}
res.setHeader("Access-Control-Allow-Headers", CORS_ALLOW_HEADERS);
res.setHeader("Access-Control-Allow-Methods", CORS_ALLOW_METHODS);
}
if (req.method === "OPTIONS") {
if (!originAllowed(origin)) return res.status(403).json(makeError(403, "CORS origin not allowed"));
return res.status(204).end();
}
if (!originAllowed(origin)) return res.status(403).json(makeError(403, "CORS origin not allowed"));
next();
});

Expand Down Expand Up @@ -66,6 +91,9 @@ const ALLOW_FETCH_HOSTS = (process.env.ALLOW_FETCH_HOSTS || "")
// verify hardening
const VERIFY_MAX_MS = Number(process.env.VERIFY_MAX_MS || 30000);

// request schema validation
const REQUEST_SCHEMA_VALIDATION = String(process.env.REQUEST_SCHEMA_VALIDATION || "0") === "1";

// CRITICAL: edge-safe schema verify behavior
// If true, /verify?schema=1 will NEVER compile or fetch; it will only validate if cached,
// otherwise it returns 202 and queues warm.
Expand All @@ -77,6 +105,10 @@ const PREWARM_MAX_VERBS = Number(process.env.PREWARM_MAX_VERBS || 25);
const PREWARM_TOTAL_BUDGET_MS = Number(process.env.PREWARM_TOTAL_BUDGET_MS || 12000);
const PREWARM_PER_VERB_BUDGET_MS = Number(process.env.PREWARM_PER_VERB_BUDGET_MS || 5000);

// debug route security
const DEBUG_ROUTES_ENABLED = String(process.env.DEBUG_ROUTES_ENABLED || "0") === "1";
const DEBUG_BEARER_TOKEN = process.env.DEBUG_BEARER_TOKEN || "";

function nowIso() {
return new Date().toISOString();
}
Expand Down Expand Up @@ -145,6 +177,25 @@ function requireBody(req, res) {
return true;
}

function requireDebugAccess(req, res) {
if (!DEBUG_ROUTES_ENABLED) {
res.status(404).json(makeError(404, "Not found"));
return false;
}
if (!DEBUG_BEARER_TOKEN) return true;
const auth = String(req.headers.authorization || "");
if (!auth.startsWith("Bearer ")) {
res.status(401).json(makeError(401, "Missing bearer token"));
return false;
}
const token = auth.slice("Bearer ".length).trim();
if (token !== DEBUG_BEARER_TOKEN) {
res.status(403).json(makeError(403, "Invalid bearer token"));
return false;
}
return true;
}

// -----------------------
// SSRF guard for fetch()
// -----------------------
Expand Down Expand Up @@ -321,6 +372,36 @@ function receiptSchemaUrlForVerb(verb) {
return `${SCHEMA_HOST}/schemas/v1.0.0/commons/${verb}/receipts/${verb}.receipt.schema.json`;
}

function requestSchemaUrlForVerb(verb) {
return `${SCHEMA_HOST}/schemas/v1.0.0/commons/${verb}/requests/${verb}.request.schema.json`;
}

async function getRequestValidatorForVerb(verb) {
const key = `req:${verb}`;
cachePrune(validatorCache, {
ttlMs: VALIDATOR_CACHE_TTL_MS,
maxEntries: MAX_VALIDATOR_CACHE_ENTRIES,
tsField: "compiledAt",
});

const hit = validatorCache.get(key);
if (hit?.validate) return hit.validate;

if (inflightValidator.has(key)) return await inflightValidator.get(key);

const build = (async () => {
const ajv = makeAjv();
const url = requestSchemaUrlForVerb(verb);
const schema = await fetchJsonWithTimeout(url, SCHEMA_FETCH_TIMEOUT_MS);
const validate = await withTimeout(ajv.compileAsync(schema), SCHEMA_VALIDATE_BUDGET_MS, "ajv_compile_budget_exceeded");
validatorCache.set(key, { compiledAt: Date.now(), validate });
return validate;
})().finally(() => inflightValidator.delete(key));

inflightValidator.set(key, build);
return await build;
}

async function getValidatorForVerb(verb) {
cachePrune(validatorCache, {
ttlMs: VALIDATOR_CACHE_TTL_MS,
Expand Down Expand Up @@ -834,6 +915,22 @@ async function handleVerb(verb, req, res) {
if (!enabled(verb)) return res.status(404).json(makeError(404, `Verb not enabled: ${verb}`));
if (!requireBody(req, res)) return;

if (REQUEST_SCHEMA_VALIDATION) {
try {
const validateReq = await getRequestValidatorForVerb(verb);
const ok = validateReq(req.body);
if (!ok) {
return res.status(400).json(
makeError(400, "Request schema validation failed", {
schema_errors: ajvErrorsToSimple(validateReq.errors) || [{ message: "request schema validation failed" }],
})
);
}
} catch (e) {
return res.status(500).json(makeError(500, `Request schema validator unavailable: ${e?.message || "unknown error"}`));
}
}

const started = Date.now();

// -----------------------
Expand Down Expand Up @@ -959,6 +1056,7 @@ app.get("/health", (req, res) => {
});

app.get("/debug/env", (req, res) => {
if (!requireDebugAccess(req, res)) return;
res.json({
ok: true,
node: process.version,
Expand All @@ -976,6 +1074,15 @@ app.get("/debug/env", (req, res) => {
schema_fetch_timeout_ms: SCHEMA_FETCH_TIMEOUT_MS,
schema_validate_budget_ms: SCHEMA_VALIDATE_BUDGET_MS,
verify_schema_cached_only: VERIFY_SCHEMA_CACHED_ONLY,
request_schema_validation: REQUEST_SCHEMA_VALIDATION,
debug_routes_enabled: DEBUG_ROUTES_ENABLED,
debug_bearer_token_set: !!DEBUG_BEARER_TOKEN,

cors: {
allow_origins: CORS_ALLOW_ORIGINS,
allow_headers: CORS_ALLOW_HEADERS,
allow_methods: CORS_ALLOW_METHODS,
},

enable_ssrf_guard: ENABLE_SSRF_GUARD,
fetch_timeout_ms: FETCH_TIMEOUT_MS,
Expand All @@ -1001,6 +1108,7 @@ app.get("/debug/env", (req, res) => {
});

app.get("/debug/enskey", async (req, res) => {
if (!requireDebugAccess(req, res)) return;
const refresh = String(req.query.refresh || "0") === "1";
const out = await fetchEnsPubkeyPem({ refresh });
res.json({
Expand All @@ -1015,6 +1123,7 @@ app.get("/debug/enskey", async (req, res) => {
});

app.get("/debug/schemafetch", (req, res) => {
if (!requireDebugAccess(req, res)) return;
const verb = String(req.query.verb || "").trim();
if (!verb) return res.status(400).json({ ok: false, error: "missing verb" });
const url = receiptSchemaUrlForVerb(verb);
Expand All @@ -1027,6 +1136,7 @@ app.get("/debug/schemafetch", (req, res) => {
});

app.get("/debug/validators", (req, res) => {
if (!requireDebugAccess(req, res)) return;
res.json({
ok: true,
cached: Array.from(validatorCache.keys()),
Expand All @@ -1041,6 +1151,7 @@ app.get("/debug/validators", (req, res) => {
// EDGE-SAFE prewarm: responds immediately, warms AFTER response
// -----------------------
app.post("/debug/prewarm", (req, res) => {
if (!requireDebugAccess(req, res)) return;
const verbs = Array.isArray(req.body?.verbs) ? req.body.verbs : [];
const cleaned = verbs
.map((v) => String(v || "").trim())
Expand Down
131 changes: 131 additions & 0 deletions tests/smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
import { execFileSync } from 'node:child_process';

const PORT = 19080;
const base = `http://127.0.0.1:${PORT}`;

function b64File(path) {
return readFileSync(path).toString('base64');
}

function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function waitForHealth(timeoutMs = 7000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const r = await fetch(`${base}/health`);
if (r.ok) return;
} catch {}
await sleep(120);
}
throw new Error('server did not become healthy in time');
}

const tmp = mkdtempSync(join(tmpdir(), 'runtime-test-'));
const priv = join(tmp, 'private.pem');
const pub = join(tmp, 'public.pem');

try {
execFileSync('openssl', ['genpkey', '-algorithm', 'Ed25519', '-out', priv], { stdio: 'ignore' });
execFileSync('openssl', ['pkey', '-in', priv, '-pubout', '-out', pub], { stdio: 'ignore' });

const env = {
...process.env,
PORT: String(PORT),
RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: b64File(priv),
RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64: b64File(pub),
RECEIPT_SIGNER_ID: 'runtime.test',
DEBUG_ROUTES_ENABLED: '1',
DEBUG_BEARER_TOKEN: 'secret-token',
REQUEST_SCHEMA_VALIDATION: '0',
CORS_ALLOW_ORIGINS: 'http://allowed.local',
};

const server = spawn('node', ['server.mjs'], {
env,
stdio: ['ignore', 'pipe', 'pipe'],
});

let logs = '';
server.stdout.on('data', (d) => (logs += d.toString()));
server.stderr.on('data', (d) => (logs += d.toString()));

try {
await waitForHealth();

// signer readiness
const healthResp = await fetch(`${base}/health`);
assert.equal(healthResp.ok, true);
const health = await healthResp.json();
assert.equal(health.ok, true);
assert.equal(health.signer_ok, true);

// verb execution
const verbResp = await fetch(`${base}/describe/v1.0.0`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
x402: { entry: 'x402://describeagent.eth/describe/v1.0.0', verb: 'describe', version: '1.0.0' },
input: { subject: 'CommandLayer', detail_level: 'short' },
}),
});
assert.equal(verbResp.ok, true);
const receipt = await verbResp.json();
assert.equal(receipt.status, 'success');
assert.ok(receipt.metadata?.proof?.signature_b64);

// verify pass path
const verifyResp = await fetch(`${base}/verify`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(receipt),
});
assert.equal(verifyResp.ok, true);
const verify = await verifyResp.json();
assert.equal(verify.ok, true);
assert.equal(verify.checks.signature_valid, true);
assert.equal(verify.checks.hash_matches, true);

// verify fail path (tamper hash)
const tampered = structuredClone(receipt);
tampered.metadata.proof.hash_sha256 = randomBytes(32).toString('hex');
const badVerifyResp = await fetch(`${base}/verify`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(tampered),
});
assert.equal(badVerifyResp.ok, true);
const badVerify = await badVerifyResp.json();
assert.equal(badVerify.ok, false);
assert.equal(badVerify.checks.hash_matches, false);

// debug route auth
const debugNoToken = await fetch(`${base}/debug/env`);
assert.equal(debugNoToken.status, 401);

const debugWithToken = await fetch(`${base}/debug/env`, {
headers: { authorization: 'Bearer secret-token' },
});
assert.equal(debugWithToken.ok, true);
const debug = await debugWithToken.json();
assert.equal(debug.debug_routes_enabled, true);
assert.equal(debug.cors.allow_origins.includes('http://allowed.local'), true);
} finally {
server.kill('SIGTERM');
await sleep(150);
if (!server.killed) server.kill('SIGKILL');
}
} catch (err) {
writeFileSync('/tmp/runtime-smoke-failure.log', String(err?.stack || err));
throw err;
} finally {
rmSync(tmp, { recursive: true, force: true });
}