Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# @commandlayer/runtime-core — environment variables
#
# Copy this file to .env and fill in real values before running local scripts.
# NEVER commit .env to version control.

# ── ENS / RPC ──────────────────────────────────────────────────────────────────
# Ethereum JSON-RPC endpoint used by resolveSignerFromENS.
# Required when calling resolveSignerFromENS in production.
# Example: Infura, Alchemy, or a local node.
RPC_URL=https://mainnet.infura.io/v3/YOUR_PROJECT_ID

# ── Ed25519 signing keys ───────────────────────────────────────────────────────
# PEM-encoded Ed25519 private key for signing receipts.
# Generate with: node -e "const {generateEd25519KeyPair}=require('@commandlayer/runtime-core'); const kp=generateEd25519KeyPair(); console.log(kp.privateKeyPem);"
SIGNING_PRIVATE_KEY_PEM=

# PEM-encoded Ed25519 public key for verifying receipts.
SIGNING_PUBLIC_KEY_PEM=

# ENS name of the signer (must have cl.sig.pub TXT record set to the public key above).
# Example: runtime.commandlayer.eth
SIGNER_ENS_NAME=
256 changes: 144 additions & 112 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,169 +1,201 @@
# @commandlayer/runtime-core

Core contract primitives for the CommandLayer v1.1.0 current line.
Core signing, verification, and canonicalization primitives for the CommandLayer v1.1.0 protocol.

This package now models the two current-line contract families explicitly:
This package is the single canonical implementation of:

- **Commons**: request/receipt payloads with no x402 or payment metadata assumptions.
- **Commercial**: the same execution contract plus an explicit `commercial` metadata object for payment-aware runtimes.
- **Canonicalization** — `json.sorted_keys.v1` deterministic JSON (keys sorted at every level)
- **Ed25519 signing and verification** — real `node:crypto` Ed25519, no mocks
- **Signed layered receipts** — v1.1.0 `SignedLayeredReceipt` with structured proof envelope
- **ENS signer resolution** — live TXT record lookup for `cl.sig.pub`
- **Legacy compat shims** — `metadata.proof` envelope bridge for runtime/server.mjs

Legacy 1.0.0 helpers are still exported, but they are isolated and labeled as legacy bridges.

## What it provides

- Current-line request normalization for Commons and Commercial flows
- Current-line receipt builders, canonicalization, signing, and verification helpers
- Explicit schema path helpers for the v1.1.0 split between Commons and Commercial
- ENS signer discovery from TXT records
- Legacy 1.0.0 receipt verification bridges for `metadata.proof` and `ed25519-sha256`
- Compact AJV error formatting
All other CommandLayer repos import from here. Nothing is reimplemented downstream.

## Install

```bash
npm install @commandlayer/runtime-core
```

## Current-line usage
Requires Node.js >= 20.

### Normalize Commons and Commercial requests
## Usage

```ts
import {
normalizeCommonsRequest,
normalizeCommercialRequest
} from '@commandlayer/runtime-core';
### Canonicalization

const commons = normalizeCommonsRequest({
trace: { request_id: 'req_1' },
payload: { message: 'hello' }
});
```ts
import { canonicalize } from '@commandlayer/runtime-core';

const commercial = normalizeCommercialRequest({
trace: { request_id: 'req_2' },
payload: { message: 'hello' },
commercial: { plan: 'pro', settlement: 'required' }
const canonical = canonicalize({
verb: 'chat.completions',
version: '1.1.0',
agent: 'runtime.commandlayer.eth',
timestamp: '2026-05-12T00:00:00.000Z',
});
// Keys are sorted at every level, no trailing whitespace, no undefined values
```

### Resolve current-line schema URLs
### Sign and verify a receipt

```ts
import {
buildSchemaPath,
COMMAND_LAYER_CURRENT_LINE,
COMMONS_CONTRACT,
COMMERCIAL_CONTRACT,
createSchemaClient
generateEd25519KeyPair,
signReceipt,
verifyReceipt,
} from '@commandlayer/runtime-core';

buildSchemaPath({
contract: COMMONS_CONTRACT,
verb: 'chat.completions',
kind: 'request'
});
// /schemas/1.1.0/commons/chat.completions/v1/request.schema.json

const schemas = createSchemaClient({
schemaHost: 'https://schemas.commandlayer.io',
lineVersion: COMMAND_LAYER_CURRENT_LINE
const { privateKeyPem, rawPublicKey } = generateEd25519KeyPair();

const signed = signReceipt(
{
verb: 'chat.completions',
version: '1.1.0',
agent: 'runtime.commandlayer.eth',
timestamp: new Date().toISOString(),
payload: { prompt: 'hello' },
result: { output: 'world' },
},
{
privateKeyPem,
kid: process.env.KEY_ID!,
signerEns: 'runtime.commandlayer.eth',
}
);

const result = verifyReceipt(signed, {
rawPublicKey,
expectedSigner: 'runtime.commandlayer.eth',
});

const validateCommercialReceipt = await schemas.getReceiptValidator({
contract: COMMERCIAL_CONTRACT,
verb: 'chat.completions',
version: 'v1'
});
console.assert(result.valid === true);
```

### Build, sign, and verify current-line receipts
### Resolve signer from ENS

```ts
import {
buildCommonsReceipt,
buildCommercialReceipt,
canonicalizeReceipt,
createLayeredReceipt,
hashReceiptCanonical,
signReceiptEd25519,
verifyReceiptSignature
} from '@commandlayer/runtime-core';
import { JsonRpcProvider } from 'ethers';
import { resolveSignerFromENS } from '@commandlayer/runtime-core';

const commonsReceipt = buildCommonsReceipt({
verb: 'chat.completions',
version: 'v1',
trace: { request_id: 'req_1' },
payload: { prompt: 'hello' },
status: 'success',
result: { output: 'world' }
});
// Positional form
const provider = new JsonRpcProvider(process.env.RPC_URL);
const signer = await resolveSignerFromENS('signer.commandlayer.eth', provider);

const commercialReceipt = buildCommercialReceipt({
verb: 'chat.completions',
version: 'v1',
trace: { request_id: 'req_2' },
payload: { prompt: 'hello' },
commercial: { plan: 'pro', settlement: 'required' },
status: 'success',
result: { output: 'world' }
// Options-object form (equivalent)
const signer2 = await resolveSignerFromENS({
ensName: 'signer.commandlayer.eth',
provider,
});
```

const canonical = canonicalizeReceipt(commercialReceipt);
const hash = hashReceiptCanonical(canonical);
Supported TXT records:

const signed = signReceiptEd25519(commercialReceipt, {
privateKey: process.env.SIGNING_PRIVATE_KEY_PEM!,
signer_id: 'signer.commandlayer.eth',
kid: '2026-01'
});
| Key | Required | Description |
|-----|----------|-------------|
| `cl.sig.pub` | Yes | `ed25519:<standard_base64_raw32>` |
| `cl.sig.kid` | No | Short key identifier |
| `cl.sig.canonical` | No | Defaults to `json.sorted_keys.v1` |

const layered = createLayeredReceipt(commonsReceipt, {
execution: { duration_ms: 42 }
});
### Key encoding

const ok = verifyReceiptSignature(signed, {
pubkey: process.env.SIGNING_PUBLIC_KEY_PEM!
});
```
```ts
import { encodePublicKey, parsePublicKey } from '@commandlayer/runtime-core';

`receipt` remains the canonical signed payload. Runtime metadata and signatures stay layered outside the receipt body.
// Encode raw 32-byte key for ENS TXT record
const ensValue = encodePublicKey(rawPublicKey);
// => "ed25519:hhyCuPNoMk4JtEvGEV8F6nMZ4uDO1EcyizPufmnJTOY="

### Resolve signer from ENS
// Parse ENS TXT record back to raw bytes
const raw = parsePublicKey(ensValue);
// => Uint8Array(32)
```

### Low-level crypto

```ts
import { JsonRpcProvider } from 'ethers';
import { resolveSignerFromENS } from '@commandlayer/runtime-core';
import {
canonicalize,
signCanonical,
verifyCanonical,
verifyCanonicalWithRawKey,
} from '@commandlayer/runtime-core';

const provider = new JsonRpcProvider(process.env.RPC_URL);
const signer = await resolveSignerFromENS({
ensName: 'signer.commandlayer.eth',
provider
});
const canonical = canonicalize(payload);
const signature = signCanonical(canonical, privateKeyPem);
const valid = verifyCanonical(canonical, signature, publicKeyPem);
const validFromRaw = verifyCanonicalWithRawKey(canonical, signature, rawPublicKey);
```

Supported TXT records:
### Legacy compat shims (runtime/server.mjs bridge)

If you need the older `metadata.proof` envelope format, import the compat helpers directly:

```ts
import {
signReceiptEd25519Sha256,
verifyReceiptEd25519Sha256,
} from '@commandlayer/runtime-core';
```

- Preferred: `cl.sig.pub = ed25519:<base64url_raw32>`
- Optional: `cl.sig.kid`, `cl.sig.canonical`
- Legacy fallback: `cl.receipt.pubkey.pem`
These are explicitly legacy APIs. New integrations should use `signReceipt` / `verifyReceipt`.

## Legacy 1.0.0 bridge
### Cross-repo canonicalization alignment

If you still need old `metadata.proof` envelopes or `ed25519-sha256` receipts, import the isolated compatibility helpers:
Every repo that imports `@commandlayer/runtime-core` should run the shared test vectors:

```ts
import { canonicalize, CANONICAL_TEST_VECTORS } from '@commandlayer/runtime-core';

for (const { description, input, expected } of CANONICAL_TEST_VECTORS) {
const actual = canonicalize(input);
if (actual !== expected) throw new Error(`Vector failed: ${description}`);
}
```

## Protocol constants

```ts
import {
signReceiptEd25519Sha256,
toLegacyReceiptEnvelope,
verifyReceiptEd25519Sha256
} from '@commandlayer/runtime-core/receipt-v1';
PROTOCOL_VERSION, // "1.1.0"
CANONICAL_METHOD, // "json.sorted_keys.v1"
SIGNATURE_ALG, // "ed25519"
ENS_KEY_PUB, // "cl.sig.pub"
ENS_KEY_KID, // "cl.sig.kid"
ENS_KEY_CANONICAL, // "cl.sig.canonical"
ENS_KEY_SIGNER, // "cl.receipt.signer"
} from '@commandlayer/runtime-core';
```

These are explicitly legacy APIs and should not be used for new current-line Commons or Commercial flows.
## Environment variables

See `.env.example` for the full list. Key variables:

| Variable | Used by | Description |
|----------|---------|-------------|
| `RPC_URL` | `resolveSignerFromENS` | Ethereum JSON-RPC endpoint |
| `SIGNING_PRIVATE_KEY_PEM` | `signReceipt`, `signCanonical` | Ed25519 private key (PEM) |
| `SIGNING_PUBLIC_KEY_PEM` | `verifyReceipt`, `verifyCanonical` | Ed25519 public key (PEM) |
| `SIGNER_ENS_NAME` | ENS | ENS name with `cl.sig.pub` set |

## Development

```bash
npm run build
npm test
npm run build # compile TypeScript
npm test # run all tests
npm run typecheck # type-check without emitting
```

## Signing protocol

The signing message is **raw UTF-8 bytes** of `canonicalize(receipt)`. This is NOT `sha256(canonical)` — signatures are over the data directly. Any change to this contract requires a protocol version bump.

```
signature = Ed25519.sign(
privateKey,
UTF8(canonicalize(receiptPayload))
)
```

## License

Apache-2.0
22 changes: 21 additions & 1 deletion src/compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ export function signReceiptEd25519Sha256(
receipt: RuntimeReceipt,
opts: SignReceiptCompatOptions
): SignedRuntimeReceipt {
if (!opts.privateKeyPem || typeof opts.privateKeyPem !== "string") {
throw new Error("signReceiptEd25519Sha256: privateKeyPem is required");
}
if (!opts.signer_id || typeof opts.signer_id !== "string") {
throw new Error("signReceiptEd25519Sha256: signer_id is required");
}
if (!opts.kid || typeof opts.kid !== "string") {
throw new Error("signReceiptEd25519Sha256: kid is required");
}

// Strip any existing proof so it's not included in the signed payload
const { metadata: meta = {}, ...rest } = receipt;
const { proof: _proof, ...metaWithoutProof } = meta;
Expand Down Expand Up @@ -131,12 +141,22 @@ export function verifyReceiptEd25519Sha256(
): VerifyReceiptCompatResult {
const checks = { signature_valid: false, hash_matches: false };

if (!opts.publicKeyPemOrDer || typeof opts.publicKeyPemOrDer !== "string") {
return { ok: false, checks, reason: "publicKeyPemOrDer is required" };
}

const proof = receipt?.metadata?.proof;
if (!proof) {
return { ok: false, checks, reason: "Missing metadata.proof" };
}

const sig = proof.signature || proof.signature_b64;
// Accept signature from either field; require non-empty string
const sig = (proof.signature && proof.signature.length > 0)
? proof.signature
: (proof.signature_b64 && proof.signature_b64.length > 0)
? proof.signature_b64
: null;

if (!sig) {
return { ok: false, checks, reason: "Missing proof.signature" };
}
Expand Down
Loading
Loading