From 70e6aca11340c714b9840c365c5bbaf3113ddbea Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Sun, 15 Feb 2026 14:54:07 -0500 Subject: [PATCH] feat: harden runtime security controls and add CI smoke coverage --- .github/workflows/ci.yml | 27 +++++++ README.md | 159 ++++++++++++++++++++++++++++++++++++--- docs/CONFIGURATION.md | 104 +++++++++++++++++++++++++ docs/OPERATIONS.md | 75 ++++++++++++++++++ docs/REVIEW.md | 82 ++++++++++++++++++++ package.json | 4 +- server.mjs | 121 +++++++++++++++++++++++++++-- tests/smoke.mjs | 131 ++++++++++++++++++++++++++++++++ 8 files changed, 685 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/CONFIGURATION.md create mode 100644 docs/OPERATIONS.md create mode 100644 docs/REVIEW.md create mode 100644 tests/smoke.mjs 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/README.md b/README.md index a06fd7c..6274440 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,152 @@ -# CommandLayer Runtime (Commons) +# CommandLayer Runtime -Reference runtime for CommandLayer Commons verbs. +Reference Node.js runtime for CommandLayer Commons verbs. This service executes deterministic verb handlers, signs receipts with Ed25519, and verifies receipts using local keys or ENS-discovered public keys. -## Endpoints -- GET /health -- GET /debug/env -- POST /fetch/v1.0.0 -- POST /verify +## What this service does -## Example -RECEIPT=$(curl -s -X POST https:///fetch/v1.0.0 \ +- Exposes `POST //v1.0.0` endpoints for Commons verbs (`fetch`, `describe`, `format`, `clean`, `parse`, `summarize`, `convert`, `explain`, `analyze`, `classify`). +- Returns signed receipts containing: + - deterministic result payloads, + - execution trace metadata, + - proof metadata (`alg`, canonical mode, SHA-256 hash, signature). +- Exposes `POST /verify` to verify receipt hash/signature, and optionally validate schema + fetch public key from ENS. +- Includes schema validator caching, warmup queueing, SSRF protections for `fetch`, and runtime safety budgets. + +## API overview + +### Core routes + +- `GET /` — service index with links and enabled verbs. +- `GET /health` — process/service health and signer readiness. +- `POST //v1.0.0` — execute a single verb and return a signed receipt. +- `POST /verify` — verify receipt integrity/signature; optional schema and ENS verification. + +### Debug routes + +> Debug routes are disabled by default. Enable with `DEBUG_ROUTES_ENABLED=1` and optionally protect with `DEBUG_BEARER_TOKEN`. + +- `GET /debug/env` — effective runtime configuration. +- `GET /debug/enskey` — ENS TXT key discovery state. +- `GET /debug/schemafetch?verb=` — computed receipt schema URL. +- `GET /debug/validators` — validator cache and warm-queue state. +- `POST /debug/prewarm` — queue schema validator warmup. + +## Quickstart + +### 1) Install dependencies + +```bash +npm install +``` + +### 2) Generate an Ed25519 keypair (for local signing) + +```bash +openssl genpkey -algorithm Ed25519 -out private.pem +openssl pkey -in private.pem -pubout -out public.pem + +export RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64="$(base64 -w0 < private.pem)" +export RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64="$(base64 -w0 < public.pem)" +export RECEIPT_SIGNER_ID="runtime.local" +``` + +> macOS note: replace `base64 -w0` with `base64 | tr -d '\n'`. + +### 3) Start the runtime + +```bash +npm start +``` + +Default port is `8080` (override with `PORT`). + +### 4) Verify startup + +```bash +curl -s http://localhost:8080/health | jq . +``` + +You should see `"ok": true` and `"signer_ok": true`. + +## Example flow + +### Request a fetch receipt + +```bash +RECEIPT=$(curl -s -X POST "http://localhost:8080/fetch/v1.0.0" \ + -H "Content-Type: application/json" \ + -d '{ + "x402": { + "entry": "x402://fetchagent.eth/fetch/v1.0.0", + "verb": "fetch", + "version": "1.0.0" + }, + "source": "https://example.com" + }') + +printf '%s\n' "$RECEIPT" | jq . +``` + +### Verify the receipt locally + +```bash +printf '%s' "$RECEIPT" | curl -s -X POST "http://localhost:8080/verify" \ + -H "Content-Type: application/json" \ + -d @- | jq . +``` + +### Verify with ENS public key lookup + +```bash +printf '%s' "$RECEIPT" | curl -s -X POST "http://localhost:8080/verify?ens=1" \ -H "Content-Type: application/json" \ - -d '{"x402":{"entry":"x402://fetchagent.eth/fetch/v1.0.0","verb":"fetch","version":"1.0.0"},"source":"https://example.com"}') + -d @- | jq . +``` + +## Verification semantics + +`POST /verify` supports query flags: + +- `ens=1` — fetch verifier pubkey from ENS TXT record (`VERIFIER_ENS_NAME`, `ENS_PUBKEY_TEXT_KEY`). +- `refresh=1` — bypass ENS cache and refresh lookup. +- `schema=1` — validate receipt against verb schema. + +When `VERIFY_SCHEMA_CACHED_ONLY=1` (default), schema validation is edge-safe: + +- if validator is warm: request is fully validated, +- if validator is cold: service returns `202` with `validator_not_warmed_yet` and queues async prewarm. + +Use `POST /debug/prewarm` and `GET /debug/validators` for schema prewarming workflows. + +## Configuration + +Detailed environment variable documentation lives in [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md). + +## Development checks + +```bash +npm run check +npm test +``` + +`npm test` runs an automated smoke suite for signer readiness, verb execution, verify pass/fail paths, and debug route auth. + +## Security notes + +- `fetch` only allows `http(s)` URLs. +- SSRF guard blocks localhost/private IP ranges and DNS resolutions to private ranges. +- Optional host allowlist (`ALLOW_FETCH_HOSTS`) can strictly bound outbound `fetch`. +- Request-level limits are capped by server-side `SERVER_MAX_HANDLER_MS`. +- `/verify` execution is bounded by `VERIFY_MAX_MS`. + +## Operational notes + +- Validator/schema caches are in-memory (per process). +- Prewarm is best-effort and asynchronous. +- In multi-replica deployments, warm each replica independently. + +See [`docs/OPERATIONS.md`](docs/OPERATIONS.md) for deployment and runbook guidance. + +## Engineering review artifacts -printf '%s' "$RECEIPT" | curl -s -X POST "https:///verify?ens=1" \ - -H "Content-Type: application/json" -d @- +For a focused codebase review (strengths, risks, and prioritized improvements), see [`docs/REVIEW.md`](docs/REVIEW.md). diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..91c4d35 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,104 @@ +# Configuration Reference + +This runtime is configured via environment variables. + +## Core service identity + +| Variable | Default | Purpose | +|---|---|---| +| `PORT` | `8080` | HTTP listen port. | +| `SERVICE_NAME` | `commandlayer-runtime` | Name exposed in index/health metadata. | +| `SERVICE_VERSION` | `1.0.0` | Service version exposed in responses. | +| `API_VERSION` | `1.0.0` | Version segment used in verb route shape. | +| `CANONICAL_BASE_URL` | `https://runtime.commandlayer.org` | Base URL metadata in index/health payloads. | + +## CORS controls + +| Variable | Default | Purpose | +|---|---|---| +| `CORS_ALLOW_ORIGINS` | empty | CSV allowlist of browser origins. Empty rejects cross-origin browser requests. Use `*` only when intended. | +| `CORS_ALLOW_HEADERS` | `Content-Type, Authorization` | Allowed CORS request headers. | +| `CORS_ALLOW_METHODS` | `GET,POST,OPTIONS` | Allowed CORS methods. | + +## Enabled verbs + +| Variable | Default | +|---|---| +| `ENABLED_VERBS` | `fetch,describe,format,clean,parse,summarize,convert,explain,analyze,classify` | + +Comma-separated list of enabled handlers. Disabled verbs return `404`. + +## Signing + verifier identity + +| Variable | Default | Purpose | +|---|---|---| +| `RECEIPT_SIGNER_ID` | `runtime` (or `ENS_NAME` when set) | Receipt proof signer identifier. | +| `RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64` | empty | Required for signing receipts. Base64 of PEM private key. | +| `RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64` | empty | Optional local pubkey for `/verify` signature checks. | +| `ENS_NAME` | empty | Optional identity alias fallback. | + +## ENS-based verification + +| Variable | Default | Purpose | +|---|---|---| +| `ETH_RPC_URL` | empty | Ethereum RPC endpoint for ENS resolver lookups. | +| `VERIFIER_ENS_NAME` | `ENS_NAME` / `RECEIPT_SIGNER_ID` fallback | ENS name queried for TXT pubkey value. | +| `ENS_PUBKEY_TEXT_KEY` | `cl.receipt.pubkey.pem` | ENS TXT key containing PEM-formatted public key. | + +## Schema fetching + validation budgets + +| Variable | Default | Purpose | +|---|---|---| +| `SCHEMA_HOST` | `https://www.commandlayer.org` | Schema host prefix used to compute receipt schema URLs. | +| `SCHEMA_FETCH_TIMEOUT_MS` | `15000` | Timeout per schema document fetch. | +| `SCHEMA_VALIDATE_BUDGET_MS` | `15000` | Budget for async schema compilation. | +| `VERIFY_SCHEMA_CACHED_ONLY` | `1` | If `1`, `/verify?schema=1` only uses warm validators and returns `202` on cold cache. | +| `REQUEST_SCHEMA_VALIDATION` | `0` | If `1`, verb endpoints validate request payloads against verb request schema before execution. | + +## Debug route controls + +| Variable | Default | Purpose | +|---|---|---| +| `DEBUG_ROUTES_ENABLED` | `0` | If `1`, enables `/debug/*` endpoints. | +| `DEBUG_BEARER_TOKEN` | empty | Optional bearer token required for `/debug/*` when set. | + +## Cache controls + +| Variable | Default | +|---|---| +| `MAX_JSON_CACHE_ENTRIES` | `256` | +| `JSON_CACHE_TTL_MS` | `600000` | +| `MAX_VALIDATOR_CACHE_ENTRIES` | `128` | +| `VALIDATOR_CACHE_TTL_MS` | `1800000` | + +## Request safety limits + +| Variable | Default | Purpose | +|---|---|---| +| `SERVER_MAX_HANDLER_MS` | `12000` | Hard upper bound for verb execution timeout. | +| `VERIFY_MAX_MS` | `30000` | Upper bound for `/verify` request processing. | + +## `fetch` hardening + +| Variable | Default | Purpose | +|---|---|---| +| `FETCH_TIMEOUT_MS` | `8000` | Timeout for outbound `fetch` HTTP request. | +| `FETCH_MAX_BYTES` | `262144` | Max bytes read from outbound response body. | +| `ENABLE_SSRF_GUARD` | `1` | Enables DNS/IP/local-network SSRF checks. | +| `ALLOW_FETCH_HOSTS` | empty | Optional CSV domain allowlist (`example.com,api.example.com`). | + +## Schema prewarm behavior + +| Variable | Default | Purpose | +|---|---|---| +| `PREWARM_MAX_VERBS` | `25` | Max verbs accepted in one `/debug/prewarm` call. | +| `PREWARM_TOTAL_BUDGET_MS` | `12000` | Total worker runtime budget. | +| `PREWARM_PER_VERB_BUDGET_MS` | `5000` | Max warm budget per verb. | + +## Recommended production baseline + +- Set explicit signing keys and verify `signer_ok=true` on `/health`. +- Keep `VERIFY_SCHEMA_CACHED_ONLY=1` for edge stability. +- Restrict egress using both network policy and `ALLOW_FETCH_HOSTS` where possible. +- Tune `FETCH_MAX_BYTES` and timeout budgets based on expected payload sizes. +- Poll `/debug/validators` after deploy and prewarm critical verbs. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..2624a6a --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,75 @@ +# Operations Runbook + +## Deploy checklist + +1. Set signing keys: + - `RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64` + - `RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64` +2. Set identity metadata: + - `RECEIPT_SIGNER_ID` + - `SERVICE_NAME`, `SERVICE_VERSION` +3. If using ENS verification: + - `ETH_RPC_URL` + - `VERIFIER_ENS_NAME` + - `ENS_PUBKEY_TEXT_KEY` +4. Set safety limits (`FETCH_TIMEOUT_MS`, `FETCH_MAX_BYTES`, `VERIFY_MAX_MS`). +5. Restrict outbound domains with `ALLOW_FETCH_HOSTS` where possible. + +## Post-deploy validation + +```bash +curl -s "$BASE_URL/health" | jq . +curl -s "$BASE_URL/debug/env" | jq . +``` + +Expected: +- `ok=true` +- `signer_ok=true` +- expected `enabled_verbs` +- expected timeouts/cache settings + +## Schema prewarm sequence + +```bash +curl -s -X POST "$BASE_URL/debug/prewarm" \ + -H 'content-type: application/json' \ + -d '{"verbs":["fetch","parse","summarize","classify"]}' | jq . + +curl -s "$BASE_URL/debug/validators" | jq . +``` + +Repeat validator polling until required verbs appear under `cached`. + +## Verification troubleshooting + +### `no public key available` + +- Set `RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64` **or** use ENS verification with: + - `ETH_RPC_URL` + - `VERIFIER_ENS_NAME` + - valid PEM at ENS TXT key. + +### `validator_not_warmed_yet` with HTTP 202 + +- Expected when `VERIFY_SCHEMA_CACHED_ONLY=1` and schema validator is cold. +- Trigger `/debug/prewarm` and retry `/verify?schema=1`. + +### `schema fetch failed` + +- Confirm schema host reachability from runtime environment. +- Check `SCHEMA_HOST`, `SCHEMA_FETCH_TIMEOUT_MS`, outbound egress rules. + +## Recommended observability + +At minimum, capture and alert on: +- HTTP 5xx rate by endpoint and verb. +- `/verify` latency and timeout count. +- `fetch` timeout/error rates. +- cold-validator 202 rate after deploy. +- cache sizes from `/debug/validators`. + +## Hardening notes + +- Keep CORS policy constrained if this service is not intended for broad browser access. +- If internet fetch is not required, disable `fetch` verb via `ENABLED_VERBS`. +- Consider process isolation or egress proxy for stricter SSRF containment. diff --git a/docs/REVIEW.md b/docs/REVIEW.md new file mode 100644 index 0000000..974362a --- /dev/null +++ b/docs/REVIEW.md @@ -0,0 +1,82 @@ +# Engineering Review (Senior-level Scrutiny) + +Scope reviewed: `server.mjs`, packaging metadata, and top-level docs. + +## Executive summary + +The runtime has strong foundational choices for a reference implementation: +- deterministic handler behavior, +- signed receipts with canonicalized payload hashing, +- bounded verification and schema compilation, +- practical SSRF controls, +- observability-oriented debug endpoints. + +Main gaps are operational/documentation maturity (now improved in this update), plus a few production hardening opportunities that should be addressed next. + +## What is strong + +1. **Cryptographic receipt design is pragmatic and clear** + - Receipts omit mutable `receipt_id` from canonical hash inputs. + - Signature is over hash, not raw receipt object. + - Canonical stable stringify minimizes key-order drift. + +2. **Schema validation architecture is edge-aware** + - Validator and schema caches reduce repeated compile/fetch overhead. + - `VERIFY_SCHEMA_CACHED_ONLY=1` avoids p95/p99 spikes and edge failures due to on-demand compilation. + - Async warm queue reduces impact on request path. + +3. **Runtime safeguards are present where needed** + - Timeout budgets on handler execution and verify endpoint. + - Byte caps and SSRF checks on outbound fetch. + - Verb allow/deny achieved via `ENABLED_VERBS`. + +4. **Trace metadata model is sensible** + - Distinguishes runtime execution `trace_id` from upstream `parent_trace_id`. + - Supports legacy and modern parent-trace pass-through patterns. + +## Risks and recommendations + +### High priority + +1. **CORS is globally permissive (`*`)** + - Risk: browser-origin abuse if deployed on public endpoints. + - Recommendation: make allowed origins configurable; default deny in production. + +2. **Debug endpoints are publicly routable** + - `/debug/env` leaks detailed runtime config and posture. + - Recommendation: gate debug routes behind auth or environment flag; disable by default. + +3. **`fetch` response headers are reflected into receipt** + - Could include sensitive metadata from upstream resources. + - Recommendation: optionally redact/allowlist stored headers. + +### Medium priority + +4. **No explicit request schema validation for verb inputs** + - Current behavior depends on handler-level checks. + - Recommendation: optionally validate request payloads with published request schemas. + +5. **Single-process in-memory caches** + - Functional but inconsistent across replicas. + - Recommendation: keep as-is for reference runtime, but document multi-replica prewarm requirement (added). + +6. **Package script references non-existent file** + - `dev:commercial` points to `commercial.server.mjs` which is not present. + - Recommendation: remove or correct script to avoid CI/developer confusion. + +## Suggested next backlog (ordered) + +1. Add `DEBUG_ROUTES_ENABLED` + optional bearer token for `/debug/*`. +2. Add `CORS_ALLOW_ORIGINS` with explicit production defaults. +3. Add request schema validation toggle (`REQUEST_SCHEMA_VALIDATION=1`). +4. Add minimal automated smoke tests for: + - signer readiness, + - one verb execution, + - verify hash/signature pass/fail paths. +5. Clean package scripts and add CI lint/check workflow. + +## Documentation changes delivered in this update + +- Rewrote README for onboarding, API surface, verification semantics, and production context. +- Added dedicated configuration reference. +- Added operations runbook for deploy/troubleshooting/prewarm workflows. 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 }); +}