Node HTTP Frameworks
Run Larkfield's pricing service in Express, Fastify, and Elysia, with every quote admitted by the kernel before the handler runs.
On this page
Larkfield's pricing service does not have to live in one framework. The same contract, a grant from your kernel and a signed receipt for every decision, holds whether a quote arrives through Express, Fastify, or Elysia on Bun.
Starts from chio new pricing-node --example pricing-node. Each framework serves POST /v1/quote, which issues and records a quote, and GET /v1/quotes, which lists them. Its Chio adapter asks a sidecar beside it for a decision before the handler runs. The middleware architecture shows where each process sits.
One authority core across three frameworks
An agent that buys from Larkfield, such as Basalt's, sends the same request to any of the three apps and meets the same decision. What differs is where each framework lets the adapter read the request body before the handler parses it, and where the handler finds its receipt.
1. Start the service and its sidecar
You need Node 22.13 or newer and npm; Elysia also needs Bun. The launcher installs the adapters from npm, starts Express on port 8093 beside a Chio sidecar on 9099, and issues a five-minute grant to Basalt's buying agent. You can also download the project.
chio new pricing-node --example pricing-node && cd pricing-node
python3 run.pyPricing: http://127.0.0.1:8093/v1/quote Quote: python3 run.py quote sku-118 10 List: python3 run.py list Revoke: python3 run.py revoke State and receipts: .pricing/ Ctrl-C stops the services and keeps your quotes.
| Framework | Start command | Application address |
|---|---|---|
Express | python3 run.py | http://127.0.0.1:8093 |
Fastify | python3 run.py --framework fastify | http://127.0.0.1:8094 |
Elysia on Bun | python3 run.py --framework elysia | http://127.0.0.1:8095 |
2. Issue a quote, then revoke the grant
In a second terminal in the same directory, quote sku-118 at 10 percent, revoke the grant, and send the same request again.
python3 run.py quote sku-118 10
python3 run.py revoke
python3 run.py quote sku-118 10{
"status": 201,
"body": {
"id": 1, "sku": "sku-118", "percent": 10, "price": "21.60", "currency": "USD",
"receipt_id": "b65e…82d4"
},
"headers": { "x-chio-receipt-id": "b65e…82d4" }
}
{ "status": 200, "body": { "released": true }, "headers": {} }
{
"status": 403,
"body": {
"error": "chio_access_denied",
"message": "capability token has been revoked",
"receipt_id": "e882…25a6"
},
"headers": {}
}You should see a 201 with USD 21.60 and the admission receipt's ID, then the same request refused with 403 after revocation. The refused quote is not recorded: python3 run.py list still shows one. Ctrl-C stops both services and keeps quotes, keys, and receipts in .pricing. python3 run.py check, with the services stopped, runs the whole sequence plus a request with no grant and an unknown SKU.
Express: admission before the body parser
The handler you already have
app.post('/v1/quote', (request, response) => {
response.status(201).json(store.issue(request.body));
});The complete Express application
Read express.mjs · complete 19 lines · complete file
import express from 'express';
import { chio, chioErrorHandler } from '@chio-protocol/express';
import { quoteStore } from './pricing-store.mjs';
const store = quoteStore('express');
const app = express();
app.use(chio({ sidecarUrl: process.env.CHIO_SIDECAR_URL ?? 'http://127.0.0.1:9099', skip: ['/healthz'] }));
app.use(express.json());
app.get('/healthz', (_request, response) => response.json({ status: 'ready', framework: 'express' }));
app.get('/v1/quotes', (_request, response) => response.json({ quotes: store.list() }));
app.post('/v1/quote', (request, response) => {
try {
const quote = store.issue(request.body);
response.status(201).json({ ...quote, receipt_id: request.chioResult.receipt.id });
} catch (error) {
response.status(400).json({ error: error.message });
}
});
app.use(chioErrorHandler);
app.listen(8093, '127.0.0.1', () => console.log('Express pricing: http://127.0.0.1:8093'));Register chio before express.json so the adapter binds the original request bytes; the body stays available to the parser after it. The handler reads request.chioResult.receipt.id, and a refusal returns from the adapter before the handler. This adapter collects the body without its own byte cap, so a deployed service puts a size limit in front of it: a later JSON parser limit cannot bound a collection that already happened.
Fastify: admission in the request hooks
The handler you already have
app.post('/v1/quote', async (request, reply) => {
return reply.code(201).send(store.issue(request.body));
});The complete Fastify application
Read fastify.mjs · complete 18 lines · complete file
import Fastify from 'fastify';
import { chio } from '@chio-protocol/fastify';
import { quoteStore } from './pricing-store.mjs';
const store = quoteStore('fastify');
const app = Fastify({ bodyLimit: 8192 });
await app.register(chio, { sidecarUrl: process.env.CHIO_SIDECAR_URL ?? 'http://127.0.0.1:9099', skip: ['/healthz'] });
app.get('/healthz', async () => ({ status: 'ready', framework: 'fastify' }));
app.get('/v1/quotes', async () => ({ quotes: store.list() }));
app.post('/v1/quote', async (request, reply) => {
try {
const quote = store.issue(request.body);
return reply.code(201).send({ ...quote, receipt_id: request.chioResult.receipt.id });
} catch (error) {
return reply.code(400).send({ error: error.message });
}
});
await app.listen({ host: '127.0.0.1', port: 8094 });
console.log('Fastify pricing: http://127.0.0.1:8094');The plugin captures the raw bytes in preParsing, replays them to Fastify, and evaluates before the handler. On an allow it attaches request.chioResult and the X-Chio-Receipt-Id header. The app sets an 8 KiB parsing limit; the plugin's earlier buffer does not enforce it, so a deployed service also needs an ingress limit.
Elysia: keep the body until admission
The complete Bun application
Read elysia.mjs · complete 20 lines · complete file
import { Elysia } from 'elysia';
import { chio } from '@chio-protocol/elysia';
import { quoteStore } from './pricing-store.mjs';
const store = quoteStore('elysia');
new Elysia()
.use(chio({ sidecarUrl: process.env.CHIO_SIDECAR_URL ?? 'http://127.0.0.1:9099', skip: ['/healthz'] }))
.get('/healthz', () => ({ status: 'ready', framework: 'elysia' }))
.get('/v1/quotes', () => ({ quotes: store.list() }))
.post('/v1/quote', async ({ request, chioResult, set }) => {
try {
const quote = store.issue(await request.json());
set.status = 201;
return { ...quote, receipt_id: chioResult.receipt.id };
} catch (error) {
set.status = 400;
return { error: error.message };
}
}, { parse: 'none' })
.listen({ hostname: '127.0.0.1', port: 8095, maxRequestBodySize: 8192 });
console.log('Elysia pricing: http://127.0.0.1:8095');The quote route sets parse: 'none', so Chio's before-handler hook can read a clone of the request and the handler parses the original with request.json(). With Elysia's automatic parser the body is consumed before the hook sees it. This follows Elysia's documented parser integration. Bun's server enforces the 8 KiB request limit.
Read the shared price table and quote store 32 lines · complete file
// The application owns its price table and the quotes it issues; Chio admits calls to its handlers.
import { mkdirSync } from 'node:fs';
mkdirSync('.pricing', { recursive: true, mode: 0o700 });
const sqlite = process.versions.bun ? await import('bun:sqlite') : await import('node:sqlite');
// Larkfield list prices in cents, as main holds them after the Quickstart.
const PRICES = { 'sku-118': 2400, 'sku-204': 5800, 'sku-301': 14000 };
export function quoteStore(name) {
const db = process.versions.bun ? new sqlite.Database(`.pricing/${name}.db`) : new sqlite.DatabaseSync(`.pricing/${name}.db`);
db.exec('CREATE TABLE IF NOT EXISTS quotes(id INTEGER PRIMARY KEY, sku TEXT NOT NULL, percent REAL NOT NULL, price TEXT NOT NULL)');
const prepare = sql => process.versions.bun ? db.query(sql) : db.prepare(sql);
return {
list() {
return prepare('SELECT id, sku, percent, price FROM quotes ORDER BY id LIMIT 100').all()
.map(row => ({ ...row, currency: 'USD' }));
},
issue(body) {
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).sort().join() !== 'percent,sku') {
throw new Error('body must contain exactly sku and percent');
}
const { sku, percent } = body;
if (!(sku in PRICES)) throw new Error(`unknown sku ${sku}`);
if (typeof percent !== 'number' || percent < 0 || percent > 100) throw new Error('percent must be between 0 and 100');
// Basis points keep the arithmetic exact; the last step rounds half up to the cent.
const cents = Math.floor((PRICES[sku] * (10000 - Math.round(percent * 100)) + 5000) / 10000);
const price = (cents / 100).toFixed(2);
const saved = prepare('INSERT INTO quotes(sku, percent, price) VALUES (?, ?, ?)').run(sku, percent, price);
return { id: Number(saved.lastInsertRowid), sku, percent, price, currency: 'USD' };
},
};
}Next.js: gate Route Handlers and Server Actions
@chio-protocol/next wraps the two App Router entry points. withChio gates a Route Handler and withChioAction gates a Server Action. Each takes an evaluate function that returns the Chio evaluation for the request or the action's arguments, and the wrapped code runs only when that evaluation is authorized.
npm install @chio-protocol/nextimport { withChio } from "@chio-protocol/next";
export const POST = withChio(async request => {
const { sku, percent } = await request.json();
return Response.json(issueQuote(sku, percent), { status: 201 });
}, {
evaluate: request => evaluateRequestWithChio(request),
});"use server";
import { withChioAction } from "@chio-protocol/next";
export const publishPrice = withChioAction(async (sku: string, price: string) => {
return prices.publish(sku, price);
}, {
evaluate: (sku: string, price: string) => evaluateActionWithChio({ tool: "publish_price", sku, price }),
});isAuthorizedEvaluation admits a request only for an allow that arrives as a mediated decision with a trusted signer, a valid signature, a receipt ID, and a parameter hash; an advisory evaluation is refused. A refused Route Handler returns 403 (set denialStatus to change it) with error: chio_denied and the x-chio-verdict and x-chio-receipt-id headers; allowed responses pass through untouched, streaming included. A refused Server Action throws ChioActionDeniedError with the receipt ID, and the action never runs. The wrappers support the App Router only.
Check both the decision and the quotes
| Request | Observed result | Quotes recorded |
|---|---|---|
POST /v1/quote without a grant | 403 from the adapter | None added |
POST /v1/quote with Basalt's grant | 201 from the service, with the receipt ID | One added |
sku-999 with the grant | Admission allows; the service returns 400 | None added |
Any quote after revoke | 403: the capability token is revoked | None added |
The third row matters. An allow receipt says admission passed; it does not say the handler succeeded.
curl -sS -w ' HTTP %{http_code}\n' http://127.0.0.1:8093/v1/quote \
-H 'Content-Type: application/json' \
-H "X-Chio-Capability: $(jq -c . .pricing/capability.json)" \
-d '{"sku":"sku-999","percent":10}'{"error":"unknown sku sku-999"} HTTP 400The receipt for that call carries the decision's status scope, which precedes the handler's 400. These adapters do not write the handler's status back into the signed record:
{
"verdict": { "verdict": "allow" },
"response_status": 200,
"metadata": { "chio_http_status_scope": "decision" }
}Read a refusal from its body
{
"error": "chio_access_denied",
"message": "side-effect route requires a capability token",
"suggestion": "provide a valid capability token in the X-Chio-Capability header or chio_capability query parameter",
"receipt_id": "f3ad…94b6"
}The refusal body carries receipt_id; look it up in the sidecar's store to read the signed reason. A stopped sidecar produces a different shape and no admission at all:
{
"error": "chio_sidecar_unreachable",
"message": "failed to reach sidecar at http://127.0.0.1:9099: fetch failed"
}All three apps return 502 for governed routes while their sidecar is down, and the excluded /healthz still answers 200. The adapters fail closed: no option turns an unreachable sidecar into an allow.
Look up the complete admission record
python3 receipts.py --decision denye882…25a6 DENY POST /v1/quote HTTP 403 f3ad…94b6 DENY POST /v1/quote HTTP 403 cb55…99f7 DENY POST /v1/quote HTTP 403
receipts.py opens .pricing/receipts.db read-only with Python's standard library and prints one row per decision; pass a receipt ID instead of --decision to print that complete signed receipt. For signature and trust checks, send the complete object to the sidecar verification endpoint.
Change the authority you issue
{
"subject": "basalt-buyer",
"job_uid": "basalt-quotes-1",
"ttl_seconds": 300,
"scopes": ["tool:chio_http_authority:authorize_http_request:invoke"]
}The launcher mints this five-minute grant for Basalt's buying agent from the sidecar's local authority. It admits HTTP requests the OpenAPI document marks as side-effecting; it is not a named-tool grant. To limit Basalt to two quotes, serve the same functions as MCP tools in OpenAPI and MCP. To require a grant for reading quotes too, change the route allowlist in the middleware guide.
Know where each adapter exposes the receipt
| Adapter | Registration and body handling | Receipt in the handler |
|---|---|---|
Express | app.use(chio(...)) before body parsing; buffered bytes stay available to the app | request. |
Fastify | await app.register(chio, ...); buffers in preParsing and replays to the parser | request. |
Elysia | .use(chio(...)); the quote route uses parse: 'none' and parses after admission | Scoped chio |
Allowed responses carry X-Chio-Receipt-Id; the Python adapters use X-Chio-Receipt. The wire operation is /chio/evaluate, which receives normalized request metadata and a hash of the body, not the body itself.