Skip to content
tsbouncerpreview
recipe

Wiring it into HTTP

Express, Hono, Fastify — and why there is no middleware.

There is no middleware, on purpose

tsbouncer ships no framework adapter and no requirePermission() wrapper. That is a decision, not an omission: a library that ships middleware has chosen your call sites, and an app with a REST endpoint, a GraphQL resolver, and a queue consumer rarely wants the same wrapper three times.

What it does give you is one function you call wherever a decision is actually needed. Here is the whole integration.

Identity first

The library has no idea who is asking, and that is where your session lives.

TypeScript
import { type Request } from 'express';
import { type Authz } from '@tsbouncer/tsbouncer';

function callerOf(req: Request) {
  const userId = req.header('x-user-id');
  if (!userId) throw new HttpError(401, 'unauthenticated', 'sign in first');
  return { userId, ref: `user:${userId}` as const };
}

A real deployment reads a session cookie or verifies a token here and returns the same shape. Everything downstream takes the reference and stops caring.

Two shapes of check

can and an explicit branch, for a denial that is a normal outcome:

TypeScript
app.get('/api/documents/:id', async (req, res) => {
  const caller = callerOf(req);
  const allowed = await authz.can(caller.ref, 'document.read', `document:${req.params.id}`);

  if (!allowed) {
    res.status(403).json({ error: 'forbidden' });
    return;
  }
  res.json(await loadDocument(req.params.id));
});

assert and one error handler, for a route that must not serve this user at all:

TypeScript
app.post('/api/documents/:id/roles', async (req, res) => {
  const caller = callerOf(req);

  await authz.assert({
    subject: caller.ref,
    permission: 'document.write',
    resource: `document:${req.params.id}`,
  });

  await authz.grant({
    subject: `role:${req.body.role}#holder`,
    relation: 'editor',
    resource: `document:${req.params.id}`,
  });

  res.status(201).json({ ok: true });
});

The error handler is the part people get wrong

isAuthorizationError is true for a denied check and for a rejected write. Mapping both to 403 is how a typo in a request body reports itself to the user as “you are not allowed”, with nothing in the logs to say the server is at fault.

TypeScript
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
  if (err instanceof HttpError) return res.status(err.status).json({ error: err.code });
  if (isAuthorizationError(err)) {
    if (err.code === 'access_denied') return res.status(403).json({ error: err.code });
    if (err.code === 'invalid_tuple') return res.status(400).json({ error: err.code });
    // evaluation_limit and friends: the library is confused, so it said no. Fail
    // closed, but say so in the log — this is the one that means "we do not know".
    console.error('authorization failed closed:', err);
    return res.status(403).json({ error: err.code });
  }
  res.status(500).json({ error: 'internal_error' });
});

The other frameworks

The shape is identical; only the request and response objects change.

TypeScript
// Hono
app.get('/api/documents/:id', async (c) => {
  const subject = `user:${c.req.header('x-user-id')}`;
  if (!(await authz.can(subject, 'document.read', `document:${c.req.param('id')}`))) {
    return c.json({ error: 'forbidden' }, 403);
  }
  return c.json(await loadDocument(c.req.param('id')));
});
TypeScript
// Fastify
app.get('/api/documents/:id', async (request, reply) => {
  const subject = `user:${request.headers['x-user-id']}`;
  const allowed = await authz.can(subject, 'document.read', `document:${request.params.id}`);
  return allowed ? { document: await loadDocument(request.params.id) } : reply.code(403).send({ error: 'forbidden' });
});

The list endpoint

TypeScript
app.get('/api/documents', async (req, res) => {
  const caller = callerOf(req);
  const { resources, truncated } = await authz.listResources({
    subject: caller.ref,
    permission: 'document.read',
    context: { callerRegion: caller.region },
  });

  res.json({ documents: await loadAll(resources), truncated });
});

One query instead of a scan with a can() per row, inheritance included. Pass truncated through — a UI that renders a partial list as complete is the bug this flag exists to prevent.

“Why can’t they open it?”

The question every authorization system gets, and the one nobody designs for.

TypeScript
app.get('/api/documents/:id/why', async (req, res) => {
  const caller = callerOf(req);
  const result = await authz.explain({
    subject: String(req.query.subject),
    permission: 'document.read',
    resource: `document:${req.params.id}`,
  });

  res.json({ allowed: result.allowed, reads: result.reads, trace: result.tree });
});

Evaluate it with the asking caller’s context, as above — a trace computed without their own state explains a decision nobody made. See A simple RBAC API.

Running versions of this page

The two guides are this recipe in applications that start, listen on a real socket, and answer real requests:

  • A simple RBAC API — Hono and a JSON file. This recipe with nothing else in the way, which is the one to read first.
  • A real-world documents API — Express, Drizzle ORM and SQLite, with inheritance, exclusions and three conditions. This recipe once the requirements have outgrown it.

Neither is a snippet. Each is a program with a scenario table that is printed as a tour and asserted in CI, so the two cannot drift apart.

Next