diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..57ca22d --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# Router -> Runtime HTTP base (required for HTTP runtime mode) +RUNTIME_URL=https://runtime.commandlayer.org + +# Port to listen on (default: 8080) +PORT=8080 + +# Where to write NDJSON run logs +RUN_LOG_DIR=run-logs + +# Receipt signing private key (one of these must be set for signing) +# RECEIPT_SIGNING_PRIVATE_KEY_PEM=-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY----- +# RECEIPT_SIGNING_PRIVATE_KEY_B64= + +# ENS resolution for ?ens=1 verify mode +ETH_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/your-key +VERIFIER_ENS_NAME=runtime.commandlayer.eth +ENS_PUBKEY_TEXT_KEY=cl.receipt.pubkey.pem + +# Schema host (default: https://www.commandlayer.org) +SCHEMA_HOST=https://www.commandlayer.org + +# Schema verification mode: +# false (default) = fetch+compile on demand with cache +# true = only use pre-warmed validators; return 202 if not warmed +VERIFY_SCHEMA_CACHED_ONLY=false + +# Enable /debug/* endpoints (unset = 404) +# ENABLE_DEBUG=1 +# DEBUG_TOKEN=changeme + +# Idempotency cache TTL in ms (default: 60000) +IDEMPOTENCY_TTL_MS=60000 + +# SSRF: comma-separated additional allowed hostnames for fetch verb +# ALLOW_FETCH_HOSTS=trusted-api.example.com diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f00ed32 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: ci + +on: + push: + branches: ["**"] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run lint + + - name: Build + run: npm run build + + - name: Test + run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..03ba81a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +## 0.1.0 + +Initial implementation: + +- `POST /run/v1.0.0` with explicit plan and scored router modes +- 10 Commons verbs: fetch, describe, format, clean, parse, summarize, convert, explain, analyze, classify +- Deterministic score function (no LLM): relevance + need_bonus + novelty + cost_penalty + risk_penalty +- Prerequisite graph: fetch→clean→summarize→classify→parse→analyze→format (state-gated) +- Schema-shaped request builders per verb (fetch special: x402+source only) +- Append-only state model: `RunState` with artifacts, trace, decisions +- Ed25519 + SHA-256 + json.sorted_keys.v1 receipt signing/verification +- ENS TXT pubkey resolution for `runtime.commandlayer.eth` (`?ens=1`) +- Schema validation via AJV compileAsync with background warm queue (`/debug/prewarm → 202`) +- `/verify?schema=1`: cached-only mode returns 202 + retry_after_ms when not warmed +- SSRF guard for fetch: blocks private IPs, localhost, IPv6, metadata addresses +- Idempotency cache: SHA-256 key derivation from stable stringify; TTL configurable +- NDJSON replay logs per run in `RUN_LOG_DIR`; `/replay/:run_id` endpoint +- HTTP delegate mode: `RUNTIME_URL` → `RuntimeClient` per step +- Local reference handlers (demo/test; no HTTP) +- CLI: `cl-router run --goal ... --content/source/plan` +- Debug endpoints gated by `ENABLE_DEBUG` env +- ENS anchors embedded as constants: `ENS_RUNTIME_SIGNER`, `ENS_CLEANAGENT_EXAMPLE` +- Tests: scoring, prereqs, requestBuilders, idempotency, engine +- CI: lint + build + test +- Docs: SPEC, API, CONTRACTS, INTEGRATION_RUNTIME, INTEGRATION_SITE, EXAMPLES diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fa28c24 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) CommandLayer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 50b2fd1..06bf373 100644 --- a/README.md +++ b/README.md @@ -1 +1,163 @@ -# router +# CommandLayer Router + +Deterministic router runtime for the **CommandLayer Protocol Commons** (10 universal verbs). + +## What it does + +- `POST /run/v1.0.0` — executes Commons verbs via: + - **explicit plan mode** (caller supplies ordered steps with conditions) + - **scored mode** (deterministic scoring function; **no LLM**) +- Builds **schema-shaped** Commons requests (`fetch` is special) +- Produces **signed receipts per step** (Ed25519 + SHA-256 + sorted keys v1) +- `POST /verify` — verifies receipts (optional ENS pubkey + schema validation) +- AJV schema warm queue (non-blocking) +- Idempotency cache (SHA-256 key derivation) +- NDJSON replay logs per run +- SSRF guard for fetch verb +- CLI: `cl-router` + +## Commons Verbs (10 Universal) + +``` +fetch describe format clean parse +summarize convert explain analyze classify +``` + +## Quickstart + +```bash +npm install +npm run build +npm test +npm start +``` + +Server starts on `http://localhost:8080`. + +## CLI + +```bash +# Scored mode +node dist/cli/cl-router.js run --goal "summarize this" --content "Hello world" + +# With URL source +node dist/cli/cl-router.js run --goal "fetch and summarize" --source https://example.com + +# Explicit plan from JSON file +node dist/cli/cl-router.js run --plan my-plan.json +``` + +## HTTP Delegate Mode + +Set `RUNTIME_URL` to delegate step execution to the CommandLayer runtime: + +```bash +RUNTIME_URL=https://runtime.commandlayer.org npm start +``` + +Without `RUNTIME_URL`, the router uses local reference handlers (demo/test). + +## Endpoints + +| Method | Path | Description | +|---|---|---| +| `GET` | `/health` | Health check | +| `POST` | `/run/v1.0.0` | Execute a run | +| `POST` | `/verify` | Verify a signed receipt | +| `GET` | `/replay/:run_id` | Retrieve run event log | +| `POST` | `/debug/prewarm` | Warm schema validators (requires ENABLE_DEBUG) | +| `GET` | `/debug/validators` | Show AJV cache state (requires ENABLE_DEBUG) | + +## ENS Anchors (Source of Truth) + +### `runtime.commandlayer.eth` — Receipt Signer Identity + +``` +cl.receipt.pubkey.pem = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA7Vkkmt6R02Iltp/+i3D5mraZyvLjfuTSVB33KwfzQC8=\n-----END PUBLIC KEY-----" +cl.sig.kid = "v1" +cl.sig.pub = "ed25519:CEHI9g4v8qMx8TLlbLVW7RtiCmzRF7U7gpkrp0iB/a0=" +cl.sig.canonical = "json.sorted_keys.v1" +cl.receipt.signer = "runtime.commandlayer.eth" +``` + +### `cleanagent.eth` — Canonical Commons Agent TXT Shape + +``` +cl.verb = "clean" +cl.version = "1.0.0" +cl.class = "commons" +cl.entry = "x402://cleanagent.eth/clean/v1.0.0" +cl.receipt.signer = "runtime.commandlayer.eth" +eth = "0xED976cA9036bC2d4E25BA8219faDA1Be503a09C7" +``` + +## Environment Variables + +```bash +PORT=8080 +RUNTIME_URL=https://runtime.commandlayer.org # HTTP delegate mode +RUN_LOG_DIR=run-logs # NDJSON replay log dir +RECEIPT_SIGNING_PRIVATE_KEY_PEM=... # Ed25519 private key PEM +ETH_RPC_URL=... # For ENS resolution (?ens=1) +VERIFIER_ENS_NAME=runtime.commandlayer.eth +SCHEMA_HOST=https://www.commandlayer.org +VERIFY_SCHEMA_CACHED_ONLY=false +ENABLE_DEBUG=1 # Enable /debug/* endpoints +DEBUG_TOKEN=changeme +IDEMPOTENCY_TTL_MS=60000 +ALLOW_FETCH_HOSTS=trusted-api.example.com # SSRF bypass list +``` + +See `.env.example` for full reference. + +## Docs + +- [docs/SPEC.md](docs/SPEC.md) — Full `/run/v1.0.0` specification +- [docs/API.md](docs/API.md) — API reference +- [docs/CONTRACTS.md](docs/CONTRACTS.md) — RunRequest/RunResponse + receipt expectations +- [docs/INTEGRATION_RUNTIME.md](docs/INTEGRATION_RUNTIME.md) — Runtime integration guide +- [docs/INTEGRATION_SITE.md](docs/INTEGRATION_SITE.md) — Site docs updates +- [docs/EXAMPLES.md](docs/EXAMPLES.md) — Usage examples + +## Architecture + +``` +src/ + server.ts Express server + route registration + index.ts Public API exports + router/ + commonsVerbs.ts 10-verb list + policy.ts allow/deny verb policy + prereqs.ts Prerequisite graph (deterministic) + scoring.ts Score function + verb selector + state.ts RunState helpers (append-only) + requestBuilders.ts Schema-shaped request builders per verb + applyReceipt.ts Receipt → artifact mapping + stop.ts Global stop condition + engine.ts Main run loop (explicit + scored) + schemas/ + urls.ts Schema URL builders + ajv.ts AJV instance + validator cache + warm.ts Background warm queue + receipts/ + sign.ts Ed25519 sign + verify + verify.ts /verify endpoint handler + ens.ts ENS TXT pubkey resolver + security/ + ssrf.ts SSRF guard for fetch + logging/ + replay.ts NDJSON event logger + idempotency/ + cache.ts In-memory idempotency cache + client/ + runtimeClient.ts HTTP client for runtime delegate + cli/ + cl-router.ts CLI entrypoint + types/ + run.ts RunRequest/RunResponse/RunState types + commons.ts CommonsVerb type + ENS anchor constants +``` + +## License + +MIT diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..2a92e59 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,183 @@ +# API Reference + +## Base URL + +``` +http://localhost:8080 (local) +https://router.commandlayer.org (production, when deployed) +``` + +--- + +## Endpoints + +### `GET /health` + +Returns service health. + +**Response:** +```json +{ "ok": true, "service": "@commandlayer/router" } +``` + +--- + +### `POST /run/v1.0.0` + +Execute a run in explicit plan or scored mode. + +**Headers:** +- `Content-Type: application/json` +- `Idempotency-Key: ` (optional; auto-derived from body hash if absent) +- `X-Idempotency-Key: ` (alternative header) + +**Body:** `RunRequest` (see SPEC.md) + +**Response:** `RunResponse` + +```json +{ + "ok": true, + "mode": "scored", + "run_id": "run_abc123", + "duration_ms": 45, + "receipts": [ ... ], + "state": { "artifacts": [...], "trace": [...], ... }, + "final": null, + "idempotency": { "hit": false } +} +``` + +**Status codes:** +- `200` — run completed (check `ok` field for success) +- `500` — internal error + +--- + +### `POST /verify` + +Verify a signed receipt. + +**Query params:** +- `?ens=1` — resolve pubkey from ENS TXT (`runtime.commandlayer.eth → cl.receipt.pubkey.pem`) +- `?schema=1` — additionally validate receipt payload against verb schema + +**Body:** Signed receipt object + +**Response:** +```json +{ + "ok": true, + "sig_valid": true, + "hash_valid": true, + "schema_valid": true, + "ens_resolved": false, + "pubkey_source": "inline" +} +``` + +**For `?schema=1` when validator not warmed (VERIFY_SCHEMA_CACHED_ONLY=true):** +```json +{ + "ok": false, + "error": "validator_not_warmed_yet", + "error_code": "schema_validator_not_warmed", + "retry_after_ms": 5000 +} +``` +HTTP status: `202` — retry after warming. + +--- + +### `GET /replay/:run_id` + +Retrieve NDJSON run log events as structured JSON. + +**Response:** +```json +{ + "ok": true, + "run_id": "run_abc123", + "events": [ + { "type": "run_start", "at": "...", "mode": "scored" }, + { "type": "step_start", "at": "...", "step": 1, "verb": "clean", "why": "scored(score=0.4500)" }, + { "type": "step_done", "at": "...", "step": 1, "verb": "clean", "ok": true }, + { "type": "run_done", "at": "...", "ok": true, "duration_ms": 12 } + ] +} +``` + +--- + +### `POST /debug/prewarm` + +**(Requires `ENABLE_DEBUG=1`)** + +Enqueue all Commons verb schemas for background warming. Returns immediately (202). + +**Headers:** +- `Authorization: Bearer ` (if `DEBUG_TOKEN` is set) + +**Response (202):** +```json +{ + "ok": true, + "queued": true, + "job_id": "warm_1_1700000000000", + "queued_count": 20, + "cached_count": 0 +} +``` + +--- + +### `GET /debug/validators` + +**(Requires `ENABLE_DEBUG=1`)** + +Show current AJV validator cache state. + +**Response:** +```json +{ + "ok": true, + "cached": [ "https://www.commandlayer.org/schemas/v1.0.0/commons/clean/..." ], + "cached_count": 4, + "inflight": 0 +} +``` + +--- + +### `GET /debug/cache` + +**(Requires `ENABLE_DEBUG=1`)** + +Show idempotency cache size. + +**Response:** +```json +{ "ok": true, "idempotency_cache_size": 12 } +``` + +--- + +## Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `PORT` | `8080` | Server listen port | +| `RUNTIME_URL` | — | If set, delegates step execution to remote runtime | +| `RUN_LOG_DIR` | `run-logs` | Directory for NDJSON replay logs | +| `RECEIPT_SIGNING_PRIVATE_KEY_PEM` | — | Ed25519 private key PEM for signing | +| `RECEIPT_SIGNING_PRIVATE_KEY_B64` | — | Ed25519 private key base64 raw seed | +| `ETH_RPC_URL` | — | Ethereum RPC URL for ENS resolution | +| `VERIFIER_ENS_NAME` | `runtime.commandlayer.eth` | ENS name to resolve pubkey from | +| `ENS_PUBKEY_TEXT_KEY` | `cl.receipt.pubkey.pem` | TXT record key to resolve | +| `SCHEMA_HOST` | `https://www.commandlayer.org` | Base URL for schema loading | +| `VERIFY_SCHEMA_CACHED_ONLY` | `false` | If `true`, only use pre-warmed validators | +| `ENABLE_DEBUG` | — | Set to any value to enable /debug/* endpoints | +| `DEBUG_TOKEN` | — | Optional bearer token for /debug/* access | +| `IDEMPOTENCY_TTL_MS` | `60000` | Idempotency cache TTL in milliseconds | +| `ALLOW_FETCH_HOSTS` | — | Comma-separated trusted hostnames for SSRF bypass | +| `CANONICAL_BASE` | `http://localhost:8080` | Base URL used in channel envelopes | diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md new file mode 100644 index 0000000..1222394 --- /dev/null +++ b/docs/CONTRACTS.md @@ -0,0 +1,170 @@ +# Contracts: RunRequest / RunResponse / Receipt Expectations + +## RunRequest + +```typescript +type RunRequest = { + goal?: string; + input?: { + content?: string; // text content to process + source?: string; // URL (triggers fetch verb) + url?: string; // alias for source + }; + state?: RunState; // resume from prior state + plan?: { + mode?: "explicit" | "scored"; + steps?: PlanStep[]; + }; + limits?: { + max_steps?: number; // default 10, max 50 + timeout_ms?: number; // default 15000 + max_output_tokens?: number; // default 1024 + max_receipts?: number; // default 50 + }; + policy?: { + allow_verbs?: string[]; // whitelist (empty = all) + deny_verbs?: string[]; // blacklist + risk_level?: "low" | "medium" | "high"; + }; + idempotency_key?: string; +}; +``` + +## PlanStep + +```typescript +type PlanStep = { + verb: string; // one of the 10 Commons verbs + version?: string; // default "1.0.0" + input?: Record; // step-level input override + conditions?: { + requires_artifact?: string; // skip if this artifact type is absent + skip_if_exists?: string; // skip if this artifact type is present + max_runs?: number; // default 1; max number of times this verb can run + }; + optional?: boolean; // default false; if true, failure does not abort run +}; +``` + +## RunResponse + +```typescript +type RunResponse = { + ok: boolean; + mode: "explicit" | "scored"; + run_id: string; // run_<16 hex chars> + duration_ms: number; + receipts: unknown[]; // one receipt per executed step + state: RunState; + final: unknown; // contents of final artifact (null if absent) + idempotency: { hit: boolean }; +}; +``` + +## RunState (Append-Only) + +```typescript +type Artifact = { id: string; at: string; type: string; data: unknown }; +type Decision = { at: string; who: string; why: string }; +type Trace = { at: string; step: number; verb: string; why: string; ok: boolean }; + +type RunState = { + goal: string | null; + tasks: unknown[]; + artifacts: Artifact[]; // append-only; never removed + decisions: Decision[]; + trace: Trace[]; + meta: Record; +}; +``` + +### Artifact Types by Verb + +| Verb | Artifact type | +|---|---| +| fetch | `fetched` | +| clean | `cleaned` | +| summarize | `summary` | +| classify | `classification` | +| analyze | `analysis` | +| parse | `parsed` | +| convert | `converted` | +| describe | `description` | +| explain | `explanation` | +| format | `final` | + +--- + +## Receipt Expectations + +All receipts emitted by the router (and verified by `/verify`) must conform to +the **runtime.commandlayer.eth** signing anchor: + +| Field | Value | +|---|---| +| `alg` | `ed25519` | +| `kid` | `v1` | +| `signer` | `runtime.commandlayer.eth` | +| `canonicalization` | `json.sorted_keys.v1` | +| Pubkey | `cl.receipt.pubkey.pem` from ENS TXT | + +### Canonical JSON Format + +Receipt payloads are serialized using `json.sorted_keys.v1`: +- All object keys sorted lexicographically at every depth +- No whitespace +- Deterministic; identical payload → identical canonical string + +### Signed Receipt Shape + +```json +{ + "payload": { ... }, + "canonical": "", + "hash": "", + "sig": "", + "alg": "ed25519", + "kid": "v1", + "signer": "runtime.commandlayer.eth" +} +``` + +--- + +## Schema-Shaped Request Constraints + +The router builds schema-shaped requests matching `protocol-commons` schemas +(`additionalProperties: false`). + +**fetch** (special — x402 + source only): +```json +{ + "x402": { "verb": "fetch", "version": "1.0.0", "entry": "x402://fetchagent.eth/fetch/v1.0.0" }, + "source": "https://example.com" +} +``` + +**All other verbs** (x402 + actor + limits + channel + input): +```json +{ + "x402": { "verb": "clean", "version": "1.0.0", "entry": "x402://cleanagent.eth/clean/v1.0.0" }, + "actor": "router", + "limits": { "max_output_tokens": 1024 }, + "channel": { + "protocol": "https", + "input_modalities": ["text"], + "output_modalities": ["json"], + "endpoint": "https://runtime.commandlayer.org/run/v1.0.0" + }, + "input": { "content": "..." } +} +``` + +--- + +## Backward Compatibility + +- Receipt format and signing semantics are **pinned** to the ENS anchor values. +- `protocol-commons` schemas are **immutable** — the router adapts to them, never the reverse. +- State model is **append-only** — existing artifacts are never removed or modified. +- Idempotency guarantees: identical `idempotency_key` → identical response within TTL. diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md new file mode 100644 index 0000000..09b7aa9 --- /dev/null +++ b/docs/EXAMPLES.md @@ -0,0 +1,202 @@ +# Examples + +## Scored Mode: Summarize a URL + +```bash +curl -X POST http://localhost:8080/run/v1.0.0 \ + -H "Content-Type: application/json" \ + -d '{ + "goal": "fetch and summarize this page", + "input": { "source": "https://example.com" }, + "plan": { "mode": "scored" }, + "limits": { "max_steps": 5 } + }' +``` + +Expected router decision sequence (scored, deterministic): +1. `fetch` — source URL present, not fetched yet +2. `clean` — fetched content present, not cleaned yet +3. `summarize` — content present, no summary yet + +--- + +## Scored Mode: Classify Input Content + +```bash +curl -X POST http://localhost:8080/run/v1.0.0 \ + -H "Content-Type: application/json" \ + -d '{ + "goal": "classify this text", + "input": { "content": "The quarterly earnings report shows..." }, + "plan": { "mode": "scored" } + }' +``` + +Router will skip `fetch` (no source URL), start with `clean`, then `classify`. + +--- + +## Explicit Plan Mode + +```bash +curl -X POST http://localhost:8080/run/v1.0.0 \ + -H "Content-Type: application/json" \ + -d '{ + "goal": "fetch, clean, summarize", + "input": { "source": "https://example.com" }, + "plan": { + "mode": "explicit", + "steps": [ + { + "verb": "fetch", + "version": "1.0.0", + "conditions": { "max_runs": 1 } + }, + { + "verb": "clean", + "version": "1.0.0", + "conditions": { "requires_artifact": "fetched" } + }, + { + "verb": "summarize", + "version": "1.0.0", + "conditions": { "skip_if_exists": "summary" } + } + ] + } + }' +``` + +--- + +## Explicit Plan with Optional Step + +```bash +curl -X POST http://localhost:8080/run/v1.0.0 \ + -H "Content-Type: application/json" \ + -d '{ + "goal": "analyze content", + "input": { "content": "..." }, + "plan": { + "mode": "explicit", + "steps": [ + { "verb": "clean" }, + { "verb": "analyze" }, + { "verb": "format", "optional": true } + ] + } + }' +``` + +If `format` fails, the run still returns `ok: true` with analysis artifact. + +--- + +## Idempotency + +```bash +KEY="my-unique-run-$(date +%s)" + +# First call +curl -X POST http://localhost:8080/run/v1.0.0 \ + -H "Idempotency-Key: $KEY" \ + -H "Content-Type: application/json" \ + -d '{ "goal": "summarize", "input": { "content": "Hello world" } }' + +# Second call with same key → returns cached response +curl -X POST http://localhost:8080/run/v1.0.0 \ + -H "Idempotency-Key: $KEY" \ + -H "Content-Type: application/json" \ + -d '{ "goal": "summarize", "input": { "content": "Hello world" } }' +# Response will include: "idempotency": { "hit": true } +``` + +--- + +## Verify a Receipt + +```bash +# Basic verify (inline fallback pubkey) +curl -X POST http://localhost:8080/verify \ + -H "Content-Type: application/json" \ + -d '{ "payload": {...}, "hash": "...", "sig": "...", "alg": "ed25519", "kid": "v1" }' + +# Verify with ENS pubkey resolution +curl -X POST "http://localhost:8080/verify?ens=1" \ + -H "Content-Type: application/json" \ + -d '{ ... }' + +# Verify with schema validation +curl -X POST "http://localhost:8080/verify?ens=1&schema=1" \ + -H "Content-Type: application/json" \ + -d '{ ... }' +``` + +--- + +## CLI + +```bash +# Build first +npm run build + +# Scored mode +node dist/cli/cl-router.js run \ + --goal "summarize this content" \ + --content "The quick brown fox jumps over the lazy dog" + +# With URL source +node dist/cli/cl-router.js run \ + --goal "fetch and summarize" \ + --source https://example.com + +# With remote runtime +RUNTIME_URL=https://runtime.commandlayer.org \ + node dist/cli/cl-router.js run \ + --goal "fetch and summarize" \ + --source https://example.com + +# Explicit plan from JSON file +node dist/cli/cl-router.js run --plan my-plan.json +``` + +--- + +## ENS Anchor Reference + +The following ENS records are the source of truth for signing and verification. +These values are exported from `@commandlayer/router`: + +```typescript +import { ENS_RUNTIME_SIGNER, ENS_CLEANAGENT_EXAMPLE } from "@commandlayer/router"; + +console.log(ENS_RUNTIME_SIGNER["cl.receipt.pubkey.pem"]); +// -----BEGIN PUBLIC KEY----- +// MCowBQYDK2VwAyEA7Vkkmt6R02Iltp/+i3D5mraZyvLjfuTSVB33KwfzQC8= +// -----END PUBLIC KEY----- + +console.log(ENS_RUNTIME_SIGNER.alg); // "ed25519" +console.log(ENS_RUNTIME_SIGNER.canonicalization); // "json.sorted_keys.v1" +console.log(ENS_RUNTIME_SIGNER["cl.sig.kid"]); // "v1" + +console.log(ENS_CLEANAGENT_EXAMPLE["cl.entry"]); +// "x402://cleanagent.eth/clean/v1.0.0" +``` + +--- + +## Policy: Restrict to Specific Verbs + +```json +{ + "goal": "clean and summarize", + "input": { "content": "..." }, + "plan": { "mode": "scored" }, + "policy": { + "allow_verbs": ["clean", "summarize"], + "risk_level": "low" + } +} +``` + +The router will only consider `clean` and `summarize` as candidates. diff --git a/docs/INTEGRATION_RUNTIME.md b/docs/INTEGRATION_RUNTIME.md new file mode 100644 index 0000000..937b88f --- /dev/null +++ b/docs/INTEGRATION_RUNTIME.md @@ -0,0 +1,129 @@ +# Integration with commandlayer/runtime + +The router can be integrated with the CommandLayer runtime in two ways: + +## Option A: HTTP Delegate (Recommended for Production) + +Set `RUNTIME_URL` in the router's environment. The router engine selects verbs +deterministically, then sends each step as a `POST /run/v1.0.0` request to the runtime. + +``` +RUNTIME_URL=https://runtime.commandlayer.org +``` + +Router flow: +``` +Client → POST /run/v1.0.0 (router) + ↓ + [engine: score/plan verbs] + ↓ + POST /run/v1.0.0 (runtime) ← each step + ↓ + [runtime executes verb, signs receipt] + ↓ + [router aggregates receipts + state] + ↓ + RunResponse → Client +``` + +The router does not duplicate execution logic — it only does routing. +The runtime remains the single source of truth for verb execution and receipt signing. + +## Option B: Local Handlers (Demo / Testing) + +When `RUNTIME_URL` is not set, the router falls back to lightweight local handlers +defined in `src/router/engine.ts`. These are reference implementations that +demonstrate the contract shape without making real HTTP calls. + +Local handlers are intentionally minimal: +- They satisfy the "no dumb loops" rule +- They return deterministic, dummy outputs +- They are suitable for unit tests and integration scaffolding + +## Drop-In Blocks for Runtime + +The following modules can be imported directly by the runtime to share logic: + +### `@commandlayer/router/scoring` + +```typescript +import { chooseVerbScored, scoreVerb } from "@commandlayer/router"; +``` + +Use for `/run/v1.0.0` plan.mode=scored in the runtime. + +### `@commandlayer/router/prereqs` + +```typescript +import { verbPrereq } from "@commandlayer/router"; +``` + +Use for prerequisite gating before executing each verb step. + +### `@commandlayer/router/requestBuilders` + +```typescript +import { buildCommonsRequest } from "@commandlayer/router"; +``` + +Use for building schema-shaped request envelopes per verb. + +### `@commandlayer/router/state` + +```typescript +import { initState, addArtifact, hasArtifact, bestContent } from "@commandlayer/router"; +``` + +Shared state model with append-only semantics. + +--- + +## Runtime Fixes Required (Non-Router) + +These fixes are required in the runtime (commandlayer/runtime), not in this repo: + +### 1) Non-Blocking Schema Warm Queue + +`POST /debug/prewarm` must: +- Require debug token +- Return `202` immediately with `job_id + queued_count` +- Warm in background (never block request path) + +### 2) Schema Verification (`/verify?schema=1`) + +| `VERIFY_SCHEMA_CACHED_ONLY` | Validator present | Behavior | +|---|---|---| +| `false` | any | Fetch+compile on demand (bounded timeout + size) | +| `true` | yes | Validate and return result | +| `true` | no | Return `202` with `retry_after_ms` + `error_code: schema_validator_not_warmed` | + +This behavior is implemented in this router's `/verify` endpoint as the reference. + +### 3) Debug Endpoint Gating + +All `/debug/*` endpoints must: +- Return `404` immediately if `ENABLE_DEBUG` is not set +- Do zero work before the gate check + +### 4) `/run/v1.0.0` in Runtime + +- Must support `plan.mode: "explicit" | "scored"` (or delegate to router library) +- Must not break existing single-verb endpoints +- Must keep receipt format identical (same signing, same canonicalization) + +--- + +## ENS Anchor Integration + +Both runtime and router must use these canonical values (from `runtime.commandlayer.eth`): + +``` +alg: ed25519 +kid: v1 +canonicalization: json.sorted_keys.v1 +pubkey (PEM): cl.receipt.pubkey.pem TXT record value +signer: runtime.commandlayer.eth +``` + +These are hardcoded as fallbacks in `src/receipts/ens.ts` and exported from +`src/types/commons.ts` as `ENS_RUNTIME_SIGNER`. diff --git a/docs/INTEGRATION_SITE.md b/docs/INTEGRATION_SITE.md new file mode 100644 index 0000000..8a3b144 --- /dev/null +++ b/docs/INTEGRATION_SITE.md @@ -0,0 +1,108 @@ +# Integration with commandlayer-org (Site / Docs) + +Updates required in the `commandlayer-org` site to match the actual system. + +--- + +## Pages / Sections to Add + +### 1. Commons — 10 Universal Verbs + +Add a page or section explaining: + +> The CommandLayer Protocol Commons defines **10 universal verbs** that any agent +> can implement to participate in the protocol: +> +> | Verb | Purpose | +> |---|---| +> | `fetch` | Retrieve content from a URL (HTTP/HTTPS) | +> | `clean` | Sanitize, normalize, and deduplicate content | +> | `parse` | Extract structured data from content | +> | `summarize` | Produce a concise summary | +> | `classify` | Assign labels or taxonomy to content | +> | `analyze` | Extract signals, patterns, metrics | +> | `describe` | Provide a description or definition | +> | `explain` | Provide a causal or mechanistic explanation | +> | `convert` | Transform content from one format to another | +> | `format` | Render or present the final artifact | + +### 2. Router — Deterministic Scored Routing + +Add a page or section explaining: + +> The CommandLayer Router is a **deterministic scoring engine** — not an LLM. +> +> It selects which verb to run next by evaluating a score function: +> +> ``` +> score(verb) = +> 0.40 × relevance(goal, verb) -- keyword match against goal +> + 0.30 × need_bonus(verb) -- stable ordering bias +> + 0.10 × novelty(verb) -- new verbs score higher +> + 0.10 × cost_penalty(verb) -- cheaper verbs score higher +> + 0.10 × risk_penalty(verb) -- 0 for all commons verbs +> ``` +> +> The router stops when no verb scores above 0 (no positive move). +> All decisions are reproducible given the same inputs. + +### 3. Receipts — Signed, Canonicalized, Verifiable + +Add a page or section explaining: + +> Every step in a CommandLayer run produces a **signed receipt**. +> +> Receipts are: +> - Signed with **Ed25519** using the private key of `runtime.commandlayer.eth` +> - Canonicalized using **json.sorted_keys.v1** (all object keys sorted recursively) +> - Hashed with **SHA-256** for integrity +> - Verifiable using the public key anchored at `runtime.commandlayer.eth` ENS TXT records +> +> Verify with: +> ``` +> POST /verify?ens=1&schema=1 +> ``` + +### 4. System Inventory / Config List + +Add a reference page with the two ENS anchors as the source of truth: + +**`runtime.commandlayer.eth`** — Receipt Signer Identity +- `cl.receipt.pubkey.pem`: Ed25519 public key PEM +- `cl.sig.kid`: `v1` +- `cl.sig.pub`: `ed25519:CEHI9g4v8qMx8TLlbLVW7RtiCmzRF7U7gpkrp0iB/a0=` +- `cl.sig.canonical`: `json.sorted_keys.v1` +- `cl.receipt.signer`: `runtime.commandlayer.eth` + +**`cleanagent.eth`** — Canonical Example of Commons Agent TXT Records +- `cl.verb`: `clean`, `cl.version`: `1.0.0`, `cl.class`: `commons` +- `cl.entry`: `x402://cleanagent.eth/clean/v1.0.0` +- `cl.schema.request`: schema URL +- `cl.schema.receipt`: schema URL +- `cl.cid.schemas`: IPFS CID for schema integrity +- `cl.agentcard`: agent card URL +- `cl.receipt.signer`: `runtime.commandlayer.eth` + +--- + +## Claims to Remove / Correct + +- Do not claim router uses LLM-based routing (it does not) +- Do not claim dynamic verb discovery without the ENS TXT record shape above +- Do not claim receipts use any algorithm other than Ed25519 + SHA-256 + sorted keys v1 +- Do not invent schema URLs that differ from the `protocol-commons` paths + +--- + +## How to Run + +```bash +# Router +npm install && npm run build && npm start + +# Test +npm test + +# With runtime delegate +RUNTIME_URL=https://runtime.commandlayer.org npm start +``` diff --git a/docs/SPEC.md b/docs/SPEC.md new file mode 100644 index 0000000..3a34558 --- /dev/null +++ b/docs/SPEC.md @@ -0,0 +1,236 @@ +# POST /run/v1.0.0 — Full Specification + +## Overview + +The CommandLayer Router accepts a `RunRequest` and executes the CommandLayer +Protocol Commons verbs deterministically. It supports two modes: + +- **explicit** — caller supplies an ordered plan of steps +- **scored** — router selects verbs deterministically using a score function (no LLM) + +--- + +## System Inventory / Config List (ENS Anchors) + +### A) ENS Anchor: `runtime.commandlayer.eth` (Receipt Signer Identity) + +**Purpose**: Global trust anchor for receipt signing and verification. +All receipts MUST use these parameters. + +| TXT Record | Value | +|---|---| +| `cl.receipt.pubkey.pem` | `-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA7Vkkmt6R02Iltp/+i3D5mraZyvLjfuTSVB33KwfzQC8=\n-----END PUBLIC KEY-----` | +| `cl.sig.kid` | `v1` | +| `cl.sig.pub` | `ed25519:CEHI9g4v8qMx8TLlbLVW7RtiCmzRF7U7gpkrp0iB/a0=` | +| `cl.sig.canonical` | `json.sorted_keys.v1` | +| `cl.receipt.signer` | `runtime.commandlayer.eth` | + +**Enforcement**: `alg=ed25519`, `canonicalization=json.sorted_keys.v1`, `kid=v1`. +Any divergence is a bug. Do not guess. + +### B) ENS Example: `cleanagent.eth` (Canonical shape for Commons agent TXT records) + +**Purpose**: Reference example showing how a Commons verb agent advertises via ENS. + +| TXT Record | Value | +|---|---| +| `cl.verb` | `clean` | +| `cl.version` | `1.0.0` | +| `cl.class` | `commons` | +| `cl.entry` | `x402://cleanagent.eth/clean/v1.0.0` | +| `cl.schema.request` | `https://commandlayer.org/schemas/v1.0.0/commons/clean/requests/clean.request.schema.json` | +| `cl.schema.receipt` | `https://commandlayer.org/schemas/v1.0.0/commons/clean/receipts/clean.receipt.schema.json` | +| `cl.cid.schemas` | `bafybeigvf6nkzws7dblos74dqqjkguwkrwn4a2c27ieygoxmgofyzdkz6m` | +| `cl.schemas.mirror.ipfs` | `ipfs://bafybeigvf6nkzws7dblos74dqqjkguwkrwn4a2c27ieygoxmgofyzdkz6m` | +| `cl.agentcard` | `https://commandlayer.org/agent-cards/agents/v1.0.0/commons/cleanagent.eth.json` | +| `cl.cid.agentcards` | `bafybeibbgpzhaitk3yr3qacxwuzv3hlb2z5xvi2ahm42ikhcxcri33cxhy` | +| `cl.owner` | `commandlayer.eth` | +| `cl.receipt.alg` | `ed25519` | +| `cl.receipt.signer` | `runtime.commandlayer.eth` | +| ETH address | `0xED976cA9036bC2d4E25BA8219faDA1Be503a09C7` | + +--- + +## Commons Verbs (10 Universal) + +``` +fetch, describe, format, clean, parse, +summarize, convert, explain, analyze, classify +``` + +--- + +## Request Shape + +```json +{ + "goal": "string", + "input": { + "content": "string", + "source": "string (URL)" + }, + "state": {}, + "plan": { + "mode": "explicit | scored", + "steps": [ + { + "verb": "fetch", + "version": "1.0.0", + "input": {}, + "conditions": { + "requires_artifact": "cleaned", + "skip_if_exists": "summary", + "max_runs": 1 + }, + "optional": false + } + ] + }, + "limits": { + "max_steps": 10, + "timeout_ms": 15000, + "max_output_tokens": 1024, + "max_receipts": 50 + }, + "policy": { + "allow_verbs": ["fetch", "clean", "summarize"], + "deny_verbs": [], + "risk_level": "low" + }, + "idempotency_key": "string" +} +``` + +--- + +## Response Shape + +```json +{ + "ok": true, + "mode": "explicit | scored", + "run_id": "run_...", + "duration_ms": 1234, + "receipts": [], + "state": { + "goal": "string", + "tasks": [], + "artifacts": [], + "decisions": [], + "trace": [], + "meta": {} + }, + "final": {}, + "idempotency": { "hit": false } +} +``` + +--- + +## Scored Mode: Score Function + +``` +score(verb) = + 0.40 * relevance(goal, verb) -- keyword match (deterministic table) ++ 0.30 * need_bonus(verb) -- stable ordering bias ++ 0.10 * novelty(verb) -- +1 first run, -0.5 subsequent ++ 0.10 * cost_penalty(verb) -- negative scalar ++ 0.10 * risk_penalty(policy, verb) -- 0 for all commons verbs +``` + +**need_bonus order**: fetch > clean > summarize > classify > parse > analyze > format > describe/explain/convert + +**Stop condition**: stop if best score <= 0 (no positive move exists). + +**Tie-break**: alphabetical (lexicographic ascending). + +**No randomness**. All decisions are deterministic given the same input. + +--- + +## Explicit Plan Mode + +Steps execute in order. For each step: + +1. Check `conditions.skip_if_exists` → skip if artifact present +2. Check `conditions.requires_artifact` → skip if artifact absent +3. Check `conditions.max_runs` → skip if verb ran >= max_runs times +4. Execute step +5. On failure: if `optional: false`, abort run; if `optional: true`, record and continue + +--- + +## Prerequisite Graph + +| Verb | Prerequisite | Blocks if | +|---|---|---| +| fetch | source URL exists | fetched artifact already present | +| clean | fetched OR input content | cleaned already present | +| parse | any content | parsed already present | +| summarize | any content | summary already present | +| classify | any content or summary | classification already present | +| analyze | any content | analysis already present | +| convert | goal contains "convert" | — | +| describe | goal matches describe/what is | — | +| explain | goal matches explain/why/how | — | +| format | useful artifact exists | final already present | + +--- + +## Schema-Shaped Request Constraints + +These match the `protocol-commons` schema definitions (pinned, immutable). + +**fetch** (special): +```json +{ "x402": { "verb": "fetch", "version": "1.0.0", "entry": "..." }, "source": "" } +``` + +**All other verbs**: +```json +{ + "x402": { "verb": "", "version": "1.0.0", "entry": "..." }, + "actor": "router", + "limits": { "max_output_tokens": 1024 }, + "channel": { "protocol": "https", "endpoint": "...", "input_modalities": ["text"], "output_modalities": ["json"] }, + "input": { "content": "..." } +} +``` + +--- + +## State Model (Append-Only) + +```typescript +type Artifact = { id: string; at: string; type: string; data: unknown }; +type Decision = { at: string; who: string; why: string }; +type Trace = { at: string; step: number; verb: string; why: string; ok: boolean }; +type RunState = { goal: string|null; tasks: unknown[]; artifacts: Artifact[]; + decisions: Decision[]; trace: Trace[]; meta: Record }; +``` + +Artifact types produced by verb: +`fetch→fetched`, `clean→cleaned`, `summarize→summary`, `classify→classification`, +`analyze→analysis`, `parse→parsed`, `convert→converted`, `describe→description`, +`explain→explanation`, `format→final` + +--- + +## Idempotency + +- Header: `Idempotency-Key` or `X-Idempotency-Key` +- Body field: `idempotency_key` +- Auto-generated: `SHA256(stable_stringify(request_body))` +- Cache TTL: `IDEMPOTENCY_TTL_MS` (default 60 000 ms) +- Cache hit returns identical response with `idempotency.hit: true` + +--- + +## Stop Conditions (No Dumb Loops) + +1. `final` artifact exists in state +2. Score <= 0 (scored mode: no positive move) +3. `max_steps` reached +4. `timeout_ms` exceeded +5. `max_receipts` reached +6. Non-optional step failed (explicit mode) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0a15b42 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1046 @@ +{ + "name": "@commandlayer/router", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@commandlayer/router", + "version": "0.1.0", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "express": "^4.21.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.11.30", + "typescript": "^5.6.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.33", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/src/cli/cl-router.ts b/src/cli/cl-router.ts new file mode 100644 index 0000000..461102a --- /dev/null +++ b/src/cli/cl-router.ts @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * cl-router CLI + * + * Usage: + * cl-router run --goal "summarize this" --content "..." [--source ] + * cl-router run --goal "fetch and summarize" --source https://example.com + * cl-router run --plan path/to/plan.json + * + * Env: + * RUNTIME_URL — if set, delegates step execution to remote runtime + * + * Output: JSON run result to stdout + */ + +import { runEngine } from "../router/engine"; +import { RuntimeClient } from "../client/runtimeClient"; +import type { RunRequest } from "../types/run"; +import * as fs from "fs"; + +const args = process.argv.slice(2); + +function flag(name: string): string | null { + const i = args.indexOf(name); + if (i === -1) return null; + return args[i + 1] ?? null; +} + +function hasFlag(name: string): boolean { + return args.includes(name); +} + +if (hasFlag("--help") || args.length === 0) { + console.log(`cl-router — CommandLayer Router CLI + +Commands: + run Execute a run (scored or explicit plan) + +Flags (run): + --goal Natural language goal (scored mode) + --content Input content string + --source Input source URL (triggers fetch) + --plan Path to a RunRequest JSON file (explicit mode) + --mode explicit|scored Override plan mode + +Env: + RUNTIME_URL Delegate execution to remote runtime + +Examples: + cl-router run --goal "summarize this" --content "Hello world" + cl-router run --goal "fetch and summarize" --source https://example.com + cl-router run --plan my-plan.json +`); + process.exit(0); +} + +const command = args[0]; + +if (command !== "run") { + console.error(`Unknown command: ${command}. Try --help.`); + process.exit(1); +} + +async function main() { + let body: RunRequest; + + const planFile = flag("--plan"); + if (planFile) { + body = JSON.parse(fs.readFileSync(planFile, "utf8")) as RunRequest; + } else { + const goal = flag("--goal") ?? ""; + const content = flag("--content") ?? ""; + const source = flag("--source") ?? ""; + const mode = flag("--mode"); + + body = { + goal, + input: { + ...(content ? { content } : {}), + ...(source ? { source } : {}), + }, + plan: { + mode: mode === "explicit" ? "explicit" : "scored", + }, + }; + } + + const runtimeBase = String(process.env.RUNTIME_URL ?? "").trim(); + const client = runtimeBase ? new RuntimeClient(runtimeBase) : null; + + const result = await runEngine({ body, client }); + console.log(JSON.stringify(result, null, 2)); +} + +main().catch((err) => { + console.error("cl-router error:", err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/src/client/runtimeClient.ts b/src/client/runtimeClient.ts new file mode 100644 index 0000000..bdde1e9 --- /dev/null +++ b/src/client/runtimeClient.ts @@ -0,0 +1,56 @@ +import type { RunRequest, RunResponse } from "../types/run"; + +/** + * HTTP client for the CommandLayer Runtime. + * Used when RUNTIME_URL is set — routes verb execution to the remote runtime + * instead of using local handlers. + * + * The router engine selects verbs deterministically; this client transports + * the request over HTTP to the runtime and returns the response. + */ +export class RuntimeClient { + constructor(private readonly baseUrl: string) {} + + async run( + body: RunRequest, + idempotencyKey?: string, + timeoutMs = 15_000 + ): Promise { + if (!this.baseUrl) { + throw new Error( + "RuntimeClient: RUNTIME_URL is not set. Set env RUNTIME_URL to call runtime over HTTP." + ); + } + + const url = `${this.baseUrl.replace(/\/$/, "")}/run/v1.0.0`; + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), Math.max(1000, timeoutMs)); + + try { + const resp = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(idempotencyKey + ? { + "Idempotency-Key": idempotencyKey, + "X-Idempotency-Key": idempotencyKey, + } + : {}), + }, + body: JSON.stringify(body), + signal: ctrl.signal, + }); + + if (!resp.ok) { + throw new Error( + `runtime returned ${resp.status}: ${await resp.text().catch(() => "")}` + ); + } + + return (await resp.json()) as RunResponse; + } finally { + clearTimeout(t); + } + } +} diff --git a/src/idempotency/cache.ts b/src/idempotency/cache.ts new file mode 100644 index 0000000..8ab0ec9 --- /dev/null +++ b/src/idempotency/cache.ts @@ -0,0 +1,61 @@ +import crypto from "crypto"; + +/** + * In-memory idempotency cache. + * + * Keys: SHA-256 of stable-stringified request body (or caller-supplied key). + * TTL: IDEMPOTENCY_TTL_MS env (default: 60 000 ms). + * + * This is a single-process in-memory cache suitable for single-instance + * deployments. For multi-instance, replace with Redis or a distributed store. + */ + +type Entry = { at: number; ttlMs: number; value: unknown }; + +const CACHE = new Map(); + +function ttlMs(): number { + return Number(process.env.IDEMPOTENCY_TTL_MS ?? 60_000); +} + +export function makeIdempotencyKey(stableBody: string): string { + return crypto.createHash("sha256").update(stableBody).digest("hex"); +} + +/** + * Get cached value or compute+store it. + * If compute() returns null/undefined, does NOT store (caller signals "not ready"). + * Returns null if cache miss and compute() returned null. + */ +export function getOrSetIdempotent( + key: string, + compute: () => unknown, + ttl?: number +): unknown { + const now = Date.now(); + const existing = CACHE.get(key); + if (existing && now - existing.at <= existing.ttlMs) { + return existing.value; + } + + const v = compute(); + if (v !== null && v !== undefined) { + CACHE.set(key, { at: now, ttlMs: ttl ?? ttlMs(), value: v }); + } + return v ?? null; +} + +/** + * Explicitly set an idempotency entry (used after run completes). + */ +export function setIdempotent(key: string, value: unknown, ttl?: number): void { + CACHE.set(key, { at: Date.now(), ttlMs: ttl ?? ttlMs(), value }); +} + +export function clearIdempotencyCache(): void { + CACHE.clear(); +} + +export function cacheSize(): number { + return CACHE.size; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..40d4d87 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,73 @@ +// ─── Types ──────────────────────────────────────────────────────────────────── +export type { + PlanMode, + PlanStep, + RunPolicy, + RunLimits, + RunInput, + RunRequest, + Artifact, + Decision, + Trace, + RunState, + RunResponse, + VerifyResult, +} from "./types/run"; + +export { COMMONS_VERB_LIST, ENS_RUNTIME_SIGNER, ENS_CLEANAGENT_EXAMPLE } from "./types/commons"; +export type { CommonsVerb } from "./types/commons"; + +// ─── Router ─────────────────────────────────────────────────────────────────── +export { COMMONS_VERBS } from "./router/commonsVerbs"; +export { isVerbAllowed } from "./router/policy"; +export { verbPrereq } from "./router/prereqs"; +export type { PrereqCtx, PrereqResult } from "./router/prereqs"; +export { scoreVerb, chooseVerbScored } from "./router/scoring"; +export { + initState, + addArtifact, + hasArtifact, + hasAnyArtifact, + bestContent, + mergeState, + nowIso, + randId, +} from "./router/state"; +export { buildCommonsRequest, defaultChannelFor, baseEnvelope } from "./router/requestBuilders"; +export { applyReceiptToState } from "./router/applyReceipt"; +export { shouldStop } from "./router/stop"; +export { runEngine, stableStringify, sha256Hex } from "./router/engine"; +export type { EngineRunArgs, EngineRunResult } from "./router/engine"; + +// ─── Schemas ────────────────────────────────────────────────────────────────── +export { requestSchemaUrl, receiptSchemaUrl, sharedSchemaUrl } from "./schemas/urls"; +export { getValidator, warmValidator, getCacheStats, isWarmed } from "./schemas/ajv"; +export { enqueueAllSchemas, enqueueSchema } from "./schemas/warm"; + +// ─── Receipts ───────────────────────────────────────────────────────────────── +export { + signReceiptEd25519, + verifyReceiptEd25519, + canonicalJsonSortedKeysV1, + CANONICAL_ID_SORTED_KEYS_V1, +} from "./receipts/sign"; +export type { SignedReceipt } from "./receipts/sign"; +export { resolveEnsPubkey, FALLBACK_PUBKEY_PEM } from "./receipts/ens"; +export type { EnsRecord } from "./receipts/ens"; + +// ─── Security ───────────────────────────────────────────────────────────────── +export { assertSafeFetchUrl, SsrfError } from "./security/ssrf"; + +// ─── Idempotency ────────────────────────────────────────────────────────────── +export { + makeIdempotencyKey, + getOrSetIdempotent, + setIdempotent, + clearIdempotencyCache, +} from "./idempotency/cache"; + +// ─── Logging ────────────────────────────────────────────────────────────────── +export { appendEvent, readRunLog } from "./logging/replay"; + +// ─── Client ─────────────────────────────────────────────────────────────────── +export { RuntimeClient } from "./client/runtimeClient"; diff --git a/src/logging/replay.ts b/src/logging/replay.ts new file mode 100644 index 0000000..8a88793 --- /dev/null +++ b/src/logging/replay.ts @@ -0,0 +1,45 @@ +import fs from "fs"; +import path from "path"; + +/** + * NDJSON replay logger. + * + * Writes run events to ${RUN_LOG_DIR}/run-${run_id}.ndjson. + * Event types: run_start, step_start, step_done, step_skip, step_error, + * run_abort, run_done, run_error. + * + * Never fails the request if logging fails. + */ + +function logDir(): string { + return process.env.RUN_LOG_DIR ?? "run-logs"; +} + +export function appendEvent(runId: string, event: Record): void { + try { + const dir = logDir(); + fs.mkdirSync(dir, { recursive: true }); + const filepath = path.join(dir, `run-${runId}.ndjson`); + fs.appendFileSync(filepath, JSON.stringify(event) + "\n", "utf8"); + } catch { + // Intentionally silent: logging must never fail a request. + } +} + +/** + * Read all events for a run (for /replay/:run_id). + * Returns null if no log file exists. + */ +export function readRunLog(runId: string): Record[] | null { + try { + const dir = logDir(); + const filepath = path.join(dir, `run-${runId}.ndjson`); + const content = fs.readFileSync(filepath, "utf8"); + return content + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); + } catch { + return null; + } +} diff --git a/src/receipts/ens.ts b/src/receipts/ens.ts new file mode 100644 index 0000000..c1964ec --- /dev/null +++ b/src/receipts/ens.ts @@ -0,0 +1,184 @@ +/** + * ENS TXT record resolution for receipt pubkey verification. + * + * Resolves cl.receipt.pubkey.pem (or ENS_PUBKEY_TEXT_KEY) from the configured + * ENS name (default: runtime.commandlayer.eth) via an ETH JSON-RPC provider. + * + * This is a lightweight resolver that does NOT import ethers/viem to avoid + * heavy dependencies. Uses raw eth_call JSON-RPC to the ENS PublicResolver. + * + * Canonical ENS anchor (runtime.commandlayer.eth): + * cl.sig.kid = "v1" + * cl.sig.pub = "ed25519:CEHI9g4v8qMx8TLlbLVW7RtiCmzRF7U7gpkrp0iB/a0=" + * cl.sig.canonical = "json.sorted_keys.v1" + * cl.receipt.signer = "runtime.commandlayer.eth" + * cl.receipt.pubkey.pem = "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" + * alg: ed25519 + */ + +const RESOLVE_TIMEOUT_MS = 5000; + +// Hardcoded fallback pubkey (from ENS TXT canonical config). +// Used when ENS resolution is unavailable or ETH_RPC_URL is not set. +export const FALLBACK_PUBKEY_PEM = + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA7Vkkmt6R02Iltp/+i3D5mraZyvLjfuTSVB33KwfzQC8=\n-----END PUBLIC KEY-----"; + +export type EnsRecord = { + pubkey_pem: string; + kid: string; + alg: string; + canonical: string; + signer: string; + source: "ens" | "fallback"; +}; + +/** + * Attempt to resolve the ENS TXT pubkey record. + * Falls back to the hardcoded canonical value if resolution fails. + */ +export async function resolveEnsPubkey(ensName?: string): Promise { + const name = ensName ?? process.env.VERIFIER_ENS_NAME ?? "runtime.commandlayer.eth"; + const rpcUrl = process.env.ETH_RPC_URL ?? ""; + const textKey = process.env.ENS_PUBKEY_TEXT_KEY ?? "cl.receipt.pubkey.pem"; + + if (!rpcUrl) { + return fallbackRecord(); + } + + try { + const resolved = await resolveTextRecord(rpcUrl, name, textKey); + if (!resolved) return fallbackRecord(); + return { + pubkey_pem: resolved, + kid: "v1", + alg: "ed25519", + canonical: "json.sorted_keys.v1", + signer: name, + source: "ens", + }; + } catch { + return fallbackRecord(); + } +} + +function fallbackRecord(): EnsRecord { + return { + pubkey_pem: FALLBACK_PUBKEY_PEM, + kid: "v1", + alg: "ed25519", + canonical: "json.sorted_keys.v1", + signer: "runtime.commandlayer.eth", + source: "fallback", + }; +} + +/** + * Resolve a TXT record from ENS using raw eth_call. + * Uses ENS Universal Resolver + PublicResolver.text() ABI. + * + * This is intentionally minimal — no ethers/viem dependency. + */ +async function resolveTextRecord( + rpcUrl: string, + name: string, + key: string +): Promise { + // ENS namehash + const node = namehash(name); + + // PublicResolver.text(bytes32,string) selector = 0x59d1d43c + // Encode: text(node, key) + const keyEncoded = encodeString(key); + const data = "0x59d1d43c" + node.slice(2) + keyEncoded; + + // ENS PublicResolver on mainnet + const to = "0x4976fb03C32e5B8cfe2b6cCB31c09Ba78EBaBa41"; + + const payload = { + jsonrpc: "2.0", + id: 1, + method: "eth_call", + params: [{ to, data }, "latest"], + }; + + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), RESOLVE_TIMEOUT_MS); + + try { + const resp = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + + const json = (await resp.json()) as { + result?: string; + error?: unknown; + }; + + if (!json.result || json.result === "0x") return null; + + return decodeString(json.result); + } finally { + clearTimeout(t); + } +} + +// ─── Minimal ABI codec ──────────────────────────────────────────────────────── + +function namehash(name: string): string { + let node = "0x" + "0".repeat(64); + if (!name) return node; + const labels = name.split(".").reverse(); + for (const label of labels) { + const labelHash = keccak256(new TextEncoder().encode(label)); + const nodeBytes = hexToBytes(node.slice(2)); + const combined = new Uint8Array(64); + combined.set(nodeBytes, 0); + combined.set(labelHash, 32); + node = "0x" + bytesToHex(keccak256(combined)); + } + return node; +} + +function encodeString(s: string): string { + const bytes = new TextEncoder().encode(s); + // offset (64 for the node arg already consumed) = 0x40 + const offset = "0000000000000000000000000000000000000000000000000000000000000040"; + const len = bytes.length.toString(16).padStart(64, "0"); + const padded = bytesToHex(bytes).padEnd(Math.ceil(bytes.length / 32) * 64, "0"); + return offset + len + padded; +} + +function decodeString(hex: string): string { + const clean = hex.startsWith("0x") ? hex.slice(2) : hex; + // ABI: offset at 0, then length at offset, then string bytes + const offsetHex = clean.slice(0, 64); + const offset = parseInt(offsetHex, 16) * 2; + const lenHex = clean.slice(offset, offset + 64); + const len = parseInt(lenHex, 16); + const strHex = clean.slice(offset + 64, offset + 64 + len * 2); + return new TextDecoder().decode(hexToBytes(strHex)); +} + +function hexToBytes(hex: string): Uint8Array { + const arr = new Uint8Array(Math.ceil(hex.length / 2)); + for (let i = 0; i < arr.length; i++) { + arr[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return arr; +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +// Node.js crypto keccak256 — uses dynamic import to avoid top-level await issues +function keccak256(data: Uint8Array): Uint8Array { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { createHash } = require("crypto"); + return createHash("sha3-256").update(data).digest(); +} diff --git a/src/receipts/sign.ts b/src/receipts/sign.ts new file mode 100644 index 0000000..fcaf9e9 --- /dev/null +++ b/src/receipts/sign.ts @@ -0,0 +1,113 @@ +import crypto from "crypto"; + +/** + * Receipt signing using Ed25519 + SHA-256 + canonical JSON (sorted keys v1). + * + * Matches runtime.commandlayer.eth ENS anchor: + * alg: ed25519 + * canonicalization: json.sorted_keys.v1 + * kid: v1 + * + * Private key loaded from env: + * RECEIPT_SIGNING_PRIVATE_KEY_PEM — PEM-encoded Ed25519 private key + * RECEIPT_SIGNING_PRIVATE_KEY_B64 — base64-encoded raw 32-byte private key seed + */ + +export const CANONICAL_ID_SORTED_KEYS_V1 = "json.sorted_keys.v1"; + +export function canonicalJsonSortedKeysV1(data: unknown): string { + const seen = new WeakSet(); + const norm = (v: unknown): unknown => { + if (v !== null && typeof v === "object") { + if (seen.has(v as object)) return null; + seen.add(v as object); + if (Array.isArray(v)) return v.map(norm); + const keys = Object.keys(v as Record).sort(); + const out: Record = {}; + for (const k of keys) out[k] = norm((v as Record)[k]); + return out; + } + return v; + }; + return JSON.stringify(norm(data)); +} + +function loadPrivateKey(): crypto.KeyObject | null { + const pem = process.env.RECEIPT_SIGNING_PRIVATE_KEY_PEM; + if (pem) { + return crypto.createPrivateKey({ key: pem.replace(/\\n/g, "\n"), format: "pem" }); + } + const b64 = process.env.RECEIPT_SIGNING_PRIVATE_KEY_B64; + if (b64) { + const seed = Buffer.from(b64, "base64"); + return crypto.createPrivateKey({ + key: seed, + format: "der", + type: "pkcs8", + }); + } + return null; +} + +export type SignedReceipt = { + payload: unknown; + canonical: string; + hash: string; + sig: string; + alg: string; + kid: string; + signer: string; +}; + +/** + * Sign a receipt payload with Ed25519 + SHA-256 + canonical JSON sorted keys v1. + * Returns null if no signing key is configured (signing is optional in router). + */ +export function signReceiptEd25519(payload: unknown): SignedReceipt | null { + const key = loadPrivateKey(); + if (!key) return null; + + const canonical = canonicalJsonSortedKeysV1(payload); + const hash = crypto.createHash("sha256").update(canonical).digest("hex"); + const sig = crypto.sign(null, Buffer.from(canonical), key).toString("base64url"); + + return { + payload, + canonical, + hash, + sig, + alg: "ed25519", + kid: "v1", + signer: process.env.VERIFIER_ENS_NAME ?? "runtime.commandlayer.eth", + }; +} + +/** + * Verify a signed receipt using an Ed25519 PEM public key. + */ +export function verifyReceiptEd25519( + receipt: SignedReceipt, + pubkeyPem: string +): { ok: boolean; error?: string } { + try { + const pubkey = crypto.createPublicKey({ + key: pubkeyPem.replace(/\\n/g, "\n"), + format: "pem", + }); + const canonical = canonicalJsonSortedKeysV1(receipt.payload); + const expectedHash = crypto.createHash("sha256").update(canonical).digest("hex"); + + if (receipt.hash !== expectedHash) { + return { ok: false, error: "hash_mismatch" }; + } + + const sigBytes = Buffer.from(receipt.sig, "base64url"); + const ok = crypto.verify(null, Buffer.from(canonical), pubkey, sigBytes); + return ok ? { ok: true } : { ok: false, error: "sig_invalid" }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/src/receipts/verify.ts b/src/receipts/verify.ts new file mode 100644 index 0000000..9137dfe --- /dev/null +++ b/src/receipts/verify.ts @@ -0,0 +1,141 @@ +import type { Request, Response } from "express"; +import { verifyReceiptEd25519, canonicalJsonSortedKeysV1 } from "./sign"; +import { resolveEnsPubkey, FALLBACK_PUBKEY_PEM } from "./ens"; +import { getValidator, isWarmed } from "../schemas/ajv"; +import { receiptSchemaUrl } from "../schemas/urls"; +import type { VerifyResult } from "../types/run"; + +/** + * POST /verify + * + * Query params: + * ?ens=1 — resolve pubkey from ENS TXT (runtime.commandlayer.eth) + * ?schema=1 — also validate receipt against its verb's schema + * + * Body: the full signed receipt object. + * + * Behavior for ?schema=1: + * - VERIFY_SCHEMA_CACHED_ONLY=true and validator not warmed → 202 with retry_after_ms + * - Otherwise: fetch+compile on demand (bounded timeout/size) + */ +export async function verifyHandler(req: Request, res: Response): Promise { + const receipt = req.body as Record; + + if (!receipt || typeof receipt !== "object") { + res.status(400).json({ ok: false, error: "receipt body required" }); + return; + } + + const useEns = req.query["ens"] === "1"; + const useSchema = req.query["schema"] === "1"; + + let pubkeyPem = FALLBACK_PUBKEY_PEM; + let pubkeySource: "inline" | "ens" = "inline"; + let ensResolved = false; + + // 1) Resolve pubkey + if (useEns) { + const ensRecord = await resolveEnsPubkey(); + pubkeyPem = ensRecord.pubkey_pem; + pubkeySource = ensRecord.source === "ens" ? "ens" : "inline"; + ensResolved = ensRecord.source === "ens"; + } + + // 2) Verify signature + hash + const signedReceipt = { + payload: receipt["payload"] ?? receipt, + canonical: "", + hash: String(receipt["hash"] ?? ""), + sig: String(receipt["sig"] ?? ""), + alg: String(receipt["alg"] ?? "ed25519"), + kid: String(receipt["kid"] ?? "v1"), + signer: String(receipt["signer"] ?? "runtime.commandlayer.eth"), + }; + signedReceipt.canonical = canonicalJsonSortedKeysV1(signedReceipt.payload); + + const { ok: sigOk, error: sigErr } = verifyReceiptEd25519(signedReceipt, pubkeyPem); + const hashExpected = signedReceipt.canonical + ? require("crypto") + .createHash("sha256") + .update(signedReceipt.canonical) + .digest("hex") + : ""; + const hashValid = signedReceipt.hash === hashExpected; + + const result: VerifyResult = { + ok: sigOk && hashValid, + sig_valid: sigOk, + hash_valid: hashValid, + ens_resolved: ensResolved, + pubkey_source: pubkeySource, + ...(sigErr ? { error: sigErr } : {}), + }; + + // 3) Schema validation (optional) + if (useSchema) { + const verb = String( + (receipt["payload"] as Record)?.["verb"] ?? + (receipt["x402"] as Record)?.["verb"] ?? + receipt["verb"] ?? + "" + ); + + if (!verb) { + result.schema_valid = false; + result.error = (result.error ? result.error + "; " : "") + "schema: no verb in receipt"; + } else { + const url = receiptSchemaUrl(verb); + const cachedOnly = process.env.VERIFY_SCHEMA_CACHED_ONLY === "true"; + + if (cachedOnly && !isWarmed(url)) { + // Return 202 with retry hint — do not block + res.status(202).json({ + ok: false, + error: "validator_not_warmed_yet", + error_code: "schema_validator_not_warmed", + retry_after_ms: 5000, + verb, + schema_url: url, + }); + return; + } + + try { + const validate = await getValidator(url); + if (!validate) { + // cached-only + not warmed (should have been caught above, defensive) + res.status(202).json({ + ok: false, + error: "validator_not_warmed_yet", + error_code: "schema_validator_not_warmed", + retry_after_ms: 5000, + }); + return; + } + + const payload = receipt["payload"] ?? receipt; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const vfn = validate as any; + const schemaOk = vfn(payload) as boolean; + result.schema_valid = schemaOk; + if (!schemaOk && vfn.errors) { + result.error = + (result.error ? result.error + "; " : "") + + "schema: " + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (vfn.errors as any[]).map((e: any) => `${e.instancePath} ${e.message}`).join(", "); + } + result.ok = result.ok && schemaOk; + } catch (err) { + result.schema_valid = false; + result.error = + (result.error ? result.error + "; " : "") + + "schema_error: " + + (err instanceof Error ? err.message : String(err)); + result.ok = false; + } + } + } + + res.status(200).json(result); +} diff --git a/src/schemas/ajv.ts b/src/schemas/ajv.ts new file mode 100644 index 0000000..1e97e91 --- /dev/null +++ b/src/schemas/ajv.ts @@ -0,0 +1,123 @@ +// AJV 8 with ESM/CJS interop +// eslint-disable-next-line @typescript-eslint/no-require-imports +const AjvCtor = require("ajv"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const addFormats = require("ajv-formats"); + +const Ajv = AjvCtor.default ?? AjvCtor; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ValidateFunction = (data: unknown) => boolean & { errors?: any[] | null }; + +// ─── AJV instance (singleton) ──────────────────────────────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let _ajv: any = null; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getAjv(): any { + if (!_ajv) { + _ajv = new Ajv({ + allErrors: true, + strict: false, + loadSchema: fetchSchemaJson, + }); + const fmt = addFormats.default ?? addFormats; + fmt(_ajv); + } + return _ajv; +} + +// ─── Schema JSON fetch ──────────────────────────────────────────────────────── + +const FETCH_TIMEOUT_MS = 8000; +const MAX_SCHEMA_BYTES = 512 * 1024; // 512 KB + +async function fetchSchemaJson(url: string): Promise> { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); + try { + const resp = await fetch(url, { signal: ctrl.signal }); + if (!resp.ok) throw new Error(`schema fetch failed: ${resp.status} ${url}`); + + const len = resp.headers.get("content-length"); + if (len && parseInt(len, 10) > MAX_SCHEMA_BYTES) { + throw new Error(`schema too large: ${url}`); + } + + const text = await resp.text(); + if (text.length > MAX_SCHEMA_BYTES) throw new Error(`schema too large: ${url}`); + + return JSON.parse(text) as Record; + } finally { + clearTimeout(t); + } +} + +// ─── Compiled validator cache ───────────────────────────────────────────────── + +const _validatorCache = new Map(); +const _inFlight = new Map>(); + + +/** + * Get a compiled AJV validator for the given schema URL. + * - Returns cached validator immediately if available. + * - Fetches + compiles on demand if VERIFY_SCHEMA_CACHED_ONLY is not "true". + * - Returns null if cached-only mode is enabled and validator is not warmed. + */ +export async function getValidator( + schemaUrl: string +): Promise { + const cached = _validatorCache.get(schemaUrl); + if (cached) return cached; + + const cachedOnly = process.env.VERIFY_SCHEMA_CACHED_ONLY === "true"; + if (cachedOnly) return null; + + // Deduplicate concurrent compiles for the same URL + const inflight = _inFlight.get(schemaUrl); + if (inflight) return inflight; + + const p = (async (): Promise => { + const ajv = getAjv(); + const schema = await fetchSchemaJson(schemaUrl); + const validate = await ajv.compileAsync(schema); + _validatorCache.set(schemaUrl, validate); + _inFlight.delete(schemaUrl); + return validate; + })(); + + _inFlight.set(schemaUrl, p); + + try { + return await p; + } catch (err) { + _inFlight.delete(schemaUrl); + throw err; + } +} + +/** + * Pre-warm a validator without blocking. + * Called from the warm queue. + */ +export function warmValidator(schemaUrl: string): void { + if (_validatorCache.has(schemaUrl)) return; + // Fire-and-forget + getValidator(schemaUrl).catch(() => {/* silent; warm queue will retry */}); +} + +export function getCacheStats(): { + cached: string[]; + inflight: number; +} { + return { + cached: [..._validatorCache.keys()], + inflight: _inFlight.size, + }; +} + +export function isWarmed(schemaUrl: string): boolean { + return _validatorCache.has(schemaUrl); +} diff --git a/src/schemas/urls.ts b/src/schemas/urls.ts new file mode 100644 index 0000000..260124d --- /dev/null +++ b/src/schemas/urls.ts @@ -0,0 +1,20 @@ +/** + * Schema URL builders for CommandLayer Protocol Commons. + * Loads from SCHEMA_HOST env (default: https://www.commandlayer.org). + */ + +export function schemaHost(): string { + return (process.env.SCHEMA_HOST ?? "https://www.commandlayer.org").replace(/\/$/, ""); +} + +export function requestSchemaUrl(verb: string): string { + return `${schemaHost()}/schemas/v1.0.0/commons/${verb}/requests/${verb}.request.schema.json`; +} + +export function receiptSchemaUrl(verb: string): string { + return `${schemaHost()}/schemas/v1.0.0/commons/${verb}/receipts/${verb}.receipt.schema.json`; +} + +export function sharedSchemaUrl(filename: string): string { + return `${schemaHost()}/schemas/v1.0.0/_shared/${filename}`; +} diff --git a/src/schemas/warm.ts b/src/schemas/warm.ts new file mode 100644 index 0000000..d39fc56 --- /dev/null +++ b/src/schemas/warm.ts @@ -0,0 +1,78 @@ +import { COMMONS_VERBS } from "../router/commonsVerbs"; +import { requestSchemaUrl, receiptSchemaUrl } from "./urls"; +import { warmValidator, getCacheStats } from "./ajv"; + +// ─── Warm queue ─────────────────────────────────────────────────────────────── +// Background queue for pre-compiling AJV validators. +// Never blocks the request path. + +type WarmJob = { schemaUrl: string; attempts: number }; + +const _queue: WarmJob[] = []; +let _running = false; +let _jobCounter = 0; + +export type WarmQueueStatus = { + job_id: string; + queued: number; + cached: string[]; + inflight: number; +}; + +/** + * Enqueue all Commons verb request + receipt schemas for background warming. + * Returns immediately (non-blocking). + */ +export function enqueueAllSchemas(): WarmQueueStatus { + const jobId = `warm_${++_jobCounter}_${Date.now()}`; + + for (const verb of COMMONS_VERBS) { + enqueueSchema(requestSchemaUrl(verb)); + enqueueSchema(receiptSchemaUrl(verb)); + } + + drainQueue(); + + const stats = getCacheStats(); + return { + job_id: jobId, + queued: _queue.length, + cached: stats.cached, + inflight: stats.inflight, + }; +} + +export function enqueueSchema(schemaUrl: string): void { + _queue.push({ schemaUrl, attempts: 0 }); +} + +/** + * Drain the warm queue in the background (non-blocking). + * Processes one job at a time with 100ms inter-job delay to avoid I/O spikes. + */ +function drainQueue(): void { + if (_running) return; + _running = true; + void processNext(); +} + +async function processNext(): Promise { + const job = _queue.shift(); + if (!job) { + _running = false; + return; + } + + try { + warmValidator(job.schemaUrl); + } catch { + // If it fails, re-queue with a limit + if (job.attempts < 2) { + _queue.push({ schemaUrl: job.schemaUrl, attempts: job.attempts + 1 }); + } + } + + // Yield to event loop between jobs + await new Promise((resolve) => setTimeout(resolve, 100)); + void processNext(); +} diff --git a/src/security/ssrf.ts b/src/security/ssrf.ts new file mode 100644 index 0000000..0096d22 --- /dev/null +++ b/src/security/ssrf.ts @@ -0,0 +1,105 @@ +import dns from "dns"; +import { promisify } from "util"; + +const resolve4 = promisify(dns.resolve4); + +/** + * SSRF guard for the fetch verb. + * + * Blocks: + * - Non-http(s) protocols + * - localhost, 127.x, 0.0.0.0 + * - 169.254.169.254 (AWS/GCP metadata) + * - Private IPv4 ranges: 10.x, 172.16-31.x, 192.168.x + * - All IPv6 (blocked by default) + * + * Allows: + * - ALLOW_FETCH_HOSTS env: comma-separated trusted hostnames that bypass DNS check + */ + +const PRIVATE_RANGES = [ + /^127\./, + /^10\./, + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, + /^192\.168\./, + /^169\.254\./, + /^0\.0\.0\.0$/, + /^::1$/, + /^fc00:/, + /^fe80:/, +]; + +export class SsrfError extends Error { + constructor(message: string) { + super(message); + this.name = "SsrfError"; + } +} + +function getAllowedHosts(): Set { + const raw = process.env.ALLOW_FETCH_HOSTS ?? ""; + return new Set( + raw + .split(",") + .map((h) => h.trim().toLowerCase()) + .filter(Boolean) + ); +} + +function isPrivateIp(ip: string): boolean { + return PRIVATE_RANGES.some((r) => r.test(ip)); +} + +/** + * Validate that a URL is safe to fetch. + * Throws SsrfError if blocked. + */ +export async function assertSafeFetchUrl(urlStr: string): Promise { + let parsed: URL; + try { + parsed = new URL(urlStr); + } catch { + throw new SsrfError(`invalid URL: ${urlStr}`); + } + + const proto = parsed.protocol; + if (proto !== "http:" && proto !== "https:") { + throw new SsrfError(`blocked protocol: ${proto}`); + } + + const hostname = parsed.hostname.toLowerCase(); + + // Block IPv6 literals (all blocked by default) + if (hostname.startsWith("[")) { + throw new SsrfError(`blocked: IPv6 addresses not allowed`); + } + + // Allowlist bypass (trusted hosts) + const allowed = getAllowedHosts(); + if (allowed.has(hostname)) return; + + // Block known bad literals before DNS + if (isPrivateIp(hostname)) { + throw new SsrfError(`blocked private/reserved address: ${hostname}`); + } + + if (hostname === "localhost") { + throw new SsrfError(`blocked: localhost`); + } + + // DNS A record resolution check + try { + const addrs = await resolve4(hostname); + for (const addr of addrs) { + if (isPrivateIp(addr)) { + throw new SsrfError(`blocked: ${hostname} resolves to private IP ${addr}`); + } + } + } catch (err) { + if (err instanceof SsrfError) throw err; + // DNS failure is treated as blocked (fail-safe) + throw new SsrfError( + `DNS resolution failed for ${hostname}: ${err instanceof Error ? err.message : String(err)}` + ); + } +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..0e1d2fc --- /dev/null +++ b/src/server.ts @@ -0,0 +1,105 @@ +import express from "express"; +import type { Request, Response } from "express"; +import { runHandler } from "./router/engine"; +import { verifyHandler } from "./receipts/verify"; +import { readRunLog } from "./logging/replay"; +import { getCacheStats } from "./schemas/ajv"; +import { enqueueAllSchemas } from "./schemas/warm"; +import { cacheSize } from "./idempotency/cache"; + +const app = express(); +app.use(express.json({ limit: "2mb" })); + +// ─── CORS (server-to-server; tighten for browser-facing deployments) ────────── +app.use((_req: Request, res: Response, next) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader( + "Access-Control-Allow-Headers", + "Content-Type, Authorization, Idempotency-Key, X-Idempotency-Key" + ); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); + if (_req.method === "OPTIONS") { + res.status(204).end(); + return; + } + next(); +}); + +// ─── Health ──────────────────────────────────────────────────────────────────── +app.get("/health", (_req: Request, res: Response) => { + res.json({ ok: true, service: "@commandlayer/router" }); +}); + +// ─── Core: POST /run/v1.0.0 ─────────────────────────────────────────────────── +app.post("/run/v1.0.0", runHandler); + +// ─── Verify ──────────────────────────────────────────────────────────────────── +app.post("/verify", verifyHandler); + +// ─── Replay: GET /replay/:run_id ────────────────────────────────────────────── +app.get("/replay/:run_id", (req: Request, res: Response) => { + const events = readRunLog(req.params["run_id"] ?? ""); + if (!events) { + res.status(404).json({ ok: false, error: "run not found" }); + return; + } + res.json({ ok: true, run_id: req.params["run_id"], events }); +}); + +// ─── Debug endpoints (gated by ENABLE_DEBUG) ────────────────────────────────── +function debugGate(req: Request, res: Response): boolean { + if (!process.env.ENABLE_DEBUG) { + res.status(404).json({ ok: false, error: "not found" }); + return false; + } + const token = process.env.DEBUG_TOKEN; + if (token) { + const provided = + req.header("Authorization")?.replace("Bearer ", "").trim() ?? ""; + if (provided !== token) { + res.status(401).json({ ok: false, error: "unauthorized" }); + return false; + } + } + return true; +} + +app.post("/debug/prewarm", (req: Request, res: Response) => { + if (!debugGate(req, res)) return; + // Return 202 immediately; warm in background (non-blocking) + const status = enqueueAllSchemas(); + res.status(202).json({ + ok: true, + queued: true, + job_id: status.job_id, + queued_count: status.queued, + cached_count: status.cached.length, + }); +}); + +app.get("/debug/validators", (req: Request, res: Response) => { + if (!debugGate(req, res)) return; + const stats = getCacheStats(); + res.json({ + ok: true, + cached: stats.cached, + cached_count: stats.cached.length, + inflight: stats.inflight, + }); +}); + +app.get("/debug/cache", (req: Request, res: Response) => { + if (!debugGate(req, res)) return; + res.json({ ok: true, idempotency_cache_size: cacheSize() }); +}); + +// ─── Boot ───────────────────────────────────────────────────────────────────── +const PORT = Number(process.env.PORT ?? 8080); +app.listen(PORT, () => { + console.log(`[commandlayer/router] listening on :${PORT}`); + if (process.env.RUNTIME_URL) { + console.log(`[commandlayer/router] runtime delegate → ${process.env.RUNTIME_URL}`); + } else { + console.log(`[commandlayer/router] using local handlers (set RUNTIME_URL for HTTP delegate)`); + } +}); diff --git a/test/engine.test.ts b/test/engine.test.ts new file mode 100644 index 0000000..20d9bae --- /dev/null +++ b/test/engine.test.ts @@ -0,0 +1,128 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { runEngine } from "../src/router/engine"; +import type { RunRequest } from "../src/types/run"; + +test("engine: scored mode with input content produces receipts + state", async () => { + const body: RunRequest = { + goal: "summarize this content", + input: { content: "The quick brown fox jumps over the lazy dog." }, + plan: { mode: "scored" }, + limits: { max_steps: 5 }, + }; + + const result = await runEngine({ body, client: null }); + + assert.ok(typeof result.ok === "boolean"); + assert.ok(typeof result.run_id === "string"); + assert.ok(result.run_id.startsWith("run_")); + assert.ok(result.duration_ms >= 0); + assert.ok(Array.isArray(result.receipts)); + assert.ok(Array.isArray(result.state.artifacts)); + assert.ok(result.state.artifacts.length > 0, "should have produced at least one artifact"); +}); + +test("engine: explicit plan executes steps in order", async () => { + const body: RunRequest = { + goal: "clean then summarize", + input: { content: "Some raw content here." }, + plan: { + mode: "explicit", + steps: [ + { verb: "clean", version: "1.0.0" }, + { verb: "summarize", version: "1.0.0" }, + ], + }, + }; + + const result = await runEngine({ body, client: null }); + const verbs = result.state.trace.map((t) => t.verb); + // Should have executed clean before summarize (if both ran) + const cleanIdx = verbs.indexOf("clean"); + const sumIdx = verbs.indexOf("summarize"); + if (cleanIdx !== -1 && sumIdx !== -1) { + assert.ok(cleanIdx < sumIdx, "clean must run before summarize in explicit plan"); + } +}); + +test("engine: explicit plan skips step when skip_if_exists condition met", async () => { + const body: RunRequest = { + goal: "test skip conditions", + input: { content: "content" }, + plan: { + mode: "explicit", + steps: [ + { verb: "clean", version: "1.0.0" }, + { + verb: "clean", + version: "1.0.0", + conditions: { skip_if_exists: "cleaned" }, + }, + ], + }, + }; + + const result = await runEngine({ body, client: null }); + const cleanRuns = result.state.trace.filter((t) => t.verb === "clean" && t.ok).length; + assert.equal(cleanRuns, 1, "clean should only run once due to skip_if_exists"); +}); + +test("engine: explicit plan respects max_runs condition", async () => { + const body: RunRequest = { + goal: "test max_runs", + input: { content: "content" }, + plan: { + mode: "explicit", + steps: [ + { verb: "clean", version: "1.0.0", conditions: { max_runs: 1 } }, + { verb: "clean", version: "1.0.0", conditions: { max_runs: 1 } }, + { verb: "clean", version: "1.0.0", conditions: { max_runs: 1 } }, + ], + }, + }; + + const result = await runEngine({ body, client: null }); + // clean can only run once per max_runs, but also won't run again because cleaned artifact exists + const cleanRuns = result.state.trace.filter((t) => t.verb === "clean").length; + assert.ok(cleanRuns <= 1, "clean should not exceed max_runs"); +}); + +test("engine: scored mode stops when no positive move", async () => { + const body: RunRequest = { + goal: "", + input: {}, + plan: { mode: "scored" }, + limits: { max_steps: 10 }, + }; + + const result = await runEngine({ body, client: null }); + // Empty goal + no input → no positive move → 0 or few steps + assert.ok(result.receipts.length < 5, "should stop early with no content/goal"); +}); + +test("engine: optional step failure does not abort run", async () => { + // Simulate a plan where first optional step uses unknown verb (will fail) + const body: RunRequest = { + goal: "test optional", + input: { content: "test content" }, + plan: { + mode: "explicit", + steps: [ + { verb: "nonexistent_verb", optional: true }, + { verb: "clean", version: "1.0.0" }, + ], + }, + }; + + const result = await runEngine({ body, client: null }); + // clean should still have run after the optional failure + const cleanRan = result.state.trace.some((t) => t.verb === "clean" && t.ok); + assert.ok(cleanRan, "clean should run after optional step failure"); +}); + +test("engine: run_id is unique across runs", async () => { + const body: RunRequest = { goal: "test", input: { content: "x" }, plan: { mode: "scored" } }; + const r1 = await runEngine({ body, client: null }); + const r2 = await runEngine({ body, client: null }); + assert.notEqual(r1.run_id, r2.run_id, "run_ids must be unique"); +}); diff --git a/test/idempotency.test.ts b/test/idempotency.test.ts new file mode 100644 index 0000000..a57d1bb --- /dev/null +++ b/test/idempotency.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + makeIdempotencyKey, + getOrSetIdempotent, + setIdempotent, + clearIdempotencyCache, + cacheSize, +} from "../src/idempotency/cache"; +import { stableStringify } from "../src/router/engine"; + +test("idempotency: makeIdempotencyKey returns stable hex string", () => { + const k1 = makeIdempotencyKey("hello"); + const k2 = makeIdempotencyKey("hello"); + assert.equal(k1, k2, "same input must produce same key"); + assert.match(k1, /^[0-9a-f]{64}$/, "must be 64-char hex"); +}); + +test("idempotency: stableStringify is deterministic regardless of key order", () => { + const a = { z: 1, a: 2, m: 3 }; + const b = { m: 3, z: 1, a: 2 }; + assert.equal(stableStringify(a), stableStringify(b), "key order must not matter"); +}); + +test("idempotency: getOrSetIdempotent caches on first call and returns cached on second", () => { + clearIdempotencyCache(); + const key = "test-key-1"; + let callCount = 0; + const compute = () => { + callCount++; + return { value: "result" }; + }; + + const first = getOrSetIdempotent(key, compute, 60_000); + assert.equal(callCount, 1, "compute called once on miss"); + assert.deepEqual(first, { value: "result" }); + + const second = getOrSetIdempotent(key, compute, 60_000); + assert.equal(callCount, 1, "compute NOT called on hit"); + assert.deepEqual(second, { value: "result" }, "cache hit returns same value"); +}); + +test("idempotency: null compute result is not cached", () => { + clearIdempotencyCache(); + const key = "test-null-result"; + let callCount = 0; + const computeNull = () => { + callCount++; + return null; + }; + + getOrSetIdempotent(key, computeNull); + getOrSetIdempotent(key, computeNull); + assert.equal(callCount, 2, "null result should not be cached; compute called each time"); +}); + +test("idempotency: setIdempotent stores value directly", () => { + clearIdempotencyCache(); + const key = "set-direct"; + setIdempotent(key, { explicit: true }); + let callCount = 0; + const result = getOrSetIdempotent(key, () => { + callCount++; + return { different: true }; + }); + assert.deepEqual(result, { explicit: true }, "stored value returned"); + assert.equal(callCount, 0, "compute not called"); +}); + +test("idempotency: clearIdempotencyCache empties cache", () => { + setIdempotent("a", "x"); + setIdempotent("b", "y"); + assert.ok(cacheSize() >= 2); + clearIdempotencyCache(); + assert.equal(cacheSize(), 0); +}); diff --git a/test/prereqs.test.ts b/test/prereqs.test.ts new file mode 100644 index 0000000..c59192e --- /dev/null +++ b/test/prereqs.test.ts @@ -0,0 +1,117 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { verbPrereq } from "../src/router/prereqs"; +import type { PrereqCtx } from "../src/router/prereqs"; + +function makeCtx(overrides: Partial = {}): PrereqCtx { + return { + goal: "", + has: () => false, + hasAny: () => false, + hasInputContent: false, + hasSourceUrl: false, + ...overrides, + }; +} + +test("prereqs: fetch requires sourceUrl and not fetched", () => { + assert.equal(verbPrereq("fetch", makeCtx({ hasSourceUrl: true })).ok, true); + assert.equal(verbPrereq("fetch", makeCtx({ hasSourceUrl: false })).ok, false); + // Already fetched → not ok + assert.equal( + verbPrereq( + "fetch", + makeCtx({ hasSourceUrl: true, has: (t) => t === "fetched" }) + ).ok, + false + ); +}); + +test("prereqs: clean requires fetched or input content, cleaned absent", () => { + assert.equal( + verbPrereq("clean", makeCtx({ has: (t) => t === "fetched" })).ok, + true + ); + assert.equal( + verbPrereq("clean", makeCtx({ hasInputContent: true })).ok, + true + ); + // Already cleaned + assert.equal( + verbPrereq( + "clean", + makeCtx({ hasInputContent: true, has: (t) => t === "cleaned" }) + ).ok, + false + ); + // No content, no fetched + assert.equal(verbPrereq("clean", makeCtx()).ok, false); +}); + +test("prereqs: summarize requires content", () => { + assert.equal(verbPrereq("summarize", makeCtx({ hasInputContent: true })).ok, true); + assert.equal(verbPrereq("summarize", makeCtx()).ok, false); + // Already summarized + assert.equal( + verbPrereq( + "summarize", + makeCtx({ hasInputContent: true, has: (t) => t === "summary" }) + ).ok, + false + ); +}); + +test("prereqs: classify requires content or summary", () => { + const withSummary = makeCtx({ + has: (t) => t === "summary", + hasAny: (ts) => ts.includes("summary"), + }); + assert.equal(verbPrereq("classify", withSummary).ok, true); + assert.equal(verbPrereq("classify", makeCtx({ hasInputContent: true })).ok, true); + assert.equal(verbPrereq("classify", makeCtx()).ok, false); +}); + +test("prereqs: analyze requires content", () => { + assert.equal(verbPrereq("analyze", makeCtx({ hasInputContent: true })).ok, true); + assert.equal(verbPrereq("analyze", makeCtx()).ok, false); +}); + +test("prereqs: convert only when goal requests it", () => { + assert.equal(verbPrereq("convert", makeCtx({ goal: "convert this to CSV" })).ok, true); + assert.equal(verbPrereq("convert", makeCtx({ goal: "summarize this" })).ok, false); +}); + +test("prereqs: describe only when goal asks for description", () => { + assert.equal(verbPrereq("describe", makeCtx({ goal: "describe what this is" })).ok, true); + assert.equal(verbPrereq("describe", makeCtx({ goal: "what is this thing?" })).ok, true); + assert.equal(verbPrereq("describe", makeCtx({ goal: "summarize this" })).ok, false); +}); + +test("prereqs: explain only when goal asks for explanation", () => { + assert.equal(verbPrereq("explain", makeCtx({ goal: "explain why this works" })).ok, true); + assert.equal(verbPrereq("explain", makeCtx({ goal: "how does this work?" })).ok, true); + assert.equal(verbPrereq("explain", makeCtx({ goal: "summarize this" })).ok, false); +}); + +test("prereqs: format requires useful artifact and final absent", () => { + // Need to wire both has() and hasAny() to the same artifact set + const makeArtifactCtx = (types: string[]) => + makeCtx({ + has: (t) => types.includes(t), + hasAny: (ts) => ts.some((t) => types.includes(t)), + }); + + const withSummary = makeArtifactCtx(["summary"]); + assert.equal(verbPrereq("format", withSummary).ok, true); + + // final exists → do not format again + const withFinal = makeArtifactCtx(["summary", "final"]); + assert.equal(verbPrereq("format", withFinal).ok, false); + + // no content at all + assert.equal(verbPrereq("format", makeCtx()).ok, false); +}); + +test("prereqs: unknown verb returns false", () => { + assert.equal(verbPrereq("nonexistent", makeCtx()).ok, false); +}); diff --git a/test/requestBuilders.test.ts b/test/requestBuilders.test.ts new file mode 100644 index 0000000..a1849cd --- /dev/null +++ b/test/requestBuilders.test.ts @@ -0,0 +1,96 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildCommonsRequest } from "../src/router/requestBuilders"; +import { initState } from "../src/router/state"; +import type { RunRequest } from "../src/types/run"; + +function makeReq(overrides: Partial = {}): RunRequest { + return { + goal: "test", + input: { content: "hello world", source: "https://example.com" }, + limits: { max_output_tokens: 512 }, + ...overrides, + }; +} + +test("requestBuilders: fetch verb produces x402 + source only", () => { + const req = makeReq(); + const state = initState("test"); + const built = buildCommonsRequest({ + verb: "fetch", + req, + state, + idempotency_key: "key123", + }); + + assert.ok("x402" in built, "must have x402 envelope"); + assert.ok("source" in built, "fetch must have source field"); + assert.ok(!("actor" in built), "fetch must NOT have actor"); + assert.ok(!("channel" in built), "fetch must NOT have channel"); + assert.ok(!("limits" in built), "fetch must NOT have limits"); + + const x402 = built["x402"] as Record; + assert.equal(x402["verb"], "fetch"); + assert.equal(built["source"], "https://example.com"); +}); + +test("requestBuilders: non-fetch verbs produce x402 + actor + limits + channel + input", () => { + const verbs = ["clean", "summarize", "classify", "analyze", "parse", "format", "convert", "describe", "explain"]; + const req = makeReq(); + const state = initState("test"); + + for (const verb of verbs) { + const built = buildCommonsRequest({ verb, req, state }); + const fields = Object.keys(built); + + assert.ok(fields.includes("x402"), `${verb}: must have x402`); + assert.ok(fields.includes("actor"), `${verb}: must have actor`); + assert.ok(fields.includes("limits"), `${verb}: must have limits`); + assert.ok(fields.includes("channel"), `${verb}: must have channel`); + assert.ok(fields.includes("input"), `${verb}: must have input`); + + const x402 = built["x402"] as Record; + assert.equal(x402["verb"], verb, `${verb}: x402.verb must match`); + } +}); + +test("requestBuilders: idempotency_key is included in x402 when provided", () => { + const req = makeReq(); + const state = initState("test"); + const built = buildCommonsRequest({ + verb: "summarize", + req, + state, + idempotency_key: "my-key", + }); + const x402 = built["x402"] as Record; + assert.equal(x402["idempotency_key"], "my-key"); +}); + +test("requestBuilders: idempotency_key absent when null", () => { + const req = makeReq(); + const state = initState("test"); + const built = buildCommonsRequest({ + verb: "summarize", + req, + state, + idempotency_key: null, + }); + const x402 = built["x402"] as Record; + assert.ok(!("idempotency_key" in x402), "idempotency_key should not be present"); +}); + +test("requestBuilders: fetch source falls back to input.url", () => { + const req = makeReq({ input: { url: "https://fallback.example.com" } }); + const state = initState("test"); + const built = buildCommonsRequest({ verb: "fetch", req, state }); + assert.equal(built["source"], "https://fallback.example.com"); +}); + +test("requestBuilders: limits.max_output_tokens respected", () => { + const req = makeReq({ limits: { max_output_tokens: 2048 } }); + const state = initState("test"); + const built = buildCommonsRequest({ verb: "analyze", req, state }); + const limits = built["limits"] as Record; + assert.equal(limits["max_output_tokens"], 2048); +}); diff --git a/test/scoring.test.ts b/test/scoring.test.ts new file mode 100644 index 0000000..0d39f21 --- /dev/null +++ b/test/scoring.test.ts @@ -0,0 +1,113 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { chooseVerbScored, scoreVerb } from "../src/router/scoring"; +import { COMMONS_VERBS } from "../src/router/commonsVerbs"; +import type { PrereqCtx } from "../src/router/prereqs"; + +function makeCtx(overrides: Partial = {}): PrereqCtx { + return { + goal: "", + has: () => false, + hasAny: () => false, + hasInputContent: false, + hasSourceUrl: false, + ...overrides, + }; +} + +test("scoring: fetch chosen when source url present and fetched absent", () => { + const ctx = makeCtx({ goal: "fetch this page", hasSourceUrl: true }); + const best = chooseVerbScored({ verbs: [...COMMONS_VERBS], ctx, ranCounts: {} }); + assert.ok(best, "should have a best verb"); + assert.equal(best!.verb, "fetch"); + assert.ok(best!.score > 0, "score should be positive"); +}); + +test("scoring: fetch NOT chosen when no source url", () => { + const ctx = makeCtx({ goal: "summarize this", hasInputContent: true, hasSourceUrl: false }); + const best = chooseVerbScored({ verbs: [...COMMONS_VERBS], ctx, ranCounts: {} }); + assert.ok(best, "should have a best verb"); + assert.notEqual(best!.verb, "fetch", "fetch must not be chosen without source url"); +}); + +test("scoring: clean chosen after fetch when cleaned absent", () => { + const artifacts = new Set(["fetched"]); + const ctx = makeCtx({ + goal: "clean this content", + has: (t) => artifacts.has(t), + hasAny: (ts) => ts.some((t) => artifacts.has(t)), + hasInputContent: false, + hasSourceUrl: false, + }); + const best = chooseVerbScored({ verbs: [...COMMONS_VERBS], ctx, ranCounts: {} }); + assert.ok(best); + assert.equal(best!.verb, "clean"); +}); + +test("scoring: returns null when no positive move (empty state, no content, no url)", () => { + const ctx = makeCtx({ goal: "", hasInputContent: false, hasSourceUrl: false }); + const best = chooseVerbScored({ verbs: [...COMMONS_VERBS], ctx, ranCounts: {} }); + assert.equal(best, null, "should stop with no positive move"); +}); + +test("scoring: already-run verbs get novelty penalty", () => { + const ctx = makeCtx({ goal: "clean content", hasInputContent: true, hasSourceUrl: false }); + // fetched and cleaned are absent, so 'clean' is runnable + const artifacts = new Set(["fetched"]); + const ctx2 = makeCtx({ + goal: "clean", + has: (t) => artifacts.has(t), + hasAny: (ts) => ts.some((t) => artifacts.has(t)), + hasInputContent: false, + hasSourceUrl: false, + }); + const scoreFirst = scoreVerb({ verb: "clean", ctx: ctx2, ranCounts: {}, policy: undefined }); + const scoreSecond = scoreVerb({ + verb: "clean", + ctx: ctx2, + ranCounts: { clean: 1 }, + policy: undefined, + }); + assert.ok(scoreFirst > scoreSecond, "second run should score lower due to novelty penalty"); +}); + +test("scoring: deny_verbs policy blocks verb", () => { + const ctx = makeCtx({ goal: "fetch page", hasSourceUrl: true }); + const best = chooseVerbScored({ + verbs: [...COMMONS_VERBS], + ctx, + ranCounts: {}, + policy: { deny_verbs: ["fetch"] }, + }); + assert.ok(!best || best.verb !== "fetch", "fetch should be denied"); +}); + +test("scoring: allow_verbs whitelist restricts candidates", () => { + const ctx = makeCtx({ + goal: "summarize this", + hasInputContent: true, + has: (t) => ["fetched", "cleaned"].includes(t), + hasAny: (ts) => ts.some((t) => ["fetched", "cleaned"].includes(t)), + }); + const best = chooseVerbScored({ + verbs: [...COMMONS_VERBS], + ctx, + ranCounts: {}, + policy: { allow_verbs: ["summarize"] }, + }); + assert.ok(best); + assert.equal(best!.verb, "summarize"); +}); + +test("scoring: tie-break is alphabetical", () => { + // Two verbs with exact same score: lower alphabetically wins + // We can't force exact ties easily without mocking, but we verify + // that the function returns a deterministic result given same inputs + const ctx = makeCtx({ goal: "explain or describe", hasInputContent: true }); + const r1 = chooseVerbScored({ verbs: ["describe", "explain"], ctx, ranCounts: {} }); + const r2 = chooseVerbScored({ verbs: ["explain", "describe"], ctx, ranCounts: {} }); + // Whatever it picks, it should be stable regardless of input order + if (r1 && r2) { + assert.equal(r1.verb, r2.verb, "tie-break must be deterministic regardless of input order"); + } +});