Chio/Docs
LOGIN · JOIN

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 with req.chioResult.
  • @chio-protocol/fastify: a Fastify plugin that decorates request.chioResult with the same payload.
  • @chio-protocol/elysia: an Elysia plugin that sets the receipt on a response header and also resolves it into scoped context as chioResult.
  • 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++. For an unmodified service behind a reverse proxy, read Protect an API.

Prerequisites

  • Node 20+ for Express and Fastify; Bun for Elysia.
  • A chio binary built from this checkout, not one on PATH. All three smokes call ensure_chio_bin (examples/_shared/hello-http-common.sh:81-96), which uses $CHIO_BIN when it is set and executable, falls back to target/debug/chio, and runs cargo build --bin chio when that is missing. So: the source checkout plus either a Rust toolchain or CHIO_BIN pointing at a binary you already have. The smoke flow then 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.

One package per framework, and nothing else. Each example's package.json lists exactly its adapter plus the framework:

bash
npm install @chio-protocol/express   # hello-express
npm install @chio-protocol/fastify   # hello-fastify
npm install @chio-protocol/elysia    # hello-elysia

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

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

Run the smoke with the sidecar, trust service, deny case, and allow case:

bash
./smoke.sh

At 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.

hello-express ./smoke.shtranscript
$ ./smoke.sh
hello-express smoke passed
artifacts: <chio-source>/examples/hello-express/.artifacts/20260905T114917Z
hello receipt: c5c5b36faf4cddb8a1e02ad8c86b5741c2cc92c53a75418761e37cde7f07c22c
deny receipt: ade223b86d3c2e27e114f1dcfd78d920d7166d35c86ab452068df3fc0620456e
allow receipt: 2fe69492cf58a0cbffad6f347630233fcf3d2a8ffd3762cd9108cb737fa4cb65
exit 0

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:

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

an ungoverned Express appjavascript
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.mjs35-84javascript
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

A sidecar denial never reaches 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:

examples/hello-express/server.mjs7-33javascript
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

an ungoverned Fastify appjavascript
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.mjs35-75javascript
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

examples/hello-elysia/server.mjs36-65javascript
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:

examples/hello-elysia/server.mjs67-95javascript
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

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

examples/hello-express/smoke.sh90-98python
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
PY

The other two differ only in what they name. This is every assertion the three smokes make on a response body:

Callhello-expresshello-fastifyhello-elysia
GET /hellomessage, receipt_idmessage, receipt_idmessage only
denyerror, receipt_iderror, receipt_iderror, receipt_id
allowmessage, count, receipt_id, has_raw_bodymessage, count, receipt_id, body_cachedmessage, count
Capability arrives asX-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

Its three HTTP exchanges behave correctly and then the reconciliation step fails with 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:

examples/hello-express/smoke.sh:84-88bash
curl -sS -D "${ARTIFACT_ROOT}/deny.headers" \
  -H "content-type: application/json" \
  --data '{"message":"denied","count":1}' \
  "${APP_URL}/echo" \
  > "${ARTIFACT_ROOT}/deny.json"
deny.headers and deny.jsontranscript
$ cat deny.headers deny.json
HTTP/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"}
exit 0

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:

the same refusal, out of receipts.ndjsontranscript
$ 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}
exit 0deny

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.

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

# 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}'
step 4, one run of hello-expresstranscript
$ 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"}
exit 0allow

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:

the first 96 bytes of capability.tokentranscript
$ head -c 96 capability.token; echo '...'
{"schema":"chio.capability.v1","id":"cap-01a07166-dfc3-76b1-8e49-fed23cf9af97","issuer":"397ce11...
exit 0

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:

allow.headers and allow.jsontranscript
$ cat allow.headers allow.json
HTTP/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}
exit 0

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

AspectExpressFastifyElysia
Registrationapp.use(chio(...))await fastify.register(chio, ...).use(chio(...))
Receipt accessreq.chioResult.receipt.idrequest.chioResult.receipt.idResponse header, plus scoped chioResult
Body bufferingPlugin buffers; req.rawBody exposedReads from Fastify pipelineReads from Request object
Sidecar unreachableAll three: the shared substrate writes 502 chio_sidecar_unreachable itself and the handler never runs
Handler style in the exampleSync, writes through resasync, returns the bodySync, returns the body

Next