Chio/Docs

Node HTTP Frameworks

Three Node examples that all govern the same contract: GET /hello is allowed, POST /echo is denied without a capability and allowed with one. The differences are how each framework registers middleware and where the receipt id lands.

What it shows

  • @chio-protocol/express: Express middleware that decorates the request with req.chioResult.
  • @chio-protocol/fastify: a Fastify plugin that decorates request.chioResult with the same payload.
  • @chio-protocol/elysia: an Elysia plugin that emits the receipt as a response header rather than mutating context.
  • All three call out to a local chio api protect sidecar at http://127.0.0.1:9090 by default.

Sidecar topology

The Node SDKs talk to a local Chio sidecar over /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

Your service runs on Express, Fastify, or Bun/Elysia. For Python see Python HTTP Frameworks; for Spring Boot or ASP.NET see JVM and .NET; for Go chi or C++ Drogon see Go and C++. Don't use this if you'd rather not run a sidecar at all: read Protect an API for the zero-code reverse-proxy alternative.

Prerequisites

  • Node 20+ for Express and Fastify; Bun for Elysia.
  • The chio CLI on PATH. The smoke flow stands up chio trust serve and chio api protect for you.
  • For Elysia: a built copy of the SDK at sdks/typescript/packages/elysia/dist. The run.sh script builds it on first run.

Run them

Each example has the same two entry points. Start the app only:

bash
cd examples/hello-express   # or hello-fastify, or hello-elysia
./run.sh

Run the full end-to-end smoke (sidecar, trust service, deny, allow):

bash
./smoke.sh

Default ports:

ExampleEnv varDefault
hello-expressHELLO_EXPRESS_PORT8011
hello-fastifyHELLO_FASTIFY_PORT8012
hello-elysiaHELLO_ELYSIA_PORT8014

Express

Plain Express middleware. Register chio() before the body parser; pull req.chioResult.receipt.id in your handler.

Before

server.mjs
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

examples/hello-express/server.mjs
import express from "express";
import { chio, chioErrorHandler } from "@chio-protocol/express";

const app = express();

app.use(
  chio({
    sidecarUrl: process.env["CHIO_SIDECAR_URL"] ?? "http://127.0.0.1:9090",
    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) => {
  res.json({
    ...(typeof req.body === "object" && req.body !== null ? req.body : { payload: req.body }),
    receipt_id: req.chioResult?.receipt.id ?? null,
    has_raw_body: Buffer.isBuffer(req.rawBody),
  });
});

app.use(chioErrorHandler);

const port = Number(process.env["HELLO_EXPRESS_PORT"] ?? "8011");
app.listen(port, "127.0.0.1");

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. chioErrorHandler turns sidecar denials into proper 4xx responses.


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

server.mjs
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

examples/hello-fastify/server.mjs
import Fastify from "fastify";
import { chio } from "@chio-protocol/fastify";

const fastify = Fastify({ logger: false });

await fastify.register(chio, {
  sidecarUrl: process.env["CHIO_SIDECAR_URL"] ?? "http://127.0.0.1:9090",
  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) => {
  const payload = request.body ?? {};
  return {
    ...(typeof payload === "object" && payload !== null ? payload : { payload }),
    receipt_id: request.chioResult?.receipt.id ?? null,
  };
});

const port = Number(process.env["HELLO_FASTIFY_PORT"] ?? "8012");
await fastify.listen({ host: "127.0.0.1", port });

Fastify body parsing happens automatically. The plugin reads the body from Fastify's pipeline rather than buffering separately, so nothing leaks into request.rawBody the way it does in Express.


Elysia (Bun)

Elysia is a Bun-first router. The @chio-protocol/elysia plugin is header-first: rather than mutating context, it sets an X-Chio-Receipt-style header on the response.

After

examples/hello-elysia/server.mjs
import http from "node:http";
import { Elysia } from "elysia";
import { chio } from "@chio-protocol/elysia";

const port = Number(process.env["HELLO_ELYSIA_PORT"] ?? "8014");

const app = new Elysia()
  .use(
    chio({
      sidecarUrl: process.env["CHIO_SIDECAR_URL"] ?? "http://127.0.0.1:9090",
      skip: ["/healthz"],
    }),
  )
  .get("/healthz", () => ({ status: "ok" }))
  .get("/hello", () => ({ message: "hello from elysia" }))
  .post("/echo", ({ body }) => {
    const payload = typeof body === "object" && body !== null ? body : {};
    return payload;
  });

const server = 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));
  res.end(Buffer.from(await response.arrayBuffer()));
});

server.listen(port, "127.0.0.1");

The example uses Node's built-in http server to host the Elysia handler, which keeps the example runnable under both Node and Bun. The Chio plugin is identical either way.


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 Python assertion block below is the exact pass/fail contract.

hello-express

examples/hello-express/smoke.sh
# GET /hello
assert body["message"] == "hello from express"
assert body["receipt_id"]

# POST /echo without capability
assert body["error"] == "chio_access_denied"
assert body["receipt_id"]

# POST /echo with X-Chio-Capability header
assert body["message"] == "hello"
assert body["count"] == 2
assert body["receipt_id"]
assert body["has_raw_body"] is True

hello-fastify

examples/hello-fastify/smoke.sh
# GET /hello
assert body["message"] == "hello from fastify"
assert body["receipt_id"]

# POST /echo without capability
assert body["error"] == "chio_access_denied"
assert body["receipt_id"]

# POST /echo with ?chio_capability=<urlencoded token>
assert body["message"] == "hello"
assert body["count"] == 2
assert body["receipt_id"]

Note that hello-fastify passes the capability via a query string parameter rather than a header, exercising the fallback wiring path.

hello-elysia

examples/hello-elysia/smoke.sh
# GET /hello
assert body["message"] == "hello from elysia"

# POST /echo without capability
assert body["error"] == "chio_access_denied"
assert body["receipt_id"]

# POST /echo with X-Chio-Capability header
assert body["message"] == "hello"
assert body["count"] == 2

Elysia surfaces the receipt as a response header rather than a body field, so the success-message printout reads x-chio-receipt-id from .artifacts/.../allow.headers.


Inspect after

Each smoke writes a timestamped artifact directory at examples/hello-<framework>/.artifacts/<ts>/. The trio below works for all three Node examples.

bash
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
# expected: "rcpt_01J..." (non-empty string)

# 2. The same receipt id on the response header
grep -i "^x-chio-receipt-id:" "$ART/allow.headers"
# expected: x-chio-receipt-id: rcpt_01J...

# 3. The persisted receipt store from the sidecar
sqlite3 "$ART/state/sidecar-receipts.sqlite3" \
  "select id, decision, route_pattern from receipts order by created_at desc limit 5;"
# expected: 3 rows, decisions = "Allow"/"Deny", route_pattern values include /hello and /echo

# 4. NDJSON receipt list, same content as the SQLite store
head -n 3 "$ART/receipts.ndjson" | jq -c '{id, decision, route_pattern}'
# expected: three compact JSON lines, one per call

# 5. App-side log line for the allow request
grep -E "POST /echo .* 200" "$ART/logs/app.log"
# expected: at least one line for the allowed POST /echo

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

Express, with a capability token. The same shape works for Fastify (header form) and Elysia (header response).

http
# → request (truncated capability token)
POST /echo HTTP/1.1
Host: 127.0.0.1:8011
Content-Type: application/json
X-Chio-Capability: chio.cap.v1.eyJzdWIiOiJhZ2VudC0...
Content-Length: 30

{"message":"hello","count":2}
http
# ← response
HTTP/1.1 200 OK
Content-Type: application/json
x-chio-receipt-id: rcpt_01JABC...
Content-Length: 92

{"message":"hello","count":2,"receipt_id":"rcpt_01JABC...","has_raw_body":true}

How the three differ

AspectExpressFastifyElysia
Registrationapp.use(chio(...))await fastify.register(chio, ...).use(chio(...))
Receipt accessreq.chioResult.receipt.idrequest.chioResult.receipt.idResponse header
Body bufferingPlugin buffers; req.rawBody exposedReads from Fastify pipelineReads from Request object
Error handlingchioErrorHandler registered lastBuilt-in plugin error pathBuilt-in plugin error path
Async patternSync handlers fineasync handlers requiredasync handlers fine

Next