BuildHTTP Frameworks
Node HTTP Frameworks
Use a local Chio sidecar with Express, Fastify, or Elysia. Each framework registers middleware differently and exposes the receipt ID in a different place.
What it shows
@chio-protocol/express: Express middleware that decorates the request withreq.chioResult.@chio-protocol/fastify: a Fastify plugin that decoratesrequest.chioResultwith the same payload.@chio-protocol/elysia: an Elysia plugin that sets the receipt on a response header and also resolves it into scoped context aschioResult.- All three call out to a local
chio api protectsidecar athttp://127.0.0.1:9090by default.
Sidecar topology
/chio/evaluate. The sidecar is what holds the kernel, the policy, and the receipt store. The Node process itself stays a thin HTTP wrapper. See HTTP Framework Middleware and Protect an API for the deeper pattern.Use this when
Prerequisites
- Node 20+ for Express and Fastify; Bun for Elysia.
- A
chiobinary built from this checkout, not one onPATH. All three smokes callensure_chio_bin(examples/_shared/hello-http-common.sh:81-96), which uses$CHIO_BINwhen it is set and executable, falls back totarget/debug/chio, and runscargo build --bin chiowhen that is missing. So: the source checkout plus either a Rust toolchain orCHIO_BINpointing at a binary you already have. The smoke flow then stands upchio trust serveandchio api protectfor you. - For Elysia: a built copy of the SDK at
sdks/typescript/packages/elysia/dist. Therun.shscript builds it on first run.
One package per framework, and nothing else. Each example's package.json lists exactly its adapter plus the framework:
npm install @chio-protocol/express # hello-express
npm install @chio-protocol/fastify # hello-fastify
npm install @chio-protocol/elysia # hello-elysiaThe three adapters sit on a shared substrate, @chio-protocol/node-http, which is why the deny body below has the same four keys whichever of the three you run. You do not install it directly. @chio-protocol/sdk is a separate package for talking to a hosted Chio edge, and none of these three examples needs it.
Run them
Each example has the same two entry points. Start the app only:
cd examples/hello-express # or hello-fastify, or hello-elysia
./run.shRun the smoke with the sidecar, trust service, deny case, and allow case:
./smoke.shAt this commit hello-express is the one of the three that passes end to end, and the transcripts on this page are its. hello-fastify drives its three exchanges correctly and then fails its own reconciliation step, and hello-elysia passes only when it is started under Bun rather than by its own run.sh. Both are explained where they come up.
$ ./smoke.shhello-express smoke passed artifacts: <chio-source>/examples/hello-express/.artifacts/20260905T114917Z hello receipt: c5c5b36faf4cddb8a1e02ad8c86b5741c2cc92c53a75418761e37cde7f07c22c deny receipt: ade223b86d3c2e27e114f1dcfd78d920d7166d35c86ab452068df3fc0620456e allow receipt: 2fe69492cf58a0cbffad6f347630233fcf3d2a8ffd3762cd9108cb737fa4cb65
Three receipt ids for three calls: the safe GET /hello, the refused POST /echo, and the same POST /echo carrying a capability. The refusal is signed and stored like the two allows; The denied call below shows its payload. Every transcript on this page comes from that one run.
Default ports:
| Example | Env var | Default |
|---|---|---|
hello-express | HELLO_EXPRESS_PORT | 8011 |
hello-fastify | HELLO_FASTIFY_PORT | 8012 |
hello-elysia | HELLO_ELYSIA_PORT | 8014 |
Express
Plain Express middleware. Register chio() before the body parser; pull req.chioResult.receipt.id in your handler.
Before
import express from "express";
const app = express();
app.use(express.json());
app.get("/hello", (req, res) => {
res.json({ message: "hello from express" });
});
app.post("/echo", (req, res) => {
res.json(req.body);
});
app.listen(8011);After
export function createApp({
enableChio = true,
sidecarUrl = process.env["CHIO_SIDECAR_URL"] ?? "http://127.0.0.1:9090",
} = {}) {
const app = express();
if (enableChio) {
app.use(
chio({
sidecarUrl,
skip: ["/healthz"],
}),
);
}
app.use(express.json());
app.get("/healthz", (_req, res) => {
res.json({ status: "ok" });
});
app.get("/hello", (req, res) => {
res.json({
message: "hello from express",
receipt_id: req.chioResult?.receipt.id ?? null,
});
});
app.post("/echo", (req, res) => {
let payload;
try {
payload = parseEchoPayload(req.body ?? {});
} catch (error) {
if (error instanceof EchoPayloadError) {
res.status(400).json({ error: error.message });
return;
}
throw error;
}
res.json({
...payload,
receipt_id: req.chioResult?.receipt.id ?? null,
has_raw_body: Buffer.isBuffer(req.rawBody),
});
});
app.use(chioErrorHandler);
return app;
}Notes: chio() buffers the raw body so the sidecar can hash it; the buffer is exposed as req.rawBody for downstream handlers that still want raw bytes, which is what has_raw_body reports. enableChio exists so the example's own tests can run the ungoverned app; the smoke leaves it at its default.
chioErrorHandler is not the deny path
chioErrorHandler. The shared interceptor writes the 403 itself and reports responseSent: true (sdks/typescript/packages/node-http/src/interceptor.ts:355-361). The handler covers the case where a chio_-prefixed error escapes to Express, and it answers 502, not a 4xx (sdks/typescript/packages/express/src/middleware.ts:122-127).All three examples share the same body validator, and it is the reason a malformed POST /echo comes back 400 from the app rather than 200:
export function parseEchoPayload(payload) {
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
throw new EchoPayloadError("body must be a JSON object");
}
const allowedKeys = new Set(["message", "count"]);
const extraKeys = Object.keys(payload)
.filter((key) => !allowedKeys.has(key))
.sort();
if (extraKeys.length > 0) {
throw new EchoPayloadError(`unexpected fields: ${extraKeys.join(", ")}`);
}
if (typeof payload.message !== "string" || payload.message.length === 0) {
throw new EchoPayloadError("message must be a non-empty string");
}
const count = payload.count ?? 1;
if (!Number.isInteger(count) || count < 1) {
throw new EchoPayloadError("count must be an integer greater than or equal to 1");
}
return {
message: payload.message,
count,
};
}Fastify
Fastify uses a plugin. Register it with await fastify.register(chio, ...) before defining routes; the plugin decorates request.chioResult on every non-skipped request.
Before
import Fastify from "fastify";
const fastify = Fastify({ logger: false });
fastify.get("/hello", async () => ({ message: "hello from fastify" }));
fastify.post("/echo", async (request) => request.body);
await fastify.listen({ host: "127.0.0.1", port: 8012 });After
export async function createServer({
enableChio = true,
sidecarUrl = process.env["CHIO_SIDECAR_URL"] ?? "http://127.0.0.1:9090",
} = {}) {
const fastify = Fastify({ logger: false });
if (enableChio) {
await fastify.register(chio, {
sidecarUrl,
skip: ["/healthz"],
});
}
fastify.get("/healthz", async () => ({ status: "ok" }));
fastify.get("/hello", async (request) => ({
message: "hello from fastify",
receipt_id: request.chioResult?.receipt.id ?? null,
}));
fastify.post("/echo", async (request, reply) => {
let payload;
try {
payload = parseEchoPayload(request.body ?? {});
} catch (error) {
if (error instanceof EchoPayloadError) {
reply.code(400);
return { error: error.message };
}
throw error;
}
return {
...payload,
receipt_id: request.chioResult?.receipt.id ?? null,
body_cached: request.body !== undefined,
};
});
return fastify;
}Fastify body parsing happens automatically. The plugin reads the raw bytes in a preParsing hook, keeps them on request.chioRawBody rather than the Express spelling req.rawBody, and replays them into the parser (sdks/typescript/packages/fastify/src/plugin.ts:80-90). The echo handler returns body_cached: request.body !== undefined to prove the parsed body survives that pass.
Elysia (Bun)
Elysia is a Bun-first router. The @chio-protocol/elysia plugin sets X-Chio-Receipt-Id on the response and resolves the same result into scoped context, so a handler can destructure ({ chioResult }) (sdks/typescript/packages/elysia/src/plugin.ts:183, 197-199).
After
export function createApp({
enableChio = true,
sidecarUrl = process.env["CHIO_SIDECAR_URL"] ?? "http://127.0.0.1:9090",
} = {}) {
const app = new Elysia();
if (enableChio) {
app.use(
chio({
sidecarUrl,
skip: ["/healthz"],
}),
);
}
return app
.get("/healthz", () => ({ status: "ok" }))
.get("/hello", () => ({ message: "hello from elysia" }))
.post("/echo", ({ body, set }) => {
try {
return parseEchoPayload(body ?? {});
} catch (error) {
if (error instanceof EchoPayloadError) {
set.status = 400;
return { error: error.message };
}
throw error;
}
});
}The example hosts that handler on Node's built-in http server rather than on Bun's:
export function createNodeServer(app, port) {
return http.createServer(async (req, res) => {
const url = new URL(
req.url ?? "/",
`http://${req.headers.host ?? `127.0.0.1:${port}`}`,
);
const bodyChunks = [];
for await (const chunk of req) {
bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const request = new Request(url, {
method: req.method ?? "GET",
headers: req.headers,
body:
bodyChunks.length > 0
? Buffer.concat(bodyChunks)
: undefined,
});
const response = await app.handle(request);
res.statusCode = response.status;
response.headers.forEach((value, key) => {
res.setHeader(key, value);
});
const responseBody = Buffer.from(await response.arrayBuffer());
res.end(responseBody);
});
}Run this one under Bun
node that adapter does not work: every POST /echo comes back 400 {"error":"chio_evaluation_failed","message":"request body could not be read for Chio evaluation: unusable"}, because the plugin's request.clone() throws on a Request built over an already drained Node stream, so only the GET receipt lands. Under bun the same script passes end to end: 200 / 403 / 200 and three receipts in the store. Reproduced here on elysia@1.4.30 and on the example's declared floor 1.4.28. Note that examples/hello-elysia/package.json:6 is "start": "node server.mjs" and run.sh:16 execs npm run start, so the example's own runner takes the path that does not work. Start it with bun server.mjs instead.Success criteria
All three smokes drive the same shape: a safe GET /hello, a denied POST /echo, then an allowed POST /echo with a capability token. Each call is checked by its own inline Python block. The middle one is the same in all three files:
python3 - "${ARTIFACT_ROOT}/deny.json" <<'PY'
import json
import sys
from pathlib import Path
body = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
assert body["error"] == "chio_access_denied", body
assert body["receipt_id"], body
PYThe other two differ only in what they name. This is every assertion the three smokes make on a response body:
| Call | hello-express | hello-fastify | hello-elysia |
|---|---|---|---|
GET /hello | message, receipt_id | message, receipt_id | message only |
| deny | error, receipt_id | error, receipt_id | error, receipt_id |
| allow | message, count, receipt_id, has_raw_body | message, count, receipt_id, body_cached | message, count |
| Capability arrives as | X-Chio-Capability | ?chio_capability= | X-Chio-Capability |
Sources: hello-express/smoke.sh:74-82, 90-98, 115-125, hello-fastify/smoke.sh:74-82, 90-98, 123-133, hello-elysia/smoke.sh:74-81, 89-97, 114-122. hello-fastify passes the capability in a query-string parameter to exercise that supported input path. Elysia's allow body carries no receipt_id, so its smoke reads the id from allow.headers instead.
hello-fastify does not pass at this commit
AssertionError: {'missing': ['']}. The Fastify plugin sets X-Chio-Receipt-Id only on the allow path (sdks/typescript/packages/fastify/src/plugin.ts:233), so the deny response carries no header id. Its five siblings all fall back to reading receipt_id out of deny.json(hello-express/smoke.sh:131-141); hello-fastify/smoke.sh:135-137 goes straight from the three headers to the reconciliation, which also omits the assert "" not in expected_ids guard. This is a defect in the example, not in the plugin contract.The denied call
The middle call of every smoke is a refusal. It posts to /echo with no capability token, and the sidecar answers before the Express handler ever runs. The smoke issues it with curl and keeps both halves of the answer:
curl -sS -D "${ARTIFACT_ROOT}/deny.headers" \
-H "content-type: application/json" \
--data '{"message":"denied","count":1}' \
"${APP_URL}/echo" \
> "${ARTIFACT_ROOT}/deny.json"$ cat deny.headers deny.jsonHTTP/1.1 403 Forbidden
X-Powered-By: Express
Content-Type: application/json
Date: Sat, 05 Sep 2026 11:49:22 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
{"error":"chio_access_denied","message":"side-effect route requires a capability token","receipt_id":"ade223b86d3c2e27e114f1dcfd78d920d7166d35c86ab452068df3fc0620456e","suggestion":"provide a valid capability token in the X-Chio-Capability header or chio_capability query parameter"}The body carries the error code, the reason, a fix, and a receipt id. That receipt id resolves in the sidecar's store, which is what separates a Chio refusal from a 403 an ordinary gateway would return:
$ jq -c 'select(.verdict.verdict=="deny")
$ | {id, method, route_pattern, verdict, response_status}' receipts.ndjson{"id":"ade223b86d3c2e27e114f1dcfd78d920d7166d35c86ab452068df3fc0620456e","method":"POST","route_pattern":"/echo","verdict":{"verdict":"deny","reason":"side-effect route requires a capability token","guard":"CapabilityGuard","http_status":403},"response_status":403}guard names which check refused (CapabilityGuard), reason is the string the caller saw, and response_status records what the caller actually received. hello-elysia answers with the same shape on its own smoke run: a 403 whose body opens {"error":"chio_access_denied", ...} and carries its own receipt id.
Inspect after
Each smoke writes a timestamped output directory at examples/hello-<framework>/.artifacts/<ts>/. The four commands below work for all three Node examples.
ART=$(ls -1d examples/hello-express/.artifacts/*/ | tail -n1)
# 1. The receipt id from the allowed echo response body
cat "$ART/allow.json" | jq .receipt_id
# 2. The same receipt id on the response header
grep -i "^x-chio-receipt-id:" "$ART/allow.headers"
# 3. The persisted receipt store from the sidecar
sqlite3 "$ART/state/sidecar-receipts.sqlite3" \
"select id, json_extract(receipt_json, '$.verdict.verdict') as verdict, json_extract(receipt_json, '$.route_pattern') as route_pattern from http_receipts order by rowid desc limit 5;"
# 4. NDJSON receipt list, same content as the SQLite store
head -n 3 "$ART/receipts.ndjson" | jq -c '{id, verdict: .verdict.verdict, route_pattern}'$ jq -c '{id, verdict: .verdict.verdict, route_pattern}' receipts.ndjson{"id":"c5c5b36faf4cddb8a1e02ad8c86b5741c2cc92c53a75418761e37cde7f07c22c","verdict":"allow","route_pattern":"/hello"}
{"id":"ade223b86d3c2e27e114f1dcfd78d920d7166d35c86ab452068df3fc0620456e","verdict":"deny","route_pattern":"/echo"}
{"id":"2fe69492cf58a0cbffad6f347630233fcf3d2a8ffd3762cd9108cb737fa4cb65","verdict":"allow","route_pattern":"/echo"}Receipt ids are 64-character lowercase hex, so yours differ from the run above. What holds across runs is the count and the shape: three receipts per smoke, two allows and one deny, and the id in the response body equal to the id on the header and to the id in the store.
For Fastify, swap examples/hello-express for examples/hello-fastify. For Elysia, the body of allow.json does not carry receipt_id; read it from allow.headers instead.
Critical-path request and response
The third call of the smoke is the same POST /echo that was refused, now carrying a capability. The token goes on X-Chio-Capability as raw JSON, not as an encoded string:
$ head -c 96 capability.token; echo '...'{"schema":"chio.capability.v1","id":"cap-01a07166-dfc3-76b1-8e49-fed23cf9af97","issuer":"397ce11...The proxy parses that header with serde_json::from_str::<CapabilityToken> (crates/products/chio-api-protect/src/proxy/http.rs:120-124) and the TypeScript side with JSON.parse (sdks/typescript/packages/node-http/src/interceptor.ts:215-222). The smoke writes it by serializing the issued capability compactly, so curl -H "X-Chio-Capability: $(cat capability.token)" is the whole of it. The answer:
$ cat allow.headers allow.jsonHTTP/1.1 200 OK
X-Powered-By: Express
X-Chio-Receipt-Id: 2fe69492cf58a0cbffad6f347630233fcf3d2a8ffd3762cd9108cb737fa4cb65
Content-Type: application/json; charset=utf-8
Content-Length: 129
ETag: W/"81-9OOq9T4T+OnNAhOCcpfD+8dn8bw"
Date: Sat, 05 Sep 2026 11:49:23 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"message":"hello","count":2,"receipt_id":"2fe69492cf58a0cbffad6f347630233fcf3d2a8ffd3762cd9108cb737fa4cb65","has_raw_body":true}X-Chio-Receipt-Id is the header spelling for every TypeScript adapter here, for Go, for C++ drogon, for .NET and for Spring Boot. The Python middlewares and the JVM streaming envelope spell it X-Chio-Receipt; the two are different headers, not two renderings of one.
How the three differ
| Aspect | Express | Fastify | Elysia |
|---|---|---|---|
| Registration | app.use(chio(...)) | await fastify.register(chio, ...) | .use(chio(...)) |
| Receipt access | req.chioResult.receipt.id | request.chioResult.receipt.id | Response header, plus scoped chioResult |
| Body buffering | Plugin buffers; req.rawBody exposed | Reads from Fastify pipeline | Reads from Request object |
| Sidecar unreachable | All three: the shared substrate writes 502 chio_sidecar_unreachable itself and the handler never runs | ||
| Handler style in the example | Sync, writes through res | async, returns the body | Sync, returns the body |
Next
- HTTP Framework Middleware: the in-process pattern these plugins implement.
- Protect an API: the zero-code reverse-proxy alternative.
- Python HTTP Frameworks: FastAPI and Django with the same contract.
- One contract, every stack: the registration line for all six languages, side by side, plus the policy and the sidecar command they share.
- Examples Overview