diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..88ddedc --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/package.json b/package.json index 1240527..76bc3e6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server.mjs b/server.mjs index 6466da1..da116bd 100644 --- a/server.mjs +++ b/server.mjs @@ -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); +} + 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(); }); @@ -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. @@ -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(); } @@ -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() // ----------------------- @@ -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, @@ -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(); // ----------------------- @@ -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, @@ -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, @@ -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({ @@ -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); @@ -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()), @@ -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()) diff --git a/tests/smoke.mjs b/tests/smoke.mjs new file mode 100644 index 0000000..cf5e3d8 --- /dev/null +++ b/tests/smoke.mjs @@ -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 }); +}