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.
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:
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:
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.
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.
// 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')));
});// 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
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.
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
- A simple RBAC API — the same shape, running.
- Errors