Chio/Docs

Node HTTP Frameworks

Run Larkfield's pricing service in Express, Fastify, and Elysia, with every quote admitted by the kernel before the handler runs.

Guide chio@5f63e6e

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.

Start

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.

Terminal 1bash
chio new pricing-node --example pricing-node && cd pricing-node
python3 run.py
Output
Pricing: 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.
FrameworkStart commandApplication 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.

Terminal 2 · pricing-nodebash
python3 run.py quote sku-118 10
python3 run.py revoke
python3 run.py quote sku-118 10
Output
{
  "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

express.mjs · before Chiojavascript
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
express.mjs · completejavascript
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

fastify.mjs · before Chiojavascript
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
fastify.mjs · completejavascript
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
elysia.mjs · completejavascript
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
pricing-store.mjs · completejavascript
// 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.

Terminalbash
npm install @chio-protocol/next
app/v1/quote/route.tstypescript
import { 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),
});
app/actions.tstypescript
"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

RequestObserved resultQuotes 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.

Terminal 2 · pricing-nodebash
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}'
Output
{"error":"unknown sku sku-999"} HTTP 400

The 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:

The sku-999 receipt · selected fieldsjson
{
  "verdict": { "verdict": "allow" },
  "response_status": 200,
  "metadata": { "chio_http_status_scope": "decision" }
}

Read a refusal from its body

Fastify · POST /v1/quote without a grantjson
{
  "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:

Express · POST /v1/quote while the sidecar is stoppedjson
{
  "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

Terminal 2 · pricing-nodebash
python3 receipts.py --decision deny
Output
e882…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

http-grant.json · completejson
{
  "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

AdapterRegistration and body handlingReceipt in the handler
Express
app.use(chio(...)) before body parsing; buffered bytes stay available to the app
request.chioResult.receipt
Fastify
await app.register(chio, ...); buffers in preParsing and replays to the parser
request.chioResult.receipt
Elysia
.use(chio(...)); the quote route uses parse: 'none' and parses after admission
Scoped chioResult.receipt

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.

Next: the same service in other languages