Getting started
Install it, declare a model, and answer one question about one document.
Install
npm i @tsbouncer/tsbouncer @tsbouncer/in-memory # kernel plus the process-local backendtsbouncer is the kernel and two ports — nothing else. Backends are separate
plugin packages: @tsbouncer/in-memory and @tsbouncer/json-file need nothing
external, @tsbouncer/redis needs a server, @tsbouncer/kysely,
@tsbouncer/drizzle, and @tsbouncer/prisma wrap the client you already have,
and @tsbouncer/defaults picks one for you. Install exactly the backends you
use; importing the root never loads one you did not ask for.
ESM only. There is no CommonJS build, and require() will not work. Node 20.11 or
newer.
Declare a model
The model is code, validated at runtime when defineModel is called. A relation
that names a type which does not exist throws immediately, not on the first
request that happens to use it.
import { defineModel, defineType, permission, relation } from '@tsbouncer/tsbouncer';
const model = defineModel({
types: {
user: defineType({}),
team: defineType({ relations: { member: relation(['user']) } }),
document: defineType({
relations: {
owner: relation(['user']),
editor: relation('user').or(relation('team', { through: 'member' })),
},
permissions: {
read: permission.or('owner', 'editor'),
},
}),
},
});Connect a store
import { createAuthz } from '@tsbouncer/tsbouncer';
import { memoryStore } from '@tsbouncer/in-memory';
const authz = createAuthz({ model, store: memoryStore() });memoryStore is process-local and is the right choice for tests. For anything
else, see Stores.
Write and ask
await authz.write([
{ subject: 'user:alice', relation: 'owner', resource: 'document:1' },
{ subject: 'team:eng#member', relation: 'editor', resource: 'document:2' },
{ subject: 'user:alice', relation: 'member', resource: 'team:eng' },
]);
await authz.can('user:alice', 'document.read', 'document:1'); // true — owner
await authz.can('user:alice', 'document.read', 'document:2'); // true — team member
await authz.can('user:bob', 'document.read', 'document:2'); // false — not a memberThree calls, and the third one is the interesting one: alice’s access to
document:2 comes from a membership tuple and a userset edge, not from a tuple
that names her on the document.
Use it from a route
The library has no opinion about your framework, so this is the whole integration:
import { isAuthorizationError } from '@tsbouncer/tsbouncer';
app.get('/api/documents/:id', async (req, res) => {
const subject = `user:${req.header('x-user-id')}`;
try {
await authz.assert({
subject,
permission: 'document.read',
resource: `document:${req.params.id}`,
});
} catch (error) {
if (isAuthorizationError(error)) {
res.status(403).json({ error: 'forbidden' });
return;
}
throw error; // a store or model failure is NOT a denial
}
res.json(await loadDocument(req.params.id));
});Next
- The model — every shape the evaluator understands.
- Data — the reference format, and what write-time validation catches.
- HTTP integration — Express, Hono, Fastify, and where to put the check.